unidpp 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 (47) hide show
  1. unidpp-0.1.0/LICENSE +21 -0
  2. unidpp-0.1.0/PKG-INFO +117 -0
  3. unidpp-0.1.0/README.md +92 -0
  4. unidpp-0.1.0/pyproject.toml +36 -0
  5. unidpp-0.1.0/setup.cfg +4 -0
  6. unidpp-0.1.0/tests/test_adapters.py +196 -0
  7. unidpp-0.1.0/tests/test_carrier.py +198 -0
  8. unidpp-0.1.0/tests/test_competitor_conformance.py +239 -0
  9. unidpp-0.1.0/tests/test_conformance.py +117 -0
  10. unidpp-0.1.0/tests/test_eu_profile.py +310 -0
  11. unidpp-0.1.0/tests/test_eventlog.py +181 -0
  12. unidpp-0.1.0/tests/test_fixtures.py +162 -0
  13. unidpp-0.1.0/tests/test_harness_f1.py +56 -0
  14. unidpp-0.1.0/tests/test_harness_f2f3.py +211 -0
  15. unidpp-0.1.0/tests/test_harness_protocol.py +111 -0
  16. unidpp-0.1.0/tests/test_harness_vectors.py +90 -0
  17. unidpp-0.1.0/tests/test_model.py +235 -0
  18. unidpp-0.1.0/tests/test_temporal.py +165 -0
  19. unidpp-0.1.0/tests/test_verify.py +227 -0
  20. unidpp-0.1.0/unidpp/__init__.py +104 -0
  21. unidpp-0.1.0/unidpp/adapters/__init__.py +21 -0
  22. unidpp-0.1.0/unidpp/adapters/en18223.py +1562 -0
  23. unidpp-0.1.0/unidpp/adapters/epcis.py +288 -0
  24. unidpp-0.1.0/unidpp/adapters/untp.py +135 -0
  25. unidpp-0.1.0/unidpp/canonical.py +114 -0
  26. unidpp-0.1.0/unidpp/carrier.py +272 -0
  27. unidpp-0.1.0/unidpp/conformance.py +769 -0
  28. unidpp-0.1.0/unidpp/eventlog.py +230 -0
  29. unidpp-0.1.0/unidpp/fixtures/__init__.py +6 -0
  30. unidpp-0.1.0/unidpp/fixtures/car.py +345 -0
  31. unidpp-0.1.0/unidpp/fixtures/laptop.py +295 -0
  32. unidpp-0.1.0/unidpp/harness/__init__.py +1 -0
  33. unidpp-0.1.0/unidpp/harness/ed25519.py +142 -0
  34. unidpp-0.1.0/unidpp/harness/f1.py +152 -0
  35. unidpp-0.1.0/unidpp/harness/framing.py +64 -0
  36. unidpp-0.1.0/unidpp/harness/mapping.py +78 -0
  37. unidpp-0.1.0/unidpp/harness/protocol.py +218 -0
  38. unidpp-0.1.0/unidpp/harness/vectors.py +270 -0
  39. unidpp-0.1.0/unidpp/model.py +1607 -0
  40. unidpp-0.1.0/unidpp/temporal.py +374 -0
  41. unidpp-0.1.0/unidpp/validate.py +237 -0
  42. unidpp-0.1.0/unidpp/verify.py +507 -0
  43. unidpp-0.1.0/unidpp.egg-info/PKG-INFO +117 -0
  44. unidpp-0.1.0/unidpp.egg-info/SOURCES.txt +45 -0
  45. unidpp-0.1.0/unidpp.egg-info/dependency_links.txt +1 -0
  46. unidpp-0.1.0/unidpp.egg-info/requires.txt +7 -0
  47. unidpp-0.1.0/unidpp.egg-info/top_level.txt +1 -0
