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.
- openexit-0.1.0/IMPLEMENTATION_NOTES.md +25 -0
- openexit-0.1.0/LICENSE +21 -0
- openexit-0.1.0/MANIFEST.in +2 -0
- openexit-0.1.0/PKG-INFO +81 -0
- openexit-0.1.0/README.md +56 -0
- openexit-0.1.0/pyproject.toml +41 -0
- openexit-0.1.0/setup.cfg +4 -0
- openexit-0.1.0/src/openexit/__init__.py +20 -0
- openexit-0.1.0/src/openexit/bundle.py +24 -0
- openexit-0.1.0/src/openexit/constants.py +2 -0
- openexit-0.1.0/src/openexit/errors.py +16 -0
- openexit-0.1.0/src/openexit/inspection.py +191 -0
- openexit-0.1.0/src/openexit/integrity.py +23 -0
- openexit-0.1.0/src/openexit/manifest.py +37 -0
- openexit-0.1.0/src/openexit/models.py +112 -0
- openexit-0.1.0/src/openexit/paths.py +27 -0
- openexit-0.1.0/src/openexit/schemas/__init__.py +0 -0
- openexit-0.1.0/src/openexit/schemas/asset.schema.json +17 -0
- openexit-0.1.0/src/openexit/schemas/checkpoint.schema.json +12 -0
- openexit-0.1.0/src/openexit/schemas/event.schema.json +25 -0
- openexit-0.1.0/src/openexit/schemas/inspection-result.schema.json +15 -0
- openexit-0.1.0/src/openexit/schemas/manifest.schema.json +22 -0
- openexit-0.1.0/src/openexit/schemas/relationship.schema.json +26 -0
- openexit-0.1.0/src/openexit/schemas/resource-chunk.schema.json +20 -0
- openexit-0.1.0/src/openexit/schemas/resource.schema.json +21 -0
- openexit-0.1.0/src/openexit/schemas/scope.schema.json +13 -0
- openexit-0.1.0/src/openexit/validation.py +65 -0
- openexit-0.1.0/src/openexit/version.py +1 -0
- openexit-0.1.0/src/openexit.egg-info/PKG-INFO +81 -0
- openexit-0.1.0/src/openexit.egg-info/SOURCES.txt +38 -0
- openexit-0.1.0/src/openexit.egg-info/dependency_links.txt +1 -0
- openexit-0.1.0/src/openexit.egg-info/requires.txt +6 -0
- openexit-0.1.0/src/openexit.egg-info/top_level.txt +1 -0
- openexit-0.1.0/tests/test_conformance.py +26 -0
- openexit-0.1.0/tests/test_errors.py +8 -0
- openexit-0.1.0/tests/test_inspection.py +61 -0
- openexit-0.1.0/tests/test_integrity.py +21 -0
- openexit-0.1.0/tests/test_manifest.py +59 -0
- openexit-0.1.0/tests/test_paths.py +29 -0
- 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.
|
openexit-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
openexit-0.1.0/README.md
ADDED
|
@@ -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"
|
openexit-0.1.0/setup.cfg
ADDED
|
@@ -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,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
|