index.toml-contracts 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.3
2
+ Name: index.toml-contracts
3
+ Version: 0.1.0
4
+ Summary: Schema and validation for ecosystem-neutral package indexes
5
+ Classifier: Programming Language :: Python :: 3.10
6
+ Classifier: Programming Language :: Python :: 3.11
7
+ Classifier: Programming Language :: Python :: 3.12
8
+ Classifier: Programming Language :: Python :: 3.13
9
+ Classifier: Programming Language :: Python :: 3.14
10
+ Classifier: Programming Language :: Python :: 3.15
11
+ Requires-Dist: jsonschema>=4.22,<5
12
+ Requires-Dist: tomli>=2,<3 ; python_full_version < '3.11'
13
+ Requires-Python: >=3.10, <3.16
14
+ Project-URL: Repository, https://github.com/groupsum/index.toml
15
+ Description-Content-Type: text/markdown
16
+
17
+ # index.toml Contracts
18
+
19
+ The distribution includes `index-v1.schema.json`, a JSON Schema for the data decoded from a UTF-8 TOML index. The Python API provides `schema()`, `validate(mapping)`, `loads(text)`, and `load(path)`. Invalid input raises `IndexValidationError`.
20
+
21
+ Validation covers record structure, portable paths, layer and package references, dependency policy, and cycles. Any language can use the bundled schema and implement the semantic rules described in the [repository contract](https://github.com/groupsum/index.toml/blob/master/README.md).
@@ -0,0 +1,5 @@
1
+ # index.toml Contracts
2
+
3
+ The distribution includes `index-v1.schema.json`, a JSON Schema for the data decoded from a UTF-8 TOML index. The Python API provides `schema()`, `validate(mapping)`, `loads(text)`, and `load(path)`. Invalid input raises `IndexValidationError`.
4
+
5
+ Validation covers record structure, portable paths, layer and package references, dependency policy, and cycles. Any language can use the bundled schema and implement the semantic rules described in the [repository contract](https://github.com/groupsum/index.toml/blob/master/README.md).
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "index.toml-contracts"
3
+ version = "0.1.0"
4
+ description = "Schema and validation for ecosystem-neutral package indexes"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10,<3.16"
7
+ classifiers = [
8
+ "Programming Language :: Python :: 3.10",
9
+ "Programming Language :: Python :: 3.11",
10
+ "Programming Language :: Python :: 3.12",
11
+ "Programming Language :: Python :: 3.13",
12
+ "Programming Language :: Python :: 3.14",
13
+ "Programming Language :: Python :: 3.15",
14
+ ]
15
+ dependencies = [
16
+ "jsonschema>=4.22,<5",
17
+ "tomli>=2,<3; python_version < '3.11'",
18
+ ]
19
+
20
+ [project.urls]
21
+ Repository = "https://github.com/groupsum/index.toml"
22
+
23
+ [[tool.uv.cache-keys]]
24
+ file = "pyproject.toml"
25
+
26
+ [[tool.uv.cache-keys]]
27
+ file = "README.md"
28
+
29
+ [[tool.uv.cache-keys]]
30
+ file = "src/**/*"
31
+
32
+ [build-system]
33
+ requires = ["uv_build>=0.9,<1"]
34
+ build-backend = "uv_build"
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "index.toml-contracts"
3
+ version = "0.1.0"
4
+ description = "Schema and validation for ecosystem-neutral package indexes"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10,<3.16"
7
+ classifiers = [
8
+ "Programming Language :: Python :: 3.10",
9
+ "Programming Language :: Python :: 3.11",
10
+ "Programming Language :: Python :: 3.12",
11
+ "Programming Language :: Python :: 3.13",
12
+ "Programming Language :: Python :: 3.14",
13
+ "Programming Language :: Python :: 3.15",
14
+ ]
15
+ dependencies = [
16
+ "jsonschema>=4.22,<5",
17
+ "tomli>=2,<3; python_version < '3.11'",
18
+ ]
19
+
20
+ [project.urls]
21
+ Repository = "https://github.com/groupsum/index.toml"
22
+
23
+ [tool.uv]
24
+ cache-keys = [{ file = "pyproject.toml" }, { file = "README.md" }, { file = "src/**/*" }]
25
+
26
+ [build-system]
27
+ requires = ["uv_build>=0.9,<1"]
28
+ build-backend = "uv_build"
@@ -0,0 +1,141 @@
1
+ """Portable package index schema and validation."""
2
+
3
+ import json
4
+ import sys
5
+ from graphlib import CycleError, TopologicalSorter
6
+ from importlib.resources import files
7
+ from pathlib import Path
8
+
9
+ from jsonschema import Draft202012Validator, validators
10
+
11
+ from .workspace import (
12
+ WorkspaceAdapter as WorkspaceAdapter,
13
+ WorkspaceDependency as WorkspaceDependency,
14
+ WorkspaceDiagnostic as WorkspaceDiagnostic,
15
+ WorkspaceMember as WorkspaceMember,
16
+ WorkspaceSnapshot as WorkspaceSnapshot,
17
+ )
18
+
19
+ if sys.version_info >= (3, 11):
20
+ import tomllib
21
+ else:
22
+ import tomli as tomllib
23
+
24
+
25
+ class IndexValidationError(ValueError):
26
+ """The document violates the index contract."""
27
+
28
+
29
+ def read_workspace_toml(path: Path) -> dict:
30
+ """Decode a workspace manifest without applying the package index schema."""
31
+ return tomllib.loads(path.read_bytes().decode("utf-8"))
32
+
33
+
34
+ def schema() -> dict:
35
+ """Return a fresh copy of the bundled JSON Schema."""
36
+ return json.loads(files(__package__).joinpath("index-v1.schema.json").read_text(
37
+ encoding="utf-8"
38
+ ))
39
+
40
+
41
+ # JSON Schema normally treats integral floats as integers. TOML distinguishes them.
42
+ _TomlValidator = validators.extend(
43
+ Draft202012Validator,
44
+ type_checker=Draft202012Validator.TYPE_CHECKER.redefine(
45
+ "integer", lambda checker, value: type(value) is int
46
+ ),
47
+ )
48
+ _validator = _TomlValidator(schema())
49
+ SCHEMA_VERSION = _validator.schema["properties"]["schema_version"]["const"]
50
+
51
+
52
+ def _by_id(records: list[dict], kind: str) -> dict[str, dict]:
53
+ by_id = {}
54
+ for record in records:
55
+ identifier = record["id"]
56
+ if identifier in by_id:
57
+ raise IndexValidationError(f"Duplicate {kind} ID {identifier!r}")
58
+ by_id[identifier] = record
59
+ return by_id
60
+
61
+
62
+ def _validate_path(path: str) -> None:
63
+ if path == ".":
64
+ return
65
+ invalid = '\\:<>|"?' + chr(42)
66
+ if any(char in invalid or ord(char) < 32 for char in path) or any(
67
+ part in ("", ".", "..") or part.endswith((".", " ")) for part in path.split("/")
68
+ ):
69
+ raise IndexValidationError(f"Invalid repository-relative path {path!r}")
70
+
71
+
72
+ def _validate_packages(layers: dict[str, dict], packages: dict[str, dict]) -> None:
73
+ for identifier, package in packages.items():
74
+ _validate_path(package["path"])
75
+ if package["layer"] not in layers:
76
+ raise IndexValidationError(f"Package {identifier!r} references an unknown layer")
77
+ dependencies = set(package.get("dependencies", []))
78
+ exceptions = set(package.get("dependency_exceptions", []))
79
+ if exceptions - dependencies:
80
+ raise IndexValidationError(f"Package {identifier!r} excepts undeclared dependencies")
81
+ for dependency in dependencies:
82
+ if dependency == identifier or dependency not in packages:
83
+ raise IndexValidationError(f"Invalid dependency {identifier!r} -> {dependency!r}")
84
+ if dependency in exceptions:
85
+ continue
86
+ target = packages[dependency]
87
+ source_layer = layers[package["layer"]]
88
+ target_layer = layers[target["layer"]]
89
+ allowed = source_layer.get("allowed_dependency_layers")
90
+ if package["layer"] == target["layer"]:
91
+ permitted = target["order"] < package["order"]
92
+ elif allowed is None:
93
+ permitted = target_layer["order"] < source_layer["order"]
94
+ else:
95
+ permitted = target["layer"] in allowed
96
+ if not permitted:
97
+ raise IndexValidationError(f"Dependency policy rejects {identifier!r} -> {dependency!r}")
98
+
99
+ graph = TopologicalSorter({
100
+ identifier: package.get("dependencies", [])
101
+ for identifier, package in packages.items()
102
+ })
103
+ try:
104
+ graph.prepare()
105
+ except CycleError as error:
106
+ raise IndexValidationError(f"Package dependency cycle: {' -> '.join(error.args[1])}") from error
107
+
108
+
109
+ def validate(index: dict) -> None:
110
+ """Validate the decoded TOML structure, references, paths and dependency graph."""
111
+ error = next(_validator.iter_errors(index), None)
112
+ if error is not None:
113
+ raise IndexValidationError(f"{error.json_path}: {error.message}")
114
+
115
+ layers = _by_id(index["layers"], "layer")
116
+ packages = _by_id(index["packages"], "package")
117
+ if len({layer["order"] for layer in layers.values()}) != len(layers):
118
+ raise IndexValidationError("Layer orders must be unique")
119
+ for identifier, layer in layers.items():
120
+ for target in layer.get("allowed_dependency_layers", []):
121
+ if target == identifier or target not in layers:
122
+ raise IndexValidationError(f"Invalid allowed layer {identifier!r} -> {target!r}")
123
+ _validate_packages(layers, packages)
124
+
125
+
126
+ def loads(source: str) -> dict:
127
+ """Parse and validate an index from TOML text."""
128
+ try:
129
+ index = tomllib.loads(source)
130
+ except tomllib.TOMLDecodeError as error:
131
+ raise IndexValidationError(f"Invalid TOML: {error}") from error
132
+ validate(index)
133
+ return index
134
+
135
+
136
+ def load(path: str | Path) -> dict:
137
+ """Read, parse and validate an index from disk."""
138
+ try:
139
+ return loads(Path(path).read_text(encoding="utf-8"))
140
+ except UnicodeDecodeError as error:
141
+ raise IndexValidationError("Index must be UTF-8") from error
@@ -0,0 +1,61 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Package index 1.0.0",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "required": ["schema_version", "layers", "packages"],
7
+ "properties": {
8
+ "schema_version": {"type": "string", "const": "1.0.0"},
9
+ "layers": {
10
+ "type": "array",
11
+ "items": {"$ref": "#/$defs/layer"}
12
+ },
13
+ "packages": {
14
+ "type": "array",
15
+ "items": {"$ref": "#/$defs/package"}
16
+ }
17
+ },
18
+ "$defs": {
19
+ "id": {
20
+ "type": "string",
21
+ "pattern": "^[a-z0-9][a-z0-9._-]*$"
22
+ },
23
+ "order": {
24
+ "type": "integer",
25
+ "minimum": 0,
26
+ "maximum": 9223372036854775807
27
+ },
28
+ "ids": {
29
+ "type": "array",
30
+ "uniqueItems": true,
31
+ "items": {"$ref": "#/$defs/id"}
32
+ },
33
+ "layer": {
34
+ "type": "object",
35
+ "additionalProperties": false,
36
+ "required": ["id", "order"],
37
+ "properties": {
38
+ "id": {"$ref": "#/$defs/id"},
39
+ "order": {"$ref": "#/$defs/order"},
40
+ "description": {"type": "string"},
41
+ "allowed_dependency_layers": {"$ref": "#/$defs/ids"}
42
+ }
43
+ },
44
+ "package": {
45
+ "type": "object",
46
+ "additionalProperties": false,
47
+ "required": ["id", "layer", "order", "path"],
48
+ "properties": {
49
+ "id": {"$ref": "#/$defs/id"},
50
+ "layer": {"$ref": "#/$defs/id"},
51
+ "order": {"$ref": "#/$defs/order"},
52
+ "path": {"type": "string", "minLength": 1},
53
+ "name": {"type": "string", "minLength": 1},
54
+ "ecosystem": {"type": "string", "minLength": 1},
55
+ "description": {"type": "string"},
56
+ "dependencies": {"$ref": "#/$defs/ids"},
57
+ "dependency_exceptions": {"$ref": "#/$defs/ids"}
58
+ }
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,46 @@
1
+ """Shared types for read-only workspace discovery."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Protocol
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class WorkspaceMember:
10
+ name: str
11
+ identity: str
12
+ path: str
13
+ manifest: str
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class WorkspaceDependency:
18
+ source: str
19
+ target: str
20
+ category: str
21
+ manifest: str
22
+ declaration: str
23
+ condition: str | None = None
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class WorkspaceDiagnostic:
28
+ code: str
29
+ manifest: str
30
+ message: str
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class WorkspaceSnapshot:
35
+ members: tuple[WorkspaceMember, ...] = ()
36
+ dependencies: tuple[WorkspaceDependency, ...] = ()
37
+ diagnostics: tuple[WorkspaceDiagnostic, ...] = ()
38
+
39
+
40
+ class WorkspaceAdapter(Protocol):
41
+ name: str
42
+ ecosystem: str
43
+
44
+ def normalize_name(self, name: str) -> str: ...
45
+
46
+ def discover(self, root: Path) -> WorkspaceSnapshot: ...