openexit 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.
Files changed (40) hide show
  1. openexit-0.1.0/IMPLEMENTATION_NOTES.md +25 -0
  2. openexit-0.1.0/LICENSE +21 -0
  3. openexit-0.1.0/MANIFEST.in +2 -0
  4. openexit-0.1.0/PKG-INFO +81 -0
  5. openexit-0.1.0/README.md +56 -0
  6. openexit-0.1.0/pyproject.toml +41 -0
  7. openexit-0.1.0/setup.cfg +4 -0
  8. openexit-0.1.0/src/openexit/__init__.py +20 -0
  9. openexit-0.1.0/src/openexit/bundle.py +24 -0
  10. openexit-0.1.0/src/openexit/constants.py +2 -0
  11. openexit-0.1.0/src/openexit/errors.py +16 -0
  12. openexit-0.1.0/src/openexit/inspection.py +191 -0
  13. openexit-0.1.0/src/openexit/integrity.py +23 -0
  14. openexit-0.1.0/src/openexit/manifest.py +37 -0
  15. openexit-0.1.0/src/openexit/models.py +112 -0
  16. openexit-0.1.0/src/openexit/paths.py +27 -0
  17. openexit-0.1.0/src/openexit/schemas/__init__.py +0 -0
  18. openexit-0.1.0/src/openexit/schemas/asset.schema.json +17 -0
  19. openexit-0.1.0/src/openexit/schemas/checkpoint.schema.json +12 -0
  20. openexit-0.1.0/src/openexit/schemas/event.schema.json +25 -0
  21. openexit-0.1.0/src/openexit/schemas/inspection-result.schema.json +15 -0
  22. openexit-0.1.0/src/openexit/schemas/manifest.schema.json +22 -0
  23. openexit-0.1.0/src/openexit/schemas/relationship.schema.json +26 -0
  24. openexit-0.1.0/src/openexit/schemas/resource-chunk.schema.json +20 -0
  25. openexit-0.1.0/src/openexit/schemas/resource.schema.json +21 -0
  26. openexit-0.1.0/src/openexit/schemas/scope.schema.json +13 -0
  27. openexit-0.1.0/src/openexit/validation.py +65 -0
  28. openexit-0.1.0/src/openexit/version.py +1 -0
  29. openexit-0.1.0/src/openexit.egg-info/PKG-INFO +81 -0
  30. openexit-0.1.0/src/openexit.egg-info/SOURCES.txt +38 -0
  31. openexit-0.1.0/src/openexit.egg-info/dependency_links.txt +1 -0
  32. openexit-0.1.0/src/openexit.egg-info/requires.txt +6 -0
  33. openexit-0.1.0/src/openexit.egg-info/top_level.txt +1 -0
  34. openexit-0.1.0/tests/test_conformance.py +26 -0
  35. openexit-0.1.0/tests/test_errors.py +8 -0
  36. openexit-0.1.0/tests/test_inspection.py +61 -0
  37. openexit-0.1.0/tests/test_integrity.py +21 -0
  38. openexit-0.1.0/tests/test_manifest.py +59 -0
  39. openexit-0.1.0/tests/test_paths.py +29 -0
  40. openexit-0.1.0/tests/test_validation.py +16 -0
