From bd69e4654f09c1f10feaa29dc8499c9b9e665ee6 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:13:27 +0530 Subject: [PATCH] feat: publish versioned JSON Schema artifacts (#240) --- .github/workflows/tests.yml | 1 + CHANGELOG.md | 2 + docs/schemas.md | 33 ++++++++++ docs/schemas/v1/command-protocol.schema.json | 15 +++++ docs/schemas/v1/error.schema.json | 17 ++++++ docs/schemas/v1/inspection.schema.json | 15 +++++ docs/schemas/v1/log.schema.json | 18 ++++++ docs/schemas/v1/ndjson.schema.json | 13 ++++ docs/schemas/v1/output.schema.json | 17 ++++++ .../schemas/v1/command-protocol.schema.json | 15 +++++ .../base_cli/schemas/v1/error.schema.json | 17 ++++++ .../schemas/v1/inspection.schema.json | 15 +++++ .../base_cli/schemas/v1/log.schema.json | 18 ++++++ .../base_cli/schemas/v1/ndjson.schema.json | 13 ++++ .../base_cli/schemas/v1/output.schema.json | 17 ++++++ mkdocs.yml | 1 + pyproject.toml | 2 +- scripts/validate_docs.py | 1 + scripts/validate_schemas.py | 61 +++++++++++++++++++ tests/test_validate_schemas.py | 39 ++++++++++++ 20 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 docs/schemas.md create mode 100644 docs/schemas/v1/command-protocol.schema.json create mode 100644 docs/schemas/v1/error.schema.json create mode 100644 docs/schemas/v1/inspection.schema.json create mode 100644 docs/schemas/v1/log.schema.json create mode 100644 docs/schemas/v1/ndjson.schema.json create mode 100644 docs/schemas/v1/output.schema.json create mode 100644 lib/python/base_cli/schemas/v1/command-protocol.schema.json create mode 100644 lib/python/base_cli/schemas/v1/error.schema.json create mode 100644 lib/python/base_cli/schemas/v1/inspection.schema.json create mode 100644 lib/python/base_cli/schemas/v1/log.schema.json create mode 100644 lib/python/base_cli/schemas/v1/ndjson.schema.json create mode 100644 lib/python/base_cli/schemas/v1/output.schema.json create mode 100644 scripts/validate_schemas.py create mode 100644 tests/test_validate_schemas.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9ee87d4..6551c83 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -66,6 +66,7 @@ jobs: python -m mypy --strict lib/python/base_cli python scripts/validate_docs.py python scripts/validate_changelog.py + python scripts/validate_schemas.py python scripts/benchmark_runtime.py --check python -m compileall -q examples - name: Run tests with coverage threshold diff --git a/CHANGELOG.md b/CHANGELOG.md index cb53260..73bd6b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ and versions are tracked in the repo-root `VERSION` file. ### Added +- Publish versioned JSON Schema artifacts for output, error, inspection, log, + NDJSON, and decoded command-protocol contracts in the package and docs site. - Add a framework choice guide, five-minute evaluation path, and clearer production-lifecycle positioning for Click and Typer adopters. - Add deterministic SPDX SBOMs, artifact checksums, and OIDC-backed GitHub diff --git a/docs/schemas.md b/docs/schemas.md new file mode 100644 index 0000000..bd01cca --- /dev/null +++ b/docs/schemas.md @@ -0,0 +1,33 @@ +# JSON Schema artifacts + +Versioned JSON Schema artifacts make the machine contracts usable from any +language. The same files are included in the `base-cli` wheel under +`base_cli/schemas/v1/` and are published here for direct browser or HTTP +consumption: + +| Contract | Schema | +| --- | --- | +| Success envelope | [`output.schema.json`](schemas/v1/output.schema.json) | +| Error envelope | [`error.schema.json`](schemas/v1/error.schema.json) | +| Inspection envelope | [`inspection.schema.json`](schemas/v1/inspection.schema.json) | +| JSON log record | [`log.schema.json`](schemas/v1/log.schema.json) | +| NDJSON record | [`ndjson.schema.json`](schemas/v1/ndjson.schema.json) | +| Decoded command protocol | [`command-protocol.schema.json`](schemas/v1/command-protocol.schema.json) | + +All artifacts use JSON Schema draft 2020-12 and carry a stable `$id` under the +`/schemas/v1/` URL prefix. A schema version is a compatibility boundary: an +additive change can remain in v1, while a change to required fields or field +meaning requires a new version and migration note. + +The command-protocol artifact describes the decoded representation of a +`COMMAND_PROTOCOL_V1` frame. The wire framing itself remains line-oriented and +is validated by the protocol codec documented in +[`json-contracts.md`](json-contracts.md). + +Consumers can load a packaged schema without importing base-cli: + +```python +from importlib.resources import files + +schema_text = files("base_cli").joinpath("schemas/v1/output.schema.json").read_text() +``` diff --git a/docs/schemas/v1/command-protocol.schema.json b/docs/schemas/v1/command-protocol.schema.json new file mode 100644 index 0000000..5263fd4 --- /dev/null +++ b/docs/schemas/v1/command-protocol.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://basefoundry.github.io/base-cli/schemas/v1/command-protocol.schema.json", + "title": "base-cli decoded command-protocol payload", + "description": "Schema for the decoded representation of COMMAND_PROTOCOL_V1 frames.", + "type": "object", + "additionalProperties": false, + "required": ["protocol_header", "record_type", "record_count", "records"], + "properties": { + "protocol_header": {"const": "COMMAND_PROTOCOL_V1"}, + "record_type": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9-]*$"}, + "record_count": {"type": "integer", "minimum": 0, "maximum": 1000000}, + "records": {"type": "array", "maxItems": 1000000, "items": {"type": "object"}} + } +} diff --git a/docs/schemas/v1/error.schema.json b/docs/schemas/v1/error.schema.json new file mode 100644 index 0000000..d81aa75 --- /dev/null +++ b/docs/schemas/v1/error.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://basefoundry.github.io/base-cli/schemas/v1/error.schema.json", + "title": "base-cli error envelope", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "schema", "code", "type", "message", "details", "run_id"], + "properties": { + "schema_version": {"const": 1}, + "schema": {"const": "base-cli.error"}, + "code": {"type": "string", "minLength": 1}, + "type": {"const": "error"}, + "message": {"type": "string"}, + "details": {"type": "object"}, + "run_id": {"type": ["string", "null"]} + } +} diff --git a/docs/schemas/v1/inspection.schema.json b/docs/schemas/v1/inspection.schema.json new file mode 100644 index 0000000..47ee682 --- /dev/null +++ b/docs/schemas/v1/inspection.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://basefoundry.github.io/base-cli/schemas/v1/inspection.schema.json", + "title": "base-cli inspection envelope", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "command", "status", "data", "error"], + "properties": { + "schema_version": {"const": 1}, + "command": {"type": "string", "minLength": 1}, + "status": {"enum": ["ok", "warn", "error"]}, + "data": {"type": "object"}, + "error": {"type": ["object", "null"]} + } +} diff --git a/docs/schemas/v1/log.schema.json b/docs/schemas/v1/log.schema.json new file mode 100644 index 0000000..2d8508e --- /dev/null +++ b/docs/schemas/v1/log.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://basefoundry.github.io/base-cli/schemas/v1/log.schema.json", + "title": "base-cli JSON log record", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "schema", "timestamp", "level", "logger", "message", "run_id"], + "properties": { + "schema_version": {"const": 1}, + "schema": {"const": "base-cli.log"}, + "timestamp": {"type": "string", "format": "date-time"}, + "level": {"type": "string", "minLength": 1}, + "logger": {"type": "string", "minLength": 1}, + "message": {"type": "string", "maxLength": 8193}, + "run_id": {"type": ["string", "null"]}, + "details": {"type": "object"} + } +} diff --git a/docs/schemas/v1/ndjson.schema.json b/docs/schemas/v1/ndjson.schema.json new file mode 100644 index 0000000..115f697 --- /dev/null +++ b/docs/schemas/v1/ndjson.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://basefoundry.github.io/base-cli/schemas/v1/ndjson.schema.json", + "title": "base-cli NDJSON record", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "schema", "record"], + "properties": { + "schema_version": {"const": 1}, + "schema": {"const": "base-cli.record"}, + "record": {"type": "object"} + } +} diff --git a/docs/schemas/v1/output.schema.json b/docs/schemas/v1/output.schema.json new file mode 100644 index 0000000..83a865a --- /dev/null +++ b/docs/schemas/v1/output.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://basefoundry.github.io/base-cli/schemas/v1/output.schema.json", + "title": "base-cli success envelope", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "schema", "code", "type", "message", "details", "run_id"], + "properties": { + "schema_version": {"const": 1}, + "schema": {"const": "base-cli.output"}, + "code": {"type": "string", "minLength": 1}, + "type": {"const": "success"}, + "message": {"type": "string"}, + "details": {"type": "object"}, + "run_id": {"type": ["string", "null"]} + } +} diff --git a/lib/python/base_cli/schemas/v1/command-protocol.schema.json b/lib/python/base_cli/schemas/v1/command-protocol.schema.json new file mode 100644 index 0000000..5263fd4 --- /dev/null +++ b/lib/python/base_cli/schemas/v1/command-protocol.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://basefoundry.github.io/base-cli/schemas/v1/command-protocol.schema.json", + "title": "base-cli decoded command-protocol payload", + "description": "Schema for the decoded representation of COMMAND_PROTOCOL_V1 frames.", + "type": "object", + "additionalProperties": false, + "required": ["protocol_header", "record_type", "record_count", "records"], + "properties": { + "protocol_header": {"const": "COMMAND_PROTOCOL_V1"}, + "record_type": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9-]*$"}, + "record_count": {"type": "integer", "minimum": 0, "maximum": 1000000}, + "records": {"type": "array", "maxItems": 1000000, "items": {"type": "object"}} + } +} diff --git a/lib/python/base_cli/schemas/v1/error.schema.json b/lib/python/base_cli/schemas/v1/error.schema.json new file mode 100644 index 0000000..d81aa75 --- /dev/null +++ b/lib/python/base_cli/schemas/v1/error.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://basefoundry.github.io/base-cli/schemas/v1/error.schema.json", + "title": "base-cli error envelope", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "schema", "code", "type", "message", "details", "run_id"], + "properties": { + "schema_version": {"const": 1}, + "schema": {"const": "base-cli.error"}, + "code": {"type": "string", "minLength": 1}, + "type": {"const": "error"}, + "message": {"type": "string"}, + "details": {"type": "object"}, + "run_id": {"type": ["string", "null"]} + } +} diff --git a/lib/python/base_cli/schemas/v1/inspection.schema.json b/lib/python/base_cli/schemas/v1/inspection.schema.json new file mode 100644 index 0000000..47ee682 --- /dev/null +++ b/lib/python/base_cli/schemas/v1/inspection.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://basefoundry.github.io/base-cli/schemas/v1/inspection.schema.json", + "title": "base-cli inspection envelope", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "command", "status", "data", "error"], + "properties": { + "schema_version": {"const": 1}, + "command": {"type": "string", "minLength": 1}, + "status": {"enum": ["ok", "warn", "error"]}, + "data": {"type": "object"}, + "error": {"type": ["object", "null"]} + } +} diff --git a/lib/python/base_cli/schemas/v1/log.schema.json b/lib/python/base_cli/schemas/v1/log.schema.json new file mode 100644 index 0000000..2d8508e --- /dev/null +++ b/lib/python/base_cli/schemas/v1/log.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://basefoundry.github.io/base-cli/schemas/v1/log.schema.json", + "title": "base-cli JSON log record", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "schema", "timestamp", "level", "logger", "message", "run_id"], + "properties": { + "schema_version": {"const": 1}, + "schema": {"const": "base-cli.log"}, + "timestamp": {"type": "string", "format": "date-time"}, + "level": {"type": "string", "minLength": 1}, + "logger": {"type": "string", "minLength": 1}, + "message": {"type": "string", "maxLength": 8193}, + "run_id": {"type": ["string", "null"]}, + "details": {"type": "object"} + } +} diff --git a/lib/python/base_cli/schemas/v1/ndjson.schema.json b/lib/python/base_cli/schemas/v1/ndjson.schema.json new file mode 100644 index 0000000..115f697 --- /dev/null +++ b/lib/python/base_cli/schemas/v1/ndjson.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://basefoundry.github.io/base-cli/schemas/v1/ndjson.schema.json", + "title": "base-cli NDJSON record", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "schema", "record"], + "properties": { + "schema_version": {"const": 1}, + "schema": {"const": "base-cli.record"}, + "record": {"type": "object"} + } +} diff --git a/lib/python/base_cli/schemas/v1/output.schema.json b/lib/python/base_cli/schemas/v1/output.schema.json new file mode 100644 index 0000000..83a865a --- /dev/null +++ b/lib/python/base_cli/schemas/v1/output.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://basefoundry.github.io/base-cli/schemas/v1/output.schema.json", + "title": "base-cli success envelope", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "schema", "code", "type", "message", "details", "run_id"], + "properties": { + "schema_version": {"const": 1}, + "schema": {"const": "base-cli.output"}, + "code": {"type": "string", "minLength": 1}, + "type": {"const": "success"}, + "message": {"type": "string"}, + "details": {"type": "object"}, + "run_id": {"type": ["string", "null"]} + } +} diff --git a/mkdocs.yml b/mkdocs.yml index 209d209..558c112 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,6 +43,7 @@ nav: - Migration guide: migrations.md - Output contracts: output-contracts.md - JSON contracts: json-contracts.md + - JSON Schema artifacts: schemas.md - Typed user configuration: user-config-typing.md - Entry-point extensions: extensions.md - Integrations: diff --git a/pyproject.toml b/pyproject.toml index 003ed14..5df2af5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,7 +101,7 @@ where = ["lib/python"] version = {file = "VERSION"} [tool.setuptools.package-data] -base_cli = ["py.typed"] +base_cli = ["py.typed", "schemas/v1/*.json"] [tool.pytest.ini_options] addopts = "-q" diff --git a/scripts/validate_docs.py b/scripts/validate_docs.py index 0585531..398b653 100644 --- a/scripts/validate_docs.py +++ b/scripts/validate_docs.py @@ -33,6 +33,7 @@ "releasing.md", "security-review.md", "security-threat-model.md", + "schemas.md", "typer-adapter.md", "user-config-typing.md", } diff --git a/scripts/validate_schemas.py b/scripts/validate_schemas.py new file mode 100644 index 0000000..1dfadb1 --- /dev/null +++ b/scripts/validate_schemas.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Validate and compare the packaged and documentation JSON Schema artifacts.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import NoReturn + +SCHEMA_ROOT = Path("schemas/v1") +REQUIRED_CONTRACT_KEYS = {"$schema", "$id", "title", "type", "required", "properties"} + + +def fail(message: str) -> NoReturn: + print(f"schema validation failed: {message}", file=sys.stderr) + raise SystemExit(1) + + +def _load(path: Path) -> object: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + fail(f"cannot parse {path}: {exc}") + + +def validate(root: Path) -> None: + package_root = root / "lib/python/base_cli" / SCHEMA_ROOT + docs_root = root / "docs" / SCHEMA_ROOT + package_files = sorted(package_root.glob("*.json")) + docs_files = sorted(docs_root.glob("*.json")) + if not package_files: + fail(f"no schemas found under {package_root}") + if [path.name for path in package_files] != [path.name for path in docs_files]: + fail("packaged and documentation schema filenames differ") + + for package_path, docs_path in zip(package_files, docs_files, strict=True): + package_payload = _load(package_path) + docs_payload = _load(docs_path) + if package_payload != docs_payload: + fail(f"packaged and documentation schemas differ for {package_path.name}") + if not isinstance(package_payload, dict) or not REQUIRED_CONTRACT_KEYS <= package_payload.keys(): + fail(f"{package_path.name} is missing required schema metadata") + if package_payload.get("$schema") != "https://json-schema.org/draft/2020-12/schema": + fail(f"{package_path.name} must use JSON Schema draft 2020-12") + if package_payload.get("type") != "object": + fail(f"{package_path.name} must describe an object") + if not isinstance(package_payload.get("required"), list): + fail(f"{package_path.name} must declare required fields") + print(f"Validated {package_path.name}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("root", type=Path, nargs="?", default=Path(__file__).resolve().parents[1]) + validate(parser.parse_args().root) + + +if __name__ == "__main__": + main() diff --git a/tests/test_validate_schemas.py b/tests/test_validate_schemas.py new file mode 100644 index 0000000..d871851 --- /dev/null +++ b/tests/test_validate_schemas.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from scripts import validate_schemas + + +class SchemaValidationTests(unittest.TestCase): + def test_repository_schemas_are_valid_and_in_sync(self) -> None: + validate_schemas.validate(Path(__file__).resolve().parents[1]) + + def test_schema_drift_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + package = root / "lib/python/base_cli/schemas/v1" + docs = root / "docs/schemas/v1" + package.mkdir(parents=True) + docs.mkdir(parents=True) + payload = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.test/schema", + "title": "test", + "type": "object", + "required": [], + "properties": {}, + } + (package / "test.json").write_text(json.dumps(payload), encoding="utf-8") + (docs / "test.json").write_text(json.dumps({**payload, "title": "drift"}), encoding="utf-8") + with self.assertRaises(SystemExit): + validate_schemas.validate(root) + + +if __name__ == "__main__": + unittest.main()