From f1f5b2fc6268512c1ad49bf00f55a745f4778d07 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:29:26 -0400 Subject: [PATCH] Publish the manifest JSON Schema and document the version ordering artlink.manifest/v0 now has a JSON Schema at artlink/schemas/artlink.manifest-v0.json, generated from the Manifest model by manifest_json_schema() and held equal to it by a test; written manifests and the README's examples validate against it, and the schema refuses what the model refuses. The README states the version comparison rule the highest policy uses, including that it is lexical and sorts a pre-release suffix above its release. Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- README.md | 4 +- artlink/manifest.py | 13 ++ artlink/schemas/artlink.manifest-v0.json | 244 +++++++++++++++++++++++ artlink/tests/test_manifest_schema.py | 61 ++++++ pyproject.toml | 1 + 5 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 artlink/schemas/artlink.manifest-v0.json create mode 100644 artlink/tests/test_manifest_schema.py diff --git a/README.md b/README.md index 4f5102a..8ed3de4 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A package for packaging, organizing, and combining artifacts The current core schemas are: -- `artlink.manifest/v0` for artifact manifests. +- `artlink.manifest/v0` for artifact manifests; its JSON Schema is published at `artlink/schemas/artlink.manifest-v0.json` and `artlink.manifest_json_schema()` returns the same document. - `artlink.template/v0` for declarative manifest validation templates. - `artlink.registry/v0` for registry configuration. @@ -253,6 +253,8 @@ Each entry point may load a `Manifest`, a manifest file path, a callable returni By default, a registry rejects multiple manifests with the same name. This is useful for ecosystems such as Python where one installed distribution should own a package name. Hardware package managers often need multiple versions of a reusable block side by side, so they can opt in with `ArtifactRegistry(allow_manifest_versions=True)` or `ArtifactRegistry.from_install_path(prefix, allow_manifest_versions=True)`. Versioned registries resolve `Reference(kind="manifest", target="hdl-filter", version="2.0.0")`; an unversioned reference is rejected if more than one version is available. +With `manifest_version_policy="highest"`, an unversioned reference to a name with several versions resolves to the highest one, where versions are compared by splitting the string into runs of digits and runs of other characters: digit runs compare as integers (so `1.10` is above `1.9`), other runs compare as text, a digit run sorts below a text run at the same position, and a version with more runs sorts above its prefix (so `2.0.0-rc1` is above `2.0.0`). This is a lexical rule, not a release-aware one; artifact ecosystems with pre-release conventions should pin versions in their references. + Registry YAML is a tool configuration file, not an artifact manifest. Its job is to tell a consuming process which install roots, manifest files, template files, and direct local artifacts should be visible in an `ArtifactRegistry`. `load_registry(path)` resolves relative `install_roots`, `manifest_files`, `template_files`, and direct artifact roots relative to the registry config file. The artlink-owned shape uses a registry schema id: diff --git a/artlink/manifest.py b/artlink/manifest.py index 8d2448d..75e9197 100644 --- a/artlink/manifest.py +++ b/artlink/manifest.py @@ -10,15 +10,19 @@ __all__ = ( "ARTLINK_MANIFEST_SCHEMA", + "MANIFEST_JSON_SCHEMA_PATH", "Manifest", "artifact_path", "load_manifest", "manifest_from_mapping", + "manifest_json_schema", "validate_artifact_files", ) ARTLINK_MANIFEST_SCHEMA = "artlink.manifest/v0" +# the published JSON Schema for that schema id; a test holds it equal to the model +MANIFEST_JSON_SCHEMA_PATH = Path(__file__).resolve().parent / "schemas" / "artlink.manifest-v0.json" class Manifest(_ManifestModel): @@ -72,6 +76,15 @@ def to_yaml_text(self) -> str: return yaml.safe_dump(self.model_dump(mode="json"), sort_keys=False) +def manifest_json_schema() -> dict[str, Any]: + """The JSON Schema of ``artlink.manifest/v0`` as the ``Manifest`` model + defines it, keyed by the yaml field names.""" + schema = Manifest.model_json_schema(by_alias=True) + schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" + schema["$id"] = "https://github.com/dau-dev/artlink/blob/main/artlink/schemas/artlink.manifest-v0.json" + return schema + + def load_manifest(path: Path, *, validate_paths: bool = False, root: Path | None = None) -> Manifest: """Read a manifest file. Every failure, unreadable file, bad YAML or an invalid document, is a ManifestError.""" diff --git a/artlink/schemas/artlink.manifest-v0.json b/artlink/schemas/artlink.manifest-v0.json new file mode 100644 index 0000000..71e92e9 --- /dev/null +++ b/artlink/schemas/artlink.manifest-v0.json @@ -0,0 +1,244 @@ +{ + "$defs": { + "Artifact": { + "additionalProperties": false, + "description": "A packageable or consumable unit of content.", + "properties": { + "digest": { + "anyOf": [ + { + "$ref": "#/$defs/Digest" + }, + { + "type": "null" + } + ], + "default": null + }, + "format": { + "default": "", + "title": "Format", + "type": "string" + }, + "id": { + "default": "", + "title": "Id", + "type": "string" + }, + "kind": { + "default": "", + "title": "Kind", + "type": "string" + }, + "language": { + "default": "", + "title": "Language", + "type": "string" + }, + "media_type": { + "default": "", + "title": "Media Type", + "type": "string" + }, + "metadata": { + "additionalProperties": true, + "title": "Metadata", + "type": "object" + }, + "name": { + "default": "", + "title": "Name", + "type": "string" + }, + "path": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "provides": { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Capability" + }, + { + "type": "string" + }, + { + "additionalProperties": true, + "type": "object" + } + ] + }, + "title": "Provides", + "type": "array" + }, + "requires": { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Capability" + }, + { + "type": "string" + }, + { + "additionalProperties": true, + "type": "object" + } + ] + }, + "title": "Requires", + "type": "array" + }, + "role": { + "title": "Role", + "type": "string" + }, + "uri": { + "default": "", + "title": "Uri", + "type": "string" + } + }, + "required": [ + "role" + ], + "title": "Artifact", + "type": "object" + }, + "Capability": { + "additionalProperties": false, + "description": "A named capability that an artifact provides or requires.", + "properties": { + "kind": { + "default": "", + "title": "Kind", + "type": "string" + }, + "metadata": { + "additionalProperties": true, + "title": "Metadata", + "type": "object" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "Capability", + "type": "object" + }, + "Digest": { + "additionalProperties": false, + "properties": { + "algorithm": { + "title": "Algorithm", + "type": "string" + }, + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "algorithm", + "value" + ], + "title": "Digest", + "type": "object" + }, + "Reference": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "manifest", + "template" + ], + "title": "Kind", + "type": "string" + }, + "metadata": { + "additionalProperties": true, + "title": "Metadata", + "type": "object" + }, + "target": { + "title": "Target", + "type": "string" + }, + "version": { + "default": "", + "title": "Version", + "type": "string" + } + }, + "required": [ + "kind", + "target" + ], + "title": "Reference", + "type": "object" + } + }, + "$id": "https://github.com/dau-dev/artlink/blob/main/artlink/schemas/artlink.manifest-v0.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "artifacts": { + "items": { + "$ref": "#/$defs/Artifact" + }, + "title": "Artifacts", + "type": "array" + }, + "intent": { + "default": "", + "title": "Intent", + "type": "string" + }, + "metadata": { + "additionalProperties": true, + "title": "Metadata", + "type": "object" + }, + "name": { + "title": "Name", + "type": "string" + }, + "references": { + "items": { + "$ref": "#/$defs/Reference" + }, + "title": "References", + "type": "array" + }, + "schema": { + "default": "artlink.manifest/v0", + "title": "Schema", + "type": "string" + }, + "version": { + "default": "", + "title": "Version", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "Manifest", + "type": "object" +} diff --git a/artlink/tests/test_manifest_schema.py b/artlink/tests/test_manifest_schema.py new file mode 100644 index 0000000..563f97b --- /dev/null +++ b/artlink/tests/test_manifest_schema.py @@ -0,0 +1,61 @@ +"""``artlink.manifest/v0`` has a published JSON Schema, generated from the +``Manifest`` model and held equal to it, and the manifests this package +writes and documents validate against it.""" + +import json +import re +from pathlib import Path + +import jsonschema +import yaml + +from artlink import Artifact, Capability, Digest, Manifest, manifest_json_schema +from artlink.manifest import MANIFEST_JSON_SCHEMA_PATH + +README = Path(__file__).resolve().parents[2] / "README.md" + + +def test_the_published_schema_is_the_model(): + published = json.loads(MANIFEST_JSON_SCHEMA_PATH.read_text(encoding="utf-8")) + assert published == manifest_json_schema(), "regenerate artlink/schemas/artlink.manifest-v0.json from manifest_json_schema()" + assert published["$schema"] == "https://json-schema.org/draft/2020-12/schema" + jsonschema.Draft202012Validator.check_schema(published) + + +def test_a_written_manifest_validates_against_the_schema(): + manifest = Manifest( + name="golden", + version="1.2.3", + intent="input", + artifacts=( + Artifact( + id="rtl", + path=Path("rtl/filter.sv"), + kind="source", + role="hdl-source", + provides=(Capability(kind="hdl-module", name="filter"), "filter"), + digest=Digest(algorithm="sha256", value="a" * 64), + ), + Artifact(uri="https://example.invalid/doc.pdf", role="docs"), + ), + ) + document = yaml.safe_load(manifest.to_yaml_text()) + jsonschema.validate(document, manifest_json_schema()) + assert Manifest(**document) == manifest + + +def test_the_readme_manifest_examples_validate_against_the_schema(): + text = README.read_text(encoding="utf-8") + documents = [yaml.safe_load(block) for block in re.findall(r"```yaml\n(.*?)```", text, re.DOTALL)] + manifests = [doc for doc in documents if isinstance(doc, dict) and doc.get("schema") == "artlink.manifest/v0"] + assert manifests + for document in manifests: + jsonschema.validate(document, manifest_json_schema()) + + +def test_the_schema_refuses_what_the_model_refuses(): + schema = manifest_json_schema() + validator = jsonschema.Draft202012Validator(schema) + assert list(validator.iter_errors({"schema": "artlink.manifest/v0", "name": "m", "unknown": 1})) # additionalProperties + assert list(validator.iter_errors({"schema": "artlink.manifest/v0"})) # name required + assert list(validator.iter_errors({"schema": "artlink.manifest/v0", "name": "m", "references": [{"kind": "package", "target": "x"}]})) diff --git a/pyproject.toml b/pyproject.toml index 758e550..7571415 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ develop = [ "check-dist", "codespell", "hatchling", + "jsonschema", "mdformat", "mdformat-tables>=1", "pytest",