unidpp-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 UniDPP 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.
unidpp-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: unidpp
3
+ Version: 0.1.0
4
+ Summary: UniDPP Python library: mirrored model, commitment-chained event log, verification-pipeline harness, conformance runners, EPCIS/UNTP adapters
5
+ Author: UniDPP contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/unidpp/unidpp-py
8
+ Keywords: digital-product-passport,dpp,unidpp,traceability,espr
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest>=7; extra == "test"
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7; extra == "dev"
23
+ Requires-Dist: build; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # unidpp-py
27
+
28
+ The UniDPP Python library — the semantics mirror and conformance
29
+ tooling. Part of UniDPP (github.com/unidpp). License: MIT.
30
+
31
+ Pure stdlib (no runtime dependencies). The package mirrors the
32
+ Rust/TypeScript core so the semantics have a second, independent
33
+ statement:
34
+
35
+ - `unidpp.model` — the mirrored model (documents, findings, verdicts);
36
+ - `unidpp.eventlog` — the commitment-chained event log;
37
+ - `unidpp.verify` — the verification-pipeline harness (the I13
38
+ degradation ladder + I9 verdicts; stdlib `hmac`/`hashlib` replace
39
+ WebCrypto, ECDSA is a deliberate unsupported-slot placeholder per
40
+ the multi-suite degrade model);
41
+ - `unidpp.canonical` — canonical JSON (RFC 8785-lite);
42
+ - `unidpp.adapters` — protocol adapters: `untp` (UNTP verifiable
43
+ credentials), `en18223` (the EN 18223 document validator +
44
+ EU-profile runner), `epcis`;
45
+ - `unidpp.temporal` — the DPP temporal profile;
46
+ - `unidpp.carrier` — QR carrier-budget conformance;
47
+ - `unidpp.conformance` — the conformance runner.
48
+
49
+ ## Conformance
50
+
51
+ The conformance runner proves the validators catch real defects, not
52
+ synthetic strawmen. `CHECK_REGISTRY` carries the check classes:
53
+
54
+ | check | what it proves |
55
+ |---|---|
56
+ | `positive-corpus` | the ported TS fixtures (laptop, car) validate against the wire schemas and their chains verify |
57
+ | `audit-negative` | the AUDIT A1–A6 corpus defects (found in EN 18223's own examples) fail with precise findings |
58
+ | `temporal-conformance` | every timestamp field conforms to the DPP temporal profile (ISO 8601-1:2019 incl. Amd 1:2022 disambiguation) |
59
+ | `carrier-budget` | serialized payload sizes fit their declared carrier class (ISO/IEC 18004 QR capacity tables, ported from the CLI) |
60
+
61
+ ```sh
62
+ python -m unidpp.conformance --out reports/
63
+ ```
64
+
65
+ The **temporal profile** (`unidpp.temporal`) validates every
66
+ timestamp field with a distinct finding code — separator typography
67
+ (`T1`, the en-dash the EN's own Annex B prints), extended-format and
68
+ precision rules, calendar ranges, the `24:00:00` end-of-day form,
69
+ timezone discipline, and the decimal sign:
70
+
71
+ ```pycon
72
+ >>> from unidpp.temporal import validate_timestamp
73
+ >>> validate_timestamp("2025–08–22T03:12:00Z", "lastUpdated").code
74
+ 'T1-separator-typography'
75
+ >>> validate_timestamp("2025-08-22T03:12:00Z", "lastUpdated") is None
76
+ True
77
+ ```
78
+
79
+ The **carrier budget** (`unidpp.carrier`) measures the canonical
80
+ serialized size of a payload against the ported ISO/IEC 18004 QR
81
+ byte-capacity tables; a payload that exceeds its declared class is
82
+ flagged with the computed size versus the capacity — never silently
83
+ truncated:
84
+
85
+ ```pycon
86
+ >>> from unidpp.carrier import measure
87
+ >>> m = measure({"a": "x" * 380}, declared="qr-v15-M")
88
+ >>> (m.size_bytes, m.fits_declared)
89
+ (388, True)
90
+ ```
91
+
92
+ Two report suites build on the runner (`scripts/`):
93
+
94
+ - **EU profile** (`eu_profile_report.py`) — the full EN 18223:2026
95
+ example corpus through the validators. The corpus itself is an
96
+ extraction from licensed sources and is deliberately **not
97
+ distributed** with the repository (gitignored); the suite runs
98
+ where the corpus is present and its findings are published as
99
+ `conformance/eu-profile/REPORT.md`.
100
+ - **Competitor register** (`competitor_report.py`) — the published
101
+ artifacts of the open-source DPP implementations (freeDPP live
102
+ endpoints, open-dpp's e2e fixture) through the same pipeline, with
103
+ verbatim source citations; findings in
104
+ `conformance/competitors/REPORT.md`.
105
+
106
+ ## Build & test
107
+
108
+ ```sh
109
+ pip install -e ".[test]"
110
+ pytest
111
+ ```
112
+
113
+ The suite covers the model, event log, verification ladder, adapters
114
+ (UNTP round-trip, EN 18223 projection), the conformance checks
115
+ (temporal, carrier, corpus), and the competitor register. The
116
+ EU-profile corpus tests skip automatically when the licensed
117
+ extraction is not present.
unidpp-0.1.0/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # unidpp-py
2
+
3
+ The UniDPP Python library — the semantics mirror and conformance
4
+ tooling. Part of UniDPP (github.com/unidpp). License: MIT.
5
+
6
+ Pure stdlib (no runtime dependencies). The package mirrors the
7
+ Rust/TypeScript core so the semantics have a second, independent
8
+ statement:
9
+
10
+ - `unidpp.model` — the mirrored model (documents, findings, verdicts);
11
+ - `unidpp.eventlog` — the commitment-chained event log;
12
+ - `unidpp.verify` — the verification-pipeline harness (the I13
13
+ degradation ladder + I9 verdicts; stdlib `hmac`/`hashlib` replace
14
+ WebCrypto, ECDSA is a deliberate unsupported-slot placeholder per
15
+ the multi-suite degrade model);
16
+ - `unidpp.canonical` — canonical JSON (RFC 8785-lite);
17
+ - `unidpp.adapters` — protocol adapters: `untp` (UNTP verifiable
18
+ credentials), `en18223` (the EN 18223 document validator +
19
+ EU-profile runner), `epcis`;
20
+ - `unidpp.temporal` — the DPP temporal profile;
21
+ - `unidpp.carrier` — QR carrier-budget conformance;
22
+ - `unidpp.conformance` — the conformance runner.
23
+
24
+ ## Conformance
25
+
26
+ The conformance runner proves the validators catch real defects, not
27
+ synthetic strawmen. `CHECK_REGISTRY` carries the check classes:
28
+
29
+ | check | what it proves |
30
+ |---|---|
31
+ | `positive-corpus` | the ported TS fixtures (laptop, car) validate against the wire schemas and their chains verify |
32
+ | `audit-negative` | the AUDIT A1–A6 corpus defects (found in EN 18223's own examples) fail with precise findings |
33
+ | `temporal-conformance` | every timestamp field conforms to the DPP temporal profile (ISO 8601-1:2019 incl. Amd 1:2022 disambiguation) |
34
+ | `carrier-budget` | serialized payload sizes fit their declared carrier class (ISO/IEC 18004 QR capacity tables, ported from the CLI) |
35
+
36
+ ```sh
37
+ python -m unidpp.conformance --out reports/
38
+ ```
39
+
40
+ The **temporal profile** (`unidpp.temporal`) validates every
41
+ timestamp field with a distinct finding code — separator typography
42
+ (`T1`, the en-dash the EN's own Annex B prints), extended-format and
43
+ precision rules, calendar ranges, the `24:00:00` end-of-day form,
44
+ timezone discipline, and the decimal sign:
45
+
46
+ ```pycon
47
+ >>> from unidpp.temporal import validate_timestamp
48
+ >>> validate_timestamp("2025–08–22T03:12:00Z", "lastUpdated").code
49
+ 'T1-separator-typography'
50
+ >>> validate_timestamp("2025-08-22T03:12:00Z", "lastUpdated") is None
51
+ True
52
+ ```
53
+
54
+ The **carrier budget** (`unidpp.carrier`) measures the canonical
55
+ serialized size of a payload against the ported ISO/IEC 18004 QR
56
+ byte-capacity tables; a payload that exceeds its declared class is
57
+ flagged with the computed size versus the capacity — never silently
58
+ truncated:
59
+
60
+ ```pycon
61
+ >>> from unidpp.carrier import measure
62
+ >>> m = measure({"a": "x" * 380}, declared="qr-v15-M")
63
+ >>> (m.size_bytes, m.fits_declared)
64
+ (388, True)
65
+ ```
66
+
67
+ Two report suites build on the runner (`scripts/`):
68
+
69
+ - **EU profile** (`eu_profile_report.py`) — the full EN 18223:2026
70
+ example corpus through the validators. The corpus itself is an
71
+ extraction from licensed sources and is deliberately **not
72
+ distributed** with the repository (gitignored); the suite runs
73
+ where the corpus is present and its findings are published as
74
+ `conformance/eu-profile/REPORT.md`.
75
+ - **Competitor register** (`competitor_report.py`) — the published
76
+ artifacts of the open-source DPP implementations (freeDPP live
77
+ endpoints, open-dpp's e2e fixture) through the same pipeline, with
78
+ verbatim source citations; findings in
79
+ `conformance/competitors/REPORT.md`.
80
+
81
+ ## Build & test
82
+
83
+ ```sh
84
+ pip install -e ".[test]"
85
+ pytest
86
+ ```
87
+
88
+ The suite covers the model, event log, verification ladder, adapters
89
+ (UNTP round-trip, EN 18223 projection), the conformance checks
90
+ (temporal, carrier, corpus), and the competitor register. The
91
+ EU-profile corpus tests skip automatically when the licensed
92
+ extraction is not present.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "unidpp"
7
+ version = "0.1.0"
8
+ description = "UniDPP Python library: mirrored model, commitment-chained event log, verification-pipeline harness, conformance runners, EPCIS/UNTP adapters"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "UniDPP contributors" }]
13
+ keywords = ["digital-product-passport", "dpp", "unidpp", "traceability", "espr"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Software Development :: Libraries :: Python Modules",
22
+ ]
23
+ dependencies = []
24
+
25
+ [project.optional-dependencies]
26
+ test = ["pytest>=7"]
27
+ dev = ["pytest>=7", "build"]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/unidpp/unidpp-py"
31
+
32
+ [tool.setuptools.packages.find]
33
+ include = ["unidpp*"]
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
unidpp-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,196 @@
1
+ """Adapters: EPCIS event import (commissioning/aggregation/transformation)
2
+ and UNTP passport stub parsing."""
3
+
4
+ from typing import ClassVar
5
+
6
+ import pytest
7
+
8
+ from unidpp.adapters import map_epcis_event, parse_untp_stub, transformation_balance
9
+ from unidpp.adapters.epcis import UnsupportedEpcisEvent, map_epcis_document
10
+ from unidpp.eventlog import append_event, verify_chain
11
+ from unidpp.model import ModelError, validate_event, validate_manifest
12
+
13
+
14
+ class TestEpcisCommissioning:
15
+ def test_maps_to_passport_created(self):
16
+ events = map_epcis_event(
17
+ {
18
+ "type": "ObjectEvent",
19
+ "action": "ADD",
20
+ "bizStep": "urn:epcglobal:cbv:bizstep:commissioning",
21
+ "eventTime": "2026-09-01T10:00:00.000Z",
22
+ "epcList": ["urn:epc:id:sgtin:0614141.107346.2017"],
23
+ }
24
+ )
25
+ assert len(events) == 1
26
+ evt = events[0]
27
+ assert evt.type == "passport.created"
28
+ assert evt.subject == "urn:epc:id:sgtin:0614141.107346.2017"
29
+ chained = append_event([], evt)
30
+ assert validate_event(chained[0]).valid
31
+
32
+ def test_mapped_events_chain(self):
33
+ log: list = []
34
+ log = append_event(
35
+ log,
36
+ map_epcis_event(
37
+ {
38
+ "type": "ObjectEvent",
39
+ "action": "ADD",
40
+ "bizStep": "commissioning",
41
+ "eventTime": "2026-09-01T10:00:00Z",
42
+ "epcList": ["urn:epc:id:sgtin:0614141.107346.2017"],
43
+ }
44
+ )[0],
45
+ )
46
+ assert verify_chain(log)
47
+
48
+
49
+ class TestEpcisAggregation:
50
+ def test_maps_to_upgrade_install_per_child(self):
51
+ events = map_epcis_event(
52
+ {
53
+ "type": "AggregationEvent",
54
+ "action": "ADD",
55
+ "eventTime": "2026-09-02T10:00:00Z",
56
+ "parentID": "urn:epc:id:sscc:0614141.1234567890",
57
+ "childEPCs": [
58
+ "urn:epc:id:sgtin:0614141.107346.2017",
59
+ "urn:epc:id:sgtin:0614141.107346.2018",
60
+ ],
61
+ }
62
+ )
63
+ assert [e.type for e in events] == ["upgrade.install", "upgrade.install"]
64
+ assert {e.payload["childId"] for e in events} == {
65
+ "urn:epc:id:sgtin:0614141.107346.2017",
66
+ "urn:epc:id:sgtin:0614141.107346.2018",
67
+ }
68
+ log: list = []
69
+ for e in events:
70
+ log = append_event(log, e)
71
+ for e in log:
72
+ assert validate_event(e).valid
73
+
74
+ def test_delete_action_not_mapped(self):
75
+ with pytest.raises(UnsupportedEpcisEvent):
76
+ map_epcis_event(
77
+ {
78
+ "type": "AggregationEvent",
79
+ "action": "DELETE",
80
+ "eventTime": "2026-09-02T10:00:00Z",
81
+ "parentID": "urn:x",
82
+ "childEPCs": ["urn:y"],
83
+ }
84
+ )
85
+
86
+
87
+ class TestEpcisTransformation:
88
+ def _evt(self, outputs, inputs):
89
+ return map_epcis_event(
90
+ {
91
+ "type": "TransformationEvent",
92
+ "eventTime": "2029-01-15T08:00:00Z",
93
+ "transformationID": "urn:x:recycle-run-1",
94
+ "inputQuantityList": inputs,
95
+ "outputQuantityList": outputs,
96
+ }
97
+ )[0]
98
+
99
+ def test_maps_to_material_decompose_with_references(self):
100
+ evt = self._evt(
101
+ outputs=[
102
+ {"epcClass": "urn:x:cells", "quantity": 40.0, "uom": "KGM"},
103
+ {"epcClass": "urn:x:casing", "quantity": 8.0, "uom": "KGM"},
104
+ {"epcClass": "urn:x:electronics", "quantity": 5.0, "uom": "KGM"},
105
+ ],
106
+ inputs=[{"epcClass": "urn:x:battery", "quantity": 53.0, "uom": "KGM"}],
107
+ )
108
+ assert evt.type == "material.decompose"
109
+ assert evt.payload["inputReferences"][0]["passportId"] == "urn:x:battery"
110
+ assert validate_event(append_event([], evt)[0]).valid
111
+ balance = transformation_balance(evt)
112
+ assert balance.balanced and balance.loss == 0.0
113
+
114
+ def test_mass_violation_detected(self):
115
+ evt = self._evt(
116
+ outputs=[{"epcClass": "urn:x:gold", "quantity": 60.0, "uom": "KGM"}],
117
+ inputs=[{"epcClass": "urn:x:battery", "quantity": 53.0, "uom": "KGM"}],
118
+ )
119
+ assert not transformation_balance(evt).balanced
120
+
121
+ def test_balance_requires_transformation_event(self):
122
+ evt = self._evt(
123
+ outputs=[{"epcClass": "urn:x:cells", "quantity": 1.0, "uom": "KGM"}],
124
+ inputs=None,
125
+ )
126
+ evt.type = "inspection.stamp" # not a transformation
127
+ with pytest.raises(ModelError):
128
+ transformation_balance(evt)
129
+
130
+ def test_document_mapping(self):
131
+ doc = {
132
+ "@context": ["https://ref.gs1.org/standards/epcis/epcis-2.0.json"],
133
+ "type": "EPCISDocument",
134
+ "eventList": [
135
+ {
136
+ "type": "ObjectEvent",
137
+ "action": "ADD",
138
+ "bizStep": "commissioning",
139
+ "eventTime": "2026-09-01T10:00:00Z",
140
+ "epcList": ["urn:epc:id:sgtin:0614141.107346.2017"],
141
+ },
142
+ {
143
+ "type": "AggregationEvent",
144
+ "action": "ADD",
145
+ "eventTime": "2026-09-02T10:00:00Z",
146
+ "parentID": "urn:x:kit",
147
+ "childEPCs": ["urn:epc:id:sgtin:0614141.107346.2017"],
148
+ },
149
+ ],
150
+ }
151
+ events = map_epcis_document(doc)
152
+ assert [e.type for e in events] == ["passport.created", "upgrade.install"]
153
+ log: list = []
154
+ for e in events:
155
+ log = append_event(log, e)
156
+ assert verify_chain(log)
157
+
158
+
159
+ class TestUntpStub:
160
+ STUB: ClassVar[dict] = {
161
+ "@context": ["https://ref.gs1.org/gs1/v/model"],
162
+ "type": "ProductPassport",
163
+ "id": "https://example.com/passports/123",
164
+ "productIdentifiers": [
165
+ {"scheme": "https://gs1.org/voc/", "value": "(01)09506000134352(21)BP52-000841"}
166
+ ],
167
+ "passportIssuer": {"id": "urn:example:issuer:1", "name": "Example Cells"},
168
+ "validFrom": "2026-09-01T00:00:00Z",
169
+ "validUntil": "2036-09-01T00:00:00Z",
170
+ "standardsConformance": [
171
+ {"standard": "ESPR", "conformanceVersion": "1"}
172
+ ],
173
+ }
174
+
175
+ def test_parse_into_neutral_manifest(self):
176
+ manifest = parse_untp_stub(self.STUB, as_of="2026-09-05T00:00:00Z")
177
+ assert manifest.subject_id.scheme == "gs1"
178
+ assert manifest.subject_id.value == "(01)09506000134352(21)BP52-000841"
179
+ assert manifest.status == "active"
180
+ assert manifest.profiles[0].profile_id == "ESPR"
181
+ assert manifest.event_log.log_uri == "https://example.com/passports/123"
182
+ assert validate_manifest(manifest).valid
183
+
184
+ def test_import_receipt_commitment_is_stable(self):
185
+ m1 = parse_untp_stub(self.STUB, as_of="2026-09-05T00:00:00Z")
186
+ m2 = parse_untp_stub(self.STUB, as_of="2026-09-05T00:00:00Z")
187
+ assert m1.event_log.commitment == m2.event_log.commitment
188
+ assert len(m1.event_log.commitment) == 64
189
+
190
+ def test_rejects_non_passport(self):
191
+ with pytest.raises(ValueError):
192
+ parse_untp_stub({"type": "DeliveryNote"}, as_of="2026-09-05T00:00:00Z")
193
+
194
+ def test_rejects_stub_without_identifiers(self):
195
+ with pytest.raises(ValueError):
196
+ parse_untp_stub({"type": "ProductPassport"}, as_of="2026-09-05T00:00:00Z")
@@ -0,0 +1,198 @@
1
+ """Carrier conformance: ISO/IEC 18004 QR capacity tables ported from the
2
+ CLI, the budget grammar, canonical serialized sizes, and the carrier
3
+ fixtures — a payload known to fit QR v15-M passes; an oversized payload
4
+ is flagged with the computed size versus the capacity; the reports carry
5
+ the carrier column."""
6
+
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from unidpp import carrier
12
+ from unidpp.carrier import (
13
+ CarrierBudgetError,
14
+ byte_capacity,
15
+ check_payload,
16
+ measure,
17
+ min_version_for,
18
+ parse_budget,
19
+ render,
20
+ serialized_size,
21
+ )
22
+ from unidpp.conformance import CHECK_REGISTRY, carrier_fixtures, run_conformance
23
+
24
+ COMPETITORS = Path(__file__).resolve().parents[1] / "conformance" / "competitors"
25
+ EU_FIXTURES = (
26
+ Path(__file__).resolve().parents[1]
27
+ / "conformance"
28
+ / "eu-profile"
29
+ / "fixtures"
30
+ / "en-18223"
31
+ )
32
+
33
+
34
+ class TestPortedCapacityTables:
35
+ def test_known_capacities_match_the_cli_tables(self):
36
+ # Spot values from unidpp-core/crates/tier_a/src/qr.rs tests.
37
+ assert byte_capacity(1, "L") == 17
38
+ assert byte_capacity(1, "H") == 7
39
+ assert byte_capacity(10, "M") == 213
40
+ assert byte_capacity(40, "L") == 2953
41
+ assert byte_capacity(40, "M") == 2331
42
+ assert byte_capacity(40, "H") == 1273
43
+
44
+ def test_the_en18220_class_span_is_covered(self):
45
+ # v10-M floor through v40-H ceiling (the plan's EN 18220 span).
46
+ lo, hi = carrier.EN18220_QR_CLASS_SPAN
47
+ assert byte_capacity(10, "M") == 213
48
+ assert byte_capacity(40, "H") == 1273
49
+ assert parse_budget(lo) == ("M", 10)
50
+ assert parse_budget(hi) == ("H", 40)
51
+ assert len(carrier.QR_BYTE_CAPACITY["M"]) == 40
52
+
53
+ def test_out_of_range_arguments_raise(self):
54
+ with pytest.raises(CarrierBudgetError):
55
+ byte_capacity(0, "M")
56
+ with pytest.raises(CarrierBudgetError):
57
+ byte_capacity(41, "M")
58
+ with pytest.raises(CarrierBudgetError):
59
+ byte_capacity(15, "X")
60
+
61
+ def test_min_version_for(self):
62
+ assert min_version_for(17, "L") == 1
63
+ assert min_version_for(18, "L") == 2
64
+ assert min_version_for(412, "M") == 15 # v15-M holds 412
65
+ assert min_version_for(413, "M") == 16
66
+ assert min_version_for(2953, "L") == 40
67
+ assert min_version_for(2954, "L") is None
68
+
69
+
70
+ class TestBudgetGrammar:
71
+ def test_valid_tokens_parse_case_insensitively(self):
72
+ assert parse_budget("qr-v15-M") == ("M", 15)
73
+ assert parse_budget("QR-V3-h") == ("H", 3)
74
+ assert parse_budget(" qr-v40-m ") == ("M", 40)
75
+
76
+ def test_invalid_tokens_rejected(self):
77
+ for bad in ("qr-v0-M", "qr-v41-M", "qr-v15-X", "qr-vX-M", "qr-v15", "v15-M"):
78
+ with pytest.raises(CarrierBudgetError):
79
+ parse_budget(bad)
80
+
81
+
82
+ class TestSerializedSize:
83
+ def test_canonical_size_is_the_compact_form(self):
84
+ assert serialized_size({"a": 1}) == len(b'{"a":1}')
85
+ assert serialized_size({"b": 2, "a": 1}) == len(b'{"a":1,"b":2}')
86
+
87
+ def test_model_objects_are_measured_in_wire_form(self):
88
+ from unidpp.model import ProductIdentifier
89
+
90
+ pid = ProductIdentifier("ma", "MA.1", "item", "live")
91
+ assert serialized_size(pid) == serialized_size(pid.to_dict())
92
+
93
+
94
+ class TestCheckPayload:
95
+ def test_fixture_known_to_fit_qr_v15_m_passes(self):
96
+ fixtures = {f.name: f for f in carrier_fixtures()}
97
+ fits = fixtures["dpp-fits-qr-v15-m"]
98
+ findings = check_payload(fits.data["payload"], fits.data["declaredCarrier"])
99
+ assert findings == []
100
+ m = measure(fits.data["payload"], declared=fits.data["declaredCarrier"])
101
+ assert m.fits_declared is True
102
+ assert 363 <= m.size_bytes <= 412 # inside the v15-M band, beyond v14-M
103
+ assert m.min_qr_by_ec["M"] == 15
104
+
105
+ def test_oversized_payload_flagged_with_size_versus_capacity(self):
106
+ fixtures = {f.name: f for f in carrier_fixtures()}
107
+ over = fixtures["dpp-over-qr-v15-m"]
108
+ findings = check_payload(over.data["payload"], over.data["declaredCarrier"])
109
+ assert [f.code for f in findings] == ["C1-carrier-over-budget"]
110
+ message = findings[0].message
111
+ assert f"{serialized_size(over.data['payload'])} bytes" in message
112
+ assert "holds 412 bytes" in message
113
+ assert "never truncate" in message
114
+
115
+ def test_malformed_budget_token_flagged(self):
116
+ findings = check_payload({"a": 1}, "qr-v15-X")
117
+ assert [f.code for f in findings] == ["C2-carrier-budget-malformed"]
118
+
119
+ def test_undeclared_payload_produces_no_findings(self):
120
+ # Without a declared class the check measures only — never flags.
121
+ assert CHECK_REGISTRY["carrier-budget"].fn({"payload": {"a": "x" * 5000}}) == []
122
+
123
+
124
+ class TestRunnerIntegration:
125
+ def test_check_registered_in_the_registry(self):
126
+ assert "carrier-budget" in CHECK_REGISTRY
127
+ assert "ISO/IEC 18004" in CHECK_REGISTRY["carrier-budget"].title
128
+
129
+ def test_runner_carrier_fixtures_run_and_pass(self):
130
+ report = run_conformance()
131
+ carrier_results = [r for r in report["results"] if r["kind"] == "carrier"]
132
+ assert [r["fixture"] for r in carrier_results] == [
133
+ "dpp-fits-qr-v15-m",
134
+ "dpp-over-qr-v15-m",
135
+ ]
136
+ assert all(r["outcome"] == "pass" for r in carrier_results)
137
+ fits, over = (r["carrier"] for r in carrier_results)
138
+ assert fits["declared"] == "qr-v15-M" and fits["fitsDeclared"] is True
139
+ assert over["declared"] == "qr-v15-M" and over["fitsDeclared"] is False
140
+ assert over["declaredCapacity"] == 412
141
+
142
+ def test_report_shows_the_carrier_column(self, tmp_path):
143
+ import unidpp.conformance as C
144
+
145
+ report = run_conformance()
146
+ md = C.write_markdown_report(report, tmp_path / "r.md").read_text()
147
+ assert "| Fixture | Kind | Outcome | Temporal | Carrier |" in md
148
+ assert "406 B vs 412 B · qr-v15-M (fits)" in md
149
+ assert "(OVER)" in md
150
+ assert "| AUDIT-A4 | negative | pass |" in md # existing rows intact
151
+
152
+ def test_measurements_cover_every_result(self):
153
+ report = run_conformance()
154
+ for r in report["results"]:
155
+ assert r["carrier"]["sizeBytes"] > 0
156
+ assert set(r["carrier"]["minQrByEc"]) == {"L", "M", "Q", "H"}
157
+
158
+
159
+ class TestReportIntegration:
160
+ @pytest.mark.skipif(
161
+ not (EU_FIXTURES / "INDEX.json").exists(),
162
+ reason="EN 18223 example corpus not present "
163
+ "(licensed extraction, not distributed with the repository)",
164
+ )
165
+ def test_eu_profile_report_carries_the_carrier_column(self):
166
+ from unidpp.adapters.en18223 import run_eu_profile_suite
167
+
168
+ report = run_eu_profile_suite(EU_FIXTURES)
169
+ by_id = {r["fixture"]: r for r in report["results"]}
170
+ # The EN's own Annex A Example 3 lands in the QR v15-M band.
171
+ ex3 = by_id["annexA-example3"]
172
+ assert ex3["carrier"]["sizeBytes"] == 371
173
+ assert ex3["carrier"]["minQrByEc"]["M"] == 15
174
+ # Unparseable fixtures carry no measurement.
175
+ assert by_id["annexA-example2"]["carrier"] is None
176
+
177
+ def test_competitor_report_carries_carrier_observations(self):
178
+ from scripts.competitor_report import run_competitor_suite
179
+
180
+ report = run_competitor_suite(COMPETITORS)
181
+ results = [r for v in report["vendors"] for r in v["results"]]
182
+ by_fixture = {r["fixture"]: r for r in results}
183
+ drill = by_fixture["drill-api-compressed"]
184
+ assert drill["carrier"]["sizeBytes"] == 2917 # exceeds QR v40-M? no: 2331
185
+ assert drill["carrier"]["minQrByEc"]["M"] is None # 2917 > 2331
186
+ # The AAS artifact is measured too (size only, no findings).
187
+ aas = by_fixture["battery-passport.json"]
188
+ assert aas["carrier"]["sizeBytes"] > 0
189
+ assert len(aas["findings"]) == 1 # still only the coverage observation
190
+
191
+ def test_render_column_formats(self):
192
+ assert render(None) == "—"
193
+ text = render(measure({"a": "x" * 380}, declared="qr-v15-M").to_dict())
194
+ assert text == "388 B vs 412 B · qr-v15-M (fits)"
195
+ over = render(measure({"a": "x" * 5000}, declared="qr-v15-M").to_dict())
196
+ assert "OVER" in over
197
+ big = render(measure({"a": "x" * 5000}).to_dict())
198
+ assert big.endswith("exceeds QR v40-M")