openexit 0.1.0__py3-none-any.whl
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/__init__.py +20 -0
- openexit/bundle.py +24 -0
- openexit/constants.py +2 -0
- openexit/errors.py +16 -0
- openexit/inspection.py +191 -0
- openexit/integrity.py +23 -0
- openexit/manifest.py +37 -0
- openexit/models.py +112 -0
- openexit/paths.py +27 -0
- openexit/schemas/__init__.py +0 -0
- openexit/schemas/asset.schema.json +17 -0
- openexit/schemas/checkpoint.schema.json +12 -0
- openexit/schemas/event.schema.json +25 -0
- openexit/schemas/inspection-result.schema.json +15 -0
- openexit/schemas/manifest.schema.json +22 -0
- openexit/schemas/relationship.schema.json +26 -0
- openexit/schemas/resource-chunk.schema.json +20 -0
- openexit/schemas/resource.schema.json +21 -0
- openexit/schemas/scope.schema.json +13 -0
- openexit/validation.py +65 -0
- openexit/version.py +1 -0
- openexit-0.1.0.dist-info/METADATA +81 -0
- openexit-0.1.0.dist-info/RECORD +26 -0
- openexit-0.1.0.dist-info/WHEEL +5 -0
- openexit-0.1.0.dist-info/licenses/LICENSE +21 -0
- openexit-0.1.0.dist-info/top_level.txt +1 -0
openexit/__init__.py
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
|
+
]
|
openexit/bundle.py
ADDED
|
@@ -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)
|
openexit/constants.py
ADDED
openexit/errors.py
ADDED
|
@@ -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)
|
openexit/inspection.py
ADDED
|
@@ -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
|
+
|
openexit/integrity.py
ADDED
|
@@ -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))
|
openexit/manifest.py
ADDED
|
@@ -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)
|
openexit/models.py
ADDED
|
@@ -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
|
openexit/paths.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path, PurePosixPath
|
|
5
|
+
|
|
6
|
+
from .errors import PaspError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def validate_package_path(value: str) -> None:
|
|
10
|
+
"""Reject unsafe protocol paths before any native filesystem access."""
|
|
11
|
+
if (not isinstance(value, str) or not value or "\x00" in value or "\\" in value
|
|
12
|
+
or value.startswith(("/", "~")) or re.match(r"^[A-Za-z]:", value)
|
|
13
|
+
or any(part in ("", ".", "..") for part in value.split("/"))):
|
|
14
|
+
raise PaspError("PASP_PATH_TRAVERSAL", f"unsafe package path: {value}")
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def safe_package_path(root: Path, value: str) -> Path:
|
|
19
|
+
"""Resolve a PASP relative path without native-OS path ambiguity."""
|
|
20
|
+
validate_package_path(value)
|
|
21
|
+
root_real = root.resolve()
|
|
22
|
+
candidate = root_real.joinpath(*PurePosixPath(value).parts)
|
|
23
|
+
try:
|
|
24
|
+
candidate.resolve().relative_to(root_real)
|
|
25
|
+
except ValueError as exc:
|
|
26
|
+
raise PaspError("PASP_PATH_TRAVERSAL", f"path escapes package: {value}") from exc
|
|
27
|
+
return candidate
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openexit.dev/pasp/1.0/schemas/asset.schema.json",
|
|
4
|
+
"title": "PASP Asset",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["id", "path", "sha256", "byteLength"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"id": { "type": "string", "minLength": 1 },
|
|
10
|
+
"path": { "type": "string", "pattern": "^assets/objects/[A-Za-z0-9][A-Za-z0-9._/-]*$" },
|
|
11
|
+
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
|
12
|
+
"byteLength": { "type": "integer", "minimum": 0 },
|
|
13
|
+
"name": { "type": "string" },
|
|
14
|
+
"mediaType": { "type": "string" },
|
|
15
|
+
"metadata": { "type": "object" }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openexit.dev/pasp/1.0/schemas/checkpoint.schema.json",
|
|
4
|
+
"title": "PASP Checkpoint",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["resource", "value"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"resource": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" },
|
|
10
|
+
"value": {}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openexit.dev/pasp/1.0/schemas/event.schema.json",
|
|
4
|
+
"title": "PASP Adapter Event",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["type"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"type": { "enum": ["export_begin", "resource_begin", "schema", "record", "checkpoint", "relationship", "asset", "resource_end", "export_end"] },
|
|
9
|
+
"scope": { "$ref": "scope.schema.json" },
|
|
10
|
+
"resource": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" },
|
|
11
|
+
"schema": { "type": "object" },
|
|
12
|
+
"schemaRef": { "type": "string" },
|
|
13
|
+
"value": {},
|
|
14
|
+
"relationship": { "$ref": "relationship.schema.json" },
|
|
15
|
+
"asset": { "$ref": "asset.schema.json" }
|
|
16
|
+
},
|
|
17
|
+
"allOf": [
|
|
18
|
+
{ "if": { "properties": { "type": { "const": "export_begin" } } }, "then": { "required": ["scope"] } },
|
|
19
|
+
{ "if": { "properties": { "type": { "enum": ["resource_begin", "resource_end", "schema", "record", "checkpoint"] } } }, "then": { "required": ["resource"] } },
|
|
20
|
+
{ "if": { "properties": { "type": { "const": "record" } } }, "then": { "required": ["value"] } },
|
|
21
|
+
{ "if": { "properties": { "type": { "const": "checkpoint" } } }, "then": { "required": ["value"] } },
|
|
22
|
+
{ "if": { "properties": { "type": { "const": "relationship" } } }, "then": { "required": ["relationship"] } },
|
|
23
|
+
{ "if": { "properties": { "type": { "const": "asset" } } }, "then": { "required": ["asset"] } }
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openexit.dev/pasp/1.0/schemas/inspection-result.schema.json",
|
|
4
|
+
"title": "PASP Inspection Result",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["valid", "paspVersion", "resources", "assets", "errors"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"valid": { "type": "boolean" },
|
|
10
|
+
"paspVersion": { "type": "string" },
|
|
11
|
+
"resources": { "type": "integer", "minimum": 0 },
|
|
12
|
+
"assets": { "type": "integer", "minimum": 0 },
|
|
13
|
+
"errors": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["code", "message"], "properties": { "code": { "type": "string", "pattern": "^PASP_[A-Z0-9_]+$" }, "message": { "type": "string" }, "path": { "type": "string" } } } }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openexit.dev/pasp/1.0/schemas/manifest.schema.json",
|
|
4
|
+
"title": "PASP Manifest",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["format", "paspVersion", "packageId", "exportId", "createdAt", "producer", "scope", "consistency", "resources", "assets", "integrity"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"format": { "const": "openexit.bundle" },
|
|
10
|
+
"paspVersion": { "const": "1.0" },
|
|
11
|
+
"packageId": { "type": "string", "minLength": 1 },
|
|
12
|
+
"exportId": { "type": "string", "minLength": 1 },
|
|
13
|
+
"createdAt": { "type": "string", "format": "date-time" },
|
|
14
|
+
"producer": { "type": "object", "additionalProperties": false, "required": ["name", "version"], "properties": { "name": { "type": "string", "minLength": 1 }, "version": { "type": "string", "minLength": 1 } } },
|
|
15
|
+
"scope": { "$ref": "scope.schema.json" },
|
|
16
|
+
"consistency": { "type": "object", "additionalProperties": false, "required": ["level"], "properties": { "level": { "enum": ["snapshot", "bounded", "best_effort"] }, "metadata": { "type": "object" } } },
|
|
17
|
+
"resources": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["name", "descriptor"], "properties": { "name": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" }, "descriptor": { "type": "string", "pattern": "^resources/[A-Za-z0-9][A-Za-z0-9._-]{0,127}/resource\\.json$" } } } },
|
|
18
|
+
"assets": { "type": "object", "additionalProperties": false, "required": ["count", "totalBytes"], "properties": { "count": { "type": "integer", "minimum": 0 }, "totalBytes": { "type": "integer", "minimum": 0 }, "index": { "type": "string", "const": "assets/index.ndjson" } } },
|
|
19
|
+
"integrity": { "type": "object", "additionalProperties": false, "required": ["algorithm"], "properties": { "algorithm": { "const": "sha256" }, "checksums": { "type": "string", "const": "checksums.sha256" } } },
|
|
20
|
+
"relationships": { "type": "string", "const": "relationships.json" }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openexit.dev/pasp/1.0/schemas/relationship.schema.json",
|
|
4
|
+
"title": "PASP Relationship",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["id", "from", "to", "cardinality"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"id": { "type": "string", "minLength": 1 },
|
|
10
|
+
"from": { "$ref": "#/$defs/endpoint" },
|
|
11
|
+
"to": { "$ref": "#/$defs/endpoint" },
|
|
12
|
+
"cardinality": { "enum": ["one-to-one", "one-to-many", "many-to-one", "many-to-many", "reference"] },
|
|
13
|
+
"metadata": { "type": "object" }
|
|
14
|
+
},
|
|
15
|
+
"$defs": {
|
|
16
|
+
"endpoint": {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"additionalProperties": false,
|
|
19
|
+
"required": ["resource", "pointer"],
|
|
20
|
+
"properties": {
|
|
21
|
+
"resource": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" },
|
|
22
|
+
"pointer": { "type": "string", "pattern": "^(/([^/~]|~[01])*)*$" }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openexit.dev/pasp/1.0/schemas/resource-chunk.schema.json",
|
|
4
|
+
"title": "PASP Resource Chunk",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["sequence", "path", "recordCount", "uncompressedBytes", "sha256"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"sequence": { "type": "integer", "minimum": 1 },
|
|
10
|
+
"path": { "type": "string", "pattern": "^resources/[A-Za-z0-9][A-Za-z0-9._-]{0,127}/[0-9]{8}\\.ndjson$" },
|
|
11
|
+
"recordCount": { "type": "integer", "minimum": 0 },
|
|
12
|
+
"uncompressedBytes": { "type": "integer", "minimum": 0 },
|
|
13
|
+
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
|
14
|
+
"firstIdentity": {},
|
|
15
|
+
"lastIdentity": {},
|
|
16
|
+
"completedAt": { "type": "string", "format": "date-time" }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openexit.dev/pasp/1.0/schemas/resource.schema.json",
|
|
4
|
+
"title": "PASP Resource",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["name", "schema", "identity", "recordCount", "chunks"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"name": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" },
|
|
10
|
+
"schema": { "oneOf": [{ "type": "string", "pattern": "^schemas/[A-Za-z0-9._/-]+\\.schema\\.json$" }, { "type": "object" }] },
|
|
11
|
+
"schemaId": { "type": "string", "minLength": 1 },
|
|
12
|
+
"schemaVersion": { "type": "string", "minLength": 1 },
|
|
13
|
+
"identity": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } },
|
|
14
|
+
"recordCount": { "type": "integer", "minimum": 0 },
|
|
15
|
+
"chunks": { "type": "array", "items": { "$ref": "resource-chunk.schema.json" } },
|
|
16
|
+
"ordered": { "type": "boolean" },
|
|
17
|
+
"metadata": { "type": "object" },
|
|
18
|
+
"capturedAt": { "type": "string", "format": "date-time" },
|
|
19
|
+
"snapshotToken": { "type": "string" }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openexit.dev/pasp/1.0/schemas/scope.schema.json",
|
|
4
|
+
"title": "PASP Scope",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["type", "id"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"type": { "type": "string", "minLength": 1 },
|
|
10
|
+
"id": { "type": "string", "minLength": 1 },
|
|
11
|
+
"metadata": { "type": "object" }
|
|
12
|
+
}
|
|
13
|
+
}
|
openexit/validation.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from importlib import resources
|
|
6
|
+
from typing import Any, Mapping
|
|
7
|
+
|
|
8
|
+
from jsonschema import Draft202012Validator, FormatChecker
|
|
9
|
+
from jsonschema.exceptions import SchemaError, ValidationError
|
|
10
|
+
from referencing import Registry, Resource
|
|
11
|
+
|
|
12
|
+
from .constants import PASP_VERSION
|
|
13
|
+
from .errors import PaspError
|
|
14
|
+
from .manifest import parse_manifest
|
|
15
|
+
from .models import Manifest
|
|
16
|
+
from .paths import validate_package_path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@lru_cache(maxsize=None)
|
|
20
|
+
def load_schema(name: str) -> dict[str, Any]:
|
|
21
|
+
"""Load a canonical PASP schema bundled with the installed wheel."""
|
|
22
|
+
if name not in {"manifest", "scope", "resource", "resource-chunk", "asset", "relationship", "checkpoint", "event", "inspection-result"}:
|
|
23
|
+
raise ValueError(f"unknown PASP schema: {name}")
|
|
24
|
+
resource = resources.files("openexit.schemas").joinpath(f"{name}.schema.json")
|
|
25
|
+
return json.loads(resource.read_text(encoding="utf-8"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@lru_cache(maxsize=1)
|
|
29
|
+
def _registry() -> Registry:
|
|
30
|
+
registry = Registry()
|
|
31
|
+
for name in ("manifest", "scope", "resource", "resource-chunk", "asset", "relationship", "checkpoint", "event", "inspection-result"):
|
|
32
|
+
schema = load_schema(name)
|
|
33
|
+
registry = registry.with_resource(schema["$id"], Resource.from_contents(schema))
|
|
34
|
+
return registry
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def validate_document(name: str, value: Any, code: str) -> None:
|
|
38
|
+
try:
|
|
39
|
+
schema = load_schema(name)
|
|
40
|
+
Draft202012Validator(schema, registry=_registry(), format_checker=FormatChecker()).validate(value)
|
|
41
|
+
except (ValidationError, SchemaError) as exc:
|
|
42
|
+
raise PaspError(code, f"invalid {name}: {exc.message}") from exc
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def validate_manifest(manifest: Manifest | Mapping[str, Any]) -> None:
|
|
46
|
+
"""Validate PASP 1.0 manifest against the packaged canonical JSON Schema."""
|
|
47
|
+
value = manifest.raw if isinstance(manifest, Manifest) else manifest
|
|
48
|
+
if not isinstance(value, Mapping):
|
|
49
|
+
raise PaspError("PASP_INVALID_MANIFEST", "manifest must be an object")
|
|
50
|
+
version = value.get("paspVersion")
|
|
51
|
+
if isinstance(version, str) and version != PASP_VERSION:
|
|
52
|
+
raise PaspError("PASP_UNSUPPORTED_VERSION", f"unsupported PASP version: {version}")
|
|
53
|
+
entries = value.get("resources")
|
|
54
|
+
if isinstance(entries, list):
|
|
55
|
+
seen: set[str] = set()
|
|
56
|
+
import re
|
|
57
|
+
for entry in entries:
|
|
58
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("name"), str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", entry["name"]):
|
|
59
|
+
raise PaspError("PASP_INVALID_RESOURCE", "invalid resource name")
|
|
60
|
+
if entry["name"] in seen:
|
|
61
|
+
raise PaspError("PASP_DUPLICATE_RESOURCE", f"duplicate resource: {entry['name']}")
|
|
62
|
+
seen.add(entry["name"])
|
|
63
|
+
if isinstance(entry.get("descriptor"), str):
|
|
64
|
+
validate_package_path(entry["descriptor"])
|
|
65
|
+
validate_document("manifest", dict(value), "PASP_INVALID_MANIFEST")
|
openexit/version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -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,26 @@
|
|
|
1
|
+
openexit/__init__.py,sha256=9PQaOvpnAI6EgaDLvxJl9ad5HjJ6OREycH4AUFDwwGc,930
|
|
2
|
+
openexit/bundle.py,sha256=TXQkHcG8Pkn2KWOkx0t0oWjSlPNGa198qwZZzoDtlBo,570
|
|
3
|
+
openexit/constants.py,sha256=l-75VsakOWnPtbpz7y2Tu8fYeR_xtoO6zs7qWfwjHAY,55
|
|
4
|
+
openexit/errors.py,sha256=pfx1ucJoEgGWjUHoIBjH5xx65Q_e0pkbebg6Wygw2KI,424
|
|
5
|
+
openexit/inspection.py,sha256=GPAN2gOkNNOiQGScumKfp8gxTL6l7gnuPziN_Htw7mg,9221
|
|
6
|
+
openexit/integrity.py,sha256=biWhIv5dqiMvM34rhbUrFbnsQlZ-UXPYShsO97SOkhI,760
|
|
7
|
+
openexit/manifest.py,sha256=1tuwoinDyLlvSAoATZ7UE30TJmtMWT5gpbo8fIsWqRA,1515
|
|
8
|
+
openexit/models.py,sha256=tF2Oxxf1dUwbpJxz-q4wmvvfARpgjkp-jWIwDoD_W8A,2313
|
|
9
|
+
openexit/paths.py,sha256=bz_M62ymvWVO5tTGfXg8dUHrTTN3F8ofaIRwxvXguVo,1034
|
|
10
|
+
openexit/validation.py,sha256=Wsh7WVgqZhvMqc5oG1VtP_Ijy3mbRtR_MdfJF3gKDI4,2961
|
|
11
|
+
openexit/version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
12
|
+
openexit/schemas/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
openexit/schemas/asset.schema.json,sha256=InNjsAWFW-JjfXYfqCKQ019tq9VKRzBn4mLOlaMjTbc,658
|
|
14
|
+
openexit/schemas/checkpoint.schema.json,sha256=BL32CzybrrAZUvf6r4Nzzv7srvBmgfizgo202fh2xJ4,383
|
|
15
|
+
openexit/schemas/event.schema.json,sha256=u8k6IISxh_jSsw0N6zLyurXTq21LQk4lN5D7hhpzXcE,1424
|
|
16
|
+
openexit/schemas/inspection-result.schema.json,sha256=DC9QKA0s7lyDshTxI9OcTIG723X-wl-pJUtwrPPO7Zo,784
|
|
17
|
+
openexit/schemas/manifest.schema.json,sha256=NJQJ57hPG8BF6gaWL5qnx7bOsNnC1todANPJ7rfbjlc,2029
|
|
18
|
+
openexit/schemas/relationship.schema.json,sha256=sem7eLmvDLkmN-6ewbLPyVGnzG3S28aOB0vsZ4m5L_8,917
|
|
19
|
+
openexit/schemas/resource-chunk.schema.json,sha256=nIMqw1OARH8Oe1H5PAnwx0s9NIwSIt-5iu4A4PCi0MA,796
|
|
20
|
+
openexit/schemas/resource.schema.json,sha256=eO-Y-N2ZVifCucB0R0rPtZw-zw6eU8-GuyLAkVlsRfY,1058
|
|
21
|
+
openexit/schemas/scope.schema.json,sha256=hNEPaG0DCLTJDMF8rMSVzHOk5DrUBej__8UXQJOrnjs,398
|
|
22
|
+
openexit-0.1.0.dist-info/licenses/LICENSE,sha256=nftgLgoUyqC-kr1DBtrR4QuX_5aZhLN-gmXcMoTyCoI,1078
|
|
23
|
+
openexit-0.1.0.dist-info/METADATA,sha256=sT0qKO0v5jjAR-piizvZkwVJiI7WU2gBBARyQKrUlE4,3151
|
|
24
|
+
openexit-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
25
|
+
openexit-0.1.0.dist-info/top_level.txt,sha256=g__fyluRYNFCGPUZE9MuhLhhgpfgdzKIXTIJTvNvqH4,9
|
|
26
|
+
openexit-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
openexit
|