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.
- index_toml_contracts-0.1.0/PKG-INFO +21 -0
- index_toml_contracts-0.1.0/README.md +5 -0
- index_toml_contracts-0.1.0/pyproject.toml +34 -0
- index_toml_contracts-0.1.0/pyproject.toml.orig +28 -0
- index_toml_contracts-0.1.0/src/index_toml_contracts/__init__.py +141 -0
- index_toml_contracts-0.1.0/src/index_toml_contracts/index-v1.schema.json +61 -0
- index_toml_contracts-0.1.0/src/index_toml_contracts/workspace.py +46 -0
|
@@ -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: ...
|