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",