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
+ )