├── tests ├── __init__.py ├── schemas │ ├── bad │ │ ├── invalid.yaml │ │ ├── bad-id.yaml │ │ ├── reserved-property.yaml │ │ └── nested-reserved-property.yaml │ └── good │ │ ├── user.yaml │ │ ├── basic.yaml │ │ ├── basic.json │ │ ├── array.yaml │ │ └── nested-array.yaml ├── conftest.py ├── test_traits.py ├── utils.py ├── test_cli.py ├── test_modifiers.py ├── test_schema.py ├── test_listeners.py └── test_logger.py ├── jupyter_events ├── py.typed ├── utils.py ├── __init__.py ├── schemas │ ├── event-core-schema.yml │ ├── property-metaschema.yml │ └── event-metaschema.yml ├── _version.py ├── yaml.py ├── traits.py ├── pytest_plugin.py ├── schema_registry.py ├── validators.py ├── cli.py ├── schema.py └── logger.py ├── docs ├── _static │ └── jupyter_logo.png ├── user_guide │ ├── index.md │ ├── configure.md │ ├── listeners.md │ ├── modifiers.md │ ├── first-event.md │ ├── event-schemas.md │ ├── application.md │ └── defining-schema.md ├── demo │ ├── index.md │ └── demo-notebook.ipynb ├── Makefile ├── index.md ├── make.bat └── conf.py ├── .readthedocs.yaml ├── SECURITY.md ├── .github ├── workflows │ ├── enforce-label.yml │ ├── publish-changelog.yml │ ├── prep-release.yml │ ├── publish-release.yml │ └── python-tests.yml └── dependabot.yml ├── RELEASE.md ├── LICENSE ├── .gitignore ├── README.md ├── .pre-commit-config.yaml ├── pyproject.toml └── CHANGELOG.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /jupyter_events/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/schemas/bad/invalid.yaml: -------------------------------------------------------------------------------- 1 | 418 i'm a teapot 2 | -------------------------------------------------------------------------------- /docs/_static/jupyter_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyter/jupyter_events/HEAD/docs/_static/jupyter_logo.png -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | pytest_plugins = ["jupyter_events.pytest_plugin"] 4 | -------------------------------------------------------------------------------- /docs/user_guide/index.md: -------------------------------------------------------------------------------- 1 | # User Guide 2 | 3 | ```{toctree} 4 | --- 5 | maxdepth: 2 6 | --- 7 | first-event 8 | event-schemas 9 | defining-schema 10 | configure 11 | application 12 | modifiers 13 | listeners 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/demo/index.md: -------------------------------------------------------------------------------- 1 | # Try it out 2 | 3 | Try out the Jupyter Events Library in the example notebook below (powered by JupyterLite): 4 | 5 | ```{retrolite} demo-notebook.ipynb 6 | --- 7 | width: 100% 8 | height: 1200px 9 | --- 10 | ``` 11 | -------------------------------------------------------------------------------- /jupyter_events/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Various utilities 3 | """ 4 | from __future__ import annotations 5 | 6 | 7 | class JupyterEventsVersionWarning(UserWarning): 8 | """Emitted when an event schema version is an `int` when it should be `str`.""" 9 | -------------------------------------------------------------------------------- /jupyter_events/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from ._version import __version__ 3 | from .logger import EVENTS_METADATA_VERSION, EventLogger 4 | from .schema import EventSchema 5 | 6 | __all__ = ["__version__", "EVENTS_METADATA_VERSION", "EventLogger", "EventSchema"] 7 | -------------------------------------------------------------------------------- /tests/schemas/good/user.yaml: -------------------------------------------------------------------------------- 1 | $id: http://event.jupyter.org/user 2 | version: "1" 3 | title: User 4 | description: | 5 | A User model. 6 | type: object 7 | properties: 8 | username: 9 | title: Username 10 | description: Username. 11 | type: string 12 | -------------------------------------------------------------------------------- /tests/schemas/bad/bad-id.yaml: -------------------------------------------------------------------------------- 1 | $id: not-a-uri 2 | version: "1" 3 | title: Schema with a Bad URI ID 4 | description: | 5 | A schema with a bad id 6 | type: object 7 | properties: 8 | bad: 9 | title: Test Property 10 | description: Test property. 11 | type: string 12 | -------------------------------------------------------------------------------- /tests/schemas/good/basic.yaml: -------------------------------------------------------------------------------- 1 | $id: http://event.jupyter.org/test 2 | version: "1" 3 | title: Simple Test Schema 4 | description: | 5 | A simple schema for testing 6 | type: object 7 | properties: 8 | prop: 9 | title: Test Property 10 | description: Test property. 11 | type: string 12 | -------------------------------------------------------------------------------- /tests/schemas/bad/reserved-property.yaml: -------------------------------------------------------------------------------- 1 | $id: http://event.jupyter.org/test 2 | version: 1 3 | title: Simple Test Schema 4 | description: | 5 | A simple schema for testing 6 | type: object 7 | properties: 8 | __badName: 9 | title: Test Property 10 | description: Test property. 11 | type: string 12 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-22.04 4 | tools: 5 | python: "3.9" 6 | sphinx: 7 | configuration: docs/source/conf.py 8 | python: 9 | install: 10 | # install itself with pip install . 11 | - method: pip 12 | path: . 13 | extra_requirements: 14 | - docs 15 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | All IPython and Jupyter security are handled via security@ipython.org. 6 | You can find more information on the Jupyter website. https://jupyter.org/security 7 | 8 | ## Tidelift 9 | 10 | You can also report security concerns for jupyter-eveents via the [Tidelift platform](https://tidelift.com/security). 11 | -------------------------------------------------------------------------------- /tests/schemas/good/basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "http://event.jupyter.org/test", 3 | "version": "1", 4 | "title": "Simple Test Schema", 5 | "description": "A simple schema for testing", 6 | "type": "object", 7 | "properties": { 8 | "prop": { 9 | "title": "Test Property", 10 | "description": "Test property.", 11 | "type": "string" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/enforce-label.yml: -------------------------------------------------------------------------------- 1 | name: Enforce PR label 2 | 3 | on: 4 | pull_request: 5 | types: [labeled, unlabeled, opened, edited, synchronize] 6 | jobs: 7 | enforce-label: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | pull-requests: write 11 | steps: 12 | - name: enforce-triage-label 13 | uses: jupyterlab/maintainer-tools/.github/actions/enforce-label@v1 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | actions: 9 | patterns: 10 | - "*" 11 | - package-ecosystem: "pip" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" 15 | groups: 16 | actions: 17 | patterns: 18 | - "*" 19 | -------------------------------------------------------------------------------- /tests/schemas/good/array.yaml: -------------------------------------------------------------------------------- 1 | $id: http://event.jupyter.org/test 2 | version: "1" 3 | title: Schema with Array 4 | description: | 5 | A schema for an array of objects. 6 | type: object 7 | properties: 8 | users: 9 | title: Test User Array 10 | description: | 11 | Test User array. 12 | type: array 13 | items: 14 | type: object 15 | title: User 16 | properties: 17 | email: 18 | type: string 19 | title: Email 20 | id: 21 | type: string 22 | title: Name 23 | -------------------------------------------------------------------------------- /docs/user_guide/configure.md: -------------------------------------------------------------------------------- 1 | # Configure applications to emit events 2 | 3 | Jupyter applications can be configured to emit events by registering 4 | logging `Handler`s with an Application's `EventLogger` object. 5 | 6 | This is usually done using a Jupyter configuration file, e.g. `jupyter_config.py`: 7 | 8 | ```python 9 | from logging import FileHandler 10 | 11 | # Log events to a local file on disk. 12 | handler = FileHandler("events.txt") 13 | 14 | # Explicitly list the types of events 15 | # to record and what properties or what categories 16 | # of data to begin collecting. 17 | c.EventLogger.handlers = [handler] 18 | ``` 19 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /jupyter_events/schemas/event-core-schema.yml: -------------------------------------------------------------------------------- 1 | $schema: http://json-schema.org/draft-07/schema 2 | $id: http://event.jupyter.org/event-schema 3 | version: "1" 4 | title: Event Schema 5 | description: | 6 | A schema for validating any Jupyter Event. 7 | type: object 8 | properties: 9 | __metadata_version__: 10 | title: Metadata Version 11 | type: number 12 | const: 1 13 | __schema_version__: 14 | title: Schema Version 15 | type: string 16 | __schema__: 17 | title: Schema ID 18 | type: string 19 | __timestamp__: 20 | title: Event Timestamp 21 | type: string 22 | format: datetime 23 | required: 24 | - __metadata_version__ 25 | - __schema__ 26 | - __schema_version__ 27 | - __timestamp__ 28 | -------------------------------------------------------------------------------- /jupyter_events/_version.py: -------------------------------------------------------------------------------- 1 | """ 2 | store the current version info of jupyter-events. 3 | """ 4 | from __future__ import annotations 5 | 6 | import re 7 | 8 | # Version string must appear intact for hatch versioning 9 | __version__ = "0.12.0" 10 | 11 | # Build up version_info tuple for backwards compatibility 12 | pattern = r"(?P\d+).(?P\d+).(?P\d+)(?P.*)" 13 | match = re.match(pattern, __version__) 14 | assert match is not None 15 | parts: list[object] = [int(match[part]) for part in ["major", "minor", "patch"]] 16 | if match["rest"]: 17 | parts.append(match["rest"]) 18 | version_info = tuple(parts) 19 | 20 | kernel_protocol_version_info = (5, 3) 21 | kernel_protocol_version = "{}.{}".format(*kernel_protocol_version_info) 22 | -------------------------------------------------------------------------------- /jupyter_events/schemas/property-metaschema.yml: -------------------------------------------------------------------------------- 1 | $schema: http://json-schema.org/draft-07/schema 2 | $id: http://event.jupyter.org/property-metaschema 3 | version: "1" 4 | title: Property Metaschema 5 | description: | 6 | A metaschema for validating properties within 7 | an event schema 8 | 9 | properties: 10 | title: 11 | type: string 12 | description: 13 | type: string 14 | properties: 15 | type: object 16 | additionalProperties: 17 | $ref: http://event.jupyter.org/property-metaschema 18 | propertyNames: 19 | pattern: ^(?!__.*) 20 | 21 | items: 22 | $ref: http://event.jupyter.org/property-metaschema 23 | 24 | additionalProperties: 25 | $ref: http://event.jupyter.org/property-metaschema 26 | 27 | propertyNames: 28 | pattern: ^(?!__.*) 29 | -------------------------------------------------------------------------------- /jupyter_events/schemas/event-metaschema.yml: -------------------------------------------------------------------------------- 1 | $schema: http://json-schema.org/draft-07/schema 2 | $id: http://event.jupyter.org/event-metaschema 3 | version: "1" 4 | title: Event Metaschema 5 | description: | 6 | A meta schema for validating that all registered Jupyter Event 7 | schemas are appropriately defined. 8 | type: object 9 | properties: 10 | version: 11 | type: string 12 | title: 13 | type: string 14 | description: 15 | type: string 16 | properties: 17 | type: object 18 | additionalProperties: 19 | $ref: http://event.jupyter.org/property-metaschema 20 | propertyNames: 21 | pattern: ^(?!__.*) 22 | patternProperties: 23 | "\\$id": 24 | type: string 25 | format: uri 26 | 27 | required: 28 | - $id 29 | - version 30 | - properties 31 | -------------------------------------------------------------------------------- /tests/test_traits.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | import pytest 6 | from traitlets import HasTraits, TraitError 7 | 8 | from jupyter_events.traits import Handlers 9 | 10 | 11 | class HasHandlers(HasTraits): 12 | handlers = Handlers(None, allow_none=True) 13 | 14 | 15 | def test_good_handlers_value(): 16 | handlers = [logging.NullHandler(), logging.NullHandler()] 17 | obj = HasHandlers(handlers=handlers) 18 | assert obj.handlers == handlers 19 | 20 | 21 | def test_bad_handlers_values(): 22 | handlers = [0, 1] 23 | 24 | with pytest.raises(TraitError): 25 | HasHandlers(handlers=handlers) 26 | 27 | 28 | def test_mixed_handlers_values(): 29 | handlers = [logging.NullHandler(), 1] 30 | with pytest.raises(TraitError): 31 | HasHandlers(handlers=handlers) 32 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Jupyter Events 2 | 3 | _An event system for Jupyter Applications and extensions._ 4 | 5 | Jupyter Events enables Jupyter Python Applications (e.g. Jupyter Server, JupyterLab Server, JupyterHub, etc.) to emit **events**—structured data describing things happening inside the application. Other software (e.g. client applications like JupyterLab) can _listen_ and respond to these events. 6 | 7 | ## Install 8 | 9 | Install Jupyter Events directly from PyPI: 10 | 11 | ``` 12 | pip install jupyter_events 13 | ``` 14 | 15 | or conda-forge: 16 | 17 | ``` 18 | conda install -c conda-forge jupyter_events 19 | ``` 20 | 21 | ## Contents 22 | 23 | ```{toctree} 24 | --- 25 | maxdepth: 2 26 | --- 27 | user_guide/index 28 | demo/index 29 | ``` 30 | 31 | ## Indices and tables 32 | 33 | - {ref}`genindex` 34 | - {ref}`modindex` 35 | - {ref}`search` 36 | -------------------------------------------------------------------------------- /tests/schemas/good/nested-array.yaml: -------------------------------------------------------------------------------- 1 | $id: http://event.jupyter.org/test 2 | version: "1" 3 | title: Schema with Array 4 | description: | 5 | A schema for an array of objects. 6 | type: object 7 | properties: 8 | users: 9 | title: Test User Array 10 | description: | 11 | Test User array. 12 | type: array 13 | items: 14 | type: object 15 | title: User 16 | properties: 17 | name: 18 | type: string 19 | title: Name 20 | hobbies: 21 | type: array 22 | title: Hobbies 23 | items: 24 | type: object 25 | title: Hobby 26 | properties: 27 | sport: 28 | title: Sport Name 29 | type: string 30 | position: 31 | title: Position 32 | type: string 33 | -------------------------------------------------------------------------------- /docs/user_guide/listeners.md: -------------------------------------------------------------------------------- 1 | # Adding event listeners 2 | 3 | Event listeners are asynchronous callback functions/methods that are triggered when an event is emitted. 4 | 5 | Listeners can be used by extension authors to trigger custom logic every time an event occurs. 6 | 7 | ## Basic usage 8 | 9 | Define a listener (async) function: 10 | 11 | ```python 12 | from jupyter_events.logger import EventLogger 13 | 14 | 15 | async def my_listener(logger: EventLogger, schema_id: str, data: dict) -> None: 16 | print("hello, from my listener") 17 | ``` 18 | 19 | Hook this listener to a specific event type: 20 | 21 | ```python 22 | event_logger.add_listener( 23 | schema_id="http://event.jupyter.org/my-event", listener=my_listener 24 | ) 25 | ``` 26 | 27 | Now, every time a `"http://event.jupyter.org/test"` event is emitted from the EventLogger, this listener will be called. 28 | -------------------------------------------------------------------------------- /tests/schemas/bad/nested-reserved-property.yaml: -------------------------------------------------------------------------------- 1 | $id: http://event.jupyter.org/test 2 | version: "1" 3 | title: Schema with Array 4 | description: | 5 | A schema for an array of objects. 6 | type: object 7 | properties: 8 | users: 9 | title: Test User Array 10 | description: | 11 | Test User array. 12 | type: array 13 | items: 14 | type: object 15 | title: User 16 | properties: 17 | name: 18 | type: string 19 | title: Name 20 | hobbies: 21 | type: array 22 | title: Hobbies 23 | items: 24 | type: object 25 | title: Hobby 26 | properties: 27 | __badName: 28 | title: Sport Name 29 | type: string 30 | position: 31 | title: Position 32 | type: string 33 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import io 4 | import json 5 | import logging 6 | import pathlib 7 | from copy import deepcopy 8 | 9 | from jupyter_events.logger import EventLogger 10 | 11 | SCHEMA_PATH = pathlib.Path(__file__).parent / "schemas" 12 | 13 | 14 | def get_event_data(event, schema, schema_id, version, unredacted_policies): 15 | sink = io.StringIO() 16 | 17 | # Create a handler that captures+records events with allowed tags. 18 | handler = logging.StreamHandler(sink) 19 | 20 | e = EventLogger(handlers=[handler], unredacted_policies=unredacted_policies) 21 | e.register_event_schema(schema) 22 | 23 | # Record event and read output 24 | e.emit(schema_id=schema_id, data=deepcopy(event)) 25 | 26 | recorded_event = json.loads(sink.getvalue()) 27 | return {key: value for key, value in recorded_event.items() if not key.startswith("__")} 28 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Making a Jupyter Events Release 2 | 3 | ## Using `jupyter_releaser` 4 | 5 | The recommended way to make a release is to use [`jupyter_releaser`](https://jupyter-releaser.readthedocs.io/en/latest/get_started/making_release_from_repo.html). 6 | 7 | Note that we must use manual versions since Jupyter Releaser does not 8 | yet support "next" or "patch" when dev versions are used. 9 | 10 | ## Manual Release 11 | 12 | To create a manual release, perform the following steps: 13 | 14 | ### Set up 15 | 16 | ```bash 17 | pip install hatch twine 18 | git pull origin $(git branch --show-current) 19 | git clean -dffx 20 | ``` 21 | 22 | ### Update the version and apply the tag 23 | 24 | ```bash 25 | echo "Enter new version" 26 | read new_version 27 | hatch version ${new_version} 28 | git tag -a ${new_version} -m "Release ${new_version}" 29 | ``` 30 | 31 | ### Build the artifacts 32 | 33 | ```bash 34 | rm -rf dist 35 | hatch build 36 | ``` 37 | 38 | ### Publish the artifacts to pypi 39 | 40 | ```bash 41 | twine check dist/* 42 | twine upload dist/* 43 | ``` 44 | -------------------------------------------------------------------------------- /.github/workflows/publish-changelog.yml: -------------------------------------------------------------------------------- 1 | name: "Publish Changelog" 2 | on: 3 | release: 4 | types: [published] 5 | 6 | workflow_dispatch: 7 | inputs: 8 | branch: 9 | description: "The branch to target" 10 | required: false 11 | 12 | jobs: 13 | publish_changelog: 14 | runs-on: ubuntu-latest 15 | environment: release 16 | steps: 17 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 18 | 19 | - uses: actions/create-github-app-token@v1 20 | id: app-token 21 | with: 22 | app-id: ${{ vars.APP_ID }} 23 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 24 | 25 | - name: Publish changelog 26 | id: publish-changelog 27 | uses: jupyter-server/jupyter_releaser/.github/actions/publish-changelog@v2 28 | with: 29 | token: ${{ steps.app-token.outputs.token }} 30 | branch: ${{ github.event.inputs.branch }} 31 | 32 | - name: "** Next Step **" 33 | run: | 34 | echo "Merge the changelog update PR: ${{ steps.publish-changelog.outputs.pr_url }}" 35 | -------------------------------------------------------------------------------- /docs/user_guide/modifiers.md: -------------------------------------------------------------------------------- 1 | # Modifying events in an application using `jupyter_events` 2 | 3 | If you're deploying a configurable application that uses Jupyter Events to emit events, you can extend the application's event logger to modify/mutate/redact incoming events before they are emitted. This is particularly useful if you need to mask, salt, or remove sensitive data from incoming event. 4 | 5 | To modify events, define a callable (function or method) that modifies the event data dictionary. This callable **must** follow an exact signature (type annotations required): 6 | 7 | ```python 8 | def my_modifier(schema_id: str, data: dict) -> dict: 9 | ... 10 | ``` 11 | 12 | The return value is the mutated event data (dict). This data will be validated and emitted _after_ it is modified, so it still must follow the event's schema. 13 | 14 | Next, add this modifier to the event logger using the `.add_modifier` method: 15 | 16 | ```python 17 | logger = EventLogger() 18 | logger.add_modifier(my_modifier) 19 | ``` 20 | 21 | This method enforces the signature above and will raise a `ModifierError` if the signature does not match. 22 | -------------------------------------------------------------------------------- /jupyter_events/yaml.py: -------------------------------------------------------------------------------- 1 | """Yaml utilities.""" 2 | from __future__ import annotations 3 | 4 | from pathlib import Path, PurePath 5 | from typing import Any 6 | 7 | from yaml import dump as ydump 8 | from yaml import load as yload 9 | 10 | try: 11 | from yaml import CSafeDumper as SafeDumper 12 | from yaml import CSafeLoader as SafeLoader 13 | except ImportError: # pragma: no cover 14 | from yaml import SafeDumper, SafeLoader # type:ignore[assignment] 15 | 16 | 17 | def loads(stream: Any) -> Any: 18 | """Load yaml from a stream.""" 19 | return yload(stream, Loader=SafeLoader) 20 | 21 | 22 | def dumps(stream: Any) -> str: 23 | """Parse the first YAML document in a stream as an object.""" 24 | return ydump(stream, Dumper=SafeDumper) 25 | 26 | 27 | def load(fpath: str | PurePath) -> Any: 28 | """Load yaml from a file.""" 29 | # coerce PurePath into Path, then read its contents 30 | data = Path(str(fpath)).read_text(encoding="utf-8") 31 | return loads(data) 32 | 33 | 34 | def dump(data: Any, outpath: str | PurePath) -> None: 35 | """Parse the a YAML document in a file as an object.""" 36 | Path(outpath).write_text(dumps(data), encoding="utf-8") 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022-, Jupyter Development Team 4 | 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are met: 9 | 10 | 1. Redistributions of source code must retain the above copyright notice, this 11 | list of conditions and the following disclaimer. 12 | 13 | 2. Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | 17 | 3. Neither the name of the copyright holder nor the names of its 18 | contributors may be used to endorse or promote products derived from 19 | this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 22 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 23 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 25 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 26 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 27 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 28 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 29 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | .DS_Store 107 | .vscode/ 108 | 109 | # pycharm 110 | .idea/ 111 | -------------------------------------------------------------------------------- /.github/workflows/prep-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 1: Prep Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version_spec: 6 | description: "New Version Specifier" 7 | default: "next" 8 | required: false 9 | branch: 10 | description: "The branch to target" 11 | required: false 12 | post_version_spec: 13 | description: "Post Version Specifier" 14 | required: false 15 | silent: 16 | description: "Set a placeholder in the changelog and don't publish the release." 17 | required: false 18 | type: boolean 19 | since: 20 | description: "Use PRs with activity since this date or git reference" 21 | required: false 22 | since_last_stable: 23 | description: "Use PRs with activity since the last stable git tag" 24 | required: false 25 | type: boolean 26 | jobs: 27 | prep_release: 28 | runs-on: ubuntu-latest 29 | permissions: 30 | contents: write 31 | steps: 32 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 33 | 34 | - name: Prep Release 35 | id: prep-release 36 | uses: jupyter-server/jupyter_releaser/.github/actions/prep-release@v2 37 | with: 38 | token: ${{ secrets.GITHUB_TOKEN }} 39 | version_spec: ${{ github.event.inputs.version_spec }} 40 | silent: ${{ github.event.inputs.silent }} 41 | post_version_spec: ${{ github.event.inputs.post_version_spec }} 42 | target: ${{ github.event.inputs.target }} 43 | branch: ${{ github.event.inputs.branch }} 44 | since: ${{ github.event.inputs.since }} 45 | since_last_stable: ${{ github.event.inputs.since_last_stable }} 46 | 47 | - name: "** Next Step **" 48 | run: | 49 | echo "Optional): Review Draft Release: ${{ steps.prep-release.outputs.release_url }}" 50 | -------------------------------------------------------------------------------- /docs/user_guide/first-event.md: -------------------------------------------------------------------------------- 1 | (first-event)= 2 | 3 | # Logging your first event! 4 | 5 | The `EventLogger` is the main object in Jupyter Events. 6 | 7 | ```python 8 | from jupyter_events.logger import EventLogger 9 | 10 | logger = EventLogger() 11 | ``` 12 | 13 | To begin emitting events from a Python application, you need to tell the `EventLogger` what events you'd like to emit. To do this, we should register our event's schema (more on this later) with the logger. 14 | 15 | ```python 16 | schema = """ 17 | $id: http://myapplication.org/example-event 18 | version: "1" 19 | title: Example Event 20 | description: An interesting event to collect 21 | properties: 22 | name: 23 | title: Name of Event 24 | type: string 25 | """ 26 | 27 | 28 | logger.register_event_schema(schema) 29 | ``` 30 | 31 | Now that the logger knows about the event, it needs to know _where_ to send it. To do this, we register a logging _Handler_ —borrowed from Python's standard [`logging`](https://docs.python.org/3/library/logging.html) library—to route the events to the proper place. 32 | 33 | ```python 34 | # We will import one of the handlers from Python's logging library 35 | from logging import StreamHandler 36 | 37 | handler = StreamHandler() 38 | 39 | logger.register_handler(handler) 40 | ``` 41 | 42 | The logger knows about the event and where to send it; all that's left is to emit an instance of the event! To do this, call the `.emit(...)` method and set the (required) `schema_id` and `data` arguments. 43 | 44 | ```python 45 | from jupyter_events import Event 46 | 47 | logger.emit( 48 | schema_id="http://myapplication.org/example-event", data={"name": "My Event"} 49 | ) 50 | ``` 51 | 52 | On emission, the following data will get printed to your console by the `StreamHandler` instance: 53 | 54 | ``` 55 | {'__timestamp__': '2022-08-09T17:15:27.458048Z', 56 | '__schema__': 'myapplication.org/example-event', 57 | '__schema_version__': 1, 58 | '__metadata_version__': 1, 59 | 'name': 'My Event'} 60 | ``` 61 | -------------------------------------------------------------------------------- /jupyter_events/traits.py: -------------------------------------------------------------------------------- 1 | """Trait types for events.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | import typing as t 6 | 7 | from traitlets import TraitError, TraitType 8 | 9 | baseclass = TraitType 10 | if t.TYPE_CHECKING: 11 | baseclass = TraitType[t.Any, t.Any] # type:ignore[misc] 12 | 13 | 14 | class Handlers(baseclass): # type:ignore[type-arg] 15 | """A trait that takes a list of logging handlers and converts 16 | it to a callable that returns that list (thus, making this 17 | trait pickleable). 18 | """ 19 | 20 | info_text = "a list of logging handlers" 21 | 22 | def validate_elements(self, obj: t.Any, value: t.Any) -> None: 23 | """Validate the elements of an object.""" 24 | if len(value) > 0: 25 | # Check that all elements are logging handlers. 26 | for el in value: 27 | if isinstance(el, logging.Handler) is False: 28 | self.element_error(obj) 29 | 30 | def element_error(self, obj: t.Any) -> None: 31 | """Raise an error for bad elements.""" 32 | msg = f"Elements in the '{self.name}' trait of an {obj.__class__.__name__} instance must be Python `logging` handler instances." 33 | raise TraitError(msg) 34 | 35 | def validate(self, obj: t.Any, value: t.Any) -> t.Any: 36 | """Validate an object.""" 37 | # If given a callable, call it and set the 38 | # value of this trait to the returned list. 39 | # Verify that the callable returns a list 40 | # of logging handler instances. 41 | if callable(value): 42 | out = value() 43 | self.validate_elements(obj, out) 44 | return out 45 | # If a list, check it's elements to verify 46 | # that each element is a logging handler instance. 47 | if isinstance(value, list): 48 | self.validate_elements(obj, value) 49 | return value 50 | self.error(obj, value) 51 | return None # type:ignore[unreachable] 52 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 2: Publish Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | branch: 6 | description: "The target branch" 7 | required: false 8 | release_url: 9 | description: "The URL of the draft GitHub release" 10 | required: false 11 | steps_to_skip: 12 | description: "Comma separated list of steps to skip" 13 | required: false 14 | 15 | jobs: 16 | publish_release: 17 | runs-on: ubuntu-latest 18 | environment: release 19 | permissions: 20 | id-token: write 21 | steps: 22 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 23 | 24 | - uses: actions/create-github-app-token@v1 25 | id: app-token 26 | with: 27 | app-id: ${{ vars.APP_ID }} 28 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 29 | 30 | - name: Populate Release 31 | id: populate-release 32 | uses: jupyter-server/jupyter_releaser/.github/actions/populate-release@v2 33 | with: 34 | token: ${{ steps.app-token.outputs.token }} 35 | branch: ${{ github.event.inputs.branch }} 36 | release_url: ${{ github.event.inputs.release_url }} 37 | steps_to_skip: ${{ github.event.inputs.steps_to_skip }} 38 | 39 | - name: Finalize Release 40 | id: finalize-release 41 | uses: jupyter-server/jupyter_releaser/.github/actions/finalize-release@v2 42 | with: 43 | token: ${{ steps.app-token.outputs.token }} 44 | release_url: ${{ steps.populate-release.outputs.release_url }} 45 | 46 | - name: "** Next Step **" 47 | if: ${{ success() }} 48 | run: | 49 | echo "Verify the final release" 50 | echo ${{ steps.finalize-release.outputs.release_url }} 51 | 52 | - name: "** Failure Message **" 53 | if: ${{ failure() }} 54 | run: | 55 | echo "Failed to Publish the Draft Release Url:" 56 | echo ${{ steps.populate-release.outputs.release_url }} 57 | -------------------------------------------------------------------------------- /jupyter_events/pytest_plugin.py: -------------------------------------------------------------------------------- 1 | """Fixtures for use with jupyter events.""" 2 | from __future__ import annotations 3 | 4 | import io 5 | import json 6 | import logging 7 | from typing import Any, Callable 8 | 9 | import pytest 10 | 11 | from jupyter_events import EventLogger 12 | 13 | 14 | @pytest.fixture 15 | def jp_event_sink() -> io.StringIO: 16 | """A stream for capture events.""" 17 | return io.StringIO() 18 | 19 | 20 | @pytest.fixture 21 | def jp_event_handler(jp_event_sink: io.StringIO) -> logging.Handler: 22 | """A logging handler that captures any events emitted by the event handler""" 23 | return logging.StreamHandler(jp_event_sink) 24 | 25 | 26 | @pytest.fixture 27 | def jp_read_emitted_events( 28 | jp_event_handler: logging.Handler, jp_event_sink: io.StringIO 29 | ) -> Callable[..., list[str] | None]: 30 | """Reads list of events since last time it was called.""" 31 | 32 | def _read() -> list[str] | None: 33 | jp_event_handler.flush() 34 | event_buf = jp_event_sink.getvalue().strip() 35 | output = [json.loads(item) for item in event_buf.split("\n")] if event_buf else None 36 | # Clear the sink. 37 | jp_event_sink.truncate(0) 38 | jp_event_sink.seek(0) 39 | return output 40 | 41 | return _read 42 | 43 | 44 | @pytest.fixture 45 | def jp_event_schemas() -> list[Any]: 46 | """A list of schema references. 47 | 48 | Each item should be one of the following: 49 | - string of serialized JSON/YAML content representing a schema 50 | - a pathlib.Path object pointing to a schema file on disk 51 | - a dictionary with the schema data. 52 | """ 53 | return [] 54 | 55 | 56 | @pytest.fixture 57 | def jp_event_logger(jp_event_handler: logging.Handler, jp_event_schemas: list[Any]) -> EventLogger: 58 | """A pre-configured event logger for tests.""" 59 | logger = EventLogger() 60 | for schema in jp_event_schemas: 61 | logger.register_event_schema(schema) 62 | logger.register_handler(handler=jp_event_handler) 63 | return logger 64 | -------------------------------------------------------------------------------- /docs/user_guide/event-schemas.md: -------------------------------------------------------------------------------- 1 | # What is an event schema? 2 | 3 | A Jupyter event schema defines the _shape_ and _type_ of an emitted event instance. This is a key piece of Jupyter Events. It tells the event listeners what they should expect when an event occurs. 4 | 5 | In the {ref}`first-event`, you saw how to register a schema with the `EventLogger`. 6 | 7 | In the next section, {ref}`defining-schema`, you will learn how to define a new schema. 8 | 9 | _So what exactly happens when we register a schema?_ 10 | 11 | ```python 12 | from jupyter_events.logger import EventLogger 13 | 14 | schema = """ 15 | $id: http://myapplication.org/example-event 16 | version: "1" 17 | title: Example Event 18 | description: An interesting event to collect 19 | properties: 20 | name: 21 | title: Name of Event 22 | type: string 23 | """ 24 | 25 | logger = EventLogger() 26 | logger.register_event_schema(schema) 27 | ``` 28 | 29 | First, the schema is validated against [Jupyter Event's metaschema](https://github.com/jupyter/jupyter_events/tree/main/jupyter_events/schemas/event-metaschema.yml). This ensures that your schema adheres minimally to Jupyter Event's expected form (read about how to define a schema [here](../user_guide/defining-schema.md)). 30 | 31 | Second, a `jsonschema.Validator` is created and cached for each one of your event schemas in a "schema registry" object. 32 | 33 | ```python 34 | print(logger.schemas) 35 | ``` 36 | 37 | ``` 38 | Validator class: Draft7Validator 39 | Schema: { 40 | "$id": "myapplication.org/example-event", 41 | "version": "1", 42 | "title": "Example Event", 43 | "description": "An interesting event to collect", 44 | "properties": { 45 | "name": { 46 | "title": "Name of Event", 47 | "type": "string" 48 | } 49 | } 50 | } 51 | ``` 52 | 53 | The registry's validators will be used to check incoming events to ensure all outgoing, emitted events are registered and follow the expected form. 54 | 55 | Lastly, if an incoming event is not found in the registry, it does not get emitted. This ensures that we only collect data that we explicitly register with the logger. 56 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | import pytest 6 | 7 | import jupyter_events 8 | from jupyter_events.cli import RC 9 | 10 | from .utils import SCHEMA_PATH 11 | 12 | NAME = "jupyter-events" 13 | VALIDATE = NAME, "validate" 14 | 15 | 16 | @pytest.fixture 17 | def cli(script_runner): 18 | def run_cli(*args, **kwargs): 19 | env = dict(os.environ) 20 | env.update(kwargs.pop("env", {})) 21 | env["PYTHONIOENCODING"] = "utf-8" 22 | kwargs["env"] = env 23 | return script_runner.run([NAME, *list(map(str, args))], **kwargs) 24 | 25 | return run_cli 26 | 27 | 28 | def test_cli_version(cli): 29 | ret = cli("--version") 30 | assert ret.success 31 | assert ret.stdout.strip() == f"{NAME}, version {jupyter_events.__version__}" 32 | 33 | 34 | def test_cli_help(cli): 35 | ret = cli("--help") 36 | assert ret.success 37 | assert f"Usage: {NAME}" in ret.stdout.strip() 38 | 39 | 40 | def test_cli_good(cli): 41 | """jupyter events validate path/to/my_schema.json""" 42 | ret = cli("validate", SCHEMA_PATH / "good/array.yaml") 43 | assert ret.success 44 | assert not ret.stderr.strip() 45 | assert "This schema is valid" in ret.stdout 46 | 47 | 48 | def test_cli_good_raw(cli): 49 | """jupyter events validate path/to/my_schema.json""" 50 | ret = cli("validate", (SCHEMA_PATH / "good/array.yaml").read_text(encoding="utf-8")) 51 | assert ret.success 52 | assert not ret.stderr.strip() 53 | assert "This schema is valid" in ret.stdout 54 | 55 | 56 | def test_cli_missing(cli): 57 | ret = cli("validate", SCHEMA_PATH / "bad/doesnt-exist.yaml") 58 | assert not ret.success 59 | assert ret.returncode == RC.UNPARSABLE 60 | assert "Schema file not present" in ret.stderr.strip() 61 | 62 | 63 | def test_cli_malformed(cli): 64 | ret = cli("validate", SCHEMA_PATH / "bad/invalid.yaml") 65 | assert not ret.success 66 | assert ret.returncode == RC.UNPARSABLE 67 | assert "Could not deserialize" in ret.stderr.strip() 68 | 69 | 70 | def test_cli_invalid(cli): 71 | ret = cli("validate", SCHEMA_PATH / "bad/reserved-property.yaml") 72 | assert not ret.success 73 | assert ret.returncode == RC.INVALID 74 | assert "The schema failed to validate" in ret.stderr.strip() 75 | -------------------------------------------------------------------------------- /docs/user_guide/application.md: -------------------------------------------------------------------------------- 1 | # Adding `EventLogger` to a Jupyter application 2 | 3 | To begin using Jupyter Events in your Python application, create an instance of the `EventLogger` object in your application. 4 | 5 | ```python 6 | from jupyter_core.application import JupyterApp 7 | from jupyter_events import EventLogger 8 | from jupyter_events import Event 9 | 10 | 11 | class MyApplication(JupyterApp): 12 | classes = [EventLogger, ...] 13 | eventlogger = Instance(EventLogger) 14 | 15 | def initialize(self, *args, **kwargs): 16 | self.eventlogger = EventLogger(parent=self) 17 | ... 18 | ``` 19 | 20 | Register an event schema with the logger. 21 | 22 | ```python 23 | schema = """ 24 | $id: http://myapplication.org/my-method 25 | version: "1" 26 | title: My Method Executed 27 | description: My method was executed one time. 28 | properties: 29 | msg: 30 | title: Message 31 | type: string 32 | """ 33 | 34 | self.eventlogger.register_event_schema(schema=schema) 35 | ``` 36 | 37 | Call `.emit(...)` within the application to emit an instance of the event. 38 | 39 | ```python 40 | def my_method(self): 41 | # Do something 42 | ... 43 | # Emit event telling listeners that this event happened. 44 | self.eventlogger.emit( 45 | schema_id="myapplication.org/my-method", data={"msg": "Hello, world!"} 46 | ) 47 | # Do something else... 48 | ... 49 | ``` 50 | 51 | Great! Now your application is logging events from within. Deployers of your application can configure the system to listen to this event using Jupyter's configuration system. This usually means reading a `jupyter_config.py` file like this: 52 | 53 | ```python 54 | # A Jupyter 55 | from logging import StreamHandler 56 | 57 | handler = StreamHandler() 58 | c.EventLogger.handlers = [handler] 59 | ``` 60 | 61 | Now when we run our application and call the method, the event will be emitted to the console: 62 | 63 | ``` 64 | app = MyApplication.launch_instance(config_file="jupyter_config.py") 65 | app.my_method() 66 | ``` 67 | 68 | ``` 69 | {'__timestamp__': '2022-08-09T17:15:27.458048Z', 70 | '__schema__': 'myapplication.org/my-method', 71 | '__schema_version__': 1, 72 | '__metadata_version__': 1, 73 | 'msg': 'Hello, world!'} 74 | ``` 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jupyter Events 2 | 3 | [![Build Status](https://github.com/jupyter/jupyter_events/actions/workflows/python-tests.yml/badge.svg?query=branch%3Amain++)](https://github.com/jupyter/jupyter_events/actions/workflows/python-tests.yml/badge.svg?query=branch%3Amain++) 4 | [![Documentation Status](https://readthedocs.org/projects/jupyter-events/badge/?version=latest)](http://jupyter-events.readthedocs.io/en/latest/?badge=latest) 5 | 6 | _An event system for Jupyter Applications and extensions._ 7 | 8 | Jupyter Events enables Jupyter Python Applications (e.g. Jupyter Server, JupyterLab Server, JupyterHub, etc.) to emit **events**—structured data describing things happening inside the application. Other software (e.g. client applications like JupyterLab) can _listen_ and respond to these events. 9 | 10 | ## Install 11 | 12 | Install Jupyter Events directly from PyPI: 13 | 14 | ``` 15 | pip install jupyter_events 16 | ``` 17 | 18 | or conda-forge: 19 | 20 | ``` 21 | conda install -c conda-forge jupyter_events 22 | ``` 23 | 24 | ## Documentation 25 | 26 | Documentation is available at [jupyter-events.readthedocs.io](https://jupyter-events.readthedocs.io). 27 | 28 | ## About the Jupyter Development Team 29 | 30 | The Jupyter Development Team is the set of all contributors to the Jupyter project. 31 | This includes all of the Jupyter subprojects. 32 | 33 | The core team that coordinates development on GitHub can be found here: 34 | https://github.com/jupyter/. 35 | 36 | ## Our Copyright Policy 37 | 38 | Jupyter uses a shared copyright model. Each contributor maintains copyright 39 | over their contributions to Jupyter. But, it is important to note that these 40 | contributions are typically only changes to the repositories. Thus, the Jupyter 41 | source code, in its entirety is not the copyright of any single person or 42 | institution. Instead, it is the collective copyright of the entire Jupyter 43 | Development Team. If individual contributors want to maintain a record of what 44 | changes/contributions they have specific copyright on, they should indicate 45 | their copyright in the commit message of the change, when they commit the 46 | change to one of the Jupyter repositories. 47 | 48 | With this in mind, the following banner should be used in any source code file 49 | to indicate the copyright and license terms: 50 | 51 | ``` 52 | # Copyright (c) Jupyter Development Team. 53 | # Distributed under the terms of the Modified BSD License. 54 | ``` 55 | -------------------------------------------------------------------------------- /tests/test_modifiers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from jupyter_events.schema import EventSchema 6 | 7 | from .utils import SCHEMA_PATH 8 | 9 | 10 | @pytest.fixture 11 | def schema(): 12 | # Read schema from path. 13 | schema_path = SCHEMA_PATH / "good" / "user.yaml" 14 | return EventSchema(schema=schema_path) 15 | 16 | 17 | @pytest.fixture 18 | def jp_event_schemas(schema): 19 | return [schema] 20 | 21 | 22 | def test_modifier_function(schema, jp_event_logger, jp_read_emitted_events): 23 | event_logger = jp_event_logger 24 | 25 | def redactor(schema_id: str, data: dict) -> dict: 26 | if "username" in data: 27 | data["username"] = "" 28 | return data 29 | 30 | # Add the modifier 31 | event_logger.add_modifier(modifier=redactor) 32 | event_logger.emit(schema_id=schema.id, data={"username": "jovyan"}) 33 | output = jp_read_emitted_events()[0] 34 | assert "username" in output 35 | assert output["username"] == "" 36 | 37 | 38 | def test_modifier_method(schema, jp_event_logger, jp_read_emitted_events): 39 | event_logger = jp_event_logger 40 | 41 | class Redactor: 42 | def redact(self, schema_id: str, data: dict) -> dict: 43 | if "username" in data: 44 | data["username"] = "" 45 | return data 46 | 47 | redactor = Redactor() 48 | 49 | # Add the modifier 50 | event_logger.add_modifier(modifier=redactor.redact) 51 | 52 | event_logger.emit(schema_id=schema.id, data={"username": "jovyan"}) 53 | output = jp_read_emitted_events()[0] 54 | assert "username" in output 55 | assert output["username"] == "" 56 | 57 | 58 | def test_remove_modifier(schema, jp_event_logger, jp_read_emitted_events): 59 | event_logger = jp_event_logger 60 | 61 | def redactor(schema_id: str, data: dict) -> dict: 62 | if "username" in data: 63 | data["username"] = "" 64 | return data 65 | 66 | # Add the modifier 67 | event_logger.add_modifier(modifier=redactor) 68 | 69 | assert len(event_logger._modifiers) == 1 70 | 71 | event_logger.emit(schema_id=schema.id, data={"username": "jovyan"}) 72 | output = jp_read_emitted_events()[0] 73 | 74 | assert "username" in output 75 | assert output["username"] == "" 76 | 77 | event_logger.remove_modifier(modifier=redactor) 78 | 79 | event_logger.emit(schema_id=schema.id, data={"username": "jovyan"}) 80 | output = jp_read_emitted_events()[0] 81 | 82 | assert "username" in output 83 | assert output["username"] == "jovyan" 84 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: monthly 3 | autoupdate_commit_msg: "chore: update pre-commit hooks" 4 | 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v4.5.0 8 | hooks: 9 | - id: check-case-conflict 10 | - id: check-ast 11 | - id: check-docstring-first 12 | - id: check-executables-have-shebangs 13 | - id: check-added-large-files 14 | - id: check-case-conflict 15 | - id: check-merge-conflict 16 | - id: check-json 17 | - id: check-toml 18 | - id: check-yaml 19 | - id: debug-statements 20 | - id: end-of-file-fixer 21 | - id: trailing-whitespace 22 | 23 | - repo: https://github.com/python-jsonschema/check-jsonschema 24 | rev: 0.27.4 25 | hooks: 26 | - id: check-github-workflows 27 | 28 | - repo: https://github.com/executablebooks/mdformat 29 | rev: 0.7.17 30 | hooks: 31 | - id: mdformat 32 | additional_dependencies: 33 | [mdformat-gfm, mdformat-frontmatter, mdformat-footnote] 34 | 35 | - repo: https://github.com/pre-commit/mirrors-prettier 36 | rev: "v4.0.0-alpha.8" 37 | hooks: 38 | - id: prettier 39 | types_or: [yaml, html, json] 40 | 41 | - repo: https://github.com/adamchainz/blacken-docs 42 | rev: "1.16.0" 43 | hooks: 44 | - id: blacken-docs 45 | additional_dependencies: [black==23.7.0] 46 | exclude: docs/user_guide/application.md 47 | 48 | - repo: https://github.com/codespell-project/codespell 49 | rev: "v2.2.6" 50 | hooks: 51 | - id: codespell 52 | args: ["-L", "sur,nd"] 53 | 54 | - repo: https://github.com/pre-commit/mirrors-mypy 55 | rev: "v1.8.0" 56 | hooks: 57 | - id: mypy 58 | files: "^jupyter_events" 59 | stages: [manual] 60 | args: ["--install-types", "--non-interactive"] 61 | additional_dependencies: 62 | [ 63 | "traitlets>=5.13", 64 | "jupyter_core>=5.4", 65 | "pyyaml", 66 | "python-json-logger", 67 | "pytest>=7", 68 | "click", 69 | "rich", 70 | ] 71 | 72 | - repo: https://github.com/pre-commit/pygrep-hooks 73 | rev: "v1.10.0" 74 | hooks: 75 | - id: rst-backticks 76 | - id: rst-directive-colons 77 | - id: rst-inline-touching-normal 78 | 79 | - repo: https://github.com/astral-sh/ruff-pre-commit 80 | rev: v0.2.0 81 | hooks: 82 | - id: ruff 83 | types_or: [python, jupyter] 84 | args: ["--fix", "--show-fixes"] 85 | - id: ruff-format 86 | types_or: [python, jupyter] 87 | 88 | - repo: https://github.com/scientific-python/cookie 89 | rev: "2024.01.24" 90 | hooks: 91 | - id: sp-repo-review 92 | additional_dependencies: ["repo-review[cli]"] 93 | args: ["--ignore", "GH102"] 94 | -------------------------------------------------------------------------------- /jupyter_events/schema_registry.py: -------------------------------------------------------------------------------- 1 | """"An event schema registry.""" 2 | from __future__ import annotations 3 | 4 | from typing import Any 5 | 6 | from .schema import EventSchema 7 | 8 | 9 | class SchemaRegistryException(Exception): 10 | """Exception class for Jupyter Events Schema Registry Errors.""" 11 | 12 | 13 | class SchemaRegistry: 14 | """A convenient API for storing and searching a group of schemas.""" 15 | 16 | def __init__(self, schemas: dict[str, EventSchema] | None = None): 17 | """Initialize the registry.""" 18 | self._schemas: dict[str, EventSchema] = schemas or {} 19 | 20 | def __contains__(self, key: str) -> bool: 21 | """Syntax sugar to check if a schema is found in the registry""" 22 | return key in self._schemas 23 | 24 | def __repr__(self) -> str: 25 | """The str repr of the registry.""" 26 | return ",\n".join([str(s) for s in self._schemas.values()]) 27 | 28 | def _add(self, schema_obj: EventSchema) -> None: 29 | if schema_obj.id in self._schemas: 30 | msg = ( 31 | f"The schema, {schema_obj.id}, is already " 32 | "registered. Try removing it and registering it again." 33 | ) 34 | raise SchemaRegistryException(msg) 35 | self._schemas[schema_obj.id] = schema_obj 36 | 37 | @property 38 | def schema_ids(self) -> list[str]: 39 | return list(self._schemas.keys()) 40 | 41 | def register(self, schema: dict[str, Any] | (str | EventSchema)) -> EventSchema: 42 | """Add a valid schema to the registry. 43 | 44 | All schemas are validated against the Jupyter Events meta-schema 45 | found here: 46 | """ 47 | if not isinstance(schema, EventSchema): 48 | schema = EventSchema(schema) 49 | self._add(schema) 50 | return schema 51 | 52 | def get(self, id_: str) -> EventSchema: 53 | """Fetch a given schema. If the schema is not found, 54 | this will raise a KeyError. 55 | """ 56 | try: 57 | return self._schemas[id_] 58 | except KeyError: 59 | msg = ( 60 | f"The requested schema, {id_}, was not found in the " 61 | "schema registry. Are you sure it was previously registered?" 62 | ) 63 | raise KeyError(msg) from None 64 | 65 | def remove(self, id_: str) -> None: 66 | """Remove a given schema. If the schema is not found, 67 | this will raise a KeyError. 68 | """ 69 | try: 70 | del self._schemas[id_] 71 | except KeyError: 72 | msg = ( 73 | f"The requested schema, {id_}, was not found in the " 74 | "schema registry. Are you sure it was previously registered?" 75 | ) 76 | raise KeyError(msg) from None 77 | 78 | def validate_event(self, id_: str, data: dict[str, Any]) -> None: 79 | """Validate an event against a schema within this 80 | registry. 81 | """ 82 | schema = self.get(id_) 83 | schema.validate(data) 84 | -------------------------------------------------------------------------------- /jupyter_events/validators.py: -------------------------------------------------------------------------------- 1 | """Event validators.""" 2 | from __future__ import annotations 3 | 4 | import pathlib 5 | import warnings 6 | from typing import Any 7 | 8 | import jsonschema 9 | from jsonschema import Draft7Validator, ValidationError 10 | from referencing import Registry 11 | from referencing.jsonschema import DRAFT7 12 | 13 | from . import yaml 14 | from .utils import JupyterEventsVersionWarning 15 | 16 | draft7_format_checker = ( 17 | Draft7Validator.FORMAT_CHECKER 18 | if hasattr(Draft7Validator, "FORMAT_CHECKER") 19 | else jsonschema.draft7_format_checker 20 | ) 21 | 22 | 23 | METASCHEMA_PATH = pathlib.Path(__file__).parent.joinpath("schemas") 24 | 25 | EVENT_METASCHEMA_FILEPATH = METASCHEMA_PATH.joinpath("event-metaschema.yml") 26 | EVENT_METASCHEMA = yaml.load(EVENT_METASCHEMA_FILEPATH) 27 | 28 | EVENT_CORE_SCHEMA_FILEPATH = METASCHEMA_PATH.joinpath("event-core-schema.yml") 29 | EVENT_CORE_SCHEMA = yaml.load(EVENT_CORE_SCHEMA_FILEPATH) 30 | 31 | PROPERTY_METASCHEMA_FILEPATH = METASCHEMA_PATH.joinpath("property-metaschema.yml") 32 | PROPERTY_METASCHEMA = yaml.load(PROPERTY_METASCHEMA_FILEPATH) 33 | 34 | SCHEMA_STORE = { 35 | EVENT_METASCHEMA["$id"]: EVENT_METASCHEMA, 36 | PROPERTY_METASCHEMA["$id"]: PROPERTY_METASCHEMA, 37 | EVENT_CORE_SCHEMA["$id"]: EVENT_CORE_SCHEMA, 38 | } 39 | 40 | resources = [ 41 | DRAFT7.create_resource(each) 42 | for each in (EVENT_METASCHEMA, PROPERTY_METASCHEMA, EVENT_CORE_SCHEMA) 43 | ] 44 | METASCHEMA_REGISTRY: Registry[Any] = resources @ Registry() 45 | 46 | JUPYTER_EVENTS_SCHEMA_VALIDATOR = Draft7Validator( 47 | schema=EVENT_METASCHEMA, 48 | registry=METASCHEMA_REGISTRY, 49 | format_checker=draft7_format_checker, 50 | ) 51 | 52 | JUPYTER_EVENTS_CORE_VALIDATOR = Draft7Validator( 53 | schema=EVENT_CORE_SCHEMA, 54 | registry=METASCHEMA_REGISTRY, 55 | format_checker=draft7_format_checker, 56 | ) 57 | 58 | 59 | def validate_schema(schema: dict[str, Any]) -> None: 60 | """Validate a schema dict.""" 61 | try: 62 | # If the `version` attribute is an integer, coerce to string. 63 | # TODO: remove this in a future version. 64 | if "version" in schema and isinstance(schema["version"], int): 65 | schema["version"] = str(schema["version"]) 66 | msg = ( 67 | "The `version` property of an event schema must be a string. " 68 | "It has been type coerced, but in a future version of this " 69 | "library, it will fail to validate. Please update schema: " 70 | f"{schema['$id']}" 71 | ) 72 | warnings.warn(JupyterEventsVersionWarning(msg), stacklevel=2) 73 | # Validate the schema against Jupyter Events metaschema. 74 | JUPYTER_EVENTS_SCHEMA_VALIDATOR.validate(schema) 75 | except ValidationError as err: 76 | reserved_property_msg = " does not match '^(?!__.*)'" 77 | if reserved_property_msg in str(err): 78 | idx = str(err).find(reserved_property_msg) 79 | bad_property = str(err)[:idx].strip() 80 | msg = ( 81 | f"{bad_property} is an invalid property name because it " 82 | "starts with `__`. Properties starting with 'dunder' " 83 | "are reserved as special meta-fields for Jupyter Events to use." 84 | ) 85 | raise ValidationError(msg) from err 86 | raise err 87 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | from __future__ import annotations 20 | 21 | project = "jupyter_events" 22 | copyright = "2019, Project Jupyter" 23 | author = "Project Jupyter" 24 | 25 | 26 | # -- General configuration --------------------------------------------------- 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 30 | # ones. 31 | extensions: list = ["myst_parser", "jupyterlite_sphinx"] 32 | 33 | try: 34 | import enchant # noqa: F401 35 | 36 | extensions += ["sphinxcontrib.spelling"] 37 | except ImportError: 38 | pass 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ["_templates"] 42 | 43 | source_suffix = [".rst", ".md"] 44 | 45 | 46 | # List of patterns, relative to source directory, that match files and 47 | # directories to ignore when looking for source files. 48 | # This pattern also affects html_static_path and html_extra_path. 49 | exclude_patterns = [ 50 | "_build", 51 | "_contents", 52 | "Thumbs.db", 53 | ".DS_Store", 54 | "demo/demo-notebook.ipynb", 55 | ] 56 | 57 | 58 | # -- Options for HTML output ------------------------------------------------- 59 | 60 | # The theme to use for HTML and HTML Help pages. See the documentation for 61 | # a list of builtin themes. 62 | # 63 | html_theme = "pydata_sphinx_theme" 64 | 65 | # Add any paths that contain custom static files (such as style sheets) here, 66 | # relative to this directory. They are copied after the builtin static files, 67 | # so a file named "default.css" will overwrite the builtin "default.css". 68 | html_static_path = ["_static"] 69 | html_logo = "_static/jupyter_logo.png" 70 | 71 | master_doc = "index" 72 | 73 | # Configure jupyterlite to import jupyter_events package 74 | jupyterlite_contents = ["demo/demo-notebook.ipynb"] 75 | 76 | html_theme_options = { 77 | "logo": { 78 | "text": "Jupyter Events", 79 | }, 80 | "navigation_with_keys": False, 81 | "icon_links": [ 82 | { 83 | # Label for this link 84 | "name": "GitHub", 85 | # URL where the link will redirect 86 | "url": "https://github.com/jupyter/jupyter_events", # required 87 | # Icon class (if "type": "fontawesome"), or path to local image (if "type": "local") 88 | "icon": "fab fa-github-square", 89 | # The type of image to be used (see below for details) 90 | "type": "fontawesome", 91 | }, 92 | { 93 | "name": "jupyter.org", 94 | "url": "https://jupyter.org", 95 | "icon": "_static/jupyter_logo.png", 96 | "type": "local", 97 | }, 98 | ], 99 | } 100 | -------------------------------------------------------------------------------- /jupyter_events/cli.py: -------------------------------------------------------------------------------- 1 | """The cli for jupyter events.""" 2 | from __future__ import annotations 3 | 4 | import json 5 | import pathlib 6 | import platform 7 | 8 | import click 9 | from jsonschema import ValidationError 10 | from rich.console import Console 11 | from rich.json import JSON 12 | from rich.markup import escape 13 | from rich.padding import Padding 14 | from rich.style import Style 15 | 16 | from jupyter_events.schema import EventSchema, EventSchemaFileAbsent, EventSchemaLoadingError 17 | 18 | WIN = platform.system() == "Windows" 19 | 20 | 21 | class RC: 22 | """Return code enum.""" 23 | 24 | OK = 0 25 | INVALID = 1 26 | UNPARSABLE = 2 27 | NOT_FOUND = 3 28 | 29 | 30 | class EMOJI: 31 | """Terminal emoji enum""" 32 | 33 | X = "XX" if WIN else "\u274c" 34 | OK = "OK" if WIN else "\u2714" 35 | 36 | 37 | console = Console() 38 | error_console = Console(stderr=True) 39 | 40 | 41 | @click.group() 42 | @click.version_option() 43 | def main() -> None: 44 | """A simple CLI tool to quickly validate JSON schemas against 45 | Jupyter Event's custom validator. 46 | 47 | You can see Jupyter Event's meta-schema here: 48 | 49 | https://raw.githubusercontent.com/jupyter/jupyter_events/main/jupyter_events/schemas/event-metaschema.yml 50 | """ 51 | 52 | 53 | @click.command() 54 | @click.argument("schema") 55 | @click.pass_context 56 | def validate(ctx: click.Context, schema: str) -> int: 57 | """Validate a SCHEMA against Jupyter Event's meta schema. 58 | 59 | SCHEMA can be a JSON/YAML string or filepath to a schema. 60 | """ 61 | console.rule("Validating the following schema", style=Style(color="blue")) 62 | 63 | _schema = None 64 | try: 65 | # attempt to read schema as a serialized string 66 | _schema = EventSchema._load_schema(schema) 67 | except EventSchemaLoadingError: 68 | # pass here to avoid printing traceback of this exception if next block 69 | # excepts 70 | pass 71 | 72 | # if not a serialized schema string, try to interpret it as a path to schema file 73 | if _schema is None: 74 | schema_path = pathlib.Path(schema) 75 | try: 76 | _schema = EventSchema._load_schema(schema_path) 77 | except (EventSchemaLoadingError, EventSchemaFileAbsent) as e: 78 | # no need for full tracestack for user error exceptions. just print 79 | # the error message and return 80 | error_console.print(f"[bold red]ERROR[/]: {e}") 81 | return ctx.exit(RC.UNPARSABLE) 82 | 83 | # Print what was found. 84 | schema_json = JSON(json.dumps(_schema)) 85 | console.print(Padding(schema_json, (1, 0, 1, 4))) 86 | # Now validate this schema against the meta-schema. 87 | try: 88 | EventSchema(_schema) 89 | console.rule("Results", style=Style(color="green")) 90 | out = Padding(f"[green]{EMOJI.OK}[white] Nice work! This schema is valid.", (1, 0, 1, 0)) 91 | console.print(out) 92 | return ctx.exit(RC.OK) 93 | except ValidationError as err: 94 | error_console.rule("Results", style=Style(color="red")) 95 | error_console.print(f"[red]{EMOJI.X} [white]The schema failed to validate.") 96 | error_console.print("\nWe found the following error with your schema:") 97 | out = escape(str(err)) # type:ignore[assignment] 98 | error_console.print(Padding(out, (1, 0, 1, 4))) 99 | return ctx.exit(RC.INVALID) 100 | 101 | 102 | main.add_command(validate) 103 | -------------------------------------------------------------------------------- /tests/test_schema.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from pathlib import Path 5 | 6 | import pytest 7 | from jsonschema.exceptions import ValidationError 8 | 9 | from jupyter_events import yaml 10 | from jupyter_events.schema import ( 11 | EventSchema, 12 | EventSchemaFileAbsent, 13 | EventSchemaLoadingError, 14 | EventSchemaUnrecognized, 15 | ) 16 | from jupyter_events.validators import validate_schema 17 | 18 | from .utils import SCHEMA_PATH 19 | 20 | BAD_SCHEMAS = [ 21 | ["reserved-property.yaml", "Properties starting with 'dunder'"], 22 | ["nested-reserved-property.yaml", "Properties starting with 'dunder'"], 23 | ["bad-id.yaml", "'not-a-uri' is not a 'uri'"], 24 | ] 25 | 26 | GOOD_SCHEMAS = ["array.yaml", "nested-array.yaml", "basic.yaml"] 27 | 28 | 29 | @pytest.mark.parametrize("schema_file,validation_error_msg", BAD_SCHEMAS) 30 | def test_bad_validations(schema_file, validation_error_msg): 31 | """ 32 | Validation fails because the schema is missing 33 | a redactionPolicies field. 34 | """ 35 | # Read the schema file 36 | with Path.open(SCHEMA_PATH / "bad" / schema_file) as f: 37 | schema = yaml.loads(f) 38 | # Assert that the schema files for a known reason. 39 | with pytest.raises(ValidationError) as err: 40 | validate_schema(schema) 41 | assert validation_error_msg in err.value.message 42 | 43 | 44 | def test_file_absent(): 45 | """Validation fails because file does not exist at path.""" 46 | with pytest.raises(EventSchemaFileAbsent): 47 | EventSchema(Path("asdf.txt")) 48 | 49 | 50 | def test_string_intended_as_path(): 51 | """Ensure EventSchema returns a helpful error message if user passes a 52 | string intended as a Path.""" 53 | expected_msg_contents = "Paths to schema files must be explicitly wrapped in a Pathlib object." 54 | str_path = os.path.join(SCHEMA_PATH, "good", "some_schema.yaml") # noqa: PTH118 55 | with pytest.raises(EventSchemaLoadingError) as e: 56 | EventSchema(str_path) 57 | 58 | assert expected_msg_contents in str(e) 59 | 60 | 61 | def test_unrecognized_type(): 62 | """Validation fails because file is not of valid type.""" 63 | with pytest.raises(EventSchemaUnrecognized): 64 | EventSchema(9001) # type:ignore[arg-type] 65 | 66 | 67 | def test_invalid_yaml(): 68 | """Validation fails because deserialized schema is not a dictionary.""" 69 | path = SCHEMA_PATH / "bad" / "invalid.yaml" 70 | with pytest.raises(EventSchemaLoadingError): 71 | EventSchema(path) 72 | 73 | 74 | def test_valid_json(): 75 | """Ensure EventSchema accepts JSON files.""" 76 | path = SCHEMA_PATH / "good" / "basic.json" 77 | EventSchema(path) 78 | 79 | 80 | @pytest.mark.parametrize("schema_file", GOOD_SCHEMAS) 81 | def test_good_validations(schema_file): 82 | """Ensure validation passes for good schemas.""" 83 | # Read the schema file 84 | with Path.open(SCHEMA_PATH / "good" / schema_file) as f: 85 | schema = yaml.loads(f) 86 | # assert that no exception gets raised 87 | validate_schema(schema) 88 | 89 | 90 | @pytest.mark.parametrize( 91 | "schema", 92 | [ 93 | # Non existent paths 94 | "non-existent-file.yml", 95 | "non/existent/path", 96 | "non/existent/path/file.yaml", 97 | # Valid yaml string, but not a valid object 98 | "random string", 99 | ], 100 | ) 101 | def test_loading_string_error(schema): 102 | with pytest.raises(EventSchemaLoadingError): 103 | EventSchema(schema) 104 | -------------------------------------------------------------------------------- /docs/user_guide/defining-schema.md: -------------------------------------------------------------------------------- 1 | (defining-schema)= 2 | 3 | # Defining an event schema 4 | 5 | All Jupyter Events schemas are valid [JSON schema](https://json-schema.org/) and can be written in valid YAML or JSON. More specifically, these schemas are validated against Jupyter Event's "meta"-JSON schema, [here](https://github.com/jupyter/jupyter_events/tree/main/jupyter_events/schemas/event-metaschema.yml). 6 | 7 | A common pattern is to define these schemas in separate files and register them with an `EventLogger` using the `.register_event_schema(...)` method: 8 | 9 | ```python 10 | schema_filepath = Path("/path/to/schema.yaml") 11 | 12 | logger = EventLogger() 13 | logger.register_event_schema(schema_filepath) 14 | ``` 15 | 16 | Note that a file path passed to `register_event_schema()` **must** be a Pathlib 17 | object. This is required for `register_event_schema()` to distinguish between 18 | file paths and schemas specified in a Python string. 19 | 20 | At a minimum, a valid Jupyter event schema requires the following keys: 21 | 22 | - `$id` : a URI to identify (and possibly locate) the schema. 23 | - `version` : the schema version. 24 | - `properties` : attributes of the event being emitted. 25 | 26 | Beyond these required items, any valid JSON should be possible. Here is a simple example of a valid JSON schema for an event. 27 | 28 | ```yaml 29 | $id: https://event.jupyter.org/example-event 30 | version: "1" 31 | title: My Event 32 | description: | 33 | Some information about my event 34 | type: object 35 | properties: 36 | thing: 37 | title: Thing 38 | description: A random thing. 39 | user: 40 | title: User name 41 | description: Name of user who initiated event 42 | required: 43 | - thing 44 | - user 45 | ``` 46 | 47 | ## Checking if a schema is valid 48 | 49 | When authoring a schema, how do you check if you schema is following the expected form? Jupyter Events offers a simple command line tool to validate your schema against its Jupyter Events metaschema. 50 | 51 | First, install the CLI: 52 | 53 | ``` 54 | pip install "jupyter_events[cli]" 55 | ``` 56 | 57 | Then, run the CLI against your schema: 58 | 59 | ``` 60 | jupyter-events validate path/to/my_schema.json 61 | ``` 62 | 63 | The output will look like this, if it passes: 64 | 65 | ``` 66 | ──────────────────────────────────── Validating the following schema ──────────────────────────────────── 67 | 68 | { 69 | "$id": "http://event.jupyter.org/test", 70 | "version": "1", 71 | "title": "Simple Test Schema", 72 | "description": "A simple schema for testing\n", 73 | "type": "object", 74 | "properties": { 75 | "prop": { 76 | "title": "Test Property", 77 | "description": "Test property.", 78 | "type": "string" 79 | } 80 | } 81 | } 82 | 83 | ──────────────────────────────────────────────── Results ──────────────────────────────────────────────── 84 | 85 | ✔ Nice work! This schema is valid. 86 | ``` 87 | 88 | or this if fails: 89 | 90 | ``` 91 | ──────────────────────────────────── Validating the following schema ──────────────────────────────────── 92 | 93 | { 94 | "$id": "http://event.jupyter.org/test", 95 | "version": "1", 96 | "title": "Simple Test Schema", 97 | "description": "A simple schema for testing\n", 98 | "type": "object", 99 | "properties": { 100 | "__badName": { 101 | "title": "Test Property", 102 | "description": "Test property.", 103 | "type": "string" 104 | } 105 | } 106 | } 107 | 108 | ──────────────────────────────────────────────── Results ──────────────────────────────────────────────── 109 | ❌ The schema failed to validate. 110 | 111 | We found the following error with your schema: 112 | 113 | '__badName' is an invalid property name because it starts with `__`. Properties starting with 114 | 'dunder' are reserved as special meta-fields for Jupyter Events to use. 115 | ``` 116 | -------------------------------------------------------------------------------- /.github/workflows/python-tests.yml: -------------------------------------------------------------------------------- 1 | name: Jupyter Events Tests 2 | on: 3 | push: 4 | branches: ["main"] 5 | pull_request: 6 | schedule: 7 | - cron: "0 8 * * *" 8 | 9 | defaults: 10 | run: 11 | shell: bash -eux {0} 12 | 13 | jobs: 14 | build: 15 | runs-on: ${{ matrix.os }} 16 | timeout-minutes: 20 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | os: [ubuntu-latest, windows-latest, macos-latest] 21 | python-version: ["3.9", "3.12", "3.13"] 22 | include: 23 | - os: windows-latest 24 | python-version: "3.9" 25 | - os: ubuntu-latest 26 | python-version: "pypy-3.9" 27 | - os: ubuntu-latest 28 | python-version: "3.10" 29 | - os: macos-latest 30 | python-version: "3.11" 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 34 | - name: Run Tests 35 | run: hatch run cov:test 36 | - uses: jupyterlab/maintainer-tools/.github/actions/upload-coverage@v1 37 | 38 | coverage: 39 | runs-on: ubuntu-latest 40 | needs: 41 | - build 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: jupyterlab/maintainer-tools/.github/actions/report-coverage@v1 45 | 46 | docs: 47 | runs-on: ubuntu-latest 48 | steps: 49 | - uses: actions/checkout@v4 50 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 51 | with: 52 | python_version: 3.12 53 | - run: hatch run docs:build 54 | 55 | test_lint: 56 | name: Test Lint 57 | runs-on: ubuntu-latest 58 | steps: 59 | - uses: actions/checkout@v4 60 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 61 | - name: Run Linters 62 | run: | 63 | hatch run typing:test 64 | hatch run lint:build 65 | pipx run interrogate . 66 | pipx run doc8 --max-line-length=200 67 | 68 | jupyter_server_downstream: 69 | runs-on: ubuntu-latest 70 | timeout-minutes: 10 71 | steps: 72 | - uses: actions/checkout@v4 73 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 74 | - uses: jupyterlab/maintainer-tools/.github/actions/downstream-test@v1 75 | with: 76 | package_name: jupyter_server 77 | 78 | test_minimum_versions: 79 | name: Test Minimum Versions 80 | timeout-minutes: 20 81 | runs-on: ubuntu-latest 82 | steps: 83 | - uses: actions/checkout@v4 84 | - name: Base Setup 85 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 86 | with: 87 | python_version: 3.9 88 | - name: Install with minimum versions and optional deps 89 | run: | 90 | pip install -e .[test] 91 | pip install jsonschema[format-nongpl,format_nongpl] 92 | - name: List installed packages 93 | run: | 94 | pip freeze 95 | pip check 96 | - name: Run the unit tests 97 | run: | 98 | pytest -vv -W default || pytest -vv -W default --lf 99 | 100 | test_prereleases: 101 | name: Test Prereleases 102 | runs-on: ubuntu-latest 103 | timeout-minutes: 20 104 | steps: 105 | - name: Checkout 106 | uses: actions/checkout@v4 107 | - name: Base Setup 108 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 109 | with: 110 | dependency_type: pre 111 | - name: Run the tests 112 | run: | 113 | hatch run test:nowarn || hatch run test:nowarn --lf 114 | 115 | make_sdist: 116 | name: Make SDist 117 | runs-on: ubuntu-latest 118 | timeout-minutes: 10 119 | steps: 120 | - uses: actions/checkout@v4 121 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 122 | - uses: jupyterlab/maintainer-tools/.github/actions/make-sdist@v1 123 | 124 | test_sdist: 125 | runs-on: ubuntu-latest 126 | needs: [make_sdist] 127 | name: Install from SDist and Test 128 | timeout-minutes: 20 129 | steps: 130 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 131 | - uses: jupyterlab/maintainer-tools/.github/actions/test-sdist@v1 132 | with: 133 | test_command: pytest -vv || pytest -vv --lf 134 | 135 | check_links: 136 | runs-on: ubuntu-latest 137 | timeout-minutes: 10 138 | steps: 139 | - uses: actions/checkout@v4 140 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 141 | - uses: jupyterlab/maintainer-tools/.github/actions/check-links@v1 142 | 143 | check_release: 144 | runs-on: ubuntu-latest 145 | steps: 146 | - name: Checkout 147 | uses: actions/checkout@v4 148 | - name: Base Setup 149 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 150 | - name: Install Dependencies 151 | run: | 152 | pip install -e . 153 | - name: Check Release 154 | uses: jupyter-server/jupyter_releaser/.github/actions/check-release@v2 155 | with: 156 | version_spec: 100.100.100 157 | token: ${{ secrets.GITHUB_TOKEN }} 158 | -------------------------------------------------------------------------------- /jupyter_events/schema.py: -------------------------------------------------------------------------------- 1 | """Event schema objects.""" 2 | from __future__ import annotations 3 | 4 | import json 5 | from pathlib import Path, PurePath 6 | from typing import Any, Union 7 | 8 | from jsonschema import FormatChecker, validators 9 | from referencing import Registry 10 | from referencing.jsonschema import DRAFT7 11 | 12 | try: 13 | from jsonschema.protocols import Validator 14 | except ImportError: 15 | Validator = Any # type:ignore[assignment, misc] 16 | 17 | from . import yaml 18 | from .validators import draft7_format_checker, validate_schema 19 | 20 | 21 | class EventSchemaUnrecognized(Exception): 22 | """An error for an unrecognized event schema.""" 23 | 24 | 25 | class EventSchemaLoadingError(Exception): 26 | """An error for an event schema loading error.""" 27 | 28 | 29 | class EventSchemaFileAbsent(Exception): 30 | """An error for an absent event schema file.""" 31 | 32 | 33 | SchemaType = Union[dict[str, Any], str, PurePath] 34 | 35 | 36 | class EventSchema: 37 | """A validated schema that can be used. 38 | 39 | On instantiation, validate the schema against 40 | Jupyter Event's metaschema. 41 | 42 | Parameters 43 | ---------- 44 | schema: dict or str 45 | JSON schema to validate against Jupyter Events. 46 | 47 | validator_class: jsonschema.validators 48 | The validator class from jsonschema used to validate instances 49 | of this event schema. The schema itself will be validated 50 | against Jupyter Event's metaschema to ensure that 51 | any schema registered here follows the expected form 52 | of Jupyter Events. 53 | 54 | registry: 55 | Registry for nested JSON schema references. 56 | """ 57 | 58 | def __init__( 59 | self, 60 | schema: SchemaType, 61 | validator_class: type[Validator] = validators.Draft7Validator, # type:ignore[assignment] 62 | format_checker: FormatChecker = draft7_format_checker, 63 | registry: Registry[Any] | None = None, 64 | ): 65 | """Initialize an event schema.""" 66 | _schema = self._load_schema(schema) 67 | # Validate the schema against Jupyter Events metaschema. 68 | validate_schema(_schema) 69 | 70 | if registry is None: 71 | registry = DRAFT7.create_resource(_schema) @ Registry() 72 | 73 | # Create a validator for this schema 74 | self._validator = validator_class(_schema, registry=registry, format_checker=format_checker) # type: ignore[call-arg] 75 | self._schema = _schema 76 | 77 | def __repr__(self) -> str: 78 | """A string repr for an event schema.""" 79 | return json.dumps(self._schema, indent=2) 80 | 81 | @staticmethod 82 | def _ensure_yaml_loaded(schema: SchemaType, was_str: bool = False) -> None: 83 | """Ensures schema was correctly loaded into a dictionary. Raises 84 | EventSchemaLoadingError otherwise.""" 85 | if isinstance(schema, dict): 86 | return 87 | 88 | error_msg = "Could not deserialize schema into a dictionary." 89 | 90 | def intended_as_path(schema: str) -> bool: 91 | path = Path(schema) 92 | return path.match("*.yml") or path.match("*.yaml") or path.match("*.json") 93 | 94 | # detect whether the user specified a string but intended a PurePath to 95 | # generate a more helpful error message 96 | if was_str and intended_as_path(schema): # type:ignore[arg-type] 97 | error_msg += " Paths to schema files must be explicitly wrapped in a Pathlib object." 98 | else: 99 | error_msg += " Double check the schema and ensure it is in the proper form." 100 | 101 | raise EventSchemaLoadingError(error_msg) 102 | 103 | @staticmethod 104 | def _load_schema(schema: SchemaType) -> dict[str, Any]: 105 | """Load a JSON schema from different sources/data types. 106 | 107 | `schema` could be a dictionary or serialized string representing the 108 | schema itself or a Pathlib object representing a schema file on disk. 109 | 110 | Returns a dictionary with schema data. 111 | """ 112 | 113 | # if schema is already a dictionary, return it 114 | if isinstance(schema, dict): 115 | return schema 116 | 117 | # if schema is PurePath, ensure file exists at path and then load from file 118 | if isinstance(schema, PurePath): 119 | if not Path(schema).exists(): 120 | msg = f'Schema file not present at path "{schema}".' 121 | raise EventSchemaFileAbsent(msg) 122 | 123 | loaded_schema = yaml.load(schema) 124 | EventSchema._ensure_yaml_loaded(loaded_schema) 125 | return loaded_schema # type:ignore[no-any-return] 126 | 127 | # finally, if schema is string, attempt to deserialize and return the output 128 | if isinstance(schema, str): 129 | # note the diff b/w load v.s. loads 130 | loaded_schema = yaml.loads(schema) 131 | EventSchema._ensure_yaml_loaded(loaded_schema, was_str=True) 132 | return loaded_schema # type:ignore[no-any-return] 133 | 134 | msg = f"Expected a dictionary, string, or PurePath, but instead received {schema.__class__.__name__}." # type:ignore[unreachable] 135 | raise EventSchemaUnrecognized(msg) 136 | 137 | @property 138 | def id(self) -> str: 139 | """Schema $id field.""" 140 | return self._schema["$id"] # type:ignore[no-any-return] 141 | 142 | @property 143 | def version(self) -> int: 144 | """Schema's version.""" 145 | return self._schema["version"] # type:ignore[no-any-return] 146 | 147 | @property 148 | def properties(self) -> dict[str, Any]: 149 | return self._schema["properties"] # type:ignore[no-any-return] 150 | 151 | def validate(self, data: dict[str, Any]) -> None: 152 | """Validate an incoming instance of this event schema.""" 153 | self._validator.validate(data) 154 | -------------------------------------------------------------------------------- /tests/test_listeners.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import io 4 | import logging 5 | 6 | import pytest 7 | 8 | from jupyter_events.logger import EventLogger, SchemaNotRegistered 9 | from jupyter_events.schema import EventSchema 10 | 11 | from .utils import SCHEMA_PATH 12 | 13 | 14 | @pytest.fixture 15 | def schema(): 16 | # Read schema from path. 17 | schema_path = SCHEMA_PATH / "good" / "basic.yaml" 18 | return EventSchema(schema=schema_path) 19 | 20 | 21 | @pytest.fixture 22 | def jp_event_schemas(schema): 23 | return [schema] 24 | 25 | 26 | async def test_listener_function(jp_event_logger, schema): 27 | event_logger = jp_event_logger 28 | listener_was_called = False 29 | 30 | async def my_listener(logger: EventLogger, schema_id: str, data: dict) -> None: 31 | nonlocal listener_was_called 32 | listener_was_called = True 33 | 34 | # Add the modifier 35 | event_logger.add_listener(schema_id=schema.id, listener=my_listener) 36 | event_logger.emit(schema_id=schema.id, data={"prop": "hello, world"}) 37 | await event_logger.gather_listeners() 38 | assert listener_was_called 39 | # Check that the active listeners are cleaned up. 40 | assert len(event_logger._active_listeners) == 0 41 | 42 | 43 | async def test_listener_function_str_annotations(jp_event_logger, schema): 44 | event_logger = jp_event_logger 45 | listener_was_called = False 46 | 47 | async def my_listener(logger: EventLogger, schema_id: str, data: dict) -> None: 48 | nonlocal listener_was_called 49 | listener_was_called = True 50 | 51 | # Add the modifier 52 | event_logger.add_listener(schema_id=schema.id, listener=my_listener) 53 | event_logger.emit(schema_id=schema.id, data={"prop": "hello, world"}) 54 | await event_logger.gather_listeners() 55 | assert listener_was_called 56 | # Check that the active listeners are cleaned up. 57 | assert len(event_logger._active_listeners) == 0 58 | 59 | 60 | async def test_remove_listener_function(jp_event_logger, schema): 61 | event_logger = jp_event_logger 62 | listener_was_called = False 63 | 64 | async def my_listener(logger: EventLogger, schema_id: str, data: dict) -> None: 65 | nonlocal listener_was_called 66 | listener_was_called = True 67 | 68 | # Add the modifier 69 | event_logger.add_listener(schema_id=schema.id, listener=my_listener) 70 | event_logger.emit(schema_id=schema.id, data={"prop": "hello, world"}) 71 | await event_logger.gather_listeners() 72 | assert listener_was_called 73 | 74 | # Check that the active listeners are cleaned up. 75 | assert len(event_logger._active_listeners) == 0 76 | 77 | event_logger.remove_listener(listener=my_listener) 78 | assert len(event_logger._modified_listeners[schema.id]) == 0 79 | assert len(event_logger._unmodified_listeners[schema.id]) == 0 80 | 81 | 82 | async def test_listener_that_raises_exception(jp_event_logger, schema): 83 | event_logger = jp_event_logger 84 | 85 | # Get an application logger that will show the exception 86 | app_log = event_logger.log 87 | log_stream = io.StringIO() 88 | h = logging.StreamHandler(log_stream) 89 | app_log.addHandler(h) 90 | 91 | async def listener_raise_exception(logger: EventLogger, schema_id: str, data: dict) -> None: 92 | raise Exception("This failed") # noqa 93 | 94 | event_logger.add_listener(schema_id=schema.id, listener=listener_raise_exception) 95 | event_logger.emit(schema_id=schema.id, data={"prop": "hello, world"}) 96 | 97 | await event_logger.gather_listeners() 98 | 99 | # Check that the exception was printed to the logs 100 | h.flush() 101 | log_output = log_stream.getvalue() 102 | assert "This failed" in log_output 103 | # Check that the active listeners are cleaned up. 104 | assert len(event_logger._active_listeners) == 0 105 | 106 | 107 | async def test_bad_listener_does_not_break_good_listener(jp_event_logger, schema): 108 | event_logger = jp_event_logger 109 | 110 | # Get an application logger that will show the exception 111 | app_log = event_logger.log 112 | log_stream = io.StringIO() 113 | h = logging.StreamHandler(log_stream) 114 | app_log.addHandler(h) 115 | 116 | listener_was_called = False 117 | 118 | async def listener_raise_exception(logger: EventLogger, schema_id: str, data: dict) -> None: 119 | raise Exception("This failed") # noqa 120 | 121 | async def my_listener(logger: EventLogger, schema_id: str, data: dict) -> None: 122 | nonlocal listener_was_called 123 | listener_was_called = True 124 | 125 | # Add a bad listener and a good listener and ensure that 126 | # emitting still works and the bad listener's exception is is logged. 127 | event_logger.add_listener(schema_id=schema.id, listener=listener_raise_exception) 128 | event_logger.add_listener(schema_id=schema.id, listener=my_listener) 129 | 130 | event_logger.emit(schema_id=schema.id, data={"prop": "hello, world"}) 131 | 132 | await event_logger.gather_listeners() 133 | 134 | # Check that the exception was printed to the logs 135 | h.flush() 136 | log_output = log_stream.getvalue() 137 | assert "This failed" in log_output 138 | assert listener_was_called 139 | # Check that the active listeners are cleaned up. 140 | assert len(event_logger._active_listeners) == 0 141 | 142 | 143 | @pytest.mark.parametrize( 144 | # Make sure no schemas are added at the start of this test. 145 | "jp_event_schemas", 146 | [ 147 | # Empty events list. 148 | [] 149 | ], 150 | ) 151 | async def test_listener_added_before_schemas_passes(jp_event_logger, schema): 152 | # Ensure there are no schemas listed. 153 | assert len(jp_event_logger.schemas.schema_ids) == 0 154 | 155 | listener_was_called = False 156 | 157 | async def my_listener(logger: EventLogger, schema_id: str, data: dict) -> None: 158 | nonlocal listener_was_called 159 | listener_was_called = True 160 | 161 | # Add the listener without any schemas 162 | jp_event_logger.add_listener(schema_id=schema.id, listener=my_listener) 163 | 164 | # Proof that emitting the event won't success 165 | with pytest.warns(SchemaNotRegistered): 166 | jp_event_logger.emit(schema_id=schema.id, data={"prop": "hello, world"}) 167 | 168 | assert not listener_was_called 169 | 170 | # Now register the event and emit. 171 | jp_event_logger.register_event_schema(schema) 172 | 173 | # Try emitting the event again and ensure the listener saw it. 174 | jp_event_logger.emit(schema_id=schema.id, data={"prop": "hello, world"}) 175 | await jp_event_logger.gather_listeners() 176 | assert listener_was_called 177 | # Check that the active listeners are cleaned up. 178 | assert len(jp_event_logger._active_listeners) == 0 179 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling>=1.5"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "jupyter-events" 7 | description = "Jupyter Event System library" 8 | readme = "README.md" 9 | requires-python = ">=3.9" 10 | authors = [ 11 | { name = "Jupyter Development Team", email = "jupyter@googlegroups.com" }, 12 | ] 13 | keywords = [ 14 | "Jupyter", 15 | "JupyterLab", 16 | ] 17 | classifiers = [ 18 | "Intended Audience :: Developers", 19 | "Intended Audience :: Science/Research", 20 | "Intended Audience :: System Administrators", 21 | "License :: OSI Approved :: BSD License", 22 | "Programming Language :: Python", 23 | "Programming Language :: Python :: 3", 24 | ] 25 | dependencies = [ 26 | "referencing", 27 | "jsonschema[format-nongpl]>=4.18.0", 28 | "python-json-logger>=2.0.4", 29 | "pyyaml>=5.3", 30 | "traitlets>=5.3", 31 | "packaging", 32 | # The following are necessary to address an issue where pyproject.toml normalizes extra dependencies 33 | # such that 'format_nongpl' is normalized to 'format-nongpl' which prevents these two validators from 34 | # from being installed when jsonschema is <= 4.9 because jsonschema uses 'format_nongpl' in those releases. 35 | "rfc3339-validator", 36 | "rfc3986-validator>=0.1.1", 37 | ] 38 | dynamic = [ 39 | "version", 40 | ] 41 | 42 | [project.license] 43 | file = 'LICENSE' 44 | 45 | [project.urls] 46 | Homepage = "http://jupyter.org" 47 | documentation = "https://jupyter-events.readthedocs.io/" 48 | repository = "https://github.com/jupyter/jupyter_events.git" 49 | changelog = "https://github.com/jupyter/jupyter_events/blob/main/CHANGELOG.md" 50 | 51 | [project.scripts] 52 | jupyter-events = "jupyter_events.cli:main" 53 | 54 | [project.optional-dependencies] 55 | docs = [ 56 | "sphinx>=8", 57 | "jupyterlite-sphinx", 58 | "myst_parser", 59 | "pydata_sphinx_theme>=0.16", 60 | "sphinxcontrib-spelling", 61 | ] 62 | test = [ 63 | "pre-commit", 64 | "pytest-asyncio>=0.19.0", 65 | "pytest-console-scripts", 66 | "pytest>=7.0", 67 | # [cli] 68 | "click", 69 | "rich", 70 | ] 71 | cli = [ 72 | "click", 73 | "rich" 74 | ] 75 | 76 | [tool.hatch.version] 77 | path = "jupyter_events/_version.py" 78 | 79 | [tool.hatch.envs.docs] 80 | features = ["docs"] 81 | [tool.hatch.envs.docs.scripts] 82 | build = "make -C docs html SPHINXOPTS='-W'" 83 | 84 | [tool.hatch.envs.test] 85 | features = ["test"] 86 | [tool.hatch.envs.test.scripts] 87 | test = "python -m pytest -vv {args}" 88 | nowarn = "test -W default {args}" 89 | 90 | [tool.hatch.envs.cov] 91 | features = ["test"] 92 | dependencies = ["coverage[toml]", "pytest-cov"] 93 | [tool.hatch.envs.cov.scripts] 94 | test = "python -m pytest -vv --cov jupyter_events --cov-branch --cov-report term-missing:skip-covered {args}" 95 | nowarn = "test -W default {args}" 96 | 97 | [tool.hatch.envs.lint] 98 | detached = true 99 | dependencies = ["pre-commit"] 100 | [tool.hatch.envs.lint.scripts] 101 | build = [ 102 | "pre-commit run --all-files ruff", 103 | "pre-commit run --all-files ruff-format" 104 | ] 105 | 106 | [tool.hatch.envs.typing] 107 | dependencies = [ "pre-commit"] 108 | detached = true 109 | [tool.hatch.envs.typing.scripts] 110 | test = "pre-commit run --all-files --hook-stage manual mypy" 111 | 112 | 113 | [tool.pytest.ini_options] 114 | minversion = "6.0" 115 | xfail_strict = true 116 | log_cli_level = "info" 117 | addopts = [ 118 | "-ra", "--durations=10", "--color=yes", "--doctest-modules", 119 | "--showlocals", "--strict-markers", "--strict-config" 120 | ] 121 | testpaths = [ 122 | "tests/" 123 | ] 124 | asyncio_mode = "auto" 125 | script_launch_mode = "subprocess" 126 | filterwarnings= [ 127 | # Fail on warnings 128 | "error", 129 | # Upstream warnings from python-dateutil 130 | "module:datetime.datetime.utc:DeprecationWarning", 131 | # Ignore importwarning on pypy for yaml 132 | "module:can't resolve package from __spec__ or __package__:ImportWarning", 133 | "ignore::jupyter_events.utils.JupyterEventsVersionWarning", 134 | ] 135 | 136 | [tool.coverage.report] 137 | exclude_lines = [ 138 | "pragma: no cover", 139 | "def __repr__", 140 | "if self.debug:", 141 | "if settings.DEBUG", 142 | "raise AssertionError", 143 | "raise NotImplementedError", 144 | "if 0:", 145 | "if __name__ == .__main__.:", 146 | "class .*\bProtocol\\):", 147 | "@(abc\\.)?abstractmethod", 148 | ] 149 | 150 | [tool.coverage.run] 151 | relative_files = true 152 | source = ["jupyter_events"] 153 | 154 | [tool.mypy] 155 | files = "jupyter_events" 156 | python_version = "3.9" 157 | strict = true 158 | enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] 159 | warn_unreachable = true 160 | 161 | [tool.ruff] 162 | line-length = 100 163 | 164 | [tool.ruff.lint] 165 | extend-select = [ 166 | "B", # flake8-bugbear 167 | "I", # isort 168 | "ARG", # flake8-unused-arguments 169 | "C4", # flake8-comprehensions 170 | "EM", # flake8-errmsg 171 | "ICN", # flake8-import-conventions 172 | "G", # flake8-logging-format 173 | "PGH", # pygrep-hooks 174 | "PIE", # flake8-pie 175 | "PL", # pylint 176 | "PTH", # flake8-use-pathlib 177 | "PT", # flake8-pytest-style 178 | "RET", # flake8-return 179 | "RUF", # Ruff-specific 180 | "SIM", # flake8-simplify 181 | "T20", # flake8-print 182 | "UP", # pyupgrade 183 | "YTT", # flake8-2020 184 | "EXE", # flake8-executable 185 | "PYI", # flake8-pyi 186 | "S", # flake8-bandit 187 | "G001", # .format and co in logging methods 188 | ] 189 | ignore = [ 190 | "E501", # E501 Line too long (158 > 100 characters) 191 | "SIM105", # SIM105 Use `contextlib.suppress(...)` 192 | "PLR", # Design related pylint codes 193 | "S101", # Use of `assert` detected 194 | ] 195 | unfixable = [ 196 | # Don't touch print statements 197 | "T201", 198 | # Don't touch noqa lines 199 | "RUF100", 200 | ] 201 | isort.required-imports = ["from __future__ import annotations"] 202 | 203 | [tool.ruff.lint.per-file-ignores] 204 | # B011 Do not call assert False since python -O removes these calls 205 | # F841 local variable 'foo' is assigned to but never used 206 | # C408 Unnecessary `dict` call 207 | # E402 Module level import not at top of file 208 | # T201 `print` found 209 | # B007 Loop control variable `i` not used within the loop body. 210 | # N802 Function name `assertIn` should be lowercase 211 | # F841 Local variable `t` is assigned to but never used 212 | # S101 Use of `assert` detected 213 | "tests/*" = ["B011", "F841", "C408", "E402", "T201", "B007", "N802", "F841", "S101", "ARG", "PGH"] 214 | # C901 Function is too complex 215 | "jupyter_events/logger.py" = ["C901"] # `emit` is too complex (12 > 10) 216 | "docs/demo/demo-notebook.ipynb" = ["PLE1142", "E402", "T201"] 217 | 218 | [tool.ruff.lint.flake8-pytest-style] 219 | fixture-parentheses = false 220 | mark-parentheses = false 221 | parametrize-names-type = "csv" 222 | 223 | [tool.interrogate] 224 | ignore-init-module=true 225 | ignore-private=true 226 | ignore-semiprivate=true 227 | ignore-property-decorators=true 228 | ignore-nested-functions=true 229 | ignore-nested-classes=true 230 | fail-under=100 231 | exclude = ["docs", "tests"] 232 | -------------------------------------------------------------------------------- /docs/demo/demo-notebook.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "Install Jupyter Events from piplite." 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": null, 13 | "metadata": {}, 14 | "outputs": [], 15 | "source": [ 16 | "from __future__ import annotations\n", 17 | "\n", 18 | "import piplite\n", 19 | "\n", 20 | "await piplite.install(\"jupyter_events\")" 21 | ] 22 | }, 23 | { 24 | "cell_type": "markdown", 25 | "metadata": {}, 26 | "source": [ 27 | "The `EventLogger` is the main object in Jupyter Events." 28 | ] 29 | }, 30 | { 31 | "cell_type": "code", 32 | "execution_count": 1, 33 | "metadata": {}, 34 | "outputs": [], 35 | "source": [ 36 | "from jupyter_events.logger import EventLogger\n", 37 | "\n", 38 | "logger = EventLogger()" 39 | ] 40 | }, 41 | { 42 | "cell_type": "markdown", 43 | "metadata": {}, 44 | "source": [ 45 | "To begin emitting events from a Python application, you need to tell the `EventLogger` what events you'd like to emit. To do this, we should register our event's schema (more on this later) with the logger." 46 | ] 47 | }, 48 | { 49 | "cell_type": "code", 50 | "execution_count": null, 51 | "metadata": {}, 52 | "outputs": [ 53 | { 54 | "name": "stdout", 55 | "output_type": "stream", 56 | "text": [ 57 | "{\n", 58 | " \"$id\": \"http://myapplication.org/example-event\",\n", 59 | " \"version\": 1,\n", 60 | " \"title\": \"Example Event\",\n", 61 | " \"description\": \"An interesting event to collect\",\n", 62 | " \"properties\": {\n", 63 | " \"name\": {\n", 64 | " \"title\": \"Name of Event\",\n", 65 | " \"type\": \"string\"\n", 66 | " }\n", 67 | " }\n", 68 | "}\n" 69 | ] 70 | } 71 | ], 72 | "source": [ 73 | "schema = \"\"\"\n", 74 | "$id: http://myapplication.org/example-event\n", 75 | "version: \"1\"\n", 76 | "title: Example Event\n", 77 | "description: An interesting event to collect\n", 78 | "properties:\n", 79 | " name:\n", 80 | " title: Name of Event\n", 81 | " type: string\n", 82 | "\"\"\"\n", 83 | "\n", 84 | "\n", 85 | "logger.register_event_schema(schema)\n", 86 | "print(logger.schemas)" 87 | ] 88 | }, 89 | { 90 | "cell_type": "markdown", 91 | "metadata": {}, 92 | "source": [ 93 | "\n", 94 | "Now that the logger knows about the event, it needs to know _where_ to send it. To do this, we register a logging _Handler_ —borrowed from Python's standard [`logging`](https://docs.python.org/3/library/logging.html) library—to route the events to the proper place." 95 | ] 96 | }, 97 | { 98 | "cell_type": "code", 99 | "execution_count": 3, 100 | "metadata": {}, 101 | "outputs": [], 102 | "source": [ 103 | "# We will import one of the handlers from Python's logging library\n", 104 | "from logging import StreamHandler\n", 105 | "\n", 106 | "handler = StreamHandler()\n", 107 | "\n", 108 | "logger.register_handler(handler)" 109 | ] 110 | }, 111 | { 112 | "cell_type": "markdown", 113 | "metadata": {}, 114 | "source": [ 115 | "The logger knows about the event and where to send it; all that's left is to emit an instance of the event!" 116 | ] 117 | }, 118 | { 119 | "cell_type": "code", 120 | "execution_count": 4, 121 | "metadata": {}, 122 | "outputs": [ 123 | { 124 | "name": "stderr", 125 | "output_type": "stream", 126 | "text": [ 127 | "{\"__timestamp__\": \"2024-05-04T23:22:40.338884+00:00Z\", \"__schema__\": \"http://myapplication.org/example-event\", \"__schema_version__\": 1, \"__metadata_version__\": 1, \"name\": \"My Event\"}\n" 128 | ] 129 | }, 130 | { 131 | "data": { 132 | "text/plain": [ 133 | "{'__timestamp__': '2024-05-04T23:22:40.338884+00:00Z',\n", 134 | " '__schema__': 'http://myapplication.org/example-event',\n", 135 | " '__schema_version__': 1,\n", 136 | " '__metadata_version__': 1,\n", 137 | " 'name': 'My Event'}" 138 | ] 139 | }, 140 | "execution_count": 4, 141 | "metadata": {}, 142 | "output_type": "execute_result" 143 | } 144 | ], 145 | "source": [ 146 | "logger.emit(schema_id=\"http://myapplication.org/example-event\", data={\"name\": \"My Event\"})" 147 | ] 148 | }, 149 | { 150 | "cell_type": "markdown", 151 | "metadata": {}, 152 | "source": [ 153 | "Now, let's demo adding a listener to the already registered event." 154 | ] 155 | }, 156 | { 157 | "cell_type": "code", 158 | "execution_count": 6, 159 | "metadata": {}, 160 | "outputs": [], 161 | "source": [ 162 | "async def my_listener(logger: EventLogger, schema_id: str, data: dict) -> None:\n", 163 | " print(\"hello, from my_listener\")\n", 164 | " print(logger)\n", 165 | " print(schema_id)\n", 166 | " print(data)\n", 167 | "\n", 168 | "\n", 169 | "logger.add_listener(schema_id=\"http://myapplication.org/example-event\", listener=my_listener)" 170 | ] 171 | }, 172 | { 173 | "cell_type": "markdown", 174 | "metadata": {}, 175 | "source": [ 176 | "If we emit the event again, you'll see our listener \"sees\" the event and executes some code:" 177 | ] 178 | }, 179 | { 180 | "cell_type": "code", 181 | "execution_count": 7, 182 | "metadata": {}, 183 | "outputs": [ 184 | { 185 | "name": "stderr", 186 | "output_type": "stream", 187 | "text": [ 188 | "{\"__timestamp__\": \"2024-05-04T23:22:40.400243+00:00Z\", \"__schema__\": \"http://myapplication.org/example-event\", \"__schema_version__\": 1, \"__metadata_version__\": 1, \"name\": \"My Event\"}\n" 189 | ] 190 | }, 191 | { 192 | "data": { 193 | "text/plain": [ 194 | "{'__timestamp__': '2024-05-04T23:22:40.400243+00:00Z',\n", 195 | " '__schema__': 'http://myapplication.org/example-event',\n", 196 | " '__schema_version__': 1,\n", 197 | " '__metadata_version__': 1,\n", 198 | " 'name': 'My Event'}" 199 | ] 200 | }, 201 | "execution_count": 7, 202 | "metadata": {}, 203 | "output_type": "execute_result" 204 | }, 205 | { 206 | "name": "stdout", 207 | "output_type": "stream", 208 | "text": [ 209 | "hello, from my_listener\n", 210 | "\n", 211 | "http://myapplication.org/example-event\n", 212 | "{'name': 'My Event'}\n" 213 | ] 214 | } 215 | ], 216 | "source": [ 217 | "logger.emit(schema_id=\"http://myapplication.org/example-event\", data={\"name\": \"My Event\"})" 218 | ] 219 | }, 220 | { 221 | "cell_type": "code", 222 | "execution_count": null, 223 | "metadata": {}, 224 | "outputs": [], 225 | "source": [] 226 | } 227 | ], 228 | "metadata": { 229 | "kernelspec": { 230 | "display_name": "Python 3.8.10 ('jupyter_events')", 231 | "language": "python", 232 | "name": "python3" 233 | }, 234 | "language_info": { 235 | "codemirror_mode": { 236 | "name": "ipython", 237 | "version": 3 238 | }, 239 | "file_extension": ".py", 240 | "mimetype": "text/x-python", 241 | "name": "python", 242 | "nbconvert_exporter": "python", 243 | "pygments_lexer": "ipython3", 244 | "version": "3.8.10" 245 | }, 246 | "orig_nbformat": 4, 247 | "vscode": { 248 | "interpreter": { 249 | "hash": "fa70b7d208e0e2ef401b5613e3a2c366a3ff98da2f39442a36f3be51bccaa21d" 250 | } 251 | } 252 | }, 253 | "nbformat": 4, 254 | "nbformat_minor": 2 255 | } 256 | -------------------------------------------------------------------------------- /tests/test_logger.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import io 4 | import json 5 | import logging 6 | from datetime import datetime, timedelta, timezone 7 | from unittest.mock import MagicMock 8 | 9 | import jsonschema 10 | import pytest 11 | from jsonschema.exceptions import ValidationError 12 | from traitlets import TraitError 13 | from traitlets.config.loader import PyFileConfigLoader 14 | 15 | from jupyter_events import yaml 16 | from jupyter_events.logger import EventLogger 17 | from jupyter_events.schema_registry import SchemaRegistryException 18 | 19 | GOOD_CONFIG = """ 20 | import logging 21 | 22 | c.EventLogger.handlers = [ 23 | logging.StreamHandler() 24 | ] 25 | """ 26 | 27 | BAD_CONFIG = """ 28 | import logging 29 | 30 | c.EventLogger.handlers = [ 31 | 0 32 | ] 33 | """ 34 | 35 | 36 | def get_config_from_file(path, content): 37 | # Write config file 38 | filename = "config.py" 39 | config_file = path / filename 40 | config_file.write_text(content) 41 | 42 | # Load written file. 43 | loader = PyFileConfigLoader(filename, path=str(path)) 44 | return loader.load_config() 45 | 46 | 47 | def test_good_config_file(tmp_path): 48 | cfg = get_config_from_file(tmp_path, GOOD_CONFIG) 49 | 50 | # Pass config to EventLogger 51 | e = EventLogger(config=cfg) 52 | 53 | assert len(e.handlers) > 0 54 | assert isinstance(e.handlers[0], logging.Handler) 55 | 56 | 57 | def test_bad_config_file(tmp_path): 58 | cfg = get_config_from_file(tmp_path, BAD_CONFIG) 59 | 60 | with pytest.raises(TraitError): 61 | EventLogger(config=cfg) 62 | 63 | 64 | def test_register_invalid_schema(): 65 | """ 66 | Invalid JSON Schemas should fail registration 67 | """ 68 | el = EventLogger() 69 | with pytest.raises(ValidationError): 70 | el.register_event_schema( 71 | { 72 | # Totally invalid 73 | "properties": True 74 | } 75 | ) 76 | 77 | 78 | def test_missing_required_properties(): 79 | """ 80 | id and $version are required properties in our schemas. 81 | 82 | They aren't required by JSON Schema itself 83 | """ 84 | el = EventLogger() 85 | with pytest.raises(ValidationError): 86 | el.register_event_schema({"properties": {}}) 87 | 88 | with pytest.raises(ValidationError): 89 | el.register_event_schema( 90 | { 91 | "$id": "something", 92 | "$version": 1, # This should been 'version' 93 | } 94 | ) 95 | 96 | 97 | def test_timestamp_override(): 98 | """ 99 | Simple test for overriding timestamp 100 | """ 101 | schema = { 102 | "$id": "http://test/test", 103 | "version": "1", 104 | "properties": { 105 | "something": { 106 | "type": "string", 107 | "title": "test", 108 | }, 109 | }, 110 | } 111 | 112 | output = io.StringIO() 113 | handler = logging.StreamHandler(output) 114 | el = EventLogger(handlers=[handler]) 115 | el.register_event_schema(schema) 116 | 117 | timestamp_override = datetime.now(tz=timezone.utc) - timedelta(days=1) 118 | 119 | el.emit( 120 | schema_id="http://test/test", 121 | data={"something": "blah"}, 122 | timestamp_override=timestamp_override, 123 | ) 124 | handler.flush() 125 | event_capsule = json.loads(output.getvalue()) 126 | event_capsule.pop("taskName", None) 127 | assert event_capsule["__timestamp__"] == timestamp_override.isoformat() + "Z" 128 | 129 | 130 | def test_emit(): 131 | """ 132 | Simple test for emitting valid events 133 | """ 134 | schema = { 135 | "$id": "http://test/test", 136 | "version": "1", 137 | "properties": { 138 | "something": { 139 | "type": "string", 140 | "title": "test", 141 | } 142 | }, 143 | } 144 | 145 | output = io.StringIO() 146 | handler = logging.StreamHandler(output) 147 | el = EventLogger(handlers=[handler]) 148 | el.register_event_schema(schema) 149 | 150 | el.emit( 151 | schema_id="http://test/test", 152 | data={ 153 | "something": "blah", 154 | }, 155 | ) 156 | handler.flush() 157 | 158 | event_capsule = json.loads(output.getvalue()) 159 | event_capsule.pop("taskName", None) 160 | 161 | assert "__timestamp__" in event_capsule 162 | # Remove timestamp from capsule when checking equality, since it is gonna vary 163 | del event_capsule["__timestamp__"] 164 | expected = { 165 | "__schema__": "http://test/test", 166 | "__schema_version__": "1", 167 | "__metadata_version__": 1, 168 | "something": "blah", 169 | } 170 | assert event_capsule == expected 171 | 172 | 173 | def test_message_field(): 174 | """ 175 | Simple test for emitting an event with 176 | the literal property "message". 177 | """ 178 | schema = { 179 | "$id": "http://test/test", 180 | "version": "1", 181 | "properties": { 182 | "something": { 183 | "type": "string", 184 | "title": "test", 185 | }, 186 | "message": { 187 | "type": "string", 188 | "title": "test", 189 | }, 190 | }, 191 | } 192 | 193 | output = io.StringIO() 194 | handler = logging.StreamHandler(output) 195 | el = EventLogger(handlers=[handler]) 196 | el.register_event_schema(schema) 197 | 198 | el.emit( 199 | schema_id="http://test/test", 200 | data={"something": "blah", "message": "a message was seen"}, 201 | ) 202 | handler.flush() 203 | 204 | event_capsule = json.loads(output.getvalue()) 205 | event_capsule.pop("taskName", None) 206 | 207 | assert "__timestamp__" in event_capsule 208 | # Remove timestamp from capsule when checking equality, since it is gonna vary 209 | del event_capsule["__timestamp__"] 210 | expected = { 211 | "__schema__": "http://test/test", 212 | "__schema_version__": "1", 213 | "__metadata_version__": 1, 214 | "something": "blah", 215 | "message": "a message was seen", 216 | } 217 | assert event_capsule == expected 218 | 219 | 220 | def test_nested_message_field(): 221 | """ 222 | Simple test for emitting an event with 223 | the literal property "message". 224 | """ 225 | schema = { 226 | "$id": "http://test/test", 227 | "version": "1", 228 | "properties": { 229 | "thing": { 230 | "type": "object", 231 | "title": "thing", 232 | "properties": { 233 | "message": { 234 | "type": "string", 235 | "title": "message", 236 | }, 237 | }, 238 | }, 239 | }, 240 | } 241 | 242 | output = io.StringIO() 243 | handler = logging.StreamHandler(output) 244 | el = EventLogger(handlers=[handler]) 245 | el.register_event_schema(schema) 246 | 247 | el.emit( 248 | schema_id="http://test/test", 249 | data={"thing": {"message": "a nested message was seen"}}, 250 | ) 251 | handler.flush() 252 | 253 | event_capsule = json.loads(output.getvalue()) 254 | event_capsule.pop("taskName", None) 255 | 256 | assert "__timestamp__" in event_capsule 257 | # Remove timestamp from capsule when checking equality, since it is gonna vary 258 | del event_capsule["__timestamp__"] 259 | expected = { 260 | "__schema__": "http://test/test", 261 | "__schema_version__": "1", 262 | "__metadata_version__": 1, 263 | "thing": {"message": "a nested message was seen"}, 264 | } 265 | assert event_capsule == expected 266 | 267 | 268 | def test_register_event_schema(tmp_path): 269 | """ 270 | Register schema from a file 271 | """ 272 | schema = { 273 | "$id": "http://test/test", 274 | "version": "1", 275 | "type": "object", 276 | "properties": { 277 | "something": { 278 | "type": "string", 279 | "title": "test", 280 | }, 281 | }, 282 | } 283 | 284 | el = EventLogger() 285 | schema_file = tmp_path.joinpath("schema.yml") 286 | yaml.dump(schema, schema_file) 287 | el.register_event_schema(schema_file) 288 | assert "http://test/test" in el.schemas 289 | 290 | 291 | def test_register_event_schema_object(tmp_path): 292 | """ 293 | Register schema from a file 294 | """ 295 | schema = { 296 | "$id": "http://test/test", 297 | "version": "1", 298 | "type": "object", 299 | "properties": { 300 | "something": { 301 | "type": "string", 302 | "title": "test", 303 | }, 304 | }, 305 | } 306 | 307 | el = EventLogger() 308 | schema_file = tmp_path.joinpath("schema.yml") 309 | yaml.dump(schema, schema_file) 310 | el.register_event_schema(schema_file) 311 | 312 | assert "http://test/test" in el.schemas 313 | 314 | 315 | def test_emit_badschema(): 316 | """ 317 | Fail fast when an event doesn't conform to its schema 318 | """ 319 | schema = { 320 | "$id": "http://test/test", 321 | "version": "1", 322 | "type": "object", 323 | "properties": { 324 | "something": { 325 | "type": "string", 326 | "title": "test", 327 | }, 328 | "status": { 329 | "enum": ["success", "failure"], 330 | "title": "test 2", 331 | }, 332 | }, 333 | } 334 | 335 | el = EventLogger(handlers=[logging.NullHandler()]) 336 | el.register_event_schema(schema) 337 | 338 | with pytest.raises(jsonschema.ValidationError) as excinfo: 339 | el.emit(schema_id="http://test/test", data={"something": "blah", "status": "hi"}) 340 | 341 | assert "'hi' is not one of" in str(excinfo.value) 342 | 343 | 344 | def test_emit_badschema_format(): 345 | """ 346 | Fail fast when an event doesn't conform to a specific format 347 | """ 348 | schema = { 349 | "$id": "http://test/test", 350 | "version": "1", 351 | "type": "object", 352 | "properties": { 353 | "something": {"type": "string", "title": "test", "format": "date-time"}, 354 | }, 355 | } 356 | 357 | el = EventLogger(handlers=[logging.NullHandler()]) 358 | el.register_event_schema(schema) 359 | 360 | with pytest.raises(jsonschema.ValidationError) as excinfo: 361 | el.emit(schema_id="http://test/test", data={"something": "chucknorris"}) 362 | 363 | assert "'chucknorris' is not a 'date-time'" in str(excinfo.value) 364 | 365 | 366 | def test_unique_logger_instances(): 367 | schema0 = { 368 | "$id": "http://test/test0", 369 | "version": "1", 370 | "type": "object", 371 | "properties": { 372 | "something": { 373 | "type": "string", 374 | "title": "test", 375 | }, 376 | }, 377 | } 378 | 379 | schema1 = { 380 | "$id": "http://test/test1", 381 | "version": "1", 382 | "type": "object", 383 | "properties": { 384 | "something": { 385 | "type": "string", 386 | "title": "test", 387 | }, 388 | }, 389 | } 390 | 391 | output0 = io.StringIO() 392 | output1 = io.StringIO() 393 | handler0 = logging.StreamHandler(output0) 394 | handler1 = logging.StreamHandler(output1) 395 | 396 | el0 = EventLogger(handlers=[handler0]) 397 | el0.register_event_schema(schema0) 398 | 399 | el1 = EventLogger(handlers=[handler1]) 400 | el1.register_event_schema(schema1) 401 | 402 | el0.emit( 403 | schema_id="http://test/test0", 404 | data={ 405 | "something": "blah", 406 | }, 407 | ) 408 | el1.emit( 409 | schema_id="http://test/test1", 410 | data={ 411 | "something": "blah", 412 | }, 413 | ) 414 | handler0.flush() 415 | handler1.flush() 416 | 417 | event_capsule0 = json.loads(output0.getvalue()) 418 | event_capsule0.pop("taskName", None) 419 | 420 | assert "__timestamp__" in event_capsule0 421 | # Remove timestamp from capsule when checking equality, since it is gonna vary 422 | del event_capsule0["__timestamp__"] 423 | expected = { 424 | "__schema__": "http://test/test0", 425 | "__schema_version__": "1", 426 | "__metadata_version__": 1, 427 | "something": "blah", 428 | } 429 | assert event_capsule0 == expected 430 | 431 | event_capsule1 = json.loads(output1.getvalue()) 432 | event_capsule1.pop("taskName", None) 433 | 434 | assert "__timestamp__" in event_capsule1 435 | # Remove timestamp from capsule when checking equality, since it is gonna vary 436 | del event_capsule1["__timestamp__"] 437 | expected = { 438 | "__schema__": "http://test/test1", 439 | "__schema_version__": "1", 440 | "__metadata_version__": 1, 441 | "something": "blah", 442 | } 443 | assert event_capsule1 == expected 444 | 445 | 446 | def test_register_duplicate_schemas(): 447 | schema0 = { 448 | "$id": "http://test/test", 449 | "version": "1", 450 | "type": "object", 451 | "properties": { 452 | "something": { 453 | "type": "string", 454 | "title": "test", 455 | }, 456 | }, 457 | } 458 | 459 | schema1 = { 460 | "$id": "http://test/test", 461 | "version": "1", 462 | "type": "object", 463 | "properties": { 464 | "something": { 465 | "type": "string", 466 | "title": "test", 467 | }, 468 | }, 469 | } 470 | 471 | el = EventLogger() 472 | el.register_event_schema(schema0) 473 | with pytest.raises(SchemaRegistryException): 474 | el.register_event_schema(schema1) 475 | 476 | 477 | async def test_noop_emit(): 478 | """Tests that the emit method returns 479 | immediately if no handlers are listeners 480 | are mapped to the incoming event. This 481 | is important for performance. 482 | """ 483 | el = EventLogger() 484 | # The `emit` method calls `validate_event` if 485 | # it doesn't return immediately. We'll use the 486 | # MagicMock here to see if/when this method is called 487 | # to ensure `emit` is returning when it should. 488 | el.schemas.validate_event = MagicMock(name="validate_event") # type:ignore[method-assign] 489 | 490 | schema_id1 = "http://test/test" 491 | schema1 = { 492 | "$id": schema_id1, 493 | "version": "1", 494 | "type": "object", 495 | "properties": { 496 | "something": { 497 | "type": "string", 498 | "title": "test", 499 | }, 500 | }, 501 | } 502 | schema_id2 = "http://test/test2" 503 | schema2 = { 504 | "$id": schema_id2, 505 | "version": "1", 506 | "type": "object", 507 | "properties": { 508 | "something_elss": { 509 | "type": "string", 510 | "title": "test", 511 | }, 512 | }, 513 | } 514 | el.register_event_schema(schema1) 515 | el.register_event_schema(schema2) 516 | 517 | # No handlers or listeners are registered 518 | # So the validate_event method should not 519 | # be called. 520 | el.emit(schema_id=schema_id1, data={"something": "hello"}) 521 | 522 | el.schemas.validate_event.assert_not_called() 523 | 524 | # Register a handler and check that .emit 525 | # validates the method. 526 | handler = logging.StreamHandler() 527 | el.register_handler(handler) 528 | 529 | el.emit(schema_id=schema_id1, data={"something": "hello"}) 530 | 531 | el.schemas.validate_event.assert_called_once() 532 | 533 | # Reset 534 | el.remove_handler(handler) 535 | el.schemas.validate_event.reset_mock() 536 | assert el.schemas.validate_event.call_count == 0 537 | 538 | # Create a listener and check that emit works 539 | 540 | async def listener(logger: EventLogger, schema_id: str, data: dict) -> None: 541 | return None 542 | 543 | el.add_listener(schema_id=schema_id1, listener=listener) 544 | 545 | el.emit(schema_id=schema_id1, data={"something": "hello"}) 546 | 547 | el.schemas.validate_event.assert_called_once() 548 | el.schemas.validate_event.reset_mock() 549 | assert el.schemas.validate_event.call_count == 0 550 | 551 | # Emit a different event with no listeners or 552 | # handlers and make sure it returns immediately. 553 | el.emit(schema_id=schema_id2, data={"something_else": "hello again"}) 554 | el.schemas.validate_event.assert_not_called() 555 | -------------------------------------------------------------------------------- /jupyter_events/logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | Emit structured, discrete events when various actions happen. 3 | """ 4 | from __future__ import annotations 5 | 6 | import asyncio 7 | import copy 8 | import json 9 | import logging 10 | import typing as t 11 | import warnings 12 | from datetime import datetime, timezone 13 | from importlib.metadata import version 14 | 15 | from jsonschema import ValidationError 16 | from packaging.version import parse 17 | from traitlets import Dict, Instance, Set, default 18 | from traitlets.config import Config, LoggingConfigurable 19 | 20 | from .schema import SchemaType 21 | from .schema_registry import SchemaRegistry 22 | from .traits import Handlers 23 | from .validators import JUPYTER_EVENTS_CORE_VALIDATOR 24 | 25 | # Check if the version is greater than 3.1.0 26 | version_info = version("python-json-logger") 27 | if parse(version_info) >= parse("3.1.0"): 28 | from pythonjsonlogger.json import JsonFormatter 29 | else: 30 | from pythonjsonlogger.jsonlogger import JsonFormatter # type: ignore[attr-defined] 31 | 32 | # Increment this version when the metadata included with each event 33 | # changes. 34 | EVENTS_METADATA_VERSION = 1 35 | 36 | 37 | class SchemaNotRegistered(Warning): 38 | """A warning to raise when an event is given to the logger 39 | but its schema has not be registered with the EventLogger 40 | """ 41 | 42 | 43 | class ModifierError(Exception): 44 | """An exception to raise when a modifier does not 45 | show the proper signature. 46 | """ 47 | 48 | 49 | class CoreMetadataError(Exception): 50 | """An exception raised when event core metadata is not valid.""" 51 | 52 | 53 | # Only show this warning on the first instance 54 | # of each event type that fails to emit. 55 | warnings.simplefilter("once", SchemaNotRegistered) 56 | 57 | 58 | class ListenerError(Exception): 59 | """An exception to raise when a listener does not 60 | show the proper signature. 61 | """ 62 | 63 | 64 | class EventLogger(LoggingConfigurable): 65 | """ 66 | An Event logger for emitting structured events. 67 | 68 | Event schemas must be registered with the 69 | EventLogger using the `register_schema` or 70 | `register_schema_file` methods. Every schema 71 | will be validated against Jupyter Event's metaschema. 72 | """ 73 | 74 | handlers = Handlers( 75 | default_value=None, 76 | allow_none=True, 77 | help="""A list of logging.Handler instances to send events to. 78 | 79 | When set to None (the default), all events are discarded. 80 | """, 81 | ).tag(config=True) 82 | 83 | schemas = Instance( 84 | SchemaRegistry, 85 | help="""The SchemaRegistry for caching validated schemas 86 | and their jsonschema validators. 87 | """, 88 | ) 89 | 90 | _modifiers = Dict({}, help="A mapping of schemas to their list of modifiers.") 91 | 92 | _modified_listeners = Dict({}, help="A mapping of schemas to the listeners of modified events.") 93 | 94 | _unmodified_listeners = Dict( 95 | {}, help="A mapping of schemas to the listeners of unmodified/raw events." 96 | ) 97 | 98 | _active_listeners: set[asyncio.Task[t.Any]] = Set() # type:ignore[assignment] 99 | 100 | async def gather_listeners(self) -> list[t.Any]: 101 | """Gather all of the active listeners.""" 102 | return await asyncio.gather(*self._active_listeners, return_exceptions=True) 103 | 104 | @default("schemas") 105 | def _default_schemas(self) -> SchemaRegistry: 106 | return SchemaRegistry() 107 | 108 | def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: 109 | """Initialize the logger.""" 110 | # We need to initialize the configurable before 111 | # adding the logging handlers. 112 | super().__init__(*args, **kwargs) 113 | # Use a unique name for the logger so that multiple instances of EventLog do not write 114 | # to each other's handlers. 115 | log_name = __name__ + "." + str(id(self)) 116 | self._logger = logging.getLogger(log_name) 117 | # We don't want events to show up in the default logs 118 | self._logger.propagate = False 119 | # We will use log.info to emit 120 | self._logger.setLevel(logging.INFO) 121 | # Add each handler to the logger and format the handlers. 122 | if self.handlers: 123 | for handler in self.handlers: 124 | self.register_handler(handler) 125 | 126 | def _load_config( 127 | self, 128 | cfg: Config, 129 | section_names: list[str] | None = None, # noqa: ARG002 130 | traits: list[str] | None = None, # type:ignore[override] # noqa: ARG002 131 | ) -> None: 132 | """Load EventLogger traits from a Config object, patching the 133 | handlers trait in the Config object to avoid deepcopy errors. 134 | """ 135 | my_cfg = self._find_my_config(cfg) 136 | handlers: list[logging.Handler] = my_cfg.pop("handlers", []) 137 | 138 | # Turn handlers list into a pickeable function 139 | def get_handlers() -> list[logging.Handler]: 140 | return handlers 141 | 142 | my_cfg["handlers"] = get_handlers 143 | 144 | # Build a new eventlog config object. 145 | eventlogger_cfg = Config({"EventLogger": my_cfg}) 146 | super()._load_config(eventlogger_cfg, section_names=None, traits=None) 147 | 148 | def register_event_schema(self, schema: SchemaType) -> None: 149 | """Register this schema with the schema registry. 150 | 151 | Get this registered schema using the EventLogger.schema.get() method. 152 | """ 153 | event_schema = self.schemas.register(schema) # type:ignore[arg-type] 154 | key = event_schema.id 155 | # It's possible that listeners and modifiers have been added for this 156 | # schema before the schema is registered. 157 | if key not in self._modifiers: 158 | self._modifiers[key] = set() 159 | if key not in self._modified_listeners: 160 | self._modified_listeners[key] = set() 161 | if key not in self._unmodified_listeners: 162 | self._unmodified_listeners[key] = set() 163 | 164 | def register_handler(self, handler: logging.Handler) -> None: 165 | """Register a new logging handler to the Event Logger. 166 | 167 | All outgoing messages will be formatted as a JSON string. 168 | """ 169 | 170 | def _handle_message_field(record: t.Any, **kwargs: t.Any) -> str: 171 | """Python's logger always emits the "message" field with 172 | the value as "null" unless it's present in the schema/data. 173 | Message happens to be a common field for event logs, 174 | so special case it here and only emit it if "message" 175 | is found the in the schema's property list. 176 | """ 177 | schema = self.schemas.get(record["__schema__"]) 178 | if "message" not in schema.properties: 179 | del record["message"] 180 | return json.dumps(record, **kwargs) 181 | 182 | formatter = JsonFormatter( 183 | json_serializer=_handle_message_field, 184 | ) 185 | handler.setFormatter(formatter) 186 | self._logger.addHandler(handler) 187 | if handler not in self.handlers: 188 | self.handlers.append(handler) 189 | 190 | def remove_handler(self, handler: logging.Handler) -> None: 191 | """Remove a logging handler from the logger and list of handlers.""" 192 | self._logger.removeHandler(handler) 193 | if handler in self.handlers: 194 | self.handlers.remove(handler) 195 | 196 | def add_modifier( 197 | self, 198 | *, 199 | schema_id: str | None = None, 200 | modifier: t.Callable[[str, dict[str, t.Any]], dict[str, t.Any]], 201 | ) -> None: 202 | """Add a modifier (callable) to a registered event. 203 | 204 | Parameters 205 | ---------- 206 | modifier: Callable 207 | A callable function/method that executes when the named event occurs. 208 | This method enforces a string signature for modifiers: 209 | 210 | (schema_id: str, data: dict) -> dict: 211 | """ 212 | # Ensure that this is a callable function/method 213 | if not callable(modifier): 214 | msg = "`modifier` must be a callable" # type:ignore[unreachable] 215 | raise TypeError(msg) 216 | 217 | # If the schema ID and version is given, only add 218 | # this modifier to that schema 219 | if schema_id: 220 | # If the schema hasn't been added yet, 221 | # start a placeholder set. 222 | modifiers = self._modifiers.get(schema_id, set()) 223 | modifiers.add(modifier) 224 | self._modifiers[schema_id] = modifiers 225 | return 226 | for id_ in self._modifiers: 227 | if schema_id is None or id_ == schema_id: 228 | self._modifiers[id_].add(modifier) 229 | 230 | def remove_modifier( 231 | self, 232 | *, 233 | schema_id: str | None = None, 234 | modifier: t.Callable[[str, dict[str, t.Any]], dict[str, t.Any]], 235 | ) -> None: 236 | """Remove a modifier from an event or all events. 237 | 238 | Parameters 239 | ---------- 240 | schema_id: str 241 | If given, remove this modifier only for a specific event type. 242 | modifier: Callable[[str, dict], dict] 243 | 244 | The modifier to remove. 245 | """ 246 | # If schema_id is given remove the modifier from this schema. 247 | if schema_id: 248 | self._modifiers[schema_id].discard(modifier) 249 | # If no schema_id is given, remove the modifier from all events. 250 | else: 251 | for schema_id in self.schemas.schema_ids: 252 | # Remove the modifier if it is found in the list. 253 | self._modifiers[schema_id].discard(modifier) 254 | self._modifiers[schema_id].discard(modifier) 255 | 256 | def add_listener( 257 | self, 258 | *, 259 | modified: bool = True, 260 | schema_id: str | None = None, 261 | listener: t.Callable[[EventLogger, str, dict[str, t.Any]], t.Coroutine[t.Any, t.Any, None]], 262 | ) -> None: 263 | """Add a listener (callable) to a registered event. 264 | 265 | Parameters 266 | ---------- 267 | modified: bool 268 | If True (default), listens to the data after it has been mutated/modified 269 | by the list of modifiers. 270 | schema_id: str 271 | $id of the schema 272 | listener: Callable 273 | A callable function/method that executes when the named event occurs. 274 | """ 275 | if not callable(listener): 276 | msg = "`listener` must be a callable" # type:ignore[unreachable] 277 | raise TypeError(msg) 278 | 279 | # If the schema ID and version is given, only add 280 | # this modifier to that schema 281 | if schema_id: 282 | if modified: 283 | # If the schema hasn't been added yet, 284 | # start a placeholder set. 285 | listeners = self._modified_listeners.get(schema_id, set()) 286 | listeners.add(listener) 287 | self._modified_listeners[schema_id] = listeners 288 | return 289 | listeners = self._unmodified_listeners.get(schema_id, set()) 290 | listeners.add(listener) 291 | self._unmodified_listeners[schema_id] = listeners 292 | return 293 | for id_ in self.schemas.schema_ids: 294 | if schema_id is None or id_ == schema_id: 295 | if modified: 296 | self._modified_listeners[id_].add(listener) 297 | else: 298 | self._unmodified_listeners[id_].add(listener) 299 | 300 | def remove_listener( 301 | self, 302 | *, 303 | schema_id: str | None = None, 304 | listener: t.Callable[[EventLogger, str, dict[str, t.Any]], t.Coroutine[t.Any, t.Any, None]], 305 | ) -> None: 306 | """Remove a listener from an event or all events. 307 | 308 | Parameters 309 | ---------- 310 | schema_id: str 311 | If given, remove this modifier only for a specific event type. 312 | 313 | listener: Callable[[EventLogger, str, dict], dict] 314 | The modifier to remove. 315 | """ 316 | # If schema_id is given remove the listener from this schema. 317 | if schema_id: 318 | self._modified_listeners[schema_id].discard(listener) 319 | self._unmodified_listeners[schema_id].discard(listener) 320 | # If no schema_id is given, remove the listener from all events. 321 | else: 322 | for schema_id in self.schemas.schema_ids: 323 | # Remove the listener if it is found in the list. 324 | self._modified_listeners[schema_id].discard(listener) 325 | self._unmodified_listeners[schema_id].discard(listener) 326 | 327 | def emit( 328 | self, *, schema_id: str, data: dict[str, t.Any], timestamp_override: datetime | None = None 329 | ) -> dict[str, t.Any] | None: 330 | """ 331 | Record given event with schema has occurred. 332 | 333 | Parameters 334 | ---------- 335 | schema_id: str 336 | $id of the schema 337 | data: dict 338 | The event to record 339 | timestamp_override: datetime, optional 340 | Optionally override the event timestamp. By default it is set to the current timestamp. 341 | 342 | Returns 343 | ------- 344 | dict 345 | The recorded event data 346 | """ 347 | # If no handlers are routing these events, there's no need to proceed. 348 | if ( 349 | not self.handlers 350 | and not self._modified_listeners.get(schema_id) 351 | and not self._unmodified_listeners.get(schema_id) 352 | ): 353 | return None 354 | 355 | # If the schema hasn't been registered, raise a warning to make sure 356 | # this was intended. 357 | if schema_id not in self.schemas: 358 | warnings.warn( 359 | f"{schema_id} has not been registered yet. If " 360 | "this was not intentional, please register the schema using the " 361 | "`register_event_schema` method.", 362 | SchemaNotRegistered, 363 | stacklevel=2, 364 | ) 365 | return None 366 | 367 | schema = self.schemas.get(schema_id) 368 | 369 | # Deep copy the data and modify the copy. 370 | modified_data = copy.deepcopy(data) 371 | for modifier in self._modifiers[schema.id]: 372 | modified_data = modifier(schema_id=schema_id, data=modified_data) 373 | 374 | if self._unmodified_listeners[schema.id]: 375 | # Process this event, i.e. validate and modify (in place) 376 | self.schemas.validate_event(schema_id, data) 377 | 378 | # Validate the modified data. 379 | self.schemas.validate_event(schema_id, modified_data) 380 | 381 | # Generate the empty event capsule. 382 | timestamp = ( 383 | datetime.now(tz=timezone.utc) if timestamp_override is None else timestamp_override 384 | ) 385 | capsule = { 386 | "__timestamp__": timestamp.isoformat() + "Z", 387 | "__schema__": schema_id, 388 | "__schema_version__": schema.version, 389 | "__metadata_version__": EVENTS_METADATA_VERSION, 390 | } 391 | try: 392 | JUPYTER_EVENTS_CORE_VALIDATOR.validate(capsule) 393 | except ValidationError as err: 394 | raise CoreMetadataError from err 395 | 396 | capsule.update(modified_data) 397 | 398 | self._logger.info(capsule) 399 | 400 | # callback for removing from finished listeners 401 | # from active listeners set. 402 | def _listener_task_done(task: asyncio.Task[t.Any]) -> None: 403 | # If an exception happens, log it to the main 404 | # applications logger 405 | err = task.exception() 406 | if err: 407 | self.log.error(err) 408 | self._active_listeners.discard(task) 409 | 410 | # Loop over listeners and execute them. 411 | for listener in self._modified_listeners[schema_id]: 412 | # Schedule this listener as a task and add 413 | # it to the list of active listeners 414 | task = asyncio.create_task( 415 | listener( 416 | logger=self, 417 | schema_id=schema_id, 418 | data=modified_data, 419 | ) 420 | ) 421 | self._active_listeners.add(task) 422 | 423 | # Adds the task and cleans it up later if needed. 424 | task.add_done_callback(_listener_task_done) 425 | 426 | for listener in self._unmodified_listeners[schema_id]: 427 | task = asyncio.create_task(listener(logger=self, schema_id=schema_id, data=data)) 428 | self._active_listeners.add(task) 429 | 430 | # Remove task from active listeners once its finished. 431 | def _listener_task_done(task: asyncio.Task[t.Any]) -> None: 432 | # If an exception happens, log it to the main 433 | # applications logger 434 | err = task.exception() 435 | if err: 436 | self.log.error(err) 437 | self._active_listeners.discard(task) 438 | 439 | # Adds the task and cleans it up later if needed. 440 | task.add_done_callback(_listener_task_done) 441 | 442 | return capsule 443 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | 6 | 7 | ## 0.12.0 8 | 9 | ([Full Changelog](https://github.com/jupyter/jupyter_events/compare/v0.11.0...6704ea630522f44542d83608f750da0068e41443)) 10 | 11 | ### Bugs fixed 12 | 13 | - pop taskName for older version of python-json-logger [#110](https://github.com/jupyter/jupyter_events/pull/110) ([@Carreau](https://github.com/Carreau)) 14 | 15 | ### Maintenance and upkeep improvements 16 | 17 | - declare dependency on packaging [#109](https://github.com/jupyter/jupyter_events/pull/109) ([@bollwyvl](https://github.com/bollwyvl)) 18 | 19 | ### Contributors to this release 20 | 21 | ([GitHub contributors page for this release](https://github.com/jupyter/jupyter_events/graphs/contributors?from=2024-12-17&to=2025-02-03&type=c)) 22 | 23 | [@bollwyvl](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Abollwyvl+updated%3A2024-12-17..2025-02-03&type=Issues) | [@Carreau](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3ACarreau+updated%3A2024-12-17..2025-02-03&type=Issues) 24 | 25 | 26 | 27 | ## 0.11.0 28 | 29 | ([Full Changelog](https://github.com/jupyter/jupyter_events/compare/v0.10.0...f8012610f86408908004febed9e0e06ef71ca951)) 30 | 31 | ### Bugs fixed 32 | 33 | - Switch schema `version` type to `str` [#104](https://github.com/jupyter/jupyter_events/pull/104) ([@afshin](https://github.com/afshin)) 34 | - Fix DeprecationWarning with patched python-json-logger [#103](https://github.com/jupyter/jupyter_events/pull/103) ([@cjwatson](https://github.com/cjwatson)) 35 | - Prevent unintended `KeyError` when emitting an unregistered event schema [#101](https://github.com/jupyter/jupyter_events/pull/101) ([@afshin](https://github.com/afshin)) 36 | 37 | ### Maintenance and upkeep improvements 38 | 39 | - Fix typo and remove double check. [#107](https://github.com/jupyter/jupyter_events/pull/107) ([@Carreau](https://github.com/Carreau)) 40 | - test on 3.13 [#106](https://github.com/jupyter/jupyter_events/pull/106) ([@Carreau](https://github.com/Carreau)) 41 | 42 | ### Documentation improvements 43 | 44 | - Fix typo [#102](https://github.com/jupyter/jupyter_events/pull/102) ([@davidbrochart](https://github.com/davidbrochart)) 45 | - Update notebook to match current version of jupyter-events [#98](https://github.com/jupyter/jupyter_events/pull/98) ([@manics](https://github.com/manics)) 46 | 47 | ### Contributors to this release 48 | 49 | ([GitHub contributors page for this release](https://github.com/jupyter/jupyter_events/graphs/contributors?from=2024-03-18&to=2024-12-17&type=c)) 50 | 51 | [@afshin](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Aafshin+updated%3A2024-03-18..2024-12-17&type=Issues) | [@Carreau](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3ACarreau+updated%3A2024-03-18..2024-12-17&type=Issues) | [@cjwatson](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Acjwatson+updated%3A2024-03-18..2024-12-17&type=Issues) | [@davidbrochart](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Adavidbrochart+updated%3A2024-03-18..2024-12-17&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Amanics+updated%3A2024-03-18..2024-12-17&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3AZsailer+updated%3A2024-03-18..2024-12-17&type=Issues) 52 | 53 | ## 0.10.0 54 | 55 | ([Full Changelog](https://github.com/jupyter/jupyter_events/compare/v0.9.1...e7784fd09356ef074d69d1c2f192f1ad96f5f00c)) 56 | 57 | ### Enhancements made 58 | 59 | - Enable adding listeners to event before the event is registered [#97](https://github.com/jupyter/jupyter_events/pull/97) ([@Zsailer](https://github.com/Zsailer)) 60 | 61 | ### Contributors to this release 62 | 63 | ([GitHub contributors page for this release](https://github.com/jupyter/jupyter_events/graphs/contributors?from=2024-03-12&to=2024-03-18&type=c)) 64 | 65 | [@Zsailer](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3AZsailer+updated%3A2024-03-12..2024-03-18&type=Issues) 66 | 67 | ## 0.9.1 68 | 69 | ([Full Changelog](https://github.com/jupyter/jupyter_events/compare/v0.9.0...014a91c793b12d008bb744614a280bc14b5be7eb)) 70 | 71 | ### Maintenance and upkeep improvements 72 | 73 | - Update Release Scripts [#96](https://github.com/jupyter/jupyter_events/pull/96) ([@blink1073](https://github.com/blink1073)) 74 | - chore: update pre-commit hooks [#95](https://github.com/jupyter/jupyter_events/pull/95) ([@pre-commit-ci](https://github.com/pre-commit-ci)) 75 | - chore: update pre-commit hooks [#94](https://github.com/jupyter/jupyter_events/pull/94) ([@pre-commit-ci](https://github.com/pre-commit-ci)) 76 | - Update ruff and typing [#93](https://github.com/jupyter/jupyter_events/pull/93) ([@blink1073](https://github.com/blink1073)) 77 | - chore: update pre-commit hooks [#92](https://github.com/jupyter/jupyter_events/pull/92) ([@pre-commit-ci](https://github.com/pre-commit-ci)) 78 | 79 | ### Contributors to this release 80 | 81 | ([GitHub contributors page for this release](https://github.com/jupyter/jupyter_events/graphs/contributors?from=2023-11-06&to=2024-03-12&type=c)) 82 | 83 | [@blink1073](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Ablink1073+updated%3A2023-11-06..2024-03-12&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Apre-commit-ci+updated%3A2023-11-06..2024-03-12&type=Issues) 84 | 85 | ## 0.9.0 86 | 87 | ([Full Changelog](https://github.com/jupyter/jupyter_events/compare/v0.8.0...228a04801224d127f4304e17398464d045794cf0)) 88 | 89 | ### Bugs fixed 90 | 91 | - Clean up linting and fix a bug that was found [#91](https://github.com/jupyter/jupyter_events/pull/91) ([@blink1073](https://github.com/blink1073)) 92 | 93 | ### Maintenance and upkeep improvements 94 | 95 | - Clean up linting and fix a bug that was found [#91](https://github.com/jupyter/jupyter_events/pull/91) ([@blink1073](https://github.com/blink1073)) 96 | - Adopt ruff format [#90](https://github.com/jupyter/jupyter_events/pull/90) ([@blink1073](https://github.com/blink1073)) 97 | - Normalize "jsonschema\[format-nongpl\]" in pyproject.toml [#86](https://github.com/jupyter/jupyter_events/pull/86) ([@frenzymadness](https://github.com/frenzymadness)) 98 | 99 | ### Contributors to this release 100 | 101 | ([GitHub contributors page for this release](https://github.com/jupyter/jupyter_events/graphs/contributors?from=2023-10-16&to=2023-11-06&type=c)) 102 | 103 | [@blink1073](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Ablink1073+updated%3A2023-10-16..2023-11-06&type=Issues) | [@frenzymadness](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Afrenzymadness+updated%3A2023-10-16..2023-11-06&type=Issues) 104 | 105 | ## 0.8.0 106 | 107 | ([Full Changelog](https://github.com/jupyter/jupyter_events/compare/v0.7.0...e3edb6a868924d3f1b15eaf18d45be621ad77cef)) 108 | 109 | ### Bugs fixed 110 | 111 | - Allow for string annotations in listener signature [#88](https://github.com/jupyter/jupyter_events/pull/88) ([@blink1073](https://github.com/blink1073)) 112 | 113 | ### Maintenance and upkeep improvements 114 | 115 | - Adopt sp-repo-review [#89](https://github.com/jupyter/jupyter_events/pull/89) ([@blink1073](https://github.com/blink1073)) 116 | - Bump actions/checkout from 3 to 4 [#84](https://github.com/jupyter/jupyter_events/pull/84) ([@dependabot](https://github.com/dependabot)) 117 | - Add more PyPI URLs [#82](https://github.com/jupyter/jupyter_events/pull/82) ([@pydanny](https://github.com/pydanny)) 118 | 119 | ### Contributors to this release 120 | 121 | ([GitHub contributors page for this release](https://github.com/jupyter/jupyter_events/graphs/contributors?from=2023-07-31&to=2023-10-16&type=c)) 122 | 123 | [@blink1073](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Ablink1073+updated%3A2023-07-31..2023-10-16&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Adependabot+updated%3A2023-07-31..2023-10-16&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Apre-commit-ci+updated%3A2023-07-31..2023-10-16&type=Issues) | [@pydanny](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Apydanny+updated%3A2023-07-31..2023-10-16&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3AZsailer+updated%3A2023-07-31..2023-10-16&type=Issues) 124 | 125 | ## 0.7.0 126 | 127 | ([Full Changelog](https://github.com/jupyter/jupyter_events/compare/v0.6.3...56e7d2660b59632765a85859217cddc7304e82f8)) 128 | 129 | ### Enhancements made 130 | 131 | - allow a 'message' field in an event schema [#72](https://github.com/jupyter/jupyter_events/pull/72) ([@Zsailer](https://github.com/Zsailer)) 132 | 133 | ### Bugs fixed 134 | 135 | - Improve usability of jp_read_emitted_events fixture [#74](https://github.com/jupyter/jupyter_events/pull/74) ([@kevin-bates](https://github.com/kevin-bates)) 136 | 137 | ### Maintenance and upkeep improvements 138 | 139 | - Migrate RefResolver to referencing.Registry [#80](https://github.com/jupyter/jupyter_events/pull/80) ([@hbcarlos](https://github.com/hbcarlos)) 140 | - Use local coverage [#73](https://github.com/jupyter/jupyter_events/pull/73) ([@blink1073](https://github.com/blink1073)) 141 | - Clean up license [#67](https://github.com/jupyter/jupyter_events/pull/67) ([@dcsaba89](https://github.com/dcsaba89)) 142 | - Add more linting [#65](https://github.com/jupyter/jupyter_events/pull/65) ([@blink1073](https://github.com/blink1073)) 143 | 144 | ### Contributors to this release 145 | 146 | ([GitHub contributors page for this release](https://github.com/jupyter/jupyter_events/graphs/contributors?from=2023-01-12&to=2023-07-31&type=c)) 147 | 148 | [@blink1073](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Ablink1073+updated%3A2023-01-12..2023-07-31&type=Issues) | [@dcsaba89](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Adcsaba89+updated%3A2023-01-12..2023-07-31&type=Issues) | [@hbcarlos](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Ahbcarlos+updated%3A2023-01-12..2023-07-31&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Akevin-bates+updated%3A2023-01-12..2023-07-31&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Apre-commit-ci+updated%3A2023-01-12..2023-07-31&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3AZsailer+updated%3A2023-01-12..2023-07-31&type=Issues) 149 | 150 | ## 0.6.3 151 | 152 | ([Full Changelog](https://github.com/jupyter/jupyter_events/compare/v0.6.2...ac65980322317f1f30bc07150c9e14afaad03d40)) 153 | 154 | ### Maintenance and upkeep improvements 155 | 156 | - Clean up typing [#64](https://github.com/jupyter/jupyter_events/pull/64) ([@blink1073](https://github.com/blink1073)) 157 | 158 | ### Contributors to this release 159 | 160 | ([GitHub contributors page for this release](https://github.com/jupyter/jupyter_events/graphs/contributors?from=2023-01-10&to=2023-01-12&type=c)) 161 | 162 | [@blink1073](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Ablink1073+updated%3A2023-01-10..2023-01-12&type=Issues) 163 | 164 | ## 0.6.2 165 | 166 | ([Full Changelog](https://github.com/jupyter/jupyter_events/compare/v0.6.1...a00859944090df5277f263fcfe72ae48b8cc2382)) 167 | 168 | ### Maintenance and upkeep improvements 169 | 170 | - Decrease pyyaml dependency floor to increase compatibility [#63](https://github.com/jupyter/jupyter_events/pull/63) ([@kevin-bates](https://github.com/kevin-bates)) 171 | 172 | ### Contributors to this release 173 | 174 | ([GitHub contributors page for this release](https://github.com/jupyter/jupyter_events/graphs/contributors?from=2023-01-10&to=2023-01-10&type=c)) 175 | 176 | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Akevin-bates+updated%3A2023-01-10..2023-01-10&type=Issues) 177 | 178 | ## 0.6.1 179 | 180 | ([Full Changelog](https://github.com/jupyter/jupyter_events/compare/v0.6.0...1aa57024d0a8c73b10d9944375f84c01ee9f5c33)) 181 | 182 | ### Maintenance and upkeep improvements 183 | 184 | - Remove artificial cap on jsonschema dependency [#61](https://github.com/jupyter/jupyter_events/pull/61) ([@kevin-bates](https://github.com/kevin-bates)) 185 | - Try dropping jsonschema dependency [#59](https://github.com/jupyter/jupyter_events/pull/59) ([@bretttully](https://github.com/bretttully)) 186 | 187 | ### Contributors to this release 188 | 189 | ([GitHub contributors page for this release](https://github.com/jupyter/jupyter_events/graphs/contributors?from=2023-01-09&to=2023-01-10&type=c)) 190 | 191 | [@blink1073](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Ablink1073+updated%3A2023-01-09..2023-01-10&type=Issues) | [@bretttully](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Abretttully+updated%3A2023-01-09..2023-01-10&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Akevin-bates+updated%3A2023-01-09..2023-01-10&type=Issues) 192 | 193 | ## 0.6.0 194 | 195 | ([Full Changelog](https://github.com/jupyter/jupyter_events/compare/v0.5.0...83f01b142c3190074d9e6108155514ddc6237d2c)) 196 | 197 | ### Maintenance and upkeep improvements 198 | 199 | - Add typing file [#60](https://github.com/jupyter/jupyter_events/pull/60) ([@blink1073](https://github.com/blink1073)) 200 | - More lint cleanup [#56](https://github.com/jupyter/jupyter_events/pull/56) ([@blink1073](https://github.com/blink1073)) 201 | - Add more ci checks [#53](https://github.com/jupyter/jupyter_events/pull/53) ([@blink1073](https://github.com/blink1073)) 202 | - Allow releasing from repo [#52](https://github.com/jupyter/jupyter_events/pull/52) ([@blink1073](https://github.com/blink1073)) 203 | - Adopt ruff and address lint [#51](https://github.com/jupyter/jupyter_events/pull/51) ([@blink1073](https://github.com/blink1073)) 204 | - Use base setup dependency type [#47](https://github.com/jupyter/jupyter_events/pull/47) ([@blink1073](https://github.com/blink1073)) 205 | - Clean up CI [#45](https://github.com/jupyter/jupyter_events/pull/45) ([@blink1073](https://github.com/blink1073)) 206 | - CI Cleanup [#44](https://github.com/jupyter/jupyter_events/pull/44) ([@blink1073](https://github.com/blink1073)) 207 | - Bump actions/checkout from 2 to 3 [#42](https://github.com/jupyter/jupyter_events/pull/42) ([@dependabot](https://github.com/dependabot)) 208 | - Add dependabot [#41](https://github.com/jupyter/jupyter_events/pull/41) ([@blink1073](https://github.com/blink1073)) 209 | - Maintenance cleanup [#36](https://github.com/jupyter/jupyter_events/pull/36) ([@blink1073](https://github.com/blink1073)) 210 | - Clean up CI checks [#35](https://github.com/jupyter/jupyter_events/pull/35) ([@blink1073](https://github.com/blink1073)) 211 | - Clean up pyproject and ci [#33](https://github.com/jupyter/jupyter_events/pull/33) ([@blink1073](https://github.com/blink1073)) 212 | 213 | ### Documentation improvements 214 | 215 | - Fix listener example [#34](https://github.com/jupyter/jupyter_events/pull/34) ([@dlqqq](https://github.com/dlqqq)) 216 | 217 | ### Contributors to this release 218 | 219 | ([GitHub contributors page for this release](https://github.com/jupyter/jupyter_events/graphs/contributors?from=2022-09-12&to=2023-01-09&type=c)) 220 | 221 | [@blink1073](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Ablink1073+updated%3A2022-09-12..2023-01-09&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Adependabot+updated%3A2022-09-12..2023-01-09&type=Issues) | [@dlqqq](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Adlqqq+updated%3A2022-09-12..2023-01-09&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Apre-commit-ci+updated%3A2022-09-12..2023-01-09&type=Issues) 222 | 223 | ## 0.5.0 224 | 225 | ([Full Changelog](https://github.com/jupyter/jupyter_events/compare/v0.4.0...af1db6f5b9052e54d5a65797b67bff17b80e7eec)) 226 | 227 | ### Enhancements made 228 | 229 | - Add pytest plugin for testing events in other libraries [#23](https://github.com/jupyter/jupyter_events/pull/23) ([@Zsailer](https://github.com/Zsailer)) 230 | - improve error messages for absent/invalid schema path [#22](https://github.com/jupyter/jupyter_events/pull/22) ([@dlqqq](https://github.com/dlqqq)) 231 | 232 | ### Bugs fixed 233 | 234 | - specify utf-8 encoding for loading/dumping yaml [#29](https://github.com/jupyter/jupyter_events/pull/29) ([@bollwyvl](https://github.com/bollwyvl)) 235 | - use safe loaders and dumpers in yaml lib [#28](https://github.com/jupyter/jupyter_events/pull/28) ([@dlqqq](https://github.com/dlqqq)) 236 | 237 | ### Maintenance and upkeep improvements 238 | 239 | - add jsonschema\[format-nongpl\], core event schema [#31](https://github.com/jupyter/jupyter_events/pull/31) ([@bollwyvl](https://github.com/bollwyvl)) 240 | - Add CLI tests, return codes, version [#30](https://github.com/jupyter/jupyter_events/pull/30) ([@bollwyvl](https://github.com/bollwyvl)) 241 | - Set version floor on jsonchema [#25](https://github.com/jupyter/jupyter_events/pull/25) ([@kevin-bates](https://github.com/kevin-bates)) 242 | - Testing that the `.emit` call is a no-op when no listeners are registered. [#24](https://github.com/jupyter/jupyter_events/pull/24) ([@Zsailer](https://github.com/Zsailer)) 243 | 244 | ### Documentation improvements 245 | 246 | - docs: wrap shell command in quotes [#21](https://github.com/jupyter/jupyter_events/pull/21) ([@dlqqq](https://github.com/dlqqq)) 247 | 248 | ### Contributors to this release 249 | 250 | ([GitHub contributors page for this release](https://github.com/jupyter/jupyter_events/graphs/contributors?from=2022-08-29&to=2022-09-12&type=c)) 251 | 252 | [@bollwyvl](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Abollwyvl+updated%3A2022-08-29..2022-09-12&type=Issues) | [@dlqqq](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Adlqqq+updated%3A2022-08-29..2022-09-12&type=Issues) | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Akevin-bates+updated%3A2022-08-29..2022-09-12&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3AZsailer+updated%3A2022-08-29..2022-09-12&type=Issues) 253 | 254 | ## 0.4.0 255 | 256 | ([Full Changelog](https://github.com/jupyter/jupyter_events/compare/v0.3.0...6d22b7dd73b1a04baf26a68539743d8a66599469)) 257 | 258 | ### Enhancements made 259 | 260 | - Add method to remove listener [#18](https://github.com/jupyter/jupyter_events/pull/18) ([@Zsailer](https://github.com/Zsailer)) 261 | - Add a simple CLI tool for validating event schemas [#9](https://github.com/jupyter/jupyter_events/pull/9) ([@Zsailer](https://github.com/Zsailer)) 262 | 263 | ### Bugs fixed 264 | 265 | - Fix minor bugs in Listeners API [#19](https://github.com/jupyter/jupyter_events/pull/19) ([@Zsailer](https://github.com/Zsailer)) 266 | 267 | ### Contributors to this release 268 | 269 | ([GitHub contributors page for this release](https://github.com/jupyter/jupyter_events/graphs/contributors?from=2022-08-24&to=2022-08-29&type=c)) 270 | 271 | [@Zsailer](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3AZsailer+updated%3A2022-08-24..2022-08-29&type=Issues) 272 | 273 | ## 0.3.0 274 | 275 | ([Full Changelog](https://github.com/jupyter/jupyter_events/compare/v0.2.0...94646036f0ab4b3397e261422fd3041c0d7501e9)) 276 | 277 | ### Enhancements made 278 | 279 | - Remove (duplicate) version argument from API [#16](https://github.com/jupyter/jupyter_events/pull/16) ([@Zsailer](https://github.com/Zsailer)) 280 | - Add a "modifiers" hook to allow extension authors to mutate/redact event data [#12](https://github.com/jupyter/jupyter_events/pull/12) ([@Zsailer](https://github.com/Zsailer)) 281 | - Add Listeners API [#10](https://github.com/jupyter/jupyter_events/pull/10) ([@Zsailer](https://github.com/Zsailer)) 282 | 283 | ### Bugs fixed 284 | 285 | - Reading strings as file path is unsafe [#15](https://github.com/jupyter/jupyter_events/pull/15) ([@Zsailer](https://github.com/Zsailer)) 286 | 287 | ### Maintenance and upkeep improvements 288 | 289 | - Fix check_release build [#14](https://github.com/jupyter/jupyter_events/pull/14) ([@blink1073](https://github.com/blink1073)) 290 | 291 | ### Documentation improvements 292 | 293 | - Add JupyterLite example to the documentation [#6](https://github.com/jupyter/jupyter_events/pull/6) ([@Zsailer](https://github.com/Zsailer)) 294 | 295 | ### Contributors to this release 296 | 297 | ([GitHub contributors page for this release](https://github.com/jupyter/jupyter_events/graphs/contributors?from=2022-08-11&to=2022-08-24&type=c)) 298 | 299 | [@blink1073](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Ablink1073+updated%3A2022-08-11..2022-08-24&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3AZsailer+updated%3A2022-08-11..2022-08-24&type=Issues) 300 | 301 | ## 0.2.0 302 | 303 | ([Full Changelog](https://github.com/jupyter/jupyter_events/compare/v0.1.0...88acd8ec613fe7d2aa6fcaf07158275989dc5dfd)) 304 | 305 | ### Enhancements made 306 | 307 | - Add new EventSchema and SchemaRegistry API [#4](https://github.com/jupyter/jupyter_events/pull/4) ([@Zsailer](https://github.com/Zsailer)) 308 | - Add redactionPolicies field to Jupyter Event schemas [#2](https://github.com/jupyter/jupyter_events/pull/2) ([@Zsailer](https://github.com/Zsailer)) 309 | 310 | ### Contributors to this release 311 | 312 | ([GitHub contributors page for this release](https://github.com/jupyter/jupyter_events/graphs/contributors?from=2022-05-31&to=2022-08-11&type=c)) 313 | 314 | [@kevin-bates](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3Akevin-bates+updated%3A2022-05-31..2022-08-11&type=Issues) | [@Zsailer](https://github.com/search?q=repo%3Ajupyter%2Fjupyter_events+involves%3AZsailer+updated%3A2022-05-31..2022-08-11&type=Issues) 315 | 316 | ## 0.1.0 317 | --------------------------------------------------------------------------------