index.toml-core 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,15 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: index.toml-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: File-backed editor 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: filelock>=3.16,<4
|
|
12
|
+
Requires-Dist: index-toml-contracts>=0.1.0,<0.2
|
|
13
|
+
Requires-Dist: tomlkit>=0.13,<1
|
|
14
|
+
Requires-Python: >=3.10, <3.16
|
|
15
|
+
Project-URL: Repository, https://github.com/groupsum/index.toml
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "index.toml-core"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "File-backed editor for ecosystem-neutral package indexes"
|
|
5
|
+
requires-python = ">=3.10,<3.16"
|
|
6
|
+
classifiers = [
|
|
7
|
+
"Programming Language :: Python :: 3.10",
|
|
8
|
+
"Programming Language :: Python :: 3.11",
|
|
9
|
+
"Programming Language :: Python :: 3.12",
|
|
10
|
+
"Programming Language :: Python :: 3.13",
|
|
11
|
+
"Programming Language :: Python :: 3.14",
|
|
12
|
+
"Programming Language :: Python :: 3.15",
|
|
13
|
+
]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"filelock>=3.16,<4",
|
|
16
|
+
"index.toml-contracts>=0.1.0,<0.2",
|
|
17
|
+
"tomlkit>=0.13,<1",
|
|
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 = "src/**/*"
|
|
28
|
+
|
|
29
|
+
[tool.uv.sources."index.toml-contracts"]
|
|
30
|
+
workspace = true
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["uv_build>=0.9,<1"]
|
|
34
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "index.toml-core"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "File-backed editor for ecosystem-neutral package indexes"
|
|
5
|
+
requires-python = ">=3.10,<3.16"
|
|
6
|
+
classifiers = [
|
|
7
|
+
"Programming Language :: Python :: 3.10",
|
|
8
|
+
"Programming Language :: Python :: 3.11",
|
|
9
|
+
"Programming Language :: Python :: 3.12",
|
|
10
|
+
"Programming Language :: Python :: 3.13",
|
|
11
|
+
"Programming Language :: Python :: 3.14",
|
|
12
|
+
"Programming Language :: Python :: 3.15",
|
|
13
|
+
]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"filelock>=3.16,<4",
|
|
16
|
+
"index.toml-contracts>=0.1.0,<0.2",
|
|
17
|
+
"tomlkit>=0.13,<1",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Repository = "https://github.com/groupsum/index.toml"
|
|
22
|
+
|
|
23
|
+
[tool.uv]
|
|
24
|
+
cache-keys = [{ file = "pyproject.toml" }, { file = "src/**/*" }]
|
|
25
|
+
|
|
26
|
+
[tool.uv.sources]
|
|
27
|
+
"index.toml-contracts" = { workspace = true }
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["uv_build>=0.9,<1"]
|
|
31
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Read, validate and edit a repository's package index."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from tempfile import NamedTemporaryFile
|
|
6
|
+
|
|
7
|
+
import tomlkit
|
|
8
|
+
from filelock import FileLock
|
|
9
|
+
from index_toml_contracts import SCHEMA_VERSION, IndexValidationError, load, loads
|
|
10
|
+
|
|
11
|
+
__all__ = ["IndexFile", "IndexValidationError", "load", "loads"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class IndexFile:
|
|
15
|
+
"""File-backed CRUD for layers and packages, addressed by stable IDs."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, path: str | Path = "pkgs/index.toml"):
|
|
18
|
+
self.path = Path(path).resolve()
|
|
19
|
+
self._lock = FileLock(f"{self.path}.lock", timeout=10)
|
|
20
|
+
|
|
21
|
+
def initialize(self) -> None:
|
|
22
|
+
"""Create an empty v1 index at an unused path."""
|
|
23
|
+
with self._lock:
|
|
24
|
+
if self.path.exists():
|
|
25
|
+
raise FileExistsError(self.path)
|
|
26
|
+
self._write(f'schema_version = "{SCHEMA_VERSION}"\nlayers = []\npackages = []\n')
|
|
27
|
+
|
|
28
|
+
def read(self) -> dict:
|
|
29
|
+
return load(self.path)
|
|
30
|
+
|
|
31
|
+
def list_layers(self) -> list[dict]:
|
|
32
|
+
return sorted(self.read()["layers"], key=lambda record: record["order"])
|
|
33
|
+
|
|
34
|
+
def list_packages(self, layer: str | None = None) -> list[dict]:
|
|
35
|
+
index = self.read()
|
|
36
|
+
orders = {record["id"]: record["order"] for record in index["layers"]}
|
|
37
|
+
return sorted(
|
|
38
|
+
(record for record in index["packages"] if layer is None or record["layer"] == layer),
|
|
39
|
+
key=lambda record: (orders[record["layer"]], record["order"], record["id"]),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def get_layer(self, identifier: str) -> dict:
|
|
43
|
+
return self._get("layers", identifier)
|
|
44
|
+
|
|
45
|
+
def get_package(self, identifier: str) -> dict:
|
|
46
|
+
return self._get("packages", identifier)
|
|
47
|
+
|
|
48
|
+
def add_layer(self, identifier: str, *, order: int, **fields) -> None:
|
|
49
|
+
self._edit("layers", identifier, "add", {"order": order, **fields})
|
|
50
|
+
|
|
51
|
+
def add_package(self, identifier: str, *, layer: str, order: int, path: str, **fields) -> None:
|
|
52
|
+
self._edit("packages", identifier, "add", {
|
|
53
|
+
"layer": layer, "order": order, "path": path, **fields,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
def update_layer(self, identifier: str, **changes) -> None:
|
|
57
|
+
self._edit("layers", identifier, "update", changes)
|
|
58
|
+
|
|
59
|
+
def update_package(self, identifier: str, **changes) -> None:
|
|
60
|
+
self._edit("packages", identifier, "update", changes)
|
|
61
|
+
|
|
62
|
+
def delete_layer(self, identifier: str) -> None:
|
|
63
|
+
self._edit("layers", identifier, "delete", {})
|
|
64
|
+
|
|
65
|
+
def delete_package(self, identifier: str) -> None:
|
|
66
|
+
self._edit("packages", identifier, "delete", {})
|
|
67
|
+
|
|
68
|
+
def _get(self, collection: str, identifier: str) -> dict:
|
|
69
|
+
records = self.read()[collection]
|
|
70
|
+
return records[self._position(records, identifier)]
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _position(records, identifier: str) -> int:
|
|
74
|
+
for position, record in enumerate(records):
|
|
75
|
+
if record["id"] == identifier:
|
|
76
|
+
return position
|
|
77
|
+
raise KeyError(identifier)
|
|
78
|
+
|
|
79
|
+
def _edit(self, collection: str, identifier: str, action: str, changes: dict) -> None:
|
|
80
|
+
if "id" in changes:
|
|
81
|
+
raise IndexValidationError("Record IDs are immutable")
|
|
82
|
+
with self._lock:
|
|
83
|
+
with self.path.open(encoding="utf-8", newline="") as stream:
|
|
84
|
+
original = stream.read()
|
|
85
|
+
loads(original)
|
|
86
|
+
document = tomlkit.parse(original)
|
|
87
|
+
records = document[collection]
|
|
88
|
+
if action == "add":
|
|
89
|
+
if any(record["id"] == identifier for record in records):
|
|
90
|
+
raise IndexValidationError(f"Duplicate record ID {identifier!r}")
|
|
91
|
+
data = {"id": identifier, **changes}
|
|
92
|
+
if isinstance(records, tomlkit.items.AoT):
|
|
93
|
+
record = tomlkit.table()
|
|
94
|
+
record.update(data)
|
|
95
|
+
records.append(record)
|
|
96
|
+
else:
|
|
97
|
+
record = tomlkit.inline_table()
|
|
98
|
+
record.update(data)
|
|
99
|
+
records.append(record)
|
|
100
|
+
else:
|
|
101
|
+
position = self._position(records, identifier)
|
|
102
|
+
if action == "delete":
|
|
103
|
+
del records[position]
|
|
104
|
+
if not records and isinstance(records, tomlkit.items.AoT):
|
|
105
|
+
document[collection] = tomlkit.array()
|
|
106
|
+
else:
|
|
107
|
+
record = records[position]
|
|
108
|
+
for key, value in changes.items():
|
|
109
|
+
if value is None:
|
|
110
|
+
if key not in record:
|
|
111
|
+
raise IndexValidationError(f"Cannot remove absent field {key!r}")
|
|
112
|
+
del record[key]
|
|
113
|
+
else:
|
|
114
|
+
record[key] = value
|
|
115
|
+
candidate = document.as_string()
|
|
116
|
+
loads(candidate)
|
|
117
|
+
if candidate != original:
|
|
118
|
+
self._write(candidate)
|
|
119
|
+
|
|
120
|
+
def _write(self, content: str) -> None:
|
|
121
|
+
temporary = None
|
|
122
|
+
try:
|
|
123
|
+
with NamedTemporaryFile(
|
|
124
|
+
dir=self.path.parent, prefix=f".{self.path.name}.", suffix=".tmp",
|
|
125
|
+
mode="w", encoding="utf-8", newline="", delete=False,
|
|
126
|
+
) as stream:
|
|
127
|
+
temporary = Path(stream.name)
|
|
128
|
+
stream.write(content)
|
|
129
|
+
stream.flush()
|
|
130
|
+
os.fsync(stream.fileno())
|
|
131
|
+
if self.path.exists():
|
|
132
|
+
os.chmod(temporary, self.path.stat().st_mode & 0o777)
|
|
133
|
+
temporary.replace(self.path)
|
|
134
|
+
finally:
|
|
135
|
+
if temporary is not None:
|
|
136
|
+
temporary.unlink(missing_ok=True)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Compare adapter snapshots with declared package identities and edges."""
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from index_toml_contracts.workspace import (
|
|
8
|
+
WorkspaceAdapter, WorkspaceDependency, WorkspaceDiagnostic, WorkspaceMember,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from . import IndexFile
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class WorkspaceDifference:
|
|
16
|
+
code: str
|
|
17
|
+
package_id: str | None = None
|
|
18
|
+
identity: str | None = None
|
|
19
|
+
manifest: str | None = None
|
|
20
|
+
expected: str | tuple[str, ...] | None = None
|
|
21
|
+
actual: str | tuple[str, ...] | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class WorkspaceCheck:
|
|
26
|
+
adapter: str
|
|
27
|
+
ecosystem: str
|
|
28
|
+
matches: tuple[tuple[str, WorkspaceMember], ...]
|
|
29
|
+
differences: tuple[WorkspaceDifference, ...]
|
|
30
|
+
diagnostics: tuple[WorkspaceDiagnostic, ...]
|
|
31
|
+
dependencies: tuple[WorkspaceDependency, ...] = ()
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def ok(self) -> bool:
|
|
35
|
+
return not (self.differences or self.diagnostics)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def check_workspace(index: IndexFile, root: Path, adapter: WorkspaceAdapter) -> WorkspaceCheck:
|
|
39
|
+
"""Read the index and compare it with the selected workspace without writing."""
|
|
40
|
+
document = index.read()
|
|
41
|
+
snapshot = adapter.discover(root.resolve())
|
|
42
|
+
if snapshot.diagnostics:
|
|
43
|
+
diagnostics = sorted(snapshot.diagnostics, key=lambda item: (item.manifest, item.code, item.message))
|
|
44
|
+
return WorkspaceCheck(adapter.name, adapter.ecosystem, (), (), tuple(diagnostics))
|
|
45
|
+
|
|
46
|
+
packages = sorted(
|
|
47
|
+
(package for package in document["packages"] if package.get("ecosystem") == adapter.ecosystem),
|
|
48
|
+
key=lambda package: package["id"],
|
|
49
|
+
)
|
|
50
|
+
by_path = defaultdict(list)
|
|
51
|
+
by_name = defaultdict(list)
|
|
52
|
+
for member in sorted(snapshot.members, key=lambda member: (member.path, member.identity)):
|
|
53
|
+
by_path[member.path].append(member)
|
|
54
|
+
by_name[member.identity].append(member)
|
|
55
|
+
|
|
56
|
+
candidates = defaultdict(list)
|
|
57
|
+
differences = []
|
|
58
|
+
claimed = set()
|
|
59
|
+
for package in packages:
|
|
60
|
+
identifier = package["id"]
|
|
61
|
+
identity = adapter.normalize_name(package.get("name", identifier))
|
|
62
|
+
paths = by_path[package["path"]]
|
|
63
|
+
names = by_name[identity]
|
|
64
|
+
exact = [member for member in paths if member in names]
|
|
65
|
+
options = exact or list(dict.fromkeys([*paths, *names]))
|
|
66
|
+
if len(options) > 1:
|
|
67
|
+
claimed.update(member.path for member in options)
|
|
68
|
+
differences.append(WorkspaceDifference(
|
|
69
|
+
"ambiguous_match", identifier, identity, actual=tuple(member.manifest for member in options),
|
|
70
|
+
))
|
|
71
|
+
elif options:
|
|
72
|
+
member = options[0]
|
|
73
|
+
claimed.add(member.path)
|
|
74
|
+
candidates[member.path].append((package, identity, member))
|
|
75
|
+
else:
|
|
76
|
+
differences.append(WorkspaceDifference("missing_workspace_member", identifier, identity, expected=package["path"]))
|
|
77
|
+
|
|
78
|
+
matches = {}
|
|
79
|
+
for path, options in sorted(candidates.items()):
|
|
80
|
+
if len(options) > 1:
|
|
81
|
+
differences.extend(
|
|
82
|
+
WorkspaceDifference("ambiguous_match", package["id"], identity, member.manifest, actual=path)
|
|
83
|
+
for package, identity, member in options
|
|
84
|
+
)
|
|
85
|
+
continue
|
|
86
|
+
package, identity, member = options[0]
|
|
87
|
+
matches[package["id"]] = member
|
|
88
|
+
if path != package["path"]:
|
|
89
|
+
differences.append(WorkspaceDifference(
|
|
90
|
+
"path_mismatch", package["id"], identity, member.manifest, package["path"], path,
|
|
91
|
+
))
|
|
92
|
+
if identity != member.identity:
|
|
93
|
+
differences.append(WorkspaceDifference(
|
|
94
|
+
"identity_mismatch", package["id"], identity, member.manifest,
|
|
95
|
+
package.get("name", package["id"]), member.name,
|
|
96
|
+
))
|
|
97
|
+
|
|
98
|
+
for member in sorted(snapshot.members, key=lambda member: (member.path, member.identity)):
|
|
99
|
+
if member.path not in claimed:
|
|
100
|
+
differences.append(WorkspaceDifference(
|
|
101
|
+
"missing_index_record", identity=member.identity, manifest=member.manifest, actual=member.path,
|
|
102
|
+
))
|
|
103
|
+
|
|
104
|
+
ids = {member.path: identifier for identifier, member in matches.items()}
|
|
105
|
+
actual_edges = defaultdict(set)
|
|
106
|
+
for edge in snapshot.dependencies:
|
|
107
|
+
if edge.source in ids and edge.target in ids:
|
|
108
|
+
actual_edges[ids[edge.source]].add(ids[edge.target])
|
|
109
|
+
selected_ids = {package["id"] for package in packages}
|
|
110
|
+
for package in packages:
|
|
111
|
+
identifier = package["id"]
|
|
112
|
+
if identifier not in matches:
|
|
113
|
+
continue
|
|
114
|
+
expected = set(package.get("dependencies", [])) & selected_ids
|
|
115
|
+
actual = actual_edges[identifier]
|
|
116
|
+
if expected != actual:
|
|
117
|
+
member = matches[identifier]
|
|
118
|
+
differences.append(WorkspaceDifference(
|
|
119
|
+
"dependency_mismatch", identifier, member.identity, member.manifest,
|
|
120
|
+
tuple(sorted(expected)), tuple(sorted(actual)),
|
|
121
|
+
))
|
|
122
|
+
|
|
123
|
+
differences.sort(key=lambda item: (item.code, item.package_id or "", item.identity or "", item.manifest or ""))
|
|
124
|
+
dependencies = tuple(sorted(snapshot.dependencies, key=lambda edge: (
|
|
125
|
+
edge.source, edge.target, edge.category, edge.manifest, edge.declaration, edge.condition or "",
|
|
126
|
+
)))
|
|
127
|
+
return WorkspaceCheck(
|
|
128
|
+
adapter.name, adapter.ecosystem, tuple(sorted(matches.items())), tuple(differences), (), dependencies,
|
|
129
|
+
)
|