@@ -0,0 +1,25 @@
1
+ # Python 0.1 implementation notes
2
+
3
+ ## Canonical PASP assumptions
4
+
5
+ `protocol/pasp/v1/` and its shared conformance suite define PASP 1.0. Directory bundles use `openexit.bundle`, `manifest.json`, resource descriptors and direct-record NDJSON chunks, optional relationship and asset indexes, and SHA-256 integrity. Package paths use protocol / separators. SDK version 0.1.0 is independent of PASP version 1.0.
6
+
7
+ ## Python API mapping
8
+
9
+ `parse_manifest` parses JSON into a `Manifest` dataclass without protocol validation. It accepts a mapping, JSON text, UTF-8 bytes, or an explicit PathLike file. `validate_manifest` returns None or raises `PaspError`. `inspect_bundle` returns a metadata summary; `verify_bundle` performs full streaming integrity and record verification and returns that summary. Dataclasses are ergonomic views; packaged canonical JSON Schemas are normative.
10
+
11
+ ## Error mapping
12
+
13
+ All protocol failures raise `PaspError` with a stable `PASP_*` code. Missing manifest maps to `PASP_MALFORMED_PACKAGE`; schema, resource, record, asset, relationship, path, version, and checksum failures map to their corresponding frozen codes.
14
+
15
+ ## Bundle inspection strategy
16
+
17
+ Read small JSON metadata files directly. Stream NDJSON chunks and asset index line by line during verification; hash chunk and asset bytes in bounded blocks. Inspection reads descriptors and summary metadata without loading record or asset contents. Reject unsafe package paths and symlinks escaping the bundle root.
18
+
19
+ ## Schema-loading strategy
20
+
21
+ Copy canonical `protocol/pasp/v1/schemas/*.json` into `src/openexit/schemas/` as package data. Load with `importlib.resources`; resolve schema references from those packaged copies. Tests compare copies byte-for-byte with canonical files. An installed wheel never traverses back into the repository.
22
+
23
+ ## Chunk sequence interpretation
24
+
25
+ The frozen chunk schema permits nonnegative sequence values and the specification requires deterministic order, without requiring a starting value or contiguous values. Python accepts strictly increasing sequences whose eight-digit filenames match their sequence. The shared fixtures use one-based contiguous sequences; this interpretation may be worth an explicit future conformance case.
openexit-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OpenExit contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ include README.md LICENSE IMPLEMENTATION_NOTES.md
2
+ recursive-include src/openexit/schemas *.json
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: openexit
3
+ Version: 0.1.0
4
+ Summary: Python SDK for OpenExit and the PASP portable application state protocol.
5
+ Author: OpenExit contributors
6
+ License-Expression: MIT
7
+ Keywords: data portability,PASP,application state,export,openexit
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Operating System :: OS Independent
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: jsonschema<5,>=4.18
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest<10,>=8; extra == "dev"
22
+ Requires-Dist: build<2,>=1; extra == "dev"
23
+ Requires-Dist: twine<7,>=6; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # OpenExit
27
+
28
+ > Portable state for any application.
29
+
30
+ This package implements PASP — the Portable Application State Protocol — for Python. OpenExit is the implementation and ecosystem; PASP 1.0 is the language-neutral protocol. SDK version 0.1.0 is separate from protocol version 1.0.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install openexit
36
+ ```
37
+
38
+ ## Inspect and verify
39
+
40
+ ```python
41
+ from openexit import inspect_bundle, verify_bundle
42
+
43
+ bundle = inspect_bundle("./acme.pasp")
44
+ print(bundle.pasp_version)
45
+ print(bundle.scope)
46
+ print(bundle.resources)
47
+
48
+ verified = verify_bundle("./acme.pasp")
49
+ print(verified.verified)
50
+ ```
51
+
52
+ `inspect_bundle` reads the manifest, descriptors, relationship metadata, and asset index. `verify_bundle` additionally streams all resource records and asset bytes, checks record schemas and identities, counts, and SHA-256 hashes. Both accept directory bundles.
53
+
54
+ ## Manifest validation
55
+
56
+ ```python
57
+ from pathlib import Path
58
+ from openexit import parse_manifest, validate_manifest
59
+
60
+ manifest = parse_manifest(Path("./acme.pasp/manifest.json"))
61
+ validate_manifest(manifest) # returns None or raises PaspError
62
+ ```
63
+
64
+ `parse_manifest` also accepts a mapping, JSON text, or UTF-8 JSON bytes. A string is treated as JSON text; pass a `Path` to read a file. Parsing does not imply validation. Models retain the raw manifest fields. Protocol errors expose a stable `code`:
65
+
66
+ ```python
67
+ from openexit import PaspError
68
+
69
+ try:
70
+ verify_bundle("./acme.pasp")
71
+ except PaspError as exc:
72
+ print(exc.code)
73
+ ```
74
+
75
+ ## Protocol source
76
+
77
+ The canonical PASP definition is in the repository at `protocol/pasp/v1/`; the shared suite is `protocol/pasp/v1/conformance/suite.json`. Installed wheels contain exact copies of the canonical JSON Schemas and do not require the repository at runtime.
78
+
79
+ ## Current limits
80
+
81
+ This SDK reads PASP 1.0 directory bundles. It does not export or import application state, run adapters, execute external processes, unpack archives, or check full referential integrity. Inspection and verification use bounded file reads; one NDJSON record or metadata file is parsed at a time. Symlinks that leave the bundle root are rejected.
@@ -0,0 +1,56 @@
1
+ # OpenExit
2
+
3
+ > Portable state for any application.
4
+
5
+ This package implements PASP — the Portable Application State Protocol — for Python. OpenExit is the implementation and ecosystem; PASP 1.0 is the language-neutral protocol. SDK version 0.1.0 is separate from protocol version 1.0.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install openexit
11
+ ```
12
+
13
+ ## Inspect and verify
14
+
15
+ ```python
16
+ from openexit import inspect_bundle, verify_bundle
17
+
18
+ bundle = inspect_bundle("./acme.pasp")
19
+ print(bundle.pasp_version)
20
+ print(bundle.scope)
21
+ print(bundle.resources)
22
+
23
+ verified = verify_bundle("./acme.pasp")
24
+ print(verified.verified)
25
+ ```
26
+
27
+ `inspect_bundle` reads the manifest, descriptors, relationship metadata, and asset index. `verify_bundle` additionally streams all resource records and asset bytes, checks record schemas and identities, counts, and SHA-256 hashes. Both accept directory bundles.
28
+
29
+ ## Manifest validation
30
+
31
+ ```python
32
+ from pathlib import Path
33
+ from openexit import parse_manifest, validate_manifest
34
+
35
+ manifest = parse_manifest(Path("./acme.pasp/manifest.json"))
36
+ validate_manifest(manifest) # returns None or raises PaspError
37
+ ```
38
+
39
+ `parse_manifest` also accepts a mapping, JSON text, or UTF-8 JSON bytes. A string is treated as JSON text; pass a `Path` to read a file. Parsing does not imply validation. Models retain the raw manifest fields. Protocol errors expose a stable `code`:
40
+
41
+ ```python
42
+ from openexit import PaspError
43
+
44
+ try:
45
+ verify_bundle("./acme.pasp")
46
+ except PaspError as exc:
47
+ print(exc.code)
48
+ ```
49
+
50
+ ## Protocol source
51
+
52
+ The canonical PASP definition is in the repository at `protocol/pasp/v1/`; the shared suite is `protocol/pasp/v1/conformance/suite.json`. Installed wheels contain exact copies of the canonical JSON Schemas and do not require the repository at runtime.
53
+
54
+ ## Current limits
55
+
56
+ This SDK reads PASP 1.0 directory bundles. It does not export or import application state, run adapters, execute external processes, unpack archives, or check full referential integrity. Inspection and verification use bounded file reads; one NDJSON record or metadata file is parsed at a time. Symlinks that leave the bundle root are rejected.
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "openexit"
7
+ version = "0.1.0"
8
+ description = "Python SDK for OpenExit and the PASP portable application state protocol."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{name = "OpenExit contributors"}]
14
+ keywords = ["data portability", "PASP", "application state", "export", "openexit"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Operating System :: OS Independent",
24
+ ]
25
+ dependencies = ["jsonschema>=4.18,<5"]
26
+
27
+ [project.optional-dependencies]
28
+ dev = ["pytest>=8,<10", "build>=1,<2", "twine>=6,<7"]
29
+
30
+ [tool.setuptools]
31
+ package-dir = {"" = "src"}
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
35
+
36
+ [tool.setuptools.package-data]
37
+ "openexit.schemas" = ["*.json"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+ addopts = "--basetemp=.pytest_tmp"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,20 @@
1
+ """OpenExit Python SDK for PASP 1.0 directory bundles."""
2
+
3
+ from .bundle import Bundle
4
+ from .constants import PASP_VERSION
5
+ from .errors import OpenExitError, PaspError
6
+ from .inspection import inspect_bundle, verify_bundle
7
+ from .manifest import parse_manifest
8
+ from .models import (AssetSummary, Consistency, InspectionResult, IntegrityInfo,
9
+ Manifest, ProducerInfo, Relationship, RelationshipEndpoint,
10
+ Resource, ResourceChunk, Scope)
11
+ from .validation import load_schema, validate_manifest
12
+ from .version import __version__
13
+
14
+ __all__ = [
15
+ "PASP_VERSION", "__version__", "OpenExitError", "PaspError",
16
+ "parse_manifest", "validate_manifest", "inspect_bundle", "verify_bundle",
17
+ "load_schema", "Bundle", "Scope", "ProducerInfo", "IntegrityInfo", "Consistency",
18
+ "ResourceChunk", "Resource", "AssetSummary", "RelationshipEndpoint",
19
+ "Relationship", "Manifest", "InspectionResult",
20
+ ]
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from .inspection import inspect_bundle, verify_bundle
7
+ from .models import InspectionResult
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Bundle:
12
+ """A small optional handle for a PASP directory bundle."""
13
+
14
+ path: Path
15
+
16
+ @classmethod
17
+ def open(cls, path: str | Path) -> Bundle:
18
+ return cls(Path(path))
19
+
20
+ def inspect(self) -> InspectionResult:
21
+ return inspect_bundle(self.path)
22
+
23
+ def verify(self) -> InspectionResult:
24
+ return verify_bundle(self.path)
@@ -0,0 +1,2 @@
1
+ PASP_VERSION = "1.0"
2
+ BUNDLE_FORMAT = "openexit.bundle"
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class OpenExitError(Exception):
7
+ """Base exception for the OpenExit Python SDK."""
8
+
9
+
10
+ class PaspError(OpenExitError):
11
+ """A PASP validation failure with a stable protocol error code."""
12
+
13
+ def __init__(self, code: str, message: str, *, context: Any = None) -> None:
14
+ self.code = code
15
+ self.context = context
16
+ super().__init__(message)
@@ -0,0 +1,191 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, Iterator
6
+
7
+ from jsonschema import Draft202012Validator, FormatChecker
8
+ from jsonschema.exceptions import SchemaError, ValidationError
9
+ from referencing import Registry
10
+ from referencing.exceptions import NoSuchResource, Unresolvable
11
+
12
+ from .errors import PaspError
13
+ from .integrity import verify_file, hash_file
14
+ from .manifest import parse_manifest, _reject_non_json_constant
15
+ from .models import (AssetSummary, Consistency, InspectionResult, IntegrityInfo,
16
+ ProducerInfo, Resource, ResourceChunk, Scope)
17
+ from .paths import safe_package_path
18
+ from .validation import validate_document, validate_manifest
19
+
20
+
21
+ def _json_file(path: Path, code: str) -> Any:
22
+ try:
23
+ return json.loads(path.read_text(encoding="utf-8"), parse_constant=_reject_non_json_constant)
24
+ except (OSError, UnicodeError, ValueError) as exc:
25
+ raise PaspError(code, f"invalid or missing JSON file: {path}") from exc
26
+
27
+
28
+ def _lines(path: Path, code: str) -> Iterator[tuple[int, Any]]:
29
+ try:
30
+ with path.open("r", encoding="utf-8") as stream:
31
+ for number, line in enumerate(stream, 1):
32
+ if not line.strip():
33
+ continue
34
+ try:
35
+ yield number, json.loads(line, parse_constant=_reject_non_json_constant)
36
+ except ValueError as exc:
37
+ raise PaspError(code, f"invalid NDJSON at {path}:{number}") from exc
38
+ except (OSError, UnicodeError) as exc:
39
+ raise PaspError(code, f"cannot read NDJSON: {path}") from exc
40
+
41
+
42
+ def _no_external_schema(uri: str):
43
+ raise NoSuchResource(ref=uri)
44
+
45
+
46
+ def _schema_for_resource(root: Path, descriptor: dict[str, Any]) -> dict[str, Any]:
47
+ schema = descriptor["schema"]
48
+ if isinstance(schema, str):
49
+ schema_path = safe_package_path(root, schema)
50
+ if not schema_path.is_file():
51
+ raise PaspError("PASP_MISSING_SCHEMA", f"missing schema: {schema}")
52
+ schema = _json_file(schema_path, "PASP_MISSING_SCHEMA")
53
+ try:
54
+ Draft202012Validator.check_schema(schema)
55
+ except SchemaError as exc:
56
+ raise PaspError("PASP_INVALID_RESOURCE", "invalid record schema") from exc
57
+ return schema
58
+
59
+
60
+ def _resource(root: Path, entry: dict[str, Any], deep: bool) -> Resource:
61
+ name = entry["name"]
62
+ descriptor_path = safe_package_path(root, entry["descriptor"])
63
+ descriptor = _json_file(descriptor_path, "PASP_INVALID_RESOURCE")
64
+ validate_document("resource", descriptor, "PASP_INVALID_RESOURCE")
65
+ if descriptor["name"] != name:
66
+ raise PaspError("PASP_INVALID_RESOURCE", f"descriptor name mismatch: {name}")
67
+ schema = _schema_for_resource(root, descriptor)
68
+ chunks = []
69
+ total_records = 0
70
+ for index, raw in enumerate(descriptor["chunks"], 1):
71
+ sequence = raw["sequence"]
72
+ if sequence != index or raw["path"] != f"resources/{name}/{index:08d}.ndjson":
73
+ raise PaspError("PASP_INVALID_RESOURCE", f"invalid chunk order/path: {name}")
74
+ chunk_path = safe_package_path(root, raw["path"])
75
+ if not chunk_path.is_file():
76
+ raise PaspError("PASP_INVALID_RESOURCE", f"missing chunk: {raw['path']}")
77
+ if deep:
78
+ verify_file(chunk_path, raw["sha256"], raw["uncompressedBytes"])
79
+ validator = Draft202012Validator(schema, format_checker=FormatChecker(), registry=Registry(retrieve=_no_external_schema))
80
+ count = 0
81
+ for line_number, record in _lines(chunk_path, "PASP_INVALID_RECORD"):
82
+ if not isinstance(record, dict):
83
+ raise PaspError("PASP_INVALID_RECORD", f"record is not an object at {raw['path']}:{line_number}")
84
+ if any(identity not in record for identity in descriptor["identity"]):
85
+ raise PaspError("PASP_INVALID_RECORD", f"missing identity at {raw['path']}:{line_number}")
86
+ try:
87
+ validator.validate(record)
88
+ except (ValidationError, Unresolvable) as exc:
89
+ raise PaspError("PASP_INVALID_RECORD", f"record schema failure at {raw['path']}:{line_number}: {exc}") from exc
90
+ count += 1
91
+ if count != raw["recordCount"]:
92
+ raise PaspError("PASP_INVALID_RESOURCE", f"chunk record count mismatch: {raw['path']}")
93
+ total_records += raw["recordCount"]
94
+ chunks.append(ResourceChunk(raw["sequence"], raw["path"], raw["recordCount"], raw["uncompressedBytes"], raw["sha256"]))
95
+ if total_records != descriptor["recordCount"]:
96
+ raise PaspError("PASP_INVALID_RESOURCE", f"resource record count mismatch: {name}")
97
+ return Resource(name, descriptor["schema"], tuple(descriptor["identity"]), descriptor["recordCount"], tuple(chunks), entry["descriptor"])
98
+
99
+
100
+ def _assets(root: Path, raw: dict[str, Any], deep: bool) -> AssetSummary:
101
+ summary = AssetSummary(raw["count"], raw["totalBytes"], raw.get("index"))
102
+ if summary.count == 0 and summary.index is None:
103
+ return summary
104
+ index_path = safe_package_path(root, summary.index or "assets/index.ndjson")
105
+ if not index_path.is_file():
106
+ raise PaspError("PASP_MISSING_ASSET", "missing asset index")
107
+ count = total_bytes = 0
108
+ for _, asset in _lines(index_path, "PASP_MISSING_ASSET"):
109
+ validate_document("asset", asset, "PASP_MISSING_ASSET")
110
+ asset_path = safe_package_path(root, asset["path"])
111
+ if not asset_path.is_file():
112
+ raise PaspError("PASP_MISSING_ASSET", f"missing asset: {asset['path']}")
113
+ if deep:
114
+ verify_file(asset_path, asset["sha256"], asset["byteLength"])
115
+ count += 1
116
+ total_bytes += asset["byteLength"]
117
+ if count != summary.count or total_bytes != summary.total_bytes:
118
+ raise PaspError("PASP_MISSING_ASSET", "asset summary mismatch")
119
+ return summary
120
+
121
+
122
+ def _relationships(root: Path, reference: str | None, names: set[str]) -> int:
123
+ if reference is None:
124
+ return 0
125
+ rel_path = safe_package_path(root, reference)
126
+ items = _json_file(rel_path, "PASP_INVALID_RELATIONSHIP")
127
+ if not isinstance(items, list):
128
+ raise PaspError("PASP_INVALID_RELATIONSHIP", "relationships must be an array")
129
+ for item in items:
130
+ validate_document("relationship", item, "PASP_INVALID_RELATIONSHIP")
131
+ if item["from"]["resource"] not in names or item["to"]["resource"] not in names:
132
+ raise PaspError("PASP_INVALID_RELATIONSHIP", "relationship endpoint resource is missing")
133
+ return len(items)
134
+
135
+
136
+ def _checksum_index(root: Path, reference: str | None) -> None:
137
+ if reference is None:
138
+ return
139
+ index = safe_package_path(root, reference)
140
+ try:
141
+ with index.open("r", encoding="utf-8") as stream:
142
+ for number, line in enumerate(stream, 1):
143
+ if not line.strip():
144
+ continue
145
+ parts = line.rstrip("\r\n").split(maxsplit=1)
146
+ if len(parts) != 2 or len(parts[0]) != 64 or any(c not in "0123456789abcdef" for c in parts[0]):
147
+ raise PaspError("PASP_MALFORMED_PACKAGE", f"invalid checksums index line {number}")
148
+ entry = safe_package_path(root, parts[1].lstrip())
149
+ if not entry.is_file():
150
+ raise PaspError("PASP_MALFORMED_PACKAGE", f"missing checksummed file: {parts[1]}")
151
+ actual, _ = hash_file(entry)
152
+ if actual != parts[0]:
153
+ raise PaspError("PASP_CHECKSUM_MISMATCH", f"checksum index mismatch: {parts[1]}")
154
+ except (OSError, UnicodeError) as exc:
155
+ raise PaspError("PASP_MALFORMED_PACKAGE", "cannot read checksums index") from exc
156
+
157
+
158
+ def _inspect(directory: str | Path, deep: bool) -> InspectionResult:
159
+ root = Path(directory)
160
+ if not root.is_dir():
161
+ raise PaspError("PASP_MALFORMED_PACKAGE", f"not a PASP directory: {root}")
162
+ manifest_path = safe_package_path(root, "manifest.json")
163
+ if not manifest_path.is_file():
164
+ raise PaspError("PASP_MALFORMED_PACKAGE", "missing manifest.json")
165
+ manifest = parse_manifest(manifest_path)
166
+ validate_manifest(manifest)
167
+ value = dict(manifest.raw)
168
+ names = {entry["name"] for entry in value["resources"]}
169
+ resources = tuple(_resource(root, entry, deep) for entry in value["resources"])
170
+ assets = _assets(root, value["assets"], deep)
171
+ relationships = _relationships(root, value.get("relationships"), names)
172
+ if deep:
173
+ _checksum_index(root, value["integrity"].get("checksums"))
174
+ return InspectionResult(
175
+ True, value["paspVersion"], value["packageId"], value["exportId"],
176
+ Scope(**value["scope"]), ProducerInfo(**value["producer"]),
177
+ Consistency(**value["consistency"]), resources, assets, relationships,
178
+ IntegrityInfo(**value["integrity"]), deep,
179
+ )
180
+
181
+
182
+ def inspect_bundle(directory: str | Path) -> InspectionResult:
183
+ """Inspect a directory bundle using metadata and streamed asset index."""
184
+ return _inspect(directory, False)
185
+
186
+
187
+ def verify_bundle(directory: str | Path) -> InspectionResult:
188
+ """Verify a directory bundle, hashing bytes and streaming records."""
189
+ return _inspect(directory, True)
190
+
191
+
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from pathlib import Path
5
+
6
+ from .errors import PaspError
7
+
8
+
9
+ def hash_file(path: Path) -> tuple[str, int]:
10
+ """Hash a file in bounded blocks and return (lowercase SHA-256, byte count)."""
11
+ digest = hashlib.sha256()
12
+ count = 0
13
+ with path.open("rb") as stream:
14
+ while block := stream.read(1024 * 1024):
15
+ digest.update(block)
16
+ count += len(block)
17
+ return digest.hexdigest(), count
18
+
19
+
20
+ def verify_file(path: Path, sha256: str, byte_length: int) -> None:
21
+ actual_hash, actual_length = hash_file(path)
22
+ if actual_hash != sha256 or actual_length != byte_length:
23
+ raise PaspError("PASP_CHECKSUM_MISMATCH", f"checksum or length mismatch: {path}", context=str(path))
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any, Mapping
7
+
8
+ from .errors import PaspError
9
+ from .models import Manifest
10
+
11
+
12
+ def _reject_non_json_constant(value: str) -> None:
13
+ raise ValueError(f"invalid JSON constant: {value}")
14
+
15
+
16
+ def parse_manifest(source: Mapping[str, Any] | os.PathLike[str] | str | bytes) -> Manifest:
17
+ """Parse a mapping, JSON text, UTF-8 bytes, or explicit PathLike file.
18
+
19
+ A string is always JSON text. Use pathlib.Path for filesystem input.
20
+ Parsing does not establish protocol validity; call validate_manifest for that.
21
+ """
22
+ try:
23
+ if isinstance(source, Mapping):
24
+ value = dict(source)
25
+ elif isinstance(source, os.PathLike):
26
+ value = json.loads(Path(source).read_text(encoding="utf-8"), parse_constant=_reject_non_json_constant)
27
+ elif isinstance(source, bytes):
28
+ value = json.loads(source.decode("utf-8"), parse_constant=_reject_non_json_constant)
29
+ elif isinstance(source, str):
30
+ value = json.loads(source, parse_constant=_reject_non_json_constant)
31
+ else:
32
+ raise TypeError("manifest source must be a mapping, PathLike, JSON str, or bytes")
33
+ except (OSError, UnicodeError, ValueError) as exc:
34
+ raise PaspError("PASP_INVALID_MANIFEST", "cannot parse manifest JSON") from exc
35
+ if not isinstance(value, dict):
36
+ raise PaspError("PASP_INVALID_MANIFEST", "manifest must be a JSON object")
37
+ return Manifest(value)
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Mapping
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Scope:
9
+ type: str
10
+ id: str
11
+ metadata: Mapping[str, Any] = field(default_factory=dict)
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ProducerInfo:
16
+ name: str
17
+ version: str
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class IntegrityInfo:
22
+ algorithm: str
23
+ checksums: str | None = None
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Consistency:
28
+ level: str
29
+ metadata: Mapping[str, Any] = field(default_factory=dict)
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ResourceChunk:
34
+ sequence: int
35
+ path: str
36
+ record_count: int
37
+ uncompressed_bytes: int
38
+ sha256: str
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Resource:
43
+ name: str
44
+ schema: str | Mapping[str, Any]
45
+ identity: tuple[str, ...]
46
+ record_count: int
47
+ chunks: tuple[ResourceChunk, ...]
48
+ descriptor: str
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class AssetSummary:
53
+ count: int
54
+ total_bytes: int
55
+ index: str | None = None
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class RelationshipEndpoint:
60
+ resource: str
61
+ pointer: str
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class Relationship:
66
+ id: str
67
+ from_endpoint: RelationshipEndpoint
68
+ to_endpoint: RelationshipEndpoint
69
+ cardinality: str
70
+ metadata: Mapping[str, Any] = field(default_factory=dict)
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class Manifest:
75
+ """Parsed JSON with lossless access to all fields, including future fields."""
76
+
77
+ raw: Mapping[str, Any]
78
+
79
+ @property
80
+ def pasp_version(self) -> str | None:
81
+ return self.raw.get("paspVersion")
82
+
83
+ @property
84
+ def package_id(self) -> str | None:
85
+ return self.raw.get("packageId")
86
+
87
+ @property
88
+ def export_id(self) -> str | None:
89
+ return self.raw.get("exportId")
90
+
91
+ @property
92
+ def scope(self) -> Scope | None:
93
+ data = self.raw.get("scope")
94
+ if not isinstance(data, dict):
95
+ return None
96
+ return Scope(data.get("type"), data.get("id"), data.get("metadata", {}))
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class InspectionResult:
101
+ valid: bool
102
+ pasp_version: str
103
+ package_id: str
104
+ export_id: str
105
+ scope: Scope
106
+ producer: ProducerInfo
107
+ consistency: Consistency
108
+ resources: tuple[Resource, ...]
109
+ assets: AssetSummary
110
+ relationship_count: int
111
+ integrity: IntegrityInfo
112
+ verified: bool