diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..6a5b498 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,31 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'jupyter-schemas' +copyright = '2024, .' +author = 'Jupyter Development Team' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx_design', + 'sphinx-jsonschema' +] + +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'pydata_sphinx_theme' +html_static_path = ['_static'] diff --git a/docs/example.rst b/docs/example.rst new file mode 100644 index 0000000..01400e4 --- /dev/null +++ b/docs/example.rst @@ -0,0 +1,129 @@ +Example schema +============== + +.. tab-set:: + + .. tab-item:: Rendered using sphinx-jsonschema + + .. jsonschema:: ../schema/server/events/kernel-actions/v1/kernel-actions.schema.json + + .. tab-item:: JSON + + .. literalinclude:: ../schema/server/events/kernel-actions/v1/kernel-actions.schema.json + :language: JSON + + .. tab-item:: TOML + + .. code-block:: toml + + "$schema" = "https://json-schema.org/draft/2020-12/schema" + "$id" = "https://schema.jupyter.org/server/events/kernel-actions/v1/kernel-actions.schema.json" + version = 1 + title = "Kernel Manager activities" + personal-data = true + description = "Record events of a kernel manager.\n" + type = "object" + required = [ "action", "msg",] + + [properties.action] + enum = [ "start", "interrupt", "shutdown", "restart",] + description = "Action performed by the Kernel Manager.\n\nThis is a required field.\n\nPossible values:\n\n1. start\n A kernel has been started with the given kernel id.\n\n2. interrupt\n A kernel has been interrupted for the given kernel id.\n\n3. shutdown\n A kernel has been shut down for the given kernel id.\n\n4. restart\n A kernel has been restarted for the given kernel id.\n" + + [properties.kernel_id] + type = "string" + description = "Kernel id.\n\nThis is a required field for all actions and statuses except action start with status error.\n" + + [properties.kernel_name] + type = "string" + description = "Name of the kernel.\n" + + [properties.status] + enum = [ "error", "success",] + description = "Status received from a rest api operation to kernel server.\n\nThis is a required field.\n\nPossible values:\n\n1. error\n Error response from a rest api operation to kernel server.\n\n2. success\n Success response from a rest api operation to kernel server.\n" + + [properties.status_code] + type = "number" + description = "Http response codes from a rest api operation to kernel server.\nExamples: 200, 400, 502, 503, 599 etc\n" + + [properties.msg] + type = "string" + description = "Description of the event specified in action.\n" + + [if.not.properties.status] + const = "error" + + [if.not.properties.action] + const = "start" + + [then] + required = [ "kernel_id",] + + .. tab-item:: YAML + + .. code-block:: yaml + + $schema: https://json-schema.org/draft/2020-12/schema + $id: https://schema.jupyter.org/server/events/kernel-actions/v1/kernel-actions.schema.json + version: 1 + title: Kernel Manager activities + personal-data: true + description: 'Record events of a kernel manager. + + ' + type: object + required: + - action + - msg + properties: + action: + enum: + - start + - interrupt + - shutdown + - restart + description: "Action performed by the Kernel Manager.\n\nThis is a required field.\n\nPossible values:\n\ + \n1. start\n A kernel has been started with the given kernel id.\n\n2. interrupt\n A kernel\ + \ has been interrupted for the given kernel id.\n\n3. shutdown\n A kernel has been shut down for\ + \ the given kernel id.\n\n4. restart\n A kernel has been restarted for the given kernel id.\n" + kernel_id: + type: string + description: 'Kernel id. + + + This is a required field for all actions and statuses except action start with status error. + + ' + kernel_name: + type: string + description: 'Name of the kernel. + + ' + status: + enum: + - error + - success + description: "Status received from a rest api operation to kernel server.\n\nThis is a required field.\n\ + \nPossible values:\n\n1. error\n Error response from a rest api operation to kernel server.\n\n\ + 2. success\n Success response from a rest api operation to kernel server.\n" + status_code: + type: number + description: 'Http response codes from a rest api operation to kernel server. + + Examples: 200, 400, 502, 503, 599 etc + + ' + msg: + type: string + description: 'Description of the event specified in action. + + ' + if: + not: + properties: + status: + const: error + action: + const: start + then: + required: + - kernel_id diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..b90026c --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,21 @@ +.. jupyter-schemas documentation master file, created by + sphinx-quickstart on Fri Feb 23 14:44:15 2024. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to jupyter-schemas's documentation! +=========================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + example + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..32bb245 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/jupyter_schemas.py b/jupyter_schemas.py index 6e43ce0..34c5ccb 100644 --- a/jupyter_schemas.py +++ b/jupyter_schemas.py @@ -1,6 +1,7 @@ import logging import typing import json +import jsonschema import pathlib import urllib.parse from jupyter_core.paths import jupyter_path @@ -17,6 +18,7 @@ class JupyterSchemaSourceNotFound(Exception): ROOT_SCHEMA_PATH: pathlib.Path = pathlib.Path(jupyter_path()[0]) / "schema" +ROOT_SCHEMA_URI: str = "https://schema.jupyter.org" def schema_path(schema_uri: str) -> pathlib.Path: @@ -59,3 +61,61 @@ def list_schemas(project: typing.Optional[str] = None) -> typing.List[str]: logging.warning(f"Could not find an ID/URI in {fpath}.") pass return schema_uris + + +class Schema: + """A Jupyter schema""" + + def __init__(self, filename: str) -> None: + """Load a schema from JSON (.json), TOML (.toml) or YAML (.yaml) file. + + Parameters + ---------- + filename : str + Name of file containing schema. + + TODO: support loading from URI not just local filename. + """ + self._filename = filename + self._dict: dict[str, typing.Any] = {} + + suffix = pathlib.Path(filename).suffix + if suffix == ".json": + self._load_json() + else: + raise ValueError(f"Unrecognised file extension on {filename}") + + def _load_json(self): + with open(self._filename, "r") as f: + self._dict = json.load(f) + + def validate(self) -> None: + """Validate this schema, raising an exception if invalid.""" + + # TODO: Better consider type and text of exceptions. + + # Initially all schemas are written against the 2020-12 Draft. + if not (meta_schema := self._dict.get("$schema")): + raise RuntimeError(f"Schema {self._filename} does not contain '$schema'") + elif meta_schema != "https://json-schema.org/draft/2020-12/schema": + raise RuntimeError( + f"Schema {self._filename} contains incorrect '$schema': {meta_schema}") + + jsonschema.Draft202012Validator.check_schema(self._dict) + + # Local schema filename must be consistent with the $id (published URI) property. + if not (id := self._dict.get("$id")): + raise RuntimeError(f"Schema {self._filename} must contain an '$id'") + + relative_path = pathlib.Path(self._filename).relative_to(ROOT_SCHEMA_PATH) + expected_id = urllib.parse.urljoin(ROOT_SCHEMA_URI, str(relative_path)) + if id != expected_id: + raise RuntimeError(f"Inconsistent schema path {self._filename} and URI {id}") + + # Schema path relative to root must contain version number exactly once. + if not (version := self._dict.get("version")): + raise RuntimeError(f"Schema {self._filename} must contain a 'version'") + + if (count := relative_path.parts.count(f"v{version}")) != 1: + raise RuntimeError( + f"Schema path {relative_path} must contain version number once not {count} times") diff --git a/pyproject.toml b/pyproject.toml index d2d5b6f..b3df010 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,18 +22,32 @@ classifiers = [ "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ] -dependencies = ["jupyter_core"] +dependencies = [ + "jsonschema", + "jupyter_core", +] [project.urls] Documentation = "https://github.com/unknown/jupyter-schemas#readme" Issues = "https://github.com/unknown/jupyter-schemas/issues" Source = "https://github.com/unknown/jupyter-schemas" +[project.optional-dependencies] +test = [ + "pytest", +] +docs = [ + "pydata_sphinx_theme", + "sphinx", + "sphinx_design", + "sphinx-jsonschema", +] + [tool.hatch.version] path = "jupyter_schemas.py" [tool.hatch.build.targets.wheel.shared-data] -"jupyter_server" = "share/jupyter/schema/jupyter_server" +"schema" = "share/jupyter/schema" [tool.hatch.envs.default] dependencies = ["coverage[toml]>=6.5", "pytest"] @@ -50,8 +64,15 @@ python = ["3.8", "3.9", "3.10", "3.11", "3.12"] dependencies = ["mypy>=1.0.0"] +[tool.pytest.ini_options] +addopts = [ + "-raXs", "--color=yes", +] +testpaths = ["tests/"] + + [tool.coverage.run] -source_pkgs = ["jupyter_schemas", "tests"] +source_pkgs = ["schema", "tests"] branch = true parallel = true diff --git a/schema/kernel/messages/v1/kernel-info-request.schema.json b/schema/kernel/messages/v1/kernel-info-request.schema.json new file mode 100644 index 0000000..19c1816 --- /dev/null +++ b/schema/kernel/messages/v1/kernel-info-request.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schema.jupyter.org/kernel/messages/v1/kernel-info-request.schema.json", + "version": 1, + "title": "Kernel info request message", + "type": "object", + "properties": { + "header": { + "$ref": "/kernel/messages/v1/message-header.schema.json", + "description": "Message header", + "properties": { + "msg_type": { + "const": "kernel_info_request" + } + } + }, + "content": { + "type": "object" + } + }, + "required": [ + "header", "content" + ] +} diff --git a/schema/kernel/messages/v1/message-header.schema.json b/schema/kernel/messages/v1/message-header.schema.json new file mode 100644 index 0000000..75c474d --- /dev/null +++ b/schema/kernel/messages/v1/message-header.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schema.jupyter.org/kernel/messages/v1/message-header.schema.json", + "version": 1, + "title": "Message header", + "type": "object", + "properties": { + "msg_id": { + "type": "string", + "description": "Message ID, must be unique per message, typically a UUID" + }, + "session": { + "type": "string", + "description": "Session ID, unique per session, typically a UUID" + }, + "username": { + "type": "string" + }, + "date": { + "type": "string", + "description": "ISO 8601 timestamp for when the message was created" + }, + "msg_type": { + "enum": [ + "kernel_info_request", + "kernel_info_reply", + "some_other_message_type" + ] + }, + "version": { + "type": "string", + "description": "Message protocol version" + } + }, + "required": [ + "msg_id", + "session", + "username", + "date", + "msg_type", + "version" + ] +} diff --git a/schema/kernel/spec/v1/kernelspec.schema.json b/schema/kernel/spec/v1/kernelspec.schema.json new file mode 100644 index 0000000..b5477f3 --- /dev/null +++ b/schema/kernel/spec/v1/kernelspec.schema.json @@ -0,0 +1,99 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schema.jupyter.org/kernel/spec/v1/kernelspec.schema.json", + "version": 1, + "title": "Jupyter Kernelspec", + "description": "A description of the data required to start and manage a Jupyter Kernel", + "type": "object", + + "definitions": { + "kernel_arguments": { + "type": "object", + "required": ["argv"], + "properties": { + "argv": { + "description": "A list of command line arguments used to start the kernel. The text {connection_file} in any argument will be replaced with the path to the connection file.", + "type": "array", + "items": { + "type": "string" + }, + "minItems": 3 + } + } + }, + "display_name": { + "type": "object", + "required": ["display_name"], + "properties": { + "display_name": { + "description": "The kernel’s name as it should be displayed in the UI. Unlike the kernel name used in the API, this can contain arbitrary unicode characters.", + "type": "string" + } + } + }, + "language": { + "type": "object", + "required": ["language"], + "properties": { + "language": { + "description": "The name of the language of the kernel. When loading notebooks, if no matching kernelspec key (may differ across machines) is found, a kernel with a matching language will be used. This allows a notebook written on any Python or Julia kernel to be properly associated with the user’s Python or Julia kernel, even if they aren’t listed under the same name as the author’s.", + "type": "string" + } + } + }, + "kernel_protocol_version": { + "type": "object", + "required": ["kernel_protocol_version"], + "properties": { + "kernel_protocol_version": { + "description": "The version of protocol this kernel implements. If not specified, the client will assume the version is <5.5 until it can get it via the kernel_info request. The kernel protocol uses semantic versioning (SemVer).", + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" + } + } + }, + "interrupt_mode": { + "type": "object", + "required": ["interrupt_mode"], + "properties": { + "interrupt_mode": { + "description": "May be either signal or message and specifies how a client is supposed to interrupt cell execution on this kernel, either by sending an interrupt signal via the operating system’s signalling facilities (e.g. SIGINT on POSIX systems), or by sending an interrupt_request message on the control channel (see Kernel interrupt). If this is not specified the client will default to signal mode.", + "type": "string", + "enum": ["signal", "message"] + } + } + }, + "env": { + "type": "object", + "required": ["env"], + "properties": { + "env": { + "description": "A dictionary of environment variables to set for the kernel. These will be added to the current environment variables before the kernel is started. Existing environment variables can be referenced using ${} and will be substituted with the corresponding value. Administrators should note that use of ${} can expose sensitive variables and should use only in controlled circumstances.", + "type": "object", + "additionalProperties": {"type": "string" } + } + } + }, + "metadata": { + "type": "object", + "required": ["metadata"], + "properties": { + "metadata": { + "description": "A dictionary of additional attributes about this kernel; used by clients to aid in kernel selection. Metadata added here should be namespaced for the tool reading and writing that metadata.", + "type": "object", + "additionalProperties": {"type": "object"} + } + } + } + }, + "anyOf": [ + { "$ref": "#/definitions/argv" }, + { "$ref": "#/definitions/display_name" }, + { "$ref": "#/definitions/language" }, + { "$ref": "#/definitions/kernel_protocol_version" }, + { "$ref": "#/definitions/interrupt_mode" }, + { "$ref": "#/definitions/env" }, + { "$ref": "#/definitions/metadata" } + ], + "required": ["argv", "display_name", "language"] +} \ No newline at end of file diff --git a/jupyter_server/events/contents_service/v1.json b/schema/server/contents/v1/contents.schema.json similarity index 94% rename from jupyter_server/events/contents_service/v1.json rename to schema/server/contents/v1/contents.schema.json index f9c9b53..fc4ba52 100644 --- a/jupyter_server/events/contents_service/v1.json +++ b/schema/server/contents/v1/contents.schema.json @@ -1,5 +1,6 @@ { - "$id": "https://schema.jupyter.org/jupyter_server/events/contents_service/v1", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schema.jupyter.org/server/contents/v1/contents.schema.json", "version": 1, "title": "Contents Manager activities", "personal-data": true, diff --git a/jupyter_server/events/gateway_client/v1.json b/schema/server/events/gateway-client/v1/gateway-client.schema.json similarity index 86% rename from jupyter_server/events/gateway_client/v1.json rename to schema/server/events/gateway-client/v1/gateway-client.schema.json index 4d141a6..cd9efb9 100644 --- a/jupyter_server/events/gateway_client/v1.json +++ b/schema/server/events/gateway-client/v1/gateway-client.schema.json @@ -1,5 +1,6 @@ { - "$id": "https://schema.jupyter.org/jupyter_server/events/gateway_client/v1", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schema.jupyter.org/server/events/gateway-client/v1/gateway-client.schema.json", "version": 1, "title": "Gateway Client activities.", "personal-data": true, diff --git a/jupyter_server/events/kernel_actions/v1.json b/schema/server/events/kernel-actions/v1/kernel-actions.schema.json similarity index 92% rename from jupyter_server/events/kernel_actions/v1.json rename to schema/server/events/kernel-actions/v1/kernel-actions.schema.json index abee832..2216f45 100644 --- a/jupyter_server/events/kernel_actions/v1.json +++ b/schema/server/events/kernel-actions/v1/kernel-actions.schema.json @@ -1,5 +1,6 @@ { - "$id": "https://schema.jupyter.org/jupyter_server/events/kernel_actions/v1", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schema.jupyter.org/server/events/kernel-actions/v1/kernel-actions.schema.json", "version": 1, "title": "Kernel Manager activities", "personal-data": true, diff --git a/tests/test_validate.py b/tests/test_validate.py new file mode 100644 index 0000000..8bca9ef --- /dev/null +++ b/tests/test_validate.py @@ -0,0 +1,12 @@ +import pytest + +from jupyter_schemas import list_schema_paths, Schema + + +@pytest.mark.parametrize("schema_path", list_schema_paths()) +def test_validate_schema(schema_path): + schema = Schema(schema_path) + schema.validate() + + +# TODO: tests to cover all failure modes of Schema.validate(). Will need invalid schemas to test.