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.
- unidpp-0.1.0/LICENSE +21 -0
- unidpp-0.1.0/PKG-INFO +117 -0
- unidpp-0.1.0/README.md +92 -0
- unidpp-0.1.0/pyproject.toml +36 -0
- unidpp-0.1.0/setup.cfg +4 -0
- unidpp-0.1.0/tests/test_adapters.py +196 -0
- unidpp-0.1.0/tests/test_carrier.py +198 -0
- unidpp-0.1.0/tests/test_competitor_conformance.py +239 -0
- unidpp-0.1.0/tests/test_conformance.py +117 -0
- unidpp-0.1.0/tests/test_eu_profile.py +310 -0
- unidpp-0.1.0/tests/test_eventlog.py +181 -0
- unidpp-0.1.0/tests/test_fixtures.py +162 -0
- unidpp-0.1.0/tests/test_harness_f1.py +56 -0
- unidpp-0.1.0/tests/test_harness_f2f3.py +211 -0
- unidpp-0.1.0/tests/test_harness_protocol.py +111 -0
- unidpp-0.1.0/tests/test_harness_vectors.py +90 -0
- unidpp-0.1.0/tests/test_model.py +235 -0
- unidpp-0.1.0/tests/test_temporal.py +165 -0
- unidpp-0.1.0/tests/test_verify.py +227 -0
- unidpp-0.1.0/unidpp/__init__.py +104 -0
- unidpp-0.1.0/unidpp/adapters/__init__.py +21 -0
- unidpp-0.1.0/unidpp/adapters/en18223.py +1562 -0
- unidpp-0.1.0/unidpp/adapters/epcis.py +288 -0
- unidpp-0.1.0/unidpp/adapters/untp.py +135 -0
- unidpp-0.1.0/unidpp/canonical.py +114 -0
- unidpp-0.1.0/unidpp/carrier.py +272 -0
- unidpp-0.1.0/unidpp/conformance.py +769 -0
- unidpp-0.1.0/unidpp/eventlog.py +230 -0
- unidpp-0.1.0/unidpp/fixtures/__init__.py +6 -0
- unidpp-0.1.0/unidpp/fixtures/car.py +345 -0
- unidpp-0.1.0/unidpp/fixtures/laptop.py +295 -0
- unidpp-0.1.0/unidpp/harness/__init__.py +1 -0
- unidpp-0.1.0/unidpp/harness/ed25519.py +142 -0
- unidpp-0.1.0/unidpp/harness/f1.py +152 -0
- unidpp-0.1.0/unidpp/harness/framing.py +64 -0
- unidpp-0.1.0/unidpp/harness/mapping.py +78 -0
- unidpp-0.1.0/unidpp/harness/protocol.py +218 -0
- unidpp-0.1.0/unidpp/harness/vectors.py +270 -0
- unidpp-0.1.0/unidpp/model.py +1607 -0
- unidpp-0.1.0/unidpp/temporal.py +374 -0
- unidpp-0.1.0/unidpp/validate.py +237 -0
- unidpp-0.1.0/unidpp/verify.py +507 -0
- unidpp-0.1.0/unidpp.egg-info/PKG-INFO +117 -0
- unidpp-0.1.0/unidpp.egg-info/SOURCES.txt +45 -0
- unidpp-0.1.0/unidpp.egg-info/dependency_links.txt +1 -0
- unidpp-0.1.0/unidpp.egg-info/requires.txt +7 -0
- 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,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")
|