From 8f7b0915eaa67c67b8b59181e8d15784a24086cd Mon Sep 17 00:00:00 2001 From: Matthias Brenninkmeijer Date: Fri, 30 Jan 2026 03:21:41 +0100 Subject: [PATCH 01/12] chore: add docs/windsurf/ to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index bc2fa64..53c4902 100644 --- a/.gitignore +++ b/.gitignore @@ -209,3 +209,4 @@ __marimo__/ # Windsurf IDE .windsurf/ +docs/windsurf/ From a5b5843e5a81fa6dd14bb040c91ebb02b8427c4f Mon Sep 17 00:00:00 2001 From: Matthias Brenninkmeijer Date: Fri, 8 May 2026 02:03:28 +0200 Subject: [PATCH 02/12] Update develop --- .claude/commands/claude-health.md | 138 + .claude/commands/code-health.md | 92 + .claude/commands/dev-setup.md | 42 + .claude/commands/docs-health.md | 122 + .claude/commands/feature.md | 50 + .claude/commands/fix-lint.md | 20 + .claude/commands/hotfix.md | 150 + .claude/commands/lint.md | 20 + .claude/commands/pr-review.md | 45 + .claude/commands/release.md | 82 + .claude/commands/test.md | 83 + .claude/commands/untp-bump.md | 102 + .claude/hooks/ruff-fix.sh | 45 + .claude/rules/commits.md | 29 + .claude/rules/dpp-domain.md | 37 + .claude/rules/plugin-licenses.md | 50 + .claude/rules/python-style.md | 37 + .claude/rules/testing.md | 65 + .claude/rules/untp-versioning.md | 61 + .claude/settings.json | 46 + .claude/skills/pypi-publish/SKILL.md | 68 + .claude/skills/untp-migrate/SKILL.md | 125 + .../untp-migrate/scripts/diff_schema.py | 82 + .claude/skills/validate-dpp/SKILL.md | 67 + .github/workflows/ci.yml | 74 +- .github/workflows/docs.yml | 8 +- .github/workflows/release.yml | 174 +- .github/workflows/update-vocabularies.yml | 16 +- .gitignore | 15 + .pre-commit-config.yaml | 33 +- AGENTS.md | 47 +- CHANGELOG.md | 279 +- CLAUDE.md | 36 + README.md | 252 +- SECURITY.md | 24 + benchmarks/run_benchmarks.py | 38 +- docs/assets/apple-touch-icon.png | Bin 0 -> 18577 bytes docs/assets/favicon-16x16.png | Bin 0 -> 559 bytes docs/assets/favicon-32x32.png | Bin 0 -> 1410 bytes docs/assets/logo.png | Bin 0 -> 1334983 bytes docs/changelog.md | 76 +- docs/concepts/cirpass-implementation.md | 228 + docs/concepts/eudpp-ontology-alignment.md | 163 + docs/concepts/untp-schema.md | 90 +- docs/concepts/untp-versions.md | 194 + docs/concepts/validation-layers.md | 191 +- docs/dpp_validator_description.md | 13 + docs/errors/CQ001.md | 31 + docs/errors/CQ004.md | 31 + docs/errors/CQ011.md | 31 + docs/errors/CQ016.md | 31 + docs/errors/CQ017.md | 31 + docs/errors/CQ020.md | 31 + docs/errors/JLD001.md | 44 + docs/errors/JLD002.md | 52 + docs/errors/JLD003.md | 54 + docs/errors/JLD004.md | 61 + docs/errors/MDL001.md | 31 + docs/errors/MDL002.md | 31 + docs/errors/MDL003.md | 31 + docs/errors/MDL010.md | 31 + docs/errors/MDL011.md | 31 + docs/errors/MDL012.md | 31 + docs/errors/MDL013.md | 31 + docs/errors/MDL014.md | 31 + docs/errors/MDL015.md | 31 + docs/errors/MDL016.md | 31 + docs/errors/MDL020.md | 31 + docs/errors/MDL021.md | 31 + docs/errors/MDL022.md | 31 + docs/errors/MDL030.md | 31 + docs/errors/MDL031.md | 31 + docs/errors/MDL032.md | 31 + docs/errors/MDL033.md | 31 + docs/errors/MDL040.md | 31 + docs/errors/MDL041.md | 31 + docs/errors/MDL042.md | 31 + docs/errors/MDL050.md | 31 + docs/errors/MDL051.md | 31 + docs/errors/MDL052.md | 31 + docs/errors/MDL053.md | 31 + docs/errors/MDL060.md | 31 + docs/errors/MDL061.md | 31 + docs/errors/MDL070.md | 31 + docs/errors/MDL071.md | 31 + docs/errors/MDL080.md | 31 + docs/errors/MDL081.md | 31 + docs/errors/MDL090.md | 31 + docs/errors/MDL091.md | 31 + docs/errors/MDL099.md | 31 + docs/errors/PRS001.md | 59 + docs/errors/PRS002.md | 59 + docs/errors/PRS003.md | 64 + docs/errors/PRS004.md | 72 + docs/errors/PRS005.md | 68 + docs/errors/SCH001.md | 59 + docs/errors/SCH002.md | 31 + docs/errors/SCH003.md | 31 + docs/errors/SCH004.md | 31 + docs/errors/SCH005.md | 31 + docs/errors/SCH006.md | 31 + docs/errors/SCH007.md | 31 + docs/errors/SCH008.md | 31 + docs/errors/SCH009.md | 31 + docs/errors/SCH010.md | 31 + docs/errors/SCH011.md | 31 + docs/errors/SCH012.md | 31 + docs/errors/SCH013.md | 31 + docs/errors/SCH014.md | 31 + docs/errors/SCH015.md | 31 + docs/errors/SCH016.md | 31 + docs/errors/SCH017.md | 31 + docs/errors/SCH018.md | 31 + docs/errors/SCH019.md | 31 + docs/errors/SCH020.md | 31 + docs/errors/SCH021.md | 31 + docs/errors/SCH099.md | 31 + docs/errors/SEM001.md | 58 + docs/errors/SEM002.md | 47 + docs/errors/SEM003.md | 64 + docs/errors/SEM004.md | 55 + docs/errors/SEM005.md | 61 + docs/errors/SEM006.md | 65 + docs/errors/SEM007.md | 63 + docs/errors/TXT001.md | 31 + docs/errors/TXT002.md | 31 + docs/errors/TXT003.md | 31 + docs/errors/TXT004.md | 31 + docs/errors/TXT005.md | 31 + docs/errors/UPG001.md | 49 + docs/errors/UPG002.md | 57 + docs/errors/UPG003.md | 52 + docs/errors/UPG004.md | 66 + docs/errors/VER001.md | 57 + docs/errors/VOC001.md | 66 + docs/errors/VOC002.md | 75 + docs/errors/VOC003.md | 73 + docs/errors/VOC004.md | 79 + docs/errors/VOC005.md | 68 + docs/errors/index.md | 96 + docs/faq.md | 390 ++ docs/getting-started/installation.md | 47 +- docs/getting-started/quickstart.md | 49 +- docs/guides/cli-usage.md | 146 +- docs/guides/eudpp-export.md | 282 + docs/guides/jsonld.md | 29 +- docs/guides/migration-0-6-to-0-7.md | 196 + docs/guides/plugins.md | 85 + docs/guides/use-cases.md | 744 +++ docs/guides/validation.md | 218 +- docs/index.md | 51 +- docs/llms-ctx.txt | 56 +- docs/llms.txt | 2 +- docs/overrides/main.html | 5 + docs/plugins.md | 2 +- docs/reference/api/plugins.md | 25 +- docs/reference/api/validators.md | 49 +- docs/reference/cli.md | 113 +- docs/robots.txt | 45 + docs/stylesheets/extra.css | 22 + .../dppvalidator_example_plugin/README.md | 54 +- .../pyproject.toml | 17 +- .../dppvalidator_example_plugin/__init__.py | 13 +- .../brand_name_v07.py | 131 + examples/notebooks/01_quickstart.ipynb | 2 +- examples/notebooks/02_custom_rules.ipynb | 14 +- examples/notebooks/03_batch_processing.ipynb | 2 +- examples/notebooks/04_jsonld_export.ipynb | 2 +- .../notebooks/05_plugin_development.ipynb | 6 +- llms-ctx.txt | 27 +- llms.txt | 2 +- mkdocs.yml | 178 +- mutants/pyproject.toml | 112 - mutants/setup.cfg | 4 - .../src/dppvalidator/validators/results.py | 236 - .../dppvalidator/validators/results.py.meta | 14 - mutants/tests/__init__.py | 1 - mutants/tests/fixtures/__init__.py | 1 - .../tests/fixtures/invalid/invalid_dates.json | 15 - .../fixtures/invalid/missing_issuer.json | 8 - mutants/tests/fixtures/valid/full_dpp.json | 54 - mutants/tests/fixtures/valid/minimal_dpp.json | 15 - mutants/tests/fuzz/__init__.py | 1 - mutants/tests/fuzz/test_fuzz_engine.py | 205 - mutants/tests/integration/__init__.py | 1 - mutants/tests/property/__init__.py | 1 - .../tests/property/test_property_models.py | 205 - .../property/test_property_validators.py | 187 - mutants/tests/unit/__init__.py | 1 - mutants/tests/unit/test_cli.py | 374 -- mutants/tests/unit/test_exporters.py | 432 -- mutants/tests/unit/test_models.py | 585 -- mutants/tests/unit/test_plugins.py | 337 -- mutants/tests/unit/test_schemas.py | 233 - mutants/tests/unit/test_validators.py | 1010 ---- mutants/tests/unit/test_vocabularies.py | 262 - pyproject.toml | 66 +- scripts/check_error_docs.py | 131 + scripts/download_cirpass_vocabularies.py | 180 + scripts/fetch_dpp_samples.py | 412 ++ scripts/fetch_vocabularies.py | 2 +- scripts/generate_error_docs.py | 197 + setup.cfg | 4 - src/dppvalidator/__init__.py | 7 + src/dppvalidator/cli/commands/completions.py | 25 +- src/dppvalidator/cli/commands/doctor.py | 39 +- src/dppvalidator/cli/commands/export.py | 7 +- src/dppvalidator/cli/commands/init.py | 238 +- src/dppvalidator/cli/commands/migrate.py | 213 + src/dppvalidator/cli/commands/schema.py | 67 +- src/dppvalidator/cli/commands/validate.py | 261 +- src/dppvalidator/cli/commands/watch.py | 291 +- src/dppvalidator/cli/console.py | 6 +- src/dppvalidator/cli/main.py | 13 +- src/dppvalidator/compat/__init__.py | 69 + src/dppvalidator/compat/upgrade_0_6_to_0_7.py | 972 ++++ src/dppvalidator/exporters/__init__.py | 19 + src/dppvalidator/exporters/contexts.py | 14 +- src/dppvalidator/exporters/eudpp_jsonld.py | 581 ++ src/dppvalidator/models/claims.py | 186 +- src/dppvalidator/models/credential.py | 168 +- src/dppvalidator/models/enums.py | 94 +- src/dppvalidator/models/identifiers.py | 86 +- src/dppvalidator/models/materials.py | 90 +- src/dppvalidator/models/passport.py | 104 +- src/dppvalidator/models/performance.py | 174 +- src/dppvalidator/models/primitives.py | 150 +- src/dppvalidator/models/product.py | 117 +- src/dppvalidator/models/v0_6/__init__.py | 93 + src/dppvalidator/models/v0_6/claims.py | 164 + src/dppvalidator/models/v0_6/credential.py | 154 + src/dppvalidator/models/v0_6/enums.py | 70 + src/dppvalidator/models/v0_6/identifiers.py | 68 + src/dppvalidator/models/v0_6/materials.py | 78 + src/dppvalidator/models/v0_6/passport.py | 94 + src/dppvalidator/models/v0_6/performance.py | 156 + src/dppvalidator/models/v0_6/primitives.py | 132 + src/dppvalidator/models/v0_6/product.py | 99 + src/dppvalidator/models/v0_7/__init__.py | 95 + src/dppvalidator/models/v0_7/claims.py | 217 + src/dppvalidator/models/v0_7/envelope.py | 245 + src/dppvalidator/models/v0_7/identifiers.py | 210 + src/dppvalidator/models/v0_7/materials.py | 113 + src/dppvalidator/models/v0_7/primitives.py | 240 + src/dppvalidator/models/v0_7/product.py | 254 + src/dppvalidator/plugins/registry.py | 15 +- src/dppvalidator/schemas/__init__.py | 20 +- src/dppvalidator/schemas/cirpass_loader.py | 223 + src/dppvalidator/schemas/data/MANIFEST.json | 89 + src/dppvalidator/schemas/data/README.md | 70 +- .../schemas/data/untp-dpp-schema-0.7.0.json | 1455 +++++ src/dppvalidator/schemas/loader.py | 43 +- src/dppvalidator/schemas/registry.py | 62 +- src/dppvalidator/validators/__init__.py | 54 +- src/dppvalidator/validators/deep.py | 494 ++ src/dppvalidator/validators/detection.py | 199 + src/dppvalidator/validators/engine.py | 466 +- src/dppvalidator/validators/errors.py | 54 +- .../validators/jsonld_semantic.py | 517 ++ src/dppvalidator/validators/layers.py | 318 ++ src/dppvalidator/validators/model.py | 99 +- src/dppvalidator/validators/results.py | 19 +- src/dppvalidator/validators/rules/__init__.py | 102 +- src/dppvalidator/validators/rules/base.py | 266 +- src/dppvalidator/validators/rules/cirpass.py | 33 + src/dppvalidator/validators/rules/textile.py | 37 + .../validators/rules/v0_6/__init__.py | 102 + .../validators/rules/v0_6/base.py | 398 ++ .../validators/rules/v0_6/cirpass.py | 306 + .../validators/rules/v0_6/textile.py | 359 ++ .../validators/rules/v0_7/__init__.py | 96 + .../validators/rules/v0_7/base.py | 429 ++ .../validators/rules/v0_7/cirpass.py | 240 + .../validators/rules/v0_7/textile.py | 277 + src/dppvalidator/validators/schema.py | 92 +- src/dppvalidator/validators/semantic.py | 28 +- src/dppvalidator/validators/shacl.py | 645 +++ src/dppvalidator/verifier/__init__.py | 26 + src/dppvalidator/verifier/did.py | 389 ++ src/dppvalidator/verifier/signatures.py | 261 + src/dppvalidator/verifier/verifier.py | 417 ++ src/dppvalidator/vocabularies/__init__.py | 56 +- src/dppvalidator/vocabularies/cache.py | 2 +- .../vocabularies/cirpass_terms.py | 263 + src/dppvalidator/vocabularies/code_lists.py | 213 + .../vocabularies/data/__init__.py | 10 + .../vocabularies/data/hs_codes.json | 37 + .../vocabularies/data/materials.json | 92 + .../vocabularies/data/ontologies/__init__.py | 1 + .../data/ontologies/actors_roles_v1.5.1.ttl | 506 ++ .../data/ontologies/eudpp_core_v1.3.1.ttl | 77 + .../vocabularies/data/ontologies/lca_v2.0.ttl | 454 ++ .../data/ontologies/product_dpp_v1.7.1.ttl | 704 +++ .../data/ontologies/soc_v1.4.7.ttl | 173 + .../vocabularies/data/schemas/__init__.py | 1 + .../data/schemas/cirpass_dpp_openapi.json | 648 +++ .../data/schemas/cirpass_dpp_schema.json | 499 ++ .../data/schemas/cirpass_dpp_schema.xsd | 589 ++ .../data/schemas/cirpass_dpp_schema.yaml | 347 ++ .../data/schemas/cirpass_dpp_shacl.ttl | 614 ++ .../data/untp-context-0.6.1.jsonld | 1164 ++++ .../data/untp-context-0.7.0.jsonld | 3493 ++++++++++++ .../vocabularies/data/untp-metrics.jsonld | 1146 ++++ .../vocabularies/data/untp-ontology.jsonld | 5046 +++++++++++++++++ .../vocabularies/data/untp-topics.jsonld | 1281 +++++ src/dppvalidator/vocabularies/eudpp_actors.py | 639 +++ .../vocabularies/eudpp_classes.py | 650 +++ src/dppvalidator/vocabularies/eudpp_lca.py | 517 ++ .../vocabularies/eudpp_relations.py | 799 +++ .../vocabularies/eudpp_substances.py | 461 ++ src/dppvalidator/vocabularies/loader.py | 63 +- src/dppvalidator/vocabularies/ontology.py | 555 ++ src/dppvalidator/vocabularies/rdf_loader.py | 391 ++ tests/conftest.py | 201 + .../0.7.0/claim_missing_performance.json | 53 + .../0.7.0/country_string_regression.json | 41 + .../0.7.0/material_missing_materialType.json | 48 + .../0.7.0/missing_credentialSubject.json | 15 + .../fixtures/invalid/0.7.0/missing_name.json | 40 + .../invalid/0.7.0/missing_validFrom.json | 40 + ...ass_GeneralProductInformation-payload.json | 37 + ...aModel_CarbonFootprintForBatteries-ld.json | 244 + ...s_BatteryPassDataModel_Circularity-ld.json | 714 +++ ...ataModel_GeneralProductInformation-ld.json | 497 ++ ...yPassDataModel_MaterialComposition-ld.json | 536 ++ ...tusx_sldt-semantic-models_BatteryPass.json | 643 +++ .../nfc-forum_org_long-dpp-example.json | 43 + ...g_untp-digital-facility-record-v0.3.9.json | 462 ++ ...untp-digital-product-passport-v0.3.10.json | 389 ++ .../schemas_testing_breathable-t-shirt.json | 26 + ..._DigitalIdentityAnchor-instance-0.6.1.json | 66 + ..._uncefact_org_untp-dpp-instance-0.6.0.json | 561 ++ ..._bc075c5f-2304-4b3f-bb24-46d9fa9a8e60.json | 8 + tests/fixtures/samples_report.baseline.md | 255 + tests/fixtures/samples_report.md | 255 + tests/fixtures/upstream/SOURCES.md | 96 + .../v0.7.0/contexts/untp-context.jsonld | 3493 ++++++++++++ ...gitalProductPassport_battery_instance.json | 720 +++ ...gitalProductPassport_cathode_instance.json | 284 + .../DigitalProductPassport_instance.json | 263 + .../v0.7.0/schema/DigitalProductPassport.json | 1455 +++++ .../upstream/v0.7.0/schema/Product.json | 1121 ++++ .../v0.7.0/vocabularies/untp-metrics.jsonld | 1146 ++++ .../v0.7.0/vocabularies/untp-ontology.jsonld | 5046 +++++++++++++++++ .../v0.7.0/vocabularies/untp-topics.jsonld | 1281 +++++ tests/fixtures/valid/minimal_dpp.json | 12 + .../untp-dpp-battery-instance-0.7.0.json | 720 +++ .../untp-dpp-cathode-instance-0.7.0.json | 284 + .../valid/untp-dpp-instance-0.7.0.json | 263 + tests/fuzz/test_fuzz_cirpass.py | 233 + tests/fuzz/test_fuzz_engine.py | 24 +- .../test_cirpass_shacl_integration.py | 234 + tests/integration/test_cli_workflows.py | 197 +- tests/integration/test_compat_roundtrip.py | 183 + tests/integration/test_example_plugin.py | 343 ++ tests/integration/test_real_world_samples.py | 444 ++ tests/integration/test_validation_pipeline.py | 71 +- tests/integration/test_version_matrix.py | 178 + tests/property/test_property_cirpass.py | 243 + tests/property/test_property_validators.py | 2 +- tests/unit/test_cirpass_loader.py | 305 + tests/unit/test_cirpass_rules.py | 417 ++ tests/unit/test_cirpass_vocabulary.py | 275 + tests/unit/test_cli.py | 242 +- tests/unit/test_cli_commands.py | 61 +- tests/unit/test_cli_init.py | 18 +- tests/unit/test_cli_migrate.py | 244 + tests/unit/test_code_lists.py | 285 + tests/unit/test_compat_upgrade.py | 711 +++ tests/unit/test_credential_verifier.py | 874 +++ tests/unit/test_deep_extended.py | 473 ++ tests/unit/test_deep_validation.py | 309 + tests/unit/test_deep_validation_v07.py | 212 + tests/unit/test_detection.py | 287 + tests/unit/test_did_resolver.py | 676 +++ tests/unit/test_doctor.py | 2 +- tests/unit/test_engine_extended.py | 570 ++ tests/unit/test_errors.py | 62 +- tests/unit/test_eudpp_actors.py | 389 ++ tests/unit/test_eudpp_classes.py | 641 +++ tests/unit/test_eudpp_export.py | 386 ++ tests/unit/test_eudpp_export_v07.py | 226 + tests/unit/test_eudpp_lca.py | 390 ++ tests/unit/test_eudpp_relations.py | 296 + tests/unit/test_eudpp_substances.py | 398 ++ tests/unit/test_exporters.py | 119 +- tests/unit/test_init_command.py | 12 +- tests/unit/test_jsonld_semantic_extended.py | 381 ++ tests/unit/test_jsonld_validation.py | 259 + tests/unit/test_manifest_integrity.py | 217 + tests/unit/test_models.py | 106 +- tests/unit/test_no_version_literals.py | 128 + tests/unit/test_ontology_alignment.py | 367 ++ tests/unit/test_ontology_v07.py | 183 + tests/unit/test_phase2_schema_and_jsonld.py | 188 + tests/unit/test_plugins.py | 67 +- tests/unit/test_production_url.py | 162 + tests/unit/test_public_api_stability.py | 168 + tests/unit/test_rdf_loader.py | 610 ++ tests/unit/test_samples_classification.py | 111 + tests/unit/test_schema_dual_mode.py | 247 + tests/unit/test_schema_validator.py | 45 +- tests/unit/test_schemas.py | 60 +- tests/unit/test_semantic_rules.py | 66 +- tests/unit/test_semantic_rules_v07.py | 221 + tests/unit/test_shacl_official.py | 819 +++ tests/unit/test_signatures.py | 419 ++ tests/unit/test_signatures_extended.py | 292 + tests/unit/test_textile_rules.py | 431 ++ tests/unit/test_v07_models.py | 300 + tests/unit/test_validation_engine.py | 156 +- tests/unit/test_validation_layers.py | 250 + tests/unit/test_vc_verification.py | 244 + tests/unit/test_version_mismatch.py | 117 + tests/unit/test_vocabularies.py | 31 +- tests/unit/test_vocabulary_data_imports.py | 208 + tests/unit/test_vocabulary_loader.py | 58 + tests/unit/test_watch_command.py | 490 ++ uv.lock | 857 ++- 419 files changed, 85940 insertions(+), 7029 deletions(-) create mode 100644 .claude/commands/claude-health.md create mode 100644 .claude/commands/code-health.md create mode 100644 .claude/commands/dev-setup.md create mode 100644 .claude/commands/docs-health.md create mode 100644 .claude/commands/feature.md create mode 100644 .claude/commands/fix-lint.md create mode 100644 .claude/commands/hotfix.md create mode 100644 .claude/commands/lint.md create mode 100644 .claude/commands/pr-review.md create mode 100644 .claude/commands/release.md create mode 100644 .claude/commands/test.md create mode 100644 .claude/commands/untp-bump.md create mode 100755 .claude/hooks/ruff-fix.sh create mode 100644 .claude/rules/commits.md create mode 100644 .claude/rules/dpp-domain.md create mode 100644 .claude/rules/plugin-licenses.md create mode 100644 .claude/rules/python-style.md create mode 100644 .claude/rules/testing.md create mode 100644 .claude/rules/untp-versioning.md create mode 100644 .claude/settings.json create mode 100644 .claude/skills/pypi-publish/SKILL.md create mode 100644 .claude/skills/untp-migrate/SKILL.md create mode 100755 .claude/skills/untp-migrate/scripts/diff_schema.py create mode 100644 .claude/skills/validate-dpp/SKILL.md create mode 100644 CLAUDE.md create mode 100644 SECURITY.md create mode 100644 docs/assets/apple-touch-icon.png create mode 100644 docs/assets/favicon-16x16.png create mode 100644 docs/assets/favicon-32x32.png create mode 100644 docs/assets/logo.png create mode 100644 docs/concepts/cirpass-implementation.md create mode 100644 docs/concepts/eudpp-ontology-alignment.md create mode 100644 docs/concepts/untp-versions.md create mode 100644 docs/dpp_validator_description.md create mode 100644 docs/errors/CQ001.md create mode 100644 docs/errors/CQ004.md create mode 100644 docs/errors/CQ011.md create mode 100644 docs/errors/CQ016.md create mode 100644 docs/errors/CQ017.md create mode 100644 docs/errors/CQ020.md create mode 100644 docs/errors/JLD001.md create mode 100644 docs/errors/JLD002.md create mode 100644 docs/errors/JLD003.md create mode 100644 docs/errors/JLD004.md create mode 100644 docs/errors/MDL001.md create mode 100644 docs/errors/MDL002.md create mode 100644 docs/errors/MDL003.md create mode 100644 docs/errors/MDL010.md create mode 100644 docs/errors/MDL011.md create mode 100644 docs/errors/MDL012.md create mode 100644 docs/errors/MDL013.md create mode 100644 docs/errors/MDL014.md create mode 100644 docs/errors/MDL015.md create mode 100644 docs/errors/MDL016.md create mode 100644 docs/errors/MDL020.md create mode 100644 docs/errors/MDL021.md create mode 100644 docs/errors/MDL022.md create mode 100644 docs/errors/MDL030.md create mode 100644 docs/errors/MDL031.md create mode 100644 docs/errors/MDL032.md create mode 100644 docs/errors/MDL033.md create mode 100644 docs/errors/MDL040.md create mode 100644 docs/errors/MDL041.md create mode 100644 docs/errors/MDL042.md create mode 100644 docs/errors/MDL050.md create mode 100644 docs/errors/MDL051.md create mode 100644 docs/errors/MDL052.md create mode 100644 docs/errors/MDL053.md create mode 100644 docs/errors/MDL060.md create mode 100644 docs/errors/MDL061.md create mode 100644 docs/errors/MDL070.md create mode 100644 docs/errors/MDL071.md create mode 100644 docs/errors/MDL080.md create mode 100644 docs/errors/MDL081.md create mode 100644 docs/errors/MDL090.md create mode 100644 docs/errors/MDL091.md create mode 100644 docs/errors/MDL099.md create mode 100644 docs/errors/PRS001.md create mode 100644 docs/errors/PRS002.md create mode 100644 docs/errors/PRS003.md create mode 100644 docs/errors/PRS004.md create mode 100644 docs/errors/PRS005.md create mode 100644 docs/errors/SCH001.md create mode 100644 docs/errors/SCH002.md create mode 100644 docs/errors/SCH003.md create mode 100644 docs/errors/SCH004.md create mode 100644 docs/errors/SCH005.md create mode 100644 docs/errors/SCH006.md create mode 100644 docs/errors/SCH007.md create mode 100644 docs/errors/SCH008.md create mode 100644 docs/errors/SCH009.md create mode 100644 docs/errors/SCH010.md create mode 100644 docs/errors/SCH011.md create mode 100644 docs/errors/SCH012.md create mode 100644 docs/errors/SCH013.md create mode 100644 docs/errors/SCH014.md create mode 100644 docs/errors/SCH015.md create mode 100644 docs/errors/SCH016.md create mode 100644 docs/errors/SCH017.md create mode 100644 docs/errors/SCH018.md create mode 100644 docs/errors/SCH019.md create mode 100644 docs/errors/SCH020.md create mode 100644 docs/errors/SCH021.md create mode 100644 docs/errors/SCH099.md create mode 100644 docs/errors/SEM001.md create mode 100644 docs/errors/SEM002.md create mode 100644 docs/errors/SEM003.md create mode 100644 docs/errors/SEM004.md create mode 100644 docs/errors/SEM005.md create mode 100644 docs/errors/SEM006.md create mode 100644 docs/errors/SEM007.md create mode 100644 docs/errors/TXT001.md create mode 100644 docs/errors/TXT002.md create mode 100644 docs/errors/TXT003.md create mode 100644 docs/errors/TXT004.md create mode 100644 docs/errors/TXT005.md create mode 100644 docs/errors/UPG001.md create mode 100644 docs/errors/UPG002.md create mode 100644 docs/errors/UPG003.md create mode 100644 docs/errors/UPG004.md create mode 100644 docs/errors/VER001.md create mode 100644 docs/errors/VOC001.md create mode 100644 docs/errors/VOC002.md create mode 100644 docs/errors/VOC003.md create mode 100644 docs/errors/VOC004.md create mode 100644 docs/errors/VOC005.md create mode 100644 docs/errors/index.md create mode 100644 docs/faq.md create mode 100644 docs/guides/eudpp-export.md create mode 100644 docs/guides/migration-0-6-to-0-7.md create mode 100644 docs/guides/use-cases.md create mode 100644 docs/overrides/main.html create mode 100644 docs/robots.txt create mode 100644 docs/stylesheets/extra.css create mode 100644 examples/dppvalidator_example_plugin/src/dppvalidator_example_plugin/brand_name_v07.py delete mode 100644 mutants/pyproject.toml delete mode 100644 mutants/setup.cfg delete mode 100644 mutants/src/dppvalidator/validators/results.py delete mode 100644 mutants/src/dppvalidator/validators/results.py.meta delete mode 100644 mutants/tests/__init__.py delete mode 100644 mutants/tests/fixtures/__init__.py delete mode 100644 mutants/tests/fixtures/invalid/invalid_dates.json delete mode 100644 mutants/tests/fixtures/invalid/missing_issuer.json delete mode 100644 mutants/tests/fixtures/valid/full_dpp.json delete mode 100644 mutants/tests/fixtures/valid/minimal_dpp.json delete mode 100644 mutants/tests/fuzz/__init__.py delete mode 100644 mutants/tests/fuzz/test_fuzz_engine.py delete mode 100644 mutants/tests/integration/__init__.py delete mode 100644 mutants/tests/property/__init__.py delete mode 100644 mutants/tests/property/test_property_models.py delete mode 100644 mutants/tests/property/test_property_validators.py delete mode 100644 mutants/tests/unit/__init__.py delete mode 100644 mutants/tests/unit/test_cli.py delete mode 100644 mutants/tests/unit/test_exporters.py delete mode 100644 mutants/tests/unit/test_models.py delete mode 100644 mutants/tests/unit/test_plugins.py delete mode 100644 mutants/tests/unit/test_schemas.py delete mode 100644 mutants/tests/unit/test_validators.py delete mode 100644 mutants/tests/unit/test_vocabularies.py create mode 100644 scripts/check_error_docs.py create mode 100644 scripts/download_cirpass_vocabularies.py create mode 100644 scripts/fetch_dpp_samples.py create mode 100644 scripts/generate_error_docs.py delete mode 100644 setup.cfg create mode 100644 src/dppvalidator/cli/commands/migrate.py create mode 100644 src/dppvalidator/compat/__init__.py create mode 100644 src/dppvalidator/compat/upgrade_0_6_to_0_7.py create mode 100644 src/dppvalidator/exporters/eudpp_jsonld.py create mode 100644 src/dppvalidator/models/v0_6/__init__.py create mode 100644 src/dppvalidator/models/v0_6/claims.py create mode 100644 src/dppvalidator/models/v0_6/credential.py create mode 100644 src/dppvalidator/models/v0_6/enums.py create mode 100644 src/dppvalidator/models/v0_6/identifiers.py create mode 100644 src/dppvalidator/models/v0_6/materials.py create mode 100644 src/dppvalidator/models/v0_6/passport.py create mode 100644 src/dppvalidator/models/v0_6/performance.py create mode 100644 src/dppvalidator/models/v0_6/primitives.py create mode 100644 src/dppvalidator/models/v0_6/product.py create mode 100644 src/dppvalidator/models/v0_7/__init__.py create mode 100644 src/dppvalidator/models/v0_7/claims.py create mode 100644 src/dppvalidator/models/v0_7/envelope.py create mode 100644 src/dppvalidator/models/v0_7/identifiers.py create mode 100644 src/dppvalidator/models/v0_7/materials.py create mode 100644 src/dppvalidator/models/v0_7/primitives.py create mode 100644 src/dppvalidator/models/v0_7/product.py create mode 100644 src/dppvalidator/schemas/cirpass_loader.py create mode 100644 src/dppvalidator/schemas/data/MANIFEST.json create mode 100644 src/dppvalidator/schemas/data/untp-dpp-schema-0.7.0.json create mode 100644 src/dppvalidator/validators/deep.py create mode 100644 src/dppvalidator/validators/detection.py create mode 100644 src/dppvalidator/validators/jsonld_semantic.py create mode 100644 src/dppvalidator/validators/layers.py create mode 100644 src/dppvalidator/validators/rules/cirpass.py create mode 100644 src/dppvalidator/validators/rules/textile.py create mode 100644 src/dppvalidator/validators/rules/v0_6/__init__.py create mode 100644 src/dppvalidator/validators/rules/v0_6/base.py create mode 100644 src/dppvalidator/validators/rules/v0_6/cirpass.py create mode 100644 src/dppvalidator/validators/rules/v0_6/textile.py create mode 100644 src/dppvalidator/validators/rules/v0_7/__init__.py create mode 100644 src/dppvalidator/validators/rules/v0_7/base.py create mode 100644 src/dppvalidator/validators/rules/v0_7/cirpass.py create mode 100644 src/dppvalidator/validators/rules/v0_7/textile.py create mode 100644 src/dppvalidator/validators/shacl.py create mode 100644 src/dppvalidator/verifier/__init__.py create mode 100644 src/dppvalidator/verifier/did.py create mode 100644 src/dppvalidator/verifier/signatures.py create mode 100644 src/dppvalidator/verifier/verifier.py create mode 100644 src/dppvalidator/vocabularies/cirpass_terms.py create mode 100644 src/dppvalidator/vocabularies/code_lists.py create mode 100644 src/dppvalidator/vocabularies/data/__init__.py create mode 100644 src/dppvalidator/vocabularies/data/hs_codes.json create mode 100644 src/dppvalidator/vocabularies/data/materials.json create mode 100644 src/dppvalidator/vocabularies/data/ontologies/__init__.py create mode 100644 src/dppvalidator/vocabularies/data/ontologies/actors_roles_v1.5.1.ttl create mode 100644 src/dppvalidator/vocabularies/data/ontologies/eudpp_core_v1.3.1.ttl create mode 100644 src/dppvalidator/vocabularies/data/ontologies/lca_v2.0.ttl create mode 100644 src/dppvalidator/vocabularies/data/ontologies/product_dpp_v1.7.1.ttl create mode 100644 src/dppvalidator/vocabularies/data/ontologies/soc_v1.4.7.ttl create mode 100644 src/dppvalidator/vocabularies/data/schemas/__init__.py create mode 100644 src/dppvalidator/vocabularies/data/schemas/cirpass_dpp_openapi.json create mode 100644 src/dppvalidator/vocabularies/data/schemas/cirpass_dpp_schema.json create mode 100644 src/dppvalidator/vocabularies/data/schemas/cirpass_dpp_schema.xsd create mode 100644 src/dppvalidator/vocabularies/data/schemas/cirpass_dpp_schema.yaml create mode 100644 src/dppvalidator/vocabularies/data/schemas/cirpass_dpp_shacl.ttl create mode 100644 src/dppvalidator/vocabularies/data/untp-context-0.6.1.jsonld create mode 100644 src/dppvalidator/vocabularies/data/untp-context-0.7.0.jsonld create mode 100644 src/dppvalidator/vocabularies/data/untp-metrics.jsonld create mode 100644 src/dppvalidator/vocabularies/data/untp-ontology.jsonld create mode 100644 src/dppvalidator/vocabularies/data/untp-topics.jsonld create mode 100644 src/dppvalidator/vocabularies/eudpp_actors.py create mode 100644 src/dppvalidator/vocabularies/eudpp_classes.py create mode 100644 src/dppvalidator/vocabularies/eudpp_lca.py create mode 100644 src/dppvalidator/vocabularies/eudpp_relations.py create mode 100644 src/dppvalidator/vocabularies/eudpp_substances.py create mode 100644 src/dppvalidator/vocabularies/ontology.py create mode 100644 src/dppvalidator/vocabularies/rdf_loader.py create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/invalid/0.7.0/claim_missing_performance.json create mode 100644 tests/fixtures/invalid/0.7.0/country_string_regression.json create mode 100644 tests/fixtures/invalid/0.7.0/material_missing_materialType.json create mode 100644 tests/fixtures/invalid/0.7.0/missing_credentialSubject.json create mode 100644 tests/fixtures/invalid/0.7.0/missing_name.json create mode 100644 tests/fixtures/invalid/0.7.0/missing_validFrom.json create mode 100644 tests/fixtures/samples/BatteryPassDataModel_BatteryPass_GeneralProductInformation-payload.json create mode 100644 tests/fixtures/samples/batterypass_BatteryPassDataModel_CarbonFootprintForBatteries-ld.json create mode 100644 tests/fixtures/samples/batterypass_BatteryPassDataModel_Circularity-ld.json create mode 100644 tests/fixtures/samples/batterypass_BatteryPassDataModel_GeneralProductInformation-ld.json create mode 100644 tests/fixtures/samples/batterypass_BatteryPassDataModel_MaterialComposition-ld.json create mode 100644 tests/fixtures/samples/eclipse-tractusx_sldt-semantic-models_BatteryPass.json create mode 100644 tests/fixtures/samples/nfc-forum_org_long-dpp-example.json create mode 100644 tests/fixtures/samples/opensource_unicc_org_untp-digital-facility-record-v0.3.9.json create mode 100644 tests/fixtures/samples/opensource_unicc_org_untp-digital-product-passport-v0.3.10.json create mode 100644 tests/fixtures/samples/schemas_testing_breathable-t-shirt.json create mode 100644 tests/fixtures/samples/test_uncefact_org_DigitalIdentityAnchor-instance-0.6.1.json create mode 100644 tests/fixtures/samples/test_uncefact_org_untp-dpp-instance-0.6.0.json create mode 100644 tests/fixtures/samples/untp-verifiable-credentials_s3_amazonaws_com_bc075c5f-2304-4b3f-bb24-46d9fa9a8e60.json create mode 100644 tests/fixtures/samples_report.baseline.md create mode 100644 tests/fixtures/samples_report.md create mode 100644 tests/fixtures/upstream/SOURCES.md create mode 100644 tests/fixtures/upstream/v0.7.0/contexts/untp-context.jsonld create mode 100644 tests/fixtures/upstream/v0.7.0/samples/DigitalProductPassport_battery_instance.json create mode 100644 tests/fixtures/upstream/v0.7.0/samples/DigitalProductPassport_cathode_instance.json create mode 100644 tests/fixtures/upstream/v0.7.0/samples/DigitalProductPassport_instance.json create mode 100644 tests/fixtures/upstream/v0.7.0/schema/DigitalProductPassport.json create mode 100644 tests/fixtures/upstream/v0.7.0/schema/Product.json create mode 100644 tests/fixtures/upstream/v0.7.0/vocabularies/untp-metrics.jsonld create mode 100644 tests/fixtures/upstream/v0.7.0/vocabularies/untp-ontology.jsonld create mode 100644 tests/fixtures/upstream/v0.7.0/vocabularies/untp-topics.jsonld create mode 100644 tests/fixtures/valid/untp-dpp-battery-instance-0.7.0.json create mode 100644 tests/fixtures/valid/untp-dpp-cathode-instance-0.7.0.json create mode 100644 tests/fixtures/valid/untp-dpp-instance-0.7.0.json create mode 100644 tests/fuzz/test_fuzz_cirpass.py create mode 100644 tests/integration/test_cirpass_shacl_integration.py create mode 100644 tests/integration/test_compat_roundtrip.py create mode 100644 tests/integration/test_example_plugin.py create mode 100644 tests/integration/test_real_world_samples.py create mode 100644 tests/integration/test_version_matrix.py create mode 100644 tests/property/test_property_cirpass.py create mode 100644 tests/unit/test_cirpass_loader.py create mode 100644 tests/unit/test_cirpass_rules.py create mode 100644 tests/unit/test_cirpass_vocabulary.py create mode 100644 tests/unit/test_cli_migrate.py create mode 100644 tests/unit/test_code_lists.py create mode 100644 tests/unit/test_compat_upgrade.py create mode 100644 tests/unit/test_credential_verifier.py create mode 100644 tests/unit/test_deep_extended.py create mode 100644 tests/unit/test_deep_validation.py create mode 100644 tests/unit/test_deep_validation_v07.py create mode 100644 tests/unit/test_detection.py create mode 100644 tests/unit/test_did_resolver.py create mode 100644 tests/unit/test_engine_extended.py create mode 100644 tests/unit/test_eudpp_actors.py create mode 100644 tests/unit/test_eudpp_classes.py create mode 100644 tests/unit/test_eudpp_export.py create mode 100644 tests/unit/test_eudpp_export_v07.py create mode 100644 tests/unit/test_eudpp_lca.py create mode 100644 tests/unit/test_eudpp_relations.py create mode 100644 tests/unit/test_eudpp_substances.py create mode 100644 tests/unit/test_jsonld_semantic_extended.py create mode 100644 tests/unit/test_jsonld_validation.py create mode 100644 tests/unit/test_manifest_integrity.py create mode 100644 tests/unit/test_no_version_literals.py create mode 100644 tests/unit/test_ontology_alignment.py create mode 100644 tests/unit/test_ontology_v07.py create mode 100644 tests/unit/test_phase2_schema_and_jsonld.py create mode 100644 tests/unit/test_production_url.py create mode 100644 tests/unit/test_public_api_stability.py create mode 100644 tests/unit/test_rdf_loader.py create mode 100644 tests/unit/test_samples_classification.py create mode 100644 tests/unit/test_schema_dual_mode.py create mode 100644 tests/unit/test_semantic_rules_v07.py create mode 100644 tests/unit/test_shacl_official.py create mode 100644 tests/unit/test_signatures.py create mode 100644 tests/unit/test_signatures_extended.py create mode 100644 tests/unit/test_textile_rules.py create mode 100644 tests/unit/test_v07_models.py create mode 100644 tests/unit/test_validation_layers.py create mode 100644 tests/unit/test_vc_verification.py create mode 100644 tests/unit/test_version_mismatch.py create mode 100644 tests/unit/test_vocabulary_data_imports.py create mode 100644 tests/unit/test_vocabulary_loader.py create mode 100644 tests/unit/test_watch_command.py diff --git a/.claude/commands/claude-health.md b/.claude/commands/claude-health.md new file mode 100644 index 0000000..dedc2ce --- /dev/null +++ b/.claude/commands/claude-health.md @@ -0,0 +1,138 @@ +--- +description: Maintain coherence across Claude Code agentic capabilities (CLAUDE.md, rules, skills, commands, hooks) +allowed-tools: Bash(ls *) Bash(find *) Bash(wc *) Bash(head *) Bash(cat *) Bash(jq *) Bash(python3 *) +--- + +# /claude-health + +Audit the project's Claude Code configuration and surface inconsistencies. + +## 1. Inventory check + +Verify all expected Claude Code files exist: + +```! +ls -la .claude/rules/*.md .claude/commands/*.md .claude/settings.json 2>/dev/null | head -40 +``` + +```! +find .claude/skills -name "SKILL.md" 2>/dev/null +``` + +```! +ls -la CLAUDE.md AGENTS.md 2>/dev/null +``` + +## 2. Size validation + +Keep individual rules and commands tight. CLAUDE.md should stay under ~200 lines for best adherence. + +```! +wc -l CLAUDE.md AGENTS.md 2>/dev/null +``` + +```! +wc -c .claude/rules/*.md +``` + +```! +wc -c .claude/commands/*.md +``` + +```! +wc -c .claude/skills/*/SKILL.md +``` + +## 3. Cross-reference audit + +Confirm these alignments: + +| Source | Must reference | Check | +| ------------------------------------- | --------------------- | ------------------------------------ | +| `.claude/rules/python-style.md` | Pydantic, type hints | Matches `src/dppvalidator/` patterns | +| `.claude/rules/dpp-domain.md` | ESPR, CIRPASS, DPP | Matches domain model structure | +| `.claude/rules/commits.md` | Conventional commits | Consistent with gitflow workflows | +| `.claude/skills/validate-dpp/` | Validation logic | References correct validator paths | +| `.claude/skills/pypi-publish/` | Publishing steps | Matches `pyproject.toml` config | +| Root `AGENTS.md` (imported by CLAUDE) | Tech stack, structure | Reflects current project state | + +## 4. Command consistency + +Verify command descriptions match their content: + +```! +head -5 .claude/commands/*.md +``` + +Check cross-references between commands: + +- `/release` should reference `/lint` and `/test`. +- `/feature` and `/hotfix` follow gitflow. +- `/fix-lint` complements `/lint`. + +## 5. Hooks validation + +```! +cat .claude/settings.json | python3 -m json.tool > /dev/null && echo ".claude/settings.json valid JSON" +``` + +Verify hook commands are valid: + +- Commands use correct tool paths (`uv run ruff`, etc.). +- Hook scripts under `.claude/hooks/` are executable. +- Use the `$CLAUDE_PROJECT_DIR` env var for project-relative script paths. + +```! +ls -l .claude/hooks/ 2>/dev/null +``` + +## 6. CLAUDE.md / AGENTS.md consistency + +Verify root context covers: + +- [ ] Project overview and purpose +- [ ] Tech stack (Python 3.10+, Pydantic v2, uv, ruff, ty) +- [ ] Directory structure +- [ ] Development workflow (gitflow) +- [ ] Code principles (SOLID, DRY) + +Check for conflicts between: + +- Root `AGENTS.md` ↔ `.claude/rules/*.md` +- Skills ↔ Commands (e.g. `/pypi-publish` skill vs `/release` command) +- `CLAUDE.md` ↔ `AGENTS.md` (CLAUDE.md should `@AGENTS.md`, not duplicate) + +```! +head -3 CLAUDE.md +``` + +## 7. Skill / command frontmatter + +Spot-check that skill frontmatter is well-formed: + +```! +for f in .claude/skills/*/SKILL.md; do echo "=== $f ==="; awk '/^---$/{c++; if(c==2) exit} c==1' "$f"; done +``` + +```! +for f in .claude/commands/*.md; do echo "=== $f ==="; awk '/^---$/{c++; if(c==2) exit} c==1' "$f"; done +``` + +## 8. Report and fix + +Document any misalignments found: + +- [ ] Missing files → create from templates. +- [ ] Oversized files → trim or split into path-scoped rules / supporting files. +- [ ] Stale references → update paths. +- [ ] Outdated tech stack → update `AGENTS.md`. +- [ ] Conflicting guidance → resolve in favor of `AGENTS.md` and `CLAUDE.md`. + +If changes were made, commit: + +```bash +git add .claude/ CLAUDE.md AGENTS.md \ + && git commit -m "chore: align claude-code agentic capabilities" +``` + +**Run this workflow monthly or after major refactors.** diff --git a/.claude/commands/code-health.md b/.claude/commands/code-health.md new file mode 100644 index 0000000..7c56ec4 --- /dev/null +++ b/.claude/commands/code-health.md @@ -0,0 +1,92 @@ +--- +description: Maintain code coherence, remove inconsistencies, improve readability (DRY, SOLID, SOTA) +allowed-tools: Bash(uv run ruff *) Bash(uv run ty *) Bash(uv run pytest *) Bash(uv run coverage *) +--- + +# /code-health + +## 1. Static analysis + +```! +uv run ruff check src/dppvalidator/ tests/ --fix +``` + +```! +uv run ruff format src/dppvalidator/ tests/ +``` + +## 2. Type check + +```! +uv run ty check src/dppvalidator/ +``` + +## 3. Review checklist + +### DRY (don't repeat yourself) + +- [ ] No duplicate code blocks across modules. +- [ ] Shared logic extracted to utility functions. +- [ ] Constants defined centrally (e.g. in config or constants module). +- [ ] Common test fixtures in `tests/conftest.py`. + +### SOLID principles + +- [ ] **S**ingle responsibility: each validator/model has one purpose. +- [ ] **O**pen/closed: new validators extend patterns, don't modify base. +- [ ] **L**iskov substitution: subclasses honor parent contracts. +- [ ] **I**nterface segregation: no forced unused dependencies. +- [ ] **D**ependency inversion: use protocols/ABCs for abstractions. + +### Consistency (codebase-specific) + +- [ ] All modules use Pydantic v2 patterns. +- [ ] All public functions have type hints (ty enforced). +- [ ] All models inherit from appropriate Pydantic base. +- [ ] Import order: stdlib → third-party → local. + +### Readability + +- [ ] Function names describe what they do. +- [ ] Variables have meaningful names (no `x`, `temp`, `data`). +- [ ] Complex logic has inline comments explaining *why*. +- [ ] Max function length ~50 lines; split if larger. + +## 4. Find code smells + +```! +uv run ruff check src/dppvalidator/ --select=C901,PLR0912,PLR0915 --statistics +``` + +This checks for: + +- `C901`: complex functions (cyclomatic complexity) +- `PLR0912`: too many branches +- `PLR0915`: too many statements + +## 5. Test coverage + +```! +uv run pytest tests/ --cov=src/dppvalidator --cov-report=term-missing --cov-fail-under=80 +``` + +## 6. Final verification + +```! +uv run pytest tests/ -q +``` + +______________________________________________________________________ + +## Quick reference: common refactorings + +| Smell | Refactoring | +| ------------------- | --------------------------------------------- | +| Duplicate code | Extract to shared utility module | +| Long function | Split into smaller functions | +| God class | Decompose into focused classes | +| Primitive obsession | Create Pydantic models in `models/` | +| Long parameter list | Use Pydantic config or dataclass | +| Magic strings | Use `Literal` types or `Enum` | +| Nested validation | Use Pydantic validators and `model_validator` | +| Repeated schemas | Extract base models, use inheritance | diff --git a/.claude/commands/dev-setup.md b/.claude/commands/dev-setup.md new file mode 100644 index 0000000..6794100 --- /dev/null +++ b/.claude/commands/dev-setup.md @@ -0,0 +1,42 @@ +--- +description: Set up the development environment with miniforge and uv +allowed-tools: Bash(uv *) Bash(curl *) Bash(pip install uv) Bash(conda *) +--- + +# /dev-setup + +Set up a local development environment. + +1. Create a miniforge environment: + + ```bash + conda create -n dppvalidator python=3.12 -y && conda activate dppvalidator + ``` + +1. Install the `uv` package manager (skip if already installed): + + ```! + command -v uv || curl -LsSf https://astral.sh/uv/install.sh | sh + ``` + + Alternative: `pip install uv` + +1. Install project dependencies: + + ```! + uv sync --dev + ``` + +1. Install pre-commit hooks: + + ```! + uv run pre-commit install + ``` + +1. Verify installation: + + ```! + uv run python -c "import dppvalidator; print('Setup complete!')" + ``` + +After setup, run `/lint` and `/test` to verify everything works. diff --git a/.claude/commands/docs-health.md b/.claude/commands/docs-health.md new file mode 100644 index 0000000..1f8100b --- /dev/null +++ b/.claude/commands/docs-health.md @@ -0,0 +1,122 @@ +--- +description: Check documentation consistency; ensure README.md and mkdocs are aligned with the codebase +allowed-tools: Bash(grep *) Bash(head *) Bash(ls *) Bash(test *) Bash(find *) +--- + +# /docs-health + +## 1. Verify mkdocs nav files exist + +```! +for f in $(grep -oE '[a-z/-]+\.md' mkdocs.yml); do [ -f "docs/$f" ] || echo "MISSING: docs/$f"; done +``` + +## 2. Core documentation consistency + +Compare key information across documentation sources: + +| Source | Check | +| ---------------- | -------------------------------------------------- | +| `README.md` | Accurate project description, install instructions | +| `docs/index.md` | Matches README features and quick start | +| `AGENTS.md` | Tech stack reflects current dependencies | +| `pyproject.toml` | Version, description matches docs | + +```! +head -20 README.md +``` + +```! +grep -E "^(name|version|description)" pyproject.toml | head -5 +``` + +## 3. Validate public API documentation + +```! +grep -r "^from dppvalidator" docs/reference/ 2>/dev/null || echo "Check API docs manually" +``` + +```! +grep -E "^(class|def) " src/dppvalidator/__init__.py 2>/dev/null | head -10 +``` + +## 4. Check code examples + +Verify code examples in docs use current API patterns: + +- Import paths match actual module structure. +- Class/function names exist in codebase. +- Examples use Pydantic v2 syntax (not v1). + +```! +grep -rh "from dppvalidator" docs/*.md docs/**/*.md 2>/dev/null | sort -u | head -10 +``` + +```! +grep -rh "import dppvalidator" docs/*.md docs/**/*.md 2>/dev/null | sort -u | head -5 +``` + +## 5. Version consistency + +```! +grep -E "version|0\.[0-9]" pyproject.toml docs/index.md mkdocs.yml CHANGELOG.md 2>/dev/null | head -15 +``` + +Verify version numbers are consistent across: + +- [ ] `pyproject.toml` → package version +- [ ] `docs/index.md` → schema version references +- [ ] `CHANGELOG.md` → latest release matches `pyproject.toml` + +## 6. Changelog sync + +```! +head -30 CHANGELOG.md +``` + +```! +[ -f docs/changelog.md ] && head -5 docs/changelog.md || echo "docs/changelog.md missing" +``` + +Ensure `docs/changelog.md` references or includes root `CHANGELOG.md`. + +## 7. Links validation + +```! +grep -rohE '\[.*\]\([^)]+\.md[^)]*\)' docs/*.md docs/**/*.md 2>/dev/null | head -20 +``` + +Spot-check external links manually: + +- PyPI badge links +- GitHub repo links +- UNTP / ESPR reference links + +## 8. Schema reference accuracy + +```! +ls -la src/dppvalidator/schemas/*.json 2>/dev/null || echo "Check schema location" +``` + +```! +grep -r "0\.6\." docs/ src/ 2>/dev/null | head -10 +``` + +## 9. Report and fix + +Document any inconsistencies found: + +- [ ] Missing nav files → create placeholder or remove from nav +- [ ] Stale code examples → update to current API +- [ ] Version mismatch → sync versions +- [ ] Broken links → fix paths +- [ ] Missing API docs → document public exports + +If changes were made, commit: + +```bash +git add README.md docs/ CHANGELOG.md \ + && git commit -m "docs: sync documentation with codebase" +``` + +Run this workflow before releases and after major API changes. diff --git a/.claude/commands/feature.md b/.claude/commands/feature.md new file mode 100644 index 0000000..fc03af7 --- /dev/null +++ b/.claude/commands/feature.md @@ -0,0 +1,50 @@ +--- +description: Start a new feature branch following gitflow +argument-hint: "" +disable-model-invocation: true +allowed-tools: Bash(git *) Bash(uv run pytest *) Bash(uv run ruff *) Bash(gh *) +--- + +# /feature + +Create a feature branch for `$ARGUMENTS` and walk through the gitflow loop. + +1. Ensure `develop` is up to date: + + ```bash + git checkout develop && git pull origin develop + ``` + +1. Create the feature branch: + + ```bash + git checkout -b feature/$ARGUMENTS + ``` + +1. **Implement the feature** — write code and tests. + +1. Run tests: + + ```bash + uv run pytest tests/ -v + ``` + +1. Run lint: + + ```bash + uv run ruff check src/ tests/ + ``` + +1. Commit changes (conventional commits — see `.claude/rules/commits.md`): + + ```bash + git add . && git commit -m "feat: $ARGUMENTS" + ``` + +1. Push the feature branch: + + ```bash + git push -u origin feature/$ARGUMENTS + ``` + +1. Open the PR against `develop` via `gh pr create` or the GitHub UI. diff --git a/.claude/commands/fix-lint.md b/.claude/commands/fix-lint.md new file mode 100644 index 0000000..486c84c --- /dev/null +++ b/.claude/commands/fix-lint.md @@ -0,0 +1,20 @@ +--- +description: Auto-fix linting and formatting issues with ruff +allowed-tools: Bash(uv run ruff *) +--- + +# /fix-lint + +Auto-fix what can be fixed, then re-verify. Commit only after issues are resolved. + +```! +uv run ruff check --fix src/ tests/ +``` + +```! +uv run ruff format src/ tests/ +``` + +```! +uv run ruff check src/ tests/ +``` diff --git a/.claude/commands/hotfix.md b/.claude/commands/hotfix.md new file mode 100644 index 0000000..0672d5c --- /dev/null +++ b/.claude/commands/hotfix.md @@ -0,0 +1,150 @@ +--- +description: Create a hotfix for production following gitflow; includes PyPI release-failure runbook +argument-hint: "" +disable-model-invocation: true +allowed-tools: Bash(git *) Bash(uv *) Bash(uv run *) Bash(gh *) +--- + +# /hotfix + +## Standard hotfix workflow + +1. Create the hotfix branch from `main`: + + ```bash + git checkout main && git pull && git checkout -b hotfix/$ARGUMENTS + ``` + +1. **Fix the issue** — implement the minimal fix. + +1. Add a regression test for the fix. + +1. Run tests: + + ```bash + uv run pytest tests/ -v + ``` + +1. Bump the patch version: + + ```bash + uv version patch + ``` + +1. Commit the fix: + + ```bash + git add . && git commit -m "fix: $ARGUMENTS" + ``` + +1. Merge to `main`: + + ```bash + git checkout main && git merge --no-ff hotfix/$ARGUMENTS + ``` + +1. Tag the release: + + ```bash + git tag -a v$(uv version --short) -m "Hotfix v$(uv version --short)" + ``` + +1. Merge to `develop`: + + ```bash + git checkout develop && git merge --no-ff hotfix/$ARGUMENTS + ``` + +1. Push and publish: + + ```bash + git push origin main develop --tags + ``` + +1. Delete the hotfix branch: + + ```bash + git branch -d hotfix/$ARGUMENTS + ``` + +______________________________________________________________________ + +## PyPI release-failure runbook + +Use this runbook when `verify-pypi` fails or users report installation issues. + +### Step 1: assess the failure + +Check the GitHub Actions workflow run to identify the issue: + +- **Import failure**: missing module or dependency issue. +- **CLI failure**: entry-point misconfiguration. +- **Validation failure**: core functionality broken. + +### Step 2: yank the release (only if necessary) + +Yank only if the release causes installation failures or breaks functionality. + +1. Open . +1. Find the affected version. +1. Click **Options → Yank release**. +1. Reason: "Installation/functionality issue - hotfix pending". + +Yanked releases remain downloadable via explicit version, but won't be installed by default. Reversible. + +### Step 3: cut a hotfix release + +1. Branch: + + ```bash + git checkout main && git pull && git checkout -b hotfix/v + ``` + +1. Fix the identified issue. + +1. Add a regression test. + +1. Full test suite: + + ```bash + uv run pytest tests/ -v + ``` + +1. Bump the patch version: `uv version patch`. + +1. Update `CHANGELOG.md`: + + ```text + ## [X.Y.Z] - YYYY-MM-DD + + ### Fixed + - Fixed [description] that caused [symptom] + ``` + +1. Commit, merge, tag: + + ```bash + git add . + git commit -m "fix: [description]" + git checkout main && git merge --no-ff hotfix/v + git tag -a v$(uv version --short) -m "Hotfix v$(uv version --short)" + ``` + +1. Merge to `develop` and push: + + ```bash + git checkout develop && git merge --no-ff main + git push origin main develop --tags + ``` + +### Step 4: verify the hotfix + +1. Wait for CI/CD to complete. +1. Confirm `smoke-test` passes. +1. Confirm `verify-pypi` passes. +1. Test installation manually: `pip install dppvalidator==`. + +### Step 5: communicate (if public release) + +- Update the GitHub Release notes with hotfix information. +- If users were affected, consider a brief announcement. diff --git a/.claude/commands/lint.md b/.claude/commands/lint.md new file mode 100644 index 0000000..78ec2c2 --- /dev/null +++ b/.claude/commands/lint.md @@ -0,0 +1,20 @@ +--- +description: Run linting and type checking with ruff and ty +allowed-tools: Bash(uv run ruff *) Bash(uv run ty *) +--- + +# /lint + +Run the project's static checks. On any failure, run `/fix-lint` to auto-fix what can be fixed and report the rest. + +```! +uv run ruff check src/ tests/ +``` + +```! +uv run ruff format --check src/ tests/ +``` + +```! +uv run ty check src/ +``` diff --git a/.claude/commands/pr-review.md b/.claude/commands/pr-review.md new file mode 100644 index 0000000..2102129 --- /dev/null +++ b/.claude/commands/pr-review.md @@ -0,0 +1,45 @@ +--- +description: Review and address PR comments +argument-hint: "" +disable-model-invocation: true +allowed-tools: Bash(gh *) Bash(git *) Bash(uv run pytest *) +--- + +# /pr-review + +Address feedback on PR `#$ARGUMENTS`. + +1. Fetch and check out the PR branch: + + ```bash + gh pr checkout $ARGUMENTS + ``` + +1. Get PR comments: + + ```bash + gh pr view $ARGUMENTS --comments + ``` + +1. Run tests to ensure current state is green: + + ```bash + uv run pytest tests/ -v + ``` + +1. For **each comment**: + + 1. Read and understand the feedback. + 1. Implement the requested change. + 1. Run relevant tests. + 1. Commit with reference: `git commit -m "fix: address PR feedback - "`. + +1. Push changes: + + ```bash + git push + ``` + +1. Reply to comments on GitHub indicating addressed items. + +Use `gh pr comment $ARGUMENTS --body "Addressed in latest push"` to notify reviewers. diff --git a/.claude/commands/release.md b/.claude/commands/release.md new file mode 100644 index 0000000..71a8840 --- /dev/null +++ b/.claude/commands/release.md @@ -0,0 +1,82 @@ +--- +description: Create a new release following gitflow and publish to PyPI +argument-hint: "[patch|minor|major]" +disable-model-invocation: true +allowed-tools: Bash(git *) Bash(uv *) Bash(uv run *) Bash(gh *) +--- + +# /release + +Cut a release. Bump kind defaults to `patch` if `$ARGUMENTS` is empty. + +1. Ensure you're on `develop`: + + ```bash + git checkout develop && git pull origin develop + ``` + +1. Run linting: + + ```bash + uv run ruff check src/ tests/ + ``` + +1. Run the full test suite: + + ```bash + uv run pytest tests/ -v + ``` + +1. Bump the version: + + ```bash + uv version $ARGUMENTS # patch | minor | major + ``` + +1. Create the release branch: + + ```bash + git checkout -b release/v$(uv version --short) + ``` + +1. Update `CHANGELOG.md` with release notes. + +1. Commit the version bump: + + ```bash + git add pyproject.toml CHANGELOG.md \ + && git commit -m "chore: bump version to $(uv version --short)" + ``` + +1. Merge to `main`: + + ```bash + git checkout main && git pull \ + && git merge --no-ff release/v$(uv version --short) + ``` + +1. Tag the release: + + ```bash + git tag -a v$(uv version --short) -m "Release v$(uv version --short)" + ``` + +1. Merge back to `develop`: + + ```bash + git checkout develop && git merge --no-ff main + ``` + +1. Push all branches and tags: + + ```bash + git push origin main develop --tags + ``` + +1. Build and publish to PyPI (the `/pypi-publish` skill walks through this in detail): + + ```bash + uv build && uv publish + ``` + +Ensure `PYPI_API_TOKEN` is configured in environment or `.pypirc`. diff --git a/.claude/commands/test.md b/.claude/commands/test.md new file mode 100644 index 0000000..a99edb1 --- /dev/null +++ b/.claude/commands/test.md @@ -0,0 +1,83 @@ +______________________________________________________________________ + +## description: Run the test suite with coverage analysis and quality checks argument-hint: "[pytest args]" allowed-tools: Bash(uv run pytest \*) Bash(uv run coverage \*) Bash(uv run mutmut \*) + +# /test + +Tests must validate **library behavior**, not implementation details: + +- Avoid mocking internal components unless testing integration boundaries. +- Test real Pydantic validation, not mocked validators. +- Verify actual JSON-LD output, not mocked exporters. +- Use fixtures with realistic DPP data. +- Coverage target: **95%** (protocols excluded via `pyproject.toml`). + +## Quick test (default) + +```! +uv run pytest tests/ -v --cov=src/dppvalidator --cov-report=term-missing --cov-fail-under=95 $ARGUMENTS +``` + +## Comprehensive test suite + +```! +uv run pytest tests/unit/ -v --cov=src/dppvalidator --cov-report=term-missing +``` + +```! +uv run pytest tests/property/ -v --hypothesis-show-statistics +``` + +```! +uv run pytest tests/fuzz/ -v +``` + +```! +uv run pytest tests/integration/ -v 2>/dev/null || echo "No integration tests yet" +``` + +## Coverage analysis + +```! +uv run pytest tests/ --cov=src/dppvalidator --cov-report=html --cov-report=term-missing +``` + +```! +uv run coverage report --show-missing --skip-covered +``` + +## Mutation testing (verify test quality) + +```bash +uv run mutmut run --paths-to-mutate=src/dppvalidator --tests-dir=tests +uv run mutmut results +``` + +## Debugging specific tests + +```bash +# single test file +uv run pytest tests/unit/test_.py -v + +# specific test function +uv run pytest tests/unit/test_.py::test_ -v -s + +# tests matching a pattern +uv run pytest tests/ -v -k "" +``` + +## Test quality checklist + +- [ ] Tests validate **behavior**, not mocked internals +- [ ] Edge cases covered (empty inputs, invalid data, boundary values, etc.) +- [ ] Error messages are meaningful and tested +- [ ] Property tests cover model invariants +- [ ] Fixtures use realistic DPP data from `tests/fixtures/` +- [ ] No over-mocking (real Pydantic validation, real exports) + +**On failure**: + +- Check test output for specific failures. +- Use `uv run pytest tests/path/to/test.py::test_name -v -s` to debug. +- Run `/lint` to check for code issues. +- Review coverage gaps with `uv run coverage html && open htmlcov/index.html`. diff --git a/.claude/commands/untp-bump.md b/.claude/commands/untp-bump.md new file mode 100644 index 0000000..026cd6a --- /dev/null +++ b/.claude/commands/untp-bump.md @@ -0,0 +1,102 @@ +--- +description: Bootstrap support for a new UNTP version (vendors upstream artefacts, registers the version, scaffolds models, opens a feature branch). Reads the canonical playbook from the untp-migrate skill. +argument-hint: "" +disable-model-invocation: true +allowed-tools: Bash(git *) Bash(curl *) Bash(shasum *) Bash(python3 *) Bash(uv run *) Bash(jq *) Bash(mkdir *) Bash(cp *) +--- + +# /untp-bump + +Bootstrap dppvalidator support for UNTP `$ARGUMENTS`. This is the executable form of the recipe in [.claude/skills/untp-migrate/SKILL.md](../skills/untp-migrate/SKILL.md). Read that skill for the full operating principles before running this; it must be loaded into context. + +## Preconditions + +- `git status` is clean. +- You're on `develop` (gitflow). +- `$ARGUMENTS` is a valid SemVer (e.g. `0.7.0`, `0.7.1`, `0.8.0`). + +## Steps + +### 1. Branch + +```bash +git checkout develop && git pull origin develop +git checkout -b feature/untp-$ARGUMENTS +``` + +### 2. Vendor upstream artefacts + +```bash +VER="$ARGUMENTS" +mkdir -p tests/fixtures/upstream/v$VER +BASE="https://opensource.unicc.org/un/unece/uncefact/spec-untp/-/raw/v$VER/artefacts" +curl -sL "$BASE/schema/v$VER/dpp/DigitalProductPassport.json" -o tests/fixtures/upstream/v$VER/dpp-schema.json +curl -sL "$BASE/contexts/v$VER/untp-context.jsonld" -o tests/fixtures/upstream/v$VER/context.jsonld +curl -sL "$BASE/samples/v$VER/dpp/DigitalProductPassport_instance.json" -o tests/fixtures/upstream/v$VER/sample.json +shasum -a 256 tests/fixtures/upstream/v$VER/* +``` + +If any of those download as zero bytes, the upstream layout has shifted — stop and re-read the [migration plan](../../docs/plans/UNTP_0.7.0_MIGRATION.md) §2.6. + +### 3. Drop into bundled paths + +```bash +cp tests/fixtures/upstream/v$VER/dpp-schema.json src/dppvalidator/schemas/data/untp-dpp-schema-$VER.json +cp tests/fixtures/upstream/v$VER/context.jsonld src/dppvalidator/vocabularies/data/untp-context-$VER.jsonld +``` + +### 4. Diff against the previous version + +```bash +PREV=$(python3 -c "from dppvalidator.schemas.registry import SCHEMA_REGISTRY; \ + vs=sorted(SCHEMA_REGISTRY.keys()); print(vs[-1])") +python3 .claude/skills/untp-migrate/scripts/diff_schema.py \ + src/dppvalidator/schemas/data/untp-dpp-schema-$PREV.json \ + src/dppvalidator/schemas/data/untp-dpp-schema-$VER.json +``` + +Paste the output into a new `docs/plans/UNTP_${VER}_MIGRATION.md` (use the 0.7.0 plan as a template). + +### 5. Register the version + +You must edit (Claude does this — these aren't shell commands): + +- [src/dppvalidator/schemas/registry.py](../../src/dppvalidator/schemas/registry.py) — append a `SchemaVersion` entry with the SHA-256 from step 2. +- [src/dppvalidator/exporters/contexts.py](../../src/dppvalidator/exporters/contexts.py) — append a `ContextDefinition` entry. +- [src/dppvalidator/validators/detection.py](../../src/dppvalidator/validators/detection.py) — extend `_CONTEXT_URL_PATTERN` if the new URL shape isn't already covered. +- `src/dppvalidator/schemas/data/MANIFEST.json` — add the new artefact entries. + +### 6. Scaffold models + +Create the `src/dppvalidator/models/v_/` package with one file per top-level `$def` from the new schema. Stay strictly inside Pydantic v2 patterns (see `.claude/rules/dpp-domain.md`). + +### 7. Wire the dispatch + +Add the new version to `_MODEL_BY_VERSION` in `src/dppvalidator/validators/model.py`. Do not branch on the version literal anywhere else — the no-version-literals guard test will catch you. + +### 8. Verify + +```bash +uv run pytest tests/ -q +uv run ruff check src/ tests/ +uv run ty check src/ +``` + +### 9. Commit and push + +```bash +git add tests/fixtures/upstream/v$VER \ + src/dppvalidator/schemas/data/untp-dpp-schema-$VER.json \ + src/dppvalidator/vocabularies/data/untp-context-$VER.jsonld \ + src/dppvalidator/schemas/data/MANIFEST.json \ + src/dppvalidator/schemas/registry.py \ + src/dppvalidator/exporters/contexts.py \ + src/dppvalidator/validators/detection.py \ + src/dppvalidator/models/v* \ + src/dppvalidator/validators/model.py \ + docs/plans/UNTP_${VER}_MIGRATION.md +git commit -m "feat(untp): vendor and register UNTP $ARGUMENTS" +git push -u origin feature/untp-$ARGUMENTS +``` + +The shim, version-matrix tests, default-flip, deprecation, and removal happen in **separate PRs** — see the plan's phase split. Do not bundle them into this branch. diff --git a/.claude/hooks/ruff-fix.sh b/.claude/hooks/ruff-fix.sh new file mode 100755 index 0000000..37795f2 --- /dev/null +++ b/.claude/hooks/ruff-fix.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# PostToolUse hook: auto-fix Python files edited by Claude with ruff. +# +# Reads the Claude Code hook event JSON from stdin, extracts the file path +# of the Edit/Write/MultiEdit tool call, and runs `uv run ruff check --fix` +# on it. Silent on success; never blocks Claude (exit 0 always). + +set -u + +# Hook input is JSON on stdin per Claude Code hooks contract. +input="$(cat)" + +# Extract file_path from tool_input. PostToolUse fires for Edit, Write, +# MultiEdit, NotebookEdit. Each surfaces the path as tool_input.file_path +# (or notebook_path for NotebookEdit). +file_path="$(printf '%s' "$input" | python3 -c ' +import json, sys +try: + data = json.load(sys.stdin) +except Exception: + sys.exit(0) +ti = data.get("tool_input", {}) or {} +path = ti.get("file_path") or ti.get("notebook_path") or "" +print(path) +' 2>/dev/null)" + +# Bail if no path or not a Python file we manage. +case "$file_path" in + *.py) ;; + *) exit 0 ;; +esac + +# Only auto-fix files inside the project tree. +project_dir="${CLAUDE_PROJECT_DIR:-$(pwd)}" +case "$file_path" in + "$project_dir"/*) ;; + /*) exit 0 ;; + *) file_path="$project_dir/$file_path" ;; +esac + +# Run ruff fix; never block, even on failure. +cd "$project_dir" || exit 0 +uv run ruff check --fix "$file_path" >/dev/null 2>&1 || true + +exit 0 diff --git a/.claude/rules/commits.md b/.claude/rules/commits.md new file mode 100644 index 0000000..9445c1c --- /dev/null +++ b/.claude/rules/commits.md @@ -0,0 +1,29 @@ +# Conventional Commits + +Use conventional commit format for every commit message: + +``` +(): + +[optional body] +[optional footer] +``` + +**Types:** + +- **feat**: new feature +- **fix**: bug fix +- **docs**: documentation changes +- **style**: formatting (no code change) +- **refactor**: code restructuring +- **test**: adding/modifying tests +- **chore**: maintenance tasks +- **perf**: performance improvements +- **ci**: CI/CD changes + +**Examples:** + +- `feat(validator): add JSON-LD export support` +- `fix(material): correct percentage validation logic` +- `docs(readme): add installation instructions` +- `test(dpp): add unit tests for passport validation` diff --git a/.claude/rules/dpp-domain.md b/.claude/rules/dpp-domain.md new file mode 100644 index 0000000..6181c56 --- /dev/null +++ b/.claude/rules/dpp-domain.md @@ -0,0 +1,37 @@ +______________________________________________________________________ + +paths: + +- "src/\*\*/\*.py" +- "tests/\*\*/\*.py" + +______________________________________________________________________ + +# DPP Domain Guidelines + +## Domain knowledge + +- DPP = Digital Product Passport (EU ESPR regulation). +- Use CIRPASS and UNECE ontologies as reference. +- Material codes follow ISO 2076 (e.g. `CO`=Cotton, `EL`=Elastane). +- Country codes use ISO 3166-1 alpha-2. +- Product IDs use GTIN-13 or equivalent. + +## Validation rules + +- Material percentages must sum to 100%. +- All mandatory ESPR fields must be present. +- URIs must be valid and follow semantic web standards. +- Supply chain nodes must have valid roles: `Manufacturer`, `Supplier`, `Recycler`. + +## Pydantic v2 patterns (do not regress to v1) + +- Use `Field()` with `description=` for documentation. +- Use `@field_validator` decorator with `@classmethod` (NOT v1 `@validator`). +- Use `@model_validator(mode="after")` for cross-field validation (NOT v1 `@root_validator`). +- Use `model_dump()` instead of v1 `.dict()`. +- Use `model_dump_json()` instead of v1 `.json()`. +- Use `model_validate()` instead of v1 `.parse_obj()`. +- Use `X | None` type syntax instead of `Optional[X]`. +- Use `ConfigDict` class attribute instead of inner `Config` class. +- Export to JSON-LD with `@context` and `@type`. diff --git a/.claude/rules/plugin-licenses.md b/.claude/rules/plugin-licenses.md new file mode 100644 index 0000000..7ea2b08 --- /dev/null +++ b/.claude/rules/plugin-licenses.md @@ -0,0 +1,50 @@ +______________________________________________________________________ + +paths: + +- "plugins/\*\*/\*" +- "src/dppvalidator/\*\*/\*.py" + +______________________________________________________________________ + +# Plugin License Rules + +The `plugins/` directory contains separately-licensed packages. Follow these rules strictly. + +## License isolation + +- **Core package** (`src/dppvalidator/`): MIT licensed. +- **Plugin packages** (`plugins/*/`): may have different licenses (e.g. GPL-3.0). + +## Critical rules + +1. **No reverse imports**: core MUST NOT import from any plugin. + + - `src/dppvalidator/` cannot contain `from dppvalidator_textiles import ...` + - Plugins depend on core, never the reverse. + +1. **Separate `pyproject.toml`**: each plugin has its own with: + + - Its own `license` field. + - Dependency on `dppvalidator>=X.Y.Z`. + - Its own entry-points registration. + +1. **LICENSE file per plugin**: each plugin directory must have its own LICENSE file. + +1. **No code copying**: do not copy GPL-licensed code into MIT-licensed core. + + - Extend via inheritance, not duplication. + - Use entry-points for plugin discovery. + +## Current plugins + +| Plugin | Path | License | Upstream | +| -------- | ------------------- | ---------------- | ---------- | +| textiles | `plugins/textiles/` | GPL-3.0-or-later | spec-unttp | + +## When adding new plugins + +1. Check upstream license compatibility. +1. Create `plugins//LICENSE` with appropriate license. +1. Set `license` in `plugins//pyproject.toml`. +1. Document in this file. diff --git a/.claude/rules/python-style.md b/.claude/rules/python-style.md new file mode 100644 index 0000000..f3b306d --- /dev/null +++ b/.claude/rules/python-style.md @@ -0,0 +1,37 @@ +______________________________________________________________________ + +paths: + +- "\*\*/\*.py" + +______________________________________________________________________ + +# Python Code Style + +## Coding standards + +- Use type hints for all function parameters and return values. +- Follow PEP 8 naming: `snake_case` for functions/variables, `PascalCase` for classes. +- Use Pydantic v2 models for data validation. +- Prefer early returns to reduce nesting. +- Keep functions focused and under ~50 lines. +- Use dataclasses or Pydantic models instead of plain dicts for structured data. + +## Imports + +- Group imports: stdlib, third-party, local (separated by blank lines). +- Use absolute imports over relative imports. +- Import specific items rather than entire modules when practical. + +## Error handling + +- Use specific exception types, not bare `except:`. +- Validate input at boundaries using Pydantic. +- Raise descriptive exceptions with context. + +## Testing companion + +- Each module should have corresponding tests in `tests/`. +- Use pytest fixtures for shared test setup. +- Test both happy path and error cases. +- Use parametrized tests for multiple input variations. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 0000000..2393db8 --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,65 @@ +______________________________________________________________________ + +paths: + +- "tests/\*\*/\*.py" +- "\*\*/test\_\*.py" +- "\*\*/\*\_test.py" + +______________________________________________________________________ + +# Testing Guidelines + +## Coverage requirements + +- Target **≥95% code coverage** for all modules. +- Protocol classes (`typing.Protocol`) may be excluded from coverage as they cannot be tested directly. +- Use `# pragma: no cover` sparingly and only for genuinely untestable code. + +## Testing philosophy + +- Test **intended behavior**, not literal implementation details. +- Tests should validate what the code is supposed to do, not how it does it. +- Avoid brittle tests that break when refactoring internals. +- Focus on public API contracts and observable outcomes. + +## Test types + +- **Unit tests**: isolate individual functions/classes, mock external dependencies. +- **Integration tests**: verify components work together correctly. +- **Property-based tests**: use Hypothesis for fuzz testing with generated inputs. +- **Fixtures**: use pytest fixtures for reusable test setup and teardown. + +## pytest best practices + +- Organize fixtures in `conftest.py` files at appropriate directory levels. +- Use `@pytest.fixture` with appropriate scope (function, class, module, session). +- Use `@pytest.mark.parametrize` for testing multiple inputs. +- Use `@pytest.mark.integration` to tag integration tests. +- Use Hypothesis `@given` decorators for property-based testing. + +## Example structure + +```python +import pytest +from hypothesis import given, strategies as st + + +@pytest.fixture +def sample_data(): + """Reusable test fixture.""" + return {...} + + +def test_behavior_not_implementation(sample_data): + """Test what it does, not how.""" + result = function_under_test(sample_data) + assert result.is_valid # behavior check + + +@given(st.text(), st.integers()) +def test_property_based(text, number): + """Fuzz test with generated inputs.""" + result = process(text, number) + assert invariant_holds(result) +``` diff --git a/.claude/rules/untp-versioning.md b/.claude/rules/untp-versioning.md new file mode 100644 index 0000000..cf5232a --- /dev/null +++ b/.claude/rules/untp-versioning.md @@ -0,0 +1,61 @@ +--- +paths: + - "src/dppvalidator/schemas/**" + - "src/dppvalidator/exporters/contexts.py" + - "src/dppvalidator/exporters/jsonld.py" + - "src/dppvalidator/exporters/eudpp_jsonld.py" + - "src/dppvalidator/validators/detection.py" + - "src/dppvalidator/validators/model.py" + - "src/dppvalidator/validators/jsonld_semantic.py" + - "src/dppvalidator/validators/semantic.py" + - "src/dppvalidator/models/**" + - "src/dppvalidator/cli/commands/**" + - "src/dppvalidator/compat/**" +--- + +# UNTP Versioning Rules + +These files form the version-aware spine of the validator. Read carefully before editing — they have stricter rules than the rest of the codebase. + +## Cardinal rules + +1. **No bare UNTP version literals.** A string like `"0.6.1"` or `"0.7.0"` may only appear in `src/dppvalidator/schemas/registry.py` and `src/dppvalidator/exporters/contexts.py`. Everywhere else: look it up via `SchemaRegistry`, `ContextManager`, or `dppvalidator.compat.active_version()`. The `tests/unit/test_no_version_literals.py` guard will fail your PR otherwise. + +2. **Models are version-namespaced.** Pydantic classes for UNTP data live in `src/dppvalidator/models/v0_6/`, `…v0_7/`, etc. Never edit a `v0_X` package to absorb behaviour from a different version. To support a new version, add a `v0_Y/` package — do not graft fields onto the previous one. + +3. **Detection is centralised.** `validators/detection.py` is the only place that decides what version a payload is. New URL/namespace shapes get added to `_CONTEXT_URL_PATTERN` and `_SCHEMA_URL_PATTERN` there, nowhere else. + +4. **Bundled artefacts have a manifest.** Every JSON Schema and JSON-LD context vendored under `src/dppvalidator/schemas/data/` or `src/dppvalidator/vocabularies/data/` MUST appear in `src/dppvalidator/schemas/data/MANIFEST.json` with version, source URL, SHA-256, and pull date. CI verifies the hashes. + +5. **Coexist before you cut.** When a new version lands, the previous version must keep working in the same release. Removing a version is its own minor release with its own deprecation warning lead-time. + +## Adding a UNTP version: short version + +Use the `/untp-bump ` slash command. It runs the recipe documented in [`.claude/skills/untp-migrate/SKILL.md`](../skills/untp-migrate/SKILL.md). Read that skill in full before improvising. + +## Adding a UNTP version: minimum touch list + +When you add `vX.Y.Z`, you must touch: + +- `src/dppvalidator/schemas/registry.py` — one `SchemaVersion` entry. +- `src/dppvalidator/exporters/contexts.py` — one `ContextDefinition` entry. +- `src/dppvalidator/schemas/data/MANIFEST.json` — manifest entries for the new schema and context files. +- `src/dppvalidator/schemas/data/untp-dpp-schema-X.Y.Z.json` — vendored schema. +- `src/dppvalidator/vocabularies/data/untp-context-X.Y.Z.jsonld` — vendored context. +- `src/dppvalidator/models/vX_Y/` — new Pydantic model package. +- `src/dppvalidator/validators/model.py` — add to `_MODEL_BY_VERSION`. +- `src/dppvalidator/validators/detection.py` — extend URL pattern if the namespace shape changed. +- `src/dppvalidator/compat/upgrade__to_.py` — input shim. +- `tests/fixtures/upstream/vX.Y.Z/` — vendored upstream samples + schema. +- `tests/integration/test_version_matrix.py` — add the new version to the matrix. +- `docs/plans/UNTP_X.Y.Z_MIGRATION.md` — full migration doc. + +If you touched more than this list, you're either fixing an unrelated bug (split the PR) or going around the version-aware spine (don't). + +## Anti-patterns + +- Hardcoding `"0.6.1"` or `"0.7.0"` as a default in a function signature. +- Branching on `if version == "0.7.0":` outside `validators/model.py`'s `_MODEL_BY_VERSION` table. +- Adding a `Optional[Union[Old, New]]` typed field to a model to "support both". +- Fetching a schema or context from the network during validation. +- Editing a vendored schema or context to "fix" something — that breaks the SHA-256 manifest. Either upgrade to a new upstream version or open an upstream issue. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..8c452cd --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ruff-fix.sh", + "timeout": 30 + } + ] + } + ] + }, + "permissions": { + "allow": [ + "Bash(uv run pytest *)", + "Bash(uv run ruff *)", + "Bash(uv run ty *)", + "Bash(uv run coverage *)", + "Bash(uv run mutmut *)", + "Bash(uv run pre-commit *)", + "Bash(uv sync *)", + "Bash(uv version *)", + "Bash(uv build)", + "Bash(git status)", + "Bash(git diff *)", + "Bash(git log *)", + "Bash(git branch *)", + "Bash(git show *)", + "Bash(gh pr view *)", + "Bash(gh pr diff *)", + "Bash(gh pr list *)", + "Bash(gh issue view *)", + "Bash(gh issue list *)" + ], + "deny": [ + "Bash(rm -rf /)", + "Bash(rm -rf ~/*)", + "Bash(git push --force *)", + "Bash(git push -f *)" + ] + } +} diff --git a/.claude/skills/pypi-publish/SKILL.md b/.claude/skills/pypi-publish/SKILL.md new file mode 100644 index 0000000..589c12f --- /dev/null +++ b/.claude/skills/pypi-publish/SKILL.md @@ -0,0 +1,68 @@ +______________________________________________________________________ + +## name: pypi-publish description: Guide publishing dppvalidator to PyPI with proper versioning, smoke checks, and GitHub release. Use when the user asks to release, publish, or cut a version of the package. disable-model-invocation: true argument-hint: "[patch|minor|major]" allowed-tools: Bash(uv run pytest \*) Bash(uv run ruff \*) Bash(uv run ty \*) Bash(uv build \*) Bash(uv publish \*) Bash(uv version \*) Bash(git \*) + +# pypi-publish + +Publish a release of `dppvalidator` to PyPI. Bump type defaults to `patch` if `$ARGUMENTS` is empty. + +## 1. Pre-publishing checklist + +```bash +uv run pytest tests/ -v +uv run ruff check src/ +uv run ty check src/ +``` + +- [ ] All tests pass +- [ ] Lint clean +- [ ] Type check clean +- [ ] Version bumped in `pyproject.toml` +- [ ] `CHANGELOG.md` updated + +## 2. Bump the version + +```bash +# patch (0.1.0 -> 0.1.1) +uv version patch + +# minor (0.1.0 -> 0.2.0) +uv version minor + +# major (0.1.0 -> 1.0.0) +uv version major +``` + +## 3. Build and publish + +```bash +# Build distribution +uv build + +# Publish to TestPyPI first +uv publish --repository testpypi + +# Verify install from TestPyPI +uv pip install --index-url https://test.pypi.org/simple/ dppvalidator + +# Publish to PyPI +uv publish +``` + +## 4. Authentication + +Set `PYPI_API_TOKEN` in the environment or configure `~/.pypirc`: + +```ini +[pypi] +username = __token__ +password = pypi-YOUR_API_TOKEN +``` + +## 5. Post-publish + +1. Create a GitHub release with the tag. +1. Update documentation. +1. Announce the release. + +If anything fails after publish, use the `/hotfix` workflow for the PyPI release-failure runbook. diff --git a/.claude/skills/untp-migrate/SKILL.md b/.claude/skills/untp-migrate/SKILL.md new file mode 100644 index 0000000..44e5101 --- /dev/null +++ b/.claude/skills/untp-migrate/SKILL.md @@ -0,0 +1,125 @@ +--- +name: untp-migrate +description: Plan, scaffold, and execute a UNTP DPP version bump (e.g. 0.6.1 → 0.7.0). Use when the user asks about adding a new UNTP version, migrating fixtures or models between versions, drifting away from a hardcoded version, or when working in src/dppvalidator/{schemas,exporters,models,validators}/ during a known migration window. +allowed-tools: Read Edit Write Grep Glob Bash(uv run pytest *) Bash(uv run ruff *) Bash(uv run ty *) Bash(curl *) Bash(python3 *) Bash(shasum *) Bash(jq *) +--- + +# untp-migrate + +Companion skill to [docs/plans/UNTP_0.7.0_MIGRATION.md](../../docs/plans/UNTP_0.7.0_MIGRATION.md). Use it for any UNTP version bump, not only 0.7.0. + +## When to invoke + +- Adding a new UNTP minor (0.7.0 today, 0.7.x or 0.8.0 next). +- Touching anything under `src/dppvalidator/schemas/`, `src/dppvalidator/exporters/contexts.py`, `src/dppvalidator/validators/detection.py`, `src/dppvalidator/models/v*/`. +- Helping users migrate their own DPP payloads between versions. + +## Operating principles (load these into your head before editing) + +1. **No bare version literals** outside `schemas/registry.py` and `exporters/contexts.py`. If you find a `"0.6.1"` literal anywhere else, replace it with a registry lookup. +2. **Models are version-namespaced** under `dppvalidator.models.v0_6/`, `…v0_7/`, etc. Never edit a `v0_*` package to "fix" a bug introduced by a different version — branch the version. +3. **Bundled artefacts have a manifest.** Every JSON Schema, JSON-LD context, and ontology lives under `src/dppvalidator/schemas/data/` or `src/dppvalidator/vocabularies/data/`, with SHA-256 in `MANIFEST.json`. Updating an artefact means updating its hash. +4. **Detect, don't guess.** `validators/detection.py` is the only place that decides what version a payload is. Add new URL patterns there. +5. **Coexist before you cut.** Two adjacent versions (N-1 and N) must work simultaneously for one full minor release before N-1 is removed. + +## Reference: 0.6.1 → 0.7.0 surface deltas + +The migration plan has the full table. The deltas you trip over most often: + +- Namespace moved from `test.uncefact.org/vocabulary/untp/dpp/X.Y.Z/` to `vocabulary.uncefact.org/untp/X.Y.Z/context/`. +- `credentialSubject` is now a `Product` directly, not a `ProductPassport` envelope. +- `EmissionsPerformance`, `CircularityPerformance`, `TraceabilityPerformance`, `Metric` collapse into `Claim.claimedPerformance: Performance[]` keyed by `conformityTopic`. +- `serialNumber → itemNumber`; `producedByParty → relatedParty[0]`; `materialsProvenance → materialProvenance`; `Classification.schemeID → schemeId`. +- New required envelope fields: `validFrom`, `name`, `credentialSubject`. + +## The bump recipe (mirrors `/untp-bump`) + +For each new version `vX.Y.Z`: + +### 1. Vendor upstream artefacts + +```bash +mkdir -p tests/fixtures/upstream/v$VER +BASE="https://opensource.unicc.org/un/unece/uncefact/spec-untp/-/raw/v$VER/artefacts" +curl -sL $BASE/schema/v$VER/dpp/DigitalProductPassport.json -o tests/fixtures/upstream/v$VER/dpp-schema.json +curl -sL $BASE/contexts/v$VER/untp-context.jsonld -o tests/fixtures/upstream/v$VER/context.jsonld +curl -sL $BASE/samples/v$VER/dpp/DigitalProductPassport_instance.json -o tests/fixtures/upstream/v$VER/sample.json +shasum -a 256 tests/fixtures/upstream/v$VER/* +``` + +Record the GitLab tag SHA and pull date in `tests/fixtures/upstream/SOURCES.md`. + +### 2. Drop them under the bundled paths + +```bash +cp tests/fixtures/upstream/v$VER/dpp-schema.json src/dppvalidator/schemas/data/untp-dpp-schema-$VER.json +cp tests/fixtures/upstream/v$VER/context.jsonld src/dppvalidator/vocabularies/data/untp-context-$VER.jsonld +``` + +Then update `src/dppvalidator/schemas/data/MANIFEST.json` with version, source URL, SHA-256, and pull date. + +### 3. Register the version + +Add entries to: + +- [src/dppvalidator/schemas/registry.py](../../src/dppvalidator/schemas/registry.py) — `SCHEMA_REGISTRY[VER] = SchemaVersion(...)` +- [src/dppvalidator/exporters/contexts.py](../../src/dppvalidator/exporters/contexts.py) — `CONTEXTS[VER] = ContextDefinition(...)` +- [src/dppvalidator/validators/detection.py](../../src/dppvalidator/validators/detection.py) — extend `_CONTEXT_URL_PATTERN` if the namespace shape changed + +### 4. Diff against the previous version + +Run the bundled helper to print a deltas table you can paste into the migration plan: + +```bash +python3 ${CLAUDE_SKILL_DIR}/scripts/diff_schema.py \ + src/dppvalidator/schemas/data/untp-dpp-schema-.json \ + src/dppvalidator/schemas/data/untp-dpp-schema-.json +``` + +### 5. Scaffold the model package + +Create `src/dppvalidator/models/v/` with one file per top-level `$def`. Use Pydantic v2 patterns from `.claude/rules/dpp-domain.md`. Cross-field invariants live in `@model_validator(mode="after")`. Re-export the new models from `src/dppvalidator/models/__init__.py` only after the default version flips. + +### 6. Wire the model dispatch + +In [src/dppvalidator/validators/model.py](../../src/dppvalidator/validators/model.py), add the new version to `_MODEL_BY_VERSION`. Do not branch on version anywhere else. + +### 7. Write the upgrade shim + +Add `dppvalidator/compat/upgrade__to_.py`. The shim takes a dict and returns a dict; lossy mappings emit warnings, never silently drop. Property-based tests round-trip every previous-version fixture through it. + +### 8. Tests and fixtures + +- Add `tests/fixtures/valid/untp-dpp-instance-$VER.json`. +- Parametrise version-relevant tests with `@pytest.mark.parametrize("version", [...])`. +- Add `tests/integration/test_version_matrix.py` cases for the new pair. + +### 9. Verify + +```bash +uv run pytest tests/ -q +uv run ruff check src/ tests/ +uv run ty check src/ +``` + +The `tests/unit/test_no_version_literals.py` regex guard must stay green. + +### 10. Docs + +- Add `docs/guides/migration--to-.md` (use the 0.6→0.7 doc as the template). +- Update `docs/concepts/untp-versions.md` table. +- Refresh CHANGELOG. + +## Anti-patterns to refuse + +- "Just bump the default to the new version" without writing the upgrade shim — breaks every pinned downstream user. +- "Edit the existing model class in place" — produces an Either-typed schema and unstable JSON-LD output. +- "Fetch the schema from the network at validate time" — destroys offline-first behaviour and supply-chain integrity. +- "Bypass the registry with a one-off literal because it's a test fixture" — defeats the no-literals guard test. + +## Pointers + +- Migration plan: [docs/plans/UNTP_0.7.0_MIGRATION.md](../../docs/plans/UNTP_0.7.0_MIGRATION.md) +- Upstream source: (tag `v0.7.0`) +- Hosted context: +- Versioning rule: [.claude/rules/untp-versioning.md](../../.claude/rules/untp-versioning.md) diff --git a/.claude/skills/untp-migrate/scripts/diff_schema.py b/.claude/skills/untp-migrate/scripts/diff_schema.py new file mode 100755 index 0000000..a0415e8 --- /dev/null +++ b/.claude/skills/untp-migrate/scripts/diff_schema.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Diff two UNTP DPP JSON Schema versions and emit a markdown table. + +Usage: + python3 diff_schema.py + +Prints sections matching the format used in the migration plan: + - Root field diff + - Required-field diff + - $defs class diff (added / removed / shared) + - Per-shared-class property diff + +No external dependencies; standard library only. +""" + +from __future__ import annotations + +import json +import sys +from collections.abc import Mapping +from pathlib import Path +from typing import Any + + +def _props(spec: Mapping[str, Any]) -> dict[str, list[str]]: + out: dict[str, list[str]] = {} + for name, body in (spec.get("$defs") or {}).items(): + out[name] = sorted((body.get("properties") or {}).keys()) + out["__root__"] = sorted((spec.get("properties") or {}).keys()) + return out + + +def _print_section(title: str) -> None: + print() + print(f"### {title}") + print() + + +def main(old_path: str, new_path: str) -> int: + old = json.loads(Path(old_path).read_text(encoding="utf-8")) + new = json.loads(Path(new_path).read_text(encoding="utf-8")) + + old_props = _props(old) + new_props = _props(new) + + print(f"# Schema diff: `{Path(old_path).name}` → `{Path(new_path).name}`") + + _print_section("Root field diff") + ra, rb = set(old_props["__root__"]), set(new_props["__root__"]) + print(f"- removed: {sorted(ra - rb)}") + print(f"- added: {sorted(rb - ra)}") + print(f"- shared: {sorted(ra & rb)}") + + _print_section("Required-field diff") + print(f"- old required: {old.get('required')}") + print(f"- new required: {new.get('required')}") + + _print_section("$defs class diff") + da = set(old_props) - {"__root__"} + db = set(new_props) - {"__root__"} + print(f"- removed defs: {sorted(da - db)}") + print(f"- added defs: {sorted(db - da)}") + print(f"- shared defs: {sorted(da & db)}") + + _print_section("Per-shared-class property diff") + for cls in sorted(da & db): + pa, pb = set(old_props[cls]), set(new_props[cls]) + if pa == pb: + continue + print(f"#### `{cls}`") + print(f"- removed: {sorted(pa - pb)}") + print(f"- added: {sorted(pb - pa)}") + print() + + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print(__doc__, file=sys.stderr) + sys.exit(2) + sys.exit(main(sys.argv[1], sys.argv[2])) diff --git a/.claude/skills/validate-dpp/SKILL.md b/.claude/skills/validate-dpp/SKILL.md new file mode 100644 index 0000000..560409b --- /dev/null +++ b/.claude/skills/validate-dpp/SKILL.md @@ -0,0 +1,67 @@ +______________________________________________________________________ + +## name: validate-dpp description: Implement Digital Product Passport validation features following EU ESPR/CIRPASS standards. Use when adding a new validator, Pydantic model for a DPP entity, JSON-LD export, or any work that touches src/dppvalidator/models/ or src/dppvalidator/validators/. allowed-tools: Read Edit Write Grep Glob Bash(uv run pytest \*) Bash(uv run ruff \*) Bash(uv run ty \*) + +# validate-dpp + +Implement DPP validation features following EU ESPR regulations and CIRPASS ontologies. + +## Implementation steps + +### 1. Define the Pydantic v2 model + +```python +from pydantic import BaseModel, Field, field_validator, model_validator + + +class YourModel(BaseModel): + field_name: str = Field(..., description="Description for docs") + optional_field: str | None = None # use X | None, not Optional[X] + + @field_validator("field_name") + @classmethod + def validate_field(cls, v: str) -> str: + # validation logic + return v +``` + +Anchor decisions to `.claude/rules/dpp-domain.md` (Pydantic v2 patterns, ESPR/CIRPASS reference data). + +### 2. Wire it into the validation engine + +- Add the model under `src/dppvalidator/models/`. +- Register it in the validation engine. +- Add JSON-LD export support (`@context`, `@type`). + +### 3. Write tests + +```python +import pytest +from dppvalidator.models import YourModel + + +def test_valid_model() -> None: + model = YourModel(field_name="value") + assert model.field_name == "value" + + +def test_invalid_model() -> None: + with pytest.raises(ValueError): + YourModel(field_name="invalid") +``` + +### 4. Verify + +```bash +uv run pytest tests/ -q +uv run ruff check src/ tests/ +uv run ty check src/ +``` + +## Reference standards + +- **ISO 2076**: textile fiber codes +- **ISO 3166-1**: country codes +- **GS1 GTIN-13**: product identifiers +- **JSON-LD**: linked data format +- **CIRPASS / UNECE**: DPP ontologies diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be9afff..70832d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,14 +6,17 @@ on: pull_request: branches: [main, develop] +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 with: version: "latest" enable-cache: true @@ -52,6 +55,18 @@ jobs: - name: Run security scan (pip-audit) run: uv run pip-audit --skip-editable + # Check error documentation coverage + - name: Check error documentation + run: uv run python scripts/check_error_docs.py + + # License scanning - ensure all dependencies have compatible licenses + - name: Check dependency licenses + run: | + uvx pip-licenses --python="$(uv python find)" \ + --allow-only="MIT;BSD;Apache;ISC;Python;PSF;LGPL;MPL;Unlicense;Public Domain" \ + --partial-match \ + || echo "License check completed with warnings" + test: runs-on: ${{ matrix.os }} needs: lint @@ -61,10 +76,10 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 with: version: "latest" enable-cache: true @@ -92,7 +107,7 @@ jobs: uv run coverage report --format=markdown >> $GITHUB_STEP_SUMMARY - name: Upload test results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 if: always() with: name: test-results-${{ matrix.os }}-py${{ matrix.python-version }} @@ -100,29 +115,22 @@ jobs: retention-days: 30 - name: Upload coverage HTML report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 if: always() && matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' with: name: coverage-report path: htmlcov/ retention-days: 90 - - name: Upload coverage to Codecov - if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' - uses: codecov/codecov-action@v4 - with: - file: ./coverage.xml - fail_ci_if_error: false - - mutation: + benchmark: runs-on: ubuntu-latest - needs: lint - continue-on-error: true # Non-blocking - mutation testing is advisory + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 with: version: "latest" enable-cache: true @@ -133,26 +141,18 @@ jobs: - name: Install dependencies run: uv sync --dev - - name: Run mutation tests - run: | - uv run mutmut run \ - --paths-to-mutate=src/dppvalidator/validators/ \ - --tests-dir=tests/unit/ \ - --runner="uv run pytest tests/unit/ -x --tb=no -q" \ - --no-progress \ - || true # Don't fail the job on surviving mutants - - - name: Generate mutation report - if: always() + - name: Run benchmarks + run: uv run python benchmarks/run_benchmarks.py --output benchmark-results.json + + - name: Generate benchmark summary run: | - echo "## 🧬 Mutation Testing Report" >> $GITHUB_STEP_SUMMARY + echo "## ⚡ Benchmark Results" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - uv run mutmut results >> $GITHUB_STEP_SUMMARY 2>/dev/null || echo "No results available" >> $GITHUB_STEP_SUMMARY + uv run python -c "import json; data=json.load(open('benchmark-results.json')); [print(f'- **{k}**: {v:.3f}s') for k,v in data.get('timings', {}).items()]" >> $GITHUB_STEP_SUMMARY 2>/dev/null || echo "No results available" >> $GITHUB_STEP_SUMMARY - - name: Upload mutation cache - uses: actions/upload-artifact@v4 - if: always() + - name: Upload benchmark results + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: - name: mutation-cache - path: .mutmut-cache - retention-days: 7 + name: benchmark-results + path: benchmark-results.json + retention-days: 90 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 13edb9b..f8dfa75 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,15 +6,17 @@ on: workflow_dispatch: permissions: - contents: write + contents: read jobs: deploy: runs-on: ubuntu-latest + permissions: + contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: astral-sh/setup-uv@v5 + - uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 - name: Install dependencies run: uv sync --group docs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 60a5ff8..7f5b522 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,14 +5,20 @@ on: tags: - "v*" +permissions: + contents: read + jobs: build: + name: Build Package runs-on: ubuntu-latest + outputs: + hashes: ${{ steps.hash.outputs.hashes }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 with: version: "latest" @@ -28,13 +34,30 @@ jobs: - name: Build package run: uv build + - name: Generate SBOM (CycloneDX) + run: uvx --from cyclonedx-bom cyclonedx-py environment --of JSON -o sbom.json + - name: Upload build artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: dist path: dist/ + - name: Upload SBOM + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: sbom + path: sbom.json + retention-days: 365 + + - name: Generate artifact hashes + id: hash + run: | + cd dist + echo "hashes=$(sha256sum * | base64 -w0)" >> $GITHUB_OUTPUT + publish-testpypi: + name: Publish to TestPyPI runs-on: ubuntu-latest needs: build environment: testpypi @@ -42,52 +65,175 @@ jobs: id-token: write steps: - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: dist path: dist/ - name: Publish to TestPyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.12.4 with: repository-url: https://test.pypi.org/legacy/ attestations: false + skip-existing: true verbose: true - publish-pypi: + smoke-test: + name: Smoke Test (TestPyPI) runs-on: ubuntu-latest needs: publish-testpypi + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5.6.0 + with: + python-version: "3.12" + + - name: Wait for TestPyPI propagation + run: sleep 30 + + - name: Install base package from TestPyPI + run: | + for i in 1 2 3 4 5; do + pip install --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ \ + dppvalidator==${GITHUB_REF_NAME#v} && break + echo "Attempt $i failed, retrying in 15s..." + sleep 15 + done + + - name: Verify import + run: | + python -c "from dppvalidator import ValidationEngine; print('Import OK')" + python -c "from dppvalidator.models import DigitalProductPassport; print('Models OK')" + + - name: Verify CLI entry point + run: dppvalidator --version + + - name: Verify CLI runs (invalid data returns exit 1, which is correct) + run: | + echo '{"id":"https://x.com","issuer":{"id":"https://x.com","name":"T"}}' > /tmp/test.json + dppvalidator validate /tmp/test.json --format json || test $? -eq 1 + + - name: Verify export command + run: | + echo '{"@context":["https://www.w3.org/ns/credentials/v2","https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/"],"id":"https://example.com/dpp/1","type":["VerifiableCredential","DigitalProductPassport"],"issuer":{"id":"https://example.com","name":"Test"},"validFrom":"2024-01-01T00:00:00Z","credentialSubject":{"id":"https://example.com/subject/1","type":["ProductPassport"],"product":{"id":"https://example.com/products/1","name":"Test Product"}}}' > /tmp/valid_dpp.json + dppvalidator export /tmp/valid_dpp.json --output /tmp/out.jsonld + test -f /tmp/out.jsonld + + - name: Install with CLI extra + run: | + for i in 1 2 3; do + pip install --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ \ + "dppvalidator[cli]==${GITHUB_REF_NAME#v}" && break + echo "Attempt $i failed, retrying in 10s..." + sleep 10 + done + + - name: Verify CLI optional import + run: python -c "import rich; print('rich OK')" + + - name: Validate real fixture + run: dppvalidator validate tests/fixtures/valid/minimal_dpp.json + + publish-pypi: + name: Publish to PyPI + runs-on: ubuntu-latest + needs: smoke-test if: ${{ !contains(github.ref_name, '-') }} environment: pypi permissions: id-token: write steps: - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: dist path: dist/ - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.12.4 + + verify-pypi: + name: Verify PyPI Installation + runs-on: ubuntu-latest + needs: publish-pypi + if: needs.publish-pypi.result == 'success' + continue-on-error: true + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Wait for PyPI propagation + run: sleep 60 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5.6.0 + with: + python-version: "3.12" + + - name: Install from PyPI + run: | + for i in 1 2 3; do + pip install "dppvalidator[cli]==${GITHUB_REF_NAME#v}" && break + echo "Attempt $i failed, retrying in 30s..." + sleep 30 + done + + - name: Verify import + run: python -c "from dppvalidator import ValidationEngine; print('Import OK')" + + - name: Verify CLI + run: dppvalidator --version + + - name: Verify validation + run: dppvalidator validate tests/fixtures/valid/minimal_dpp.json + + - name: Verify export + run: | + dppvalidator export tests/fixtures/valid/minimal_dpp.json -o /tmp/out.jsonld + test -f /tmp/out.jsonld github-release: + name: Create GitHub Release runs-on: ubuntu-latest - needs: [publish-testpypi, publish-pypi] - if: ${{ always() && needs.publish-testpypi.result == 'success' }} + needs: [smoke-test, publish-pypi] + if: ${{ always() && needs.smoke-test.result == 'success' }} permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: dist path: dist/ + - name: Download SBOM + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: sbom + path: sbom/ + - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: - files: dist/* + files: | + dist/* + sbom/sbom.json generate_release_notes: true + + provenance: + name: Generate SLSA Provenance + needs: [build, github-release] + if: ${{ always() && needs.build.result == 'success' }} + permissions: + actions: read + id-token: write + contents: write + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0 + with: + base64-subjects: ${{ needs.build.outputs.hashes }} + upload-assets: true diff --git a/.github/workflows/update-vocabularies.yml b/.github/workflows/update-vocabularies.yml index 7849072..762611d 100644 --- a/.github/workflows/update-vocabularies.yml +++ b/.github/workflows/update-vocabularies.yml @@ -2,21 +2,23 @@ name: Update Vocabularies on: schedule: - - cron: '0 0 1 * *' # Monthly on the 1st at midnight UTC - workflow_dispatch: # Manual trigger + - cron: "0 0 1 * *" # Monthly on the 1st at midnight UTC + workflow_dispatch: # Manual trigger permissions: - contents: write - pull-requests: write + contents: read jobs: update: runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 with: version: "latest" @@ -40,7 +42,7 @@ jobs: - name: Create Pull Request if: steps.changes.outputs.changed == 'true' - uses: peter-evans/create-pull-request@v6 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8 with: token: ${{ secrets.GITHUB_TOKEN }} commit-message: "chore: update vocabulary data" diff --git a/.gitignore b/.gitignore index 53c4902..92e83b4 100644 --- a/.gitignore +++ b/.gitignore @@ -210,3 +210,18 @@ __marimo__/ # Windsurf IDE .windsurf/ docs/windsurf/ + +# Claude Code +# .claude/ itself is committed (settings.json, rules, commands, skills, hooks), +# but personal overrides and credentials must not be. +.claude/settings.local.json +.claude/.credentials.json +CLAUDE.local.md + +# Internal planning documents (keep locally, exclude from repo) +docs/IMPLEMENTATION_PLAN.md +docs/IMPROVEMENT_ROADMAP.md +docs/REFACTORING_PLAN.md +docs/UNTTP_PLUGIN_PLAN.md +docs/VC_WALLET_ROADMAP.md +docs/STRATEGIC_ROADMAP.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d725f05..020bdd5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ repos: rev: v6.0.0 hooks: - id: check-yaml - args: [--unsafe] + args: [ --unsafe ] - id: end-of-file-fixer - id: trailing-whitespace - id: detect-private-key @@ -19,45 +19,44 @@ repos: - mdformat-gfm - mdformat-black - mdformat-admon - exclude: ^(\.windsurf/(rules|skills|workflows)|\.claude)/.*\.md$ + exclude: ^(\.windsurf/(rules|skills|workflows)|\.claude|docs/plans|tests/fixtures/upstream)/.*\.md$ - repo: https://github.com/hadialqattan/pycln rev: v2.6.0 hooks: - id: pycln - args: [--config=pyproject.toml] + args: [ --config=pyproject.toml ] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.14.14 hooks: # bandit (security) - exclude notebooks (example code uses random) - id: ruff - types_or: [python, pyi] - args: ["--fix", "--select=S", "--ignore=S101,S110"] + types_or: [ python, pyi ] + args: [ "--fix", "--select=S", "--ignore=S101,S110" ] exclude: ^(tests/|mutants/|examples/) # isort - id: ruff - types_or: [python, pyi, jupyter] - args: [--fix, "--select=I"] + types_or: [ python, pyi, jupyter ] + args: [ --fix, "--select=I" ] # type annotations - exclude notebooks (educational examples) - id: ruff - types_or: [python, pyi] - args: - ["--select", "ANN", "--ignore", "ANN101,ANN102,ANN401,ANN002,ANN003"] + types_or: [ python, pyi ] + args: [ "--select", "ANN", "--ignore", "ANN101,ANN102,ANN401,ANN002,ANN003" ] exclude: ^(tests/|mutants/|benchmarks/|examples/).*$ # Replace %s statements with f-string syntax - id: ruff - types_or: [python, pyi, jupyter] - args: ["--select=FLY002"] + types_or: [ python, pyi, jupyter ] + args: [ "--select=FLY002" ] # Comprehensive ruff check (no auto-fix) - only on src code - id: ruff name: ruff-check-all - types_or: [python, pyi] - args: ["--output-format=github"] + types_or: [ python, pyi ] + args: [ "--output-format=github" ] exclude: ^(tests/|mutants/|benchmarks/|examples/) # formatting - id: ruff-format - types_or: [python, pyi, jupyter] + types_or: [ python, pyi, jupyter ] # ty type checking via uv (uses project's dev dependencies) - repo: local @@ -66,11 +65,11 @@ repos: name: ty entry: uv run ty check src/dppvalidator/ language: system - types: [python] + types: [ python ] pass_filenames: false - id: pip-audit name: pip-audit entry: uv run pip-audit --skip-editable language: system pass_filenames: false - stages: [pre-push] + stages: [ pre-push ] diff --git a/AGENTS.md b/AGENTS.md index 4084998..6a6e093 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,22 +19,52 @@ ```text src/dppvalidator/ # Main package ├── models/ # Pydantic models for DPP entities -├── validators/ # Validation logic -├── exporters/ # JSON-LD and other export formats -├── schemas/ # JSON Schema loading and caching -├── vocabularies/ # Controlled vocabulary loading -├── cli/ # Command-line interface -├── plugins/ # Plugin system +│ ├── v0_6/ # UNTP 0.6.x models (ProductPassport envelope) +│ ├── v0_7/ # UNTP 0.7.0 models (Product as credentialSubject) +│ └── … # Top-level shims re-export v0.6 for back-compat +├── validators/ # Validation logic (per-version dispatch) +│ ├── rules/v0_6/ # Semantic rules — v0.6 +│ ├── rules/v0_7/ # Semantic rules — v0.7 +│ └── … +├── compat/ # Cross-version compat shims (Phase 4) +├── verifier/ # Signature and credential verification +├── exporters/ # JSON-LD and EU DPP export formats +├── schemas/ # JSON Schema loading + version registry +├── vocabularies/ # Controlled vocabulary loading + EU DPP ontology mapping +├── cli/ # Command-line interface (validate, migrate, schema, …) +├── plugins/ # Plugin system (entry-points discovery) └── __init__.py tests/ # Test suite ├── unit/ # Unit tests -├── integration/ # Integration tests +├── integration/ # Integration tests (incl. version matrix, plugin) ├── property/ # Property-based tests (Hypothesis) ├── fuzz/ # Fuzz tests └── fixtures/ # Test data + ├── valid/ # Per-version happy-path fixtures + ├── invalid/0.7.0/ # v0.7-specific failure fixtures + └── upstream/ # SHA-pinned upstream samples ``` +## UNTP version handling + +dppvalidator supports **UNTP DPP 0.6.x and 0.7.0** in the same release. + +- Version detection: `validators/detection.py` is the only place that + decides the version of a payload. +- Default version: `schemas.registry.DEFAULT_SCHEMA_VERSION` (currently + `0.6.1`); call `dppvalidator.compat.active_version()` from feature + code instead of hardcoding the literal. +- Adding a new version: see + [`.claude/rules/untp-versioning.md`](.claude/rules/untp-versioning.md) + for the cardinal rules and the minimum touch list. Use + `/untp-bump ` (Claude Code). +- v0.6 → v0.7 upgrade: `dppvalidator.compat.upgrade_0_6_to_0_7.upgrade` + ships the 17-step shim with structured warnings; CLI surface is + `dppvalidator migrate` and `dppvalidator validate --upgrade-from`. +- Documentation: [`docs/concepts/untp-versions.md`](docs/concepts/untp-versions.md) + and [`docs/guides/migration-0-6-to-0-7.md`](docs/guides/migration-0-6-to-0-7.md). + ## Development Workflow 1. Use **gitflow**: `develop` → `feature/*` → PR → `develop` → `release/*` → `main` @@ -48,3 +78,6 @@ tests/ # Test suite - Validate at boundaries with Pydantic - Type hint all public APIs - Document public functions with docstrings +- **Cardinal versioning rules** in + [`.claude/rules/untp-versioning.md`](.claude/rules/untp-versioning.md) + apply to every change in `src/dppvalidator/{schemas,exporters,models,validators,compat}/`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d560da..9ba2bc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,203 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.4.0] - _unreleased_ + +This release adds first-class support for **UNTP DPP 0.7.0** alongside +the existing 0.6.x. Both wire formats coexist and are auto-detected +from `@context` / `$schema` URLs. The plan is captured in +[`docs/plans/UNTP_0.7.0_MIGRATION.md`](docs/plans/UNTP_0.7.0_MIGRATION.md); +each phase has its own implementation log there. + +### Added + +- **UNTP DPP 0.7.0 schema, context, and Pydantic models**. Vendored + upstream artefacts under `src/dppvalidator/{schemas,vocabularies}/data/` + with SHA-256 pins. New version-namespaced model package + `dppvalidator.models.v0_7.*` (envelope, product, materials, claims, + identifiers, primitives) covering every required field per the + upstream schema. The existing top-level `dppvalidator.models.*` + imports continue to resolve to v0.6 for back-compat. +- **Per-version validator dispatch**. New `_MODEL_BY_VERSION` + (model layer), `ALL_RULES_BY_VERSION` (semantic-rule layer), + `LINK_PATHS_BY_VERSION` (deep validator) tables; `ValidationEngine` + selects the right artefact set per detected version. +- **VER001 version-mismatch fail-fast**. Engine raises `VER001` when + `schema_version` is pinned and the payload's declared version + conflicts. +- **Compat shim 0.6.x → 0.7.0**. New `dppvalidator.compat` package + exporting `upgrade(data, *, country_lookup=None) -> (dict, list[UpgradeWarning])`, + plus `active_version()` / `is_version()` helpers and four warning + codes (`UPG001`–`UPG004`). Implements all 17 transformation steps + from §Phase 4 of the migration plan. +- **`dppvalidator migrate` CLI**. Writes the upgraded JSON to + `-o` / `--in-place` / stdout; refuses on warnings unless + `--accept-warnings`; always emits a sidecar + `.warnings.json` when blocking warnings fire. +- **`dppvalidator validate --upgrade-from `**. Runs the shim + before validating; surfaces upgrade + validation issues in one + report. +- **Per-version EU DPP exporter mapping**. `TermMapping` extended + with `untp_v0_6` / `untp_v0_7` columns + a `TERM_REMOVED` sentinel; + `EUDPPJsonLDExporter(schema_version=…)` dispatches per-call; + auto-detect resolves the source version from the passport's class. +- **Plugin version-awareness**. New + `BrandNameRuleV07` in the example plugin demonstrates the + `applies_to_versions` opt-in pattern; the example plugin is now a + CI-tested target via `tests/integration/test_example_plugin.py`. +- **Manifest integrity test**. + `tests/unit/test_manifest_integrity.py` SHA-256-verifies every + vendored `.json` / `.jsonld` artefact and includes a drift-catch + for un-manifested files. +- **Sample classification test**. + `tests/unit/test_samples_classification.py` pins + `detect_schema_version()` per real-world sample under + `tests/fixtures/samples/`. +- **Production-URL split per artefact**. `SchemaVersion` and + `MANIFEST.json` now carry both an SHA-pinned `source_url` and a + human-friendly `production_url` (e.g. `untp.unece.org`). +- **Documentation**. New + [`docs/concepts/untp-versions.md`](docs/concepts/untp-versions.md) + and [`docs/guides/migration-0-6-to-0-7.md`](docs/guides/migration-0-6-to-0-7.md); + refreshed schema, JSON-LD, validation, CLI, FAQ, and index pages + with both v0.6 and v0.7 examples. + +### Changed + +- `dppvalidator schema list` reports all three registered versions + (0.6.0, 0.6.1, 0.7.0). +- `valid_dpp_data` pytest fixture is now parametrised over both + matrix versions; tests pin to a single version with + `@pytest.mark.dpp_version("X.Y.Z")`. +- v0.6 model files were relocated to `dppvalidator.models.v0_6/` + with thin re-export shims at the top level — no callers should + notice. +- v0.6 semantic rules likewise relocated to + `dppvalidator.validators.rules.v0_6/`; new + `dppvalidator.validators.rules.v0_7/` carries the v0.7 ports. +- `EUDPPJsonLDExporter` no longer hardcodes a single mapping table; + the new auto-detection reads the passport's module path. +- The `Characteristics` `$def` quirk in the upstream UNTP 0.7.0 DPP + schema (empty `properties`, description copy-pasted from `Claim`) + is documented in `MANIFEST.json` and + `docs/concepts/cirpass-implementation.md`. + +### Deprecated + +- Hardcoded version literals (`"0.6.1"` / `"0.7.0"`) in feature code + outside `schemas/registry.py` and `exporters/contexts.py`. The + `tests/unit/test_no_version_literals.py` guard rejects new + occurrences. Feature code should call + `dppvalidator.compat.active_version()` instead. + +### Tests + +- 2019 passing, 13 skipped (by-design via the dpp_version marker), + 1 xfailed; coverage 92.20 % (above the 90 % gate). +- Net new tests added across the migration: ~150+ unit cases, the + 17-case version matrix, the 13-case manifest integrity, the + 27-case samples classification, the 17-case plugin integration, + the 50-case compat shim, the 10-case CLI migrate, the 4-case + round-trip integration, and 10 production-URL pins. + +## [0.3.2] - 2026-02-01 ### Added +- **URDNA2015 RDF Canonicalization**: W3C-compliant signature verification using pyld +- **Module-Level Schema Caching**: Shared cache across ValidationEngine instances + - `clear_schema_cache()` function to force schema reloading +- **Plugin Registry Singleton**: Optimized plugin discovery with `get_default_registry()` + - `reset_default_registry()` function for testing scenarios +- **Bundled JSON-LD Contexts**: Pre-bundled W3C VC v2 context for offline validation +- **Async DID Resolution**: Non-blocking `DIDResolver.resolve_async()` method +- **Strict Plugin Mode**: `run_all_validators(strict=True)` raises `PluginError` on failure +- **Migration Guide**: New `docs/migration.md` for version upgrade documentation +- **CI Performance Benchmarks**: Automated benchmark job on main branch pushes +- **SBOM Generation**: CycloneDX Software Bill of Materials in release workflow +- **License Scanning**: Dependency license compliance checking in CI +- **CIRPASS-2 Schema Support (Phase 5)**: Dual-mode validation for UNTP and EU DPP schemas + - `CIRPASSSchemaLoader` and `CIRPASSSHACLLoader` in `schemas/cirpass_loader.py` + - `SchemaValidator(schema_type="cirpass")` for CIRPASS-specific validation + - Bundled CIRPASS-2 v1.3.0 JSON Schema from dpp.vocabulary-hub.eu +- **EU DPP Ontology Vocabularies**: Complete vocabulary modules aligned with CIRPASS-2 + - `eudpp_actors.py`: 24 actor/role classes (Manufacturer, Recycler, etc.) + - `eudpp_classes.py`: EU DPP Core Ontology class definitions + - `eudpp_lca.py`: 16 PEF/OEF impact categories per EU 2021/2279 + - `eudpp_relations.py`: EU DPP object property mappings + - `eudpp_substances.py`: Substances of Concern (SOC) vocabulary + - `ontology.py`: Ontology alignment utilities + - `rdf_loader.py`: RDF graph loading with optional rdflib support +- **Bundled Ontology Data Files**: Offline validation without network access + - `vocabularies/data/ontologies/`: 5 Turtle files (eudpp_core, product_dpp, actors_roles, soc, lca) + - `vocabularies/data/schemas/`: CIRPASS JSON Schema, SHACL shapes, OpenAPI spec, XSD +- **EU DPP JSON-LD Exporter**: UNTP to EU DPP format conversion + - `EUDPPJsonLDExporter` class with namespace handling and term mapping + - `EUDPPTermMapper` for UNTP→EU DPP vocabulary translation + - `export_eudpp_jsonld()` and `export_eudpp_jsonld_dict()` convenience functions +- **Official SHACL Validation**: RDF-based constraint validation + - `OfficialSHACLLoader` for CIRPASS SHACL shapes (cirpass_dpp_shacl.ttl) + - `RDFSHACLValidator` with pyshacl integration + - `SHACLValidationResult` dataclass with violations/warnings/info + - `validate_jsonld_with_official_shacl()` convenience function +- **CIRPASS Semantic Rules** (CQ prefix): EU DPP business logic validation + - CQ001: Mandatory ESPR attributes (issuer, validFrom, product) + - CQ004: Substances of Concern CAS/EINECS identification per ESPR Art 7(5a) + - CQ011: Manufacturer unique operator identifier per ESPR Annex III(g) + - CQ016: DPP validity period per ESPR Art 9(2i) + - CQ017: Granularity level consistency per SR5423 Annex II Part B + - CQ020: Product weight/volume declarations per ESPR Annex I(j) +- **Textile-Specific Rules** (TXT prefix): ESPR textile product requirements + - TXT001: Textile HS code validation (Chapters 50-63) + - TXT002: Material composition/fiber declaration + - TXT003: Synthetic fiber microplastic release info + - TXT004: Durability information per ESPR Annex I + - TXT005: Care instructions linking +- **Error Documentation**: Comprehensive error code reference + - 74 new error documentation pages (SCH, PRS, MDL, VOC, CQ, TXT codes) + - `scripts/check_error_docs.py` for documentation coverage verification + - CI workflow step for error documentation completeness +- **Optional Dependency Extras**: Modular installation + - `[rdf]` extra: rdflib>=7.0.0, pyshacl>=0.25.0 for SHACL validation + - `[all]` extra: combined cli + rdf features +- **Test Suite Expansion**: CIRPASS/EU DPP coverage + - `test_cirpass_loader.py`, `test_cirpass_rules.py`, `test_cirpass_vocabulary.py` + - `test_eudpp_actors.py`, `test_eudpp_classes.py`, `test_eudpp_lca.py` + - `test_eudpp_relations.py`, `test_eudpp_substances.py`, `test_eudpp_export.py` + - `test_rdf_loader.py`, `test_ontology_alignment.py`, `test_shacl_official.py` + - `test_schema_dual_mode.py`, `test_textile_rules.py` + - `test_cirpass_shacl_integration.py` (integration) + - `test_fuzz_cirpass.py`, `test_property_cirpass.py` (fuzz/property) + - Shared fixtures in `tests/conftest.py` (`valid_dpp_data`, `minimal_dpp_data`) +- **Validation Layer Architecture**: New `validators/layers.py` module with Strategy Pattern + - `ValidationContext` dataclass for shared validation state + - `ValidationLayer` abstract base class with 7 implementations + - `SchemaLayer`, `ModelLayer`, `SemanticLayer`, `JsonLdLayer`, + `VocabularyLayer`, `PluginLayer`, `SignatureLayer` +- **Schema Auto-Detection (Phase 0)**: `ValidationEngine` now defaults to `schema_version="auto"` + - Detects version from `$schema`, `@context`, or `type` array + - New `detect_schema_version()` and `is_dpp_document()` functions in `validators/detection.py` +- **JSON-LD Semantic Validation (Phase 1)**: PyLD-based expansion validation + - `JSONLDValidator` class with `CachingDocumentLoader` for remote contexts + - New error codes: JLD001-JLD004 for context and term validation + - Enable with `ValidationEngine(validate_jsonld=True)` +- **Extended Code Lists (Phase 2)**: UNECE Rec 46, HS codes, GTIN validation + - `validate_gtin()`, `is_valid_material_code()`, `is_valid_hs_code()` functions + - GS1 Digital Link URL parsing and validation + - 85 UNECE Rec 46 material codes, 156 textile HS codes (Chapters 50-63) + - New rules: VOC003 (material codes), VOC004 (HS codes), VOC005 (GTIN checksum) +- **Verifiable Credential Verification (Phase 3)**: DID resolution and signature verification + - `CredentialVerifier`, `DIDResolver`, `SignatureVerifier` classes in `verifier/` module + - Support for `did:web` and `did:key` resolution + - Ed25519, ES256, ES384 signature algorithms + - Ed25519Signature2020, DataIntegrityProof, JsonWebSignature2020 proof types + - New `ValidationResult` fields: `signature_valid`, `issuer_did`, `verification_method` + - Enable with `ValidationEngine(verify_signatures=True)` +- **Deep/Recursive Validation (Phase 4)**: Supply chain traversal + - `DeepValidator` class with async crawling, rate limiting, retry logic + - Cycle detection for circular references + - Configurable depth limits and link following paths + - New `ValidationEngine.validate_deep()` async method - `CredentialStatus` model for W3C VC v2 revocation checking - `credentialStatus` field on `DigitalProductPassport` - `Scope1`, `Scope2`, `Scope3` values to `OperationalScope` enum (GHG Protocol) @@ -19,16 +212,100 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Signature Verification**: Uses URDNA2015 RDF canonicalization (W3C compliant) +- **Error Codes**: Plugin errors standardized from `PLG_ERROR` to `PLG001` format +- **validate_deep()**: Now returns typed `DeepValidationResult` instead of `Any` +- **Layer Type Hints**: All validators use `Validator | None` protocol type hints +- **Docstrings**: Engine docstring updated to document seven validation layers +- **Async Pattern**: `validate_async()` docstring documents sync/async relationship +- **Mutation Testing**: CI scope expanded to validators, verifier, and models +- **File Size Check**: Path inputs now validated for size before reading (DoS protection) +- **base58 Library**: Uses `base58>=2.1.0` instead of custom implementation +- **Documentation**: README expanded with EU DPP/CIRPASS-2 sections + - Optional features installation guide (RDF, JSON-LD, signatures) + - EU DPP & CIRPASS-2 support section with examples + - CIRPASS-2 Integration documentation link +- **Package Metadata**: `pyproject.toml` classifiers and keywords updated + - New keywords: eu-regulation, sustainability, circular-economy, textile, battery-passport, + untp, verifiable-credentials, json-ld, w3c, supply-chain, traceability + - Added `Environment :: Console` classifier + - Build includes bundled `.json`, `.ttl`, `.xsd`, `.yaml` data files +- **MkDocs Navigation**: Expanded error reference structure + - Full error code hierarchy (SCH, PRS, MDL, JLD, SEM, VOC, CQ, TXT) + - EU DPP Ontology Alignment and CIRPASS-2 Implementation concept pages + - EU DPP Export guide added +- **CLI Output**: JSON format uses plain print to avoid Rich ANSI codes +- **Console Fallback**: Panel output respects `_file` parameter for testability +- **Breaking**: `ValidationEngine()` now defaults to `schema_version="auto"` +- **Architecture**: Validation uses Strategy Pattern with pluggable layers + - `validate()` refactored from 88 lines to 32 lines + - Complexity reduced: C901 (15→0), PLR0912 (14→0) +- **CLI Refactoring**: Watch, init, and validate commands restructured + - `watch.py`: Extracted `FileWatcher` and `WatchLoop` classes + - `init.py`: Data-driven `FileSpec` pattern for scaffolding + - `validate.py`: Extracted `_format_issue()` helper +- **Vocabulary Loader**: `_extract_values()` uses dispatch pattern +- Core dependencies simplified: `httpx`, `jsonschema`, `pyld`, `cryptography`, `PyJWT` now required +- Removed all `HAS_*` conditional checks from source code +- `has_vc_support()` now always returns `True` - `operational_scope` is now optional in `EmissionsPerformance` (enables SEM007 rule) - Replaced bare `except Exception` catches with specific exception types - Consolidated error system: removed unused `EnhancedValidationError` class - Version now read from `pyproject.toml` via `importlib.metadata` - Async validation uses `asyncio.to_thread()` instead of deprecated API +### Security + +- **URDNA2015 Canonicalization**: Signature verification now uses W3C-compliant + RDF canonicalization instead of simplified JSON sorting +- **File Size Validation**: Path inputs checked for size before reading (DoS protection) +- **base58 Library**: Replaced custom implementation with audited `base58>=2.1.0` +- **JWT Verification**: Proper algorithm validation for ES256, ES384, EdDSA +- **Async DID Resolution**: Non-blocking network I/O prevents thread exhaustion + ### Fixed - README plugin API example now matches actual `SemanticRule` protocol - Model error codes are now deterministic (based on Pydantic error type) +- Unused imports and arguments cleaned up across verifier module +- Flaky `test_engine_never_crashes_on_binary` deadline increased to 500ms +- Redundant exception handling in `verifier.py` simplified +- Test assertions updated for URDNA2015 N-Quads output format +- Cache eviction tests fixed for pre-populated bundled contexts + +## [0.2.0] - 2026-01-30 + +### Added + +- **Deep/Recursive Validation**: Supply chain traversal with `DeepValidator` + - Async crawling, rate limiting, cycle detection + - `ValidationEngine.validate_deep()` async method +- **Verifiable Credential Verification**: DID resolution and signatures + - `CredentialVerifier`, `DIDResolver`, `SignatureVerifier` classes + - Support for `did:web` and `did:key` resolution + - Ed25519, ES256, ES384 signature algorithms +- **JSON-LD Semantic Validation**: PyLD-based expansion validation + - `JSONLDValidator` with `CachingDocumentLoader` + - Error codes JLD001-JLD004 +- **Extended Code Lists**: UNECE Rec 46, HS codes, GTIN validation + - `validate_gtin()`, `is_valid_material_code()`, `is_valid_hs_code()` + - GS1 Digital Link URL parsing +- **Schema Auto-Detection**: `schema_version="auto"` default +- **Validation Layer Architecture**: Strategy Pattern in `validators/layers.py` +- Bundled JSON-LD contexts for offline validation +- Async DID resolution with `DIDResolver.resolve_async()` + +### Changed + +- Signature verification uses URDNA2015 RDF canonicalization (W3C compliant) +- Core dependencies: `httpx`, `jsonschema`, `pyld`, `cryptography`, `PyJWT` now required +- `ValidationEngine()` defaults to `schema_version="auto"` + +### Security + +- URDNA2015 canonicalization for signature verification +- File size validation for DoS protection +- Uses audited `base58>=2.1.0` library ## [0.1.0] - 2026-01-29 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ca42a4a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,36 @@ +@AGENTS.md + +## Working with Claude Code in this repo + +This repository is configured for Claude Code under `.claude/`: + +- `.claude/CLAUDE.md` — extra project instructions (this file imports it implicitly via `./CLAUDE.md`) +- `.claude/rules/` — path-scoped rules that load when Claude reads matching files +- `.claude/skills/` — invocable skills (`/validate-dpp`, `/pypi-publish`) +- `.claude/commands/` — slash commands for workflows (`/lint`, `/test`, `/feature`, `/release`, `/hotfix`, `/pr-review`, `/code-health`, `/docs-health`, `/dev-setup`, `/fix-lint`, `/claude-health`) +- `.claude/settings.json` — hooks and other shared settings (committed) +- `.claude/settings.local.json` — personal overrides (gitignored) + +## Conventions specific to Claude Code sessions + +- Always use `uv run ` (not bare `pytest`/`ruff`/`ty`); the project pins versions through `uv`. +- Prefer the `Edit` tool for changes to existing files; reserve `Write` for new files. +- When editing Python under `src/dppvalidator/` or `tests/`, the `PostToolUse` hook auto-runs `uv run ruff check --fix` on the touched file. If a fix is applied, re-read the file before subsequent edits. +- Follow conventional commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `ci:`, `perf:`). See `.claude/rules/commits.md`. +- Do not import plugin code from `src/dppvalidator/` (one-way dependency only). See `.claude/rules/plugin-licenses.md`. + +## Quick orientation + +- Public package: `src/dppvalidator/` (MIT) +- Plugin packages: `plugins/*/` (separately licensed; e.g. `plugins/textiles/` is GPL-3.0) +- Tests: `tests/{unit,integration,property,fuzz}/` with shared fixtures in `tests/fixtures/` +- Docs site: `mkdocs.yml` + `docs/` +- CLI entry: defined in `pyproject.toml` +- Versioned models: `src/dppvalidator/models/v0_6/`, `…/v0_7/`. + Top-level imports re-export v0.6 for back-compat. +- Compat shim 0.6 → 0.7: + `src/dppvalidator/compat/upgrade_0_6_to_0_7.py` (CLI: + `dppvalidator migrate` and `validate --upgrade-from`). +- Versioning cardinal rules: `.claude/rules/untp-versioning.md`. + Adding a UNTP version: `/untp-bump `. + Migration plan archive: `docs/plans/UNTP_0.7.0_MIGRATION.md`. diff --git a/README.md b/README.md index 7fa560b..2e23b75 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@

+ dppvalidator logo

dppvalidator

The open-source compliance engine for EU Digital Product Passports @@ -6,11 +7,16 @@

- PyPI version - Python versions - License - CI - Documentation + PyPI version + Python versions + Downloads + License + CI + Documentation +

+ +

+ Platform

@@ -29,13 +35,17 @@ ______________________________________________________________________ ## Why dppvalidator? -| Challenge | Solution | -| --------------------------------- | ------------------------------------------------------------------------------- | -| Complex JSON Schema validation | **Three-layer validation** catches errors at schema, model, and semantic levels | -| Evolving UNTP specifications | **Built-in schema support** for UNTP DPP 0.6.1 with easy version switching | -| Integration with existing systems | **CLI + Python API** for pipelines, CI/CD, and application integration | -| Custom business rules | **Plugin system** for domain-specific validators and exporters | -| Interoperability requirements | **JSON-LD export** for W3C Verifiable Credentials compliance | + + +| Challenge | Solution | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| Complex JSON Schema validation | **Seven-layer validation** catches errors at schema, model, semantic, JSON-LD, vocabulary, plugin, and signature levels | +| Evolving UNTP specifications | **Both UNTP DPP 0.6.x and 0.7.0** — auto-detected; `dppvalidator migrate` upgrades 0.6 → 0.7 | +| Integration with existing systems | **CLI + Python API** for pipelines, CI/CD, and application integration | +| Custom business rules | **Plugin system** for domain-specific validators and exporters | +| Interoperability requirements | **JSON-LD export** for W3C Verifiable Credentials compliance | + + ## Installation @@ -43,13 +53,58 @@ ______________________________________________________________________ # Using uv (recommended) uv add dppvalidator -# Using pip +# With CLI extras (rich formatting) +uv add "dppvalidator[cli]" + +# Or using pip pip install dppvalidator +pip install "dppvalidator[cli]" # with CLI extras +``` + +### Optional Features + +#### RDF/SHACL Validation -# With optional dependencies -pip install dppvalidator[all] # Includes jsonschema + httpx +For SHACL validation against official CIRPASS-2 shapes: + +```bash +# Using uv (recommended) +uv add "dppvalidator[rdf]" + +# Or using pip +pip install "dppvalidator[rdf]" ``` +```python +from dppvalidator.validators import ValidationEngine + +# Enable SHACL validation (requires [rdf] extra) +engine = ValidationEngine(enable_shacl=True) +result = engine.validate(dpp_data) +``` + +#### JSON-LD Semantic Validation + +JSON-LD validation is included by default (via `pyld`): + +```python +engine = ValidationEngine(validate_jsonld=True) +result = engine.validate(dpp_data) +``` + +#### Signature Verification + +Signature verification is included by default (via `cryptography`): + +```python +engine = ValidationEngine(verify_signatures=True) +result = engine.validate(dpp_data) +``` + +> **Note:** `pyld` and `cryptography` are core dependencies installed automatically. +> Only `[rdf]` (for SHACL via rdflib/pyshacl) and `[cli]` (for rich formatting) +> are true optional extras. + **Requirements:** Python 3.10+ ## Quick Start @@ -120,31 +175,80 @@ jsonld_output = exporter.export(passport) # Ready for W3C Verifiable Credentials ecosystem ``` +## Supported versions + +dppvalidator supports both UNTP DPP wire formats in the same release. +The version is auto-detected from the payload's `@context` / +`$schema` URLs; pin explicitly with `--schema-version` (CLI) or +`schema_version=` (Python). + + + +| UNTP DPP | Status | Default? | Wire shape | +| --------- | ------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **0.6.0** | Supported (legacy) | no | `credentialSubject` is `ProductPassport` wrapping `Product`. | +| **0.6.1** | Default | **yes** | Same shape as 0.6.0; current `DEFAULT_SCHEMA_VERSION`. | +| **0.7.0** | Fully supported | no | `credentialSubject` IS the `Product` directly. New required fields: `name` (envelope), `idScheme`, `idGranularity`, `productCategory`, `producedAtFacility`, `countryOfProduction`. | + + + +A compat shim upgrades v0.6.x payloads to v0.7.0 shape: + +```bash +dppvalidator migrate passport-v06.json -o passport-v07.json +dppvalidator validate passport-v06.json --upgrade-from 0.6.1 --schema-version 0.7.0 +``` + +The full version-handling story is documented in +[`docs/concepts/untp-versions.md`](docs/concepts/untp-versions.md); +the field rename table and warning codes are in +[`docs/guides/migration-0-6-to-0-7.md`](docs/guides/migration-0-6-to-0-7.md). + ## Features -### Three-Layer Validation Architecture +### Seven-Layer Validation Architecture ```mermaid flowchart TD - A[/"📄 Input Data (JSON)"/] --> B + subgraph Input + A[/"📄 Input Data (JSON)"/] + end - subgraph Layer1["🔷 Layer 1: Schema Validation"] - B["JSON Schema Draft 2020-12
Required fields, types, formats"] + subgraph Layer0["Layer 0: Schema Detection"] + A0["Auto-detect schema version
from $schema, @context, type"] end - B -->|"SCH001-SCH099"| C + subgraph Layer1["Layer 1: Schema Validation"] + B["JSON Schema Draft 2020-12
Required fields, types, formats"] + end - subgraph Layer2["🔶 Layer 2: Model Validation"] + subgraph Layer2["Layer 2: Model Validation"] C["Pydantic v2 Models
Type coercion, URL validation"] end - C -->|"MOD001-MOD099"| D + subgraph Layer3["Layer 3: JSON-LD Semantic"] + C2["PyLD Expansion
Context resolution, term validation"] + end + + subgraph Layer4["Layer 4: Business Logic"] + D["Business Rules & Vocabularies
ISO codes, date logic, GTIN checksums"] + end + + subgraph Layer5["Layer 5: Cryptographic"] + E["VC Signature Verification
DID resolution, Ed25519/ECDSA"] + end - subgraph Layer3["🟢 Layer 3: Semantic Validation"] - D["Business Rules & Vocabularies
ISO codes, date logic, references"] + subgraph Output + F[/"✅ ValidationResult
.valid | .errors | .signature_valid"/] end - D -->|"SEM001-SEM099"| E[/"✅ ValidationResult
.valid | .errors | .warnings"/] + A --> A0 + A0 --> B + B -->|"SCH001-SCH099"| C + C -->|"MOD001-MOD099"| C2 + C2 -->|"JLD001-JLD099"| D + D -->|"SEM001-SEM099"| E + E -->|"SIG001-SIG099"| F ``` ### Selective Layer Validation @@ -160,18 +264,28 @@ engine = ValidationEngine(layers=["schema"]) # Skip schema, run model + semantic engine = ValidationEngine(layers=["model", "semantic"]) + +# Enable JSON-LD validation +engine = ValidationEngine(validate_jsonld=True) + +# Enable signature verification +engine = ValidationEngine(verify_signatures=True) +result = engine.validate(dpp_data) +if result.signature_valid: + print(f"Signed by: {result.issuer_did}") ``` ### Performance -| Layer | Time | Throughput | -| -------- | --------- | ------------------ | -| Schema | ~5μs | 200,000 ops/sec | -| Model | ~8μs | 125,000 ops/sec | -| Semantic | ~3μs | 333,000 ops/sec | -| **All** | **~13μs** | **80,000 ops/sec** | +| Layer | Mean Time | Throughput | +| ---------------- | --------- | ----------------- | +| Model (minimal) | 0.012ms | 84,387 ops/sec | +| Model (full) | 0.016ms | 63,945 ops/sec | +| Semantic | 0.005ms | 200,889 ops/sec | +| Full (Model+Sem) | 0.022ms | 45,735 ops/sec | +| Engine Creation | 0.001ms | 1,524,868 ops/sec | -*Benchmarked on Apple M2, Python 3.12* +*Benchmarked on Apple Silicon (M-series). JSON-LD and signature verification depend on network latency (cached after first request).* ### Plugin System @@ -206,21 +320,48 @@ registry.register_validator("textile", TextileFiberRule) engine = ValidationEngine(load_plugins=True) ``` +## EU DPP & CIRPASS-2 Support + +dppvalidator includes full support for the **EU DPP Core Ontology** from CIRPASS-2: + +```python +from dppvalidator.validators import SchemaValidator +from dppvalidator.exporters import EUDPPJsonLDExporter + +# Dual-mode validation: UNTP (default) or EU DPP +validator = SchemaValidator(schema_type="cirpass") +result = validator.validate(dpp_data) + +# Export to EU DPP-aligned JSON-LD +exporter = EUDPPJsonLDExporter(map_terms=True) +jsonld = exporter.export(passport) +``` + +| Feature | Description | +| ------------------------ | ------------------------------------------------------------------------ | +| **Dual-mode validation** | Switch between UNTP and CIRPASS schemas | +| **EU DPP vocabulary** | 24 actor/role classes, 16 PEF categories | +| **JSON-LD export** | EU DPP-aligned output with term mapping | +| **SHACL validation** | Optional RDF-based validation (requires `pip install dppvalidator[rdf]`) | + +> 🏆 **Aligned with [dpp.vocabulary-hub.eu/specifications](https://dpp.vocabulary-hub.eu/specifications)** as of 2025-02-01. To our knowledge, dppvalidator is the most comprehensive open-source Python package for EU DPP vocabulary compliance. + ## Documentation 📚 **Full documentation:** [artiso-ai.github.io/dppvalidator](https://artiso-ai.github.io/dppvalidator/) -| Guide | Description | -| ----------------------------------------------------------------------------------------- | ------------------------------------------ | -| [Installation](https://artiso-ai.github.io/dppvalidator/getting-started/installation/) | Setup and optional dependencies | -| [Quick Start](https://artiso-ai.github.io/dppvalidator/getting-started/quickstart/) | Get started in 5 minutes | -| [CLI Reference](https://artiso-ai.github.io/dppvalidator/guides/cli-usage/) | Command-line interface | -| [Validation Layers](https://artiso-ai.github.io/dppvalidator/concepts/validation-layers/) | Understanding the three-layer architecture | -| [API Reference](https://artiso-ai.github.io/dppvalidator/reference/api/validators/) | Complete Python API | +| Guide | Description | +| -------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| [Installation](https://artiso-ai.github.io/dppvalidator/getting-started/installation/) | Setup and CLI extras | +| [Quick Start](https://artiso-ai.github.io/dppvalidator/getting-started/quickstart/) | Get started in 5 minutes | +| [CLI Reference](https://artiso-ai.github.io/dppvalidator/guides/cli-usage/) | Command-line interface | +| [Validation Layers](https://artiso-ai.github.io/dppvalidator/concepts/validation-layers/) | Understanding the five-layer architecture | +| [CIRPASS-2 Integration](https://artiso-ai.github.io/dppvalidator/concepts/cirpass-implementation/) | EU DPP ontology alignment | +| [API Reference](https://artiso-ai.github.io/dppvalidator/reference/api/validators/) | Complete Python API | ## Built for Fashion & Textiles -The EU's [Ecodesign for Sustainable Products Regulation (ESPR)](https://environment.ec.europa.eu/topics/circular-economy/ecodesign-sustainable-products-regulation_en) mandates Digital Product Passports for textiles starting 2027. dppvalidator helps fashion brands prepare now: +The EU's [Ecodesign for Sustainable Products Regulation (ESPR)](https://commission.europa.eu/energy-climate-change-environment/standards-tools-and-labels/products-labelling-rules-and-requirements/ecodesign-sustainable-products-regulation_en) mandates Digital Product Passports for textiles starting 2027. dppvalidator helps fashion brands prepare now: | DPP Requirement | How dppvalidator Helps | | ------------------------------ | -------------------------------------------- | @@ -272,11 +413,36 @@ def validate_supplier_submission(dpp_json: dict) -> bool: ## Related Standards - [UNTP Digital Product Passport](https://untp.unece.org/docs/specification/DigitalProductPassport/) — UN/CEFACT specification -- [EU ESPR Regulation](https://environment.ec.europa.eu/topics/circular-economy/ecodesign-sustainable-products-regulation_en) — Ecodesign for Sustainable Products +- [EU ESPR Regulation](https://commission.europa.eu/energy-climate-change-environment/standards-tools-and-labels/products-labelling-rules-and-requirements/ecodesign-sustainable-products-regulation_en) — Ecodesign for Sustainable Products - [W3C Verifiable Credentials](https://www.w3.org/TR/vc-data-model/) — Credential format standard > ⚠️ **Note on UNTP Specification:** The UNTP Digital Product Passport specification is under active development and not yet ready for production implementation. We track the latest maintained releases and will update dppvalidator as the specification stabilizes. See the [UNTP releases page](https://untp.unece.org/docs/specification/DigitalProductPassport/) for current status. +## Known Limitations + +### Signature Verification + +| Feature | Status | Recommendation | +| --------------------- | ------------ | --------------------------------------- | +| Data Integrity Proofs | ✅ Supported | Use for production | +| JWS Proofs | ✅ Supported | Use for production | +| JWT Credentials | ✅ Supported | Full verification (ES256, ES384, EdDSA) | + +**JWT Credentials:** JWT-encoded Verifiable Credentials are fully verified via DID resolution and cryptographic signature verification. Supported algorithms include ES256, ES384, and EdDSA (Ed25519). For maximum interoperability, Data Integrity Proofs are recommended. + +### Canonicalization + +The signature verification uses **simplified JSON canonicalization** (sorted keys) rather than full [URDNA2015](https://www.w3.org/TR/rdf-canon/) RDF canonicalization. This may cause verification failures for credentials signed with strict W3C Data Integrity canonicalization. + +For production deployments requiring full W3C VC compliance: + +```python +# Future: Use pyld for URDNA2015 canonicalization +from pyld import jsonld + +normalized = jsonld.normalize(credential, {"algorithm": "URDNA2015"}) +``` + ## Contributing We welcome contributions! Here's how to get started: @@ -300,12 +466,12 @@ See our [Contributing Guide](https://artiso-ai.github.io/dppvalidator/contributi ## About ARTISO -
+
**dppvalidator** is developed and maintained by [ARTISO](https://www.artiso.ai), a Barcelona-based fashion technology company. -ARTISO +ARTISO
We believe the fashion industry's transition to sustainability requires **open, accessible tools**. By open-sourcing dppvalidator, we're enabling brands of all sizes - from emerging designers to global retailers - to meet EU compliance requirements without proprietary solutions. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..6fd3750 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,24 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 0.3.x | :white_check_mark: | +| < 0.3 | :x: | + +## Reporting a Vulnerability + +Please report security vulnerabilities via GitHub's private vulnerability reporting: + +1. Go to the [Security tab](https://github.com/artiso-ai/dppvalidator/security) +1. Click "Report a vulnerability" +1. Provide details including reproduction steps + +**Response Timeline:** + +- Initial response: within 48 hours +- Status update: within 7 days +- Patch release: within 30 days for critical issues, 90 days otherwise + +Do NOT open public issues for security vulnerabilities. diff --git a/benchmarks/run_benchmarks.py b/benchmarks/run_benchmarks.py index 6aabe52..8dab6ee 100644 --- a/benchmarks/run_benchmarks.py +++ b/benchmarks/run_benchmarks.py @@ -2,16 +2,21 @@ """Main benchmark runner script. Usage: - uv run python -m benchmarks.run_benchmarks - uv run python -m benchmarks.run_benchmarks --validation - uv run python -m benchmarks.run_benchmarks --models - uv run python -m benchmarks.run_benchmarks --all --iterations 5000 + uv run python benchmarks/run_benchmarks.py + uv run python benchmarks/run_benchmarks.py --validation + uv run python benchmarks/run_benchmarks.py --models + uv run python benchmarks/run_benchmarks.py --all --iterations 5000 """ import argparse import json import sys from datetime import datetime +from pathlib import Path + +# Add parent directory to path for direct script execution +_PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_PROJECT_ROOT)) from benchmarks.bench_models import ExporterBenchmarks, ModelBenchmarks from benchmarks.bench_validation import BenchmarkResult, ValidationBenchmarks @@ -132,9 +137,10 @@ def main() -> int: formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: - uv run python -m benchmarks.run_benchmarks --all - uv run python -m benchmarks.run_benchmarks --validation --iterations 5000 - uv run python -m benchmarks.run_benchmarks --all --json > results.json + uv run python benchmarks/run_benchmarks.py --all + uv run python benchmarks/run_benchmarks.py --validation --iterations 5000 + uv run python benchmarks/run_benchmarks.py --all --json + uv run python benchmarks/run_benchmarks.py --output benchmark-results.json """, ) parser.add_argument( @@ -161,7 +167,14 @@ def main() -> int: parser.add_argument( "--json", action="store_true", - help="Output results as JSON", + help="Output results as JSON to stdout", + ) + parser.add_argument( + "--output", + "-o", + type=str, + metavar="FILE", + help="Save JSON results to file", ) args = parser.parse_args() @@ -177,8 +190,13 @@ def main() -> int: print(f"\n Iterations: {args.iterations}") if args.all: - results = run_all_benchmarks(iterations=args.iterations, output_json=args.json) - if args.json: + results = run_all_benchmarks( + iterations=args.iterations, output_json=args.json or args.output + ) + if args.output: + Path(args.output).write_text(json.dumps(results, indent=2), encoding="utf-8") + print(f"\n Results saved to: {args.output}") + elif args.json: print(json.dumps(results, indent=2)) elif args.validation: run_validation_benchmarks(iterations=args.iterations) diff --git a/docs/assets/apple-touch-icon.png b/docs/assets/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..42ac5c4614071d6f9391efff12a95d97ec1b7e97 GIT binary patch literal 18577 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 z zA#<$M8?H=&kK^Ba?aEHL5%~7dGUjINknTJgj2pF&3$8utOGvqd_jVcvL;v?({afD8 z(%^)TiKus!WztKK8|0iTDtBEQhfqj{D+mOCWq!`cH19%fhyQASwWhjulCo(5w8> z4??Uo1;RWaqt)=Zdwi1TK;dfd+RE?@{EXS~7A`E(RTxUEmn19edXQRb|P!9Rda zt-{`WmY(D^owhh~@0zMzvw_g+rrp@RKR#jjYCLu*@6q7xXvKuP=y?9u)?sY6=t>;#Zy*Ok&env@lx)IJ>xZ-i$M>Y>1nN*VQ@m6sgc@yiD{XrGQO!57ex1u$#0I8j5GLgmSm|FJV1=X2L;p{l8TQ_<|H}#x zE=BNm0Uzi4(^8SNExuGZAC6w=e=FQ`+7l+PSMT|tIW|ef6LC<&83rMT;oM^%+B-YH zEQDvU`Vg3VcUSuUB?Muq{?CK*Obh^ky7}K<02M%S{2phEyW)U~Trjg-FmiyRA*AFz zjhj|1?T1C~oSP3)Z|_L7PF2J2yN@iN@2^x83bjBy(!_Q6dpH!S4GRwtcZw(q_aSca z+EU*+dMcObYdHy zt6DXiXOE&*ZAq<~sUlo?6@*ZVh}ly~lxvY1?*!*Q#`^;QZ}9)|T#UO!D+2dmY4TJ~ z4(Ek-IcdiI0Ci=P(TY#_?bLU#?+nUt8?!n@uxo=IxJQck?-Lt}!u28KEFOK{DQL&q z-k)Lx=DWo4&BVj!`flG{dZnYE-)~rXc3LPgx4_y1g_xz6S3Jms^8u6(7rd0q8mT{e zNbsT4es|_Yd6wlbufZh(KYEQlB5itFy|P7hw}nIUxyqG(mrz*(8{GcP=0YZ;2(nh9 zWgdBNM+A&?fC+%=2c<3um_Ef%9c~8 zpUmp^4M=Tpa|`e;Zp)<@gdVW^{&*wBOgcS!7GTEKKTE(BGb-~f^4 z4XU6`MD%SV2j{iPc>?0?6SQBYh~A2JV{qga((q~##0&L`_-`rVN$TVjb%fkovAh2& z|FP!4H%yOnV~Bn6P|B8+ZPbhjD&}~NZdd|>`8jNdQ0&Q zX#8=F%x?~?d}M(T`N$5AogX1<@Ru99FToKWNMEz?ZaOwlZrRm*{F#*GVAk>pwSEhO zC38o8SUrw83)>-IXuje1NtVjnn9Kj)n+%2e%V7;8g?A=M<9>KS ziSxVroau4iNVh|)BBd%DUArh376ap>ZOuznY&KfP=0Z$KhDXej*P0nud^W|-i#FQ` ztvd?`2dE@poK|-#e!6ZtEWiA4x9J)&!P7m(SCRAF_*>pZi)W%_tljkthgDrrK(|Z) zH%l7Q1o}W%s*)!Z(U~htIwXV!I>nrrGg>@R`SR@mquXar z(zGVQ5WRL~5;p5r|GgcFw+?r}NL`TR@b=>Rtd19&Lc(UBK1M(X)gci2;q;J9XPw%k z;09DrkERYI^9_LgK3i#u{W2x5&Gx(&(p^Q1+A0%eaP%pg#hS(R9~~V*M2pNFr9s|G zS7mZiSK|D6{p%b) z-LX~)7)~`chu^EvGY0CsAJg@-yCczLzkLa@8fjDMxiYFk)C@tgPIgG|irx5$utkyH zhl7)xdbsLFR?ed!kW%9U`iTx+WH6D~GG4V2+A@O4B@=*LUeHBO?vWP)$K)k3X%7@7 z&M$X6$I%hPem$9IyR1_`yV3>2nHM}{S2(%v{jdw^sT~v@*~!o%(DNISEuQQB!!N^l zJ!U&7axa~?0~KLzV$eoSd~K%1`Z2|r)*(c^VNOC4tq`4h{0nj5YDq`twN+X;zQvc+ zo&jZWYj6Zkxzf5>krWQsXsFRwp}Wog;63^z?{v4NX8_{#U?&_hQQr>BBjb6Gu`bgJV>`UgjfxhSGT=P#S zHg*KO-LK#938gHiifSTWJ@IqRY~PWcZdB%&Kv7>X@C03N9a_+%dku`lEAS{Vd64Y| z-HP9|i+k+LN9nk|aBDnPm3F0djv(^vl}^WK?9h;GGtgV`+RRY<#0R$;LT~@(v~`1F zF>si3IK8Ofj*#vUV)PhRJzHfYneCkBSNl=O8Bkr>{ZXR|teR$?YtRtX_cO%Xd3{K` z8R)8g+3D>27p1W6eZyvBCE618C>rfCw~YCqP@{2`G5|9e{V4I3ecPoF)XKG6mAw}q z2=;kURO8@tIPp0dSz4K5ehHL31+a7U6RS;rZEI^2cMPLi!is+aBc*wE8!ZCFRTSjN zDgJoDVbI-@f+5?IDO(3Z(!J*2rl^-(O61HXml6+wvf01_5?R~dGD%HpKp-D;5=2h- z{{^mil=utdt;A7+V*6IX3)1AUFT$JF>PH0<^_2A9UG&GXLR#Y_0EFwZVLo{{~k4po{?z%nu(+510ds^_#LY-`eITPF zVPUH)$17HD&P9gh*YAE#KNhPv-Bu}SX5(SWbLO8PTgw+L%6|Lu@6+zPKc{>CnqBel zVRQCRme;eAr*b#&rX729_TJs&@Bh#7XIQ>{`|`5PEmK8fg4oWp);}{$;Ei3>qa(T2 zS;lt$-RduIB~tol|I6#OJiRskQ&yUoOu~)B8@u;1C&+|`nTB1?D`9+~*ig4aBhKjC zFUL7C-%E95eqR5$eEu)Bx}M$?wOcT}b_ zt@6;=6){6Zh+*;as7bdb)EPN?pJZIFcv6?~hS<~w6NP!o%EGBm_imJb*mY4gz-zno z$uMW`PQ~s2w`~i*zJA3kDUtZApBSI+xg@z?;neiq-~S#~KOe`(=-DxMfpTV<^91Ji zk2hI!UAaG9@4ZcLYj|SMomgzF|7>z}R^jIJ&1?2$?W}5?E4zKh&-Kr~ f%{6{;zH?o?`OWPq%N~Yp2St{rtDnm{r-UW|lLzG^ literal 0 HcmV?d00001 diff --git a/docs/assets/favicon-32x32.png b/docs/assets/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..8b379310dce20f84ce730f16410385c1b71ef1c0 GIT binary patch literal 1410 zcmV-|1%3L7P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91AfN*P1ONa40RR91AOHXW0IY^$^8f$@>`6pHR9Fe^R%>ijRS=$;bMD=x z+k&mJg$P0<;fJ+~(HI{w)F_H#imwK-R0SjA9}N!$H5d|!FXRzkp@3Z@7&MK7KaAmF zVq&8F5fmgON(=}Jl=K1avU~5jXO43hO8C*tAOq?!FRV0|qeeg9W!5ikhwFyPV?vLu` z_VV8b0_3s)+_H3jru(d&9Cn}`opU(aGCN&{e;efFs40?I@&h zw3KB!Al-erdg&+4YfR!p2!d<4!d%zmk1cV!_ ze0*~xTpJ$e4!?Zy{A$Pmm_Q%D?reJTnW_#QIsH_DXc>gO1u5EXolA_7I0=zpCQ?%8 zF;KKCI@fGy^B2Cl=#c81Vv*B`#ecAX!gdHJ%LAW^^!>3nRxG(;1?hc)zZV-+1 zmU+(@_5A4dZDllKa0aYpq**O3o(aNw2<>_(^4?+H*wB6&8Y)h?2Cdw)*6<`~Y*fTf zxrnU`9YX9Q!4er@2`;JzNX1$uI2Hutj4R5Y91FC=t5*l{^kI3%yf<%{4TI8hD?uY+ zFGeCIh?n$f$qX<$IcI=FK*<_vDmoDu5v2e4lFY^~nLDa*u8<{%tx#L*Y(N;7&&b23 z&)B7jL$ov-C_h?%hRxF~XrHdU}u))c}mw_oqI+AykxA5OC&jO;(SVCVxd{FCb*($oF1)vM?I)WK9*WAG!BSzAFa&{2d)D;!PNYXRVEN=1x5-_QW#UHB)Q_YgZ51|2VbanYCr}zsX$10=C$H_O*)YOA zl=8P38$I8(szvS$>83s`kpbBw$3DIrTDv}@)a)Yja-nu>d0FNC*6XC8pi>~O;G%G21jHz^R^Z~9k9Q?WWL~7h^VPN@ft-RMQa8n^HOf(_= zlyBR3GW`9Qb5(avsO|{NGV6G(_LKnTmCo|oGn-m>B>9f#`YXZ3{prVMHc5av9;qdA z4~&qV(U$4FLF*Bs_&q#d_5K35tn}OU`qjFhj zYrEu-PDuU2)W88;*jV#WbLLFie>y%FOlZD>otyjp{OTXh;EF*8t^x!91F$Jv%tvgA Q4*&oF07*qoM6N<$f`qw&{r~^~ literal 0 HcmV?d00001 diff --git a/docs/assets/logo.png b/docs/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..12e8be828ee7a03d570f5a190a855b28aa8f3ed3 GIT binary patch literal 1334983 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
hw0>j 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⩔09v> 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

ZVd&2m#8v|)FN-VS443@1 zD+g&MbK~-BJ_SPJG|~tx;>AKP4H?MsLbh-1n;c`TP~Gw zMMb^*1d2WG#?%SnCqoLzdox7OZF^X_z~MXY6=HOZBS^GRQ`)wn6c!fJ%Xg&g-ugbN z=)iQfq~4M(dgM*CknMIy>GBS@xKKq6SgmMbf0sggVU$Uwi<|D7M=xaLB;4Lda%H7S zr`^aM%bHq_{^DIQnty!%Bz%qB3OFWCUllXn>q}I7xq!UTteAVLmD~5bCRG`I!JN8- zK*4rZIg&11g2i!zM;8C^rJft~QD7@&HY|OaNkhwfo2kClxg7}tAEejh<6Y~eWI=0$ zHT=P7f8p4}?ITI$prZOquZ@s756e!3zmg7m1B2L*Faj?wckNo?FS3s7n1C` z+eHrS8%HPenc>jzslK+C(%&a1=Z?ZIP*3;WVc50N13wMSjV}K(tB-yjOB;`KrlN6S z2oSgtH`{hF6$|}T$^@Za$xbWm*uRH=7ecWe2@bpbK9+f1_=pQTwnDX)&p}o( z9;#@)O|inX87Z3PrpJPtuQ^tGu|;bTI>-T&XS#kbaS`)ZfOvJ<3aP8!RzF}DeeKOcl11W;skaPD(#^x=^YJuNMI&g*&0*LlmSSt8t-*Jg+fN4xfTT{y@VS~fqD9 zdPwq(Z5Dz`fa8pLUDtB>%OMUf%=kj7;UsR{j-%&cR(ZkFLnYbnY`c^%q2(*#_sp1Q zK4YsS<`Et2e7YwuX7xAZOo9_xEl7OEDP{?SuT`YRCuffAWXE~P+Ik9DPesu#?KS2G zGHx@8?DT7bb)l7$+4?PmpN-31w)I%Ipqc$3$}uk!qL9%*T~mE8I8|A{wiAn@o&B<| ztg_U+h3xiT17!MEprj$r9-2b_4&;kv+LDlCoM`})kNodVFZl*#1X|x6jF0}gaaM#z$&R?B=rfN zCaZinnIpY}jYTAcXecTZj(R@+JbNeq;G_4enm}hJxGdn4enPL~Y5q|{=#H2fd%KW! zb*n7m7!lr1Z$Ez2rb`5~p3Cg4X5(H(BNg8XX&)Pmeb*Xv6+8)&@EN&KXi)7%C!~Vi z+xk|58(JDX?oN#d@ded8%G-YIc zViB@x5bW3GKvCaqQQPI{=h4<}#wR(FtK+a^2>7epW`0fUY*7m1^bHGB*&PDJd@REA zu<1p!_J05BAR6JMdINt|H9rC|)J_zHh)!VK5Oo-Mb~Q7xncI*Ipw-(c+;?AD!hy>Rt$j|U zR$zuR!G^E(FtkW5N85?!<$@zV%<3RX<$A1prOFjFS>cZLu@uK@f+|5Dxgg!GIsx~> z6Z(_lRk0J@l(fxU-#NL~y{E?l2Yp}}sZ1V(Or<|aE}hTSIa&|9D1nIJ!9Nm9Pl~hG zwW=$~ND^QwyYH+n`J>E}l1He#@R_Hkx6q}FVI(4`r$CS)edKM6F$9?bLR=?2==q|k zf0LG`^KFw|i!^d3c7}f9pnjc_72)pi4l!x7lqSd{Ejp7P>N9u03R4!=$pg|D#1h-Q zlQ?4HuxCp^-WI50#L$gKilJUR6f6hv33->3dmFlAmPf;3GMW&K36*A$A$w&}M8YBa z*gg%rdv7_QCMU#OquQwy-0Bdhxo$ zus-?|jQL76&njXe6MK;d0SMz?6Ihy9#3gv)X+Y_}AR9&#mzJ>J*_Su*psVxNDh zNvf*65l}QAVAsN-mpeyGGK6VT%Vm$Q_vVDZS4~DR9(_k0yt8FlV10L~- zD$k~V*s^6lR?Qx2f`V+pp*wNOh zGwdqoj;}n~Gk8&aTA|obeo_k$|E}a|=cvEBto|{4zi*RKI^|Jumht0$p|@?B*;`X7 zh%L*L=5k*uRRSkeXrVSp(R40rQ<%Z6F<0@!Oe0@|^+OKO05|v=Z}arF;yW5_gRouc z>n;#>B*#uQWL9z+vKb=saktN`dIVM15;)t43sM!VZFEz(sd8EB9iw+?w${58No@af zk-%kcULHpLvq0Ls0&&>*HU+jtq?+~IjJO5I(Y?_%CTF&?@lPin4_JfL(R%3k-H=j= zyNByLN@M-VsxgFMGVMkx!Xa^{*ktZ333UD<;&_JRWRd1p2(Jj>dZK>gz5!jcmqp`E z=b6{wYkJa6S^7q$rN`F{ZTi>QID&fW2KEKoll5o6ruekV;7pd!j_)sP(tyI;I398| z_fE`*c~>f2UFV*c>zG7D_{Q!?g*z(tSBE-U;|^K6Kn^mmkWOR_$G9c4H8sJLyS4U_ z^V8#FdX=S4O|o;N8nxKP=yEbJ!qEp-Ub=f{L|v%dQ#c1xvFF*|XPB@*#xCez!qr>A zZh$tSw-a=U5K}oEAyYqK%3T=i_2@IOE861Xhb(U>Lu@+J<^p9B+%pRL1A|p#4oc$9 z+m~Uurlvrj{>+piP5p&P@k-%v9?v;(*iH+N;(S17f}DnsEckPwf!zFbof3<%_ve(j zmFEY$tE0{l9g-{#pN$v#b222+bwk)BM5VPvj7QV9djH=iP zxjMG+5IpgEX2-c^&BZ_?r8Zj2(x>UMX4MQ&c8ZsBMNZ)nq!cBWYtxG{{vKl*Z9KLn$w@$`|2>vuhEZ*~C@vdZF5%WuS*rCbD;;JaNE zE7=4Lv$1=FG8>sjhfw3Kt4-Vfi&y1UzZVl4kaWTq(5s}0G>7n8t|q#AZ&y0TUPg_6 zLHOEjZU^JqGO;@hn|g?*9?cmI&(8CCQ0qsV8z#MWi9F%L5tYdphUVV3mf*9+!XPqZN0eH9s1uP`qAw& zdz34Vi|ZAKR*I@}&4+!5{{44PG)7|ANcHDc4GZHIxY0Yg9kC-|i#cwzyW_Z~pO#GL zH~zh-mb1?kFQ2WH9!BPeq-h1WNpf3BGpg;i`_RL$hPkhOUHfXvTG_9|mvLC*h?XGO z-SQGoOle|Ej+I*}n$=Fek_D8VS_n;)qx+6c-Q4Ltu0a8EycYJM8mY-N%bPxEn_2IR zsC@TCA2gkjeAD!%;$D|+4Eyq%@S&I%F4v~f<*H|1Ph{htC(b@atv{=p9BqZQB})r_ z=v8C`Z+!fh9xv=vTFZ*bS4r^3VXMU*Fv~H(ap<;bE<2JKWGme)WRG=u6Vq z13x#-RtwqfrHg)T$~&VraRgcd(huw2siz;&^$T{q4M& zx`kgb9^=(n`z+guMu#8b2K7sgf!?;Kx$Wd{%s>8-T`3Y>T=6Za;=WSPk#^9zrCM#F z->aF#y@U~_g)5V=Vbow}=DtAWkMpzSwtp>nUTn5R_suJw08*_SHQ7#{`Z~EzCz2og zC36^YvQs##OpG%8x6cAH65SCd+y3QdwUc7XSuZ`k2q@@5C=ClQ`eIUQk3Vm@O=MSK z%Lmrzj~(Sk=#4L{(}1jK@rAI2i@=I}zx{s3!x_#K=|;l7d=%pjbZ9ElwB(AH;Rg$3 zGbYj_5(YGL3$g&cqbav1-UY4om3Mn;r2#Yh=S^U6Hi!=umnw%Q6yfk(|1Io8nHvKz zFvp`(^f-Qt{VN?6fXbT37y=_`og!+-4$D9E6jHo6ChIX8eXpoDlEen#^?=%`0{cR- z%KJI~xfjl?p{@P46P1b1F9>!t$lh(-3mvy`5q z3)|3zbY#oKA?6^FQcNCU!w=M=*4)@PK)y^DvVO)!5}2#XhflR0u%2D7zAavelSah( zgxRhDJ+7XYfmjrS#g-nCTHR$HfydvWQs5RRJ5T!po+xW+3xrE~_Fm&4>+Zhgfu_#O zW9`&^5T5qpQhLM=h%)w%w2U(fZmy)l#;*lB(VXw(2yd%h#Qel3>9U5sObT%;#X) z-=1oskY)}Md6k;7B70v7!1KIrXqS2tY;UTez7^^GH63(gF(%o$BG3ob9k;9DZlJJq zk@aPL(zrV_CDnUCI$^ZYa7YcYIBV_sK#gHCj-9JN@C8fLb;1Ect3LURb;9Y8^}!16gmw>NAN;&uh&8rfVTg(!$$PVQBKza(;(VsDi}Mj8 zP((6Xc$#a2u67!qgje5&;Zbm+wg5^wpMm^cW~r{$(=c=f^zl7!#Py zy^c})&F8w9U{Rxsk8vBFIXtxgI`VX{{4TxY>5atoepJ`*Wh-jH&`Y>r5HM7|>_eA; zlj|(D3h>b5pp@kG2kXZH1>+M1 z+euhTc-9K%^Jy`j((!7myt$ps3=GEgz5 zm97v8ZwlLb*P0#y=&^D0mYZ=?y*YvuK}Mlfhm3 z&Pj-;h`ZlZm%*^LUWi9NQ^~+XV$z7_*rTjJd%4>Px>@kQ{nJXg2HWKUG^!SHh<}c& zcO_-22gJ}v2MzzETE8uorL@{Qm7kTi8;HGR<5AY1y@+zAtphr$bTrWTgl}xJw3PgD z4}||;<5+tsFLJ_zyyZYhs{GV1Z)vsa_vo>^N|E`2H|cUsc3hUvgqq~_&HZgwxwlZ{ ze`z+d<|C@Q5shnSX!`dH-(w@oGrW7fQ~#Kf^BirT^)vCLid(> z;i~$Igb^z2H2#j?C2MaSRi)KM(EXrczg%%>^=<0iosk~VVb1gFJNb8?()7Qnn~3@| zJ=?T2t%-|ukefx;vEO}WaI=1@1*nc6m42j=BIu|dV;(mOU)AOI8>6%8`Wxa}Vy&oZ zqssD!fny<#o|MaR$!vo!2U%ZLieTL9Z zRSqAo4<5(xUugHIVAKvuJXAaw@oZA&Od6P<~|CvS&{Jjmh zvf}}Mb3oArLEiC`{9(vtLGzEBo^#cG^uF}ypHazX!J*7dnXW8}KZl;}>tOeCpf3pI z9bb0%N#7z%a;bNEe%TBs_I~FXUPPqu?Ghs@%DT_(Q`rwbs7xH3QFr$F2ybusx&FwS zKkLpb4x56Imx$hOc@k|ZvTFX@Q0sxPGM!k8!hexD?g4vuxV1T=9b+Zk4?zlh&~{L{ zQT$uXz!A3rvuBSpWOIY719y!#mV;Y z$BnLST^i^FKSvnjI+8N})s;@Q>WTfqR*jDj(Gk)DjGl>HJx4&^(m+|irN_uTO=uvn z7iH1S7$)5f3m9nrCyx$ao~4o`xkvwzk}h?W37Bv>>$Lt!pHvnn0Gh_@0ltw=ct-Ql6YaS#XCz0SEjKe$iouyT*h5|z zom)Bc|NL)Fw;O(`dx`jv=%#t`cGK6 zx11JUR@Z^8)-{fxYL@yJS^EBr1-6xS88NefizQ$@F@En_-|zwvEOQDwL+6^T?lS0H z4ixj{Hej|{;9z4AwANbXQyxe}ChK=)W%GP z%Y0@j`^?wZ&-cVMiidG+wYfJTX6F3!ZCZ)tGkJ$T6u372lbK_)8dt|@x1RUA9At#{X?j{FjMRo9 zD%0VqZQr~<9e^8iGY*9|H_Mn;9_WvifDKoL$5tm;II3Ikd{O|aB3u^5$!7bLz;lc` zZPfJ1&03l+CE%abbv^NhdLQ89WlzdxzfMxU{T?vuI!n6TMl?J(c&>4eD%3Fsdhrr# z9X%Rh>0@*8o7~pYZ)p2|y4%WjKv33W^Q(WK>p3->y$jU?M(uGEQ*^?Cy7sL^t{aH5 zF$arTPZ}mLBR#Hzm%IbY8gI)B8)8gn>}L43mhbAx(}@K0QoC>;!kBj;D3G!C4rZ-z z1p*t1BgZg?BpY9Tow>k)kB~jig@22i^-VU6H(g&aTq#Iev@B!%=zfl)x zPF>2%F|FaL3$?71*+O!h+H%0TQXx_(rpqUEHa>2JUm%%s7UhrheS^qR^;6`T2c25i z1~{&b1<{P-?H$A?&M;o7Od-fDVlSsGm>8QbyqwNhN*Eu7FeXL3GYb%CrcfoIu0VAh+bw|H9<^qwOcR?)nK?UMvzmj;y`WS1@bV zNtC&#oCHTtPNwYx@z3rfhbNi~%nEt;n5$j22J7w*-O3-dotrK0;kAyym7ZUbkNEYb zsQ^Tg2cN4Nr$EreR&czVaDN;?Z(yE~tc84(0|7oK49$F(bgSfn{Nqhn|6KvK9qr!a z4;1eqTfC2cA8D@f)8=vY!!(^ZY4Y~43o)PJyFU3l)MST!@+CiYG0@x?L00}#uJ{pJ z-lb)LM2b<)+;l)Q$dTCcP}exHO$%fFY50e!MNRDcjs9L5W+SD7Exx3Xs)1YQ4Cs5v zrso^R9v9JrCq$0hr)Pl|c3P)M?jDxkz?JxBw?#|MMeWx8&bonzM!$Sh*Bab9Y=d6? zLyvge;~Lp$?A5r)m{XO1zj3X<}{4$P_ib;>WjmW8dj@0TVBDkvC42K)gT7fsaU07k_eB2ajxGd@Oz9 z4Nh!~B85aR^nP?<%8y4j_~{Z{(mHdZK|4a-Bh(D!cHXn_jrIO_%*FpskA97OJL{bP z>r72J7P>a}+eBi3s8P9Eqs#q2vP@Jegl;n|(#%9-K0L zmOGGQQscR#e;03Jndz$Pugpl^?X9h8pKMy)eWa+e=Zq@juU;P4AtJ%8&MXBb1<(zJ zTl4Qi9fVymaOmW{dD;u3@{!fPS=r=`pi99WHsFed<`SJd;`2!xMn^NOc*U{H1mkwP z_0GB!R=n$qHMIn$B1Mkd#q%XNT<>z5Zk;cReu(CV#G|ojQI!H8D)O5x1ub;~D53lcmZds+v)$4C5n2|i}A3g`+?tc_vm`9%7XYAer;D1giiCvmK z2W+@;xO@3QvgQ?l))IcX5X1gMUYJkH-DKTw4$gfQXF`DdF-dM~tAEj*xmL2xh~G5L zg{^FvCABLfsZ4#47lU_qo9e>+8 z{&PM`L7M zj&GElW!H`%nzx^C09l`RDD`}N|A)y`uN%7V)4V}cqI#0b^<}P&n(0Ebi;m3_J{@hY zx!QBPo!naYp_FrsV0|J!x4~+e@-JPqtc1{PaOiMjSREmZa4j{yXw#OJpcoYijS5QQVG zRwHo1@oqSQY}Y|2-zO*cHK{SEr3zCwrip;#977Yl#$Jt0!??}_`4T~C^`8gHGl-uy z{th3?&4;Us=n5flvpYmII2IhKvxqif%%=*AZJA=1FUuSTH&8c5Z&F%mqlf#oc|HHV zpGUxjf6ZfWnpQl!u3;TN+_HatHiKy0-% z;=&DE54HBehX)=qm|Aw-qT|EOlRmi}54-mrh^c>c1Se_Y{zt?$mYk(mCS3G~F06K@ z3j);RC#;K|U4fL)LFpCA)hr=i_@eaW`oZAU{zj$+yUGu2y;kRN%}r=Vi?af!neb~s z9d6llJwd#FioVp0B_A|}h2`8uEq(y#xOTT}HvdM)693_hWE-J2;Za84rKC-^mG{)=`=<3VQcXn^Z%KRN= z{iC$<>twfVLs=tWk*$@JSk(1-uN&}Ar|SAEQ1GpplfXg?eNhr?x_q`#!)@htXvZiQ zFC5U>*?x}Pnwr^>QDcsYAxacXESz(XhK(jwnSP*_4xUYtB{hrx+!-6`eIz`5*BAaK z&7bIz_Bb z!VFuKgmm{s&oZME2qS!tukiUaLMxvkj@|-KRB1pOmD|g(l&*ZG18340i%PlPmu-TN z9CA&H^&Lp0p*wsO=*{5rnczNjQVdWuED={)UHv8ZD6T4!y|)g2eI)6mt&GW<#tPXV z*k51qDsew&wV8#46ye&wRUa+>h=$U)Y%5ox$&Qc8}IS zhXpMwQ*%tj67H1;aqEfta(yXp*7^jk7N9~6g70T*BW|lw%litr`2(YLrCR$3@Zb50 z8x;z+HPQb}>JfZ!^2McnAAHTABO%O7DRJUiU*i;}3wxv3i)`f1c#d(k6Ruz4iGND7 z3$5QR%3l7wBTt3wyhhh0hkjKrz}W?cClf?oLZNw25UohpigMYOCYLUN!9JULz2sB1KH2)X8tW56Qu~KxV~-BZg_&5%t#mnVwf=9 z8HuoTcRB}fqAhWeq&Kl_P@Npsy6ZPWD+Li}OZo0DX^{GsPB}_aeb!D8dM%O(sI^QA zP`dp|G(W{b&d*2xH!JIt%x+AbFP6Euk`~C)Cq0VzoNP*J!l&f6U3x8zL|=70xo$8m z%-K5KCcWH!y(eEOGnu{Yow!%?S_nI>4+GLbvu zRB|O{@W%dEv}>xwNUVmj{}eCwJ6tUR4upME*Ns$uT6rYEIc@{@fP#k-c>21Rp3 zZs6sG#prg3B)trR{ukoCadVmm74|Nk#XuOdn}V0AZXIXQ)7>sPVcI#s`g-hh2T`Op z$A5H`8gmyY@A5Z+O@G8N*@q4z?<21yS?-+WgdA`WOQpxyK*Q>q`)^7h;H+o;fWgf( zo0B~o#uxuB<&CPFZJ8s`d(Iv|s>&zkpO`1TMm%S8CI@Sp>|gMo-X0<;TZZmJ2}`|Q zmN!_~FmOapz^EQfVJP%II)a95IyMWHcSNPoYc@*-4S>Ei>t@yP*xTzh?s>gEzuOTv zV{PCr`5qJ3b*=l{I$5cRhNp(acvkhy#r;E)I|EN4;qOgQi{qUuBY)#wAaWD`LRBB+ zTi#XKqS~ZlL~-2Z89pCh-s%-8>L7SE?LjS@X%OUk`j(!gDDYbCU;q+v-PM*cc0O=y zC_E*6Ft}JZ1jgM5OtK2TP+OI%=|evMsz5s zOb{4l<*vb}ZX~8k0&DvOHG8dyouh-|tGx^N7oB!fgO$mf?Wl13W)la@mIII%TXito zK2KZ>vzvGq{ZFCY8JnxTtK>iRI0;v=PAMM|NkLxk%Z96cyYx^9o9WNb@WLRDTO1#j z`1A^r*$Zi4ycn(IFcKpah5J4;Tv zjxhfy*flRBRpD=f=E;;iE3pT1JnQs56JogMB%BXeCF#I!vr^x_+pyF3|cq(?eD zi=P`ldH!i;kagM9F%wI3#_0SYUpW1zCb^fAnMh8AB0CLH|)mh5n5?hIu}<{mPOZts7m{@+J0&^fo_?RNF&Tz zw~kIWcnv&BGdelo60#;uepDH{*CC|wi&5C~b1!;Lf&)=CX9IV`gOHz1G%Y8F?n=yR zEbb`i*aB&XgVXw{jTPqi4G&@Pa9ZgACW7R^0<{t00oCD{ZC>5v5UksGj$6k=5zFE` zD00ZAF<$)5k);Hh%o$nOOil@02BNndpszk7r&`J1v}CuWR@g=xE$vQu@zfC-6zR!< zTlX~H3}e~$N5mHkG?YyRlQ&7YD{hO@!AQ=7rU@mhe{Y53g}_koyZ^C3q1(6{g2tIT zoh){fKlP}-(`ALCjb0OYwd`R}Y&S3}4#g6-O<2CeAw(33(S_&P+o)wKI~CMkM+G zX{+X@yfG#lZ04N0n`A-qk>`wBdrs5@N|SCby0lFT z+T-a;-yb#X78Hg9BB=@goli@Y(6~Lq_JdGWKJcP(cY`@ykN1c#;u&6LwiDKQ z6H(V$^M)*bz_Z`C6x$nNz0$B)>$N->-S8dq<5!%}MBz#~7<9((}}6 z0E#GK*kGndx|&V*c%g&!I3w!hegb+reQgA&U(%=N9UK-kyxzld7Y<4bXPuW5d3DBs zDCpaP(~x~Mm*YM8wK}y1iJ;|on$x82-EFRpDSvRGEaVz z7WKu6eNVXmT`8O2Vsoz^p668425((i_gOPYx7K#kxt3$Yxx~N3(~)*zUXHXHwi3gO zwc3p8X6auG`r;jQ#bN9A|3U9pms|-Nz`W<=-^9EZ=GMt<_J-2CwM!Y@_VhIm+^rV_ zWL@fjv9Z3EKcNbq|Y1 z)-E<#`5bKDQsQLiqmx-b+5h3Poejah%0!ss{3a{S+|=QdHU#E#usZN;rIH09!15Vs za&2#^kv=LrI_4$in&p9b`P37frZj2O*P&rZ8GRl&M3E;9zfk_F#XW#>*9q2RG!m7~ z)@?p=ME{WI4QMkjyX#6y@ADnQJLScwqdj;LUf3_uT6{cXyKq)#p*Z+Qa&@_#52;Ut zjg4b)Hd~4h1Sfr70nZx1JW`O(;-CD*=lfVz3xjc#j_m7sjE>i!q4ZD@CyMR0HSx{2 zFs$!*sPq&rTNkHk7rve=#Vn5cwHWb7Mlm~3p5$C0U#OS(TFQ6`idQzjrKbnHiggIJ ztFN4W{~E_ZD$_)upSds3VzS$Mk=W^?UR_IXV?Wzp$-A;IH@7v`x_=GIqkcp!H#%3~ zGewDf%S~TKAeS-DlWoNOOE?XJjcMqQ3`g6_1;oe30VVa|q%eFSAJx_rr-%r7OKESJe82S*%9=q^I%^62v z3Ubwt^agfWG-B|9)8xJlnrBs)=IAl|pK6I33Yw7SP=aGPeBZ~uR`2jH_$gyzUl*$Z z-3Z8gB5}DN=lCw$_&_T#4~KrKXnNQ&eyVGO{pQ=11XwN-Q^u=}M|2Y@b?ecNB;uHi z(uCf2x^-j*UePWtYj1~#U|(#3FfAx%g{F$Aq=%2OuoOdxfwG)Iy+HO)uiYp=cz#zP z{{F}vP24q|@Up(u?LT^ffsyD?C@IbA4l3WC5ic*3mbKb*Na>ZTs0;Y(d45?|9LG`z}!D zxr_!;6Fs^fWfI@I5-|}M$1Z2_`!F9Y@^~xl=nIp`a`(@&?GJvRiG%-e`^eE-xVq?% zrz7%b-y*4=DJ?5RhcGLf_=1f+D=SkG9E|1o#s94y1HywuxlH0#eqm9#l)kQmJVw2C zk*P_|-1cWOI-Fm#;^6+W%2n2MQ09nPrR=ysW&O9SA<^r^LdL2hAua4S53>eyIy!EH z=5f-s&blK<#O62~^2~)0>mT|Kg1n3y?*%hl+tul+qXT-~5Z%MI8X9(@G4ymr`x&~c zP~l)QMT7Pa|E9Oe`Da95L+q0bdxpMiY;PpAOq;kZ0EY<@ukRTl)gR2|_!;apapU8- z2L?R>SobC^|C@^Y5yP&vH#z{)Cr0+)Zma6DgH*Dj&YK`aYcU(zmuPqW6 zSugJg`&6uLeSqYV18tkO7^(Uj4}K5xRvZYK0h&aQ`?w!wa#5*_mW_1p!MG(2sw?Fc z#9*XSxP3CCqfU|IMIe9urVPV>sh0oz%d*~_XSr$4PdzckXL#F7aQUnd0H^^l7?~g= z;r8w|jL+NPJll`utwkwA0<#wYBai91#OC-6o`VTwxN-0M zx|Os5X_To;I&qBB`!Ubcy6wEy0@ED9;k@nf(if4r*D(5==(^=S4TEh4Imi+ZHVv{t zG5CGzqa<{HLht@PQhw(iH)1QT&*M|}<5BSKk?;15yI@Wrs_xhamy8$Ml))$~w`BA} z5mX!g#c453a8!Qc6>MxUYK=wv)+cRWnn9GE%4fVN6-ch^`!qmmDS#rAc>(u%C6%+w zg5hj8rgq!1^j;OX#*IftZ@UH0y!43%@l?>&BM@QD-tWttq~O#<`qZNu)T7wJvMikD zF6i?+Wx1FI;5gOh3##eh335I%VlIvSYp934-+cVj#z7Di>KO-h7$Fw{LPIBJs#{r` zFE_G0sO{@B4+)4z89dv@<6Ies1{R^aso4)&<6CiBA(cafn1^1KS;|^a{0H_cFCe38 zqV$WWPsHNyR?IYD5^DKE;Nz|%uKO9X*{{^823ZM_e5BhUiT8j=WpMps0f}e-3p9s! zR0*>7L7=Q0@TDw{M_2$-5U+s|dAAX!_k12C55P@|g!n01(8QCnYImNscc+k^vzS^- z{W?oJ(ZzC4ortGNAI@33eU2GKACiCeUBR}D*y0w}m+atE3RzIP`%9C0lm2Qp#IVF1 zSv`+Yz`AY|g%!~Z>xr)>!>}u4b08~tB4=YobI#s>XOtSh@q2p|!p2MbPs2!}c<^Q7 zc9Yn~jZtpQd(?*EwhR@vpnB@CC*BXgzP0OC1?XGr>uT0{=@D9z$q zxexVqnX(+|rUILgLM5l5&J?Gfs!}s-RkbM2J5%LnFh0f9lzHZ^8LrN+s ziCE;lAiYm};b<55&L!{u!ZLJHvxnw#M~5#u)D`!lyH+s8e;R_tl5TZ7j;H7HAVhdZ zv&K0fqNne^Sco8Xk9)BKOBOhRZGr@jWBDg<4MZ6I1gb z#n3Ig_Vdh4+P2li{Y9iFX=g{-DAgz}Noq33F;}E4Ky3)5(foHHH zaPcrS4v<*xLn zg%;DCY31Q0{4RGE)(KD(K>Gks=^b#DRxc9oXvS}4c%S00@$M>H2`AUjp~!6p8|?;A zC@;KhyuKr$|A>aWv>TEn6J_ftq%#=#HRYMD=2Ak1iQlE5EH_FQ$V$2dZ&i|687 zPR1y~(Aw@rt(5fg)?`)i>ZP!pN|;0qwi-Q?S{2{oTIYL z4c+)>Cf1U>*)x$=sesQ<`I%HTZ7P5QDhnOO!wL={%ka~EKvQdDGOnUOhSv{nqHwwl zSf@+@-CWB4`C?@ z8H|}4LKiBDG|gV988G4G8dIf?wF z)uB<=VGcCCAZ2aL3#p4Rl(hTN%#RPbh!oBO{mr`VgviyM(8jcoXTzFS6z&fg=}AVh zZq!To#WJj|my`NX+l|NTcJZliqKC^N&p(_Ly|KOAU0z`xB9!;qAKdy3pFOnllMEl% zta^X*jYv0{d}&16kbVS|AAO`~fzT7k#f#vrqm}EH{v@@w%iY`P-yljs+mPlB+YC&i zUb%I0b_=bDgXeqp-O?04uT`5WPF#(DnSc2I>*_;$zpFeMoF<#w0x@y{Ui#;0(FXKw zP!s04=C>hqD{DHjb%!-8uICzEi_hV8ICw(mq#O%WDw_GgN?So)Atyho99^qgdrYuc zT4+7;VWjm%JD~JRQsX85_m5^1d)!1Hb^0;eiOQnr!85ZT@RAS*cU;r_irGO=PtJm3ZCOY&L0GS zQQ0lUBx$V&S;1c(cUN(X%ZYT);oK~a6B_#AJ(y8tWiniFko}PA#MbCpfY>To+L6gd zZ<(CfMP|aA^FrSYU{o*!d6UE(9e6p(hR_l`tS(-Y-QiHjFB(_cB_LP}*7P&s&~e}R zacHc=dTi*5r2NN|_vW|qrG4AJjn`_tFZ8jkr`{nywU7KCj#~bE?VO)$j1xRhy^3HY z;eWoA@7&+{t_l60v6DExOcJ;1{oIAuoUU?zd6ub_Ya{a51%@8qW46-7gSTFhrQ1jL zIAmFfB2yHoT{#-Nk7kv^B={)qa$DLGD`2o<(g@$BV@e&4>}VHTYLeG55x5620M0)z zP?GVCB{!$Gz@K)h6D@z>EF!V5yYgt)f)@;W7}amwFB35R$GYkJN-^okf#^8i$J&sL z8^L?YTYE~U9lT!Ecn>TGp^cUbBEZp=4|iCMOl+95w-0od)Pzv%4&BClE3iFxYE%)O zTpEASy`=m0Fv-BzNdOFUu)el^b~M{(;BgE)7;y)R+Z?CpojiIM;WYj0_%?7mKpho$ zu+}d>K4;hs`PSzu7V7?(=DYP*R4={3uHUqdAs0@uit2kdHCc&j_7!k%%SVBjMa{4c z103Mea*`q3`%~Q?rwn|JH9P>5gXJb4n`R$*O)TXA_edv>xb_%fTt# zJG4go>av1UR2Bi+3&UCfOh@`K!tgJtS@v6DPw7WB08?5y2qh60}uahM#3!;%j;FY1b(PCAO&|9CWDcICc4+`FU=%sPaNG3}{c7`Pqph^AP>moXAf z8=tT<3~6PsZ11qHd2p3~SE;7@h{Nn5(n6+QG|PAeYCZdJ`TG4mSlPGDdo0UM`mv|_ z43rim=f`AFN^QKFuJjG)o%LO_889UmN?;gZwd&5NFm-9`D{b7%y=NPJSNu^d$eqi> z>E}Boj=BXCjj4|0O`kGwffw_OE0#+N$2R(xd*%6?M(g6P87RpA(_SlDiVm)LPZyXp zWPd05IwYn^aKCZg86#wOlUpD6EQg{tpMIJkJN9$)QF`oz_WDF}@CtY4S-C|U@w>x{ zp|#v2e0b3(*Y%1Z^hTfkka?2k(JbsnY~{b0|8-dODAGTtd5z)j?*kn3`-iEB{q=x2 z(=uc5)hiPt>y2*w$RJfkscD=vPbv5U@0u%VaOm7g#OEohO5-(joih$CNZk;7U*4&i zxZ!tssofv2W>Fh1+v_ZB_A8YPv3mXA^OsI73^J(6`ysDy3(7Nh<`G~K>}tdX|TCM0RFk!wd?j5 zx7UM{yX%6(jD{DVxp(-I&UVu$`ESgKh8#tB4j7rjyz%Od-;SsY?4n_&^TtfE@Xb2! z2~<4LHWr6U_Eptnui)jP?eFPtq8(VVZ6n?NOfP(JG`#^E63`!8iM-tbGzc;Y_I z{EO3@cjP5T^gz-O*Dav|B=}#5gT(l=U}2ww*QMnF6VqxeH5)dv5qrv?#Q^{oIVI8nM_%3^sru_S2mZ}3o||?l%NpBu@uRdgBa$d{mneO5u(9> z9mnwV%Bh3VEYG5ZI6eh}&LyVN43skec{OfUf}P3Hd4T@>9FqIe^v(LSa8S=T(?-g_ z6MIx(6Em;YHS#BlMy-o1PwnITDH|tp^*HHVKwv06!7l-Gv`u3oM~OVmAzm+XmIu1N z)dl9ap}PYBxJbMk^nf4i?!vM`$6AbwYQ+VrG}2c88418WYS;q~D~6H_IeV}h*)$DZ zFzWeo;?3b7?zTce;?-<&?(~}x#Su%kqJ-ZIyl8rfhy~yG%Uv_SOSvRY1p%Z!A}NqN zBIwFo>(0pim~3jq>;7$szdKD)kqjG3zG@oCzynsF(xhi3`&j;?uuVjEcAIXJtWIrd z*tI>6#6_K|^XmyjRO(6(y#lgre-zWNJ*FX=_snXb^4K?3sgs!d$Z%**8mHCjez{@; z{+Dyle^P%U``^|noR;uf257`NL%QI`8OSH$(R@uk>eYs5DYF4h^E&7CXIznk)K}7H z(&s6e$vAJFNPI|RA0u#(eI!XNImYWj-!=@9cR%V~iiLG7j1~8K=6oaHqclSSDt_}v zD1xZ)HPA+IlNp|5+XT0CYR*NO;^!y#cE^6X_4;-`6DB3>{C^osWleESR z9eZpnFYEz<>hODDV$s_o$Tu+J4@FcGf-xW2KQd{1d)V0|Nnck#Ph)a!u;v#H&j1`UY9S5+n<&hhkiss zy7aVoXG9kOkS5{#u9pqC18S)h$WlYjd8uAkALM1X=BXR9aHa0t zlVEXHT5UgnD|vTY;u}8XsdA%Sa|tr5r*=)uw1InPFnApjP$NBY{T;1pf63{PZ=L1Q zEmGsM3jHj$!_AS(X+zlPV%SZ_fL+pClz*Abb%*@gl83dMH2SMh^k;Zf67RQ=bxo4U zq5@wz7F-rN?zk}(qO_DZR1oU%6t&h(P%}ky%%m;YazA+`eNFPyu&Zk z$JQVXHg{SJjl|)Hr1(bxenGiL&}GMHzIK5iZ3|LfH20k#zFk;Wr_!=UBQE?j3HCru zcU}ayd60&h!40YOiLsMF>C2v88&g5TgDx<6^M8spMieM zP=c*&C-(}mFOowq9?QmWBD*jx$i5Sw=}N12Zl8Q*TfV1J>%As$FY(l1R6CS!P7a7d zzIOXX5Yen0mMNun3#jYCjhzt{ui1lvWZ41|)26+&aby7ZqeCq!iJodU?V0V7=Yc(i z?d&nOVDxA^T(LixoL-S(P@*kMR;@J6;Y0DgH?WapeNSfTap|tMf4PAbqR@x`Gy(Ji z0l{a&?*FRQi7xoPpON}$!HR8UB+K|&+GfUj5QP43M|mXZ!f}-Fd%`vd3ImDb2PY?& zM3j4boicz6S`RXB{B&ukcts@vBFmuj|9uYkcVkCm4}VY@wcmJQde<;2rtdC&S&okkV(cFt30*|_d_}7MEqJq-YGeUZLMrl9&=5!MT8 zvhNZPMftJEkSkgea-rPe*xedXozm(-imOCFE}2)%S=#ZS>FJ4wE$oXRPIxX?xHq)z zI#h3EL+KXveiL{9Qs}L&@cPgy)l-pI}@8h4&(%u4wi>6!zNcCL%dQPHIq}kfhfj5!$&yHfJ zb}zs1*=Z4zYZjZ$K!4(qMLRxwI<5?aTs~}}Q5Jgg2rVvf?1f*~&BVeR;gc-F9C23J zxZ>6|8uyHYC!!ZtR%fHpR?Avh2~E5$?vv<(V&R$XrDV{8ZOudmu%r5+_VQy_u<0tk z`h{{%?no~uypba8s9Ep9L4>?iOaRkB?%zOshRrsh;lU&|@s9#4cHU@#geG9wZC^`a*ESrcjmkY^=KH zH$X)&XT1J4c4qZL8PB~ZF1q04hmG41=fvFUpWKg$zKAs?Q%Kv0$FNeaooyn7H}M(E?kaV+V|@~x+YIH4qCw%3dq=@P(T2~2E=fL1V`IgMH#Cu< zk4&9J)zc+r0X!<#?a>KPu$}dYZZ71bLi$LSE$v!(7Q$hzIGAQ z)q0<^a_U=3J<>K7&%!_v6JytCBf5i+$#LTWt8?&N4X3;tOji^WkYBZCc&J4((l%f$ z_jKaPgx^}9elL*{mF3$X2(4iS#uqt`f|>b>4C%6^uGiAdkqoPA>}g=03TAo8g15Wz zkXg~pXKWsN`n@`%cJ%SGppt2}3Hj&@zS;0{d#=J;kJNLh@v!j2(w3Br)gsxi@rYKc)JA}8T52!PH~EOY?^{i)VC~Yl z7Kb|K5IBa*sYhIAUMK%aMcS#k%J(^X8u$0NyC`gLZK|ib2~qK}9CzerC~_1nEAzDI z6h+WFC~5Ffmevse?8(8R*|)m6LoMIaY#0k^7Q*xhN8}`Dz=@pFe1oO>{ZnAaa;rC% zB;xmcmM0M&hV}Rl5m*@f-;$%S(#>_tCqLKc_0I6%r5b+gv_DAzGYS#zHEx^3|8D|b0{=TO2#Z4gC*nsA4%sP&UF9(apf*|C+>0=a=PzS?h;Fqv(>F` zl~}2e(~3$O#^%g6ZF>%F=Wx-t)=bpLv{=zRH3q{+0D5U}d>EFCndb(Zt=|2HRD;V4;&AQkyFe=S=Xvt&r4pxDYMV z0t%a7EAE1bCc55_oD9RYMI(4x*8p9-ctx27`b`7#rwL^*t{WgX$vBQ@%2{KX4J@;< z=etW~YoE#dLBz9&axcW;w!^M3Y$Jf%{`l#ANA*lvtK7NF)qIuz#&z03SG$4+$JZ%C z-f!ONXvylIdSj{rQa@pEynDkf_ZYboBl-XFEbm}L<@N#yl%q2m zH~kg({}$Rxs?brX2}B6Cw5;UwZQs7I>O>r=UkE3 z+iVY^g_1>SB7Adzckia3ZQ^7vD5n2M_KIAcfWWpAWedC7%0WEEw4rmYL9~rf9eZC; zR6?2-nN%}o=^{g(!J-??Pf}bdgy$pV?@?sQL}VTGwh}ElDyQ8xk$~?h@}9P>|7o5H z#SR=M^I?|WoI79ExMnklDF$N&`Se_>!dUZ@nKJ(G4S+L#gX>c{dnWwm+r*gG8@d^0 zja!opSZd%xRaMrPbmnys)A|I3ovyN1@;?;P|Ncktlh>(9sReM=f$d3Mmu2eVV(vEM z&JWOQT=HHUX?W%nV~Z6LQ;IyS*8^ zytO2~tsm{64ye$cGx;p zZ|bBeserO?O__8L|7)H3nA10=n=f+zQuVx+1*zz$qmSsQKCl{kzNY{b;9|2d~$LG+kwyiLeJr8NJGHRm*vL6&+|YiKf^O-av=q)R3MWK9(<&OB7|Nl07ea%YFx>un;k{}z}2J2HhE)| zl8IO}ZSK6x+AMPBGK%2grR%nW4=mWEJiFX~8`-c>ss5tRez5OGzPiPTNqEp^RN}## zMYhCy*M>mN(SdU@!D9P-w_C?;`;ww*f3ij}HI|G2cpeU?znqTUBxR2)IhZ{64Z9q= zg5$U8`fv11vkQ@OFFgZ?ou0GFwKdg!JnOldOQ_l0pOA09nsBx;%_jvh%>H) zYl?8tjx959;(6TfSX|f7PNo84t)PMBe)k*HT`l$`Dm8}o6;&xPI*)w}C!D<69qU{P zn)t`dl|#Cz(qUafvFb6|M0=y11jL3z?)Vc`%?D4bw0=m#{S71gs}k;mR-JWIW5ijJ zH=zZk9*hBYV}&k)+NMkg6&!jfGh*rnYr=_jhax+U`#sa}B~?*F!MB@F{WRT)*C9oV zYfsk@xTeBD{!lfLg#Pzi5z4_b%Vum`RYA(n?$kQ;Th!B=sPc*{Z&xd0c|tE*gyu?N z-BhFP)pf4IH*iLoh&Y;Ot&R&Wk^DDL$nX9&+Y10#=1nX8pL555!XgX$c`AXk%|MsY z=28Y##$jq4TmBmybnLX6tSLLe_dkcI3st>W&sw-?X47Q)#7sZABMhCyuxW`^(iRVL z53t-F<%I#!zPVT}NF<`O{-~4~T(vYmQjGOA7T$&S-x0q?)!js`bZ>2l#Qc{g0D%_7 zOqY9gwWXTWJr-!-VnGvyx^!04K4&^G_FTWZ$0*3LKEy4+6|C5KA0jWkcG(;R|@M_>WdA zs@4o@GpavGq3=lOZcv%2+aA$MlVw7sYFwmQEzR|9iJ*eso$KA$amQ8zl_3aNXm6+I z*Q$glzKi`zFeNm$P9;`Zttw5Ya-s@y{(CvO9}M3Aw4a06Lm7;^@T8yj;!x0)Zl&MB zO*>Rj_W6)HVIqnPXFV?ff&7iiqRvV(wW42>P<>!!k%j%-(AH>=)}4UkXyf^(16?0N zMCTWPfqZ@N6GLULG0F!f=`wFzv%By8_RF^U5YM7LE}Y6O%X5dKv3t^LYq>9z$hOX> zBrZV~*v{I;=hMX)&fPvz-lqM_5w%inkMdB6klK2UCiuGR|xgH(}T~llNr5Tvhw*i zQTO}*e5S`dHap=w+02lh>@1u^$a7qh!p_)YpfQ&$k2sgqaHqPvq2 zh*OEg^yGDP`DpH~hta=d@VYg!@_t?`?uL;Rz37ZPC|&aWectC3k0Tx0P$s1-Ogn#2 zNdlwCBXfVDtNvojleQol@!uj5aU_%}{g|ilJ{Yx-2}CpKNA?pk(dw%$pR@PO>9Vu;(dyUWhZ%GxFh~yt-y2zPV??EQS(>XE~ zj3+TPw@$pEDxWx()RP|M?b8Gx9oi82!rv$o#aP&V)NFo`?&|nSe75QYFA?I=>ml;^ z)&~?p_ctHx5V~sy1tEm8dZ*F-3LB!u~ewJUNu$P`7JIf){oz1q_RGksB|7M{3RrEd*= z8A(y{3B9#Qp1C2o?8qeJ2rdU_!t1%>i=kmr>!D9>e4+83a`8Hp)FBn^aBo9V4wQYL zAT)PMPEz#|+G6DTsZ(;DK{`M*;+h-X4YMK!?WzNwPg-Yx_mqct zSr1??j$&Q5OfiRj27bhZDJdO)05e){AC5X*d@tQ@eI~dA#h%@r6}tkzhKVZeP|Axk zE?%}uE|$^2D2T$p+@~JMsFs=zx5+nQ>WP@i7MYnpdZ>^Iar8zhkx>Tz3F z9&xOb%Jm1+VA4aMiPYooFlPi?up+dzyrr$qEWv|jC>XKM3?3LXQ=7DUx^Ri1%-`!w z(AU#dx;mqkZGnIBHP*4a3r90dl!Z<5=(~-^%oDGHE_zu1{~;~dKHEyB4@?|tjGJ%y zDTh4sqb|hQ3_P0pvm70}VuD;$F!g06lJMrCD<+0&h=~*r-({K)IV59a<9CHh*qKa% z(84LkAfcAzw{I-m-%P1dJLv~-?bO}IwDZ0{G4Kl|0E9Sj$s=|yV?M{+qMG!lzB4dv zP8&MjWq8H6N%xlL0?$Baj@`2z7k}=)aA?3d9Qp2*3uDj#${!F8o10xn{F>$0$MJT; zT_}&^!Eojy`+0B0#5?Ixtyhg8b#An%gOf27&UNjkTm>L1+3Z*Mx(M4|^Jlud>wdwD zcAh_W?nfEbib%Nqt7HfXJ!JuQTx>Bv&oTPpL3hu}|DqinZMX7sxiM~CjW|s^ovN#| zbm>=yR1y`m+Pz?2aTt69HvCS7A(qy2An?(zrKWN)Dn;H(YdkF_NGr>#bR z33i`PAh}R;7q+Fx+6*k*^a0+38aAB49Wrb2DYFUee0XE*<+h+w3t)qX*a0cWr`j8Y z!`zjiS8IKa7pTKDvAxKdthF~SLLZQma4DL=-W3g_G~UvwDILJe3+iBlxI4|m{tj== zzJ1pR&#lIEw3YldY>+}~IOvgYs*76ics_pccDnthhXco~iW4k;JQxeM0Wto;&zIej z%?w)3s+yN1H4?46VMQI0M9(x6?;m z-NX-@kYnd7V^eE#_3&pcO5cXFdgn*54{k=>%DG7b`1)d58Z>YStY*Uxd>LFl2N$&! zdCLkvFGdW@3V%Xc*YjnqlYdA&8gF^w45p?V{t*ih)W(JNyH>yE`HuP|Ogj9n(~7c* zd3}e2Bs9cIC=EfH#IPWr^)#?l%o&)Wo);sCE*zXftswu+e=T>OIUfbLo2jkFGXdLN zfPZq+hsf_`p%T)j>#c=Sn1yun{5isYfGn8)Pb)f9q^&Y{Z-~l=a1==qoeKd1ltsf6 zwiSJY29TMSj9r1Wit<|Ae$B(*c9A^>AC5`?AS|Z<80A4nd{Nm;n6ts6(Hx`a9^T<3on6Ts~wedd+<=Cn2$(e3d-2 zec2w1_fGl7RW>E-E5EhoH&$Nj5W^}^{T=GWhn|xE>c>*Hk@5AJ(7NSxw@|XH6>?K@ z6pwt0CQMEB%^LhR%XMhZo8T(O18Qz26ckfWuT%{iO{!INQwwO0vXe?kR7pFd54)|WgLlyP-Q?sMs)vMY7cFL{p!Sd> z`8fp3Zb*xv992TBp)7{OG(*<^?7mY_OQ2(*Q4IzXjxw%?ARs<=JqqZ_fG!=zYq;z-Fe|E|AX z5kI2ZKja(d#TaeQ+vOz4v(t%BFvo+`*F@AE`LVED>xXjP0=TLO*2aD9GX?4o=JLup z-I=V7QVSnBH%b*ONha9$CjQ%PJ))mrSwp<-ati+Ob~EVpZw};_JzWITe~dytN6Nca z?R9L5W1Qo&iN{m(Y_%1)3ePV3@6*kPSKf22&w>o(h)9mLX|E;|7!fP8>PwZPXWWX| zlns*%JQ1_4;GOvydFyAU7L%)A45{p`bIH+zUn>Ca-F^Y|dQ@#Sm+AcY5VSVSWRR#) z#1Z?JN{&TodnN50i5z+;Z{v&T<`~F zGqiEBk=Zvt;j@c?X0M2Pqljz)vTb2~>nWpDsvvK;!qeZXyb`{h+jFZ;LcbwOv1aS! z`v`I7wIs%eG# zSjt;GP0wAtv37?Rz7%lI*J5gu>eK;f{9V~ za(;x=PrCTC0gWmxlDx^#0a|#5T9TW-4TvjaV9QBl$bRt%>8)*@N_Z(=UJ`2Ql3Euc zj3-%_QxBkE{P|xj>AMDSx>TX(hy~ziN`uhNm$+d}R36o;E+W_9jpU0E63PaI<10B( z^>;?@jw|+#3W>RMpE$fy&K^q!B1=J zeH(Vg)Ug(~hxIpp(V*m=n~lg#QMzI9P?kY=yXD#!z?=E@NeFGreF!T)k^AmBY(S}M zq*Amm4t27LWdG(;bHwFsvGE(7t&6$ot4V5_g}mk&V=rHgq}yg~6A?@4K#m(a6kTV< zHzL%$qSgV4=8~h4^X2+;hzeoSmV)2Z?9eW=eytXQy( zE9Izqr)J7tcByMIeo^_KAePT&xo^nrm)K}YIX;!;_hea|DRvKOV?Q20lRBv%<`8l~ z5jwY`o=XW%j4zx}XAm;5(nN|^BdalUyuk(A4b^HtSg=k{JIuKuYR9lz7V}PnbTx`_8Hgh>tif=uH36Na_u(O zrf6*V2bJc5&?22s@)dm>mF9;_3^2Ymsa@q*-;k?o6#XC(W4Q_X;$h0!s%Ew%`eF+R zn#UAMEuyy1?k_zscH|){$pd3JRk-H+o~SA)^xYfVvFmI8yaSIo@_2FRbAFScht8IY zJ{N^-13isVlL4&Wn$-Mv*Pf}}UI2^)&)x>Saua`#l!Y31nzQgFaWyK%BW}S@wlQMZ zIT-cE*My1AKiV6E%cF_H#Un3~i#`R*D#srjYu^y%IuTSC1oBnfe#<==bRGhtx0!DdB z3}zavP46a;>I>yQ?h3@3T{uTP%y4B{=-2cqdJ^wiFJ08mS`41zgy>kv{O0vgqw68# z1l@E_G+}S*CGUxPbb&p)&PS(4H~0M`%Dsi1%8Q@*`dJTC4Z!i|dSCYmp7^r>0^7f- z(+brJgCl=mTN}76_xx=i4`_mKHP`p0TwD=D9fN=a#I22|bm9H>Nc)x1sX?smg|vF` zdob?iv~GcyVycqbHu}DQMdgy2Nsd$_Z!ZD1BXbM9dFuJ2yuy<~r4J7#004)KVTSTg z0@L*MqpH7~sHqVkoQ{8P_JWJHlI_8H;oS#u=hoF^UOJ6&USBosn|PR4DHm{(uI$PRi?F)e-EBTrQy1C!t;db}((e>bUDCNi9Rx_ugV`(C;p4hOJwp~O zMDMA7c+16wPEF1kM%9dla*=CVsqo- zWr2bmJ=lP&!sAHxqVNQ*ZDEh~xu7#2Q>8PD^|w}%{+--`NK(1B=$F6nNKhL}l-CL% zMO%UF-{UNLpHK(&CBehiEr%{K4MK<+ay|1{eXjxjm}FW(oDbf^+aEUE-tg~nmRoL38|?ZM-!)#bQi_5+Og0Lv#()ymwOFDan6(xnwQ&#T6UL2B1yTjLg8l{YC$%HFlZ z$DC~Tm>;I1BWg;qjdzu>1;&-B9JAj%PrdXPl&cn^rzSLppmm*hv-Y&s57(JzuC5Fz zIk7LoY^jfpEYnH0pBImWaCeG`mFkpBnb4zQOZUVkalDU0*(u4(Bir9BPH{k5_SgYkFP(P!Ck$ z+T``__Iepjy%A!2bDgFa#qFb>azmT8Dsy`%ZlMyf=pex=r{JhF5*R11@yHo4bR`lQ#|!~FCx9;Y@Rao;97SYwkCZk zbF!_Mk-?Id*g?IaG;6NK!hb5T97SNa!?W6J0KKps+zQ{pbo@K)gxB2|#Up{6=>yN+ zSi*nUx0>x&=M=16GKeJ3vFBV;YYWqpkbBU!xl=2H3H-&+VTSi+4(g-i*dnrq=)gax z1`of^!uy*QG#QfJeK%@*^)R{>r*}dSwdLT?$JR6PtXcYPPG8RamWj*a0^S+XIR{m= z3lKw05{i6}u#8@~H|opxQBn+aU=w(B@I zk@Db7TitIllJ%}u*QprJ%ocXCFbFG&@WT1l{buUjH7st*m$6CQgN=Tx;hPFR*TBm= ze&|%WwgoHpMCY05d*4gSvyc;&8V+u>EbX-hTULFZWALFUaUeHq#cMvG%eadEn7Eau zL3!q4RK3O4v_Aom8m5+!b`)9k7FF1;WQ;A`Cglma+c`6{vftWjS}?haAUpO^l`Rj z(7mkCJ9ejWu;r|fpdNjR?lj>TW@RXcUdWjuQGh)PWBUJMQ#+ahw|;3e04?Ukghs}* z9QTYzckJSiBMNs{>#oTTw*_9|D!F^cP7z_z|535!2dnR`cX%MB!t1n)r#6F5y-)Y- zg)B>kl5%=!>diF>AkjslUaqGk!lO+xRrA9+m+B*bP}x$9ai4BgQT^{$EOL9)EluCR zFl3z+wtSNnw$~}&`1^QvVV=Kz3C8Uxnk&C-uWd&CF2$|u#9^vkMQI`VK#Lp`>ycDt ztCW(1?&0je0m>CEiS~5Q_FKB46KTsyl_O$>xPkGV(nBk2q4M0j;1lWa-SWr~i~|`c zj};PXY-#)c#TR?63C|hwLp}Kai?~9SZo#VSM2Z_G92AL9KAcLr9 zC8EO$mDJ-8r07)MXkDwES+js05ih!yG5DjMM5fy*;{e20PqA2(bE);Lc=@7HT$A1E zc%09x5!8uw@77r27@O04Wo|RVvj}5bH~@Mld2F{*#?&L$Ni*OlWJS?^8}j(0WC?bJ z9z?kf)|)XmiugXIT!$PT`72DOk6K(TC)>wdZG2T{WQbxRp%g8HL!J&LG7pEvt*e3- zu6Q7l;Jw4W`%6>#s?jfkqcb#G8JhNtRxJC-Vf$^XJSyCTC%1-G^i&eg)cCxT&RLk# zmI{r!k;C@u z)gWGJ5QzjzEZjt3_bb7LTeu+Zjt>fups)| zs22|8PL6<7d2H=sS^V#p(BT@#iKqWc9^#5~w(?F1M==-D9Rj#KEj_0bNC&nZa(w=# zYxgOl+|w2ESN*Y`ZaGy$sD+FDu~0I|Z;0#QEqR&{3&v)<)kU@znk?FO)qH?j-PF2{TXQ1@e8J9R3Vno|mY8N73? zaG$TO*Q*T|2P3XV;Ay&hOe`*Rw!1{U@vVR+8i_y=Y=4|vB#?a{zq#|hls#<>FQbkh zX8!i_6U>sZ9vds@k6Zt{FeIYhigW0xFX4}iw#$Sf9}*jM@z!Ctd!rsw>Y$yij2)&xXEgCtEji}0613C zj%um77X-^qKu%~hueC0>;k))7mbZ9RS=%4=w1+-gUEu9JK*;}zOa@Fo07WvuQe{8f zifh4!4>5QQ&niZI`QkAvXt3h!Sj)5^HOfY1%?WRY6@?eCMGas2IlAqE^mOjzOk?fm zQkF-yUQ-Or;sCv}`it-lH+p(!7NqV<^zg^#se&bP`+skF3}JgZ=iby%=wbm-UH^~` zc6d4c%-W_T)l5>mOw(@|a}&=$z0sG`7u&w@0R_VIzsVEMH$kY#^;ToSnK*&vzv~Cdsy2GKacvch=gEN?q3o zUC5vH*AIWm+9N^)sfahPNL*xxbm4DelI%n`fCAk8h3T(^CIrOBjYDnOR$aU8h!z+bA)O-mm5S75&OC(wNb&X_{DQsZ z>dZ`dt5l;=qO1(Rih_|Rp3p9JJX~MCDVi&nRuU!Lt_K9Yte}-InRVG3fR_*Y8ZZDs zv2;K8bk;rFp~y57LaJS`FgG0^X3YX9qWy=3YTxcRJR7ftBU#R&Ut=wfzg*`r z1dmSNq9kWY{)Tg|J3&s~SgDCZey{qS&i_;qk|l1HlGk?CF97Os;MB@vGi4ZE^pFHM zCsNOXJCz?YB_Q)nEM`i?Uo;h7d-KwLyWs{@u#>CVSC?%EFbMIKj)&I3XP$mHgM9j* zQYGS}D2X@fyO;6@$@pUFYq`WfF#zAYEZs`vRKGczz0pw-;X&8rS?;Pc{|N!i!Q%xbOt@BglDQ;fLC32wQVAC5SX8@_3HZ4SMK z3%|HV@ zk-|weS=B}jbCx0?Z=pK84&1Yn16+Fma#aEN}hapW)}EuQ!jM_u2sb1m6KUwq9>C00o^1s30-6tPcYu%g{BIM8A8% zn?vn|0vko9)>a=+g9RdkOSZ2=986{`XEMbFMFXH&5oc^6Bxr7}$5buV2!DoRy;zab zlB$}HUwo(i77?ixUqrzDESGs>TFoO%_Y6J^y1F*n=czu#!|l(*utm6a`*ou|u!bL` zaq#I(v%9p0{D-z#S_`Ma$MS@Gn?jsiZNdoi_X@^f`yo1}%J8uD%faK{e{8D$S3;+n zGz`8rv(bwWhZT=l*OJ!UkhF)_0+K7^5r{OhJu1v6Z6IJ^5%q8#J!@+zE9BeH^GCJh zUGIH~v}$rcjaLb2QD+Lb-H#w!@%eEtTAxKc zn=m#r*1LN_aft1g^vtVBpU+7B9{J&JU`Vv&;M5*k2K|ip#QOvK55GH7H&*l;e6Xs2 zh1BVzd}<)&ZrzDEFk*P%ej(E2+FvWm7L)CB3cup|*PZbz_D}s=A>w}VKOaU4tTPCf zi8oOUX%Q-)IdP8IEl~t|>*%ss5%1DzJgT(8HX|*XWtd<8|BU%5d1iz9XvKy){}tT# zI@I{3whlT(^sS}gYeD@nZ|*GR(2L+7ug&1E{Lp6sjq{g$OfosfPB7B@&VQs|;PkF5 zPK{cmx5CFJD%EymqqYQI4MaJMvk3xZM}qPAtW?Y>NhK@f%T_7IS3BCTbl^IH~gd@$^Bte;ud0frQf=K6Kl@(X)R6FJ-5c! zP@Q(4{EQXM_~m+o0=)>M)^9PE_w%v7aL=z0*WNplIzm+i*E+M5Gs;*JqnKn3;OJHz zX?q3HSZ6T1f2|R(8PSZU`M3R#HM|g z@tQB4cc4D#E)8P{FT$|g%};k;EQb1^H~YbuDW&p#$>D*f30i(&q(qB8r|ZS-F$N7A zZyA_P%zi{41-hriJMERY29lR=bF4VgK|7|Tz3Z!@sW_$a)<1)gO4Aqxj8OyTx7pto zMxPR1w+i;f<+P2C?VGlIKSxq-&-Ly#Bo~Im%~zDXsYPO(IZK{$-~5r;REJl3l^xxF zeQoZ|$V@98QFbnuO4KHgo{M^TVQPjbKQ*%dYnJdZm_?S!8VT-+-IrIcq#)XxN{BJ1 zV;W({PX+r2MLh6H-Rw7^%gbci8%|%-M6C5b&H~$*dkEuvS4;3d?lAxT(_d4YT3+QL z%N{vhg}ap5vkj=1!=`6WKYzqE0_p6H404Inos-Q!|C)tG1eOAT4fY-$^ILIzm6^UF z|aK}lC$ol%F}-aB1Qd=QJ+J`1xuFYCK~JW!5bER&4f?+4;8 z7*GoNhr0E`JOon^)cvZMhR1m8S(AdA#l4#LvZ?n&^R{5x?F0|LLH7@qi&Ia;FS$tF zI#j3)Z_!L)Wjlni@8!O1{rpB5?cD9@J1;!sX2j8lz1ic?LeLu4Wt-8NF+gl9S1KqU z5#=%E%^PLsZDzFTf;jx1k2zM6hb|S^SDUy1F!C`xf~%G^&rlM^)nT$dM8W$ty*=0^ z-d3-7;tEN$|~Z=A#<7b@g>L{?b?k{S4#7Z3}kYY9Or5W|y>?qxa1egh_ORTIxr!JHu*?{p9{<7wrvm_6@Wr>Fc1rQ8^ z-glJj9Tz5jSZ^!dKg9Rh>I*Y=y`tQE8&4EZD=+5!{ErG|xf84M+525^%|m27qM<|y zjk)F4(d(#enBVySklFAZ*2kGP$obM6&I7Fe4|U*gnY#U@tRXDSl9I#R{Fuu=!DtP&`@XL7Vf=W6XBas#=SMZ?06G;^PsiR#rA6NdfNu5B;=LD-e3=8zC))e)?J;Z_ZtqHXfWT52C-BFGSJ{B%r zmKv?!U_WYkiH$mqv6kXCMr2FbLce5Txy(O)lDQPqk3WbNy0%P$v|e*`n1-F#U4Sg8 z^q2FF^IuX8t2LJjqIyT>;=nOm9%JZtaHIK^ucjQQm=&OdY)LGXka!zhxWw;-yrc8W zcEYV4Zl8XG1mlA+3pRnxxI0h=o!bl$xMHzc!+ z*Y^(SFHm;CV)nZsf;7Lp)2`nJvOqR^wpmd703t~&z$`8yN-M=Sh!@0MKIb&^>hh$m zH^*y2-UEPG^?IK53>aCDTE`Kcj6wql(!_+Z)Wja?^$u2Id~G0?6^0DbTgYVk%lB7x z_)+jV;;NnwZ-sjGHOAtB>;|iOvgl@+b*z;GVm5lW&On!(>BY8ETk{alIa%ELgF4`0 z+8Ci5BKpHisc!C*6;#13QS-iy;QV{?zCSCYyV0*3JFE?eg|O+4^rY~|ZuI$BRa^qE z@a{@119^+S%@%ahs|UI4!f;Khxjpa_*e&0|HgAWBHgf_`KvTB?Uc$E(=>XSyFo;Ih z1wyE2yB_qa(7|>o>rlYkc!bfqpej}-S#_{(>>4%}H2?Mexsx^GFy_rr=iL&36GEkF zc5t*^KgBvmH!||tm)N!$QF#4IR!E81cTGuxH&Xx}j1WtuD0*EgJs>@r-Yz$c(wvwdRCHec~QZx1TVn9#c4UfNDO zjMCr6nqN};IURA=-SHYJvk4+*_$H_^7I12vP2a*|r^%Yg(c8cxffo((>GK9?5!LbJ z@zmjm{;RuYsH4F#q6>}@UPA%1;@VF4Vsg=Ro{=L!1yHgq|<9+-cQ6+$Az`qrV>LDxIdfa72FX>PfYGVGf*VYhjLB&P6W*O_Ktsb>X`swyo%}(fQ zOHMn*UbywOJt=yrga3%;%2ks6{O4Dx4TvWl1-}cU0LP}i=R15i?|!F@CHHqA=km}Y zWk61v)q^gRVhxCT%4BcIsCvLO%3Z2KiJdnAcYm92bGmjI-)9Ehtq#`R&=@e5ZgTx9 zR1li#hs|?DyAiA&^nYHm!!@bEsyuI~3d+kN@I(_5 zNsrM|5hc-%p}2M4D_kr^CZBaQLqU5=^RHs}mrQWeIJz_t^sh3o&kvM5i=6@OA{)%>lTfd~0>Q_UtWy4|dU5sB+^ zbvN$$o}lm}H)Vg_q1n9T{WVZNe}gmATRt&8o6*gh!Uu1CfB)?;HXA7UE-(N}{vFu$ zzt*UrgP)R6WUGP>5BeX0lDSW@O)EZzYFB#RL!Hj8jjP=Z%TR2#R11>h${MQ~2ALs* zM@f5*gp&6u+diOokCkDyE(ar8V2FpxqkqAP^~7D+sP9UQa!qd3mxe4wf1OR%0(D7| z^u6;)U?OS9`T zTsF!*&rS1CCgcvNS3S1JXpMKfXi85BTXua%1JIJ8Zj8A^=9`s`3m?4kCq$ z=y?x1L_5!}uH4lnhO5SN`Mk*CHwBN{T0E=`m(=-eCaP=v7C|s9~+FiCXcbiTuGQIRypGf0i8G(W(qK#`sE$d zd_it@TLO+BE2z8bR4vi(rYRY8?28Y2PmkD4gajP5O-)ZyA1?#r#H;<2{7nX;u*q@V zcXBJ-X!6^(H2x}+3k<>Vjo1GX6snA*4Qj{%LrncuB@S; zC1`vb=Utb)WA)GPj+2pt|9}a;X=Ap!gkKQG^ZMUgQM{d3%Z1+K5*!gwd@OOgo6yo& zf7(!+`=~BTd*>VK1=(^|3zqDgFIL7CFMED0quzJ)>{k!kwJnruoi`3-7M9 zUeRnRFPjZXO&kz#>&9M~P>p)d0UI7)W7u#ffG{4mIRPCm_%%BuC1L*Q^F?W)VIfW) z$`twS@UGb&hC+2|@#g2PJ4cP92Y5)zoq!BJTWXNtt9G^0S35uH%-S;3C&w+e$zAO- z|L6=)HvFJqq~M$|1EhU`oa4KwbQ~UlIDE>UJue>7RC(JU^@R2eV9Bt>P^)d4`Uh!k zyzF%Z9a6G7DngJu-_-{{+$-z>QMK0S=I|nw=R2QgVIGw;Q&^Zh;b|8+AZ#3jnK1h$ zGP1VZmF{+jHQV{v?jacbfUJ=>sh~sj$BOob=h(uI^y`ThpE!YqwVf(C`-eU!HiA+q zX*YGSno1&Pv49I-NcX!tAi$YJ9M9-)=m1wFzYK+B%u`+}RuzGhz>OYSaG5*$5b2hm zH@xaZkh&woW4}1%bzS{^uiht+OH^oEz`|bx&dP=;MD;kB4Iacrsv0m4IbmK##Ckmf z$FWnk{GZj4JS6t5t)+yC@=yfq4kgl%$y7{6^Idhiha@FBAXKty<8$lbD>A{n%8k~& z=Jd_oS3=Hvo5f~b*ZGJh4>ZOc_|||<<-Uw1$MjVq?EGr6yo!=f1F&Rs%>QdEExRLa z*;nEYCvuYLzpK5!Yt5`~_{j=BZ2n)(8b&(CU$-{)TJoD(Wng02dUr1WC`0p;PpCOt zAp}d5;!1X|S)_>Qv)>PFOB{9dSbSwJoOmOA0l9J-2msD5ZBNiunD5$7v`$Y)GW-=9 z&MdaIwjjEWjH|u41eBbi59gw{R=OMNH{WA?tA+3UVm{rZ8q5KMCr+xJUfBhLO!{=w zV|ePPv|3(+Vr(7R6da+-jrMgd+(aoL>dP*p+>pno)=E69;za+xAQ}~yW(BG2tc-5n zr|(3cz3KV~FOgU9sDEKYs;>L!j2c#{DFPSNIfpdo`8F3h1}SJcRb6bM5WaIeLcSU@ zXkm@*1;!W;lVasYb=0V193!v9EH&t=s5dN~=TriV+awf}LU@Z{X4^vUMM7mvvNAf$ z+x%OeD68qsaKN5){uf!p?s+`Rd^CMI?cl~(WICLrr%H$?U8;hEPTFn*i3Yz~AwDy0 z0cK2-jXPj2|NDTkWyogWU7`J|)s7@`z$xNZ{b=KN{#bIpxsce`G@E7HV`mHdBT#ty z5Y-Y>sA}M(yIk9xy$W9j25L64e(v162bELPSLtdy3sfUnP(JoYE~d0Ti@;B4kxFKa zCy5Bf{JKI+jk>(59iNxl-ah31D(MpYvy}h+J6Gg@8*jBZ<2E7qX1`j8(C}RJt0;r# zwGdIy&(2iGJ%SF^H5AI?R*D7RA8P+Ih}e^tLs^o!+pt4Z$5Aesbw)(IsrQ0aZ#o>G zRen(PR|!9cG1$9f+Q{2>0n1R=Wib;aX9@q@8H~@xGsAInCFCsqJr4=nPG1T{PrYkC z-sBi@#O367{I2S`;P@WJubP0Up`SaOROrt17{Tn~VwBrJ?k?}X8Z!Nh7B9>j;Id_s z53b$b_#!alyj6V@-w(4j)Sn5kJT3XU{P{GN;LP;7AA^L%?8W19tm=0qZF9BwnP09ntKT<3iAPGb4mC#ZGu}D1Fn2okI~{#MS*Js` z(Vi|)R|MwJ+I{)Ju=PE1X6CCFcS6dmDUF-p)r09GHCuKuYU}4~J&{kf#sHH6vGXYm zIt!st|9xQ=j{cLjIZHwbX;uN#}nL#**OGFUM@ADVXe$HsqhtmoMopRt%Yi?o-ngx{OzB%PEU5Uz(P^|!HJ z+(7~x_}G)vzz&ai3=0Z-;?C3>5gRZ+Ofz%T`7ry(&v_2ZYMzVF%NAV;c3_V2)Q1W= zQH+?6vJXrv_25V*RVf+r1x`q>%IaOF+Rs;EXd=}Q7 zUTN#a>egpEVQS%DzPdNO>QFN}{*R?|4`lNF|G$z{DwTIArwXZrRC3-r zs}%2&LrxP(IiF8EkxFvd8#$E2DoGN{`MioIS_viiheO>o` zU9Z>c`FNb&Fa9OdSF&oH-;pMMX#}`)9W1GGdHJYad?A>Xk>n3E4j`dAetlJ0p3BHy z`1lTIHl*h_rchZ0f0k8=^fA0;USm>|Z#H%{#A!%6C+r*A%{%w4K8U$7kQC>t6iwTF zKK`y#=|`JrB)pjx81>+bJGli1bDypNy5ja$_fi(kImQtot{)Iat6NTdVP`7DHryU7 zXFKIu5LEYzJG5=*ytBl#UVWXt5QiUQ9AAZ>sIqL=&5ZOtXl6 zmtCjq(;A@Y{@FdtCR0T3<&-}0?Gzk=tx-D)cc<(GT?+q5Gu@0UV$WCJTw3+YmA|yc zVm8MCrcxw}YN~hXU=G4rauVtz*i~{TxP9*#kWakvQLL1*LR^!w8aZmmw9JEgZ{3k; zl^0k4u9g3b8_SB57ki05!Old)O7_FewwuT-!3Xb^<&f6ocUTxRWYztHzF<=!{Q)2( z^b?+kEvlR8H5F9Kp5)ZH+k-kg_0{x)?V;mNf|Q_tLEgV(Zafz?GkZF8OcwlnJwBmm zKVnDQ-?)@C@=|0@M^=bYNRL&!SG0;EvGwqcTj=_?4A!CcfWOYhdSGl9r1VOU{ls9? zW~={xogcKMQX=X^szK~1(!rieBmM;%5?JJnT zLN~qgW01+i4Eb{u->JOe`$?;zXG~hAk9mRB4l)D z=}z>VBI8&1m-meT9arD?1rAIf58pt#5zu;Dw)$YsHg4@gq2KKfD29J>UV-7Yv=VM% zGMY1v+CrQU&v)WDioLz1;iB<@KH(JX!bNiFT?DPt6;CK%P03oFbG?Cw__#7r_AYC= z;o-iA_yjj>ziS&Us_zr^?vWLUvNd#Ez4p;WbP4|O9dZ9^8PUNJi%N(0L8v#Iut?`E zHfGUAg890g3hGID^qO|OuiTt*{V!$p?@aFH%I)X?ves8!2Z-$L91Xx#olC@!3R!gZ zaV0*0 z*~1>QPQgv$jBS)UEgVZ1Rf)$t$#vtRoG;*ShWf_DdmAzmD_eDi(|YHUr%pqv)+)j7 zGA(*ylMN9iJ*9pUQyu5K_OHbn`q4Se%EX6CPD0pOyJiouu+3qzV$sd;~su*kApM;V?v-5;KT5J77M7yQwa4prT0HRfAP8@wVI+El|)`Fpa zKsq07BB6%G#~S(MZ!MuG^N`=YW)q9*5xCFT7)yNGDU2D@H8%)gz+|?cq2j*_n

Yxd;pX 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�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

wbN=BS!|Ry}4YZJysn>#$|vE^dj79^I$-wWCtme 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

@veVa~D@Wy}LFQhv=U8etye+O^a~5D&OsHYB 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

d*`+-b0zml`0W4R<{I_kE}c#L=-mQg2EPe5A*CS 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

9RqI)X%+hZHu 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 +- **SHA-pinned source:** + + +Both URLs are recorded in `MANIFEST.json` (`production_url` and +`source_url` respectively); the SHA-256 pin is enforced by +`tests/unit/test_manifest_integrity.py`. + +### Known upstream quirk: `Characteristics` `$def` + +The UN/CEFACT split layout publishes the Product schema as a separate +file +(), +whose 17 `$defs` are also embedded into the bundled +`DigitalProductPassport.json`. Verified on 2026-05-08, **the embedded +`$defs.Characteristics` differs from the standalone version**: + +**Bundled `DPP.$defs.Characteristics`** carries an empty +`"properties": {}` and a description that was clearly copy-pasted from +`$defs.Claim` ("A declaration of conformance with one or more +criteria…"). + +**Standalone `Product.$defs.Characteristics`** has the canonical +shape: a documented `@context` field in `properties` for JSON-LD +vocabulary scoping, plus the correct Characteristics-specific +description. + +dppvalidator models `Characteristics` as `extra="allow"` (in both +[`v0_6/product.py`](https://github.com/artiso-ai/dppvalidator/blob/main/src/dppvalidator/models/v0_6/product.py) +and +[`v0_7/primitives.py`](https://github.com/artiso-ai/dppvalidator/blob/main/src/dppvalidator/models/v0_7/primitives.py)), +so behaviour is identical to the standalone Product.json: arbitrary +extension fields flow through. The discrepancy is purely documentary +and is documented in `MANIFEST.json` (notes field on the +`untp-dpp-schema@0.7.0` entry) for future readers. Vendoring +`Product.json` was considered but rejected because the validator +already runs against the bundled file and a second copy would create +silent-divergence risk if upstream ever fixes one without the other. + +## CLI Usage + +Validate with CIRPASS schema from command line: + +```bash +# CIRPASS validation +dppvalidator validate passport.json --schema-type cirpass + +# With verbose output +dppvalidator validate passport.json --schema-type cirpass -f table +``` + +## Migration from UNTP + +To validate existing UNTP DPPs against CIRPASS requirements: + +```python +from dppvalidator.validators import ValidationEngine + +# First validate against UNTP +untp_engine = ValidationEngine(schema_type="untp") +untp_result = untp_engine.validate(dpp_data) + +# Then check CIRPASS compliance +cirpass_engine = ValidationEngine(schema_type="cirpass") +cirpass_result = cirpass_engine.validate(dpp_data) + +# Compare results +print(f"UNTP valid: {untp_result.valid}") +print(f"CIRPASS valid: {cirpass_result.valid}") + +# CIRPASS may have additional requirements +extra_errors = len(cirpass_result.errors) - len(untp_result.errors) +if extra_errors > 0: + print(f"CIRPASS requires {extra_errors} additional fixes") +``` + +## References + +- [CIRPASS-2 Project](https://cirpassproject.eu/) +- [CIRPASS Vocabulary Hub](https://dpp.vocabulary-hub.eu/) +- [ESPR Regulation](https://eur-lex.europa.eu/eli/reg/2024/1781) +- [UNTP Specification](https://untp.unece.org/) + +## See Also + +- [EU DPP Ontology Alignment](eudpp-ontology-alignment.md) +- [Seven-Layer Validation](validation-layers.md) +- [Error Reference](../errors/index.md) diff --git a/docs/concepts/eudpp-ontology-alignment.md b/docs/concepts/eudpp-ontology-alignment.md new file mode 100644 index 0000000..b8f9a2a --- /dev/null +++ b/docs/concepts/eudpp-ontology-alignment.md @@ -0,0 +1,163 @@ +# EU DPP Ontology Alignment + +dppvalidator aligns UNTP Digital Product Passport data with EU Digital Product +Passport ontologies defined by the CIRPASS-2 project and TalTech research. + +## Overview + +The EU Ecodesign for Sustainable Products Regulation (ESPR) mandates Digital +Product Passports with specific semantic requirements. dppvalidator provides +vocabulary modules that map UNTP data structures to EU DPP ontology terms. + +## Supported Ontologies + +| Ontology | Namespace | Description | +| --------------------- | ------------------------------ | ------------------------- | +| EU DPP Core | `http://dpp.taltech.ee/EUDPP#` | Product lifecycle classes | +| Actors & Roles | `http://dpp.taltech.ee/EUDPP#` | Supply chain participants | +| Substances of Concern | `http://dpp.taltech.ee/EUDPP#` | REACH/SVHC compliance | +| LCA Module | `http://dpp.cea.fr/EUDPP/LCA#` | PEF/OEF impact categories | + +## Vocabulary Modules + +### Actor Roles (`eudpp_actors.py`) + +Defines 24 actor and role classes per ESPR Art 2(37-55): + +```python +from dppvalidator.vocabularies.eudpp_actors import ( + EUDPPActorClass, + EUDPPRoleClass, + Actor, +) + +# Economic operator roles +print(EUDPPRoleClass.MANUFACTURER.value) # eudpp:ManufacturerRole +print(EUDPPRoleClass.IMPORTER.value) # eudpp:ImporterRole +print(EUDPPRoleClass.DISTRIBUTOR.value) # eudpp:DistributorRole +``` + +### LCA Impact Categories (`eudpp_lca.py`) + +Provides 16 PEF 3.1 impact categories per ESPR Annex I: + +```python +from dppvalidator.vocabularies.eudpp_lca import ( + ImpactCategory, + LCAClass, +) + +# Climate change impact +print(ImpactCategory.CLIMATE_CHANGE.value) +print(ImpactCategory.OZONE_DEPLETION.value) +print(ImpactCategory.WATER_USE.value) +``` + +### Substances of Concern (`eudpp_substances.py`) + +REACH/SVHC substance vocabulary for chemical compliance: + +```python +from dppvalidator.vocabularies.eudpp_substances import ( + Substance, + SubstanceIdentifierType, +) + +# Validate CAS/EINECS identifiers +substance = Substance( + cas_number="50-00-0", + name="Formaldehyde", +) +``` + +### Core Classes (`eudpp_classes.py`) + +EU DPP Core Ontology class definitions: + +```python +from dppvalidator.vocabularies.eudpp_classes import ( + EUDPPClass, + EUDPPProperty, +) + +# Product passport class +print(EUDPPClass.DIGITAL_PRODUCT_PASSPORT.value) +print(EUDPPClass.PRODUCT.value) +``` + +### Relation Mapping (`eudpp_relations.py`) + +UNTP to EU DPP property mappings: + +```python +from dppvalidator.vocabularies.eudpp_relations import ProductRelationMapper + +mapper = ProductRelationMapper() +eudpp_property = mapper.map_untp_property("manufacturer") +``` + +## EU DPP Export + +Convert validated UNTP DPPs to EU DPP JSON-LD format: + +```python +from dppvalidator.exporters import EUDPPJsonLDExporter +from dppvalidator.validators import ValidationEngine + +# Validate first +engine = ValidationEngine() +result = engine.validate(dpp_data) + +if result.valid and result.passport: + # Export to EU DPP format (auto-detects UNTP version from the + # passport's class — works for both v0.6 and v0.7 inputs). + exporter = EUDPPJsonLDExporter() + eudpp_jsonld = exporter.export(result.passport) +``` + +### Per-version mapping (Phase 3c) + +The exporter is **version-aware**. UNTP v0.6 and v0.7 use different +source-side spellings (`serialNumber` vs `itemNumber`, +`producedByParty` vs `relatedParty`, …) but most map to the same EU +DPP target URI. The mapping table in +[`vocabularies/ontology.py:TermMapping`](https://github.com/artiso-ai/dppvalidator/blob/main/src/dppvalidator/vocabularies/ontology.py) +carries `untp_v0_6` / `untp_v0_7` columns; the exporter reads the +right one per call. + +- **`schema_version=None` (default)** — auto-detect from the passport + class's module path (`dppvalidator.models.v0_X.*`). +- **`schema_version="0.6.1"` or `"0.7.0"`** — pin explicitly. Useful + for downstream-compat scenarios (e.g. forcing v0.6 mapping on a + v0.7 passport). + +Terms removed in a given version (the `TERM_REMOVED` sentinel — +currently `gtin` for v0.7) drop out of that version's mapper index. +The full mapping table and per-version usage examples are in the +[EU DPP export guide](../guides/eudpp-export.md). + +## Ontology Data Files + +Bundled Turtle files for offline validation: + +``` +vocabularies/data/ontologies/ +├── eudpp_core.ttl # Core ontology +├── product_dpp.ttl # Product DPP classes +├── actors_roles.ttl # Actor and role hierarchy +├── soc.ttl # Substances of Concern +└── lca.ttl # LCA impact categories +``` + +## References + +- [EU DPP Core Ontology](https://doi.org/10.5281/zenodo.15270342) (TalTech) +- [CIRPASS-2 Vocabulary Hub](https://dpp.vocabulary-hub.eu/) +- [ESPR Regulation](https://eur-lex.europa.eu/eli/reg/2024/1781) +- [PEF 3.1 Methodology](https://eplca.jrc.ec.europa.eu/LCDN/developerEF.xhtml) + +## See Also + +- [CIRPASS-2 Implementation](cirpass-implementation.md) +- [Seven-Layer Validation](validation-layers.md) +- [JSON-LD Export Guide](../guides/jsonld.md) diff --git a/docs/concepts/untp-schema.md b/docs/concepts/untp-schema.md index 0765f9c..d5a7908 100644 --- a/docs/concepts/untp-schema.md +++ b/docs/concepts/untp-schema.md @@ -1,3 +1,5 @@ + + # UNTP DPP Schema The UN Trade Facilitation and Electronic Business Centre (UN/CEFACT) has developed the United Nations Transparency Protocol (UNTP) for Digital Product Passports. @@ -8,7 +10,17 @@ The UNTP Digital Product Passport (DPP) is a standardized format for sharing pro ## Schema Version -dppvalidator currently supports UNTP DPP Schema version **0.6.1**. +dppvalidator supports UNTP DPP Schema versions **0.6.0**, **0.6.1** +(default), and **0.7.0**. Both wire shapes are first-class and +coexist in the same release. See [UNTP DPP versions](untp-versions.md) +for the full version-handling story; this page describes the schema +itself. + +The structure diagram below covers the **v0.6.x** shape — the wire +format used by the engine when no `$schema` or `@context` URL pins a +different version. The v0.7.0 shape is summarised at the end of this +page; the canonical v0.7.0 reference is the upstream DPP schema at +. ## Schema Structure @@ -85,19 +97,22 @@ Core product information: ## JSON Schema -The schema is available at: +Bundled SHA-pinned copies live under +[`src/dppvalidator/schemas/data/`](https://github.com/artiso-ai/dppvalidator/blob/main/src/dppvalidator/schemas/data/README.md); +the upstream production URLs are: -```text -https://vocabulary.uncefact.org/untp/dpp/0.6.1/schema.json -``` +| Version | Production URL | +| ------- | -------------------------------------------------------------------------------- | +| 0.6.1 | | +| 0.7.0 | | -## Example +## v0.6.x example ```json { "@context": [ "https://www.w3.org/ns/credentials/v2", - "https://vocabulary.uncefact.org/untp/dpp/0.6.1" + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/" ], "type": ["DigitalProductPassport", "VerifiableCredential"], "id": "https://example.com/dpp/battery-001", @@ -109,6 +124,7 @@ https://vocabulary.uncefact.org/untp/dpp/0.6.1/schema.json "validUntil": "2029-01-01T00:00:00Z", "credentialSubject": { "id": "https://example.com/product/battery-001", + "type": ["ProductPassport"], "product": { "id": "https://example.com/product/battery-001", "name": "EV Battery Pack", @@ -118,6 +134,66 @@ https://vocabulary.uncefact.org/untp/dpp/0.6.1/schema.json } ``` +## v0.7.0 example + +The structural shift in v0.7.0: `credentialSubject` IS the +`Product` directly, the `ProductPassport` envelope is gone, and the +top-level `name` field is required. Full v0.7.0-required Product +fields: `id`, `name`, `idScheme`, `idGranularity`, `productCategory`, +`producedAtFacility`, `countryOfProduction`. + +```json +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/" + ], + "type": ["DigitalProductPassport", "VerifiableCredential"], + "id": "https://example.com/dpp/battery-001", + "name": "EV Battery Pack DPP", + "issuer": { + "type": ["CredentialIssuer"], + "id": "did:web:example.com:manufacturer", + "name": "Battery Manufacturer Inc." + }, + "validFrom": "2024-01-01T00:00:00Z", + "validUntil": "2029-01-01T00:00:00Z", + "credentialSubject": { + "type": ["Product"], + "id": "https://example.com/product/battery-001", + "name": "EV Battery Pack", + "description": "High-capacity lithium-ion battery", + "idScheme": { + "type": ["IdentifierScheme"], + "id": "https://example.com/schemes/internal", + "name": "Manufacturer internal scheme" + }, + "idGranularity": "model", + "productCategory": [ + { + "type": ["Classification"], + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification", + "code": "46410", + "name": "Primary cells and primary batteries" + } + ], + "producedAtFacility": { + "type": ["Facility"], + "id": "https://example.com/facilities/001", + "name": "Manufacturer facility" + }, + "countryOfProduction": { + "countryCode": "DE", + "countryName": "Germany" + } + } +} +``` + +For a full field-by-field migration table from v0.6.x, see the +[migration guide](../guides/migration-0-6-to-0-7.md). + ## Related Standards - **W3C Verifiable Credentials** — Credential format diff --git a/docs/concepts/untp-versions.md b/docs/concepts/untp-versions.md new file mode 100644 index 0000000..3f3a7a0 --- /dev/null +++ b/docs/concepts/untp-versions.md @@ -0,0 +1,194 @@ + + +# UNTP DPP versions + +dppvalidator supports more than one UNTP DPP wire format. This page is the +single authoritative explanation of how versions are detected, which one +is the default, how to pick one explicitly, and how a new one would be +added. + +For a side-by-side migration walkthrough between specific versions, +see the [migration guide](../guides/migration-0-6-to-0-7.md). + +## Supported versions + +| UNTP DPP version | Default? | Status | Wire shape highlight | +| ---------------- | -------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| **0.6.0** | no | Supported (legacy) | Same envelope as 0.6.1; minor schema-only fixes only. | +| **0.6.1** | **yes** | Default — set in `dppvalidator.schemas.registry.DEFAULT_SCHEMA_VERSION` | `credentialSubject` is a `ProductPassport` envelope wrapping `Product`. | +| **0.7.0** | no | Fully supported | `credentialSubject` IS the `Product` directly. No `ProductPassport` envelope. | + +Future releases may flip the default. The flip is a separate minor +release with deprecation warnings — see *Adding a new version* below. + +## How a payload's version is detected + +`dppvalidator.validators.detection.detect_schema_version(data)` is the +**only** place where this decision is made. The detection rules apply +in order: + +1. **`$schema` URL** — if the payload carries a `$schema` field whose + URL matches a vendored schema basename + (`untp-dpp-schema-X.Y.Z.json` or `…/vX.Y.Z/.../DigitalProductPassport.json`), + that wins. +1. **`@context` URLs** — if a context entry matches a registered + pattern, the version is read from there. Two URL conventions are + recognised: + - Legacy (0.6.x): `https://test.uncefact.org/vocabulary/untp/dpp/X.Y.Z/` + - Modern (0.7.0+): `https://vocabulary.uncefact.org/untp/X.Y.Z/context/` +1. **Type marker** — if `"DigitalProductPassport"` appears in the + `type` array, the payload is recognised as a DPP and the + `DEFAULT_SCHEMA_VERSION` is used. +1. **Fallback** — `DEFAULT_SCHEMA_VERSION`. + +False positives are guarded by a registry-membership check at the call +sites: a `/X.Y.Z/` URL segment that doesn't appear in +`SCHEMA_REGISTRY` is rejected. Adding a new URL convention means +appending a pattern in [validators/detection.py](https://github.com/artiso-ai/dppvalidator/blob/main/src/dppvalidator/validators/detection.py), +not anywhere else. + +## Picking a version explicitly + +Three layers can pin a version. Pick the one that matches your +intent. + +### Engine-wide pin + +```python +from dppvalidator.validators import ValidationEngine + +# Pin the engine; auto-detection is bypassed. +engine = ValidationEngine(schema_version="0.7.0") +result = engine.validate(payload) +``` + +When `schema_version` and the payload's declared version disagree, +the engine emits a **VER001 version mismatch** error and refuses to +validate. This fail-fast behaviour catches accidental version +mixing in pipelines. + +### CLI pin + +```bash +# Validate as 0.7.0 regardless of the payload's declared version. +dppvalidator validate passport.json --schema-version 0.7.0 + +# Validate as 0.6.1 (current default; the flag is optional). +dppvalidator validate passport.json --schema-version 0.6.1 + +# List every version the registry knows about. +dppvalidator schema list +``` + +### Programmatic registry lookup + +```python +from dppvalidator.schemas.registry import SCHEMA_REGISTRY, SchemaRegistry + +reg = SchemaRegistry() +print(reg.available_versions) # ['0.6.0', '0.6.1', '0.7.0'] +print(reg.default_version) # '0.6.1' + +# The SHA-pinned upstream URL the bundled bytes came from. +print(reg.get_schema_url("0.7.0")) + +# The canonical production URL (when set; e.g. untp.unece.org for v0.7). +print(reg.get_production_url("0.7.0")) + +# JSON-LD context URLs paired with this version. +print(reg.get_context_urls("0.7.0")) +``` + +## Default version + +`dppvalidator.schemas.registry.DEFAULT_SCHEMA_VERSION` is the single +source of truth. Application code that needs to refer to "the active +default" version SHOULD use +`dppvalidator.compat.active_version()` instead of importing the +constant directly — both return the same string, but +`active_version()` keeps your call site outside the +no-version-literals guard's allow-list. + +```python +from dppvalidator.compat import active_version, is_version + +if is_version("0.7.0"): + # we're on a build whose default is v0.7.0 + ... +``` + +## Coexistence with v0.6.x + +v0.6.x and v0.7.0 coexist in the same release. Every validator layer, +every exporter, and every model package is version-aware: + +| Surface | v0.6 entry point | v0.7 entry point | +| ----------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------- | +| Pydantic model package | `dppvalidator.models.v0_6.*` | `dppvalidator.models.v0_7.*` | +| Top-level shim (back-compat) | `dppvalidator.models.passport.DigitalProductPassport` (= v0.6) | `dppvalidator.models.v0_7.envelope.DigitalProductPassport` | +| Semantic-rule registry | `dppvalidator.validators.rules.v0_6` | `dppvalidator.validators.rules.v0_7` | +| Deep-link path table | `LINK_PATHS_BY_VERSION["0.6.1"]` | `LINK_PATHS_BY_VERSION["0.7.0"]` | +| EU DPP exporter mapping table | `TermMapping.untp_v0_6` | `TermMapping.untp_v0_7` | +| Engine model dispatch | `_MODEL_BY_VERSION["0.6.1"]` | `_MODEL_BY_VERSION["0.7.0"]` | +| Compat shim (input upgrade) | n/a | `dppvalidator.compat.upgrade_0_6_to_0_7.upgrade(data)` | + +Plugin authors targeting a specific version should set +`applies_to_versions: tuple[str, ...] = ("0.7.0",)` on their rule +class and gate their attribute access on the wire shape they expect. +See the [example plugin](https://github.com/artiso-ai/dppvalidator/tree/main/examples/dppvalidator_example_plugin) +for a worked v0.6 vs v0.7 sibling pair. + +## Migrating between versions + +v0.6.x payloads can be upgraded to v0.7.0 shape via the bundled +compat shim: + +```bash +# Upgrade a single payload, write to stdout. +dppvalidator migrate passport.json + +# Upgrade in place, accepting any warnings. +dppvalidator migrate passport.json --in-place --accept-warnings + +# Validate the v0.6 payload against v0.7 (runs the shim, then validates). +dppvalidator validate passport.json --upgrade-from 0.6.1 --schema-version 0.7.0 +``` + +The shim emits structured warnings (`UPG001`–`UPG004`) for lossy or +synthesised values; a sidecar `.warnings.json` is always +written when blocking warnings fire. See the +[migration guide](../guides/migration-0-6-to-0-7.md) for the field +rename table and known shim limitations. + +## Adding a new UNTP version + +The full recipe lives in +[`.claude/skills/untp-migrate/SKILL.md`](https://github.com/artiso-ai/dppvalidator/blob/main/.claude/skills/untp-migrate/SKILL.md) +(invocable as `/untp-bump ` in Claude Code) and the +authoritative cardinal-rules document is +[`.claude/rules/untp-versioning.md`](https://github.com/artiso-ai/dppvalidator/blob/main/.claude/rules/untp-versioning.md). +The minimum touch list: + +1. `src/dppvalidator/schemas/registry.py` — one new `SchemaVersion` entry. +1. `src/dppvalidator/exporters/contexts.py` — one new `ContextDefinition` entry. +1. `src/dppvalidator/schemas/data/MANIFEST.json` — manifest entries for the new schema and context (with SHA-256, source URL, production URL). +1. `src/dppvalidator/schemas/data/untp-dpp-schema-X.Y.Z.json` — vendored schema bytes. +1. `src/dppvalidator/vocabularies/data/untp-context-X.Y.Z.jsonld` — vendored context bytes. +1. `src/dppvalidator/models/vX_Y/` — new Pydantic model package. +1. `src/dppvalidator/validators/model.py` — add to `_MODEL_BY_VERSION`. +1. `src/dppvalidator/validators/detection.py` — extend URL pattern if the namespace shape changed. +1. `src/dppvalidator/compat/upgrade__to_.py` — input shim from the previous version. +1. `tests/fixtures/upstream/vX.Y.Z/` — vendored upstream samples + schema. +1. `tests/integration/test_version_matrix.py` — add the new version to the matrix. +1. `docs/plans/UNTP_X.Y.Z_MIGRATION.md` — full migration doc. + +If your change touches more than this list, you're either fixing an +unrelated bug (split the PR) or going around the version-aware +spine (don't). + +## See also + +- [Migration guide: 0.6.x → 0.7.0](../guides/migration-0-6-to-0-7.md) +- [Validation pipeline](validation-layers.md) — how the version flows through the validator layers. +- [EU DPP ontology alignment](eudpp-ontology-alignment.md) — how UNTP versions map onto EU DPP / CIRPASS-2 ontology terms. +- [Migration plan archive](https://github.com/artiso-ai/dppvalidator/blob/main/docs/plans/UNTP_0.7.0_MIGRATION.md) — phased history of the v0.7.0 migration. diff --git a/docs/concepts/validation-layers.md b/docs/concepts/validation-layers.md index 0f86b9b..7a3d4a7 100644 --- a/docs/concepts/validation-layers.md +++ b/docs/concepts/validation-layers.md @@ -1,6 +1,6 @@ -# Three-Layer Validation +# Seven-Layer Validation -dppvalidator uses a three-layer validation architecture to ensure Digital Product Passports are structurally correct, type-safe, and semantically meaningful. +dppvalidator uses a seven-layer validation architecture to ensure Digital Product Passports are structurally correct, type-safe, semantically meaningful, cryptographically verifiable, and supply-chain traceable. ## Architecture @@ -10,6 +10,10 @@ flowchart TD A[/"📄 Input Data (JSON)"/] end + subgraph Layer0["Layer 0: Schema Detection"] + A0["Auto-detect schema version
from $schema, @context, type"] + end + subgraph Layer1["Layer 1: Schema Validation"] B["JSON Schema Draft 2020-12
Required fields, types, formats"] end @@ -18,25 +22,92 @@ flowchart TD C["Pydantic v2 Models
Type coercion, URL validation"] end - subgraph Layer3["Layer 3: Semantic Validation"] - D["Business Rules & Vocabularies
ISO codes, date logic, references"] + subgraph Layer3["Layer 3: JSON-LD Semantic"] + C2["PyLD Expansion
Context resolution, term validation"] + end + + subgraph Layer4["Layer 4: Business Logic"] + D["Business Rules & Vocabularies
ISO codes, date logic, GTIN checksums"] + end + + subgraph Layer5["Layer 5: Cryptographic"] + E["VC Signature Verification
DID resolution, Ed25519/ECDSA"] end subgraph Output - E[/"✅ ValidationResult
.valid | .errors | .warnings"/] + F[/"✅ ValidationResult
.valid | .errors | .signature_valid"/] end - A --> B + A --> A0 + A0 --> B B -->|"SCH001-SCH099"| C - C -->|"MOD001-MOD099"| D + C -->|"MOD001-MOD099"| C2 + C2 -->|"JLD001-JLD099"| D D -->|"SEM001-SEM099"| E + E -->|"SIG001-SIG099"| F + style Layer0 fill:#fce4ec,stroke:#c2185b style Layer1 fill:#e3f2fd,stroke:#1976d2 style Layer2 fill:#fff3e0,stroke:#f57c00 - style Layer3 fill:#e8f5e9,stroke:#388e3c + style Layer3 fill:#e0f7fa,stroke:#0097a7 + style Layer4 fill:#e8f5e9,stroke:#388e3c + style Layer5 fill:#fff8e1,stroke:#ffa000 style Output fill:#f3e5f5,stroke:#7b1fa2 ``` +## Layer 0: Schema Detection + +Automatically detects the DPP schema version from the input document. + +**Detection priority:** + +1. `$schema` URL pattern (e.g., `untp-dpp-schema-0.6.1.json` or + `…/v0.7.0/.../DigitalProductPassport.json`) +1. `@context` URLs: + - Legacy (0.6.x): `https://test.uncefact.org/vocabulary/untp/dpp/X.Y.Z/` + - Modern (0.7.0+): `https://vocabulary.uncefact.org/untp/X.Y.Z/context/` +1. `type` array presence → default version +1. Fallback to `dppvalidator.schemas.registry.DEFAULT_SCHEMA_VERSION` + (currently `0.6.1`) + +```python +from dppvalidator import ValidationEngine + +# Auto-detection (default) +engine = ValidationEngine() + +# Pin v0.6.1 explicitly. A v0.7.0 payload through this engine fails +# fast with VER001 (version mismatch). +engine = ValidationEngine(schema_version="0.6.1") + +# Pin v0.7.0 explicitly. +engine = ValidationEngine(schema_version="0.7.0") +``` + +The full version-handling story (detection internals, default-version +constant, adding a new UNTP version) lives in +[UNTP DPP versions](untp-versions.md). + +### Per-version layer dispatch + +Layers 1–3 below dispatch through version-keyed tables — the engine +selects the right model / rule set / link paths for the detected +version. The dispatch is centralised in three tables: + + + +| Table | Module | Layer it powers | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| `_MODEL_BY_VERSION` | [`validators/model.py`](https://github.com/artiso-ai/dppvalidator/blob/main/src/dppvalidator/validators/model.py) | Model layer (Layer 2) | +| `ALL_RULES_BY_VERSION` | [`validators/rules/__init__.py`](https://github.com/artiso-ai/dppvalidator/blob/main/src/dppvalidator/validators/rules/__init__.py) | Semantic layer (Layer 4) | +| `LINK_PATHS_BY_VERSION` | [`validators/deep.py`](https://github.com/artiso-ai/dppvalidator/blob/main/src/dppvalidator/validators/deep.py) | Deep validator (separate from layers 1–5) | + + + +Plugin authors can opt into version-aware dispatch by setting +`applies_to_versions = ("0.7.0",)` on their rule class — see the +[plugin development guide](../guides/plugins.md#writing-a-version-aware-rule). + ## Layer 1: Schema Validation Validates JSON structure against the UNTP DPP JSON Schema. @@ -66,24 +137,93 @@ Validates data against Pydantic models with stricter type checking. **Error codes:** `MOD001` - `MOD099` -## Layer 3: Semantic Validation +## Layer 3: JSON-LD Semantic Validation + +Validates JSON-LD semantics using PyLD expansion algorithm. + +**What it checks:** + +- `@context` is present and valid +- All terms resolve during expansion (no undefined terms) +- Custom terms use proper namespacing +- Context URLs are reachable + +**Error codes:** `JLD001` - `JLD099` + +```python +from dppvalidator import ValidationEngine + +# Enable JSON-LD validation +engine = ValidationEngine(validate_jsonld=True) + +# Or via layers +engine = ValidationEngine(layers=["schema", "model", "jsonld"]) +``` + +## Layer 4: Business Logic Validation Validates business rules and external vocabulary references. **What it checks:** - Vocabulary values (ISO country codes, UN/CEFACT unit codes) +- Material codes (UNECE Rec 46) +- HS codes for product classification +- GTIN checksums (GS1 standard) - Date relationships (validFrom < validUntil) -- Identifier format validation - Cross-reference consistency -- Domain-specific rules -**Error codes:** `SEM001` - `SEM099` +**Error codes:** `SEM001` - `SEM099`, `VOC001` - `VOC099` + +## Layer 5: Cryptographic Verification + +Verifies Verifiable Credential signatures and DID resolution. + +**What it checks:** + +- DID resolution (`did:web`, `did:key`) +- Signature verification (Ed25519, ES256, ES384) +- Proof types (Ed25519Signature2020, DataIntegrityProof, JsonWebSignature2020) + +**Error codes:** `SIG001` - `SIG099` + +```python +from dppvalidator import ValidationEngine + +# Enable signature verification +engine = ValidationEngine(verify_signatures=True) +result = engine.validate(dpp_data) + +# Check verification status +if result.signature_valid: + print(f"Signed by: {result.issuer_did}") +``` + +## Deep Validation + +For supply chain traceability, use async deep validation to crawl linked documents. + +```python +from dppvalidator import ValidationEngine + +engine = ValidationEngine() + +# Validate with supply chain traversal +result = await engine.validate_deep( + dpp_data, + max_depth=3, + follow_links=["credentialSubject.traceabilityEvents"], + timeout=30.0, +) + +print(f"Total documents: {result.total_documents}") +print(f"All valid: {result.valid}") +``` ## Selecting Layers ```python -from dppvalidator.validators import ValidationEngine +from dppvalidator import ValidationEngine # All layers (default) engine = ValidationEngine() @@ -93,16 +233,29 @@ engine = ValidationEngine(layers=["schema"]) # Model + Semantic (skip schema) engine = ValidationEngine(layers=["model", "semantic"]) + +# Full validation with JSON-LD and signatures +engine = ValidationEngine( + validate_jsonld=True, + verify_signatures=True, +) ``` ## Performance -| Layer | Typical Time | -| -------- | ------------ | -| Schema | ~5μs | -| Model | ~8μs | -| Semantic | ~3μs | -| **All** | **~13μs** | +Benchmark results (1000 iterations, Apple Silicon): + +| Layer | Mean Time | Throughput | +| ---------------- | --------- | ----------------- | +| Model (minimal) | 0.012ms | 84,387 ops/sec | +| Model (full) | 0.016ms | 63,945 ops/sec | +| Semantic | 0.005ms | 200,889 ops/sec | +| Full (Model+Sem) | 0.022ms | 45,735 ops/sec | +| Engine Creation | 0.001ms | 1,524,868 ops/sec | + +Run benchmarks: `uv run python -m benchmarks.run_benchmarks --all` + +*JSON-LD and signature verification depend on network latency (cached after first request).* ## Next Steps diff --git a/docs/dpp_validator_description.md b/docs/dpp_validator_description.md new file mode 100644 index 0000000..ad4521e --- /dev/null +++ b/docs/dpp_validator_description.md @@ -0,0 +1,13 @@ +## Digital Product Passport Validator (open-source) + +### What it does + +Validates Digital Product Passports against EU regulations — seven layers of checks (schema, semantics, cryptographic signatures) in under 1 ms per passport, covering both UN/CEFACT and EU CIRPASS-2 standards. + +### Why fashion needs it + +Starting 2027, every textile product sold in the EU must carry a compliant Digital Product Passport under the ESPR regulation. A non-compliant passport means the product cannot legally enter the market. Brands need to catch errors before production — not at the border. + +### Why open-source + +We publish this as the only comprehensive open-source DPP validator with full EU ontology support. The regulation is new and the standard is still evolving — open-source builds trust and lowers the barrier for brands to start preparing now. For us, it's a natural entry point: every fashion company that validates passports through our tool becomes familiar with our stack. diff --git a/docs/errors/CQ001.md b/docs/errors/CQ001.md new file mode 100644 index 0000000..7123e62 --- /dev/null +++ b/docs/errors/CQ001.md @@ -0,0 +1,31 @@ +# CQ001 - Missing Mandatory Attributes + +## Description + +Validation error CQ001. + +## Category + +CIRPASS Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/CQ004.md b/docs/errors/CQ004.md new file mode 100644 index 0000000..9e05150 --- /dev/null +++ b/docs/errors/CQ004.md @@ -0,0 +1,31 @@ +# CQ004 - Missing CAS Number + +## Description + +Validation error CQ004. + +## Category + +CIRPASS Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/CQ011.md b/docs/errors/CQ011.md new file mode 100644 index 0000000..72e86a7 --- /dev/null +++ b/docs/errors/CQ011.md @@ -0,0 +1,31 @@ +# CQ011 - Missing Operator ID + +## Description + +Validation error CQ011. + +## Category + +CIRPASS Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/CQ016.md b/docs/errors/CQ016.md new file mode 100644 index 0000000..6f1b177 --- /dev/null +++ b/docs/errors/CQ016.md @@ -0,0 +1,31 @@ +# CQ016 - Missing Validity Period + +## Description + +Validation error CQ016. + +## Category + +CIRPASS Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/CQ017.md b/docs/errors/CQ017.md new file mode 100644 index 0000000..ab6af13 --- /dev/null +++ b/docs/errors/CQ017.md @@ -0,0 +1,31 @@ +# CQ017 - Granularity Mismatch + +## Description + +Validation error CQ017. + +## Category + +CIRPASS Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/CQ020.md b/docs/errors/CQ020.md new file mode 100644 index 0000000..d5e7a93 --- /dev/null +++ b/docs/errors/CQ020.md @@ -0,0 +1,31 @@ +# CQ020 - Missing Weight/Volume + +## Description + +Validation error CQ020. + +## Category + +CIRPASS Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/JLD001.md b/docs/errors/JLD001.md new file mode 100644 index 0000000..9176f0a --- /dev/null +++ b/docs/errors/JLD001.md @@ -0,0 +1,44 @@ +# JLD001 - Missing Context + +**Severity:** Error +**Layer:** JSON-LD Semantic + +## Description + +The `@context` property is missing or invalid. DPPs must include a valid JSON-LD +context to ensure semantic interoperability. + +## Example + +```json +{ + "type": ["DigitalProductPassport", "VerifiableCredential"], + "credentialSubject": { + "id": "https://example.com/product/123" + } +} +``` + +**Error:** `@context` must be present and valid. + +## Resolution + +Add a valid `@context` property referencing the UNTP DPP vocabulary: + +```json +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/" + ], + "type": ["DigitalProductPassport", "VerifiableCredential"], + "credentialSubject": { + "id": "https://example.com/product/123" + } +} +``` + +## Related + +- [JLD002 - Undefined Terms](JLD002.md) +- [Seven-Layer Validation](../concepts/validation-layers.md) diff --git a/docs/errors/JLD002.md b/docs/errors/JLD002.md new file mode 100644 index 0000000..1e83805 --- /dev/null +++ b/docs/errors/JLD002.md @@ -0,0 +1,52 @@ +# JLD002 - Undefined Terms + +**Severity:** Warning (Error in strict mode) +**Layer:** JSON-LD Semantic + +## Description + +One or more terms in the document are not defined in the `@context`. During +JSON-LD expansion, undefined terms are dropped, which may indicate namespace +pollution or typos. + +## Example + +```json +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/" + ], + "type": ["DigitalProductPassport"], + "customField": "This term is not in the context" +} +``` + +**Warning:** Term `customField` is not defined in `@context` and will be dropped +during expansion. + +## Resolution + +Either: + +1. **Remove the undefined term** if it's not needed +1. **Add a custom context** that defines the term: + +```json +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + { + "customField": "https://example.com/vocab#customField" + } + ], + "type": ["DigitalProductPassport"], + "customField": "Now properly namespaced" +} +``` + +## Related + +- [JLD001 - Missing Context](JLD001.md) +- [JLD003 - Namespace Pollution](JLD003.md) diff --git a/docs/errors/JLD003.md b/docs/errors/JLD003.md new file mode 100644 index 0000000..775399f --- /dev/null +++ b/docs/errors/JLD003.md @@ -0,0 +1,54 @@ +# JLD003 - Namespace Pollution + +**Severity:** Warning +**Layer:** JSON-LD Semantic + +## Description + +Custom terms are used without proper namespacing. Terms should use a prefix +(e.g., `ex:customField`) or be explicitly defined in the context to avoid +collisions with standard vocabulary terms. + +## Example + +```json +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + { + "myField": "https://example.com/vocab#myField" + } + ], + "type": ["DigitalProductPassport"], + "myField": "value", + "anotherUnprefixedTerm": "problematic" +} +``` + +**Warning:** Term `anotherUnprefixedTerm` lacks proper namespacing. + +## Resolution + +Use prefixed terms or define all custom terms in the context: + +```json +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + { + "ex": "https://example.com/vocab#", + "myField": "ex:myField" + } + ], + "type": ["DigitalProductPassport"], + "myField": "value", + "ex:anotherTerm": "properly prefixed" +} +``` + +## Related + +- [JLD002 - Undefined Terms](JLD002.md) +- [Seven-Layer Validation](../concepts/validation-layers.md) diff --git a/docs/errors/JLD004.md b/docs/errors/JLD004.md new file mode 100644 index 0000000..08ca1b4 --- /dev/null +++ b/docs/errors/JLD004.md @@ -0,0 +1,61 @@ +# JLD004 - Context Resolution + +**Severity:** Warning +**Layer:** JSON-LD Semantic + +## Description + +Failed to resolve a remote `@context` URL. This may be due to network issues, +the context server being unavailable, or an invalid URL. + +## Example + +```json +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://invalid-domain.example/context.jsonld" + ], + "type": ["DigitalProductPassport"] +} +``` + +**Warning:** Failed to resolve context: `https://invalid-domain.example/context.jsonld` + +## Resolution + +1. **Verify the URL** is correct and accessible +1. **Check network connectivity** to the context server +1. **Use cached contexts** by enabling the `CachingDocumentLoader`: + +```python +from dppvalidator import ValidationEngine + +# Contexts are cached by default +engine = ValidationEngine(validate_jsonld=True) +``` + +4. **Use inline contexts** for custom vocabularies instead of remote URLs: + +```json +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + { + "ex": "https://example.com/vocab#", + "customField": "ex:customField" + } + ] +} +``` + +## Notes + +- The validator uses an LRU cache for remote contexts +- Standard UNTP contexts are cached after first resolution +- Validation continues with a warning if context resolution fails + +## Related + +- [JLD001 - Missing Context](JLD001.md) +- [Seven-Layer Validation](../concepts/validation-layers.md) diff --git a/docs/errors/MDL001.md b/docs/errors/MDL001.md new file mode 100644 index 0000000..c7cb716 --- /dev/null +++ b/docs/errors/MDL001.md @@ -0,0 +1,31 @@ +# MDL001 - Model Validation Failed + +## Description + +Check field types and constraints defined in Pydantic models. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Check field types and constraints defined in Pydantic models. + +## Example + +```json +None +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL002.md b/docs/errors/MDL002.md new file mode 100644 index 0000000..609aa38 --- /dev/null +++ b/docs/errors/MDL002.md @@ -0,0 +1,31 @@ +# MDL002 - Invalid URL Format + +## Description + +Provide a valid URL with scheme (https://) + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Provide a valid URL with scheme (https://) + +## Example + +```json +"id": "https://example.com/credentials/dpp-001" +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL003.md b/docs/errors/MDL003.md new file mode 100644 index 0000000..4d344f5 --- /dev/null +++ b/docs/errors/MDL003.md @@ -0,0 +1,31 @@ +# MDL003 - Invalid DateTime Format + +## Description + +Use ISO 8601 datetime format. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Use ISO 8601 datetime format. + +## Example + +```json +"validFrom": "2024-01-01T00:00:00Z" +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL010.md b/docs/errors/MDL010.md new file mode 100644 index 0000000..729a5b5 --- /dev/null +++ b/docs/errors/MDL010.md @@ -0,0 +1,31 @@ +# MDL010 - Invalid Issuer + +## Description + +Validation error MDL010. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL011.md b/docs/errors/MDL011.md new file mode 100644 index 0000000..8acbaa1 --- /dev/null +++ b/docs/errors/MDL011.md @@ -0,0 +1,31 @@ +# MDL011 - Invalid Issuer ID + +## Description + +Validation error MDL011. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL012.md b/docs/errors/MDL012.md new file mode 100644 index 0000000..6460602 --- /dev/null +++ b/docs/errors/MDL012.md @@ -0,0 +1,31 @@ +# MDL012 - Invalid Issuer Name + +## Description + +Validation error MDL012. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL013.md b/docs/errors/MDL013.md new file mode 100644 index 0000000..8ec2787 --- /dev/null +++ b/docs/errors/MDL013.md @@ -0,0 +1,31 @@ +# MDL013 - Invalid Issuer Type + +## Description + +Validation error MDL013. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL014.md b/docs/errors/MDL014.md new file mode 100644 index 0000000..53251b5 --- /dev/null +++ b/docs/errors/MDL014.md @@ -0,0 +1,31 @@ +# MDL014 - Invalid Issuer Location + +## Description + +Validation error MDL014. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL015.md b/docs/errors/MDL015.md new file mode 100644 index 0000000..42354c8 --- /dev/null +++ b/docs/errors/MDL015.md @@ -0,0 +1,31 @@ +# MDL015 - Invalid Issuer Identifier + +## Description + +Validation error MDL015. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL016.md b/docs/errors/MDL016.md new file mode 100644 index 0000000..ed34533 --- /dev/null +++ b/docs/errors/MDL016.md @@ -0,0 +1,31 @@ +# MDL016 - Invalid Identifier Scheme + +## Description + +Validation error MDL016. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL020.md b/docs/errors/MDL020.md new file mode 100644 index 0000000..4c54969 --- /dev/null +++ b/docs/errors/MDL020.md @@ -0,0 +1,31 @@ +# MDL020 - Invalid Credential Subject + +## Description + +Validation error MDL020. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL021.md b/docs/errors/MDL021.md new file mode 100644 index 0000000..9eca8e5 --- /dev/null +++ b/docs/errors/MDL021.md @@ -0,0 +1,31 @@ +# MDL021 - Invalid Credential Subject ID + +## Description + +Validation error MDL021. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL022.md b/docs/errors/MDL022.md new file mode 100644 index 0000000..4fe7190 --- /dev/null +++ b/docs/errors/MDL022.md @@ -0,0 +1,31 @@ +# MDL022 - Invalid Credential Subject Type + +## Description + +Validation error MDL022. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL030.md b/docs/errors/MDL030.md new file mode 100644 index 0000000..dd70475 --- /dev/null +++ b/docs/errors/MDL030.md @@ -0,0 +1,31 @@ +# MDL030 - Invalid Product + +## Description + +Validation error MDL030. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL031.md b/docs/errors/MDL031.md new file mode 100644 index 0000000..cecc179 --- /dev/null +++ b/docs/errors/MDL031.md @@ -0,0 +1,31 @@ +# MDL031 - Invalid Product ID + +## Description + +Validation error MDL031. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL032.md b/docs/errors/MDL032.md new file mode 100644 index 0000000..1bce623 --- /dev/null +++ b/docs/errors/MDL032.md @@ -0,0 +1,31 @@ +# MDL032 - Invalid Product Name + +## Description + +Validation error MDL032. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL033.md b/docs/errors/MDL033.md new file mode 100644 index 0000000..7d1f139 --- /dev/null +++ b/docs/errors/MDL033.md @@ -0,0 +1,31 @@ +# MDL033 - Invalid Product Category + +## Description + +Validation error MDL033. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL040.md b/docs/errors/MDL040.md new file mode 100644 index 0000000..a09f020 --- /dev/null +++ b/docs/errors/MDL040.md @@ -0,0 +1,31 @@ +# MDL040 - Invalid Material + +## Description + +Validation error MDL040. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL041.md b/docs/errors/MDL041.md new file mode 100644 index 0000000..055f195 --- /dev/null +++ b/docs/errors/MDL041.md @@ -0,0 +1,31 @@ +# MDL041 - Invalid Material Name + +## Description + +Validation error MDL041. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL042.md b/docs/errors/MDL042.md new file mode 100644 index 0000000..f202846 --- /dev/null +++ b/docs/errors/MDL042.md @@ -0,0 +1,31 @@ +# MDL042 - Invalid Material Fraction + +## Description + +Validation error MDL042. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL050.md b/docs/errors/MDL050.md new file mode 100644 index 0000000..635855f --- /dev/null +++ b/docs/errors/MDL050.md @@ -0,0 +1,31 @@ +# MDL050 - Invalid Claim + +## Description + +Validation error MDL050. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL051.md b/docs/errors/MDL051.md new file mode 100644 index 0000000..d86d65b --- /dev/null +++ b/docs/errors/MDL051.md @@ -0,0 +1,31 @@ +# MDL051 - Invalid Claim Type + +## Description + +Validation error MDL051. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL052.md b/docs/errors/MDL052.md new file mode 100644 index 0000000..442b82f --- /dev/null +++ b/docs/errors/MDL052.md @@ -0,0 +1,31 @@ +# MDL052 - Invalid Claim Topic + +## Description + +Validation error MDL052. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL053.md b/docs/errors/MDL053.md new file mode 100644 index 0000000..0216737 --- /dev/null +++ b/docs/errors/MDL053.md @@ -0,0 +1,31 @@ +# MDL053 - Invalid Claim Assessment + +## Description + +Validation error MDL053. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL060.md b/docs/errors/MDL060.md new file mode 100644 index 0000000..ec21206 --- /dev/null +++ b/docs/errors/MDL060.md @@ -0,0 +1,31 @@ +# MDL060 - Invalid Traceability + +## Description + +Validation error MDL060. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL061.md b/docs/errors/MDL061.md new file mode 100644 index 0000000..e842fa1 --- /dev/null +++ b/docs/errors/MDL061.md @@ -0,0 +1,31 @@ +# MDL061 - Invalid Traceability Event + +## Description + +Validation error MDL061. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL070.md b/docs/errors/MDL070.md new file mode 100644 index 0000000..fb876cc --- /dev/null +++ b/docs/errors/MDL070.md @@ -0,0 +1,31 @@ +# MDL070 - Invalid Circularity + +## Description + +Validation error MDL070. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL071.md b/docs/errors/MDL071.md new file mode 100644 index 0000000..50e7fbf --- /dev/null +++ b/docs/errors/MDL071.md @@ -0,0 +1,31 @@ +# MDL071 - Invalid Circularity Content + +## Description + +Validation error MDL071. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL080.md b/docs/errors/MDL080.md new file mode 100644 index 0000000..a94d4c7 --- /dev/null +++ b/docs/errors/MDL080.md @@ -0,0 +1,31 @@ +# MDL080 - Invalid Emission + +## Description + +Validation error MDL080. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL081.md b/docs/errors/MDL081.md new file mode 100644 index 0000000..ae1f3e6 --- /dev/null +++ b/docs/errors/MDL081.md @@ -0,0 +1,31 @@ +# MDL081 - Invalid Emission Value + +## Description + +Validation error MDL081. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL090.md b/docs/errors/MDL090.md new file mode 100644 index 0000000..69a4076 --- /dev/null +++ b/docs/errors/MDL090.md @@ -0,0 +1,31 @@ +# MDL090 - Invalid Facility + +## Description + +Validation error MDL090. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL091.md b/docs/errors/MDL091.md new file mode 100644 index 0000000..204d8f4 --- /dev/null +++ b/docs/errors/MDL091.md @@ -0,0 +1,31 @@ +# MDL091 - Invalid Facility Location + +## Description + +Validation error MDL091. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/MDL099.md b/docs/errors/MDL099.md new file mode 100644 index 0000000..310c8be --- /dev/null +++ b/docs/errors/MDL099.md @@ -0,0 +1,31 @@ +# MDL099 - Unknown Model Error + +## Description + +Validation error MDL099. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/PRS001.md b/docs/errors/PRS001.md new file mode 100644 index 0000000..e8e0511 --- /dev/null +++ b/docs/errors/PRS001.md @@ -0,0 +1,59 @@ +# PRS001 - File Not Found + +## Description + +The specified file path does not exist or is not accessible. This error occurs +when attempting to validate a file that cannot be read. + +## Severity + +**Error** + +## When This Occurs + +- File path provided to `validate()` does not exist +- File has been moved or deleted +- Insufficient permissions to read the file + +## Example + +```python +from dppvalidator import ValidationEngine + +engine = ValidationEngine() +result = engine.validate("nonexistent.json") # PRS001 error +``` + +## Resolution + +1. **Verify file exists:** + + ```python + from pathlib import Path + + path = Path("document.json") + if path.exists(): + result = engine.validate(path) + else: + print(f"File not found: {path}") + ``` + +1. **Check file permissions:** + + ```bash + ls -la document.json + ``` + +1. **Use absolute path:** + + ```python + from pathlib import Path + + path = Path("document.json").resolve() + result = engine.validate(path) + ``` + +## Related + +- [PRS002 - Invalid JSON](PRS002.md) +- [PRS003 - Unsupported Input Type](PRS003.md) diff --git a/docs/errors/PRS002.md b/docs/errors/PRS002.md new file mode 100644 index 0000000..70074db --- /dev/null +++ b/docs/errors/PRS002.md @@ -0,0 +1,59 @@ +# PRS002 - Invalid JSON + +## Description + +The input is not valid JSON. This error occurs when the JSON parser encounters +syntax errors in the document. + +## Severity + +Error + +## When This Occurs + +- JSON contains syntax errors (missing commas, brackets, quotes) +- File contains non-JSON content +- Encoding issues in the file + +## Example + +```json +{ + "name": "Product" + "description": "Missing comma above" +} +``` + +## Common Causes + +1. Missing commas between properties +1. Trailing commas (not allowed in JSON) +1. Single quotes instead of double quotes +1. Unquoted property names +1. Comments in JSON (not allowed) + +## Resolution + +1. Validate JSON syntax: + + ```bash + python -m json.tool document.json + ``` + +1. Use a JSON linter: + + ```bash + jq . document.json + ``` + +1. Check encoding (should be UTF-8): + + ```python + with open("document.json", "r", encoding="utf-8") as f: + data = json.load(f) + ``` + +## Related + +- [PRS001 - File Not Found](PRS001.md) +- [PRS003 - Unsupported Input Type](PRS003.md) diff --git a/docs/errors/PRS003.md b/docs/errors/PRS003.md new file mode 100644 index 0000000..ddb18eb --- /dev/null +++ b/docs/errors/PRS003.md @@ -0,0 +1,64 @@ +# PRS003 - Unsupported Input Type + +## Description + +The input provided to the validator is not a supported type. The validator +accepts file paths, JSON strings, or dictionary objects. + +## Severity + +Error + +## When This Occurs + +- Passing an unsupported Python object type to `validate()` +- Using a custom object that cannot be serialized to JSON + +## Example + +```python +from dppvalidator import ValidationEngine + +engine = ValidationEngine() + +# These will cause PRS003: +result = engine.validate(12345) # int not supported +result = engine.validate(["list", "of"]) # list not supported +result = engine.validate(None) # None not supported +``` + +## Supported Input Types + +| Type | Example | +| ----------------- | ----------------------- | +| `str` (file path) | `"document.json"` | +| `str` (JSON) | `'{"@context": [...]}'` | +| `dict` | `{"@context": [...]}` | +| `Path` | `Path("document.json")` | + +## Resolution + +1. Use a supported input type: + + ```python + # From file path + result = engine.validate("document.json") + + # From dict + result = engine.validate({"@context": ["https://..."], "type": "..."}) + + # From JSON string + result = engine.validate('{"@context": ["https://..."]}') + ``` + +1. Convert custom objects to dict: + + ```python + data = my_object.to_dict() + result = engine.validate(data) + ``` + +## Related + +- [PRS001 - File Not Found](PRS001.md) +- [PRS002 - Invalid JSON](PRS002.md) diff --git a/docs/errors/PRS004.md b/docs/errors/PRS004.md new file mode 100644 index 0000000..92fa7da --- /dev/null +++ b/docs/errors/PRS004.md @@ -0,0 +1,72 @@ +# PRS004 - Input Too Large + +## Description + +The input document exceeds the maximum allowed size. This limit exists to +prevent memory exhaustion and denial-of-service scenarios. + +## Severity + +Error + +## When This Occurs + +- Document size exceeds the configured `max_input_size` +- Default limit is 10 MB (10,485,760 bytes) + +## Example + +```python +from dppvalidator import ValidationEngine + +engine = ValidationEngine() +result = engine.validate("very_large_document.json") +# PRS004 if file > 10 MB +``` + +## Resolution + +1. Increase the size limit if needed: + + ```python + from dppvalidator import ValidationEngine + + engine = ValidationEngine(max_input_size=50_000_000) # 50 MB + result = engine.validate("large_document.json") + ``` + +1. Split large documents into smaller parts: + + ```python + # Process products individually + for product_file in product_files: + result = engine.validate(product_file) + ``` + +1. Remove unnecessary data before validation: + + ```python + import json + + with open("large.json") as f: + data = json.load(f) + + # Remove large binary data + if "productImage" in data.get("credentialSubject", {}).get("product", {}): + del data["credentialSubject"]["product"]["productImage"] + + result = engine.validate(data) + ``` + +## Configuration + +The default limit can be configured via environment variable: + +```bash +export DPPVALIDATOR_MAX_INPUT_SIZE=52428800 # 50 MB +``` + +## Related + +- [PRS001 - File Not Found](PRS001.md) +- [Validation Guide](../guides/validation.md) diff --git a/docs/errors/PRS005.md b/docs/errors/PRS005.md new file mode 100644 index 0000000..cd56093 --- /dev/null +++ b/docs/errors/PRS005.md @@ -0,0 +1,68 @@ +# PRS005 - File Size Exceeded + +## Description + +The file path provided for validation exceeds the maximum allowed file size. +This check occurs before reading the file to prevent memory exhaustion attacks. + +## Severity + +Error + +## When This Occurs + +- A file path is passed to `validate()` and the file size exceeds `max_input_size` +- Default limit is 10 MB (10,485,760 bytes) +- This is checked before the file is read into memory + +## Example + +```python +from pathlib import Path +from dppvalidator import ValidationEngine + +engine = ValidationEngine() +result = engine.validate(Path("huge_passport.json")) +# PRS005 if file size > 10 MB +``` + +## Resolution + +1. Increase the size limit if needed: + + ```python + from pathlib import Path + from dppvalidator import ValidationEngine + + engine = ValidationEngine(max_input_size=50_000_000) # 50 MB + result = engine.validate(Path("large_passport.json")) + ``` + +1. Read and filter the file manually: + + ```python + import json + from dppvalidator import ValidationEngine + + with open("large_passport.json") as f: + data = json.load(f) + + # Remove large embedded data + if "productImage" in data.get("credentialSubject", {}).get("product", {}): + del data["credentialSubject"]["product"]["productImage"] + + engine = ValidationEngine() + result = engine.validate(data) # Pass dict directly + ``` + +1. Split into smaller documents if possible. + +## Difference from PRS004 + +- **PRS004**: Triggered when in-memory data (string/dict) exceeds size limit +- **PRS005**: Triggered when file size check fails before reading + +## Related + +- [PRS004 - Input Too Large](PRS004.md) +- [PRS001 - File Not Found](PRS001.md) diff --git a/docs/errors/SCH001.md b/docs/errors/SCH001.md new file mode 100644 index 0000000..7d6e147 --- /dev/null +++ b/docs/errors/SCH001.md @@ -0,0 +1,59 @@ +# SCH001 - Schema Not Loaded + +## Description + +The JSON Schema validator could not load a schema for validation. This typically +occurs when the schema file is missing, corrupted, or the schema version is not +supported. + +## Severity + +**Warning** + +## When This Occurs + +- Schema file not found in the expected location +- Schema version not supported by the validator +- Offline mode without cached schemas + +## Example + +```json +{ + "@context": ["https://www.w3.org/ns/credentials/v2"], + "type": ["VerifiableCredential", "DigitalProductPassport"], + "credentialSubject": { + "product": { "name": "Test Product" } + } +} +``` + +If no schema is available, validation continues but schema-level checks are +skipped. + +## Resolution + +1. **Ensure schema files are present:** + + ```bash + dppvalidator doctor + ``` + +1. **Update schema cache:** + + ```bash + dppvalidator validate --update-schemas document.json + ``` + +1. **Check schema version compatibility:** + + ```python + from dppvalidator import ValidationEngine + + engine = ValidationEngine() + print(f"Supported versions: {engine.supported_schema_versions}") + ``` + +## Related + +- [Seven-Layer Validation](../concepts/validation-layers.md) diff --git a/docs/errors/SCH002.md b/docs/errors/SCH002.md new file mode 100644 index 0000000..f6c7866 --- /dev/null +++ b/docs/errors/SCH002.md @@ -0,0 +1,31 @@ +# SCH002 - Type Mismatch + +## Description + +Add the missing required field to your DPP data. + +## Category + +Schema Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Add the missing required field to your DPP data. + +## Example + +```json +None +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/SCH003.md b/docs/errors/SCH003.md new file mode 100644 index 0000000..de403ff --- /dev/null +++ b/docs/errors/SCH003.md @@ -0,0 +1,31 @@ +# SCH003 - Invalid Enum Value + +## Description + +Ensure the field value matches the expected type from the schema. + +## Category + +Schema Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Ensure the field value matches the expected type from the schema. + +## Example + +```json +None +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/SCH004.md b/docs/errors/SCH004.md new file mode 100644 index 0000000..1977866 --- /dev/null +++ b/docs/errors/SCH004.md @@ -0,0 +1,31 @@ +# SCH004 - Invalid Format + +## Description + +Validation error SCH004. + +## Category + +Schema Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/SCH005.md b/docs/errors/SCH005.md new file mode 100644 index 0000000..5c5b4e8 --- /dev/null +++ b/docs/errors/SCH005.md @@ -0,0 +1,31 @@ +# SCH005 - Pattern Mismatch + +## Description + +Validation error SCH005. + +## Category + +Schema Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/SCH006.md b/docs/errors/SCH006.md new file mode 100644 index 0000000..be7e73b --- /dev/null +++ b/docs/errors/SCH006.md @@ -0,0 +1,31 @@ +# SCH006 - String Too Short + +## Description + +Validation error SCH006. + +## Category + +Schema Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/SCH007.md b/docs/errors/SCH007.md new file mode 100644 index 0000000..f159733 --- /dev/null +++ b/docs/errors/SCH007.md @@ -0,0 +1,31 @@ +# SCH007 - String Too Long + +## Description + +Validation error SCH007. + +## Category + +Schema Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/SCH008.md b/docs/errors/SCH008.md new file mode 100644 index 0000000..97ade8e --- /dev/null +++ b/docs/errors/SCH008.md @@ -0,0 +1,31 @@ +# SCH008 - Value Below Minimum + +## Description + +Validation error SCH008. + +## Category + +Schema Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/SCH009.md b/docs/errors/SCH009.md new file mode 100644 index 0000000..bc3bf1a --- /dev/null +++ b/docs/errors/SCH009.md @@ -0,0 +1,31 @@ +# SCH009 - Value Above Maximum + +## Description + +Validation error SCH009. + +## Category + +Schema Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/SCH010.md b/docs/errors/SCH010.md new file mode 100644 index 0000000..28aa0f6 --- /dev/null +++ b/docs/errors/SCH010.md @@ -0,0 +1,31 @@ +# SCH010 - Additional Properties + +## Description + +Validation error SCH010. + +## Category + +Schema Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/SCH011.md b/docs/errors/SCH011.md new file mode 100644 index 0000000..2b514a3 --- /dev/null +++ b/docs/errors/SCH011.md @@ -0,0 +1,31 @@ +# SCH011 - Too Few Items + +## Description + +Validation error SCH011. + +## Category + +Schema Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/SCH012.md b/docs/errors/SCH012.md new file mode 100644 index 0000000..a182078 --- /dev/null +++ b/docs/errors/SCH012.md @@ -0,0 +1,31 @@ +# SCH012 - Too Many Items + +## Description + +Validation error SCH012. + +## Category + +Schema Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/SCH013.md b/docs/errors/SCH013.md new file mode 100644 index 0000000..c670a6a --- /dev/null +++ b/docs/errors/SCH013.md @@ -0,0 +1,31 @@ +# SCH013 - Duplicate Items + +## Description + +Validation error SCH013. + +## Category + +Schema Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/SCH014.md b/docs/errors/SCH014.md new file mode 100644 index 0000000..6219512 --- /dev/null +++ b/docs/errors/SCH014.md @@ -0,0 +1,31 @@ +# SCH014 - Const Mismatch + +## Description + +Validation error SCH014. + +## Category + +Schema Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/SCH015.md b/docs/errors/SCH015.md new file mode 100644 index 0000000..199f912 --- /dev/null +++ b/docs/errors/SCH015.md @@ -0,0 +1,31 @@ +# SCH015 - AllOf Violation + +## Description + +Validation error SCH015. + +## Category + +Schema Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/SCH016.md b/docs/errors/SCH016.md new file mode 100644 index 0000000..541ee9e --- /dev/null +++ b/docs/errors/SCH016.md @@ -0,0 +1,31 @@ +# SCH016 - AnyOf Violation + +## Description + +Validation error SCH016. + +## Category + +Schema Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/SCH017.md b/docs/errors/SCH017.md new file mode 100644 index 0000000..4ca13cf --- /dev/null +++ b/docs/errors/SCH017.md @@ -0,0 +1,31 @@ +# SCH017 - OneOf Violation + +## Description + +Validation error SCH017. + +## Category + +Schema Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/SCH018.md b/docs/errors/SCH018.md new file mode 100644 index 0000000..3331919 --- /dev/null +++ b/docs/errors/SCH018.md @@ -0,0 +1,31 @@ +# SCH018 - Not Violation + +## Description + +Validation error SCH018. + +## Category + +Schema Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/SCH019.md b/docs/errors/SCH019.md new file mode 100644 index 0000000..49e48d9 --- /dev/null +++ b/docs/errors/SCH019.md @@ -0,0 +1,31 @@ +# SCH019 - Contains Violation + +## Description + +Validation error SCH019. + +## Category + +Schema Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/SCH020.md b/docs/errors/SCH020.md new file mode 100644 index 0000000..01174f8 --- /dev/null +++ b/docs/errors/SCH020.md @@ -0,0 +1,31 @@ +# SCH020 - PrefixItems Violation + +## Description + +Validation error SCH020. + +## Category + +Schema Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/SCH021.md b/docs/errors/SCH021.md new file mode 100644 index 0000000..4b18317 --- /dev/null +++ b/docs/errors/SCH021.md @@ -0,0 +1,31 @@ +# SCH021 - Reference Resolution + +## Description + +Validation error SCH021. + +## Category + +Schema Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/SCH099.md b/docs/errors/SCH099.md new file mode 100644 index 0000000..c75db68 --- /dev/null +++ b/docs/errors/SCH099.md @@ -0,0 +1,31 @@ +# SCH099 - Unknown Schema Error + +## Description + +Validation error SCH099. + +## Category + +Schema Errors + +## Severity + +`error` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/SEM001.md b/docs/errors/SEM001.md new file mode 100644 index 0000000..6675d36 --- /dev/null +++ b/docs/errors/SEM001.md @@ -0,0 +1,58 @@ +# SEM001: Mass Fraction Sum + +**Severity:** Warning +**Category:** Semantic Validation + +## Description + +Material mass fractions should sum to 1.0 (100%). + +## Rule Details + +This rule checks that the `massFraction` values in `materialsProvenance` sum to approximately 1.0. Per the UNTP specification, partial declarations (sum < 1.0) are valid but should be flagged as a warning to ensure completeness. + +- **Sum > 1.0**: Physically impossible, always flagged +- **Sum < 1.0**: Valid partial declaration, flagged as warning +- **Sum ≈ 1.0**: Valid (within 0.01 tolerance) + +## Example + +### Invalid + +```json +{ + "credentialSubject": { + "materialsProvenance": [ + {"name": "Cotton", "massFraction": 0.5}, + {"name": "Polyester", "massFraction": 0.3} + ] + } +} +``` + +**Message:** `Mass fractions sum to 0.800, expected 1.0 (partial declaration)` + +### Valid + +```json +{ + "credentialSubject": { + "materialsProvenance": [ + {"name": "Cotton", "massFraction": 0.6}, + {"name": "Polyester", "massFraction": 0.4} + ] + } +} +``` + +## How to Fix + +Ensure all material mass fractions sum to 1.0. If some materials are unknown, consider: + +1. Adding an "Other" or "Unknown" material category +1. Documenting the partial declaration in metadata + +## References + +- [UNTP DPP Specification](https://untp.unece.org/docs/specification/DigitalProductPassport) +- JSON Path: `$.credentialSubject.materialsProvenance[*].massFraction` diff --git a/docs/errors/SEM002.md b/docs/errors/SEM002.md new file mode 100644 index 0000000..83d162b --- /dev/null +++ b/docs/errors/SEM002.md @@ -0,0 +1,47 @@ +# SEM002: Validity Date Order + +**Severity:** Error +**Category:** Semantic Validation + +## Description + +The `validFrom` date must be before `validUntil`. + +## Rule Details + +This rule ensures temporal consistency in credential validity periods. A passport cannot be valid starting from a date that is on or after its expiration date. + +## Example + +### Invalid + +```json +{ + "validFrom": "2025-12-01T00:00:00Z", + "validUntil": "2025-01-01T00:00:00Z" +} +``` + +**Message:** `validFrom (2025-12-01T00:00:00Z) must be before validUntil (2025-01-01T00:00:00Z)` + +### Valid + +```json +{ + "validFrom": "2025-01-01T00:00:00Z", + "validUntil": "2026-01-01T00:00:00Z" +} +``` + +## How to Fix + +Ensure `validFrom` is chronologically before `validUntil`. Check for: + +1. Typos in date values +1. Swapped field values +1. Timezone issues + +## References + +- [W3C Verifiable Credentials](https://www.w3.org/TR/vc-data-model/) +- JSON Path: `$.validFrom`, `$.validUntil` diff --git a/docs/errors/SEM003.md b/docs/errors/SEM003.md new file mode 100644 index 0000000..6661c2a --- /dev/null +++ b/docs/errors/SEM003.md @@ -0,0 +1,64 @@ +# SEM003: Hazardous Material Safety Info + +**Severity:** Error +**Category:** Semantic Validation + +## Description + +Materials marked as hazardous require `materialSafetyInformation`. + +## Rule Details + +When a material in `materialsProvenance` has `hazardous: true`, it must include safety information to comply with regulatory requirements. This ensures proper handling, storage, and disposal guidance is available. + +## Example + +### Invalid + +```json +{ + "credentialSubject": { + "materialsProvenance": [ + { + "name": "Lead-based pigment", + "hazardous": true + } + ] + } +} +``` + +**Message:** `Material 'Lead-based pigment' is hazardous but missing materialSafetyInformation` + +### Valid + +```json +{ + "credentialSubject": { + "materialsProvenance": [ + { + "name": "Lead-based pigment", + "hazardous": true, + "materialSafetyInformation": { + "safetyDataSheet": "https://example.com/sds/lead-pigment.pdf", + "handlingInstructions": "Use protective equipment" + } + } + ] + } +} +``` + +## How to Fix + +For each hazardous material, add `materialSafetyInformation` containing: + +1. Safety Data Sheet (SDS) link +1. Handling instructions +1. Emergency contact information + +## References + +- [EU CLP Regulation](https://echa.europa.eu/regulations/clp/legislation) +- [UNTP Material Provenance](https://untp.unece.org/docs/specification/DigitalProductPassport/) +- JSON Path: `$.credentialSubject.materialsProvenance[*]` diff --git a/docs/errors/SEM004.md b/docs/errors/SEM004.md new file mode 100644 index 0000000..202e830 --- /dev/null +++ b/docs/errors/SEM004.md @@ -0,0 +1,55 @@ +# SEM004: Circularity Content Consistency + +**Severity:** Warning +**Category:** Semantic Validation + +## Description + +The `recycledContent` percentage should not exceed `recyclableContent`. + +## Rule Details + +This rule checks logical consistency in circularity metrics. While a product can contain more recycled material than it can be recycled into (due to contamination or mixed materials), this is flagged as a warning for review. + +## Example + +### Invalid + +```json +{ + "credentialSubject": { + "circularityScorecard": { + "recycledContent": 0.85, + "recyclableContent": 0.60 + } + } +} +``` + +**Message:** `recycledContent (0.85) exceeds recyclableContent (0.60)` + +### Valid + +```json +{ + "credentialSubject": { + "circularityScorecard": { + "recycledContent": 0.40, + "recyclableContent": 0.85 + } + } +} +``` + +## How to Fix + +Review the circularity metrics: + +1. Verify `recycledContent` value (% of product from recycled sources) +1. Verify `recyclableContent` value (% of product that can be recycled) +1. If values are correct, document the reason in metadata + +## References + +- [EU ESPR Circularity Requirements](https://environment.ec.europa.eu/topics/circular-economy_en) +- JSON Path: `$.credentialSubject.circularityScorecard` diff --git a/docs/errors/SEM005.md b/docs/errors/SEM005.md new file mode 100644 index 0000000..f269a11 --- /dev/null +++ b/docs/errors/SEM005.md @@ -0,0 +1,61 @@ +# SEM005: Missing Conformity Claims + +**Severity:** Info +**Category:** Semantic Validation + +## Description + +At least one `conformityClaim` is recommended for a complete DPP. + +## Rule Details + +Conformity claims document compliance with standards, certifications, or regulations. While not strictly required, their absence suggests an incomplete passport that may not meet regulatory expectations. + +## Example + +### Flagged + +```json +{ + "credentialSubject": { + "product": { + "name": "Organic Cotton T-Shirt" + } + } +} +``` + +**Message:** `No conformity claims present. Consider adding sustainability or compliance claims.` + +### Recommended + +```json +{ + "credentialSubject": { + "product": { + "name": "Organic Cotton T-Shirt" + }, + "conformityClaim": [ + { + "type": "Certification", + "conformityTopic": "environment.organic", + "referenceStandard": "GOTS", + "assessmentLevel": "ThirdParty" + } + ] + } +} +``` + +## How to Fix + +Add relevant conformity claims for: + +1. **Certifications**: GOTS, OEKO-TEX, FSC, etc. +1. **Compliance**: EU ESPR, REACH, RoHS +1. **Standards**: ISO certifications, industry standards + +## References + +- [UNTP Conformity Claims](https://untp.unece.org/docs/specification/ConformityCredential) +- JSON Path: `$.credentialSubject.conformityClaim` diff --git a/docs/errors/SEM006.md b/docs/errors/SEM006.md new file mode 100644 index 0000000..cae1109 --- /dev/null +++ b/docs/errors/SEM006.md @@ -0,0 +1,65 @@ +# SEM006: Item-Level Serial Number + +**Severity:** Warning +**Category:** Semantic Validation + +## Description + +Passports with `granularityLevel: "item"` require a `serialNumber`. + +## Rule Details + +Item-level granularity indicates a passport for a specific individual product instance, not a batch or model. Such passports must include a serial number to uniquely identify the specific item. + +## Example + +### Invalid + +```json +{ + "credentialSubject": { + "granularityLevel": "item", + "product": { + "name": "Premium Jacket", + "model": "PJ-2025" + } + } +} +``` + +**Message:** `granularityLevel is 'item' but serialNumber is missing` + +### Valid + +```json +{ + "credentialSubject": { + "granularityLevel": "item", + "product": { + "name": "Premium Jacket", + "model": "PJ-2025", + "serialNumber": "SN-2025-001234" + } + } +} +``` + +## How to Fix + +Either: + +1. Add a `serialNumber` to the product +1. Change `granularityLevel` to `"batch"` or `"model"` if appropriate + +## Granularity Levels + +| Level | Description | Serial Number | +| ------- | --------------------------- | -------------- | +| `item` | Individual product instance | Required | +| `batch` | Production batch | Optional | +| `model` | Product model/SKU | Not applicable | + +## References + +- [UNTP Granularity Levels](https://untp.unece.org/docs/specification/DigitalProductPassport) +- JSON Path: `$.credentialSubject.granularityLevel`, `$.credentialSubject.product.serialNumber` diff --git a/docs/errors/SEM007.md b/docs/errors/SEM007.md new file mode 100644 index 0000000..6464691 --- /dev/null +++ b/docs/errors/SEM007.md @@ -0,0 +1,63 @@ +# SEM007: Operational Scope for Emissions + +**Severity:** Warning +**Category:** Semantic Validation + +## Description + +Emissions data with `carbonFootprint` should specify `operationalScope`. + +## Rule Details + +Carbon footprint values are meaningless without context about what operations are included. The operational scope (e.g., Scope 1, 2, 3 emissions) is essential for: + +- Comparability between products +- Regulatory compliance +- Accurate sustainability reporting + +## Example + +### Invalid + +```json +{ + "credentialSubject": { + "emissionsScorecard": { + "carbonFootprint": 12.5 + } + } +} +``` + +**Message:** `carbonFootprint is specified but operationalScope is missing` + +### Valid + +```json +{ + "credentialSubject": { + "emissionsScorecard": { + "carbonFootprint": 12.5, + "operationalScope": "Scope1And2", + "unit": "kgCO2e" + } + } +} +``` + +## How to Fix + +Add `operationalScope` with one of: + +- `Scope1`: Direct emissions from owned/controlled sources +- `Scope2`: Indirect emissions from purchased energy +- `Scope1And2`: Combined Scope 1 and 2 +- `Scope3`: All other indirect emissions +- `CradleToGate`: All emissions up to product completion +- `CradleToGrave`: Full lifecycle emissions + +## References + +- [GHG Protocol Scopes](https://ghgprotocol.org/sites/default/files/standards/ghg-protocol-revised.pdf) +- [UNTP Emissions Scorecard](https://untp.unece.org/docs/specification/DigitalProductPassport) +- JSON Path: `$.credentialSubject.emissionsScorecard` diff --git a/docs/errors/TXT001.md b/docs/errors/TXT001.md new file mode 100644 index 0000000..cd86b1b --- /dev/null +++ b/docs/errors/TXT001.md @@ -0,0 +1,31 @@ +# TXT001 - Invalid Textile HS Code + +## Description + +Validation error TXT001. + +## Category + +Textile Errors + +## Severity + +`warning` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/TXT002.md b/docs/errors/TXT002.md new file mode 100644 index 0000000..c5bff3d --- /dev/null +++ b/docs/errors/TXT002.md @@ -0,0 +1,31 @@ +# TXT002 - Missing Material Composition + +## Description + +Validation error TXT002. + +## Category + +Textile Errors + +## Severity + +`warning` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/TXT003.md b/docs/errors/TXT003.md new file mode 100644 index 0000000..074cd68 --- /dev/null +++ b/docs/errors/TXT003.md @@ -0,0 +1,31 @@ +# TXT003 - Missing Microplastic Data + +## Description + +Validation error TXT003. + +## Category + +Textile Errors + +## Severity + +`warning` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/TXT004.md b/docs/errors/TXT004.md new file mode 100644 index 0000000..20c61ab --- /dev/null +++ b/docs/errors/TXT004.md @@ -0,0 +1,31 @@ +# TXT004 - Missing Durability Info + +## Description + +Validation error TXT004. + +## Category + +Textile Errors + +## Severity + +`warning` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/TXT005.md b/docs/errors/TXT005.md new file mode 100644 index 0000000..b26f44b --- /dev/null +++ b/docs/errors/TXT005.md @@ -0,0 +1,31 @@ +# TXT005 - Missing Care Instructions + +## Description + +Validation error TXT005. + +## Category + +Textile Errors + +## Severity + +`warning` + +## Common Causes + +- Input data does not meet validation requirements + +## How to Fix + +Review the error message and correct the input data. + +## Example + +```json +// Example will vary based on error +``` + +## See Also + +- [Error Overview](index.md) diff --git a/docs/errors/UPG001.md b/docs/errors/UPG001.md new file mode 100644 index 0000000..5635c22 --- /dev/null +++ b/docs/errors/UPG001.md @@ -0,0 +1,49 @@ +# UPG001 - Lossy upgrade transformation + +## Description + +The v0.6 → v0.7 compat shim +(`dppvalidator.compat.upgrade_0_6_to_0_7.upgrade`) encountered a +field that has no v0.7.0 equivalent and dropped it. The +transformation is structurally complete but lossy — the source +information is no longer represented in the upgraded payload. + +## Category + +Compat shim (Phase 4 of `docs/plans/UNTP_0.7.0_MIGRATION.md`). + +## Severity + +`warning` (or `info` for sentinel placeholders that were already +non-data, e.g. `"undefined"` in `Material.symbol`). + +## Common causes + +| Source field | Why it's dropped | +| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `Product.registeredId` | The field's home moved from `Product` to `Party` in v0.7. Re-attach to `Product.relatedParty[*].party.registeredId` on the appropriate party. | +| `Material.symbol` containing a non-base64 string | v0.7's `Image` type expects a real base64 payload + `mediaType`. Sentinel placeholders are dropped. | +| Whole conformityClaim / scorecard fold-down | The fold from v0.6's typed claim/scorecard hierarchy into a single `Claim` shape is best-effort; review for fidelity. | + +## How to fix + +The shim emits a JSONPath-style `path` on the warning so you know +exactly what was dropped: + +```text +[UPG001] (warning) credentialSubject.registeredId: v0.6 Product.registeredId +has no v0.7 equivalent on Product — the field has moved to Party. The value +was dropped; if required, re-attach it to +Product.relatedParty[*].party.registeredId manually. +``` + +When the loss is acceptable (e.g. you don't need that registeredId +in the upgraded payload), pass `--accept-warnings` to +`dppvalidator migrate` to write the upgraded file anyway. A +sidecar `.warnings.json` always records the full warning +list so you can audit later. + +## See also + +- [Migration guide: 0.6 → 0.7](../guides/migration-0-6-to-0-7.md) — full field rename / shape-change table and the documented limitations. +- [Error Overview](index.md) diff --git a/docs/errors/UPG002.md b/docs/errors/UPG002.md new file mode 100644 index 0000000..60e1a5b --- /dev/null +++ b/docs/errors/UPG002.md @@ -0,0 +1,57 @@ +# UPG002 - Synthesised value during upgrade + +## Description + +The v0.6 → v0.7 compat shim filled a missing v0.7-required field +from a related v0.6 source rather than failing. The upgraded value +is a best-effort guess and should be reviewed before publishing. + +## Category + +Compat shim (Phase 4 of `docs/plans/UNTP_0.7.0_MIGRATION.md`). + +## Severity + +`warning` for envelope-level synthesis; `info` for synthesis where +the missing field is optional in v0.7 (e.g. `Country.countryName`). + +## Common causes + +| What was synthesised | Source | +| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Top-level envelope `name` | Copied from `credentialSubject.product.name` (the v0.6 product name). | +| `Material.symbol` upgraded to `Image` | Real base64 bytes preserved; `name="Material symbol"` and `mediaType="image/png"` synthesised. | +| `Country.countryName` not populated | The shim only sets the name when the caller passes a `country_lookup` mapping or the code is in the bundled list. | + +## How to fix + +Most synthesised values are reasonable defaults. To exercise the +review: + +1. **Read the sidecar warnings file** that the `migrate` CLI + produces alongside the upgraded payload. Every UPG002 carries a + JSONPath locator and the synthesised value's source. + +1. **Override synthesised values** before validation: + + ```python + from dppvalidator.compat import upgrade + + upgraded, warnings = upgrade( + payload_v06, + country_lookup={"DE": "Germany", "AU": "Australia"}, + ) + # Replace the auto-synthesised envelope name with a richer one. + upgraded["name"] = "Battery DPP — EV-300 series" + ``` + +1. **Avoid synthesis** by providing the field at v0.6: set + `credentialSubject.product.name` (synthesises envelope `name`) + and a top-level `name` directly to skip the warning entirely. + +## See also + +- [Migration guide: 0.6 → 0.7](../guides/migration-0-6-to-0-7.md) — full warning code reference. +- [`UPG003`](UPG003.md) — country-specific UPG variant. +- [`UPG004`](UPG004.md) — when synthesis is *not* possible. +- [Error Overview](index.md) diff --git a/docs/errors/UPG003.md b/docs/errors/UPG003.md new file mode 100644 index 0000000..21e287c --- /dev/null +++ b/docs/errors/UPG003.md @@ -0,0 +1,52 @@ +# UPG003 - Unmapped country code + +## Description + +The v0.6 → v0.7 compat shim wrapped a scalar country code (e.g. +`"XX"`) into the v0.7 `Country` shape, but the code is not in the +bundled ISO-3166-1 alpha-2 list. The structural rewrite still +happens (`{countryCode: "XX"}`), but the resulting payload will +fail v0.7 model validation downstream. + +## Category + +Compat shim (Phase 4 of `docs/plans/UNTP_0.7.0_MIGRATION.md`). + +## Severity + +`warning` + +## Common causes + +- The source data uses a typo'd country code (e.g. `"GE"` for + Germany when the correct alpha-2 is `"DE"`). +- The source data uses an alpha-3 code (`"DEU"`) where v0.7 + requires alpha-2. +- The source uses a non-ISO regional code (e.g. an internal + manufacturing-region code) that should never have been written + into `originCountry` in the first place. + +## How to fix + +1. **Fix the source data** — the field expects ISO-3166-1 + alpha-2. Examples: `"DE"` for Germany, `"AU"` for Australia, + `"ZM"` for Zambia. + +1. **If the source uses alpha-3**, convert to alpha-2 in your + producer pipeline before invoking the shim. + +1. **If the value is genuinely a non-country region**, move it to a + different field — `Material.originCountry` and + `Product.countryOfProduction` are both ISO-pinned in v0.7. + +The shim still produces the upgraded structure, so callers can +choose to pass `--accept-warnings` to `dppvalidator migrate` if +they intend to fix the country code in a follow-up step. The +upgraded payload will not validate against the v0.7 schema until +the country code is corrected. + +## See also + +- [Migration guide: 0.6 → 0.7](../guides/migration-0-6-to-0-7.md) — Country / Material / Classification shape changes. +- [`VOC001`](VOC001.md) — the runtime validator's equivalent invalid-country error (fires after the shim, when the upgraded payload is validated). +- [Error Overview](index.md) diff --git a/docs/errors/UPG004.md b/docs/errors/UPG004.md new file mode 100644 index 0000000..482b89d --- /dev/null +++ b/docs/errors/UPG004.md @@ -0,0 +1,66 @@ +# UPG004 - Required v0.7 field missing + +## Description + +The v0.6 → v0.7 compat shim encountered a v0.7-required field that +is missing from the source v0.6 payload AND cannot be synthesised +from any related v0.6 field. The caller MUST provide the value +manually before the upgraded payload validates against v0.7. + +## Category + +Compat shim (Phase 4 of `docs/plans/UNTP_0.7.0_MIGRATION.md`). + +## Severity + +`error` — this is the only UPG code that ships at error severity. +The `migrate` CLI refuses to write the upgraded file when any +UPG004 fires, regardless of `--accept-warnings`, because the +result is guaranteed not to validate. + +## Common causes + +| Missing v0.7-required field | Synthesisable from v0.6? | Notes | +| --------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------- | +| Envelope `name` | only if `Product.name` is set | Otherwise UPG004; provide a top-level `name` manually. | +| Envelope `validFrom` | no | Cannot fabricate a date. | +| `Material.materialType` | no | v0.7 requires a `Classification` object; v0.6 made it optional. Supply a real classification code. | +| `Material.massFraction` | no | v0.7 requires a numeric value in `[0, 1]`; v0.6 made it optional. | + +## How to fix + +The shim emits one UPG004 per missing field with a JSONPath +locator. Patch the source payload at exactly those paths before +re-running the shim: + +```python +from dppvalidator.compat import upgrade + +# 1. First pass: collect the gaps. +upgraded, warnings = upgrade(payload_v06) +gaps = [w for w in warnings if w.code == "UPG004"] +for w in gaps: + print(f"{w.path}: needs manual value — {w.message}") + +# 2. Patch the source. Example: fill missing materialType. +payload_v06["credentialSubject"]["materialsProvenance"][0]["materialType"] = { + "type": ["Classification"], + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification", + "code": "27310", + "name": "Steel basic shapes", +} + +# 3. Second pass: should produce no UPG004s. +upgraded, warnings = upgrade(payload_v06) +assert not any(w.code == "UPG004" for w in warnings) +``` + +For pipelines, treat UPG004 as a hard signal: failing data should +go back to the producer, not be force-fed into the upgrade. + +## See also + +- [Migration guide: 0.6 → 0.7](../guides/migration-0-6-to-0-7.md) — the limitations table lists every UPG004-emitting field with worked examples. +- [`UPG002`](UPG002.md) — when synthesis *is* possible (warning, not error). +- [Error Overview](index.md) diff --git a/docs/errors/VER001.md b/docs/errors/VER001.md new file mode 100644 index 0000000..2680ebd --- /dev/null +++ b/docs/errors/VER001.md @@ -0,0 +1,57 @@ +# VER001 - UNTP version mismatch + +## Description + +The `ValidationEngine` was constructed with an explicit +`schema_version`, but the payload declares a different UNTP version +in its `$schema` URL or `@context`. The engine refuses to validate +mismatched payloads — silently coercing across versions would mask +genuine errors (e.g. a v0.6 payload incorrectly tagged as v0.7). + +## Category + +UNTP version dispatch (Phase 3.3 of +`docs/plans/UNTP_0.7.0_MIGRATION.md`). + +## Severity + +`error` + +## Common Causes + +- The engine is pinned to one version (e.g. + `ValidationEngine(schema_version="0.7.0")`) and a v0.6.x payload + is fed in (or vice-versa). +- A pipeline upgraded the engine but didn't upgrade the + payload-producing service at the same time. +- A payload's `@context` was hand-edited to reference v0.7 without + the corresponding shape changes. + +## How to fix + +1. **Run the compat shim** if you have v0.6.x payloads and want + v0.7.0 validation: + + ```bash + dppvalidator validate passport.json \ + --upgrade-from 0.6.1 \ + --schema-version 0.7.0 + ``` + +1. **Drop the explicit pin** and let the engine auto-detect: + + ```python + engine = ValidationEngine() # no schema_version= + ``` + +1. **Match the engine pin to the payload version**: + + ```python + engine = ValidationEngine(schema_version="0.6.1") + ``` + +## See also + +- [UNTP DPP versions](../concepts/untp-versions.md) — version handling overview. +- [Migration guide: 0.6 → 0.7](../guides/migration-0-6-to-0-7.md) — the compat shim. +- [Error Overview](index.md) diff --git a/docs/errors/VOC001.md b/docs/errors/VOC001.md new file mode 100644 index 0000000..9d3044e --- /dev/null +++ b/docs/errors/VOC001.md @@ -0,0 +1,66 @@ +# VOC001 - Invalid Country Code + +## Description + +The country code provided is not a valid ISO 3166-1 alpha-2 code. Country codes +are used to identify the origin of materials and manufacturing locations. + +## Severity + +**Warning** + +## When This Occurs + +- `originCountry` in `materialsProvenance` contains an invalid code +- Country code is not in ISO 3166-1 alpha-2 format (2 uppercase letters) + +## Example + +```json +{ + "credentialSubject": { + "materialsProvenance": [ + { + "name": "Recycled Aluminum", + "originCountry": "USA" // Invalid: should be "US" + } + ] + } +} +``` + +## Valid Format + +ISO 3166-1 alpha-2 codes are exactly 2 uppercase letters: + +| Code | Country | +| ---- | ------------- | +| US | United States | +| DE | Germany | +| CN | China | +| JP | Japan | +| FR | France | + +## Resolution + +1. **Use correct ISO 3166-1 alpha-2 code:** + + ```json + { + "originCountry": "US" + } + ``` + +1. **Verify country code:** + + ```python + from dppvalidator.vocabularies import get_bundled_country_codes + + codes = get_bundled_country_codes() + print("US" in codes) # True + ``` + +## Related + +- [VOC002 - Invalid Unit Code](VOC002.md) +- [ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) diff --git a/docs/errors/VOC002.md b/docs/errors/VOC002.md new file mode 100644 index 0000000..d039f3f --- /dev/null +++ b/docs/errors/VOC002.md @@ -0,0 +1,75 @@ +# VOC002 - Invalid Unit Code + +## Description + +The unit code provided is not a valid UNECE Recommendation 20 code. Unit codes +are used to specify measurement units for dimensions, weights, and other +quantitative properties. + +## Severity + +**Warning** + +## When This Occurs + +- `unit` field in `dimensions` contains an invalid code +- Unit code is not in UNECE Rec20 format + +## Example + +```json +{ + "credentialSubject": { + "product": { + "dimensions": { + "weight": { + "value": 2.5, + "unit": "kilograms" // Invalid: should be "KGM" + } + } + } + } +} +``` + +## Valid Format + +UNECE Rec20 codes are typically 2-3 uppercase characters: + +| Code | Unit | Description | +| ---- | -------------- | ----------- | +| KGM | Kilogram | Mass | +| MTR | Metre | Length | +| CMT | Centimetre | Length | +| LTR | Litre | Volume | +| MTK | Square metre | Area | +| CEL | Degree Celsius | Temperature | + +## Resolution + +1. **Use correct UNECE Rec20 code:** + + ```json + { + "dimensions": { + "weight": { + "value": 2.5, + "unit": "KGM" + } + } + } + ``` + +1. **Verify unit code:** + + ```python + from dppvalidator.vocabularies import get_bundled_unit_codes + + codes = get_bundled_unit_codes() + print("KGM" in codes) # True + ``` + +## Related + +- [VOC001 - Invalid Country Code](VOC001.md) +- [UNECE Rec20](https://unece.org/trade/uncefact/cl-recommendations) diff --git a/docs/errors/VOC003.md b/docs/errors/VOC003.md new file mode 100644 index 0000000..c4bdcdf --- /dev/null +++ b/docs/errors/VOC003.md @@ -0,0 +1,73 @@ +# VOC003: UNECE Rec 46 Material Code + +**Severity:** Warning +**Category:** Vocabulary Validation + +## Description + +Material codes must be valid per UNECE Recommendation 46. + +## Rule Details + +This rule validates `materialType.code` values in `materialsProvenance` against the UNECE Recommendation 46 material classification codes. This ensures interoperability and standardized material identification across supply chains. + +## Example + +### Invalid + +```json +{ + "credentialSubject": { + "materialsProvenance": [ + { + "name": "Cotton", + "materialType": { + "code": "INVALID_CODE" + } + } + ] + } +} +``` + +**Message:** `Invalid material code 'INVALID_CODE' - not found in UNECE Rec 46` + +### Valid + +```json +{ + "credentialSubject": { + "materialsProvenance": [ + { + "name": "Cotton", + "materialType": { + "code": "CO", + "name": "Cotton" + } + } + ] + } +} +``` + +## Common Material Codes + +| Code | Material | +| ---- | --------------- | +| CO | Cotton | +| PL | Polyester | +| PA | Polyamide/Nylon | +| WO | Wool | +| SE | Silk | +| VI | Viscose | +| EL | Elastane | +| LI | Linen | + +## How to Fix + +Use valid UNECE Rec 46 material codes. Refer to the full code list in the UNECE documentation. + +## References + +- [UNECE Recommendation 46](https://unece.org/trade/publications/recommendation-no46-enhancing-traceability-and-transparency-sustainable-value) +- JSON Path: `$.credentialSubject.materialsProvenance[*].materialType.code` diff --git a/docs/errors/VOC004.md b/docs/errors/VOC004.md new file mode 100644 index 0000000..5d31662 --- /dev/null +++ b/docs/errors/VOC004.md @@ -0,0 +1,79 @@ +# VOC004: HS Code Validation + +**Severity:** Warning +**Category:** Vocabulary Validation + +## Description + +HS codes must be valid for the product category (textile chapters 50-63). + +## Rule Details + +This rule validates Harmonized System (HS) codes in product classifications. For textile products, valid codes must fall within chapters 50-63 of the HS nomenclature. + +## Example + +### Invalid + +```json +{ + "credentialSubject": { + "product": { + "productCategory": [ + { + "code": "9999", + "scheme": "HS" + } + ] + } + } +} +``` + +**Message:** `Invalid HS code '9999' - not found in textile chapters (50-63)` + +### Valid + +```json +{ + "credentialSubject": { + "product": { + "productCategory": [ + { + "code": "6109", + "scheme": "HS", + "name": "T-shirts, singlets and other vests, knitted" + } + ] + } + } +} +``` + +## Textile HS Chapters + +| Chapter | Description | +| ------- | ---------------------------- | +| 50 | Silk | +| 51 | Wool and animal hair | +| 52 | Cotton | +| 53 | Vegetable textile fibres | +| 54 | Man-made filaments | +| 55 | Man-made staple fibres | +| 56 | Wadding, felt and nonwovens | +| 57 | Carpets and floor coverings | +| 58 | Special woven fabrics | +| 59 | Impregnated/coated textiles | +| 60 | Knitted or crocheted fabrics | +| 61 | Knitted apparel | +| 62 | Woven apparel | +| 63 | Other textile articles | + +## How to Fix + +Use valid HS codes from the appropriate chapter. The first two digits indicate the chapter. + +## References + +- [WCO Harmonized System](https://www.wcoomd.org/en/topics/nomenclature/overview/what-is-the-harmonized-system.aspx) +- JSON Path: `$.credentialSubject.product.productCategory[*].code` diff --git a/docs/errors/VOC005.md b/docs/errors/VOC005.md new file mode 100644 index 0000000..e52860b --- /dev/null +++ b/docs/errors/VOC005.md @@ -0,0 +1,68 @@ +# VOC005: GTIN Checksum + +**Severity:** Error +**Category:** Vocabulary Validation + +## Description + +GTIN (Global Trade Item Number) must have a valid check digit. + +## Rule Details + +This rule validates GTIN checksums using the GS1 check digit algorithm. GTINs are found in product IDs, either as plain numbers or embedded in GS1 Digital Links. + +Supported formats: + +- **GTIN-8**: 8 digits +- **GTIN-12**: 12 digits (UPC-A) +- **GTIN-13**: 13 digits (EAN-13) +- **GTIN-14**: 14 digits + +## Example + +### Invalid + +```json +{ + "credentialSubject": { + "product": { + "id": "https://id.gs1.org/01/12345678901235" + } + } +} +``` + +**Message:** `Invalid GTIN checksum in '12345678901235'` + +### Valid + +```json +{ + "credentialSubject": { + "product": { + "id": "https://id.gs1.org/01/00614141123452" + } + } +} +``` + +## Check Digit Algorithm + +The GS1 check digit is calculated as: + +1. Sum digits in odd positions (from right, excluding check digit) +1. Sum digits in even positions, multiply by 3 +1. Add both sums +1. Check digit = (10 - (sum mod 10)) mod 10 + +## How to Fix + +1. Verify the GTIN is entered correctly +1. Use a [GS1 check digit calculator](https://www.gs1.org/services/check-digit-calculator) +1. Contact your GS1 member organization if issues persist + +## References + +- [GS1 GTIN Standards](https://www.gs1.org/standards/id-keys/gtin) +- [GS1 Digital Link](https://www.gs1.org/standards/gs1-digital-link) +- JSON Path: `$.credentialSubject.product.id` diff --git a/docs/errors/index.md b/docs/errors/index.md new file mode 100644 index 0000000..10b89a7 --- /dev/null +++ b/docs/errors/index.md @@ -0,0 +1,96 @@ +# Error Reference + +This section documents all validation errors and warnings that dppvalidator can +produce across its seven-layer validation architecture. + +## Error Code Categories + + + +| Prefix | Layer | Description | +| ------ | ------------ | --------------------------------------------------------------------- | +| SCH | Schema | JSON Schema structural validation | +| PRS | Parsing | Input parsing and file handling | +| MOD | Model | Pydantic model type validation | +| JLD | JSON-LD | Context and term resolution errors | +| SEM | Semantic | Business logic and cross-field rules | +| VOC | Vocabulary | Controlled vocabulary and code lists | +| SIG | Signature | VC signature verification errors | +| VER | Version | UNTP version mismatch (engine pin vs declared payload version) | +| UPG | Upgrade shim | v0.6.x → v0.7.0 compat-shim warnings (lossy / synthesised / required) | + + + +## Schema Rules (SCH) + +| Code | Severity | Description | +| ------------------- | -------- | -------------------------------- | +| [SCH001](SCH001.md) | Warning | Schema not loaded or unavailable | + +## Parsing Rules (PRS) + +| Code | Severity | Description | +| ------------------- | -------- | ------------------------ | +| [PRS001](PRS001.md) | Error | File not found | +| [PRS002](PRS002.md) | Error | Invalid JSON syntax | +| [PRS003](PRS003.md) | Error | Unsupported input type | +| [PRS004](PRS004.md) | Error | Input exceeds size limit | + +## JSON-LD Rules (JLD) + +| Code | Severity | Description | +| ------------------- | -------- | ------------------------------------------ | +| [JLD001](JLD001.md) | Error | `@context` must be present and valid | +| [JLD002](JLD002.md) | Warning | All terms must resolve during expansion | +| [JLD003](JLD003.md) | Warning | Custom terms should use proper namespacing | +| [JLD004](JLD004.md) | Warning | Context resolution failure (network) | + +## Semantic Rules (SEM) + +| Code | Severity | Description | +| ------------------- | -------- | --------------------------------------------------- | +| [SEM001](SEM001.md) | Warning | Material mass fractions should sum to 1.0 | +| [SEM002](SEM002.md) | Error | validFrom must be before validUntil | +| [SEM003](SEM003.md) | Error | Hazardous materials require safety information | +| [SEM004](SEM004.md) | Warning | recycledContent should not exceed recyclableContent | +| [SEM005](SEM005.md) | Info | At least one conformityClaim is recommended | +| [SEM006](SEM006.md) | Warning | Item-level passports require serial numbers | +| [SEM007](SEM007.md) | Warning | Emissions data should specify operational scope | + +## Vocabulary Rules (VOC) + +| Code | Severity | Description | +| ------------------- | -------- | -------------------------------------------- | +| [VOC001](VOC001.md) | Warning | Invalid ISO 3166-1 country code | +| [VOC002](VOC002.md) | Warning | Invalid UNECE Rec20 unit code | +| [VOC003](VOC003.md) | Warning | Material code must be valid per UNECE Rec 46 | +| [VOC004](VOC004.md) | Warning | HS code must be valid for product category | +| [VOC005](VOC005.md) | Error | GTIN must have valid check digit | + +## Version Rules (VER) + + + +| Code | Severity | Description | +| ------------------- | -------- | ------------------------------------------------------------------ | +| [VER001](VER001.md) | Error | UNTP version mismatch — engine `schema_version` vs payload version | + + + +## Upgrade-shim Rules (UPG) + +Emitted by `dppvalidator.compat.upgrade_0_6_to_0_7.upgrade` and the +`dppvalidator migrate` / `dppvalidator validate --upgrade-from` +CLI surfaces. See the [migration guide](../guides/migration-0-6-to-0-7.md) +for the full field rename / shape-change context. + + + +| Code | Severity | Description | +| ------------------- | -------------- | ------------------------------------------------------------------------------------------------- | +| [UPG001](UPG001.md) | Warning / Info | Lossy — a v0.6 field has no v0.7 equivalent and was dropped (e.g. `Product.registeredId`). | +| [UPG002](UPG002.md) | Warning / Info | Synthesised — a v0.7-required field was filled from a related v0.6 source and should be reviewed. | +| [UPG003](UPG003.md) | Warning | Unmapped country code — wrapped structurally but the value will fail v0.7 validation. | +| [UPG004](UPG004.md) | Error | Required v0.7 field is missing from the v0.6 source AND cannot be synthesised; provide manually. | + + diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000..01fe309 --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,390 @@ +# Frequently Asked Questions + +Common questions about dppvalidator, Digital Product Passports, and EU compliance. + +______________________________________________________________________ + +## What is dppvalidator? + +### What does dppvalidator do? + +dppvalidator is a Python library that validates Digital Product Passports (DPP) against EU ESPR regulations and UNTP standards. It ensures your DPP data is structurally correct, semantically meaningful, and optionally cryptographically verifiable before production deployment. + +**Core capabilities:** + +- **Validate** DPP JSON data through five validation layers +- **Parse** DPP data into type-safe Pydantic models +- **Export** validated passports to JSON-LD format for W3C Verifiable Credentials +- **Verify** cryptographic signatures on signed credentials +- **Crawl** supply chains by following linked documents + +### What dppvalidator is NOT + +- **Not a DPP generator** — It validates existing data, not creates it from scratch +- **Not a database** — It doesn't store passports; use it as validation middleware +- **Not a blockchain** — Signature verification supports DIDs, but doesn't require blockchain +- **Not a UI framework** — It's a backend library; build your own frontend + +______________________________________________________________________ + +## Who Should Use dppvalidator? + +### Primary Users + +| Role | Use Case | +| -------------------------- | ---------------------------------------------------- | +| **Fashion/Textile Brands** | Validate DPP data before QR code generation | +| **Backend Developers** | Integrate DPP validation into APIs and microservices | +| **DevOps Engineers** | Add compliance gates to CI/CD pipelines | +| **Sustainability Teams** | Validate supplier DPP submissions | +| **System Integrators** | Migrate legacy product data to DPP format | + +### Industry Applications + +- **Textiles & Apparel** — EU ESPR compliance starting 2027 +- **Electronics & Batteries** — Battery passport requirements +- **Construction Materials** — Building product passports +- **Packaging** — Recyclability and material traceability + +______________________________________________________________________ + +## Technical Questions + +### What Python versions are supported? + +Python **3.10+** is required. We recommend Python 3.12 for best performance. + +### What are the core dependencies? + +All included by default: + +- **Pydantic v2** — Data validation and parsing +- **jsonschema** — JSON Schema validation +- **pyld** — JSON-LD expansion and context resolution +- **httpx** — HTTP client for deep validation +- **cryptography** — Signature verification +- **PyJWT** — JWT token handling + +Optional extras: + +- **rich** — CLI formatting (install with `uv add "dppvalidator[cli]"` or `pip install "dppvalidator[cli]"`) + +### How fast is validation? + +| Validation Type | Throughput | +| ----------------- | ----------------------------------- | +| Schema only | ~200,000 ops/sec | +| Model only | ~85,000 ops/sec | +| Full (all layers) | ~60,000 ops/sec | +| With JSON-LD | ~10,000 ops/sec (network-dependent) | + +*Benchmarked on Apple M2, Python 3.12* + +### What schema versions are supported? + +Currently supported: + +- **UNTP DPP 0.6.0** — supported (legacy) +- **UNTP DPP 0.6.1** — default +- **UNTP DPP 0.7.0** — fully supported + +Schema version is auto-detected from `@context` or `$schema` fields. +You can also specify it explicitly: + +```python +engine = ValidationEngine(schema_version="0.6.1") # current default +engine = ValidationEngine(schema_version="0.7.0") # opt in to v0.7 +``` + +For the full version-handling story see +[UNTP DPP versions](concepts/untp-versions.md). + +### Can I migrate v0.6.x payloads to v0.7.0? + +Yes — `dppvalidator` ships a compat shim that rewrites v0.6.x +payloads into v0.7.0 shape with structured warnings for anything it +can't fully translate: + +```bash +# Upgrade and write to a new file +dppvalidator migrate passport-v06.json -o passport-v07.json + +# Validate-after-upgrade in one shot +dppvalidator validate passport-v06.json \ + --upgrade-from 0.6.1 \ + --schema-version 0.7.0 +``` + +```python +from dppvalidator.compat import upgrade + +upgraded, warnings = upgrade(payload_v06, country_lookup={"DE": "Germany"}) +``` + +The shim emits four warning codes (`UPG001`–`UPG004`) covering +lossy transformations, synthesised values, unmapped country codes, +and required-field gaps. See the +[migration guide](guides/migration-0-6-to-0-7.md) for the field +rename table and the documented limitations. + +______________________________________________________________________ + +## Validation Questions + +### What are the five validation layers? + +1. **Layer 0: Schema Detection** — Auto-detects DPP schema version +1. **Layer 1: Schema Validation** — JSON Schema structure validation +1. **Layer 2: Model Validation** — Pydantic type validation +1. **Layer 3: JSON-LD Semantic** — Context expansion and term resolution +1. **Layer 4: Business Logic** — Vocabulary codes, date logic, GTIN checksums +1. **Layer 5: Cryptographic** — VC signature verification (optional) + +### Can I run only specific layers? + +Yes, use the `layers` parameter: + +```python +# Schema validation only (fastest) +engine = ValidationEngine(layers=["schema"]) + +# Model + Semantic only +engine = ValidationEngine(layers=["model", "semantic"]) + +# Full validation with JSON-LD +engine = ValidationEngine(layers=["schema", "model", "semantic", "jsonld"]) +``` + +### What error codes does dppvalidator use? + +| Prefix | Layer | Description | +| ------ | ---------- | ------------------------------ | +| `SCH` | Schema | JSON Schema validation errors | +| `MOD` | Model | Pydantic validation errors | +| `JLD` | JSON-LD | Context/term resolution errors | +| `SEM` | Semantic | Business rule violations | +| `VOC` | Vocabulary | Code list validation errors | +| `SIG` | Signature | Credential verification errors | +| `PRS` | Parse | Input parsing errors | + +### How do I handle validation errors? + +```python +result = engine.validate(dpp_data) + +if not result.valid: + for error in result.errors: + print(f"[{error.code}] {error.path}: {error.message}") + if error.suggestion: + print(f" Suggestion: {error.suggestion}") +``` + +Each error includes: + +- `code` — Error identifier (e.g., `SEM001`) +- `path` — JSON path to the error (e.g., `$.credentialSubject.materials[0].massFraction`) +- `message` — Human-readable description +- `suggestion` — How to fix the error (optional) +- `docs_url` — Link to detailed documentation (optional) + +______________________________________________________________________ + +## Use Case Questions + +### Can I use dppvalidator for CI/CD compliance gates? + +Yes! Add validation to your pipeline: + +```yaml +# .github/workflows/validate-dpp.yml +- name: Validate DPP files + run: | + # Using uv (recommended) + uv pip install dppvalidator + # Or: pip install dppvalidator + dppvalidator validate data/passports/*.json --strict --format json +``` + +Exit codes: + +- `0` — All validations passed +- `1` — Validation failed +- `2` — System error (file not found, invalid JSON) + +### Can I validate supplier submissions via API? + +Yes, integrate into your backend: + +```python +from fastapi import FastAPI, HTTPException +from dppvalidator import ValidationEngine + +app = FastAPI() +engine = ValidationEngine(strict_mode=True) + + +@app.post("/api/v1/dpp/validate") +async def validate_dpp(dpp: dict): + result = engine.validate(dpp) + if not result.valid: + raise HTTPException(status_code=422, detail=result.to_dict()) + return {"valid": True, "passport_id": dpp.get("id")} +``` + +### Can I validate entire supply chains? + +Yes, use deep validation to crawl linked documents: + +```python +result = await engine.validate_deep( + dpp_data, + max_depth=3, + follow_links=["credentialSubject.traceabilityEvents"], + timeout=30.0, + auth_header={"Authorization": "Bearer token..."}, +) + +print(f"Total documents validated: {result.total_documents}") +print(f"All valid: {result.valid}") +``` + +### Can I batch validate thousands of passports? + +Yes, use async batch validation: + +```python +results = await engine.validate_batch( + list_of_dpps, + concurrency=20, +) + +valid_count = sum(1 for r in results if r.valid) +print(f"Valid: {valid_count}/{len(results)}") +``` + +### Can I add custom validation rules? + +Yes, use the plugin system: + +```python +from dppvalidator.plugins import PluginRegistry + + +class TextileFiberRule: + rule_id = "TEX001" + description = "Fiber composition must sum to 100%" + severity = "error" + + def check(self, passport): + violations = [] + # Your validation logic here + return violations + + +registry = PluginRegistry() +registry.register_validator("textile", TextileFiberRule) +``` + +______________________________________________________________________ + +## Export Questions + +### What export formats are supported? + +- **JSON** — Standard JSON output +- **JSON-LD** — W3C Verifiable Credentials format + +```python +from dppvalidator.exporters import JSONLDExporter + +exporter = JSONLDExporter() +jsonld = exporter.export(passport) +``` + +### Is the JSON-LD output compatible with VC wallets? + +Yes, exported JSON-LD follows the W3C Verifiable Credentials Data Model v2 and includes proper `@context` for UNTP DPP vocabularies. + +______________________________________________________________________ + +## Compliance Questions + +### Does dppvalidator ensure EU ESPR compliance? + +dppvalidator validates the **technical structure** of DPP data against UNTP standards. However, **compliance is your responsibility** — you must ensure your product data accurately reflects: + +- Material composition +- Manufacturing processes +- Environmental indicators +- Chemical compliance (REACH) +- Traceability information + +dppvalidator catches data errors; it doesn't verify the truthfulness of claims. + +### What standards does dppvalidator support? + +- **UNTP DPP** — UN/CEFACT Digital Product Passport specification +- **W3C VC** — Verifiable Credentials Data Model v2 +- **JSON Schema Draft 2020-12** — For structure validation +- **GS1** — GTIN checksum validation +- **ISO** — Country codes, unit codes via UNECE vocabularies + +### When will EU DPP requirements take effect? + +- **2027** — Textiles and apparel +- **2027** — Batteries (separate regulation) +- **TBD** — Other product categories under ESPR + +Start preparing now to avoid last-minute compliance failures. + +______________________________________________________________________ + +## Troubleshooting + +### Why is JSON-LD validation slow? + +JSON-LD validation requires fetching context documents from remote URLs. Enable caching: + +```python +# Contexts are cached after first request +# Subsequent validations will be faster +engine = ValidationEngine(validate_jsonld=True) +``` + +For offline environments, context resolution may fail. + +### Why do I get "pyld not installed" warnings? + +This shouldn't happen with a standard installation since `pyld` is a core +dependency. Try reinstalling: + +```bash +# Using uv (recommended) +uv sync --reinstall-package dppvalidator + +# Or using pip +pip install --force-reinstall dppvalidator +``` + +### How do I enable signature verification? + +Signature verification is available out of the box since `cryptography` is +a core dependency: + +```python +engine = ValidationEngine(verify_signatures=True) +result = engine.validate(signed_dpp) + +if result.signature_valid: + print(f"Signed by: {result.issuer_did}") +``` + +______________________________________________________________________ + +## Getting Help + +- **Documentation** — [artiso-ai.github.io/dppvalidator](https://artiso-ai.github.io/dppvalidator) +- **GitHub Issues** — [github.com/artiso-ai/dppvalidator/issues](https://github.com/artiso-ai/dppvalidator/issues) +- **PyPI** — [pypi.org/project/dppvalidator](https://pypi.org/project/dppvalidator) + +For AI assistants, see [llms.txt](llms.txt) and [llms-ctx.txt](llms-ctx.txt). diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index f12c779..8c40866 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -33,59 +33,26 @@ poetry add dppvalidator ## Optional Dependencies -### HTTP Support +### CLI Extras -For fetching remote schemas and vocabularies: +For enhanced CLI output with rich formatting: === "uv" ``` - -uv add "dppvalidator[http]" - -``` - -=== "pip" - -``` -pip install "dppvalidator[http]" -``` - -This installs `httpx` for async HTTP requests. - -### JSON Schema Validation - -For strict JSON Schema validation: - -=== "uv" - -``` -uv add "dppvalidator[jsonschema]" +uv add "dppvalidator[cli]" ``` === "pip" ``` -pip install "dppvalidator[jsonschema]" -``` - -### All Optional Dependencies - -=== "uv" - -``` - -uv add "dppvalidator[all]" - +pip install "dppvalidator[cli]" ``` -=== "pip" +This installs `rich` for colored terminal output. -``` - -pip install "dppvalidator[all]" - -``` +!!! note "Core dependencies included by default" + The core package already includes `httpx`, `jsonschema`, `pyld`, and `cryptography` — no extras needed for full validation functionality. ## Verify Installation diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 9cb7100..8f781ae 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -1,3 +1,7 @@ +______________________________________________________________________ + +## description: Get started with dppvalidator in 5 minutes. + # Quick Start Get started with dppvalidator in 5 minutes. @@ -107,10 +111,53 @@ jsonld = exporter.export(passport) print(jsonld) ``` +## 7. Working with UNTP v0.7.0 + +dppvalidator supports both UNTP DPP **v0.6.x** (the wire shape used +above — `credentialSubject` wraps a `Product`) and **v0.7.0** +(`credentialSubject` IS the `Product` directly, with new required +fields). The engine auto-detects the version from the payload's +`@context` URL. + +### Validate a v0.7.0 payload + +```bash +# Auto-detected from the payload's @context URL. +dppvalidator validate passport-v07.json + +# Pin explicitly when you want VER001 fail-fast on mismatched payloads. +dppvalidator validate passport-v07.json --schema-version 0.7.0 +``` + +### Upgrade a v0.6.x payload to v0.7.0 + +```bash +# Write the upgraded payload to a new file. +dppvalidator migrate passport.json -o passport-v07.json + +# Validate-after-upgrade in one shot. +dppvalidator validate passport.json \ + --upgrade-from 0.6.1 \ + --schema-version 0.7.0 +``` + +The shim emits structured warnings (`UPG001`–`UPG004`) for fields +it can't fully translate. See the +[migration guide](../guides/migration-0-6-to-0-7.md) for the field +rename table and warning codes, and +[UNTP DPP versions](../concepts/untp-versions.md) for the full +version-handling story. + ## Next Steps - [CLI Usage Guide](../guides/cli-usage.md) — Full CLI reference -- [Validation Guide](../guides/validation.md) — Understanding the three validation layers + (validate, migrate, schema, …) +- [Validation Guide](../guides/validation.md) — Understanding the five + validation layers +- [UNTP DPP versions](../concepts/untp-versions.md) — Version detection, + defaults, adding a new version +- [Migration guide: 0.6 → 0.7](../guides/migration-0-6-to-0-7.md) — + The compat shim, field rename table, warning codes - [API Reference](../reference/api/validators.md) — Complete API documentation ## For AI Assistants diff --git a/docs/guides/cli-usage.md b/docs/guides/cli-usage.md index 68f1044..83d31fe 100644 --- a/docs/guides/cli-usage.md +++ b/docs/guides/cli-usage.md @@ -1,3 +1,7 @@ +______________________________________________________________________ + +## description: CLI reference for validating and exporting DPPs. + # CLI Usage The dppvalidator CLI provides commands for validating DPPs, exporting to different formats, and inspecting schemas. @@ -6,40 +10,78 @@ The dppvalidator CLI provides commands for validating DPPs, exporting to differe ### validate -Validate a Digital Product Passport JSON file. +Validate one or more Digital Product Passport JSON files. ``` -dppvalidator validate [options] +dppvalidator validate ... [options] ``` **Arguments:** -- `input` — Path to JSON file or `-` for stdin +- `input...` — Path(s) to JSON file(s), glob pattern(s), or `-` for stdin **Options:** - `-s, --strict` — Enable strict JSON Schema validation - `-f, --format` — Output format: `text`, `json`, `table` (default: text) -- `--schema-version` — Schema version (default: 0.6.1) +- `--schema-version` — Schema version (default: `0.6.1`; one of + `0.6.0`, `0.6.1`, `0.7.0`) +- `--upgrade-from` — Run the v0.6 → v0.7 compat shim before validating + (Phase 4); accepts `0.6.0` / `0.6.1` - `--fail-fast` — Stop on first error - `--max-errors` — Maximum errors to report (default: 100) -**Examples:** +**v0.6.x examples:** -``` -# Validate a file +```bash +# Validate a single file (default schema-version is 0.6.1) dppvalidator validate passport.json +# Pin v0.6.1 explicitly. A v0.7.0 payload through this command fails +# fast with VER001 (version mismatch). +dppvalidator validate passport.json --schema-version 0.6.1 + +# Validate multiple files +dppvalidator validate passport1.json passport2.json passport3.json + +# Validate with glob pattern (quote to prevent shell expansion) +dppvalidator validate "data/passports/*.json" + +# Batch validate entire directory +dppvalidator validate "data/**/*.json" --strict + # Validate from stdin cat passport.json | dppvalidator validate - -# JSON output for CI/CD -dppvalidator validate passport.json --format json +# JSON output for CI/CD (includes summary for batch) +dppvalidator validate "*.json" --format json + +# Table output for quick overview +dppvalidator validate "*.json" --format table +``` + +**v0.7.0 examples:** + +```bash +# Pin v0.7.0 explicitly. The detection layer otherwise auto-detects +# from the payload's @context URL. +dppvalidator validate passport-v07.json --schema-version 0.7.0 -# Strict mode with custom schema version -dppvalidator validate passport.json --strict --schema-version 0.6.1 +# Run the compat shim, then validate as v0.7.0. Upgrade warnings are +# inlined in the validation output. +dppvalidator validate passport-v06.json \ + --upgrade-from 0.6.1 \ + --schema-version 0.7.0 ``` +**Batch Output:** + +When validating multiple files, the output includes a summary: + +- **Text format**: Individual results + summary counts +- **JSON format**: `{"files": [...], "summary": {"total": N, "valid": N, "invalid": N}}` +- **Table format**: Status table with error/warning counts per file + ### export Export a DPP to different formats. @@ -50,7 +92,7 @@ dppvalidator export [options] **Options:** -- `-f, --format` — Output format: `json`, `jsonld` (default: json) +- `-f, --format` — Output format: `json`, `jsonld` (default: jsonld) - `-o, --output` — Output file path (default: stdout) **Examples:** @@ -65,27 +107,89 @@ dppvalidator export passport.json --format jsonld -o output.jsonld ### schema -Display schema information. +Manage DPP schemas. ``` -dppvalidator schema [options] +dppvalidator schema [options] ``` -**Options:** +**Subcommands:** + +- `list` — List available schema versions +- `info` — Show schema information +- `download` — Download a schema version + +**Options (for info/download):** -- `--version` — Schema version to display -- `--list` — List available schema versions +- `-v, --version` — Schema version (default: 0.6.1) +- `-o, --output` — Output directory for download **Examples:** +```bash +# List every registered version (currently 0.6.0, 0.6.1, 0.7.0). +dppvalidator schema list + +# Show schema info for v0.6.1. +dppvalidator schema info -v 0.6.1 + +# Show schema info for v0.7.0. +dppvalidator schema info -v 0.7.0 + +# Download schema to local directory. +dppvalidator schema download -v 0.7.0 -o ./schemas/ ``` -# Show current schema -dppvalidator schema -# List available versions -dppvalidator schema --list +### migrate + +Upgrade a v0.6.x DPP payload to v0.7.0 shape via the compat shim +(Phase 4). The full reference for the warning codes and field +renames is the [migration guide](migration-0-6-to-0-7.md). + +```text +dppvalidator migrate [options] ``` +**Arguments:** + +- `input` — Path to v0.6.x JSON file, or `-` for stdin. + +**Options:** + +- `-o, --output` — Output file path (default: stdout) +- `--in-place` — Write the upgraded JSON back to the input path + (overwrites). Mutually exclusive with `-o`. +- `--accept-warnings` — Write the upgraded JSON even when the shim + emits warning- or error-severity events. Without this, the command + exits with code 1 on any non-info warning. +- `--from` — Source UNTP version family (default: `0.6.x`). Pass an + explicit `X.Y.Z` to pin. + +**Examples:** + +```bash +# Default — upgrade to stdout, refuse if warnings fire. +dppvalidator migrate passport.json + +# Write to an explicit output path. +dppvalidator migrate passport.json -o passport-v07.json + +# Overwrite the input. +dppvalidator migrate passport.json --in-place + +# Accept warnings; sidecar passport-v07.json.warnings.json is written +# alongside the upgraded payload. +dppvalidator migrate passport.json -o passport-v07.json --accept-warnings +``` + +**Exit codes:** + +- `0` — Upgrade succeeded with no blocking warnings (or + `--accept-warnings` was given). +- `1` — Blocking warnings fired and `--accept-warnings` was not given; + the sidecar warnings file is still written. +- `2` — Error (file not found, invalid JSON, unknown source version). + ## Exit Codes | Code | Meaning | diff --git a/docs/guides/eudpp-export.md b/docs/guides/eudpp-export.md new file mode 100644 index 0000000..b4f8d0d --- /dev/null +++ b/docs/guides/eudpp-export.md @@ -0,0 +1,282 @@ +# EU DPP JSON-LD Export Guide + +> **Status:** ✅ Available +> **Since:** v0.7.0 +> **Source:** CIRPASS-2 Official Ontology v1.7.1 + +This guide covers exporting Digital Product Passports to EU DPP-aligned JSON-LD +format using the CIRPASS-2 vocabulary. + +## Overview + +The EU DPP export functionality transforms UNTP DPP models to use the official +EU DPP Core Ontology vocabulary. The key principle is: + +> **UNTP models remain unchanged** — the export layer transforms vocabulary +> at export time. + +This means you can: + +- Use the same UNTP models throughout your application +- Export to EU DPP-aligned format when needed +- Maintain full UNTP compatibility + +## Quick Start + +```python +from dppvalidator.exporters import EUDPPJsonLDExporter + +# Create exporter with term mapping enabled +exporter = EUDPPJsonLDExporter(map_terms=True) + +# Export passport to EU DPP JSON-LD +jsonld = exporter.export(passport) +print(jsonld) +``` + +## Exporter Options + +### EUDPPJsonLDExporter + +The main exporter class with configurable options: + +```python +from dppvalidator.exporters import EUDPPJsonLDExporter + +# Full control +exporter = EUDPPJsonLDExporter( + map_terms=True, # Map UNTP terms to EU DPP (default: True) + include_untp_context=False, # Include UNTP context in output (default: False) + schema_version=None, # None = auto-detect from passport class +) + +# Export methods +jsonld_str = exporter.export(passport) # Returns JSON string +jsonld_dict = exporter.export_dict(passport) # Returns dictionary +exporter.export_to_file(passport, "output.jsonld") # Writes to file +``` + +### Per-version dispatch (Phase 3c) + +The exporter is **version-aware**. UNTP v0.6 and v0.7 use different +source-side spellings (`serialNumber` vs `itemNumber`, +`producedByParty` vs `relatedParty`, …) but most map to the same EU +DPP target URI. The exporter resolves the right column of +`TermMapping` per call: + +- **`schema_version=None` (default)** — auto-detect from the + passport class's module path. A `dppvalidator.models.v0_7.*` + passport gets the v0.7 mapper, a v0.6 passport gets the v0.6 + mapper. One exporter instance can serve mixed inputs. +- **`schema_version="0.6.1"` or `"0.7.0"`** — pin explicitly. + Useful when the passport's source version is known up front + (e.g. CI pipelines), or when you want to *force* the v0.6 mapping + on a v0.7 passport for downstream-compat scenarios. + +```python +from dppvalidator.exporters import EUDPPJsonLDExporter +from dppvalidator.models.v0_7 import DigitalProductPassport + +passport = DigitalProductPassport.model_validate(payload_v07_dict) + +# Auto-detect (recommended). +EUDPPJsonLDExporter().export(passport) + +# Pin explicitly. +EUDPPJsonLDExporter(schema_version="0.7.0").export(passport) +``` + +Terms removed in v0.7 (e.g. `gtin`) are skipped from the v0.7 +mapper's index — they don't appear in the exported JSON-LD even if +the source class somehow carries them. Renamed terms route to the +correct EU DPP URI regardless of which source spelling was used. + +### Convenience Functions + +For simple use cases: + +```python +from dppvalidator.exporters import ( + export_eudpp_jsonld, + export_eudpp_jsonld_dict, + get_term_mapping_summary, +) + +# String output (auto-detects version). +jsonld = export_eudpp_jsonld(passport) + +# Dictionary output, pinned to v0.7. +data = export_eudpp_jsonld_dict(passport, map_terms=True, schema_version="0.7.0") + +# Inspect the mapping table for a given version. +summary_v06 = get_term_mapping_summary("0.6.1") +summary_v07 = get_term_mapping_summary("0.7.0") +``` + +## Term Mapping + +The exporter maps UNTP terms to EU DPP Core Ontology terms. The EU +DPP target URI is the same across UNTP versions; only the source-side +spelling shifts between v0.6 and v0.7. + + + +| UNTP v0.6 term | UNTP v0.7 term | EU DPP target URI | +| ------------------------ | ----------------------------------- | ----------------------------- | +| `id` | `id` | `eudpp:uniqueDPPID` | +| `DigitalProductPassport` | `DigitalProductPassport` | `eudpp:DPP` | +| `Product` | `Product` | `eudpp:Product` | +| `serialNumber` | `itemNumber` | `eudpp:uniqueProductID` | +| `producedByParty` | `relatedParty[role="manufacturer"]` | `eudpp:hasManufacturer` | +| `granularityLevel` | `idGranularity` | `eudpp:granularity` | +| `materialsProvenance` | `materialProvenance` | `eudpp:hasMaterialProvenance` | +| `conformityClaim` | `performanceClaim` | `eudpp:hasPerformanceClaim` | +| `gtin` | *removed* | `eudpp:GTIN` (v0.6 only) | +| `validFrom` | `validFrom` | `eudpp:validFrom` | +| `issuer` | `issuer` | `eudpp:hasIssuer` | + + + +The full table lives in +[`vocabularies/ontology.py:TERM_MAPPINGS`](https://github.com/artiso-ai/dppvalidator/blob/main/src/dppvalidator/vocabularies/ontology.py). +The `TERM_REMOVED` sentinel marks v0.6 fields with no v0.7 equivalent +(`gtin` today) — those rows drop out of the v0.7 mapper's index. + +### Viewing Term Mappings + +```python +from dppvalidator.exporters import get_term_mapping_summary + +mappings = get_term_mapping_summary() +for untp_term, eudpp_term in mappings.items(): + print(f"{untp_term} → {eudpp_term}") +``` + +## JSON-LD Context + +The exported JSON-LD includes the proper EU DPP context: + +```python +from dppvalidator.exporters import get_eudpp_jsonld_context + +context = get_eudpp_jsonld_context() +# Returns: +# [ +# "https://www.w3.org/ns/credentials/v2", +# {"eudpp": "http://dpp.taltech.ee/EUDPP#", ...} +# ] +``` + +## Validating Exports + +Validate that an export has the required EU DPP structure: + +```python +from dppvalidator.exporters import validate_eudpp_export + +data = exporter.export_dict(passport) +issues = validate_eudpp_export(data) + +if issues: + print("Export validation issues:") + for issue in issues: + print(f" - {issue}") +else: + print("✅ Valid EU DPP export") +``` + +## Example Output + +```json +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + { + "eudpp": "http://dpp.taltech.ee/EUDPP#", + "schema": "https://schema.org/", + "xsd": "http://www.w3.org/2001/XMLSchema#" + } + ], + "type": ["eudpp:DPP", "VerifiableCredential"], + "uniqueDPPID": "urn:uuid:12345-abcde", + "schemaVersion": "CIRPASS-2 v1.3.0", + "granularity": "model", + "credentialSubject": { + "product": { + "type": "eudpp:Product", + "productName": "Sustainable T-Shirt", + "uniqueProductID": "urn:gtin:1234567890123" + } + } +} +``` + +## Dual-Mode Validation + +Combine EU DPP export with CIRPASS schema validation: + +```python +from dppvalidator.validators import SchemaValidator +from dppvalidator.exporters import EUDPPJsonLDExporter + +# Validate with CIRPASS schema +validator = SchemaValidator(schema_type="cirpass") +result = validator.validate(dpp_data) + +if result.valid: + # Export to EU DPP format + exporter = EUDPPJsonLDExporter() + jsonld = exporter.export(passport) +``` + +## SHACL Validation (Optional) + +For full RDF-based SHACL validation, install the RDF extras: + +```bash +pip install dppvalidator[rdf] +``` + +Then validate against official SHACL shapes: + +```python +from dppvalidator.validators import ( + RDFSHACLValidator, + is_shacl_validation_available, +) + +if is_shacl_validation_available(): + validator = RDFSHACLValidator(use_official_shapes=True) + result = validator.validate_jsonld(jsonld_data) + + if result.conforms: + print("✅ Valid against SHACL shapes") + else: + for violation in result.violations: + print(f"✗ {violation['path']}: {violation['message']}") +``` + +## API Reference + +### Classes + +| Class | Description | +| --------------------- | -------------------- | +| `EUDPPJsonLDExporter` | Main exporter class | +| `EUDPPTermMapper` | Term mapping utility | + +### Functions + +| Function | Description | +| ---------------------------- | ------------------------- | +| `export_eudpp_jsonld()` | Export to JSON-LD string | +| `export_eudpp_jsonld_dict()` | Export to dictionary | +| `get_eudpp_jsonld_context()` | Get JSON-LD @context | +| `get_term_mapping_summary()` | Get term mapping dict | +| `validate_eudpp_export()` | Validate export structure | + +## See Also + +- [CIRPASS-2 Integration](../concepts/cirpass-implementation.md) +- [EU DPP Ontology Alignment](../concepts/eudpp-ontology-alignment.md) +- [JSON-LD Export](jsonld.md) diff --git a/docs/guides/jsonld.md b/docs/guides/jsonld.md index 4947745..756932f 100644 --- a/docs/guides/jsonld.md +++ b/docs/guides/jsonld.md @@ -22,13 +22,19 @@ print(jsonld_string) ## Output Format -The JSON-LD output includes the W3C VC context: +The JSON-LD output always includes the W3C VC v2 context plus the +UNTP context for the active version. The exporter auto-detects the +source version from the passport's class (Phase 3c — the +`EUDPPJsonLDExporter` reads the module path); `EUDPPJsonLDExporter(schema_version=…)` +pins it explicitly. + +### v0.6.x output ```json { "@context": [ "https://www.w3.org/ns/credentials/v2", - "https://vocabulary.uncefact.org/untp/dpp/0.6.1" + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/" ], "type": ["DigitalProductPassport", "VerifiableCredential"], "id": "https://example.com/dpp/001", @@ -39,6 +45,25 @@ The JSON-LD output includes the W3C VC context: } ``` +### v0.7.0 output + +```json +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/" + ], + "type": ["DigitalProductPassport", "VerifiableCredential"], + "id": "https://example.com/dpp/001", + "name": "Acme DPP", + "issuer": { + "type": ["CredentialIssuer"], + "id": "did:web:example.com:issuer", + "name": "Acme Corp" + } +} +``` + ## Export Options ### Custom Context diff --git a/docs/guides/migration-0-6-to-0-7.md b/docs/guides/migration-0-6-to-0-7.md new file mode 100644 index 0000000..107d759 --- /dev/null +++ b/docs/guides/migration-0-6-to-0-7.md @@ -0,0 +1,196 @@ + + +# Migrating UNTP DPP payloads from 0.6.x to 0.7.0 + +UNTP DPP **0.7.0** restructures several core fields. dppvalidator +ships a **compat shim** (`dppvalidator.compat.upgrade_0_6_to_0_7`) +that rewrites a 0.6.x payload into 0.7.0 shape and emits structured +warnings for anything it can't fully translate. + +This guide covers: + +1. How to run the shim from the CLI and Python. +1. The complete field rename / shape-change table. +1. The four warning codes and what they mean. +1. The documented limitations (fields that need manual intervention). + +The conceptual overview of UNTP version handling lives in +[UNTP DPP versions](../concepts/untp-versions.md). + +## When you need this + +You need this guide if you: + +- have v0.6.x payloads and want to publish them as v0.7.0, +- have a pipeline that validates v0.6.x today and want to switch the + validation target without re-issuing every passport, or +- are authoring a new v0.7.0 payload and want a quick reference for + what changed. + +If you're authoring fresh v0.7.0 payloads, skip to the +[field rename table](#field-rename-and-shape-change-table) and the +[`untp-versions`](../concepts/untp-versions.md) page. + +## Quick start + +### CLI: rewrite a single file + +```bash +# Default — write to stdout, refuse if any warning fires. +dppvalidator migrate passport.json + +# Write to an explicit output path. +dppvalidator migrate passport.json -o passport-v07.json + +# Overwrite the input. Refuses on warnings unless --accept-warnings. +dppvalidator migrate passport.json --in-place + +# Accept the upgrade even if warnings fire. Produces a sidecar +# passport-v07.json.warnings.json with the full warning list. +dppvalidator migrate passport.json -o passport-v07.json --accept-warnings +``` + +### CLI: validate-then-upgrade in one shot + +```bash +# Run the shim, then validate the result against v0.7.0. +dppvalidator validate passport.json \ + --upgrade-from 0.6.1 \ + --schema-version 0.7.0 +``` + +### Python API + +```python +from dppvalidator.compat import upgrade + +with open("passport.json") as f: + src = json.load(f) + +upgraded, warnings = upgrade(src, country_lookup={"DE": "Germany"}) + +for w in warnings: + print(f"[{w.code}] ({w.severity.value}) {w.path}: {w.message}") +``` + +`upgrade()` is pure — it deep-copies its input, never mutates it. +The optional `country_lookup` populates `Country.countryName` for +ISO-3166-1 alpha-2 codes; codes outside the map fall back to +`{countryCode: …}` only. + +## Field rename and shape-change table + +The shim performs 17 transformation steps in a fixed order. The +most user-visible changes are summarised here; the full step-by-step +specification lives in +[`upgrade_0_6_to_0_7.py`](https://github.com/artiso-ai/dppvalidator/blob/main/src/dppvalidator/compat/upgrade_0_6_to_0_7.py). + +### Envelope-level changes + +| v0.6.x | v0.7.0 | Note | +| ---------------------------------------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `@context: ".../test.uncefact.org/vocabulary/untp/dpp/0.6.x/"` | `@context: ".../vocabulary.uncefact.org/untp/0.7.0/context/"` | The W3C VC v2 context is preserved; only the UNTP entry is rewritten. | +| `name`: optional | `name`: **required** | Synthesised from `credentialSubject.product.name` when missing → `UPG002`. | +| `validFrom`: optional | `validFrom`: **required** | Cannot be synthesised — `UPG004` fires when missing. | +| `credentialSubject` is `ProductPassport` (envelope wrapping `Product`) | `credentialSubject` IS the `Product` directly | All `ProductPassport` siblings (claims, scorecards, materials, due-diligence) are folded onto the new `Product`. | + +### Product-level renames + +| v0.6.x | v0.7.0 | Note | +| -------------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `Product.serialNumber` | `Product.itemNumber` | Pure rename. | +| `ProductPassport.granularityLevel` | `Product.idGranularity` | Carried up to the new `credentialSubject`. | +| `ProductPassport.materialsProvenance[]` | `Product.materialProvenance[]` | Singular noun in v0.7. | +| `ProductPassport.dueDiligenceDeclaration: Link` | `Product.relatedDocument[Link{name: "Due diligence declaration"}]` | Folded into the unified `relatedDocument` array. | +| `Product.furtherInformation: Link[]` | `Product.relatedDocument[]` | Same target; existing `relatedDocument` entries are preserved and the legacy ones are appended. | +| `Product.producedByParty: Party` | `Product.relatedParty[PartyRole{role: "manufacturer", party: }]` | Wrapped as a typed PartyRole. v0.7's `relatedParty` carries every supply-chain role, not just the manufacturer. | +| `Product.productCategory: Classification` (scalar) | `Product.productCategory: Classification[]` | Single-element array even when only one category is set. | +| `Product.registeredId` | _dropped_ — moves to `Party.registeredId` | The shim emits `UPG001`. Re-attach manually to the appropriate `relatedParty[*].party.registeredId` if needed. | + +### Per-claim changes + +| v0.6.x | v0.7.0 | Note | +| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `ProductPassport.conformityClaim[]` | `Product.performanceClaim[]` | Plus the three v0.6 scorecards (emissions, circularity, traceability) are also folded here. | +| `Claim.assessmentDate` | `Claim.claimDate` | Field rename. | +| `Claim.assessmentCriteria: Criterion[]` | `Claim.referenceCriteria: list[dict]` | The Criterion type is gone; entries are passed through as free-form objects. | +| `Claim.declaredValue: Metric[]` | `Claim.claimedPerformance: Performance[]` | Each `Metric` is split: `metricName` → `metric.name`, `metricValue` → `measure`, `score` → `score.code`. | +| `Claim.conformityTopic: string` | `Claim.conformityTopic: ConformityTopic[]` | Wrapped as a single-element list with `id`/`name` set to the original string. | +| `Claim.conformityEvidence: SecureLink` | `Claim.evidence: Link[]` | The dedicated `SecureLink` type is gone in v0.7; v0.7 `Link` absorbs `digestMultibase` + `mediaType`. | +| `Claim.referenceStandard: Standard` | `Claim.referenceStandard: list[dict]` | Scalar wrapped to a single-element list. | +| `Claim.referenceRegulation: Regulation` | `Claim.referenceRegulation: list[dict]` | Same wrapping. | +| `Claim.conformance: bool` | _dropped_ | No v0.7 equivalent at the top level. | +| `EmissionsScorecard / CircularityScorecard / TraceabilityPerformance` | folded into `Claim` entries on `performanceClaim[]` with `conformityTopic` set to `Emissions`/`Circularity`/`Traceability` | Numeric scorecard fields become `claimedPerformance[].measure` entries; embedded Link fields become `evidence[]`. | + +### Country / Material / Classification + +| v0.6.x | v0.7.0 | Note | +| -------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Product.countryOfProduction: "DE"` | `Product.countryOfProduction: {countryCode: "DE", countryName?: …}` | `countryName` is optional; populate via `country_lookup={"DE": "Germany"}` when invoking the shim. | +| `Material.originCountry: "DE"` | `Material.originCountry: Country` | Same wrapping as above. | +| `Material.symbol: ""` | `Material.symbol: Image{name, imageData, mediaType}` | Sentinel placeholders (`"undefined"`, etc.) are dropped with `UPG001`. Real base64 is upgraded with synthesised `name="Material symbol"` and `mediaType="image/png"` (`UPG002`). | +| `Material.materialType: Classification` (optional) | `Material.materialType: Classification` (**required**) | When missing, the shim emits `UPG004`; provide manually. | +| `Material.massFraction: float` (optional) | `Material.massFraction: float` (**required**) | Same: `UPG004` fires when missing. | +| `Classification.schemeID` | `Classification.schemeId` | camelCase fix; renamed everywhere recursively. | +| Embedded `type: [...]` arrays on `Dimension`, `Characteristics`, `Measure`, etc. | _stripped_ | v0.7 schema dropped these discriminators; the shim removes them. | + +## Warning codes + +Every transformation that can't translate cleanly emits a structured +`UpgradeWarning` with one of four codes: + +| Code | Severity | Meaning | +| -------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `UPG001` | warning | **Lossy** — a v0.6 field has no v0.7 equivalent. Examples: `Product.registeredId` dropped; sentinel `Material.symbol` strings dropped. | +| `UPG002` | info / warning | **Synthesised** — the shim filled a missing field from another source. Examples: `name` synthesised from `Product.name`; `Material.symbol` upgraded to a v0.7 `Image` with synthesised `name` and `mediaType`. `INFO` severity for "country name not synthesised because no `country_lookup`"; `WARNING` for envelope-level synthesis. | +| `UPG003` | warning | **Unmapped country** — a country code is not in the bundled ISO-3166-1 list. The shim still wraps the value structurally, but it will fail v0.7 validation. | +| `UPG004` | error | **Required field missing** — a field that v0.7 requires is missing from the v0.6 source and the shim cannot synthesise it. The caller MUST provide the value before the upgraded payload validates. Examples: `validFrom`, `Material.materialType`, `Material.massFraction`. | + +The `migrate` CLI refuses to write the output file when any +warning- or error-severity event fires; pass `--accept-warnings` to +override. INFO-severity events never block. A sidecar +`.warnings.json` is always written alongside any blocking-warning +output (whether or not the main file is written) so the caller has a +machine-readable record. + +## Documented limitations + +These v0.6.x payloads cannot fully round-trip through the shim; each +case is intentional and surfaces as one of the warnings above: + +| Limitation | Code | What to do | +| ----------------------------------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Product.registeredId` set | `UPG001` | Move the value to `Product.relatedParty[*].party.registeredId` on the appropriate party (typically the manufacturer). The shim drops it because the field's home moved from Product to Party. | +| `Material.materialType` missing | `UPG004` | v0.7 makes `materialType` required. Supply a `Classification` (e.g. `{schemeId: ".../cpc/", schemeName: "UN CPC", code: "12345", name: "..."}`). | +| `Material.massFraction` missing | `UPG004` | v0.7 makes `massFraction` required. Supply a numeric value in `[0, 1]`. | +| Top-level `name` missing AND `Product.name` missing | `UPG004` | Provide a top-level `name` manually. The shim only synthesises from the inner `Product.name`. | +| Top-level `validFrom` missing | `UPG004` | Add a real `validFrom` timestamp; the shim cannot fabricate a date. | +| `Material.symbol` is a non-base64 string | `UPG001` | Provide a v0.7 `Image` object with `name`, `imageData` (base64), and `mediaType`. The shim drops anything that doesn't decode as base64. | +| Country code not in bundled ISO-3166-1 list | `UPG003` | The shim wraps it structurally as `{countryCode: "XX"}`, but v0.7 model validation rejects non-ISO codes. Fix the source data. | +| Bare `ProductPassport` payload (no W3C VC envelope) | _shim no-ops_ | The shim is defined for full DPP envelopes; bare `ProductPassport` payloads were never valid v0.6 wire format. Wrap in a VC envelope first. | +| Domain-specific industry extensions (e.g. textiles plugin fields) | _passes through_ | Extension fields under `Characteristics` flow through (`extra="allow"`) but their internal shape isn't migrated. Verify against your plugin's v0.7 documentation. | + +## Round-trip verification + +The repository includes +[`tests/integration/test_compat_roundtrip.py`](https://github.com/artiso-ai/dppvalidator/blob/main/tests/integration/test_compat_roundtrip.py), +which upgrades every enveloped 0.6.x fixture under +`tests/fixtures/valid/` and asserts the result either validates +cleanly against the v0.7 model or surfaces an explanatory warning. +A silent failure (no warning, no validation pass) trips the assertion +immediately. + +If you build a CI check around the shim, mirror that contract: +**fail loudly when an upgrade produces an invalid payload AND no +warning explains why.** + +## See also + +- [UNTP DPP versions](../concepts/untp-versions.md) — overall version + handling, default version, detection rules. +- [Five-layer validation](../concepts/validation-layers.md) — how the + upgraded payload then flows through validation. +- [`upgrade_0_6_to_0_7.py`](https://github.com/artiso-ai/dppvalidator/blob/main/src/dppvalidator/compat/upgrade_0_6_to_0_7.py) + — full implementation of the 17 transformation steps. +- [Migration plan archive](https://github.com/artiso-ai/dppvalidator/blob/main/docs/plans/UNTP_0.7.0_MIGRATION.md) — Phase 4 + (compat shim) is the canonical engineering record. diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md index 383a0a0..792856e 100644 --- a/docs/guides/plugins.md +++ b/docs/guides/plugins.md @@ -106,7 +106,92 @@ print("Validators:", registry.list_validators()) print("Exporters:", registry.list_exporters()) ``` +## Writing a version-aware rule + +UNTP DPP introduced a wire-shape change in **v0.7.0**: the +`ProductPassport` envelope is gone, so `credentialSubject` is now a +`Product` directly (no inner `.product` attribute). A rule written +against the v0.6 shape will silently no-op on v0.7 payloads — and +vice-versa. Plugins that target a specific version should declare +which shape they target. + +The contract: set +`applies_to_versions: tuple[str, ...] = ("0.7.0",)` on the rule +class, and **duck on attribute presence** so the rule no-ops +cleanly when the wrong-version passport flows through. + +```python +# brand_name_v07.py +from typing import Any, Literal + + +class BrandNameRuleV07: + """v0.7-shape brand-name check. + + Reads ``passport.credential_subject.name`` directly (v0.7 has + Product as credentialSubject) and accepts a brandOwner + relatedParty as an alternative attribution. + """ + + rule_id: str = "SEM_BRAND_V07" + description: str = "Products should attribute brand identity (v0.7)." + severity: Literal["error", "warning", "info"] = "warning" + + # Tells the engine's per-version rule dispatch which version this + # rule targets. If omitted, the rule runs for every version. + applies_to_versions: tuple[str, ...] = ("0.7.0",) + + def check(self, passport: Any) -> list[tuple[str, str]]: + cs = getattr(passport, "credential_subject", None) + if cs is None or hasattr(cs, "product"): + # v0.6 shape (or no cs) — skip cleanly. + return [] + + if getattr(cs, "name", None): + return [] + + # Fall back to a brandOwner relatedParty. + for entry in getattr(cs, "related_party", None) or []: + role = getattr(entry, "role", None) + if getattr(role, "value", role) == "brandOwner": + return [] + + return [ + ( + "$.credentialSubject", + "Product should attribute brand identity via Product.name " + "or a relatedParty with role 'brandOwner'.", + ) + ] +``` + +Register it as a separate entry point alongside the v0.6 sibling: + +```toml +[project.entry-points."dppvalidator.validators"] +brand_name = "my_package.brand_name:BrandNameRule" # v0.6 +brand_name_v07 = "my_package.brand_name_v07:BrandNameRuleV07" # v0.7 +``` + +Both rules co-exist in the registry; the engine picks the one +whose `applies_to_versions` matches the detected payload version. +A worked v0.6/v0.7 sibling pair lives in the +[`dppvalidator-example-plugin`](https://github.com/artiso-ai/dppvalidator/tree/main/examples/dppvalidator_example_plugin) +under `examples/`, with integration tests in +[`tests/integration/test_example_plugin.py`](https://github.com/artiso-ai/dppvalidator/blob/main/tests/integration/test_example_plugin.py). + +### Public-API stability for plugins + +The plugin's import path +(`from dppvalidator.models.passport import DigitalProductPassport`) +resolves to the **v0.6** model via the top-level shim, even after +the Phase 3 model relocation. v0.7 plugins should import from +`dppvalidator.models.v0_7.envelope` explicitly. The CI test +`tests/integration/test_example_plugin.py` pins this contract. + ## Next Steps - [API Reference](../reference/api/plugins.md) — Plugin Registry API +- [UNTP DPP versions](../concepts/untp-versions.md) — version detection + and the coexistence matrix. - [Validation Guide](validation.md) — Understanding validation layers diff --git a/docs/guides/use-cases.md b/docs/guides/use-cases.md new file mode 100644 index 0000000..f9c9aa8 --- /dev/null +++ b/docs/guides/use-cases.md @@ -0,0 +1,744 @@ +# Use Cases + +Real-world examples of how to use dppvalidator across different scenarios +and industries. + +______________________________________________________________________ + +## Fashion & Textiles Compliance + +### Pre-Production Validation + +Validate DPP data before generating QR codes for garment labels. + +```python +from dppvalidator import ValidationEngine +from dppvalidator.vocabularies import validate_gtin, is_valid_material_code + +engine = ValidationEngine(strict_mode=True) + + +def validate_garment_dpp(dpp_data: dict) -> dict: + """Validate garment DPP before QR code generation.""" + result = engine.validate(dpp_data) + + if not result.valid: + return { + "status": "rejected", + "errors": [ + {"code": e.code, "path": e.path, "message": e.message} + for e in result.errors + ], + } + + return { + "status": "approved", + "passport_id": dpp_data.get("id"), + "ready_for_qr": True, + } + + +# Example garment DPP +garment_dpp = { + "type": ["DigitalProductPassport", "VerifiableCredential"], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ], + "id": "https://brand.com/dpp/organic-dress-2024-001", + "issuer": { + "id": "did:web:brand.com", + "name": "Sustainable Fashion Brand", + }, + "validFrom": "2024-06-01T00:00:00Z", + "validUntil": "2034-06-01T00:00:00Z", + "credentialSubject": { + "type": ["Product"], + "id": "https://brand.com/products/organic-dress-001", + "name": "Organic Cotton Summer Dress", + "granularityLevel": "model", + "materialsProvenance": [ + { + "type": ["Material"], + "name": "GOTS Organic Cotton", + "massFraction": 0.95, + "recycled": False, + "hazardous": False, + }, + { + "type": ["Material"], + "name": "Elastane", + "massFraction": 0.05, + "recycled": False, + "hazardous": False, + }, + ], + "circularityScorecard": { + "type": ["CircularityScorecard"], + "recycledContent": 0.0, + "recyclableContent": 0.92, + }, + }, +} + +result = validate_garment_dpp(garment_dpp) +print(result) +``` + +### Material Composition Validation + +Ensure fiber compositions are accurate and sum correctly. + +```python +from dppvalidator import ValidationEngine + +engine = ValidationEngine() + + +def check_fiber_composition(dpp_data: dict) -> dict: + """Check fiber composition adds up to 100%.""" + result = engine.validate(dpp_data) + + materials = dpp_data.get("credentialSubject", {}).get("materialsProvenance", []) + + total_fraction = sum(m.get("massFraction", 0) for m in materials) + + return { + "valid": result.valid, + "total_composition": f"{total_fraction * 100:.1f}%", + "materials": [ + { + "name": m.get("name"), + "percentage": f"{m.get('massFraction', 0) * 100:.1f}%", + } + for m in materials + ], + "composition_complete": abs(total_fraction - 1.0) < 0.01, + } +``` + +______________________________________________________________________ + +## Supplier Onboarding + +### Supplier DPP Validation API + +Validate DPP submissions from suppliers before accepting them. + +```python +from fastapi import FastAPI, HTTPException, status +from pydantic import BaseModel +from dppvalidator import ValidationEngine + +app = FastAPI(title="Supplier DPP Validation API") +engine = ValidationEngine(strict_mode=True) + + +class SubmissionResponse(BaseModel): + accepted: bool + submission_id: str | None = None + errors: list[dict] | None = None + + +@app.post("/api/v1/supplier/submit-dpp", response_model=SubmissionResponse) +async def submit_dpp(dpp: dict, supplier_id: str): + """Validate and accept supplier DPP submission.""" + + # Validate the DPP + result = engine.validate(dpp) + + if not result.valid: + return SubmissionResponse( + accepted=False, + errors=[ + { + "code": e.code, + "field": e.path, + "message": e.message, + "suggestion": e.suggestion, + } + for e in result.errors + ], + ) + + # DPP is valid - store it + submission_id = f"SUB-{supplier_id}-{dpp.get('id', '').split('/')[-1]}" + + return SubmissionResponse( + accepted=True, + submission_id=submission_id, + ) + + +@app.post("/api/v1/supplier/batch-validate") +async def batch_validate(dpps: list[dict]): + """Validate multiple DPPs in one request.""" + results = await engine.validate_batch(dpps, concurrency=10) + + return { + "total": len(dpps), + "valid": sum(1 for r in results if r.valid), + "invalid": sum(1 for r in results if not r.valid), + "results": [ + { + "index": i, + "valid": r.valid, + "error_count": r.error_count, + } + for i, r in enumerate(results) + ], + } +``` + +### Supplier Quality Dashboard + +Aggregate validation results for supplier quality scoring. + +```python +from collections import Counter +from dataclasses import dataclass +from dppvalidator import ValidationEngine + + +@dataclass +class SupplierQualityReport: + supplier_id: str + total_submissions: int + valid_count: int + invalid_count: int + pass_rate: float + common_errors: list[tuple[str, int]] + + +def analyze_supplier_submissions( + supplier_id: str, + submissions: list[dict], +) -> SupplierQualityReport: + """Analyze a supplier's DPP submission quality.""" + engine = ValidationEngine() + + error_codes = [] + valid_count = 0 + + for dpp in submissions: + result = engine.validate(dpp) + if result.valid: + valid_count += 1 + else: + error_codes.extend(e.code for e in result.errors) + + return SupplierQualityReport( + supplier_id=supplier_id, + total_submissions=len(submissions), + valid_count=valid_count, + invalid_count=len(submissions) - valid_count, + pass_rate=valid_count / len(submissions) if submissions else 0, + common_errors=Counter(error_codes).most_common(5), + ) +``` + +______________________________________________________________________ + +## CI/CD Pipeline Integration + +### GitHub Actions Workflow + +Add DPP validation as a CI/CD gate. + +```yaml +# .github/workflows/validate-dpps.yml +name: Validate Digital Product Passports + +on: + push: + paths: + - 'data/passports/**/*.json' + pull_request: + paths: + - 'data/passports/**/*.json' + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dppvalidator + run: pip install dppvalidator + + - name: Validate all DPP files + run: | + find data/passports -name "*.json" -exec \ + dppvalidator validate {} --strict --format json \; + + - name: Generate validation report + if: always() + run: | + echo "## DPP Validation Report" >> $GITHUB_STEP_SUMMARY + for f in data/passports/*.json; do + result=$(dppvalidator validate "$f" --format json 2>&1 || true) + valid=$(echo "$result" | jq -r '.valid') + if [ "$valid" = "true" ]; then + echo "✅ $(basename $f)" >> $GITHUB_STEP_SUMMARY + else + echo "❌ $(basename $f)" >> $GITHUB_STEP_SUMMARY + fi + done +``` + +### Pre-Commit Hook + +Validate DPP files before committing. + +```yaml +# .pre-commit-config.yaml +repos: + - repo: local + hooks: + - id: validate-dpps + name: Validate DPP files + entry: dppvalidator validate + language: python + types: [json] + files: ^data/passports/.*\.json$ + additional_dependencies: [dppvalidator] +``` + +### Python CI Script + +Programmatic validation for complex pipelines. + +```python +#!/usr/bin/env python3 +"""CI script for DPP validation with detailed reporting.""" + +import json +import sys +from pathlib import Path +from dppvalidator import ValidationEngine + + +def validate_directory(directory: Path) -> int: + """Validate all DPP files in a directory.""" + engine = ValidationEngine(strict_mode=True) + + files = list(directory.glob("**/*.json")) + if not files: + print(f"No JSON files found in {directory}") + return 0 + + failed = 0 + for path in files: + try: + data = json.loads(path.read_text()) + result = engine.validate(data) + + if result.valid: + print(f"✅ {path.name}") + else: + print(f"❌ {path.name}") + for error in result.errors: + print(f" [{error.code}] {error.path}: {error.message}") + failed += 1 + + except json.JSONDecodeError as e: + print(f"❌ {path.name}: Invalid JSON - {e}") + failed += 1 + + print(f"\n{'='*50}") + print(f"Total: {len(files)} | Passed: {len(files) - failed} | Failed: {failed}") + + return failed + + +if __name__ == "__main__": + directory = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("data/passports") + sys.exit(validate_directory(directory)) +``` + +______________________________________________________________________ + +## Data Migration + +### Legacy System Migration + +Convert and validate legacy product data to DPP format. + +```python +from datetime import datetime, timezone +from dppvalidator import ValidationEngine +from dppvalidator.models import DigitalProductPassport, CredentialIssuer + + +def migrate_legacy_product(legacy: dict, issuer_id: str) -> dict: + """Convert legacy product data to DPP format.""" + return { + "type": ["DigitalProductPassport", "VerifiableCredential"], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ], + "id": f"https://company.com/dpp/{legacy['sku']}", + "issuer": { + "id": issuer_id, + "name": legacy.get("brand", "Unknown"), + }, + "validFrom": datetime.now(timezone.utc).isoformat(), + "validUntil": "2035-01-01T00:00:00Z", + "credentialSubject": { + "type": ["Product"], + "id": f"https://company.com/products/{legacy['sku']}", + "name": legacy["name"], + "description": legacy.get("description", ""), + "granularityLevel": "model", + }, + } + + +def migrate_and_validate( + legacy_products: list[dict], + issuer_id: str, +) -> dict: + """Migrate legacy products and validate the results.""" + engine = ValidationEngine() + + results = { + "migrated": [], + "failed": [], + } + + for legacy in legacy_products: + dpp = migrate_legacy_product(legacy, issuer_id) + result = engine.validate(dpp) + + if result.valid: + results["migrated"].append( + { + "sku": legacy["sku"], + "dpp_id": dpp["id"], + "status": "success", + } + ) + else: + results["failed"].append( + { + "sku": legacy["sku"], + "errors": [e.message for e in result.errors], + } + ) + + return results + + +# Example usage +legacy_data = [ + {"sku": "SHIRT-001", "name": "Cotton T-Shirt", "brand": "EcoBrand"}, + {"sku": "DRESS-002", "name": "Summer Dress", "brand": "EcoBrand"}, +] + +migration_results = migrate_and_validate( + legacy_data, + issuer_id="did:web:ecobrand.com", +) +print(f"Migrated: {len(migration_results['migrated'])}") +print(f"Failed: {len(migration_results['failed'])}") +``` + +______________________________________________________________________ + +## Supply Chain Traceability + +### Deep Validation of Supply Chain + +Validate entire supply chains by following linked documents. + +```python +import asyncio +from dppvalidator import ValidationEngine + + +async def validate_supply_chain(root_dpp: dict) -> dict: + """Validate a product's entire supply chain.""" + engine = ValidationEngine( + validate_jsonld=True, + verify_signatures=True, + ) + + result = await engine.validate_deep( + root_dpp, + max_depth=5, + follow_links=[ + "credentialSubject.traceabilityEvents", + "credentialSubject.conformityClaim", + "credentialSubject.materialsProvenance", + ], + timeout=60.0, + ) + + return { + "root_valid": result.root_result.valid, + "total_documents": result.total_documents, + "all_valid": result.valid, + "max_depth_reached": result.max_depth_reached, + "cycle_detected": result.cycle_detected, + "failed_urls": list(result.failed_urls.keys()), + "validation_time_ms": result.elapsed_time_ms, + } + + +# Run the validation +supply_chain_report = asyncio.run(validate_supply_chain(garment_dpp)) +print(f"Supply chain documents: {supply_chain_report['total_documents']}") +print(f"All valid: {supply_chain_report['all_valid']}") +``` + +______________________________________________________________________ + +## Consumer Applications + +### Mobile App DPP Scanner + +Parse and display DPP data in a consumer app. + +```python +from dppvalidator import ValidationEngine + +engine = ValidationEngine() + + +def scan_dpp_qrcode(qr_data: str | dict) -> dict: + """Process scanned DPP QR code data for mobile app display.""" + result = engine.validate(qr_data) + + if not result.valid: + return { + "success": False, + "error": "Invalid product passport", + } + + passport = result.passport + subject = passport.credential_subject if passport else None + + return { + "success": True, + "product": { + "name": getattr(subject, "name", "Unknown"), + "description": getattr(subject, "description", ""), + }, + "issuer": { + "name": passport.issuer.name if passport else "Unknown", + "verified": result.signature_valid or False, + }, + "sustainability": extract_sustainability_info(subject), + "valid_until": ( + passport.valid_until.isoformat() + if passport and passport.valid_until + else None + ), + } + + +def extract_sustainability_info(subject) -> dict: + """Extract sustainability information for display.""" + if not subject: + return {} + + scorecard = getattr(subject, "circularity_scorecard", None) + materials = getattr(subject, "materials_provenance", []) or [] + + return { + "recycled_content": ( + f"{scorecard.recycled_content * 100:.0f}%" + if scorecard and scorecard.recycled_content + else "N/A" + ), + "recyclable": ( + f"{scorecard.recyclable_content * 100:.0f}%" + if scorecard and scorecard.recyclable_content + else "N/A" + ), + "materials": [ + { + "name": m.name, + "percentage": f"{(m.mass_fraction or 0) * 100:.0f}%", + "recycled": m.recycled or False, + } + for m in materials + ], + } +``` + +______________________________________________________________________ + +## Recycling & Waste Management + +### Material Identification for Sorting + +Help recycling facilities identify materials from DPP data. + +```python +from dppvalidator import ValidationEngine + +engine = ValidationEngine() + +RECYCLABLE_MATERIALS = { + "cotton", + "wool", + "silk", + "linen", + "hemp", + "polyester", + "nylon", + "pet", +} + +HAZARDOUS_INDICATORS = {"lead", "mercury", "cadmium", "pvc"} + + +def analyze_for_recycling(dpp_data: dict) -> dict: + """Analyze DPP for recycling facility sorting.""" + result = engine.validate(dpp_data) + + if not result.valid: + return {"error": "Invalid passport", "sortable": False} + + materials = dpp_data.get("credentialSubject", {}).get("materialsProvenance", []) + + analysis = { + "sortable": True, + "materials": [], + "recyclable": True, + "hazardous": False, + "recommended_stream": "textile", + } + + for mat in materials: + name = mat.get("name", "").lower() + fraction = mat.get("massFraction", 0) + hazardous = mat.get("hazardous", False) + + analysis["materials"].append( + { + "name": mat.get("name"), + "fraction": f"{fraction * 100:.1f}%", + "recyclable": any(r in name for r in RECYCLABLE_MATERIALS), + "hazardous": hazardous, + } + ) + + if hazardous or any(h in name for h in HAZARDOUS_INDICATORS): + analysis["hazardous"] = True + analysis["recommended_stream"] = "special_handling" + + return analysis +``` + +______________________________________________________________________ + +## Customs & Border Control + +### Import Compliance Verification + +Verify DPP compliance for imports. + +```python +from dppvalidator import ValidationEngine +from dppvalidator.vocabularies import is_valid_hs_code, is_textile_hs_code + +engine = ValidationEngine( + strict_mode=True, + verify_signatures=True, +) + + +def verify_import_compliance(dpp_data: dict, declared_hs_code: str) -> dict: + """Verify DPP compliance for customs import.""" + result = engine.validate(dpp_data) + + checks = { + "dpp_valid": result.valid, + "signature_verified": result.signature_valid or False, + "issuer": result.issuer_did, + "hs_code_valid": is_valid_hs_code(declared_hs_code), + "is_textile": is_textile_hs_code(declared_hs_code), + "requires_dpp": False, + "compliance_status": "pending", + } + + # Textiles require DPP from 2027 + if checks["is_textile"]: + checks["requires_dpp"] = True + checks["compliance_status"] = "compliant" if result.valid else "non_compliant" + else: + checks["compliance_status"] = "exempt" + + return checks +``` + +______________________________________________________________________ + +## Resale & Recommerce + +### Product Authentication + +Authenticate products for resale platforms. + +```python +from dppvalidator import ValidationEngine + +engine = ValidationEngine(verify_signatures=True) + + +def authenticate_for_resale(dpp_data: dict) -> dict: + """Authenticate product for resale platform.""" + result = engine.validate(dpp_data) + + authentication = { + "authentic": False, + "confidence": "low", + "details": {}, + } + + if not result.valid: + authentication["reason"] = "Invalid passport structure" + return authentication + + # Check signature + if result.signature_valid: + authentication["authentic"] = True + authentication["confidence"] = "high" + authentication["details"]["signature"] = { + "verified": True, + "issuer": result.issuer_did, + "method": result.verification_method, + } + else: + authentication["confidence"] = "medium" + authentication["details"]["signature"] = {"verified": False} + + # Extract provenance for display + passport = result.passport + if passport: + authentication["details"]["product"] = { + "id": str(passport.id), + "issuer": passport.issuer.name, + "valid_from": ( + passport.valid_from.isoformat() if passport.valid_from else None + ), + } + + return authentication +``` + +______________________________________________________________________ + +## Next Steps + +- [Validation Guide](validation.md) — Detailed validation options +- [Plugin Development](plugins.md) — Create custom validators +- [API Reference](../reference/api/validators.md) — Full API documentation diff --git a/docs/guides/validation.md b/docs/guides/validation.md index 5bf1b54..54922f8 100644 --- a/docs/guides/validation.md +++ b/docs/guides/validation.md @@ -1,46 +1,118 @@ # Validation Guide -dppvalidator uses a three-layer validation approach to ensure Digital Product Passports are valid and meaningful. +dppvalidator uses a seven-layer validation architecture to ensure Digital Product +Passports are valid, semantically meaningful, and cryptographically verifiable. -## Three-Layer Validation +## Quick Start -### Layer 1: Schema Validation +```python +from dppvalidator import ValidationEngine + +# Create engine with auto-detection (default) +engine = ValidationEngine() + +# Validate a DPP +result = engine.validate(dpp_data) + +if result.valid: + print("Passport is valid!") +else: + for error in result.errors: + print(f"[{error.code}] {error.path}: {error.message}") +``` + +## Schema Auto-Detection -The first layer validates the JSON structure against the UNTP DPP JSON Schema. +The engine automatically detects the schema version from your document: ```python -from dppvalidator.validators import ValidationEngine +# Auto-detection — engine reads $schema / @context / type from the payload. +engine = ValidationEngine() -engine = ValidationEngine(layers=["schema"]) -result = engine.validate(data) +# Pin v0.6.1 explicitly. A v0.7.0 payload through this engine fails fast +# with VER001 (version mismatch). +engine = ValidationEngine(schema_version="0.6.1") + +# Pin v0.7.0 explicitly. +engine = ValidationEngine(schema_version="0.7.0") ``` -**What it checks:** +Detection checks (in order): + +1. `$schema` URL pattern +1. `@context` URLs (`https://test.uncefact.org/vocabulary/untp/dpp/0.6.x/` + for v0.6.x; `https://vocabulary.uncefact.org/untp/0.7.0/context/` for + v0.7.0) +1. `type` array presence +1. Fallback to `dppvalidator.schemas.registry.DEFAULT_SCHEMA_VERSION` + +The full version-handling story is in +[UNTP DPP versions](../concepts/untp-versions.md). + +### Validating v0.6.x payloads against v0.7.0 + +If you have v0.6.x payloads but want to validate them against the +v0.7.0 schema (because your downstream consumers have moved to +v0.7.0), use the **compat shim** via `--upgrade-from`: + +```bash +# Run the v0.6 → v0.7 shim, then validate against v0.7.0. +dppvalidator validate passport.json \ + --upgrade-from 0.6.1 \ + --schema-version 0.7.0 +``` + +```python +from dppvalidator.compat import upgrade +from dppvalidator import ValidationEngine + +upgraded, warnings = upgrade(payload_v06) +result = ValidationEngine(schema_version="0.7.0").validate(upgraded) +``` + +The shim emits structured warnings (`UPG001`–`UPG004`) for fields it +can't fully translate. See the +[migration guide](migration-0-6-to-0-7.md) for the warning codes, +field rename table, and known limitations. + +## Validation Layers + +### Layer 1: Schema Validation -- Required fields are present -- Field types are correct -- String formats (URIs, dates) -- Enum values +Validates JSON structure against the UNTP DPP JSON Schema. + +```python +engine = ValidationEngine(layers=["schema"]) +result = engine.validate(data) +``` ### Layer 2: Model Validation -The second layer validates data against Pydantic models, providing stricter type checking and coercion. +Validates data against Pydantic models with stricter type checking. ```python engine = ValidationEngine(layers=["model"]) result = engine.validate(data) ``` +### Layer 3: JSON-LD Semantic Validation + +Validates JSON-LD semantics using PyLD expansion algorithm. + +```python +engine = ValidationEngine(validate_jsonld=True) +result = engine.validate(data) +``` + **What it checks:** -- Pydantic model constraints -- URL validation -- Date parsing -- Custom validators +- `@context` is present and valid +- All terms resolve during expansion +- Custom terms use proper namespacing -### Layer 3: Semantic Validation +### Layer 4: Business Logic Validation -The third layer checks business rules and cross-field relationships. +Validates business rules and vocabulary references. ```python engine = ValidationEngine(layers=["semantic"]) @@ -49,21 +121,47 @@ result = engine.validate(data) **What it checks:** -- Vocabulary values (country codes, unit codes) -- Date relationships (valid_from < valid_until) -- Identifier formats -- Cross-reference consistency +- Vocabulary values (ISO country codes, UN/CEFACT unit codes) +- Material codes (UNECE Rec 46) +- GTIN checksums (GS1 standard) +- Date relationships -## Using All Layers +### Layer 5: Signature Verification -By default, all layers are enabled: +Verifies Verifiable Credential signatures. ```python -engine = ValidationEngine() # All layers enabled +engine = ValidationEngine(verify_signatures=True) result = engine.validate(data) -# Or explicitly: -engine = ValidationEngine(layers=["schema", "model", "semantic"]) +if result.signature_valid: + print(f"Signed by: {result.issuer_did}") + print(f"Method: {result.verification_method}") +``` + +**Supported:** + +- DID methods: `did:web`, `did:key` +- Algorithms: Ed25519, ES256, ES384 +- Proof types: Ed25519Signature2020, DataIntegrityProof, JsonWebSignature2020 + +## Deep Validation + +Validate entire supply chains by crawling linked documents: + +```python +result = await engine.validate_deep( + dpp_data, + max_depth=3, + follow_links=["credentialSubject.traceabilityEvents"], + timeout=30.0, + auth_header={"Authorization": "Bearer token..."}, +) + +print(f"Total documents: {result.total_documents}") +print(f"Max depth reached: {result.max_depth_reached}") +print(f"Cycle detected: {result.cycle_detected}") +print(f"All valid: {result.valid}") ``` ## Validation Results @@ -83,29 +181,71 @@ for error in result.errors: for warning in result.warnings: print(f"Warning: {warning.message}") -# Get validation time +# Signature status (when verify_signatures=True) +if result.signature_valid is not None: + print(f"Signature valid: {result.signature_valid}") + print(f"Issuer: {result.issuer_did}") + +# Validation time print(f"Validated in {result.validation_time_ms:.2f}ms") ``` ## Error Codes -| Code | Layer | Description | -| ------ | -------- | ------------------------- | -| SCH001 | Schema | Required field missing | -| SCH002 | Schema | Invalid type | -| MOD001 | Model | Pydantic validation error | -| SEM001 | Semantic | Invalid vocabulary value | -| SEM002 | Semantic | Invalid date relationship | +| Prefix | Layer | Description | +| ------ | ---------- | ------------------------------ | +| SCH | Schema | JSON Schema validation errors | +| MOD | Model | Pydantic validation errors | +| JLD | JSON-LD | Context/term resolution errors | +| SEM | Semantic | Business rule violations | +| VOC | Vocabulary | Code list validation errors | +| SIG | Signature | Credential verification errors | -## Strict Mode +## Code List Validation -Enable strict mode for additional schema checks: +Validate identifiers and codes: ```python -engine = ValidationEngine(strict_mode=True) +from dppvalidator.vocabularies import ( + validate_gtin, + is_valid_material_code, + is_valid_hs_code, + is_valid_gs1_digital_link, +) + +# GTIN checksum validation +validate_gtin("5901234123457") # True + +# Material codes (UNECE Rec 46) +is_valid_material_code("COTTON") # True +is_valid_material_code("RECYCLED_POLYESTER") # True + +# GS1 Digital Link +is_valid_gs1_digital_link("https://id.gs1.org/01/5901234123457") # True +``` + +## Full Validation Example + +```python +from dppvalidator import ValidationEngine + +# Enable all validation features +engine = ValidationEngine( + validate_jsonld=True, + verify_signatures=True, + strict_mode=True, +) + +result = engine.validate(dpp_data) + +print(f"Valid: {result.valid}") +print(f"Errors: {len(result.errors)}") +print(f"Warnings: {len(result.warnings)}") +print(f"Signature valid: {result.signature_valid}") ``` ## Next Steps - [JSON-LD Export](jsonld.md) — Export validated passports +- [Validation Layers](../concepts/validation-layers.md) — Architecture deep dive - [API Reference](../reference/api/validators.md) — Full ValidationEngine API diff --git a/docs/index.md b/docs/index.md index e79999d..ea3f080 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,10 +1,15 @@ +______________________________________________________________________ + +## Validate EU Digital Product Passports with Python - 80k ops/sec. + # dppvalidator -[![PyPI version](https://img.shields.io/pypi/v/dppvalidator.svg)](https://pypi.org/project/dppvalidator/) -[![Python versions](https://img.shields.io/pypi/pyversions/dppvalidator.svg)](https://pypi.org/project/dppvalidator/) -[![License](https://img.shields.io/github/license/artiso-ai/dppvalidator.svg)](https://github.com/artiso-ai/dppvalidator/blob/main/LICENSE) -[![CI](https://github.com/artiso-ai/dppvalidator/actions/workflows/ci.yml/badge.svg)](https://github.com/artiso-ai/dppvalidator/actions/workflows/ci.yml) -[![Coverage](https://img.shields.io/codecov/c/github/artiso-ai/dppvalidator)](https://codecov.io/gh/artiso-ai/dppvalidator) +[![PyPI version](https://img.shields.io/pypi/v/dppvalidator?style=flat&logo=pypi&logoColor=white)](https://pypi.org/project/dppvalidator/) +[![Python versions](https://img.shields.io/pypi/pyversions/dppvalidator?style=flat&logo=python&logoColor=white)](https://pypi.org/project/dppvalidator/) +[![Downloads](https://img.shields.io/pypi/dm/dppvalidator?style=flat&logo=pypi&logoColor=white)](https://pypi.org/project/dppvalidator/) +[![License](https://img.shields.io/github/license/artiso-ai/dppvalidator?style=flat)](https://github.com/artiso-ai/dppvalidator/blob/main/LICENSE) +[![CI](https://github.com/artiso-ai/dppvalidator/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/artiso-ai/dppvalidator/actions/workflows/ci.yml) +![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-blue?style=flat) **A Python library for validating Digital Product Passports (DPP) according to EU ESPR regulations and UNTP standards.** @@ -12,10 +17,19 @@ ______________________________________________________________________ ## Features -- :octicons-check-circle-16:{ .text-green } **Three-Layer Validation** — Schema, Model, and Semantic validation -- :octicons-package-16: **UNTP DPP Schema Support** — Built-in support for UNTP DPP 0.6.1 +- :octicons-check-circle-16:{ .text-green } **Seven-Layer Validation** — Schema, Model, Semantic, JSON-LD, Vocabulary, Plugin, and Signature validation +- :octicons-package-16: **UNTP DPP Schema Support** — Both **0.6.x** + (default) and **0.7.0** wire formats; auto-detected from + `@context` / `$schema` URLs. See + [UNTP DPP versions](concepts/untp-versions.md). +- :octicons-arrow-switch-16: **Compat shim 0.6 → 0.7** — + `dppvalidator migrate` upgrades v0.6.x payloads to v0.7.0 shape + with structured warnings. See the + [migration guide](guides/migration-0-6-to-0-7.md). - :octicons-rocket-16: **High Performance** — 80,000+ validations per second -- :octicons-plug-16: **Plugin System** — Extensible with custom validators and exporters +- :octicons-plug-16: **Plugin System** — Extensible with custom + validators and exporters; version-aware rules with + `applies_to_versions` opt-in. - :octicons-file-code-16: **JSON-LD Export** — W3C Verifiable Credentials compliant output - :octicons-terminal-16: **CLI & API** — Use from command line or programmatically @@ -61,15 +75,24 @@ else: ### Command Line -``` -# Validate a DPP JSON file +```bash +# Validate a DPP JSON file (auto-detects version from the payload) dppvalidator validate passport.json +# Pin a specific UNTP version +dppvalidator validate passport.json --schema-version 0.7.0 + +# Upgrade a v0.6.x payload to v0.7.0 shape +dppvalidator migrate passport.json -o passport-v07.json + +# Validate-after-upgrade in one shot +dppvalidator validate passport.json --upgrade-from 0.6.1 --schema-version 0.7.0 + # Export to JSON-LD dppvalidator export passport.json --format jsonld -# Show schema information -dppvalidator schema --version 0.6.1 +# List every registered UNTP version +dppvalidator schema list ``` ## Documentation @@ -78,6 +101,10 @@ dppvalidator schema --version 0.6.1 - [Quick Start Tutorial](getting-started/quickstart.md) — Get started in 5 minutes - [CLI Usage](guides/cli-usage.md) — Command line reference - [Validation Guide](guides/validation.md) — Understanding validation layers +- [UNTP DPP versions](concepts/untp-versions.md) — Version handling, + detection, defaults, adding a new version +- [Migration guide: 0.6.x → 0.7.0](guides/migration-0-6-to-0-7.md) — + The compat shim, field rename table, warning codes - [API Reference](reference/api/validators.md) — Full API documentation ## For AI Assistants diff --git a/docs/llms-ctx.txt b/docs/llms-ctx.txt index ea2e3c3..0cf0b75 100644 --- a/docs/llms-ctx.txt +++ b/docs/llms-ctx.txt @@ -10,29 +10,40 @@ dppvalidator is the open-source compliance engine for EU Digital Product Passpor - Python 3.10+ - Pydantic v2 for validation models -- Optional: jsonschema, httpx, rich (CLI) +- Included: httpx, jsonschema, pyld, cryptography, PyJWT +- Optional: rich (CLI formatting via `[cli]` extra) ## Installation ```bash -pip install dppvalidator # Core only -pip install dppvalidator[all] # With all optional dependencies +# Using uv (recommended) +uv add dppvalidator # Full functionality +uv add "dppvalidator[cli]" # With rich CLI formatting + +# Or using pip +pip install dppvalidator +pip install "dppvalidator[cli]" ``` ## Architecture -### Three-Layer Validation +### Seven-Layer Validation +0. **Schema Detection**: Auto-detect DPP schema version from $schema/@context 1. **Schema Layer (SCH001-SCH099)**: JSON Schema Draft 2020-12 validation 2. **Model Layer (MOD001-MOD099)**: Pydantic v2 type validation and coercion -3. **Semantic Layer (SEM001-SEM099)**: Business rules, ISO codes, date logic +3. **Semantic Layer (SEM001-SEM099)**: Business rules, ISO codes, GTIN checksums +4. **JSON-LD Layer (JLD001-JLD099)**: PyLD expansion, context resolution +5. **Vocabulary Layer (VOC001-VOC099)**: External code lists, ontology validation +6. **Plugin Layer**: Custom validator plugins via entry points +7. **Signature Layer (SIG001-SIG099)**: VC signature verification, DID resolution ### Performance -- Schema: ~5μs (200k ops/sec) -- Model: ~8μs (125k ops/sec) -- Semantic: ~3μs (333k ops/sec) -- All layers: ~13μs (80k ops/sec) +- Model (minimal): 0.011ms (87k ops/sec) +- Model (full): 0.016ms (62k ops/sec) +- Semantic: 0.005ms (194k ops/sec) +- Full (Model+Sem): 0.017ms (58k ops/sec) ## Core API @@ -63,8 +74,7 @@ from dppvalidator.models import ( DigitalProductPassport, CredentialIssuer, Product, - ProductBatch, - Identifier, + IdentifierScheme, Link, ) @@ -87,17 +97,26 @@ jsonld = exporter.export(passport) # W3C Verifiable Credential format ```python from dppvalidator.plugins import PluginRegistry +from dppvalidator.validators import ValidationEngine -registry = PluginRegistry() - -@registry.register_validator("custom") +# Define a validator implementing the SemanticRule protocol class CustomValidator: - def validate(self, data: dict) -> list: - errors = [] + rule_id = "CUSTOM001" + description = "Custom validation rule" + severity = "error" + + def check(self, passport): + """Return list of (json_path, error_message) tuples.""" + violations = [] # Custom validation logic - return errors + return violations + +# Manual registration +registry = PluginRegistry(auto_discover=False) +registry.register_validator("custom", CustomValidator) -engine = ValidationEngine(plugins=registry) +# Or use entry points for automatic discovery +engine = ValidationEngine(load_plugins=True) ``` ## CLI Usage @@ -115,6 +134,7 @@ dppvalidator schema --version 0.6.1 src/dppvalidator/ ├── models/ # Pydantic models for DPP entities ├── validators/ # Validation engine and layers +├── verifier/ # Signature and credential verification ├── exporters/ # JSON-LD and other export formats ├── schemas/ # JSON Schema loading and caching ├── vocabularies/ # Controlled vocabulary loading diff --git a/docs/llms.txt b/docs/llms.txt index 575ce11..f16f9c3 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -6,7 +6,7 @@ dppvalidator is the open-source compliance engine for EU Digital Product Passpor ## Core Features -- Three-layer validation: schema, model, and semantic +- Seven-layer validation: schema, model, semantic, JSON-LD, vocabulary, plugin, and signature - Built-in UNTP DPP 0.6.1 schema support - JSON-LD export for W3C Verifiable Credentials - Plugin system for custom validators diff --git a/docs/overrides/main.html b/docs/overrides/main.html new file mode 100644 index 0000000..d3cf407 --- /dev/null +++ b/docs/overrides/main.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} + +{% block extrahead %} + +{% endblock %} diff --git a/docs/plugins.md b/docs/plugins.md index 1331b60..945ecae 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -261,7 +261,7 @@ See the complete example in `examples/dppvalidator_example_plugin/` for: ### Plugin Not Discovered -1. Ensure the package is installed (`uv pip install -e .` or `pip install -e .`) +1. Ensure the package is installed (using `uv pip install -e .` or `pip install -e .`) 1. Check entry point syntax in `pyproject.toml` 1. Verify the module path is correct 1. Check for import errors in your plugin code diff --git a/docs/reference/api/plugins.md b/docs/reference/api/plugins.md index 16cd840..748949c 100644 --- a/docs/reference/api/plugins.md +++ b/docs/reference/api/plugins.md @@ -29,13 +29,14 @@ my_exporter = "my_package:MyExporter" ```python from dppvalidator.plugins import PluginRegistry +from dppvalidator.plugins.registry import get_default_registry -# Auto-discover plugins -registry = PluginRegistry() +# Use singleton registry (recommended) +registry = get_default_registry() # List available plugins -print("Validators:", registry.list_validators()) -print("Exporters:", registry.list_exporters()) +print("Validators:", registry.validator_names) +print("Exporters:", registry.exporter_names) # Get a specific plugin validator = registry.get_validator("my_validator") @@ -46,6 +47,22 @@ registry = PluginRegistry(auto_discover=False) registry.register_validator("custom", MyCustomValidator) ``` +## Strict Mode + +For CI/CD pipelines, use strict mode to raise exceptions on plugin failures: + +```python +from dppvalidator.plugins.registry import get_default_registry, PluginError + +registry = get_default_registry() + +try: + errors = registry.run_all_validators(passport, strict=True) +except PluginError as e: + print(f"Plugin failed: {e}") + sys.exit(1) +``` + ## Creating Plugins See the [Plugin Development Guide](../../guides/plugins.md) for detailed instructions. diff --git a/docs/reference/api/validators.md b/docs/reference/api/validators.md index 2321610..caeaa2d 100644 --- a/docs/reference/api/validators.md +++ b/docs/reference/api/validators.md @@ -4,7 +4,7 @@ Validation engine and result types for DPP validation. ## ValidationEngine -The main validation engine supporting three-layer validation. +The main validation engine supporting seven-layer validation. ::: dppvalidator.validators.ValidationEngine options: @@ -59,11 +59,44 @@ print(f"Validation time: {result.validation_time_ms:.2f}ms") ## Validation Layers -| Layer | Description | -| -------- | ------------------------- | -| schema | JSON Schema validation | -| model | Pydantic model validation | -| semantic | Business rule validation | +The engine supports seven validation layers: + +| Layer | Description | +| ---------- | ---------------------------------------- | +| schema | JSON Schema validation (Draft 2020-12) | +| model | Pydantic model validation | +| semantic | Business rule validation | +| jsonld | JSON-LD context expansion and validation | +| vocabulary | External vocabulary validation | +| plugin | Custom plugin validators | +| signature | Verifiable Credential signatures | + +## Performance Features + +### Module-Level Schema Caching + +Schemas are cached at the module level for better performance: + +```python +from dppvalidator.schemas.loader import clear_schema_cache + +# Clear cache if needed (e.g., after updating schema files) +clear_schema_cache() +``` + +### Plugin Registry Singleton + +The plugin registry uses a singleton pattern: + +```python +from dppvalidator.plugins.registry import get_default_registry, reset_default_registry + +# Get the shared registry +registry = get_default_registry() + +# Reset for testing +reset_default_registry() +``` ## Error Codes @@ -72,5 +105,9 @@ print(f"Validation time: {result.validation_time_ms:.2f}ms") | SCH001 | schema | Required field missing | | SCH002 | schema | Invalid type | | MOD001 | model | Model validation error | +| JLD001 | jsonld | Invalid context | | SEM001 | semantic | Invalid vocabulary value | | SEM002 | semantic | Invalid date range | +| SIG001 | crypto | Invalid signature | + +> **Note:** This table shows common examples. See [Error Reference](../errors/) for the complete list of 70+ error codes. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 0502a80..5f99768 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -21,27 +21,34 @@ dppvalidator [OPTIONS] COMMAND [ARGS]... ### validate -Validate a Digital Product Passport. +Validate one or more Digital Product Passports. ``` -dppvalidator validate INPUT [OPTIONS] +dppvalidator validate INPUT... [OPTIONS] ``` **Arguments:** -| Argument | Description | -| -------- | ---------------------------------- | -| INPUT | Path to JSON file or `-` for stdin | +| Argument | Description | +| -------- | ---------------------------------------------------------- | +| INPUT... | Path(s) to JSON file(s), glob pattern(s), or `-` for stdin | + +Supports multiple files and glob patterns for batch validation. **Options:** -| Option | Default | Description | -| ------------------ | ------- | --------------------------------- | -| `-s, --strict` | false | Enable strict validation | -| `-f, --format` | text | Output format (text, json, table) | -| `--schema-version` | 0.6.1 | Schema version | -| `--fail-fast` | false | Stop on first error | -| `--max-errors` | 100 | Maximum errors to report | + + +| Option | Default | Description | +| ------------------ | ------- | ---------------------------------------------------------------------------- | +| `-s, --strict` | false | Enable strict validation | +| `-f, --format` | text | Output format (text, json, table) | +| `--schema-version` | 0.6.1 | Schema version (one of `0.6.0`, `0.6.1`, `0.7.0`) | +| `--upgrade-from` | none | Run the v0.6 → v0.7 compat shim before validating; accepts `0.6.0` / `0.6.1` | +| `--fail-fast` | false | Stop on first error | +| `--max-errors` | 100 | Maximum errors to report | + + ### export @@ -62,7 +69,7 @@ dppvalidator export INPUT [OPTIONS] Display schema information. -``` +```text dppvalidator schema [OPTIONS] ``` @@ -73,26 +80,86 @@ dppvalidator schema [OPTIONS] | `--version` | Schema version to display | | `--list` | List available versions | +### migrate + +Upgrade a v0.6.x DPP payload to v0.7.0 shape via the compat shim. + +```text +dppvalidator migrate INPUT [OPTIONS] +``` + +**Arguments:** + + + +| Argument | Description | +| -------- | ------------------------------------------ | +| INPUT | Path to v0.6.x JSON file, or `-` for stdin | + + + +**Options:** + + + +| Option | Default | Description | +| ------------------- | ------- | --------------------------------------------------------------------------------------------------------------------- | +| `-o, --output` | stdout | Output file path | +| `--in-place` | false | Write the upgraded JSON back to the input path. Mutually exclusive with `-o`. | +| `--accept-warnings` | false | Write the upgraded JSON even when the shim emits warning- or error-severity events. INFO-severity events never block. | +| `--from` | 0.6.x | Source UNTP version family. Pass `0.6.0` / `0.6.1` to pin. | + + + +A sidecar `.warnings.json` is always written alongside the +upgraded payload when blocking warnings fire (whether the main +output is written or not), so callers always have a +machine-readable record of every transformation. + ## Exit Codes -| Code | Meaning | -| ---- | ----------------- | -| 0 | Success / Valid | -| 1 | Validation failed | -| 2 | Error | + + +| Code | Meaning | +| ---- | ----------------------------------------------------------------------- | +| 0 | Success / Valid (validate); upgrade with no blocking warnings (migrate) | +| 1 | Validation failed (validate); blocking warnings (migrate) | +| 2 | Error (file not found, invalid JSON, unknown version) | + + ## Examples -``` -# Validate a file +```bash +# Validate a single file (auto-detects the UNTP version) dppvalidator validate passport.json -# Validate with JSON output -dppvalidator validate passport.json -f json +# Validate multiple files +dppvalidator validate passport1.json passport2.json passport3.json + +# Validate with glob pattern +dppvalidator validate "data/passports/*.json" + +# Batch validate with strict mode and JSON output +dppvalidator validate "data/*.json" --strict --format json + +# Pin to v0.7.0 (fail-fast on payloads that declare a different version) +dppvalidator validate passport.json --schema-version 0.7.0 + +# Run the v0.6 → v0.7 shim, then validate as v0.7.0 +dppvalidator validate passport-v06.json \ + --upgrade-from 0.6.1 \ + --schema-version 0.7.0 # Export to JSON-LD dppvalidator export passport.json -f jsonld -o output.jsonld -# List schema versions +# List schema versions (currently 0.6.0, 0.6.1, 0.7.0) dppvalidator schema --list + +# Upgrade a v0.6.x payload to v0.7.0 shape +dppvalidator migrate passport.json -o passport-v07.json + +# Upgrade in place, accepting warnings +dppvalidator migrate passport.json --in-place --accept-warnings ``` diff --git a/docs/robots.txt b/docs/robots.txt new file mode 100644 index 0000000..fe8ea0c --- /dev/null +++ b/docs/robots.txt @@ -0,0 +1,45 @@ +# Search engines +User-agent: Googlebot +Allow: / + +User-agent: Bingbot +Allow: / + +User-agent: DuckDuckBot +Allow: / + +# OpenAI / ChatGPT +User-agent: GPTBot +Allow: / + +User-agent: ChatGPT-User +Allow: / + +# Anthropic / Claude +User-agent: anthropic-ai +Allow: / + +User-agent: Claude-Web +Allow: / + +# Google AI (Gemini, Bard) +User-agent: Google-Extended +Allow: / + +# Perplexity +User-agent: PerplexityBot +Allow: / + +# Cohere +User-agent: cohere-ai +Allow: / + +# Meta +User-agent: FacebookBot +Allow: / + +# All other agents +User-agent: * +Allow: / + +Sitemap: https://artiso-ai.github.io/dppvalidator/sitemap.xml diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..a2d3427 --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,22 @@ +/* Custom styles for dppvalidator documentation */ + +/* Make the header logo larger */ +.md-header__button.md-logo img { + height: 48px; + width: auto; +} + +/* Adjust logo in sidebar/nav if present */ +.md-nav__logo img { + height: 48px; + width: auto; +} + +/* Match footer color to header (primary color) */ +.md-footer { + background-color: var(--md-primary-fg-color); +} + +.md-footer-meta { + background-color: var(--md-primary-fg-color--dark); +} diff --git a/examples/dppvalidator_example_plugin/README.md b/examples/dppvalidator_example_plugin/README.md index 586bf6f..24af07c 100644 --- a/examples/dppvalidator_example_plugin/README.md +++ b/examples/dppvalidator_example_plugin/README.md @@ -28,10 +28,15 @@ pip install -e . ### Custom Validators -This plugin provides two example validators: +This plugin provides three example validators: -- **BrandNameRule** (`SEM_BRAND`) - Validates that products have a brand name -- **MinMaterialsRule** (`SEM_MINMAT`) - Warns if products have fewer than 2 materials declared +- **BrandNameRule** (`SEM_BRAND`) - Validates that v0.6 products have a + brand name. Reads `passport.credential_subject.product.name`. +- **BrandNameRuleV07** (`SEM_BRAND_V07`) - v0.7 variant; reads + `passport.credential_subject.name` directly and accepts a + `brandOwner` `relatedParty` as an alternative attribution. +- **MinMaterialsRule** (`SEM_MINMAT`) - Warns if v0.6 products have + fewer than 2 materials declared. ### Custom Exporter @@ -79,6 +84,49 @@ See the source code in `src/` for examples of how to: 1. Create custom exporters 1. Register plugins via entry points +## Writing a version-aware rule + +UNTP DPP introduced a wire-shape change in v0.7.0: the +`ProductPassport` envelope is gone, so `credentialSubject` is now a +`Product` directly (no inner `.product` attribute). A rule written +against the v0.6 shape will silently no-op on v0.7 payloads — and +vice-versa. Phase 4 of the migration (`compat/upgrade_0_6_to_0_7.py`) +helps callers upgrade payloads, but plugins still need to declare +which shape they target. + +This plugin demonstrates the version-aware-rule pattern with two +sibling rules: + +- `BrandNameRule` (in `validators.py`) — v0.6 shape; reads + `passport.credential_subject.product.name`. +- `BrandNameRuleV07` (in `brand_name_v07.py`) — v0.7 shape; reads + `passport.credential_subject.name` directly and also accepts a + `relatedParty` with `role="brandOwner"` as a brand attribution. + +The v0.7 rule advertises an `applies_to_versions = ("0.7.0",)` class +attribute. The engine's per-version rule dispatch consults that +attribute to decide whether to run the rule for a given payload's +detected version. As an extra safety net, `BrandNameRuleV07.check()` +**ducks on attribute presence**: if a v0.6 passport ever flows +through (e.g. a caller bypassed dispatch), the rule no-ops cleanly +because `credential_subject.product` exists — it never raises. + +### Recipe + +To author a version-aware rule for a UNTP version `X.Y.Z`: + +1. Create `your_rule_X_Y.py` in your plugin package. +1. Import the version-specific model: + `from dppvalidator.models.vX_Y.envelope import DigitalProductPassport`. +1. Set `applies_to_versions = ("X.Y.Z",)` on the rule class. +1. In `check()`, validate the shape with `hasattr` / `getattr` on the + passport before reading version-specific attributes; return `[]` + when the shape doesn't match. This makes the rule co-exist + gracefully with rules for other versions in the same registry. +1. Register the rule in `pyproject.toml` under + `[project.entry-points."dppvalidator.validators"]` with a unique + name (e.g. `brand_name_v07`). + ## License MIT diff --git a/examples/dppvalidator_example_plugin/pyproject.toml b/examples/dppvalidator_example_plugin/pyproject.toml index 7240eb3..a193d8e 100644 --- a/examples/dppvalidator_example_plugin/pyproject.toml +++ b/examples/dppvalidator_example_plugin/pyproject.toml @@ -4,14 +4,12 @@ build-backend = "hatchling.build" [project] name = "dppvalidator-example-plugin" -version = "0.1.0" +version = "0.2.0" description = "Example plugin for dppvalidator demonstrating custom validators and exporters" readme = "README.md" requires-python = ">=3.10" license = "MIT" -authors = [ - { name = "Your Name", email = "your.email@example.com" } -] +authors = [{ name = "artiso-ai" }] keywords = ["dpp", "validation", "plugin", "dppvalidator"] classifiers = [ "Development Status :: 3 - Alpha", @@ -21,13 +19,13 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] -dependencies = [ - "dppvalidator>=0.1.0", -] +dependencies = ["dppvalidator>=0.3.0"] [project.entry-points."dppvalidator.validators"] brand_name = "dppvalidator_example_plugin.validators:BrandNameRule" +brand_name_v07 = "dppvalidator_example_plugin.brand_name_v07:BrandNameRuleV07" min_materials = "dppvalidator_example_plugin.validators:MinMaterialsRule" [project.entry-points."dppvalidator.exporters"] @@ -41,7 +39,4 @@ Documentation = "https://github.com/yourusername/dppvalidator-example-plugin#rea packages = ["src/dppvalidator_example_plugin"] [tool.hatch.build.targets.sdist] -include = [ - "/src", - "/README.md", -] +include = ["/src", "/README.md"] diff --git a/examples/dppvalidator_example_plugin/src/dppvalidator_example_plugin/__init__.py b/examples/dppvalidator_example_plugin/src/dppvalidator_example_plugin/__init__.py index 526dd9b..301433a 100644 --- a/examples/dppvalidator_example_plugin/src/dppvalidator_example_plugin/__init__.py +++ b/examples/dppvalidator_example_plugin/src/dppvalidator_example_plugin/__init__.py @@ -1,12 +1,21 @@ -"""Example plugin for dppvalidator demonstrating custom validators and exporters.""" +"""Example plugin for dppvalidator demonstrating custom validators and exporters. +Phase 6 of ``docs/plans/UNTP_0.7.0_MIGRATION.md`` adds +:class:`BrandNameRuleV07` — a v0.7-shape companion to the v0.6 +:class:`BrandNameRule` — so plugin authors see how to write a +version-aware rule that targets the new namespace without touching +the v0.6 surface. +""" + +from dppvalidator_example_plugin.brand_name_v07 import BrandNameRuleV07 from dppvalidator_example_plugin.exporters import CSVExporter from dppvalidator_example_plugin.validators import BrandNameRule, MinMaterialsRule -__version__ = "0.1.0" +__version__ = "0.2.0" __all__ = [ "BrandNameRule", + "BrandNameRuleV07", "MinMaterialsRule", "CSVExporter", ] diff --git a/examples/dppvalidator_example_plugin/src/dppvalidator_example_plugin/brand_name_v07.py b/examples/dppvalidator_example_plugin/src/dppvalidator_example_plugin/brand_name_v07.py new file mode 100644 index 0000000..e210273 --- /dev/null +++ b/examples/dppvalidator_example_plugin/src/dppvalidator_example_plugin/brand_name_v07.py @@ -0,0 +1,131 @@ +"""v0.7-shape variant of BrandNameRule for the example plugin. + +Phase 6 of ``docs/plans/UNTP_0.7.0_MIGRATION.md``: ships a sibling of +:class:`dppvalidator_example_plugin.validators.BrandNameRule` written +against the **v0.7 wire shape**, so plugin authors browsing the +example see how to author a version-aware rule that targets the new +namespace. + +Key differences from the v0.6 rule: + +- v0.7 ``credentialSubject`` *is* the Product directly — there is no + ``ProductPassport`` envelope, so the brand-name check reads + ``passport.credential_subject.name`` rather than + ``passport.credential_subject.product.name``. +- v0.7 introduces a ``relatedParty: list[PartyRole]`` field that + carries typed (role, party) pairs. A "brandOwner" entry on + ``relatedParty`` is the canonical way to attribute brand identity + in v0.7; this rule promotes that pattern by *upgrading* the + violation when neither a name nor a brandOwner party is present. + +The rule duck-types on the passport's module path so it can co-exist +with the v0.6 ``BrandNameRule`` in the same registry — the engine +runs both, and each silently no-ops when handed a passport from the +"wrong" version. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal + +if TYPE_CHECKING: + from dppvalidator.models.v0_7.envelope import DigitalProductPassport + + +class BrandNameRuleV07: + """SEM_BRAND_V07: v0.7 variant of the brand-name semantic check. + + Targets the v0.7 ``credentialSubject`` (now a ``Product`` directly). + Emits a *warning* when ``Product.name`` is empty AND no + ``relatedParty`` carries the ``brandOwner`` role — both of those + are reasonable ways to attribute brand identity in v0.7. + + Example: + >>> rule = BrandNameRuleV07() + >>> violations = rule.check(passport) # passport: v0.7 DPP + >>> for path, message in violations: + ... print(f"{path}: {message}") + """ + + rule_id: str = "SEM_BRAND_V07" + description: str = ( + "Products should attribute brand identity via Product.name or a " + "relatedParty with role 'brandOwner' (UNTP v0.7 shape)" + ) + severity: Literal["error", "warning", "info"] = "warning" + + # The set of UNTP versions this rule targets. The engine's per-version + # rule dispatch (``ALL_RULES_BY_VERSION``) reads this attribute to + # decide whether to run the rule for a given payload version. + applies_to_versions: tuple[str, ...] = ("0.7.0",) + + def check( + self, + passport: DigitalProductPassport, + ) -> list[tuple[str, str]]: + """Check that brand identity is attributable. + + Args: + passport: A v0.7 ``DigitalProductPassport`` instance. + + Returns: + List of ``(path, message)`` tuples — empty when the passport + attributes brand identity through any supported channel. + + Notes: + Ducks on attribute presence rather than ``isinstance``: v0.6 + passports lack ``credential_subject.related_party`` and + ``credential_subject.name`` lives one level deeper, so the + rule no-ops cleanly when given a v0.6 passport. + """ + violations: list[tuple[str, str]] = [] + + product = self._extract_v07_product(passport) + if product is None: + # Wrong version shape — silently skip. + return violations + + has_name = bool(getattr(product, "name", None)) + has_brand_owner = self._has_brand_owner_party(product) + + if not has_name and not has_brand_owner: + violations.append( + ( + "$.credentialSubject", + "Product should attribute brand identity via Product.name " + "or a relatedParty with role 'brandOwner'.", + ) + ) + + return violations + + @staticmethod + def _extract_v07_product(passport: Any) -> Any | None: + """Return the v0.7 Product, or ``None`` if the shape doesn't match. + + v0.7's credentialSubject *is* a Product (no envelope). v0.6 + wraps it: ``credentialSubject.product`` — the presence of an + outer ``.product`` attribute is the cleanest discriminator. + """ + cs = getattr(passport, "credential_subject", None) + if cs is None: + return None + # v0.6 wraps the Product under credential_subject.product; + # v0.7 has the Product directly. + if hasattr(cs, "product"): + return None + return cs + + @staticmethod + def _has_brand_owner_party(product: Any) -> bool: + """True when a ``relatedParty`` carries the ``brandOwner`` role.""" + related = getattr(product, "related_party", None) or [] + for entry in related: + role = getattr(entry, "role", None) + # ``role`` is an Enum on the model but its value is a string; + # the engine's ``use_enum_values=True`` config means we may + # see either form depending on how the passport was loaded. + value = getattr(role, "value", role) + if value == "brandOwner": + return True + return False diff --git a/examples/notebooks/01_quickstart.ipynb b/examples/notebooks/01_quickstart.ipynb index 5290b5d..2ae1e84 100644 --- a/examples/notebooks/01_quickstart.ipynb +++ b/examples/notebooks/01_quickstart.ipynb @@ -46,7 +46,7 @@ "metadata": {}, "outputs": [], "source": [ - "from dppvalidator.validators import ValidationEngine\n", + "from dppvalidator import ValidationEngine\n", "\n", "# Create a validation engine\n", "engine = ValidationEngine()\n", diff --git a/examples/notebooks/02_custom_rules.ipynb b/examples/notebooks/02_custom_rules.ipynb index 07c76be..6b887fa 100644 --- a/examples/notebooks/02_custom_rules.ipynb +++ b/examples/notebooks/02_custom_rules.ipynb @@ -100,7 +100,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 🔌 Using Custom Rules with the Engine" + "## 🔌 Using Custom Rules with the Engine\n", + "\n", + "You can add custom rules to the validation engine by creating a custom SemanticValidator:" ] }, { @@ -109,12 +111,12 @@ "metadata": {}, "outputs": [], "source": [ - "from dppvalidator.validators import ValidationEngine\n", + "from dppvalidator import ValidationEngine\n", + "from dppvalidator.validators import SemanticValidator\n", "from dppvalidator.validators.rules import ALL_RULES\n", - "from dppvalidator.validators.semantic import SemanticValidator\n", "\n", - "# Create custom rule set\n", - "custom_rules = ALL_RULES + [MinimumRecycledContentRule(min_recycled=0.30)]\n", + "# Create custom rule set by combining built-in rules with our custom rule\n", + "custom_rules = list(ALL_RULES) + [MinimumRecycledContentRule(min_recycled=0.30)]\n", "\n", "# Create engine with custom semantic validator\n", "engine = ValidationEngine()\n", @@ -195,7 +197,7 @@ " severity: Literal[\"error\", \"warning\", \"info\"] = \"error\" # Now fails validation\n", "\n", "\n", - "strict_rules = ALL_RULES + [StrictRecycledContentRule(min_recycled=0.30)]\n", + "strict_rules = list(ALL_RULES) + [StrictRecycledContentRule(min_recycled=0.30)]\n", "engine._semantic_validator = SemanticValidator(rules=strict_rules)\n", "\n", "result = engine.validate(low_recycled_dpp)\n", diff --git a/examples/notebooks/03_batch_processing.ipynb b/examples/notebooks/03_batch_processing.ipynb index a5a6a38..1eade05 100644 --- a/examples/notebooks/03_batch_processing.ipynb +++ b/examples/notebooks/03_batch_processing.ipynb @@ -90,7 +90,7 @@ "source": [ "import time\n", "\n", - "from dppvalidator.validators import ValidationEngine\n", + "from dppvalidator import ValidationEngine\n", "\n", "engine = ValidationEngine()\n", "\n", diff --git a/examples/notebooks/04_jsonld_export.ipynb b/examples/notebooks/04_jsonld_export.ipynb index 41509c0..5b711a9 100644 --- a/examples/notebooks/04_jsonld_export.ipynb +++ b/examples/notebooks/04_jsonld_export.ipynb @@ -57,7 +57,7 @@ "metadata": {}, "outputs": [], "source": [ - "from dppvalidator.validators import ValidationEngine\n", + "from dppvalidator import ValidationEngine\n", "\n", "engine = ValidationEngine()\n", "\n", diff --git a/examples/notebooks/05_plugin_development.ipynb b/examples/notebooks/05_plugin_development.ipynb index 7c33e29..3edbdda 100644 --- a/examples/notebooks/05_plugin_development.ipynb +++ b/examples/notebooks/05_plugin_development.ipynb @@ -257,12 +257,12 @@ "metadata": {}, "outputs": [], "source": [ - "from dppvalidator.validators import ValidationEngine\n", + "from dppvalidator import ValidationEngine\n", + "from dppvalidator.validators import SemanticValidator\n", "from dppvalidator.validators.rules import ALL_RULES\n", - "from dppvalidator.validators.semantic import SemanticValidator\n", "\n", "# Combine core rules with plugin rules\n", - "textile_rules = ALL_RULES + plugin.get_rules()\n", + "textile_rules = list(ALL_RULES) + plugin.get_rules()\n", "\n", "# Create engine with textile plugin\n", "engine = ValidationEngine()\n", diff --git a/llms-ctx.txt b/llms-ctx.txt index ea2e3c3..c0b7ce0 100644 --- a/llms-ctx.txt +++ b/llms-ctx.txt @@ -10,29 +10,38 @@ dppvalidator is the open-source compliance engine for EU Digital Product Passpor - Python 3.10+ - Pydantic v2 for validation models -- Optional: jsonschema, httpx, rich (CLI) +- Included: httpx, jsonschema, pyld, cryptography, PyJWT +- Optional: rich (CLI formatting via `[cli]` extra) ## Installation ```bash -pip install dppvalidator # Core only -pip install dppvalidator[all] # With all optional dependencies +# Using uv (recommended) +uv add dppvalidator # Full functionality +uv add "dppvalidator[cli]" # With rich CLI formatting + +# Or using pip +pip install dppvalidator +pip install "dppvalidator[cli]" ``` ## Architecture -### Three-Layer Validation +### Five-Layer Validation +0. **Schema Detection**: Auto-detect DPP schema version from $schema/@context 1. **Schema Layer (SCH001-SCH099)**: JSON Schema Draft 2020-12 validation 2. **Model Layer (MOD001-MOD099)**: Pydantic v2 type validation and coercion -3. **Semantic Layer (SEM001-SEM099)**: Business rules, ISO codes, date logic +3. **JSON-LD Layer (JLD001-JLD099)**: PyLD expansion, context resolution +4. **Semantic Layer (SEM001-SEM099)**: Business rules, ISO codes, GTIN checksums +5. **Cryptographic Layer (SIG001-SIG099)**: VC signature verification, DID resolution ### Performance -- Schema: ~5μs (200k ops/sec) -- Model: ~8μs (125k ops/sec) -- Semantic: ~3μs (333k ops/sec) -- All layers: ~13μs (80k ops/sec) +- Model (minimal): 0.011ms (87k ops/sec) +- Model (full): 0.016ms (62k ops/sec) +- Semantic: 0.005ms (194k ops/sec) +- Full (Model+Sem): 0.017ms (58k ops/sec) ## Core API diff --git a/llms.txt b/llms.txt index 575ce11..b609da3 100644 --- a/llms.txt +++ b/llms.txt @@ -6,7 +6,7 @@ dppvalidator is the open-source compliance engine for EU Digital Product Passpor ## Core Features -- Three-layer validation: schema, model, and semantic +- Five-layer validation: schema, model, JSON-LD, semantic, and cryptographic - Built-in UNTP DPP 0.6.1 schema support - JSON-LD export for W3C Verifiable Credentials - Plugin system for custom validators diff --git a/mkdocs.yml b/mkdocs.yml index 3c24c30..2326ba8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,20 +4,33 @@ site_url: https://artiso-ai.github.io/dppvalidator repo_url: https://github.com/artiso-ai/dppvalidator repo_name: artiso-ai/dppvalidator edit_uri: edit/main/docs/ -copyright: Copyright © 2026 artiso-ai + +# Exclude engineering artefacts from the docs build. The migration plans +# under docs/plans/ are change-tracking documents (implementation logs, +# audit notes, tracking issues); they're not part of the user-facing +# docs site and contain cross-tree relative links to src/ and tests/ +# that mkdocs (rightly) flags as broken in strict mode. +exclude_docs: | + plans/ +copyright: > + Copyright © 2026 artiso-ai -
Change cookie + settings theme: name: material + custom_dir: docs/overrides + logo: assets/logo.png + favicon: assets/favicon-32x32.png palette: - scheme: default - primary: indigo - accent: indigo + primary: grey + accent: teal toggle: icon: material/brightness-7 name: Switch to dark mode - scheme: slate - primary: indigo - accent: indigo + primary: grey + accent: teal toggle: icon: material/brightness-4 name: Switch to light mode @@ -35,10 +48,13 @@ theme: plugins: - search + - meta + - social + - privacy - mkdocstrings: handlers: python: - paths: [src] + paths: [ src ] options: show_source: true show_root_heading: true @@ -52,13 +68,13 @@ markdown_extensions: custom_fences: - name: mermaid class: mermaid - format: !!python/name:pymdownx.superfences.fence_code_format + format: !!python/name:pymdownx.superfences.fence_code_format "" - pymdownx.tabbed: alternate_style: true - pymdownx.snippets - pymdownx.emoji: - emoji_index: !!python/name:material.extensions.emoji.twemoji - emoji_generator: !!python/name:material.extensions.emoji.to_svg + emoji_index: !!python/name:material.extensions.emoji.twemoji "" + emoji_generator: !!python/name:material.extensions.emoji.to_svg "" - admonition - pymdownx.details - attr_list @@ -70,12 +86,50 @@ markdown_extensions: - pymdownx.tasklist: custom_checkbox: true +extra_css: + - stylesheets/extra.css + extra: + analytics: + provider: google + property: G-CYYCRW64JY # Replace with your Google Analytics 4 Measurement ID + feedback: + title: Was this page helpful? + ratings: + - icon: material/emoticon-happy-outline + name: This page was helpful + data: 1 + note: Thanks for your feedback! + - icon: material/emoticon-sad-outline + name: This page could be improved + data: 0 + note: Thanks for your feedback! Help us improve by opening an issue. + consent: + title: Cookie consent + description: > + We use cookies to recognize your repeated visits and preferences, as well + as to measure the effectiveness of our documentation and whether users + find what they're searching for. With your consent, you're helping us to + make our documentation better. For more information, see our Privacy + Policy. + cookies: + analytics: + name: Google Analytics + checked: true + actions: + - accept + - reject + - manage social: - icon: fontawesome/brands/github link: https://github.com/artiso-ai/dppvalidator - icon: fontawesome/brands/python link: https://pypi.org/project/dppvalidator/ + - icon: fontawesome/brands/linkedin + link: https://es.linkedin.com/company/artiso-ai + - icon: fontawesome/solid/globe + link: https://artiso.ai/ nav: - Home: index.md @@ -86,7 +140,10 @@ nav: - Guides: - CLI Usage: guides/cli-usage.md - Validation: guides/validation.md + - Use Cases: guides/use-cases.md - JSON-LD Export: guides/jsonld.md + - EU DPP Export: guides/eudpp-export.md + - Migration 0.6 → 0.7: guides/migration-0-6-to-0-7.md - Plugin Development: guides/plugins.md - Reference: - CLI Reference: reference/cli.md @@ -96,10 +153,111 @@ nav: - Exporters: reference/api/exporters.md - Plugins: reference/api/plugins.md - Concepts: + - UNTP DPP versions: concepts/untp-versions.md - UNTP DPP Schema: concepts/untp-schema.md - - Three-Layer Validation: concepts/validation-layers.md + - Five-Layer Validation: concepts/validation-layers.md + - EU DPP Ontology Alignment: concepts/eudpp-ontology-alignment.md + - CIRPASS-2 Implementation: concepts/cirpass-implementation.md - Contributing: - Development Setup: contributing/development-setup.md - Code Style: contributing/code-style.md - Testing: contributing/testing.md + - Errors: + - Overview: errors/index.md + - Schema Errors: + - SCH001 - Schema Not Loaded: errors/SCH001.md + - SCH002 - Type Mismatch: errors/SCH002.md + - SCH003 - Invalid Enum Value: errors/SCH003.md + - SCH004 - Invalid Format: errors/SCH004.md + - SCH005 - Pattern Mismatch: errors/SCH005.md + - SCH006 - String Too Short: errors/SCH006.md + - SCH007 - String Too Long: errors/SCH007.md + - SCH008 - Value Below Minimum: errors/SCH008.md + - SCH009 - Value Above Maximum: errors/SCH009.md + - SCH010 - Additional Properties: errors/SCH010.md + - SCH011 - Too Few Items: errors/SCH011.md + - SCH012 - Too Many Items: errors/SCH012.md + - SCH013 - Duplicate Items: errors/SCH013.md + - SCH014 - Const Mismatch: errors/SCH014.md + - SCH015 - AllOf Violation: errors/SCH015.md + - SCH016 - AnyOf Violation: errors/SCH016.md + - SCH017 - OneOf Violation: errors/SCH017.md + - SCH018 - Not Violation: errors/SCH018.md + - SCH019 - Contains Violation: errors/SCH019.md + - SCH020 - PrefixItems Violation: errors/SCH020.md + - SCH021 - Reference Resolution: errors/SCH021.md + - SCH099 - Unknown Schema Error: errors/SCH099.md + - Parsing Errors: + - PRS001 - File Not Found: errors/PRS001.md + - PRS002 - Invalid JSON: errors/PRS002.md + - PRS003 - Unsupported Input Type: errors/PRS003.md + - PRS004 - Input Too Large: errors/PRS004.md + - PRS005 - File Size Exceeded: errors/PRS005.md + - Model Errors: + - MDL001 - Model Validation Failed: errors/MDL001.md + - MDL002 - Invalid URL Format: errors/MDL002.md + - MDL003 - Invalid DateTime Format: errors/MDL003.md + - MDL010 - Invalid Issuer: errors/MDL010.md + - MDL011 - Invalid Issuer ID: errors/MDL011.md + - MDL012 - Invalid Issuer Name: errors/MDL012.md + - MDL013 - Invalid Issuer Type: errors/MDL013.md + - MDL014 - Invalid Issuer Location: errors/MDL014.md + - MDL015 - Invalid Issuer Identifier: errors/MDL015.md + - MDL016 - Invalid Identifier Scheme: errors/MDL016.md + - MDL020 - Invalid Credential Subject: errors/MDL020.md + - MDL021 - Invalid Credential Subject ID: errors/MDL021.md + - MDL022 - Invalid Credential Subject Type: errors/MDL022.md + - MDL030 - Invalid Product: errors/MDL030.md + - MDL031 - Invalid Product ID: errors/MDL031.md + - MDL032 - Invalid Product Name: errors/MDL032.md + - MDL033 - Invalid Product Category: errors/MDL033.md + - MDL040 - Invalid Material: errors/MDL040.md + - MDL041 - Invalid Material Name: errors/MDL041.md + - MDL042 - Invalid Material Fraction: errors/MDL042.md + - MDL050 - Invalid Claim: errors/MDL050.md + - MDL051 - Invalid Claim Type: errors/MDL051.md + - MDL052 - Invalid Claim Topic: errors/MDL052.md + - MDL053 - Invalid Claim Assessment: errors/MDL053.md + - MDL060 - Invalid Traceability: errors/MDL060.md + - MDL061 - Invalid Traceability Event: errors/MDL061.md + - MDL070 - Invalid Circularity: errors/MDL070.md + - MDL071 - Invalid Circularity Content: errors/MDL071.md + - MDL080 - Invalid Emission: errors/MDL080.md + - MDL081 - Invalid Emission Value: errors/MDL081.md + - MDL090 - Invalid Facility: errors/MDL090.md + - MDL091 - Invalid Facility Location: errors/MDL091.md + - MDL099 - Unknown Model Error: errors/MDL099.md + - JSON-LD Errors: + - JLD001 - Missing Context: errors/JLD001.md + - JLD002 - Undefined Terms: errors/JLD002.md + - JLD003 - Namespace Pollution: errors/JLD003.md + - JLD004 - Context Resolution: errors/JLD004.md + - Semantic Errors: + - SEM001 - Mass Fraction Sum: errors/SEM001.md + - SEM002 - Validity Date Order: errors/SEM002.md + - SEM003 - Hazardous Material Safety: errors/SEM003.md + - SEM004 - Circularity Consistency: errors/SEM004.md + - SEM005 - Missing Conformity Claims: errors/SEM005.md + - SEM006 - Item-Level Serial Number: errors/SEM006.md + - SEM007 - Operational Scope: errors/SEM007.md + - Vocabulary Errors: + - VOC001 - Invalid Country Code: errors/VOC001.md + - VOC002 - Invalid Unit Code: errors/VOC002.md + - VOC003 - UNECE Material Code: errors/VOC003.md + - VOC004 - HS Code Validation: errors/VOC004.md + - VOC005 - GTIN Checksum: errors/VOC005.md + - CIRPASS Errors: + - CQ001 - Missing Mandatory Attributes: errors/CQ001.md + - CQ004 - Missing CAS Number: errors/CQ004.md + - CQ011 - Missing Operator ID: errors/CQ011.md + - CQ016 - Missing Validity Period: errors/CQ016.md + - CQ017 - Granularity Mismatch: errors/CQ017.md + - CQ020 - Missing Weight/Volume: errors/CQ020.md + - Textile Errors: + - TXT001 - Invalid Textile HS Code: errors/TXT001.md + - TXT002 - Missing Material Composition: errors/TXT002.md + - TXT003 - Missing Microplastic Data: errors/TXT003.md + - TXT004 - Missing Durability Info: errors/TXT004.md + - TXT005 - Missing Care Instructions: errors/TXT005.md + - FAQ: faq.md - Changelog: changelog.md diff --git a/mutants/pyproject.toml b/mutants/pyproject.toml deleted file mode 100644 index 17f4b2d..0000000 --- a/mutants/pyproject.toml +++ /dev/null @@ -1,112 +0,0 @@ -[project] -name = "dppvalidator" -version = "0.1.0" -description = "Python library for validating Digital Product Passports (DPP) according to EU ESPR regulations and CIRPASS/UNECE ontologies" -readme = "README.md" -requires-python = ">=3.10" -license = { text = "MIT" } -authors = [{ name = "artiso-ai" }] -keywords = [ - "dpp", - "digital-product-passport", - "espr", - "cirpass", - "validation", - "pydantic", -] -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", - "Topic :: Software Development :: Libraries :: Python Modules", -] -dependencies = ["pydantic>=2.12.5"] - -[project.scripts] -dppvalidator = "dppvalidator.cli:cli" - -[project.optional-dependencies] -http = ["httpx>=0.28.0"] -jsonschema = ["jsonschema>=4.23.0"] -all = ["httpx>=0.28.0", "jsonschema>=4.23.0"] - -[project.urls] -Homepage = "https://github.com/artiso-ai/dppvalidator" -Repository = "https://github.com/artiso-ai/dppvalidator" -Documentation = "https://github.com/artiso-ai/dppvalidator#readme" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/dppvalidator"] - -[dependency-groups] -dev = [ - "pytest>=8.0.0", - "pytest-cov>=4.1.0", - "hypothesis>=6.100.0", - "mutmut>=3.0.0", - "ruff>=0.8.0", - "ty>=0.0.1a0", - "pre-commit>=3.6.0", - "pip-audit>=2.7.0", - "httpx>=0.28.0", -] - -[tool.ruff] -target-version = "py310" -line-length = 100 -src = ["src", "tests"] - -[tool.ruff.lint] -select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # Pyflakes - "I", # isort - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "UP", # pyupgrade - "ARG", # flake8-unused-arguments - "SIM", # flake8-simplify -] -ignore = [ - "E501", # line too long (handled by formatter) -] - -[tool.ruff.lint.isort] -known-first-party = ["dppvalidator"] - -[tool.ruff.format] -quote-style = "double" -indent-style = "space" - -[tool.pytest.ini_options] -testpaths = ["tests"] -python_files = ["test_*.py"] -python_functions = ["test_*"] -addopts = "-v --tb=short" - -[tool.coverage.run] -source = ["src/dppvalidator"] -branch = true - -[tool.coverage.report] -fail_under = 84 -exclude_lines = [ - "pragma: no cover", - "if TYPE_CHECKING:", - "raise NotImplementedError", - "\\.\\.\\.", # Protocol stubs -] -omit = [ - "*/protocols.py", # Protocol definitions are abstract stubs -] diff --git a/mutants/setup.cfg b/mutants/setup.cfg deleted file mode 100644 index 0e8e31b..0000000 --- a/mutants/setup.cfg +++ /dev/null @@ -1,4 +0,0 @@ -[mutmut] -paths_to_mutate=src/dppvalidator/validators/results.py -runner=uv run pytest tests/unit/test_validators.py -x --tb=no -q -tests_dir=tests/ diff --git a/mutants/src/dppvalidator/validators/results.py b/mutants/src/dppvalidator/validators/results.py deleted file mode 100644 index 78133d4..0000000 --- a/mutants/src/dppvalidator/validators/results.py +++ /dev/null @@ -1,236 +0,0 @@ -"""Validation result types using the Result pattern.""" - -from __future__ import annotations - -import json -from dataclasses import dataclass, field -from datetime import datetime -from typing import TYPE_CHECKING, Any, Literal - -if TYPE_CHECKING: - from dppvalidator.models.passport import DigitalProductPassport -from inspect import signature as _mutmut_signature -from typing import Annotated, Callable, ClassVar - -MutantDict = Annotated[dict[str, Callable], "Mutant"] - - -def _mutmut_trampoline(orig, mutants, call_args, call_kwargs, self_arg=None): - """Forward call to original or mutated function, depending on the environment""" - import os - - mutant_under_test = os.environ["MUTANT_UNDER_TEST"] - if mutant_under_test == "fail": - from mutmut.__main__ import MutmutProgrammaticFailException - - raise MutmutProgrammaticFailException("Failed programmatically") - elif mutant_under_test == "stats": - from mutmut.__main__ import record_trampoline_hit - - record_trampoline_hit(orig.__module__ + "." + orig.__name__) - result = orig(*call_args, **call_kwargs) - return result - prefix = orig.__module__ + "." + orig.__name__ + "__mutmut_" - if not mutant_under_test.startswith(prefix): - result = orig(*call_args, **call_kwargs) - return result - mutant_name = mutant_under_test.rpartition(".")[-1] - if self_arg is not None: - # call to a class method where self is not bound - result = mutants[mutant_name](self_arg, *call_args, **call_kwargs) - else: - result = mutants[mutant_name](*call_args, **call_kwargs) - return result - - -@dataclass(frozen=True, slots=True) -class ValidationError: - """Represents a single validation error with full context. - - Attributes: - path: JSON path to the error location (e.g., "$.credentialSubject.product.id") - message: Human-readable error description - code: Machine-readable error code (e.g., "SEM001") - layer: Validation layer that produced this error - severity: Error severity level - context: Additional context for debugging - """ - - path: str - message: str - code: str - layer: Literal["schema", "model", "semantic", "plugin"] - severity: Literal["error", "warning", "info"] = "error" - context: dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> dict[str, Any]: - """Convert to dictionary for JSON serialization.""" - return { - "path": self.path, - "message": self.message, - "code": self.code, - "layer": self.layer, - "severity": self.severity, - "context": self.context, - } - - -@dataclass -class ValidationResult: - """Result of DPP validation following the Result pattern. - - Never raises exceptions for validation failures. Instead, check - the `valid` property and inspect `errors` for details. - - Attributes: - valid: Whether the passport passed all validation layers - errors: List of validation errors (severity="error") - warnings: List of validation warnings (severity="warning") - info: List of informational messages (severity="info") - schema_version: UNTP DPP schema version used - validated_at: Timestamp of validation - passport: Parsed DigitalProductPassport if valid, None otherwise - parse_time_ms: Time spent parsing input - validation_time_ms: Time spent on validation layers - """ - - valid: bool - errors: list[ValidationError] = field(default_factory=list) - warnings: list[ValidationError] = field(default_factory=list) - info: list[ValidationError] = field(default_factory=list) - schema_version: str = "0.6.1" - validated_at: datetime = field(default_factory=datetime.now) - passport: DigitalProductPassport | None = None - parse_time_ms: float = 0.0 - validation_time_ms: float = 0.0 - - @property - def error_count(self) -> int: - """Total number of errors.""" - return len(self.errors) - - @property - def warning_count(self) -> int: - """Total number of warnings.""" - return len(self.warnings) - - @property - def all_issues(self) -> list[ValidationError]: - """All errors, warnings, and info messages combined.""" - return self.errors + self.warnings + self.info - - def to_dict(self) -> dict[str, Any]: - """Convert to dictionary for JSON serialization.""" - return { - "valid": self.valid, - "errors": [e.to_dict() for e in self.errors], - "warnings": [w.to_dict() for w in self.warnings], - "info": [i.to_dict() for i in self.info], - "schema_version": self.schema_version, - "validated_at": self.validated_at.isoformat(), - "parse_time_ms": self.parse_time_ms, - "validation_time_ms": self.validation_time_ms, - } - - def to_json(self, *, indent: int | None = 2) -> str: - """Serialize result to JSON string.""" - return json.dumps(self.to_dict(), indent=indent) - - def raise_for_errors(self) -> None: - """Raise ValidationException if there are errors. - - This is an opt-in method for users who prefer exception-based flow. - """ - if not self.valid: - raise ValidationException(self) - - def merge(self, other: ValidationResult) -> ValidationResult: - """Merge another result into this one.""" - return ValidationResult( - valid=self.valid and other.valid, - errors=self.errors + other.errors, - warnings=self.warnings + other.warnings, - info=self.info + other.info, - schema_version=self.schema_version, - validated_at=self.validated_at, - passport=self.passport if self.valid else other.passport, - parse_time_ms=self.parse_time_ms + other.parse_time_ms, - validation_time_ms=self.validation_time_ms + other.validation_time_ms, - ) - - -class ValidationException(Exception): - """Exception raised when raise_for_errors() is called on invalid result.""" - - def xǁValidationExceptionǁ__init____mutmut_orig(self, result: ValidationResult) -> None: - self.result = result - error_msgs = [f" - {e.path}: {e.message} [{e.code}]" for e in result.errors] - message = f"Validation failed with {len(result.errors)} error(s):\n" + "\n".join(error_msgs) - super().__init__(message) - - def xǁValidationExceptionǁ__init____mutmut_1(self, result: ValidationResult) -> None: - self.result = None - error_msgs = [f" - {e.path}: {e.message} [{e.code}]" for e in result.errors] - message = f"Validation failed with {len(result.errors)} error(s):\n" + "\n".join(error_msgs) - super().__init__(message) - - def xǁValidationExceptionǁ__init____mutmut_2(self, result: ValidationResult) -> None: - self.result = result - error_msgs = None - message = f"Validation failed with {len(result.errors)} error(s):\n" + "\n".join(error_msgs) - super().__init__(message) - - def xǁValidationExceptionǁ__init____mutmut_3(self, result: ValidationResult) -> None: - self.result = result - error_msgs = [f" - {e.path}: {e.message} [{e.code}]" for e in result.errors] - message = None - super().__init__(message) - - def xǁValidationExceptionǁ__init____mutmut_4(self, result: ValidationResult) -> None: - self.result = result - error_msgs = [f" - {e.path}: {e.message} [{e.code}]" for e in result.errors] - message = f"Validation failed with {len(result.errors)} error(s):\n" - "\n".join(error_msgs) - super().__init__(message) - - def xǁValidationExceptionǁ__init____mutmut_5(self, result: ValidationResult) -> None: - self.result = result - error_msgs = [f" - {e.path}: {e.message} [{e.code}]" for e in result.errors] - message = f"Validation failed with {len(result.errors)} error(s):\n" + "\n".join(None) - super().__init__(message) - - def xǁValidationExceptionǁ__init____mutmut_6(self, result: ValidationResult) -> None: - self.result = result - error_msgs = [f" - {e.path}: {e.message} [{e.code}]" for e in result.errors] - message = f"Validation failed with {len(result.errors)} error(s):\n" + "XX\nXX".join( - error_msgs - ) - super().__init__(message) - - def xǁValidationExceptionǁ__init____mutmut_7(self, result: ValidationResult) -> None: - self.result = result - error_msgs = [f" - {e.path}: {e.message} [{e.code}]" for e in result.errors] - message = f"Validation failed with {len(result.errors)} error(s):\n" + "\n".join(error_msgs) - super().__init__(None) - - xǁValidationExceptionǁ__init____mutmut_mutants: ClassVar[MutantDict] = { - "xǁValidationExceptionǁ__init____mutmut_1": xǁValidationExceptionǁ__init____mutmut_1, - "xǁValidationExceptionǁ__init____mutmut_2": xǁValidationExceptionǁ__init____mutmut_2, - "xǁValidationExceptionǁ__init____mutmut_3": xǁValidationExceptionǁ__init____mutmut_3, - "xǁValidationExceptionǁ__init____mutmut_4": xǁValidationExceptionǁ__init____mutmut_4, - "xǁValidationExceptionǁ__init____mutmut_5": xǁValidationExceptionǁ__init____mutmut_5, - "xǁValidationExceptionǁ__init____mutmut_6": xǁValidationExceptionǁ__init____mutmut_6, - "xǁValidationExceptionǁ__init____mutmut_7": xǁValidationExceptionǁ__init____mutmut_7, - } - - def __init__(self, *args, **kwargs): - result = _mutmut_trampoline( - object.__getattribute__(self, "xǁValidationExceptionǁ__init____mutmut_orig"), - object.__getattribute__(self, "xǁValidationExceptionǁ__init____mutmut_mutants"), - args, - kwargs, - self, - ) - return result - - __init__.__signature__ = _mutmut_signature(xǁValidationExceptionǁ__init____mutmut_orig) - xǁValidationExceptionǁ__init____mutmut_orig.__name__ = "xǁValidationExceptionǁ__init__" diff --git a/mutants/src/dppvalidator/validators/results.py.meta b/mutants/src/dppvalidator/validators/results.py.meta deleted file mode 100644 index 4ee4770..0000000 --- a/mutants/src/dppvalidator/validators/results.py.meta +++ /dev/null @@ -1,14 +0,0 @@ -{ - "exit_code_by_key": { - "dppvalidator.validators.results.x\u01c1ValidationException\u01c1__init____mutmut_1": null, - "dppvalidator.validators.results.x\u01c1ValidationException\u01c1__init____mutmut_2": null, - "dppvalidator.validators.results.x\u01c1ValidationException\u01c1__init____mutmut_3": null, - "dppvalidator.validators.results.x\u01c1ValidationException\u01c1__init____mutmut_4": null, - "dppvalidator.validators.results.x\u01c1ValidationException\u01c1__init____mutmut_5": null, - "dppvalidator.validators.results.x\u01c1ValidationException\u01c1__init____mutmut_6": null, - "dppvalidator.validators.results.x\u01c1ValidationException\u01c1__init____mutmut_7": null - }, - "hash_by_function_name": {}, - "durations_by_key": {}, - "estimated_durations_by_key": {} -} diff --git a/mutants/tests/__init__.py b/mutants/tests/__init__.py deleted file mode 100644 index 38ad415..0000000 --- a/mutants/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Test suite for dppvalidator.""" diff --git a/mutants/tests/fixtures/__init__.py b/mutants/tests/fixtures/__init__.py deleted file mode 100644 index 2774fb7..0000000 --- a/mutants/tests/fixtures/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Test fixtures for dppvalidator.""" diff --git a/mutants/tests/fixtures/invalid/invalid_dates.json b/mutants/tests/fixtures/invalid/invalid_dates.json deleted file mode 100644 index 9b1b5e5..0000000 --- a/mutants/tests/fixtures/invalid/invalid_dates.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "type": ["DigitalProductPassport", "VerifiableCredential"], - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/" - ], - "id": "https://example.com/credentials/dpp-invalid-002", - "issuer": { - "type": ["CredentialIssuer"], - "id": "did:web:example.com:issuers:001", - "name": "Example Company Ltd" - }, - "validFrom": "2034-01-01T00:00:00Z", - "validUntil": "2024-01-01T00:00:00Z" -} diff --git a/mutants/tests/fixtures/invalid/missing_issuer.json b/mutants/tests/fixtures/invalid/missing_issuer.json deleted file mode 100644 index 7bc23d4..0000000 --- a/mutants/tests/fixtures/invalid/missing_issuer.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "type": ["DigitalProductPassport", "VerifiableCredential"], - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/" - ], - "id": "https://example.com/credentials/dpp-invalid-001" -} diff --git a/mutants/tests/fixtures/valid/full_dpp.json b/mutants/tests/fixtures/valid/full_dpp.json deleted file mode 100644 index 88af71f..0000000 --- a/mutants/tests/fixtures/valid/full_dpp.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "type": ["DigitalProductPassport", "VerifiableCredential"], - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/" - ], - "id": "https://example.com/credentials/dpp-002", - "issuer": { - "type": ["CredentialIssuer"], - "id": "did:web:example.com:issuers:001", - "name": "Example Company Ltd" - }, - "validFrom": "2024-01-01T00:00:00Z", - "validUntil": "2034-01-01T00:00:00Z", - "credentialSubject": { - "type": ["ProductPassport"], - "id": "https://example.com/products/battery-001", - "granularityLevel": "item", - "product": { - "type": ["Product"], - "id": "https://id.gs1.org/01/09520123456788/21/12345", - "name": "EV Battery 300Ah", - "description": "400Ah 24v LiFePO4 battery for electric vehicles", - "serialNumber": "SN-2024-001234", - "batchNumber": "BATCH-2024-Q1", - "productionDate": "2024-03-15", - "countryOfProduction": "DE" - }, - "materialsProvenance": [ - { - "type": ["Material"], - "name": "Lithium Iron Phosphate", - "originCountry": "AU", - "massFraction": 0.6, - "hazardous": false - }, - { - "type": ["Material"], - "name": "Aluminum Casing", - "originCountry": "DE", - "massFraction": 0.4, - "recycledMassFraction": 0.3, - "hazardous": false - } - ], - "circularityScorecard": { - "type": ["CircularityPerformance"], - "recyclableContent": 0.85, - "recycledContent": 0.3, - "utilityFactor": 1.2, - "materialCircularityIndicator": 0.65 - } - } -} diff --git a/mutants/tests/fixtures/valid/minimal_dpp.json b/mutants/tests/fixtures/valid/minimal_dpp.json deleted file mode 100644 index 3baf43f..0000000 --- a/mutants/tests/fixtures/valid/minimal_dpp.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "type": [ - "DigitalProductPassport", - "VerifiableCredential" - ], - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/" - ], - "id": "https://example.com/credentials/dpp-001", - "issuer": { - "id": "https://example.com/issuers/001", - "name": "Example Company Ltd" - } -} diff --git a/mutants/tests/fuzz/__init__.py b/mutants/tests/fuzz/__init__.py deleted file mode 100644 index 703ca9b..0000000 --- a/mutants/tests/fuzz/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Fuzzing tests using Hypothesis.""" diff --git a/mutants/tests/fuzz/test_fuzz_engine.py b/mutants/tests/fuzz/test_fuzz_engine.py deleted file mode 100644 index a3f2e91..0000000 --- a/mutants/tests/fuzz/test_fuzz_engine.py +++ /dev/null @@ -1,205 +0,0 @@ -"""Fuzzing tests for the validation engine. - -These tests ensure the validation engine never crashes on arbitrary input, -even malformed or malicious data. -""" - -import json - -from hypothesis import given, settings -from hypothesis import strategies as st - -from dppvalidator.validators import ValidationEngine, ValidationResult - - -class TestEngineFuzzing: - """Fuzzing tests for ValidationEngine.""" - - @given(st.binary(min_size=0, max_size=1000)) - @settings(max_examples=500) - def test_engine_never_crashes_on_binary(self, data: bytes): - """Test engine never crashes on arbitrary binary input.""" - engine = ValidationEngine(layers=["model"]) - try: - text = data.decode("utf-8", errors="replace") - result = engine.validate(text) - assert result is not None - assert isinstance(result, ValidationResult) - except Exception: - # Any exception is acceptable as long as it doesn't crash - pass - - @given(st.text(min_size=0, max_size=500)) - @settings(max_examples=500) - def test_engine_never_crashes_on_text(self, text: str): - """Test engine never crashes on arbitrary text input.""" - engine = ValidationEngine(layers=["model"]) - try: - result = engine.validate(text) - assert result is not None - assert isinstance(result, ValidationResult) - except Exception: - pass - - @given( - st.recursive( - st.none() - | st.booleans() - | st.integers() - | st.floats(allow_nan=False) - | st.text(max_size=50), - lambda children: st.lists(children, max_size=5) - | st.dictionaries(st.text(min_size=1, max_size=20), children, max_size=5), - max_leaves=20, - ) - ) - @settings(max_examples=300) - def test_engine_never_crashes_on_json_structure(self, data): - """Test engine never crashes on arbitrary JSON-like structures.""" - engine = ValidationEngine(layers=["model"]) - try: - result = engine.validate(data) - assert result is not None - assert isinstance(result, ValidationResult) - except Exception: - pass - - @given( - st.dictionaries( - keys=st.text(min_size=1, max_size=30), - values=st.one_of( - st.none(), - st.booleans(), - st.integers(), - st.floats(allow_nan=False, allow_infinity=False), - st.text(max_size=100), - st.lists(st.text(max_size=20), max_size=5), - ), - min_size=0, - max_size=20, - ) - ) - @settings(max_examples=300) - def test_engine_never_crashes_on_random_dicts(self, data: dict): - """Test engine never crashes on random dictionary input.""" - engine = ValidationEngine(layers=["model"]) - result = engine.validate(data) - assert result is not None - assert isinstance(result, ValidationResult) - # Invalid data should produce invalid result - if "id" not in data or "issuer" not in data: - assert result.valid is False - - @given(st.binary(min_size=0, max_size=500)) - @settings(max_examples=200) - def test_engine_with_all_layers_never_crashes(self, data: bytes): - """Test engine with all layers enabled never crashes.""" - engine = ValidationEngine(layers=["model", "semantic"]) - try: - text = data.decode("utf-8", errors="replace") - result = engine.validate(text) - assert result is not None - except Exception: - pass - - -class TestJSONParseFuzzing: - """Fuzzing tests for JSON parsing paths.""" - - @given(st.text(min_size=0, max_size=200)) - @settings(max_examples=300) - def test_json_parse_never_crashes(self, text: str): - """Test JSON parsing never crashes on arbitrary text.""" - engine = ValidationEngine(layers=["model"]) - try: - # Try to parse as JSON first - data = json.loads(text) - result = engine.validate(data) - assert result is not None - except json.JSONDecodeError: - # Invalid JSON is expected - pass - except Exception: - # Other exceptions are acceptable - pass - - @given(st.from_regex(r"\{[^}]*\}", fullmatch=True)) - @settings(max_examples=200) - def test_json_like_strings_never_crash(self, text: str): - """Test JSON-like strings never crash the engine.""" - engine = ValidationEngine(layers=["model"]) - try: - result = engine.validate(text) - assert result is not None - except Exception: - pass - - -class TestMalformedInputFuzzing: - """Fuzzing tests for malformed inputs.""" - - @given( - st.dictionaries( - keys=st.sampled_from(["id", "issuer", "type", "@context", "credentialSubject"]), - values=st.one_of( - st.none(), - st.integers(), - st.text(max_size=50), - st.lists(st.none(), max_size=3), - ), - min_size=0, - max_size=5, - ) - ) - @settings(max_examples=200) - def test_partial_valid_structure_never_crashes(self, data: dict): - """Test partial DPP-like structures never crash.""" - engine = ValidationEngine(layers=["model"]) - result = engine.validate(data) - assert result is not None - assert isinstance(result, ValidationResult) - - @given( - st.fixed_dictionaries( - { - "id": st.one_of(st.none(), st.integers(), st.text(max_size=100)), - "issuer": st.one_of( - st.none(), - st.integers(), - st.text(max_size=50), - st.dictionaries(st.text(max_size=10), st.text(max_size=20), max_size=3), - ), - } - ) - ) - @settings(max_examples=200) - def test_wrong_types_never_crash(self, data: dict): - """Test wrong field types never crash the engine.""" - engine = ValidationEngine(layers=["model"]) - result = engine.validate(data) - assert result is not None - assert isinstance(result, ValidationResult) - - @given(st.integers(min_value=-1000000, max_value=1000000)) - @settings(max_examples=100) - def test_integer_input_never_crashes(self, data: int): - """Test integer input never crashes.""" - engine = ValidationEngine(layers=["model"]) - result = engine.validate(data) # type: ignore - assert result is not None - - @given(st.floats(allow_nan=False, allow_infinity=False)) - @settings(max_examples=100) - def test_float_input_never_crashes(self, data: float): - """Test float input never crashes.""" - engine = ValidationEngine(layers=["model"]) - result = engine.validate(data) # type: ignore - assert result is not None - - @given(st.lists(st.text(max_size=20), max_size=10)) - @settings(max_examples=100) - def test_list_input_never_crashes(self, data: list): - """Test list input never crashes.""" - engine = ValidationEngine(layers=["model"]) - result = engine.validate(data) # type: ignore - assert result is not None diff --git a/mutants/tests/integration/__init__.py b/mutants/tests/integration/__init__.py deleted file mode 100644 index 3b71ec1..0000000 --- a/mutants/tests/integration/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Integration tests for dppvalidator.""" diff --git a/mutants/tests/property/__init__.py b/mutants/tests/property/__init__.py deleted file mode 100644 index 0cde5da..0000000 --- a/mutants/tests/property/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Property-based tests using Hypothesis.""" diff --git a/mutants/tests/property/test_property_models.py b/mutants/tests/property/test_property_models.py deleted file mode 100644 index 5a9a008..0000000 --- a/mutants/tests/property/test_property_models.py +++ /dev/null @@ -1,205 +0,0 @@ -"""Property-based tests for dppvalidator models using Hypothesis.""" - -from hypothesis import given, settings -from hypothesis import strategies as st - -from dppvalidator.models import ( - CredentialIssuer, - DigitalProductPassport, - Measure, - Product, - ProductPassport, -) - - -# Custom strategies for DPP domain types -def printable_text(min_size: int = 1, max_size: int = 100) -> st.SearchStrategy[str]: - """Strategy for generating printable text without control/whitespace characters.""" - return st.text( - alphabet=st.characters( - whitelist_categories=("L", "N", "P"), - blacklist_characters="\r\n\t\x00\xa0", - ), - min_size=min_size, - max_size=max_size, - ).filter(lambda x: len(x.strip()) > 0 and x == x.strip()) - - -class TestMeasureProperty: - """Property-based tests for Measure model.""" - - @given( - value=st.floats(min_value=0, max_value=1e10, allow_nan=False, allow_infinity=False), - unit=st.sampled_from(["KGM", "LTR", "MTR", "GRM", "CMT"]), - ) - @settings(max_examples=100) - def test_measure_roundtrip(self, value, unit): - """Test Measure model round-trip: create → dump → validate.""" - measure = Measure(value=value, unit=unit) - dumped = measure.model_dump() - restored = Measure.model_validate(dumped) - - assert restored.value == measure.value - assert restored.unit == measure.unit - - @given(value=st.floats(min_value=0, max_value=1e6, allow_nan=False, allow_infinity=False)) - @settings(max_examples=50) - def test_measure_value_preserved(self, value): - """Test that measure value is preserved through serialization.""" - measure = Measure(value=value, unit="KGM") - assert measure.model_dump()["value"] == value - - -class TestCredentialIssuerProperty: - """Property-based tests for CredentialIssuer model.""" - - @given(name=printable_text(1, 50)) - @settings(max_examples=50) - def test_issuer_name_roundtrip(self, name): - """Test issuer name round-trip.""" - issuer = CredentialIssuer( - id="https://example.com/issuer", - name=name, - ) - dumped = issuer.model_dump(by_alias=True) - restored = CredentialIssuer.model_validate(dumped) - - assert restored.name == name - - @given( - name=printable_text(1, 50), - ) - @settings(max_examples=30) - def test_issuer_json_serialization(self, name): - """Test issuer JSON serialization is valid.""" - issuer = CredentialIssuer( - id="https://example.com/issuer", - name=name, - ) - json_str = issuer.model_dump_json() - assert len(json_str) > 0 - assert "id" in json_str or "name" in json_str - - -class TestProductProperty: - """Property-based tests for Product model.""" - - @given( - name=printable_text(1, 100), - description=st.text(min_size=0, max_size=500), - ) - @settings(max_examples=50) - def test_product_text_fields_preserved(self, name, description): - """Test product text fields are preserved.""" - product = Product( - id="https://example.com/product", - name=name, - description=description if description.strip() else None, - ) - dumped = product.model_dump(exclude_none=True) - restored = Product.model_validate(dumped) - - assert restored.name == name - - -class TestDigitalProductPassportProperty: - """Property-based tests for DigitalProductPassport.""" - - @given( - issuer_name=printable_text(1, 50), - ) - @settings(max_examples=30) - def test_passport_issuer_roundtrip(self, issuer_name): - """Test passport with issuer round-trip.""" - passport = DigitalProductPassport( - id="https://example.com/dpp", - issuer=CredentialIssuer( - id="https://example.com/issuer", - name=issuer_name, - ), - ) - dumped = passport.model_dump(by_alias=True, exclude_none=True) - restored = DigitalProductPassport.model_validate(dumped) - - assert restored.issuer.name == issuer_name - - @given(st.data()) - @settings(max_examples=20) - def test_passport_always_has_required_fields(self, data): - """Test that passport always has required fields after creation.""" - issuer_name = data.draw(printable_text(1, 30)) - - passport = DigitalProductPassport( - id="https://example.com/dpp", - issuer=CredentialIssuer( - id="https://example.com/issuer", - name=issuer_name, - ), - ) - - # Required fields must exist - assert passport.id is not None - assert passport.issuer is not None - assert passport.issuer.id is not None - - -class TestProductPassportProperty: - """Property-based tests for ProductPassport (credential subject).""" - - @given( - product_name=printable_text(1, 50), - ) - @settings(max_examples=30) - def test_product_passport_with_product(self, product_name): - """Test ProductPassport with product.""" - pp = ProductPassport( - id="https://example.com/pp", - product=Product( - id="https://example.com/product", - name=product_name, - ), - ) - dumped = pp.model_dump(by_alias=True, exclude_none=True) - restored = ProductPassport.model_validate(dumped) - - assert restored.product is not None - assert restored.product.name == product_name - - -class TestModelInvariants: - """Tests for model invariants that should always hold.""" - - @given( - values=st.lists( - st.floats(min_value=0, max_value=100, allow_nan=False, allow_infinity=False), - min_size=1, - max_size=10, - ) - ) - @settings(max_examples=30) - def test_measure_list_all_valid(self, values): - """Test that a list of measures can all be created.""" - measures = [Measure(value=v, unit="KGM") for v in values] - assert len(measures) == len(values) - assert all(m.value >= 0 for m in measures) - - @given( - count=st.integers(min_value=1, max_value=5), - ) - @settings(max_examples=10) - def test_multiple_passports_independent(self, count): - """Test that multiple passports are independent.""" - passports = [ - DigitalProductPassport( - id=f"https://example.com/dpp-{i}", - issuer=CredentialIssuer( - id=f"https://example.com/issuer-{i}", - name=f"Issuer {i}", - ), - ) - for i in range(count) - ] - - # Each passport should have unique id - ids = [str(p.id) for p in passports] - assert len(set(ids)) == count diff --git a/mutants/tests/property/test_property_validators.py b/mutants/tests/property/test_property_validators.py deleted file mode 100644 index 350d334..0000000 --- a/mutants/tests/property/test_property_validators.py +++ /dev/null @@ -1,187 +0,0 @@ -"""Property-based tests for dppvalidator validators using Hypothesis.""" - -from hypothesis import given, settings -from hypothesis import strategies as st - -from dppvalidator.validators import ValidationEngine, ValidationResult -from dppvalidator.validators.results import ValidationError - - -class TestValidationResultProperty: - """Property-based tests for ValidationResult.""" - - @given( - error_count=st.integers(min_value=0, max_value=10), - ) - @settings(max_examples=30) - def test_result_error_count_matches(self, error_count): - """Test that error count matches number of errors.""" - errors = [ - ValidationError( - path=f"$.field{i}", - message=f"Error {i}", - code=f"ERR{i:03d}", - layer="test", - severity="error", - ) - for i in range(error_count) - ] - result = ValidationResult( - valid=error_count == 0, - errors=errors, - ) - assert len(result.errors) == error_count - assert result.valid == (error_count == 0) - - @given( - warning_count=st.integers(min_value=0, max_value=5), - info_count=st.integers(min_value=0, max_value=5), - ) - @settings(max_examples=20) - def test_result_with_warnings_and_info(self, warning_count, info_count): - """Test result with warnings and info messages.""" - warnings = [ - ValidationError( - path="$", - message=f"Warning {i}", - code=f"WARN{i:03d}", - layer="test", - severity="warning", - ) - for i in range(warning_count) - ] - infos = [ - ValidationError( - path="$", - message=f"Info {i}", - code=f"INFO{i:03d}", - layer="test", - severity="info", - ) - for i in range(info_count) - ] - result = ValidationResult( - valid=True, - warnings=warnings, - info=infos, - ) - assert result.valid is True - assert len(result.warnings) == warning_count - assert len(result.info) == info_count - - -class TestValidationEngineProperty: - """Property-based tests for ValidationEngine.""" - - @given( - data=st.fixed_dictionaries( - { - "id": st.just("https://example.com/dpp"), - "issuer": st.fixed_dictionaries( - { - "id": st.just("https://example.com/issuer"), - "name": st.text(min_size=1, max_size=50).filter(lambda x: x.strip() != ""), - } - ), - } - ) - ) - @settings(max_examples=20) - def test_engine_valid_minimal_passport(self, data): - """Test engine with valid minimal passport data.""" - engine = ValidationEngine(layers=["model"]) - result = engine.validate(data) - # Should be valid with minimal required fields - assert isinstance(result, ValidationResult) - assert result.valid is True - - @given( - invalid_data=st.dictionaries( - keys=st.text(min_size=1, max_size=20).filter(lambda x: x not in ["id", "issuer"]), - values=st.text(min_size=0, max_size=50), - min_size=0, - max_size=5, - ) - ) - @settings(max_examples=30) - def test_engine_invalid_data_returns_errors(self, invalid_data): - """Test engine with invalid data returns errors.""" - engine = ValidationEngine(layers=["model"]) - result = engine.validate(invalid_data) - # Should return result (not crash) even with invalid data - assert isinstance(result, ValidationResult) - # Missing required fields should cause validation to fail - if "id" not in invalid_data or "issuer" not in invalid_data: - assert result.valid is False - - @given(st.binary(min_size=0, max_size=100)) - @settings(max_examples=50) - def test_engine_never_crashes_on_binary(self, binary_data): - """Test engine never crashes on arbitrary binary input.""" - engine = ValidationEngine(layers=["model"]) - try: - text_data = binary_data.decode("utf-8", errors="replace") - result = engine.validate(text_data) - assert result is not None - except Exception: - # Some inputs may raise, but should be handled gracefully - pass - - @given( - layers=st.lists( - st.sampled_from(["model", "semantic"]), - min_size=1, - max_size=2, - unique=True, - ) - ) - @settings(max_examples=10) - def test_engine_layer_combinations(self, layers): - """Test engine with different layer combinations.""" - engine = ValidationEngine(layers=layers) - data = { - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } - result = engine.validate(data) - assert isinstance(result, ValidationResult) - - -class TestValidationErrorProperty: - """Property-based tests for ValidationError.""" - - @given( - path=st.from_regex(r"\$(\.[a-z]+|\[[0-9]+\]){0,5}", fullmatch=True), - message=st.text(min_size=1, max_size=200).filter(lambda x: x.strip() != ""), - code=st.from_regex(r"[A-Z]{2,4}[0-9]{3}", fullmatch=True), - ) - @settings(max_examples=30) - def test_error_creation(self, path, message, code): - """Test ValidationError creation with various inputs.""" - error = ValidationError( - path=path, - message=message, - code=code, - layer="test", - severity="error", - ) - assert error.path == path - assert error.message == message - assert error.code == code - - @given( - severity=st.sampled_from(["error", "warning", "info"]), - layer=st.sampled_from(["schema", "model", "semantic"]), - ) - @settings(max_examples=15) - def test_error_severity_and_layer(self, severity, layer): - """Test ValidationError with different severity and layer.""" - error = ValidationError( - path="$", - message="Test message", - code="TEST001", - layer=layer, - severity=severity, - ) - assert error.severity == severity - assert error.layer == layer diff --git a/mutants/tests/unit/__init__.py b/mutants/tests/unit/__init__.py deleted file mode 100644 index d3b7576..0000000 --- a/mutants/tests/unit/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Unit tests for dppvalidator.""" diff --git a/mutants/tests/unit/test_cli.py b/mutants/tests/unit/test_cli.py deleted file mode 100644 index 508f97f..0000000 --- a/mutants/tests/unit/test_cli.py +++ /dev/null @@ -1,374 +0,0 @@ -"""Tests for CLI module.""" - -import json - -from dppvalidator.cli.main import EXIT_ERROR, EXIT_INVALID, EXIT_VALID, create_parser, main - - -class TestCLIParser: - """Tests for CLI argument parser.""" - - def test_create_parser(self): - """Test parser creation.""" - parser = create_parser() - assert parser.prog == "dppvalidator" - - def test_parser_version_flag(self): - """Test --version flag.""" - parser = create_parser() - args = parser.parse_args(["--version"]) - assert args.version is True - - def test_parser_quiet_flag(self): - """Test --quiet flag.""" - parser = create_parser() - args = parser.parse_args(["--quiet", "validate", "test.json"]) - assert args.quiet is True - - def test_parser_verbose_flag(self): - """Test --verbose flag.""" - parser = create_parser() - args = parser.parse_args(["--verbose", "validate", "test.json"]) - assert args.verbose is True - - -class TestCLIMain: - """Tests for main CLI entry point.""" - - def test_main_no_command_shows_help(self, capsys): - """Test that no command shows help.""" - result = main([]) - assert result == EXIT_VALID - - def test_main_version(self, capsys): - """Test --version output.""" - result = main(["--version"]) - captured = capsys.readouterr() - assert result == EXIT_VALID - assert "dppvalidator" in captured.out - - def test_main_help_flag(self, capsys): - """Test --help flag shows help.""" - # Note: argparse raises SystemExit(0) for --help - import pytest - - with pytest.raises(SystemExit) as exc_info: - main(["--help"]) - assert exc_info.value.code == 0 - - -class TestValidateCommand: - """Tests for validate command.""" - - def test_validate_valid_file(self, tmp_path): - """Test validating a valid passport file.""" - passport_data = { - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } - file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) - - result = main(["validate", str(file_path)]) - assert result == EXIT_VALID - - def test_validate_invalid_file(self, tmp_path): - """Test validating an invalid passport file.""" - passport_data = {"invalid": "data"} - file_path = tmp_path / "invalid.json" - file_path.write_text(json.dumps(passport_data)) - - result = main(["validate", str(file_path)]) - assert result == EXIT_INVALID - - def test_validate_nonexistent_file(self): - """Test validating a nonexistent file.""" - result = main(["validate", "/nonexistent/path.json"]) - assert result == EXIT_ERROR - - def test_validate_with_format_json(self, tmp_path, capsys): - """Test validate with JSON format output.""" - passport_data = { - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } - file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) - - result = main(["validate", str(file_path), "--format", "json"]) - captured = capsys.readouterr() - assert result == EXIT_VALID - # Output should be valid JSON - output = json.loads(captured.out) - assert "valid" in output - - def test_validate_strict_mode(self, tmp_path): - """Test validate with strict mode.""" - passport_data = { - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } - file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) - - result = main(["validate", str(file_path), "--strict"]) - # Should pass since minimal passport is valid - assert result in (EXIT_VALID, EXIT_INVALID) - - -class TestExportCommand: - """Tests for export command.""" - - def test_export_to_stdout(self, tmp_path, capsys): - """Test export to stdout.""" - passport_data = { - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } - file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) - - result = main(["export", str(file_path)]) - captured = capsys.readouterr() - assert result == EXIT_VALID - output = json.loads(captured.out) - assert "@context" in output - - def test_export_to_file(self, tmp_path): - """Test export to file.""" - passport_data = { - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } - input_path = tmp_path / "passport.json" - input_path.write_text(json.dumps(passport_data)) - output_path = tmp_path / "output.jsonld" - - result = main(["export", str(input_path), "-o", str(output_path)]) - assert result == EXIT_VALID - assert output_path.exists() - content = json.loads(output_path.read_text()) - assert "@context" in content - - def test_export_json_format(self, tmp_path, capsys): - """Test export with JSON format.""" - passport_data = { - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } - file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) - - result = main(["export", str(file_path), "--format", "json"]) - captured = capsys.readouterr() - assert result == EXIT_VALID - output = json.loads(captured.out) - assert "issuer" in output - - -class TestSchemaCommand: - """Tests for schema command.""" - - def test_schema_list(self, capsys): - """Test schema list command.""" - result = main(["schema", "list"]) - captured = capsys.readouterr() - assert result == EXIT_VALID - assert "0.6.1" in captured.out or "Available" in captured.out - - def test_schema_info(self, capsys): - """Test schema info command.""" - result = main(["schema", "info", "--version", "0.6.1"]) - captured = capsys.readouterr() - # Should return valid or error depending on implementation - assert result in (EXIT_VALID, EXIT_ERROR) - - -class TestCompletionsCommand: - """Tests for completions command.""" - - def test_completions_bash(self, capsys): - """Test bash completions generation.""" - result = main(["completions", "bash"]) - captured = capsys.readouterr() - assert result == EXIT_VALID - assert "_dppvalidator_completions" in captured.out - assert "complete -F" in captured.out - - def test_completions_zsh(self, capsys): - """Test zsh completions generation.""" - result = main(["completions", "zsh"]) - captured = capsys.readouterr() - assert result == EXIT_VALID - assert "#compdef dppvalidator" in captured.out - assert "_dppvalidator" in captured.out - - def test_completions_fish(self, capsys): - """Test fish completions generation.""" - result = main(["completions", "fish"]) - captured = capsys.readouterr() - assert result == EXIT_VALID - assert "complete -c dppvalidator" in captured.out - assert "__fish_use_subcommand" in captured.out - - -class TestCLIExitCodes: - """Tests for CLI exit codes.""" - - def test_exit_valid_constant(self): - """Test EXIT_VALID constant.""" - assert EXIT_VALID == 0 - - def test_exit_invalid_constant(self): - """Test EXIT_INVALID constant.""" - assert EXIT_INVALID == 1 - - def test_exit_error_constant(self): - """Test EXIT_ERROR constant.""" - assert EXIT_ERROR == 2 - - -class TestSchemaCommandExtended: - """Extended tests for schema command.""" - - def test_schema_no_subcommand(self, capsys): - """Test schema with no subcommand.""" - result = main(["schema"]) - captured = capsys.readouterr() - assert result == 2 # EXIT_ERROR - assert "Usage" in captured.out - - def test_schema_list(self, capsys): - """Test schema list command.""" - result = main(["schema", "list"]) - captured = capsys.readouterr() - assert result == EXIT_VALID - assert "Available" in captured.out or "0.6" in captured.out - - -class TestValidateCommandExtended: - """Extended tests for validate command.""" - - def test_validate_with_table_format(self, tmp_path, capsys): - """Test validate with table format.""" - passport_data = { - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } - file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) - - result = main(["validate", str(file_path), "--format", "table"]) - assert result in (EXIT_VALID, EXIT_INVALID) - - def test_validate_invalid_json(self, tmp_path): - """Test validate with invalid JSON file.""" - file_path = tmp_path / "invalid.json" - file_path.write_text("not valid json") - - result = main(["validate", str(file_path)]) - assert result == EXIT_ERROR - - def test_validate_stdin(self, tmp_path, monkeypatch): - """Test validate from stdin.""" - import io - import sys - - passport_json = json.dumps( - { - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } - ) - monkeypatch.setattr(sys, "stdin", io.StringIO(passport_json)) - - result = main(["validate", "-"]) - assert result in (EXIT_VALID, EXIT_INVALID, EXIT_ERROR) - - -class TestExportCommandExtended: - """Extended tests for export command.""" - - def test_export_nonexistent_file(self): - """Test export with nonexistent file.""" - result = main(["export", "/nonexistent/file.json"]) - assert result == EXIT_ERROR - - def test_export_invalid_json(self, tmp_path): - """Test export with invalid JSON.""" - file_path = tmp_path / "invalid.json" - file_path.write_text("not json") - - result = main(["export", str(file_path)]) - assert result == EXIT_ERROR - - def test_export_with_format_jsonld(self, tmp_path, capsys): - """Test export with jsonld format.""" - passport_data = { - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } - file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) - - result = main(["export", str(file_path), "--format", "jsonld"]) - captured = capsys.readouterr() - assert result == EXIT_VALID - assert "@context" in captured.out - - -class TestCLIErrorHandling: - """Tests for CLI error handling.""" - - def test_main_verbose_error(self): - """Test verbose error output.""" - result = main(["--verbose", "validate", "/nonexistent/file.json"]) - assert result == EXIT_ERROR - - def test_main_quiet_mode(self, tmp_path): - """Test quiet mode.""" - passport_data = { - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } - file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) - - result = main(["--quiet", "validate", str(file_path)]) - assert result == EXIT_VALID - - -class TestSchemaCommandFullCoverage: - """Full coverage tests for schema command.""" - - def test_schema_list_via_main(self): - """Test schema list via main CLI.""" - result = main(["schema", "list"]) - assert result == EXIT_VALID - - def test_schema_info_with_context(self, capsys): - """Test schema info shows context info.""" - from dppvalidator.cli.commands.schema import _show_info - - result = _show_info("0.6.1") - captured = capsys.readouterr() - assert result == EXIT_VALID - assert "0.6.1" in captured.out - - def test_schema_info_unknown(self, capsys): - """Test schema info with unknown version.""" - from dppvalidator.cli.commands.schema import _show_info - - result = _show_info("9.9.9") - captured = capsys.readouterr() - assert result == 2 - assert "Unknown" in captured.out - - def test_schema_list_output(self, capsys): - """Test schema list output format.""" - from dppvalidator.cli.commands.schema import _list_schemas - - result = _list_schemas() - captured = capsys.readouterr() - assert result == EXIT_VALID - assert "Available" in captured.out diff --git a/mutants/tests/unit/test_exporters.py b/mutants/tests/unit/test_exporters.py deleted file mode 100644 index b21dab0..0000000 --- a/mutants/tests/unit/test_exporters.py +++ /dev/null @@ -1,432 +0,0 @@ -"""Tests for exporters.""" - -import json - -import pytest - -from dppvalidator.exporters import ( - ContextManager, - JSONExporter, - JSONLDExporter, - export_json, - export_jsonld, -) -from dppvalidator.models import CredentialIssuer, DigitalProductPassport - - -class TestContextManager: - """Tests for ContextManager.""" - - def test_default_version(self): - """Test default version is 0.6.1.""" - manager = ContextManager() - assert manager.version == "0.6.1" - - def test_get_context(self): - """Test getting context URLs.""" - manager = ContextManager() - context = manager.get_context() - assert "https://www.w3.org/ns/credentials/v2" in context - assert any("untp/dpp/0.6.1" in url for url in context) - - def test_get_type(self): - """Test getting type array.""" - manager = ContextManager() - types = manager.get_type() - assert "DigitalProductPassport" in types - assert "VerifiableCredential" in types - - def test_validate_context_valid(self): - """Test validating correct context.""" - manager = ContextManager() - context = manager.get_context() - assert manager.validate_context(context) is True - - def test_validate_context_invalid(self): - """Test validating incorrect context.""" - manager = ContextManager() - assert manager.validate_context(["https://wrong.context"]) is False - - def test_available_versions(self): - """Test listing available versions.""" - manager = ContextManager() - versions = manager.available_versions - assert "0.6.1" in versions - assert "0.6.0" in versions - - -class TestJSONLDExporter: - """Tests for JSONLDExporter.""" - - @pytest.fixture - def passport(self) -> DigitalProductPassport: - """Create test passport.""" - return DigitalProductPassport( - id="https://example.com/credentials/dpp-001", - issuer=CredentialIssuer( - id="https://example.com/issuers/001", - name="Example Company Ltd", - ), - ) - - def test_export_string(self, passport: DigitalProductPassport): - """Test exporting to string.""" - exporter = JSONLDExporter() - result = exporter.export(passport) - assert isinstance(result, str) - data = json.loads(result) - assert "@context" in data - - def test_export_dict(self, passport: DigitalProductPassport): - """Test exporting to dictionary.""" - exporter = JSONLDExporter() - result = exporter.export_dict(passport) - assert isinstance(result, dict) - assert "@context" in result - - def test_export_includes_context(self, passport: DigitalProductPassport): - """Test that export includes W3C VC context.""" - exporter = JSONLDExporter() - result = exporter.export_dict(passport) - assert "https://www.w3.org/ns/credentials/v2" in result["@context"] - - def test_export_compact(self, passport: DigitalProductPassport): - """Test compact export (no indentation).""" - exporter = JSONLDExporter() - result = exporter.export(passport, indent=None) - assert "\n" not in result - - def test_convenience_function(self, passport: DigitalProductPassport): - """Test export_jsonld convenience function.""" - result = export_jsonld(passport) - data = json.loads(result) - assert "@context" in data - - -class TestJSONExporter: - """Tests for JSONExporter.""" - - @pytest.fixture - def passport(self) -> DigitalProductPassport: - """Create test passport.""" - return DigitalProductPassport( - id="https://example.com/credentials/dpp-001", - issuer=CredentialIssuer( - id="https://example.com/issuers/001", - name="Example Company Ltd", - ), - ) - - def test_export_string(self, passport: DigitalProductPassport): - """Test exporting to string.""" - exporter = JSONExporter() - result = exporter.export(passport) - assert isinstance(result, str) - data = json.loads(result) - assert "issuer" in data - - def test_export_dict(self, passport: DigitalProductPassport): - """Test exporting to dictionary.""" - exporter = JSONExporter() - result = exporter.export_dict(passport) - assert isinstance(result, dict) - - def test_export_excludes_none(self, passport: DigitalProductPassport): - """Test that None values are excluded.""" - exporter = JSONExporter(exclude_none=True) - result = exporter.export_dict(passport) - assert result.get("credentialSubject") is None or "credentialSubject" not in result - - def test_convenience_function(self, passport: DigitalProductPassport): - """Test export_json convenience function.""" - result = export_json(passport) - data = json.loads(result) - assert "issuer" in data - - -class TestRoundTrip: - """Round-trip tests: parse → export → parse.""" - - def test_roundtrip_jsonld_minimal(self): - """Test round-trip with minimal passport via JSON-LD.""" - from dppvalidator.validators import ValidationEngine - - # Create original passport - original = DigitalProductPassport( - id="https://example.com/credentials/dpp-001", - issuer=CredentialIssuer( - id="https://example.com/issuers/001", - name="Round Trip Test Company", - ), - ) - - # Export to JSON-LD - exporter = JSONLDExporter() - exported_json = exporter.export(original) - - # Parse exported JSON - exported_data = json.loads(exported_json) - - # Validate and re-parse - engine = ValidationEngine() - result = engine.validate(exported_data) - - # Verify round-trip success - assert result.valid is True - assert result.passport is not None - assert str(result.passport.id) == str(original.id) - assert result.passport.issuer.name == original.issuer.name - - def test_roundtrip_json_minimal(self): - """Test round-trip with minimal passport via plain JSON.""" - from dppvalidator.validators import ValidationEngine - - original = DigitalProductPassport( - id="https://example.com/credentials/dpp-002", - issuer=CredentialIssuer( - id="https://example.com/issuers/002", - name="JSON Round Trip Co", - ), - ) - - # Export to JSON - exporter = JSONExporter() - exported_json = exporter.export(original) - - # Parse and validate - exported_data = json.loads(exported_json) - engine = ValidationEngine() - result = engine.validate(exported_data) - - assert result.valid is True - assert result.passport is not None - assert result.passport.issuer.name == original.issuer.name - - def test_roundtrip_with_product(self): - """Test round-trip with product data.""" - from dppvalidator.models import Product, ProductPassport - from dppvalidator.validators import ValidationEngine - - original = DigitalProductPassport( - id="https://example.com/credentials/dpp-003", - issuer=CredentialIssuer( - id="https://example.com/issuers/003", - name="Product Test Inc", - ), - credentialSubject=ProductPassport( - product=Product( - id="https://example.com/products/widget-001", - name="Premium Widget", - serialNumber="SN-12345", - ), - ), - ) - - # Export and re-parse - exporter = JSONLDExporter() - exported = exporter.export(original) - data = json.loads(exported) - - engine = ValidationEngine() - result = engine.validate(data) - - assert result.valid is True - assert result.passport is not None - assert result.passport.credential_subject is not None - assert result.passport.credential_subject.product is not None - assert result.passport.credential_subject.product.name == "Premium Widget" - assert result.passport.credential_subject.product.serial_number == "SN-12345" - - def test_roundtrip_with_materials(self): - """Test round-trip with materials data.""" - from dppvalidator.models import Material, ProductPassport - from dppvalidator.validators import ValidationEngine - - original = DigitalProductPassport( - id="https://example.com/credentials/dpp-004", - issuer=CredentialIssuer( - id="https://example.com/issuers/004", - name="Materials Test Ltd", - ), - credentialSubject=ProductPassport( - materialsProvenance=[ - Material(name="Steel", massFraction=0.6), - Material(name="Plastic", massFraction=0.4), - ], - ), - ) - - # Export and re-parse - exporter = JSONLDExporter() - exported = exporter.export(original) - data = json.loads(exported) - - engine = ValidationEngine() - result = engine.validate(data) - - assert result.valid is True - assert result.passport is not None - assert result.passport.credential_subject is not None - materials = result.passport.credential_subject.materials_provenance - assert materials is not None - assert len(materials) == 2 - assert materials[0].name == "Steel" - assert materials[0].mass_fraction == 0.6 - - def test_roundtrip_with_dates(self): - """Test round-trip preserves dates.""" - from datetime import datetime, timezone - - from dppvalidator.validators import ValidationEngine - - valid_from = datetime(2024, 1, 1, tzinfo=timezone.utc) - valid_until = datetime(2034, 12, 31, tzinfo=timezone.utc) - - original = DigitalProductPassport( - id="https://example.com/credentials/dpp-005", - issuer=CredentialIssuer( - id="https://example.com/issuers/005", - name="Dates Test Corp", - ), - validFrom=valid_from, - validUntil=valid_until, - ) - - exporter = JSONLDExporter() - exported = exporter.export(original) - data = json.loads(exported) - - engine = ValidationEngine() - result = engine.validate(data) - - assert result.valid is True - assert result.passport is not None - assert result.passport.valid_from is not None - assert result.passport.valid_until is not None - # Dates should be preserved (compare as dates) - assert result.passport.valid_from.year == 2024 - assert result.passport.valid_until.year == 2034 - - def test_roundtrip_dict_export(self): - """Test round-trip using export_dict.""" - from dppvalidator.validators import ValidationEngine - - original = DigitalProductPassport( - id="https://example.com/credentials/dpp-006", - issuer=CredentialIssuer( - id="https://example.com/issuers/006", - name="Dict Export Test", - ), - ) - - exporter = JSONLDExporter() - data = exporter.export_dict(original) - - engine = ValidationEngine() - result = engine.validate(data) - - assert result.valid is True - assert result.passport is not None - - def test_roundtrip_preserves_context(self): - """Test round-trip preserves @context.""" - original = DigitalProductPassport( - id="https://example.com/credentials/dpp-007", - issuer=CredentialIssuer( - id="https://example.com/issuers/007", - name="Context Test", - ), - ) - - exporter = JSONLDExporter() - data = exporter.export_dict(original) - - # Verify context is present - assert "@context" in data - assert "https://www.w3.org/ns/credentials/v2" in data["@context"] - - # Re-export should preserve context - from dppvalidator.validators import ValidationEngine - - engine = ValidationEngine() - result = engine.validate(data) - assert result.valid is True - - # Export again - if result.passport: - re_exported = exporter.export_dict(result.passport) - assert "@context" in re_exported - - -class TestExporterEdgeCases: - """Edge case tests for exporters.""" - - def test_jsonld_exporter_version(self): - """Test JSONLDExporter with specific version.""" - exporter = JSONLDExporter(version="0.6.0") - assert exporter.version == "0.6.0" - - def test_context_manager_unknown_version(self): - """Test ContextManager with unknown version falls back to default.""" - manager = ContextManager(version="99.99.99") - context = manager.get_context() - # Should fall back to default - assert len(context) > 0 - - def test_json_exporter_include_none(self): - """Test JSONExporter with exclude_none=False.""" - passport = DigitalProductPassport( - id="https://example.com/dpp", - issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - ) - exporter = JSONExporter(exclude_none=False) - data = exporter.export_dict(passport) - # Should include None values - assert data is not None - - def test_json_exporter_no_alias(self): - """Test JSONExporter with by_alias=False.""" - passport = DigitalProductPassport( - id="https://example.com/dpp", - issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - ) - exporter = JSONExporter() - data = exporter.export_dict(passport, by_alias=False) - # Uses snake_case field names - assert data is not None - - -class TestExportToFile: - """Tests for file export functionality.""" - - def test_jsonld_export_to_file(self, tmp_path): - """Test JSONLDExporter export_to_file.""" - passport = DigitalProductPassport( - id="https://example.com/dpp", - issuer=CredentialIssuer(id="https://example.com/issuer", name="File Test"), - ) - output_path = tmp_path / "output.jsonld" - - exporter = JSONLDExporter() - exporter.export_to_file(passport, output_path) - - assert output_path.exists() - content = output_path.read_text() - data = json.loads(content) - assert "@context" in data - - def test_json_export_to_file(self, tmp_path): - """Test JSONExporter export_to_file.""" - passport = DigitalProductPassport( - id="https://example.com/dpp", - issuer=CredentialIssuer(id="https://example.com/issuer", name="File Test"), - ) - output_path = tmp_path / "output.json" - - exporter = JSONExporter() - exporter.export_to_file(passport, output_path) - - assert output_path.exists() - content = output_path.read_text() - data = json.loads(content) - assert "issuer" in data diff --git a/mutants/tests/unit/test_models.py b/mutants/tests/unit/test_models.py deleted file mode 100644 index 0a723cc..0000000 --- a/mutants/tests/unit/test_models.py +++ /dev/null @@ -1,585 +0,0 @@ -"""Tests for UNTP DPP models.""" - -import pytest -from pydantic import ValidationError - -from dppvalidator.models import ( - CircularityPerformance, - Claim, - Classification, - ConformityTopic, - CredentialIssuer, - Criterion, - CriterionStatus, - DigitalProductPassport, - Dimension, - EmissionsPerformance, - EncryptionMethod, - Facility, - GranularityLevel, - HashMethod, - IdentifierScheme, - Link, - Material, - Measure, - Metric, - OperationalScope, - Party, - Product, - ProductPassport, - Regulation, - SecureLink, - Standard, - TraceabilityPerformance, -) - - -class TestMeasure: - """Tests for Measure model.""" - - def test_valid_measure(self): - """Test creating a valid measure.""" - measure = Measure(value=100.0, unit="KGM") - assert measure.value == 100.0 - assert measure.unit == "KGM" - - def test_measure_to_jsonld(self): - """Test JSON-LD serialization.""" - measure = Measure(value=50.5, unit="LTR") - jsonld = measure.to_jsonld() - assert jsonld["type"] == ["Measure"] - assert jsonld["value"] == 50.5 - - -class TestMaterial: - """Tests for Material model.""" - - def test_valid_material(self): - """Test creating a valid material.""" - material = Material(name="Lithium Iron Phosphate") - assert material.name == "Lithium Iron Phosphate" - - def test_material_with_mass_fraction(self): - """Test material with mass fraction.""" - material = Material( - name="Steel", - massFraction=0.6, - originCountry="DE", - ) - assert material.mass_fraction == 0.6 - assert material.origin_country == "DE" - - def test_hazardous_without_safety_info(self): - """Test hazardous material requires safety info.""" - with pytest.raises(ValidationError, match="materialSafetyInformation"): - Material(name="Hazardous Chemical", hazardous=True) - - def test_hazardous_with_safety_info(self): - """Test hazardous material with safety info.""" - material = Material( - name="Hazardous Chemical", - hazardous=True, - materialSafetyInformation={"linkURL": "https://example.com/msds"}, - ) - assert material.hazardous is True - - -class TestCredentialIssuer: - """Tests for CredentialIssuer model.""" - - def test_valid_issuer(self): - """Test creating a valid issuer.""" - issuer = CredentialIssuer( - id="https://example.com/issuers/001", - name="Example Company Ltd", - ) - assert issuer.name == "Example Company Ltd" - - def test_issuer_to_jsonld(self): - """Test JSON-LD serialization.""" - issuer = CredentialIssuer( - id="https://example.com/issuers/001", - name="Test Corp", - ) - jsonld = issuer.to_jsonld() - assert "CredentialIssuer" in jsonld["type"] - - -class TestProduct: - """Tests for Product model.""" - - def test_valid_product(self): - """Test creating a valid product.""" - product = Product( - id="https://example.com/products/001", - name="EV Battery", - ) - assert product.name == "EV Battery" - - def test_product_with_serial_number(self): - """Test product with serial number.""" - product = Product( - id="https://example.com/products/002", - name="Battery Pack", - serialNumber="SN-2024-001", - batchNumber="BATCH-Q1", - ) - assert product.serial_number == "SN-2024-001" - - -class TestDigitalProductPassport: - """Tests for DigitalProductPassport model.""" - - @pytest.fixture - def minimal_dpp_data(self) -> dict: - """Minimal valid DPP data fixture.""" - return { - "id": "https://example.com/credentials/dpp-001", - "issuer": { - "id": "https://example.com/issuers/001", - "name": "Example Company Ltd", - }, - } - - def test_minimal_dpp(self, minimal_dpp_data: dict): - """Test creating a minimal valid DPP.""" - dpp = DigitalProductPassport(**minimal_dpp_data) - assert dpp.issuer.name == "Example Company Ltd" - - def test_dpp_default_context(self, minimal_dpp_data: dict): - """Test DPP has default context.""" - dpp = DigitalProductPassport(**minimal_dpp_data) - assert "https://www.w3.org/ns/credentials/v2" in dpp.context - - def test_dpp_with_dates(self, minimal_dpp_data: dict): - """Test DPP with validity dates.""" - minimal_dpp_data["validFrom"] = "2024-01-01T00:00:00Z" - minimal_dpp_data["validUntil"] = "2034-01-01T00:00:00Z" - dpp = DigitalProductPassport(**minimal_dpp_data) - assert dpp.valid_from is not None - assert dpp.valid_until is not None - - def test_dpp_invalid_date_order(self, minimal_dpp_data: dict): - """Test DPP rejects validFrom >= validUntil.""" - minimal_dpp_data["validFrom"] = "2034-01-01T00:00:00Z" - minimal_dpp_data["validUntil"] = "2024-01-01T00:00:00Z" - with pytest.raises(ValidationError, match="validFrom"): - DigitalProductPassport(**minimal_dpp_data) - - def test_dpp_to_jsonld(self, minimal_dpp_data: dict): - """Test JSON-LD serialization.""" - dpp = DigitalProductPassport(**minimal_dpp_data) - jsonld = dpp.to_jsonld(include_context=True) - assert "@context" in jsonld - assert "DigitalProductPassport" in jsonld["type"] - - -class TestEnums: - """Tests for enumeration types.""" - - def test_conformity_topic_values(self): - """Test ConformityTopic enum values.""" - assert ConformityTopic.ENVIRONMENT_EMISSIONS.value == "environment.emissions" - assert ConformityTopic.SOCIAL_LABOUR.value == "social.labour" - - def test_granularity_level_values(self): - """Test GranularityLevel enum values.""" - assert GranularityLevel.ITEM.value == "item" - assert GranularityLevel.BATCH.value == "batch" - assert GranularityLevel.MODEL.value == "model" - - def test_operational_scope_values(self): - """Test OperationalScope enum values.""" - assert OperationalScope.CRADLE_TO_GATE.value == "CradleToGate" - - def test_hash_method_values(self): - """Test HashMethod enum values.""" - assert HashMethod.SHA_256.value == "SHA-256" - - def test_encryption_method_values(self): - """Test EncryptionMethod enum values.""" - assert EncryptionMethod.AES.value == "AES" - - def test_criterion_status_values(self): - """Test CriterionStatus enum values.""" - assert CriterionStatus.ACTIVE.value == "active" - - -class TestLink: - """Tests for Link model.""" - - def test_link_with_url(self): - """Test creating a link with URL.""" - link = Link(linkURL="https://example.com/doc") - assert str(link.link_url) == "https://example.com/doc" - - def test_link_with_name(self): - """Test link with display name.""" - link = Link(linkURL="https://example.com/doc", linkName="Documentation") - assert link.link_name == "Documentation" - - def test_link_to_jsonld(self): - """Test JSON-LD serialization.""" - link = Link(linkURL="https://example.com/doc") - jsonld = link.to_jsonld() - assert jsonld["type"] == ["Link"] - - -class TestSecureLink: - """Tests for SecureLink model.""" - - def test_secure_link_with_hash(self): - """Test secure link with hash.""" - link = SecureLink( - linkURL="https://example.com/file.pdf", - hashDigest="abc123", - hashMethod=HashMethod.SHA_256, - ) - assert link.hash_digest == "abc123" - assert link.hash_method == HashMethod.SHA_256 - - def test_secure_link_to_jsonld(self): - """Test JSON-LD serialization.""" - link = SecureLink(linkURL="https://example.com/file.pdf") - jsonld = link.to_jsonld() - assert "SecureLink" in jsonld["type"] - assert "Link" in jsonld["type"] - - -class TestClassification: - """Tests for Classification model.""" - - def test_valid_classification(self): - """Test creating a valid classification.""" - classification = Classification( - id="https://vocabulary.example.com/class/001", - name="Electronics", - code="ELEC", - ) - assert classification.name == "Electronics" - assert classification.code == "ELEC" - - def test_classification_to_jsonld(self): - """Test JSON-LD serialization.""" - classification = Classification( - id="https://vocabulary.example.com/class/001", - name="Electronics", - ) - jsonld = classification.to_jsonld() - assert jsonld["type"] == ["Classification"] - - -class TestIdentifierScheme: - """Tests for IdentifierScheme model.""" - - def test_valid_identifier_scheme(self): - """Test creating a valid identifier scheme.""" - scheme = IdentifierScheme( - id="https://id.gs1.org/", - name="GS1 Global Trade Item Number", - ) - assert scheme.name == "GS1 Global Trade Item Number" - - def test_identifier_scheme_to_jsonld(self): - """Test JSON-LD serialization.""" - scheme = IdentifierScheme(name="DUNS") - jsonld = scheme.to_jsonld() - assert jsonld["type"] == ["IdentifierScheme"] - - -class TestParty: - """Tests for Party model.""" - - def test_valid_party(self): - """Test creating a valid party.""" - party = Party( - id="https://example.com/parties/001", - name="Example Corporation", - ) - assert party.name == "Example Corporation" - - def test_party_with_registered_id(self): - """Test party with registered ID.""" - party = Party( - id="https://example.com/parties/001", - name="Example Corp", - registeredId="REG-12345", - ) - assert party.registered_id == "REG-12345" - - def test_party_to_jsonld(self): - """Test JSON-LD serialization.""" - party = Party(id="https://example.com/parties/001", name="Corp") - jsonld = party.to_jsonld() - assert jsonld["type"] == ["Party"] - - -class TestFacility: - """Tests for Facility model.""" - - def test_valid_facility(self): - """Test creating a valid facility.""" - facility = Facility( - id="https://example.com/facilities/001", - name="Manufacturing Plant A", - ) - assert facility.name == "Manufacturing Plant A" - - def test_facility_to_jsonld(self): - """Test JSON-LD serialization.""" - facility = Facility(id="https://example.com/facilities/001", name="Plant A") - jsonld = facility.to_jsonld() - assert jsonld["type"] == ["Facility"] - - -class TestMetric: - """Tests for Metric model.""" - - def test_valid_metric(self): - """Test creating a valid metric.""" - metric = Metric( - metricName="Carbon Footprint", - metricValue={"value": 25.5, "unit": "KGM"}, - ) - assert metric.metric_name == "Carbon Footprint" - assert metric.metric_value.value == 25.5 - - def test_metric_with_accuracy(self): - """Test metric with accuracy.""" - metric = Metric( - metricName="Energy Usage", - metricValue={"value": 100, "unit": "KWH"}, - accuracy=0.95, - ) - assert metric.accuracy == 0.95 - - def test_metric_accuracy_bounds(self): - """Test metric accuracy must be 0-1.""" - with pytest.raises(ValidationError): - Metric( - metricName="Test", - metricValue={"value": 1, "unit": "EA"}, - accuracy=1.5, - ) - - -class TestEmissionsPerformance: - """Tests for EmissionsPerformance model.""" - - def test_valid_emissions(self): - """Test creating valid emissions performance.""" - emissions = EmissionsPerformance( - carbonFootprint=25.5, - declaredUnit="KGM", - operationalScope=OperationalScope.CRADLE_TO_GATE, - primarySourcedRatio=0.8, - ) - assert emissions.carbon_footprint == 25.5 - assert emissions.operational_scope == OperationalScope.CRADLE_TO_GATE - - def test_emissions_ratio_bounds(self): - """Test primary sourced ratio must be 0-1.""" - with pytest.raises(ValidationError): - EmissionsPerformance( - carbonFootprint=25.5, - declaredUnit="KGM", - operationalScope=OperationalScope.CRADLE_TO_GATE, - primarySourcedRatio=1.5, - ) - - def test_emissions_to_jsonld(self): - """Test JSON-LD serialization.""" - emissions = EmissionsPerformance( - carbonFootprint=25.5, - declaredUnit="KGM", - operationalScope=OperationalScope.CRADLE_TO_GATE, - primarySourcedRatio=0.8, - ) - jsonld = emissions.to_jsonld() - assert jsonld["type"] == ["EmissionsPerformance"] - - -class TestCircularityPerformance: - """Tests for CircularityPerformance model.""" - - def test_valid_circularity(self): - """Test creating valid circularity performance.""" - circularity = CircularityPerformance( - recyclableContent=0.85, - recycledContent=0.3, - utilityFactor=1.2, - ) - assert circularity.recyclable_content == 0.85 - assert circularity.recycled_content == 0.3 - - def test_circularity_content_bounds(self): - """Test recyclable content must be 0-1.""" - with pytest.raises(ValidationError): - CircularityPerformance(recyclableContent=1.5) - - def test_circularity_to_jsonld(self): - """Test JSON-LD serialization.""" - circularity = CircularityPerformance(recycledContent=0.3) - jsonld = circularity.to_jsonld() - assert jsonld["type"] == ["CircularityPerformance"] - - -class TestTraceabilityPerformance: - """Tests for TraceabilityPerformance model.""" - - def test_valid_traceability(self): - """Test creating valid traceability performance.""" - traceability = TraceabilityPerformance( - valueChainProcess="Manufacturing", - verifiedRatio=0.9, - ) - assert traceability.value_chain_process == "Manufacturing" - assert traceability.verified_ratio == 0.9 - - def test_traceability_to_jsonld(self): - """Test JSON-LD serialization.""" - traceability = TraceabilityPerformance(verifiedRatio=0.8) - jsonld = traceability.to_jsonld() - assert jsonld["type"] == ["TraceabilityPerformance"] - - -class TestCriterion: - """Tests for Criterion model.""" - - def test_valid_criterion(self): - """Test creating a valid criterion.""" - criterion = Criterion( - id="https://example.com/criteria/001", - name="Energy Efficiency", - description="Minimum energy efficiency requirements", - conformityTopic=ConformityTopic.ENVIRONMENT_ENERGY, - status=CriterionStatus.ACTIVE, - ) - assert criterion.name == "Energy Efficiency" - assert criterion.conformity_topic == ConformityTopic.ENVIRONMENT_ENERGY - - def test_criterion_to_jsonld(self): - """Test JSON-LD serialization.""" - criterion = Criterion( - id="https://example.com/criteria/001", - name="Test", - description="Test criterion", - conformityTopic=ConformityTopic.ENVIRONMENT_ENERGY, - status=CriterionStatus.ACTIVE, - ) - jsonld = criterion.to_jsonld() - assert jsonld["type"] == ["Criterion"] - - -class TestStandard: - """Tests for Standard model.""" - - def test_valid_standard(self): - """Test creating a valid standard.""" - standard = Standard( - id="https://iso.org/14001", - name="ISO 14001", - issuingParty={ - "id": "https://iso.org", - "name": "ISO", - }, - ) - assert standard.name == "ISO 14001" - - def test_standard_to_jsonld(self): - """Test JSON-LD serialization.""" - standard = Standard( - name="ISO 14001", - issuingParty={"id": "https://iso.org", "name": "ISO"}, - ) - jsonld = standard.to_jsonld() - assert jsonld["type"] == ["Standard"] - - -class TestRegulation: - """Tests for Regulation model.""" - - def test_valid_regulation(self): - """Test creating a valid regulation.""" - regulation = Regulation( - id="https://ec.europa.eu/espr", - name="EU ESPR", - administeredBy={ - "id": "https://ec.europa.eu", - "name": "European Commission", - }, - jurisdictionCountry="EU", - ) - assert regulation.name == "EU ESPR" - assert regulation.jurisdiction_country == "EU" - - def test_regulation_to_jsonld(self): - """Test JSON-LD serialization.""" - regulation = Regulation( - name="ESPR", - administeredBy={"id": "https://ec.europa.eu", "name": "EC"}, - ) - jsonld = regulation.to_jsonld() - assert jsonld["type"] == ["Regulation"] - - -class TestClaim: - """Tests for Claim model.""" - - def test_valid_claim(self): - """Test creating a valid claim.""" - claim = Claim( - id="https://example.com/claims/001", - conformance=True, - conformityTopic=ConformityTopic.ENVIRONMENT_EMISSIONS, - ) - assert claim.conformance is True - assert claim.conformity_topic == ConformityTopic.ENVIRONMENT_EMISSIONS - - def test_claim_to_jsonld(self): - """Test JSON-LD serialization has both types.""" - claim = Claim( - id="https://example.com/claims/001", - conformance=True, - conformityTopic=ConformityTopic.ENVIRONMENT_EMISSIONS, - ) - jsonld = claim.to_jsonld() - assert "Claim" in jsonld["type"] - assert "Declaration" in jsonld["type"] - - -class TestProductPassport: - """Tests for ProductPassport model.""" - - def test_valid_product_passport(self): - """Test creating a valid product passport.""" - passport = ProductPassport( - id="https://example.com/passports/001", - granularityLevel=GranularityLevel.ITEM, - ) - assert passport.granularity_level == GranularityLevel.ITEM - - def test_product_passport_to_jsonld(self): - """Test JSON-LD serialization.""" - passport = ProductPassport() - jsonld = passport.to_jsonld() - assert jsonld["type"] == ["ProductPassport"] - - -class TestDimension: - """Tests for Dimension model.""" - - def test_valid_dimension(self): - """Test creating a valid dimension.""" - dimension = Dimension( - length={"value": 100, "unit": "CMT"}, - width={"value": 50, "unit": "CMT"}, - height={"value": 25, "unit": "CMT"}, - ) - assert dimension.length.value == 100 - - def test_dimension_to_jsonld(self): - """Test JSON-LD serialization.""" - dimension = Dimension() - jsonld = dimension.to_jsonld() - assert jsonld["type"] == ["Dimension"] diff --git a/mutants/tests/unit/test_plugins.py b/mutants/tests/unit/test_plugins.py deleted file mode 100644 index 89770a8..0000000 --- a/mutants/tests/unit/test_plugins.py +++ /dev/null @@ -1,337 +0,0 @@ -"""Tests for plugin architecture.""" - -import pytest - -from dppvalidator.models import CredentialIssuer, DigitalProductPassport -from dppvalidator.plugins import ( - EXPORTER_ENTRY_POINT, - VALIDATOR_ENTRY_POINT, - PluginRegistry, - discover_exporters, - discover_validators, - get_default_registry, - list_available_plugins, - reset_default_registry, -) - - -class TestPluginDiscovery: - """Tests for plugin discovery functions.""" - - def test_validator_entry_point_constant(self): - """Test validator entry point constant.""" - assert VALIDATOR_ENTRY_POINT == "dppvalidator.validators" - - def test_exporter_entry_point_constant(self): - """Test exporter entry point constant.""" - assert EXPORTER_ENTRY_POINT == "dppvalidator.exporters" - - def test_discover_validators_returns_iterator(self): - """Test discover_validators returns an iterator.""" - result = discover_validators() - assert hasattr(result, "__iter__") - - def test_discover_exporters_returns_iterator(self): - """Test discover_exporters returns an iterator.""" - result = discover_exporters() - assert hasattr(result, "__iter__") - - def test_list_available_plugins_returns_dict(self): - """Test list_available_plugins returns dictionary.""" - result = list_available_plugins() - assert isinstance(result, dict) - assert "validators" in result - assert "exporters" in result - - -class TestPluginRegistry: - """Tests for PluginRegistry.""" - - def test_registry_init_no_auto_discover(self): - """Test registry initialization without auto-discovery.""" - registry = PluginRegistry(auto_discover=False) - assert registry.validator_count == 0 - assert registry.exporter_count == 0 - - def test_register_validator(self): - """Test manual validator registration.""" - registry = PluginRegistry(auto_discover=False) - - class MockValidator: - rule_id = "MOCK" - description = "Mock validator" - severity = "warning" - - def check(self, passport): - return [] - - registry.register_validator("mock", MockValidator()) - assert "mock" in registry.validator_names - assert registry.validator_count == 1 - - def test_register_exporter(self): - """Test manual exporter registration.""" - registry = PluginRegistry(auto_discover=False) - - class MockExporter: - def export(self, passport): - return "" - - registry.register_exporter("mock", MockExporter()) - assert "mock" in registry.exporter_names - assert registry.exporter_count == 1 - - def test_get_validator(self): - """Test getting a registered validator.""" - registry = PluginRegistry(auto_discover=False) - - class MockValidator: - pass - - registry.register_validator("mock", MockValidator()) - result = registry.get_validator("mock") - assert result is not None - - def test_get_validator_not_found(self): - """Test getting a non-existent validator.""" - registry = PluginRegistry(auto_discover=False) - result = registry.get_validator("nonexistent") - assert result is None - - def test_get_exporter(self): - """Test getting a registered exporter.""" - registry = PluginRegistry(auto_discover=False) - - class MockExporter: - pass - - registry.register_exporter("mock", MockExporter()) - result = registry.get_exporter("mock") - assert result is not None - - def test_get_exporter_not_found(self): - """Test getting a non-existent exporter.""" - registry = PluginRegistry(auto_discover=False) - result = registry.get_exporter("nonexistent") - assert result is None - - def test_unregister_validator(self): - """Test unregistering a validator.""" - registry = PluginRegistry(auto_discover=False) - - class MockValidator: - pass - - registry.register_validator("mock", MockValidator()) - assert registry.unregister_validator("mock") is True - assert registry.validator_count == 0 - - def test_unregister_validator_not_found(self): - """Test unregistering a non-existent validator.""" - registry = PluginRegistry(auto_discover=False) - assert registry.unregister_validator("nonexistent") is False - - def test_unregister_exporter(self): - """Test unregistering an exporter.""" - registry = PluginRegistry(auto_discover=False) - - class MockExporter: - pass - - registry.register_exporter("mock", MockExporter()) - assert registry.unregister_exporter("mock") is True - assert registry.exporter_count == 0 - - def test_unregister_exporter_not_found(self): - """Test unregistering a non-existent exporter.""" - registry = PluginRegistry(auto_discover=False) - assert registry.unregister_exporter("nonexistent") is False - - -class TestRunAllValidators: - """Tests for running plugin validators.""" - - @pytest.fixture - def passport(self) -> DigitalProductPassport: - """Create test passport.""" - return DigitalProductPassport( - id="https://example.com/dpp", - issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - ) - - def test_run_all_validators_empty_registry(self, passport): - """Test running validators on empty registry.""" - registry = PluginRegistry(auto_discover=False) - errors = registry.run_all_validators(passport) - assert errors == [] - - def test_run_all_validators_with_passing_rule(self, passport): - """Test running validators that pass.""" - registry = PluginRegistry(auto_discover=False) - - class PassingRule: - rule_id = "PASS" - description = "Always passes" - severity = "error" - - def check(self, p): - return [] - - registry.register_validator("passing", PassingRule()) - errors = registry.run_all_validators(passport) - assert len(errors) == 0 - - def test_run_all_validators_with_failing_rule(self, passport): - """Test running validators that produce violations.""" - registry = PluginRegistry(auto_discover=False) - - class FailingRule: - rule_id = "FAIL" - description = "Always fails" - severity = "error" - - def check(self, p): - return [("$.path", "Error message")] - - registry.register_validator("failing", FailingRule()) - errors = registry.run_all_validators(passport) - assert len(errors) == 1 - assert errors[0].code == "FAIL" - assert errors[0].layer == "plugin" - - def test_run_all_validators_with_class(self, passport): - """Test running validators registered as classes.""" - registry = PluginRegistry(auto_discover=False) - - class ClassRule: - rule_id = "CLASS" - description = "Class-based rule" - severity = "warning" - - def check(self, p): - return [("$.test", "Class rule violation")] - - # Register class, not instance - registry.register_validator("class_rule", ClassRule) - errors = registry.run_all_validators(passport) - assert len(errors) == 1 - assert errors[0].severity == "warning" - - def test_run_all_validators_handles_exceptions(self, passport): - """Test that plugin exceptions are caught.""" - registry = PluginRegistry(auto_discover=False) - - class BrokenRule: - rule_id = "BROKEN" - description = "Throws exception" - severity = "error" - - def check(self, p): - raise RuntimeError("Plugin error") - - registry.register_validator("broken", BrokenRule()) - errors = registry.run_all_validators(passport) - assert len(errors) == 1 - assert errors[0].code == "PLG_ERROR" - assert "Plugin error" in errors[0].message - - def test_run_all_validators_multiple_rules(self, passport): - """Test running multiple validators.""" - registry = PluginRegistry(auto_discover=False) - - class Rule1: - rule_id = "R1" - severity = "error" - - def check(self, p): - return [("$.r1", "Rule 1")] - - class Rule2: - rule_id = "R2" - severity = "warning" - - def check(self, p): - return [("$.r2", "Rule 2")] - - registry.register_validator("r1", Rule1()) - registry.register_validator("r2", Rule2()) - errors = registry.run_all_validators(passport) - assert len(errors) == 2 - - -class TestDefaultRegistry: - """Tests for default registry singleton.""" - - def teardown_method(self): - """Reset registry after each test.""" - reset_default_registry() - - def test_get_default_registry_returns_registry(self): - """Test getting default registry.""" - registry = get_default_registry() - assert isinstance(registry, PluginRegistry) - - def test_get_default_registry_is_singleton(self): - """Test that default registry is a singleton.""" - registry1 = get_default_registry() - registry2 = get_default_registry() - assert registry1 is registry2 - - def test_reset_default_registry(self): - """Test resetting the default registry.""" - registry1 = get_default_registry() - reset_default_registry() - registry2 = get_default_registry() - assert registry1 is not registry2 - - -class TestRegistryProperties: - """Tests for registry properties.""" - - def test_validator_names(self): - """Test validator_names property.""" - registry = PluginRegistry(auto_discover=False) - - class MockValidator: - pass - - registry.register_validator("v1", MockValidator()) - registry.register_validator("v2", MockValidator()) - names = registry.validator_names - assert "v1" in names - assert "v2" in names - - def test_exporter_names(self): - """Test exporter_names property.""" - registry = PluginRegistry(auto_discover=False) - - class MockExporter: - pass - - registry.register_exporter("e1", MockExporter()) - registry.register_exporter("e2", MockExporter()) - names = registry.exporter_names - assert "e1" in names - assert "e2" in names - - def test_validator_count(self): - """Test validator_count property.""" - registry = PluginRegistry(auto_discover=False) - assert registry.validator_count == 0 - - class MockValidator: - pass - - registry.register_validator("v1", MockValidator()) - assert registry.validator_count == 1 - - def test_exporter_count(self): - """Test exporter_count property.""" - registry = PluginRegistry(auto_discover=False) - assert registry.exporter_count == 0 - - class MockExporter: - pass - - registry.register_exporter("e1", MockExporter()) - assert registry.exporter_count == 1 diff --git a/mutants/tests/unit/test_schemas.py b/mutants/tests/unit/test_schemas.py deleted file mode 100644 index d411b80..0000000 --- a/mutants/tests/unit/test_schemas.py +++ /dev/null @@ -1,233 +0,0 @@ -"""Tests for schema registry and loader.""" - -import pytest - -from dppvalidator.schemas import ( - DEFAULT_SCHEMA_VERSION, - SchemaLoader, - SchemaRegistry, - SchemaVersion, -) - - -class TestSchemaVersion: - """Tests for SchemaVersion.""" - - def test_verify_integrity_no_hash(self): - """Test integrity verification with no hash.""" - version = SchemaVersion( - version="0.6.1", - url="https://example.com/schema.json", - sha256=None, - context_urls=(), - ) - assert version.verify_integrity(b"any content") is True - - def test_verify_integrity_matching_hash(self): - """Test integrity verification with matching hash.""" - import hashlib - - content = b'{"test": "schema"}' - expected_hash = hashlib.sha256(content).hexdigest() - - version = SchemaVersion( - version="0.6.1", - url="https://example.com/schema.json", - sha256=expected_hash, - context_urls=(), - ) - assert version.verify_integrity(content) is True - - def test_verify_integrity_mismatched_hash(self): - """Test integrity verification with wrong hash.""" - version = SchemaVersion( - version="0.6.1", - url="https://example.com/schema.json", - sha256="wronghash", - context_urls=(), - ) - assert version.verify_integrity(b"any content") is False - - -class TestSchemaRegistry: - """Tests for SchemaRegistry.""" - - def test_default_version(self): - """Test default version is 0.6.1.""" - registry = SchemaRegistry() - assert registry.default_version == "0.6.1" - - def test_available_versions(self): - """Test listing available versions.""" - registry = SchemaRegistry() - versions = registry.available_versions - assert "0.6.1" in versions - assert "0.6.0" in versions - - def test_get_schema(self): - """Test getting schema definition.""" - registry = SchemaRegistry() - schema = registry.get_schema("0.6.1") - assert schema.version == "0.6.1" - assert "untp-dpp-schema-0.6.1" in schema.url - - def test_get_schema_default(self): - """Test getting default schema.""" - registry = SchemaRegistry() - schema = registry.get_schema() - assert schema.version == DEFAULT_SCHEMA_VERSION - - def test_get_schema_unknown(self): - """Test getting unknown version raises.""" - registry = SchemaRegistry() - with pytest.raises(ValueError, match="Unknown schema version"): - registry.get_schema("9.9.9") - - def test_get_context_urls(self): - """Test getting context URLs.""" - registry = SchemaRegistry() - urls = registry.get_context_urls("0.6.1") - assert "https://www.w3.org/ns/credentials/v2" in urls - assert any("untp/dpp/0.6.1" in url for url in urls) - - -class TestSchemaLoader: - """Tests for SchemaLoader.""" - - def test_init(self): - """Test loader initialization.""" - loader = SchemaLoader() - assert loader.timeout_seconds == 30.0 - - def test_init_custom_params(self, tmp_path): - """Test loader initialization with custom params.""" - loader = SchemaLoader(cache_dir=tmp_path, timeout_seconds=10.0) - assert loader.timeout_seconds == 10.0 - assert loader._cache_dir == tmp_path - - def test_load_schema_unknown_version(self): - """Test loading unknown version raises.""" - loader = SchemaLoader() - with pytest.raises(ValueError, match="Unknown schema version"): - loader.load_schema("9.9.9") - - def test_clear_cache(self, tmp_path): - """Test clearing cache.""" - loader = SchemaLoader(cache_dir=tmp_path) - # Create a cache file - tmp_path.mkdir(parents=True, exist_ok=True) - (tmp_path / "test.json").write_text("{}") - loader.clear_cache() - # Memory cache should be empty - assert loader._schema_cache == {} - - def test_load_schema_uses_memory_cache(self, tmp_path): - """Test that memory cache is used.""" - loader = SchemaLoader(cache_dir=tmp_path) - # Pre-populate memory cache - loader._schema_cache["0.6.1"] = {"$schema": "test"} - result = loader.load_schema("0.6.1") - assert result == {"$schema": "test"} - - def test_load_schema_default_version(self, tmp_path): - """Test loading default version.""" - loader = SchemaLoader(cache_dir=tmp_path) - # Pre-populate cache - loader._schema_cache[DEFAULT_SCHEMA_VERSION] = {"default": True} - result = loader.load_schema() # No version specified - assert result == {"default": True} - - def test_load_local_nonexistent(self, tmp_path): - """Test _load_local with nonexistent file.""" - loader = SchemaLoader(cache_dir=tmp_path) - schema_def = SchemaVersion( - version="0.6.1", - url="https://example.com/schema.json", - sha256=None, - context_urls=(), - ) - result = loader._load_local(schema_def) - assert result is None - - def test_load_cached_nonexistent(self, tmp_path): - """Test _load_cached with nonexistent file.""" - loader = SchemaLoader(cache_dir=tmp_path) - schema_def = SchemaVersion( - version="0.6.1", - url="https://example.com/schema.json", - sha256=None, - context_urls=(), - ) - result = loader._load_cached(schema_def) - assert result is None - - def test_load_cached_valid(self, tmp_path): - """Test _load_cached with valid cache file.""" - loader = SchemaLoader(cache_dir=tmp_path) - schema_def = SchemaVersion( - version="test", - url="https://example.com/schema.json", - sha256=None, # No integrity check - context_urls=(), - ) - # Create cache file - tmp_path.mkdir(parents=True, exist_ok=True) - cache_file = tmp_path / "untp-dpp-schema-test.json" - cache_file.write_text('{"cached": true}') - - result = loader._load_cached(schema_def) - assert result == {"cached": True} - - def test_load_cached_integrity_failure(self, tmp_path): - """Test _load_cached with integrity check failure.""" - loader = SchemaLoader(cache_dir=tmp_path) - schema_def = SchemaVersion( - version="test", - url="https://example.com/schema.json", - sha256="wronghash", # Will fail integrity - context_urls=(), - ) - # Create cache file - tmp_path.mkdir(parents=True, exist_ok=True) - cache_file = tmp_path / "untp-dpp-schema-test.json" - cache_file.write_text('{"cached": true}') - - result = loader._load_cached(schema_def) - assert result is None - # File should be deleted - assert not cache_file.exists() - - def test_cache_to_disk(self, tmp_path): - """Test _cache_to_disk.""" - loader = SchemaLoader(cache_dir=tmp_path) - content = b'{"test": true}' - loader._cache_to_disk("0.6.1", content) - cache_file = tmp_path / "untp-dpp-schema-0.6.1.json" - assert cache_file.exists() - assert cache_file.read_bytes() == content - - def test_download_schema_no_httpx(self, tmp_path, monkeypatch): - """Test download_schema without httpx.""" - import dppvalidator.schemas.loader as loader_module - - monkeypatch.setattr(loader_module, "HAS_HTTPX", False) - - loader = SchemaLoader(cache_dir=tmp_path) - with pytest.raises(RuntimeError, match="httpx required"): - loader.download_schema("0.6.1", tmp_path) - - def test_fetch_remote_no_httpx(self, tmp_path, monkeypatch): - """Test _fetch_remote without httpx.""" - import dppvalidator.schemas.loader as loader_module - - monkeypatch.setattr(loader_module, "HAS_HTTPX", False) - - loader = SchemaLoader(cache_dir=tmp_path) - schema_def = SchemaVersion( - version="0.6.1", - url="https://example.com/schema.json", - sha256=None, - context_urls=(), - ) - result = loader._fetch_remote(schema_def) - assert result is None diff --git a/mutants/tests/unit/test_validators.py b/mutants/tests/unit/test_validators.py deleted file mode 100644 index f3b9407..0000000 --- a/mutants/tests/unit/test_validators.py +++ /dev/null @@ -1,1010 +0,0 @@ -"""Tests for validation engine and validators.""" - -import asyncio -import json -import tempfile -from pathlib import Path - -import pytest - -from dppvalidator.models import CredentialIssuer, DigitalProductPassport -from dppvalidator.validators import ValidationEngine, ValidationResult -from dppvalidator.validators.model import ModelValidator -from dppvalidator.validators.results import ValidationError, ValidationException -from dppvalidator.validators.rules.base import ( - CircularityContentRule, - ConformityClaimRule, - GranularitySerialNumberRule, - HazardousMaterialRule, - MassFractionSumRule, - OperationalScopeRule, - ValidityDateRule, -) -from dppvalidator.validators.schema import SchemaValidator -from dppvalidator.validators.semantic import SemanticValidator - -FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" - - -class TestValidationResult: - """Tests for ValidationResult.""" - - def test_empty_result_is_valid(self): - """Test that empty result is valid.""" - result = ValidationResult(valid=True, schema_version="0.6.1") - assert result.valid is True - assert len(result.errors) == 0 - - def test_result_with_error_is_invalid(self): - """Test that result with error is invalid.""" - errors = [ - ValidationError( - path="$.issuer", - message="Missing required field", - code="REQUIRED", - layer="model", - ) - ] - result = ValidationResult(valid=False, errors=errors, schema_version="0.6.1") - assert result.valid is False - assert len(result.errors) == 1 - - def test_result_with_warning_is_valid(self): - """Test that result with warning only is still valid.""" - warnings = [ - ValidationError( - path="$.field", - message="Recommended field missing", - code="SEM005", - layer="semantic", - severity="warning", - ) - ] - result = ValidationResult(valid=True, warnings=warnings, schema_version="0.6.1") - assert result.valid is True - assert len(result.warnings) == 1 - - def test_result_to_json(self): - """Test JSON serialization.""" - result = ValidationResult(valid=True, schema_version="0.6.1") - json_str = result.to_json() - data = json.loads(json_str) - assert data["valid"] is True - assert data["schema_version"] == "0.6.1" - - def test_result_merge(self): - """Test merging two results.""" - result1 = ValidationResult( - valid=False, - errors=[ValidationError(path="$.a", message="Error A", code="E1", layer="model")], - schema_version="0.6.1", - ) - result2 = ValidationResult( - valid=False, - errors=[ValidationError(path="$.b", message="Error B", code="E2", layer="model")], - schema_version="0.6.1", - ) - merged = result1.merge(result2) - assert len(merged.errors) == 2 - - -class TestValidationEngine: - """Tests for ValidationEngine.""" - - @pytest.fixture - def engine(self) -> ValidationEngine: - """Create validation engine.""" - return ValidationEngine(schema_version="0.6.1") - - def test_validate_minimal_valid_dpp(self, engine: ValidationEngine): - """Test validating a minimal valid DPP.""" - data = { - "id": "https://example.com/credentials/dpp-001", - "issuer": { - "id": "https://example.com/issuers/001", - "name": "Example Company Ltd", - }, - } - result = engine.validate(data) - assert result.valid is True - - def test_validate_missing_issuer(self, engine: ValidationEngine): - """Test validation fails for missing issuer.""" - data = { - "id": "https://example.com/credentials/dpp-001", - } - result = engine.validate(data) - assert result.valid is False - assert len(result.errors) > 0 - - def test_validate_invalid_json(self, engine: ValidationEngine): - """Test validation handles invalid data gracefully.""" - result = engine.validate("not valid json data") - assert result.valid is False - - def test_validate_with_fail_fast(self, engine: ValidationEngine): - """Test fail_fast stops on first error.""" - data = {"invalid": "data"} - result = engine.validate(data, fail_fast=True) - assert result.valid is False - assert len(result.all_issues) >= 1 - - def test_validate_fixture_valid(self, engine: ValidationEngine): - """Test validating valid fixture.""" - fixture_path = FIXTURES_DIR / "valid" / "minimal_dpp.json" - if fixture_path.exists(): - data = json.loads(fixture_path.read_text()) - result = engine.validate(data) - assert result.valid is True - - def test_validate_fixture_invalid(self, engine: ValidationEngine): - """Test validating invalid fixture.""" - fixture_path = FIXTURES_DIR / "invalid" / "missing_issuer.json" - if fixture_path.exists(): - data = json.loads(fixture_path.read_text()) - result = engine.validate(data) - assert result.valid is False - - -class TestSemanticRules: - """Tests for semantic validation rules.""" - - @pytest.fixture - def engine(self) -> ValidationEngine: - """Create validation engine.""" - return ValidationEngine(schema_version="0.6.1") - - def test_sem002_invalid_date_order(self, engine: ValidationEngine): - """Test SEM002: validFrom must be before validUntil.""" - data = { - "id": "https://example.com/credentials/dpp-001", - "issuer": { - "id": "https://example.com/issuers/001", - "name": "Example Company Ltd", - }, - "validFrom": "2034-01-01T00:00:00Z", - "validUntil": "2024-01-01T00:00:00Z", - } - result = engine.validate(data) - assert result.valid is False - - def test_sem001_mass_fraction_sum(self, engine: ValidationEngine): - """Test SEM001: mass fractions must sum to 1.0.""" - data = { - "id": "https://example.com/credentials/dpp-001", - "issuer": { - "id": "https://example.com/issuers/001", - "name": "Example Company Ltd", - }, - "credentialSubject": { - "materialsProvenance": [ - {"name": "Material A", "massFraction": 0.3}, - {"name": "Material B", "massFraction": 0.3}, - ], - }, - } - result = engine.validate(data) - # SEM001 only fires if passport is valid and has materials - # If model validation fails, semantic rules don't run - assert result is not None - - -class TestValidationError: - """Tests for ValidationError dataclass.""" - - def test_error_to_dict(self): - """Test ValidationError to_dict method.""" - error = ValidationError( - path="$.issuer", - message="Missing field", - code="E001", - layer="model", - severity="error", - context={"type": "missing"}, - ) - d = error.to_dict() - assert d["path"] == "$.issuer" - assert d["message"] == "Missing field" - assert d["code"] == "E001" - assert d["layer"] == "model" - assert d["context"]["type"] == "missing" - - -class TestValidationResultExtended: - """Extended tests for ValidationResult.""" - - def test_error_count(self): - """Test error_count property.""" - result = ValidationResult( - valid=False, - errors=[ - ValidationError(path="$.a", message="A", code="E1", layer="model"), - ValidationError(path="$.b", message="B", code="E2", layer="model"), - ], - schema_version="0.6.1", - ) - assert result.error_count == 2 - - def test_warning_count(self): - """Test warning_count property.""" - result = ValidationResult( - valid=True, - warnings=[ - ValidationError( - path="$.a", message="A", code="W1", layer="model", severity="warning" - ), - ], - schema_version="0.6.1", - ) - assert result.warning_count == 1 - - def test_all_issues(self): - """Test all_issues property combines all.""" - result = ValidationResult( - valid=False, - errors=[ValidationError(path="$.a", message="A", code="E1", layer="model")], - warnings=[ - ValidationError( - path="$.b", message="B", code="W1", layer="model", severity="warning" - ) - ], - info=[ - ValidationError(path="$.c", message="C", code="I1", layer="model", severity="info") - ], - schema_version="0.6.1", - ) - assert len(result.all_issues) == 3 - - def test_raise_for_errors(self): - """Test raise_for_errors raises ValidationException.""" - result = ValidationResult( - valid=False, - errors=[ValidationError(path="$.a", message="Error", code="E1", layer="model")], - schema_version="0.6.1", - ) - with pytest.raises(ValidationException) as exc_info: - result.raise_for_errors() - assert exc_info.value.result == result - assert "Error" in str(exc_info.value) - - def test_raise_for_errors_valid(self): - """Test raise_for_errors does nothing for valid result.""" - result = ValidationResult(valid=True, schema_version="0.6.1") - result.raise_for_errors() # Should not raise - - -class TestValidationEngineExtended: - """Extended tests for ValidationEngine.""" - - @pytest.fixture - def engine(self) -> ValidationEngine: - """Create validation engine.""" - return ValidationEngine(schema_version="0.6.1") - - def test_validate_file(self, engine: ValidationEngine): - """Test validate_file method.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump( - { - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - }, - f, - ) - f.flush() - result = engine.validate_file(f.name) - assert result.valid is True - - def test_validate_file_not_found(self, engine: ValidationEngine): - """Test validate_file with non-existent file.""" - result = engine.validate(Path("/non/existent/file.json")) - assert result.valid is False - assert any("File not found" in e.message for e in result.errors) - - def test_validate_file_invalid_json(self, engine: ValidationEngine): - """Test validate_file with invalid JSON.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - f.write("not valid json {{{") - f.flush() - result = engine.validate_file(f.name) - assert result.valid is False - assert any("Invalid JSON" in e.message for e in result.errors) - - def test_validate_string_invalid_json(self, engine: ValidationEngine): - """Test validate with invalid JSON string.""" - result = engine.validate("{invalid json") - assert result.valid is False - assert any("Invalid JSON" in e.message for e in result.errors) - - def test_validate_async(self, engine: ValidationEngine): - """Test async validation.""" - data = { - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } - result = asyncio.run(engine.validate_async(data)) - assert result.valid is True - - def test_validate_batch(self, engine: ValidationEngine): - """Test batch validation.""" - items = [ - {"id": "https://example.com/dpp1", "issuer": {"id": "https://a.com", "name": "A"}}, - {"id": "https://example.com/dpp2", "issuer": {"id": "https://b.com", "name": "B"}}, - ] - results = asyncio.run(engine.validate_batch(items, concurrency=2)) - assert len(results) == 2 - assert all(r.valid for r in results) - - def test_validate_with_max_errors(self, engine: ValidationEngine): - """Test validation stops at max_errors.""" - data = {"invalid": "data"} - result = engine.validate(data, max_errors=1) - assert result.valid is False - - def test_engine_layers_config(self): - """Test engine with specific layers.""" - engine = ValidationEngine(layers=["model"]) - data = {"id": "https://example.com/dpp", "issuer": {"id": "https://a.com", "name": "A"}} - result = engine.validate(data) - assert result is not None - - def test_engine_strict_mode(self): - """Test engine with strict mode.""" - engine = ValidationEngine(strict_mode=True) - assert engine.strict_mode is True - - -class TestModelValidator: - """Tests for ModelValidator.""" - - def test_validate_valid_data(self): - """Test validating valid data.""" - validator = ModelValidator() - data = {"id": "https://example.com/dpp", "issuer": {"id": "https://a.com", "name": "A"}} - result = validator.validate(data) - assert result.valid is True - assert result.passport is not None - - def test_validate_invalid_data(self): - """Test validating invalid data.""" - validator = ModelValidator() - data = {"invalid": "data"} - result = validator.validate(data) - assert result.valid is False - assert len(result.errors) > 0 - - def test_loc_to_path_with_index(self): - """Test _loc_to_path with array index.""" - validator = ModelValidator() - path = validator._loc_to_path(("items", 0, "name")) - assert path == "$.items[0].name" - - def test_safe_input_with_long_string(self): - """Test _safe_input truncates long values.""" - validator = ModelValidator() - long_dict = {"key": "x" * 200} - result = validator._safe_input(long_dict) - assert "..." in result - - def test_safe_input_with_none(self): - """Test _safe_input with None.""" - validator = ModelValidator() - assert validator._safe_input(None) is None - - def test_safe_input_with_primitives(self): - """Test _safe_input with primitive types.""" - validator = ModelValidator() - assert validator._safe_input("test") == "test" - assert validator._safe_input(42) == 42 - assert validator._safe_input(3.14) == 3.14 - assert validator._safe_input(True) is True - - -class TestSchemaValidator: - """Tests for SchemaValidator.""" - - def test_validate_without_jsonschema(self): - """Test validation when jsonschema not available returns warning.""" - validator = SchemaValidator() - data = {"id": "test"} - result = validator.validate(data) - # Either passes with warning (no jsonschema) or validates - assert result is not None - - def test_validate_with_custom_schema_path(self): - """Test validator with custom schema path.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump({"type": "object"}, f) - f.flush() - validator = SchemaValidator(schema_path=Path(f.name)) - result = validator.validate({"test": "data"}) - assert result is not None - - -class TestSemanticValidator: - """Tests for SemanticValidator.""" - - @pytest.fixture - def valid_passport(self) -> DigitalProductPassport: - """Create a valid passport for testing.""" - return DigitalProductPassport( - id="https://example.com/dpp", - issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - ) - - def test_validate_with_custom_rules(self, valid_passport: DigitalProductPassport): - """Test validator with custom rules.""" - validator = SemanticValidator(rules=[]) - result = validator.validate(valid_passport) - assert result.valid is True - assert len(result.errors) == 0 - - def test_validate_with_default_rules(self, valid_passport: DigitalProductPassport): - """Test validator with default rules.""" - validator = SemanticValidator() - result = validator.validate(valid_passport) - # Valid passport may have info messages but no errors - assert result is not None - - -class TestSemanticRulesDetailed: - """Detailed tests for individual semantic rules.""" - - @pytest.fixture - def valid_passport(self) -> DigitalProductPassport: - """Create a valid passport.""" - return DigitalProductPassport( - id="https://example.com/dpp", - issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - ) - - def test_mass_fraction_rule_no_subject(self, valid_passport: DigitalProductPassport): - """Test MassFractionSumRule with no credential subject.""" - rule = MassFractionSumRule() - violations = rule.check(valid_passport) - assert len(violations) == 0 - - def test_validity_date_rule_valid_dates(self, valid_passport: DigitalProductPassport): - """Test ValidityDateRule with valid date ordering.""" - rule = ValidityDateRule() - violations = rule.check(valid_passport) - assert len(violations) == 0 - - def test_hazardous_material_rule_no_materials(self, valid_passport: DigitalProductPassport): - """Test HazardousMaterialRule with no materials.""" - rule = HazardousMaterialRule() - violations = rule.check(valid_passport) - assert len(violations) == 0 - - def test_circularity_content_rule_no_scorecard(self, valid_passport: DigitalProductPassport): - """Test CircularityContentRule with no scorecard.""" - rule = CircularityContentRule() - violations = rule.check(valid_passport) - assert len(violations) == 0 - - def test_conformity_claim_rule_no_claims(self, valid_passport: DigitalProductPassport): - """Test ConformityClaimRule with no claims.""" - rule = ConformityClaimRule() - violations = rule.check(valid_passport) - # No credential subject means no violation - assert len(violations) == 0 - - def test_granularity_serial_rule_no_product(self, valid_passport: DigitalProductPassport): - """Test GranularitySerialNumberRule with no product.""" - rule = GranularitySerialNumberRule() - violations = rule.check(valid_passport) - assert len(violations) == 0 - - def test_operational_scope_rule_no_scorecard(self, valid_passport: DigitalProductPassport): - """Test OperationalScopeRule with no emissions scorecard.""" - rule = OperationalScopeRule() - violations = rule.check(valid_passport) - assert len(violations) == 0 - - -class TestSemanticRulesWithViolations: - """Tests for semantic rules that produce violations.""" - - def test_mass_fraction_sum_violation(self): - """Test MassFractionSumRule detects invalid sum via engine.""" - # Test via the engine to verify the full flow - # Model validation catches this before semantic rules run - engine = ValidationEngine() - data = { - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - "credentialSubject": { - "materialsProvenance": [ - {"name": "A", "massFraction": 0.3}, - {"name": "B", "massFraction": 0.3}, - ] - }, - } - result = engine.validate(data) - # Model validator catches mass fraction sum error - assert result.valid is False - - def test_validity_date_rule_violation(self): - """Test ValidityDateRule detects invalid date order via engine.""" - # Model validation catches this before semantic rules run - engine = ValidationEngine() - data = { - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - "validFrom": "2034-01-01T00:00:00Z", - "validUntil": "2024-01-01T00:00:00Z", - } - result = engine.validate(data) - # Model validator catches date order error - assert result.valid is False - assert any("validFrom" in e.message for e in result.errors) - - def test_circularity_content_violation(self): - """Test CircularityContentRule detects recycled > recyclable.""" - from dppvalidator.models import CircularityPerformance, ProductPassport - - passport = DigitalProductPassport( - id="https://example.com/dpp", - issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - credentialSubject=ProductPassport( - circularityScorecard=CircularityPerformance( - recycledContent=0.8, - recyclableContent=0.5, - ) - ), - ) - rule = CircularityContentRule() - violations = rule.check(passport) - assert len(violations) == 1 - assert "exceeds" in violations[0][1] - - def test_conformity_claim_info(self): - """Test ConformityClaimRule emits info for missing claims.""" - from dppvalidator.models import ProductPassport - - passport = DigitalProductPassport( - id="https://example.com/dpp", - issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - credentialSubject=ProductPassport(), - ) - rule = ConformityClaimRule() - violations = rule.check(passport) - assert len(violations) == 1 - assert "conformity claims" in violations[0][1].lower() - - def test_granularity_serial_number_warning(self): - """Test GranularitySerialNumberRule warning for item without serial.""" - from dppvalidator.models import GranularityLevel, Product, ProductPassport - - passport = DigitalProductPassport( - id="https://example.com/dpp", - issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - credentialSubject=ProductPassport( - granularityLevel=GranularityLevel.ITEM, - product=Product(id="https://example.com/product", name="Test Product"), - ), - ) - rule = GranularitySerialNumberRule() - violations = rule.check(passport) - assert len(violations) == 1 - assert "serialNumber" in violations[0][1] - - def test_operational_scope_warning(self): - """Test OperationalScopeRule warning for missing scope.""" - from dppvalidator.models import EmissionsPerformance, OperationalScope, ProductPassport - - passport = DigitalProductPassport( - id="https://example.com/dpp", - issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - credentialSubject=ProductPassport( - emissionsScorecard=EmissionsPerformance( - carbonFootprint=25.5, - declaredUnit="KGM", - operationalScope=OperationalScope.NONE, - primarySourcedRatio=0.8, - ) - ), - ) - rule = OperationalScopeRule() - # Since scope is set (even if NONE), no violation - violations = rule.check(passport) - # Check the rule runs without error - assert violations is not None - - -class TestSemanticValidatorSeverities: - """Tests for semantic validator handling different severities.""" - - def test_validator_separates_severities(self): - """Test validator correctly separates errors, warnings, info.""" - from dppvalidator.models import ProductPassport - - passport = DigitalProductPassport( - id="https://example.com/dpp", - issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - credentialSubject=ProductPassport(), - ) - validator = SemanticValidator() - result = validator.validate(passport) - # ConformityClaimRule should emit an info message - assert result.info is not None or result.warnings is not None or result.errors is not None - - -class TestSchemaValidatorExtended: - """Extended tests for SchemaValidator.""" - - def test_schema_validation_with_valid_data(self): - """Test schema validation with valid data.""" - validator = SchemaValidator() - data = { - "id": "https://example.com/dpp", - "@context": ["https://www.w3.org/ns/credentials/v2"], - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } - result = validator.validate(data) - # Result should exist regardless of jsonschema availability - assert result is not None - - def test_schema_validator_error_to_path(self): - """Test _error_to_path method.""" - validator = SchemaValidator() - - # Create a mock error object - class MockError: - absolute_path = ["items", 0, "name"] - - path = validator._error_to_path(MockError()) - assert path == "$.items[0].name" - - -class TestEngineEdgeCases: - """Edge case tests for ValidationEngine.""" - - def test_validate_with_only_semantic_layer(self): - """Test validation with only semantic layer.""" - engine = ValidationEngine(layers=["semantic"]) - data = {"id": "https://example.com/dpp", "issuer": {"id": "https://a.com", "name": "A"}} - # Without model layer, semantic validation won't run - result = engine.validate(data) - assert result is not None - - def test_validate_with_only_schema_layer(self): - """Test validation with only schema layer.""" - engine = ValidationEngine(layers=["schema"]) - data = {"id": "https://example.com/dpp"} - result = engine.validate(data) - assert result is not None - - -class TestProtocols: - """Tests for validator protocols.""" - - def test_validator_protocol_compliance(self): - """Test that ModelValidator implements Validator protocol.""" - from dppvalidator.validators.protocols import Validator - - validator = ModelValidator() - assert isinstance(validator, Validator) - - def test_semantic_rule_protocol_compliance(self): - """Test that rules implement SemanticRule protocol.""" - from dppvalidator.validators.protocols import SemanticRule - - rule = MassFractionSumRule() - assert isinstance(rule, SemanticRule) - assert rule.rule_id == "SEM001" - assert rule.severity == "error" - assert rule.description is not None - - def test_schema_validator_implements_validator(self): - """Test SchemaValidator implements Validator protocol.""" - from dppvalidator.validators.protocols import Validator - - validator = SchemaValidator() - assert isinstance(validator, Validator) - - -class TestHazardousMaterialRule: - """Tests for HazardousMaterialRule.""" - - def test_hazardous_without_safety_info(self): - """Test hazardous material without safety info via engine.""" - engine = ValidationEngine() - data = { - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - "credentialSubject": { - "materialsProvenance": [ - {"name": "Chemical X", "hazardous": True}, - ] - }, - } - result = engine.validate(data) - # Model validator catches hazardous without safety info - assert result.valid is False - - def test_hazardous_with_safety_info(self): - """Test hazardous material with safety info passes.""" - engine = ValidationEngine() - data = { - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - "credentialSubject": { - "materialsProvenance": [ - { - "name": "Chemical X", - "hazardous": True, - "materialSafetyInformation": {"linkURL": "https://example.com/msds"}, - }, - ] - }, - } - result = engine.validate(data) - # This should pass hazardous validation - assert result is not None - - -class TestSchemaValidatorWithJsonSchema: - """Tests for SchemaValidator with jsonschema library.""" - - def test_load_schema_from_docs(self): - """Test loading schema from docs directory.""" - validator = SchemaValidator() - # Trigger schema loading - schema = validator._load_schema() - assert schema is not None - - def test_get_validator_returns_validator(self): - """Test _get_validator returns a validator or None.""" - validator = SchemaValidator() - v = validator._get_validator() - # v may be None if jsonschema not available, or a validator - assert v is None or hasattr(v, "iter_errors") - - def test_schema_validation_errors(self): - """Test schema validation produces errors for invalid data.""" - validator = SchemaValidator() - # Missing required fields - data = {"type": ["DigitalProductPassport"]} - result = validator.validate(data) - # Result exists regardless of jsonschema availability - assert result is not None - - -class TestModelValidatorEdgeCases: - """Edge case tests for ModelValidator.""" - - def test_safe_input_with_object(self): - """Test _safe_input with custom object.""" - validator = ModelValidator() - - class CustomObj: - def __str__(self): - return "x" * 200 - - result = validator._safe_input(CustomObj()) - assert len(result) <= 100 - - -class TestSemanticValidatorWarningsAndInfo: - """Tests for semantic validator with warnings and info.""" - - def test_warning_rule_produces_warning(self): - """Test a warning severity rule produces a warning.""" - from dppvalidator.models import GranularityLevel, Product, ProductPassport - - passport = DigitalProductPassport( - id="https://example.com/dpp", - issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - credentialSubject=ProductPassport( - granularityLevel=GranularityLevel.ITEM, - product=Product(id="https://example.com/product", name="Test"), - ), - ) - validator = SemanticValidator() - result = validator.validate(passport) - # Should have warnings from GranularitySerialNumberRule - assert len(result.warnings) > 0 or len(result.info) > 0 - - def test_info_rule_produces_info(self): - """Test an info severity rule produces info.""" - from dppvalidator.models import ProductPassport - - passport = DigitalProductPassport( - id="https://example.com/dpp", - issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - credentialSubject=ProductPassport(), - ) - validator = SemanticValidator() - result = validator.validate(passport) - # Should have info from ConformityClaimRule - assert len(result.info) > 0 - - -class TestSchemaValidatorFullCoverage: - """Full coverage tests for SchemaValidator.""" - - def test_load_schema_with_cache(self): - """Test schema caching on second load.""" - validator = SchemaValidator() - schema1 = validator._load_schema() - schema2 = validator._load_schema() - assert schema1 is schema2 # Same cached object - - def test_validate_with_schema_errors(self): - """Test validation with schema that produces errors.""" - validator = SchemaValidator() - # Force schema load - validator._load_schema() - # Validate invalid data - result = validator.validate({"@context": "invalid"}) - # Should return a result - assert result is not None - - def test_validator_with_empty_schema(self): - """Test validation when schema is empty.""" - validator = SchemaValidator() - validator._schema = {} - v = validator._get_validator() - # Empty schema may return None validator - assert v is None or v is not None - - -class TestEngineFullCoverage: - """Full coverage tests for ValidationEngine.""" - - def test_engine_max_errors_stops_early(self): - """Test engine stops collecting errors at max_errors.""" - engine = ValidationEngine() - data = {} # Invalid - missing required fields - result = engine.validate(data, max_errors=1) - assert result.valid is False - - def test_engine_fail_fast_in_schema_layer(self): - """Test fail_fast stops after schema layer.""" - engine = ValidationEngine(layers=["schema", "model"]) - data = {} - result = engine.validate(data, fail_fast=True) - assert result is not None - - def test_validate_with_all_layers_disabled(self): - """Test validation with empty layers list returns result.""" - engine = ValidationEngine(layers=[]) - data = {"id": "https://example.com/dpp", "issuer": {"id": "https://a.com", "name": "A"}} - result = engine.validate(data) - # With no layers, validation returns True - assert result is not None - - def test_semantic_layer_skipped_without_passport(self): - """Test semantic layer skipped when model parsing fails.""" - engine = ValidationEngine(layers=["model", "semantic"]) - data = {} # Will fail model validation - result = engine.validate(data) - # Semantic rules shouldn't run since passport is None - assert result.valid is False - - -class TestSchemaValidatorMoreCoverage: - """More coverage tests for SchemaValidator.""" - - def test_schema_validator_custom_path(self, tmp_path): - """Test SchemaValidator with custom schema path.""" - from dppvalidator.validators.schema import SchemaValidator - - # Create a minimal schema - schema_file = tmp_path / "schema.json" - schema_file.write_text('{"type": "object"}') - - validator = SchemaValidator(schema_path=schema_file) - result = validator.validate({"id": "test"}) - assert result is not None - - def test_schema_validator_load_schema_cached(self, tmp_path): - """Test that schema is cached after first load.""" - from dppvalidator.validators.schema import SchemaValidator - - schema_file = tmp_path / "schema.json" - schema_file.write_text('{"type": "object"}') - - validator = SchemaValidator(schema_path=schema_file) - # First load - schema1 = validator._load_schema() - # Second load should return same cached object - schema2 = validator._load_schema() - assert schema1 is schema2 - - def test_schema_validator_error_to_path_with_array(self): - """Test _error_to_path with array indices.""" - from dppvalidator.validators.schema import SchemaValidator - - validator = SchemaValidator() - - # Create mock error with array path - class MockError: - absolute_path = ["items", 0, "name"] - - path = validator._error_to_path(MockError()) - assert path == "$.items[0].name" - - -class TestRulesFullCoverage: - """Full coverage tests for semantic rules.""" - - def test_mass_fraction_rule_with_valid_sum(self): - """Test MassFractionSumRule with valid sum.""" - from dppvalidator.models import Material, ProductPassport - - passport = DigitalProductPassport( - id="https://example.com/dpp", - issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - credentialSubject=ProductPassport( - materialsProvenance=[ - Material(name="A", massFraction=0.5), - Material(name="B", massFraction=0.5), - ] - ), - ) - rule = MassFractionSumRule() - violations = rule.check(passport) - assert len(violations) == 0 - - def test_mass_fraction_rule_no_fractions(self): - """Test MassFractionSumRule with materials but no fractions.""" - from dppvalidator.models import Material, ProductPassport - - passport = DigitalProductPassport( - id="https://example.com/dpp", - issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - credentialSubject=ProductPassport( - materialsProvenance=[ - Material(name="A"), - Material(name="B"), - ] - ), - ) - rule = MassFractionSumRule() - violations = rule.check(passport) - # No fractions means no violation - assert len(violations) == 0 - - def test_hazardous_material_rule_with_violation(self): - """Test HazardousMaterialRule directly.""" - from dppvalidator.models import Link, Material, ProductPassport - - # Create passport with hazardous material that has safety info - passport = DigitalProductPassport( - id="https://example.com/dpp", - issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - credentialSubject=ProductPassport( - materialsProvenance=[ - Material( - name="Safe Chemical", - hazardous=True, - materialSafetyInformation=Link(linkURL="https://example.com/msds"), - ), - ] - ), - ) - rule = HazardousMaterialRule() - violations = rule.check(passport) - # Has safety info, so no violation - assert len(violations) == 0 - - def test_operational_scope_rule_with_violation(self): - """Test OperationalScopeRule with missing scope.""" - # Test via engine since direct model construction enforces required fields - engine = ValidationEngine() - data = { - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - "credentialSubject": { - "emissionsScorecard": { - "carbonFootprint": 25.5, - "declaredUnit": "KGM", - "primarySourcedRatio": 0.8, - } - }, - } - result = engine.validate(data) - # Should fail model validation due to missing required fields - assert result is not None diff --git a/mutants/tests/unit/test_vocabularies.py b/mutants/tests/unit/test_vocabularies.py deleted file mode 100644 index 56611b7..0000000 --- a/mutants/tests/unit/test_vocabularies.py +++ /dev/null @@ -1,262 +0,0 @@ -"""Tests for vocabularies module.""" - -import time - -import pytest - -from dppvalidator.vocabularies import ( - VOCABULARIES, - CacheEntry, - VocabularyCache, - VocabularyDefinition, - VocabularyLoader, - get_bundled_country_codes, - get_bundled_unit_codes, -) - - -class TestVocabularyDefinition: - """Tests for VocabularyDefinition.""" - - def test_vocabulary_definition_attributes(self): - """Test VocabularyDefinition has required attributes.""" - vocab = VocabularyDefinition( - name="TestVocab", - url="https://example.com/vocab", - description="Test vocabulary", - ) - assert vocab.name == "TestVocab" - assert vocab.url == "https://example.com/vocab" - assert vocab.description == "Test vocabulary" - - def test_vocabularies_dict_exists(self): - """Test VOCABULARIES dictionary exists.""" - assert "CountryId" in VOCABULARIES - assert "UnitMeasureCode" in VOCABULARIES - - -class TestCacheEntry: - """Tests for CacheEntry.""" - - def test_cache_entry_creation(self): - """Test CacheEntry creation.""" - entry = CacheEntry( - data=frozenset(["A", "B"]), - fetched_at=time.time(), - expires_at=time.time() + 3600, - source_url="https://example.com", - ) - assert "A" in entry.data - assert "B" in entry.data - - -class TestVocabularyCache: - """Tests for VocabularyCache.""" - - def test_cache_init_default_dir(self): - """Test cache initialization with default directory.""" - cache = VocabularyCache() - assert cache.cache_dir is not None - assert cache.ttl_seconds == 24 * 3600 - - def test_cache_init_custom_dir(self, tmp_path): - """Test cache initialization with custom directory.""" - cache = VocabularyCache(cache_dir=tmp_path, ttl_hours=12) - assert cache.cache_dir == tmp_path - assert cache.ttl_seconds == 12 * 3600 - - def test_cache_set_and_get(self, tmp_path): - """Test setting and getting cache entries.""" - cache = VocabularyCache(cache_dir=tmp_path) - url = "https://example.com/vocab" - data = frozenset(["A", "B", "C"]) - - cache.set(url, data) - result = cache.get(url) - - assert result == data - - def test_cache_get_nonexistent(self, tmp_path): - """Test getting nonexistent cache entry.""" - cache = VocabularyCache(cache_dir=tmp_path) - result = cache.get("https://nonexistent.com") - assert result is None - - def test_cache_memory_cache(self, tmp_path): - """Test memory cache is used.""" - cache = VocabularyCache(cache_dir=tmp_path) - url = "https://example.com/vocab" - data = frozenset(["X", "Y"]) - - cache.set(url, data) - # Second get should come from memory - result = cache.get(url) - assert result == data - - def test_cache_clear(self, tmp_path): - """Test clearing cache.""" - cache = VocabularyCache(cache_dir=tmp_path) - url = "https://example.com/vocab" - data = frozenset(["A"]) - - cache.set(url, data) - cache.clear() - result = cache.get(url) - - assert result is None - - def test_cache_invalidate(self, tmp_path): - """Test invalidating single entry.""" - cache = VocabularyCache(cache_dir=tmp_path) - url = "https://example.com/vocab" - data = frozenset(["A"]) - - cache.set(url, data) - cache.invalidate(url) - result = cache.get(url) - - assert result is None - - def test_cache_expired_entry(self, tmp_path): - """Test expired entry is not returned.""" - cache = VocabularyCache(cache_dir=tmp_path, ttl_hours=0) - url = "https://example.com/vocab" - data = frozenset(["A"]) - - cache.set(url, data) - # Force expiration by waiting slightly - time.sleep(0.1) - cache._memory_cache.clear() # Clear memory cache to force disk read - result = cache.get(url) - - # TTL of 0 hours means immediate expiration - assert result is None - - def test_cache_path_generation(self, tmp_path): - """Test cache path is generated correctly.""" - cache = VocabularyCache(cache_dir=tmp_path) - url = "https://example.com/vocab" - path = cache._get_cache_path(url) - assert path.parent == tmp_path - assert path.suffix == ".json" - - -class TestVocabularyLoader: - """Tests for VocabularyLoader.""" - - def test_loader_init(self, tmp_path): - """Test loader initialization.""" - loader = VocabularyLoader( - cache_dir=tmp_path, - cache_ttl_hours=12, - offline_mode=True, - timeout_seconds=5.0, - ) - assert loader.offline_mode is True - assert loader.timeout_seconds == 5.0 - - def test_loader_available_vocabularies(self): - """Test available_vocabularies property.""" - loader = VocabularyLoader(offline_mode=True) - vocabs = loader.available_vocabularies - assert "CountryId" in vocabs - assert "UnitMeasureCode" in vocabs - - def test_loader_unknown_vocabulary(self): - """Test getting unknown vocabulary raises error.""" - loader = VocabularyLoader(offline_mode=True) - with pytest.raises(ValueError, match="Unknown vocabulary"): - loader.get_vocabulary("NonexistentVocab") - - def test_loader_is_valid_value_unknown_vocab(self): - """Test is_valid_value with unknown vocabulary.""" - loader = VocabularyLoader(offline_mode=True) - result = loader.is_valid_value("NonexistentVocab", "value") - assert result is False - - def test_loader_offline_mode_uses_fallback(self, tmp_path): - """Test offline mode uses fallback values.""" - loader = VocabularyLoader(cache_dir=tmp_path, offline_mode=True) - countries = loader.get_vocabulary("CountryId") - # Should return bundled fallback - assert isinstance(countries, frozenset) - - def test_loader_is_valid_country(self, tmp_path): - """Test is_valid_country convenience method.""" - loader = VocabularyLoader(cache_dir=tmp_path, offline_mode=True) - # Common country codes should be valid - assert loader.is_valid_country("US") or loader.is_valid_country("DE") or True - - def test_loader_is_valid_unit(self, tmp_path): - """Test is_valid_unit convenience method.""" - loader = VocabularyLoader(cache_dir=tmp_path, offline_mode=True) - # Check method exists and returns bool - result = loader.is_valid_unit("KGM") - assert isinstance(result, bool) - - def test_loader_clear_cache(self, tmp_path): - """Test clear_cache method.""" - loader = VocabularyLoader(cache_dir=tmp_path, offline_mode=True) - loader.get_vocabulary("CountryId") # Populate fallback tracking - loader.clear_cache() - # Should not raise - assert loader._fallback_used == set() - - def test_loader_cached_values(self, tmp_path): - """Test loader uses cached values.""" - loader = VocabularyLoader(cache_dir=tmp_path, offline_mode=True) - - # First call uses fallback - first = loader.get_vocabulary("CountryId") - # Second call should use cache if available - second = loader.get_vocabulary("CountryId") - - assert first == second - - def test_loader_extract_values_graph(self, tmp_path): - """Test _extract_values with @graph format.""" - loader = VocabularyLoader(cache_dir=tmp_path, offline_mode=True) - data = { - "@graph": [ - {"@id": "http://example.com#CODE1"}, - {"@id": "http://example.com#CODE2"}, - ] - } - result = loader._extract_values(data, "test") - assert result is not None - assert "CODE1" in result - assert "CODE2" in result - - def test_loader_extract_values_member(self, tmp_path): - """Test _extract_values with member format.""" - loader = VocabularyLoader(cache_dir=tmp_path, offline_mode=True) - data = { - "member": [ - {"notation": "A"}, - {"notation": "B"}, - ] - } - result = loader._extract_values(data, "test") - assert result is not None - assert "A" in result - assert "B" in result - - def test_loader_extract_values_empty(self, tmp_path): - """Test _extract_values with empty data.""" - loader = VocabularyLoader(cache_dir=tmp_path, offline_mode=True) - result = loader._extract_values({}, "test") - assert result is None - - -class TestBundledVocabularies: - """Tests for bundled vocabulary data.""" - - def test_get_bundled_country_codes(self): - """Test get_bundled_country_codes returns frozenset.""" - codes = get_bundled_country_codes() - assert isinstance(codes, frozenset) - - def test_get_bundled_unit_codes(self): - """Test get_bundled_unit_codes returns frozenset.""" - codes = get_bundled_unit_codes() - assert isinstance(codes, frozenset) diff --git a/pyproject.toml b/pyproject.toml index 1f3fdb5..a2f50c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dppvalidator" -version = "0.1.0" +version = "0.3.2" description = "Python library for validating Digital Product Passports (DPP) according to EU ESPR regulations and CIRPASS/UNECE ontologies" readme = "README.md" requires-python = ">=3.10" @@ -13,9 +13,21 @@ keywords = [ "cirpass", "validation", "pydantic", + "eu-regulation", + "sustainability", + "circular-economy", + "textile", + "battery-passport", + "untp", + "verifiable-credentials", + "json-ld", + "w3c", + "supply-chain", + "traceability", ] classifiers = [ "Development Status :: 4 - Beta", + "Environment :: Console", "Framework :: Pydantic :: 2", "Intended Audience :: Developers", "Intended Audience :: Manufacturing", @@ -27,22 +39,30 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Topic :: Internet :: WWW/HTTP :: Indexing/Search", "Topic :: Scientific/Engineering :: Information Analysis", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Software Development :: Quality Assurance", "Typing :: Typed", ] -dependencies = ["pydantic>=2.12.5"] +dependencies = [ + "pydantic>=2.12.5", + "httpx>=0.28.0", + "jsonschema>=4.23.0", + "pyld>=2.0.4", + "cryptography>=43.0.0", + "PyJWT>=2.9.0", + "base58>=2.1.0", +] [project.scripts] dppvalidator = "dppvalidator.cli:cli" dppvalidator-precommit = "dppvalidator.cli.commands.precommit:main" [project.optional-dependencies] -http = ["httpx>=0.28.0"] -jsonschema = ["jsonschema>=4.23.0"] cli = ["rich>=13.0.0"] -all = ["httpx>=0.28.0", "jsonschema>=4.23.0", "rich>=13.0.0"] +rdf = ["rdflib>=7.0.0", "pyshacl>=0.25.0"] +all = ["dppvalidator[cli,rdf]"] [project.urls] Homepage = "https://github.com/artiso-ai/dppvalidator" @@ -58,24 +78,43 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/dppvalidator"] +[tool.hatch.build.targets.wheel.sources] +"src" = "" + +[tool.hatch.build] +include = [ + "src/dppvalidator/**/*.py", + "src/dppvalidator/**/*.json", + "src/dppvalidator/**/*.ttl", + "src/dppvalidator/**/*.xsd", + "src/dppvalidator/**/*.yaml", +] + [dependency-groups] dev = [ + # Testing "pytest>=8.0.0", "pytest-cov>=4.1.0", "pytest-asyncio>=0.24.0", "hypothesis>=6.100.0", "mutmut>=3.0.0", + # Linting & Type Checking "ruff>=0.8.0", "ty>=0.0.1a0", "pre-commit>=3.6.0", + # Security & License Scanning "pip-audit>=2.7.0", - "httpx>=0.28.0", - "jsonschema>=4.23.0", + "pip-licenses>=5.0.0", + # SBOM Generation + "cyclonedx-bom>=7.0.0", + # Optional extras (include all for dev) "rich>=13.0.0", + "rdflib>=7.0.0", + "pyshacl>=0.25.0", ] docs = [ "mkdocs>=1.6.0", - "mkdocs-material>=9.5.0", + "mkdocs-material[imaging]>=9.5.0", "mkdocstrings[python]>=0.27.0", ] @@ -112,6 +151,12 @@ testpaths = ["tests"] python_files = ["test_*.py"] python_functions = ["test_*"] addopts = "-v --tb=short" +filterwarnings = [ + # rdflib internal deprecations (waiting for upstream fixes) + "ignore:ConjunctiveGraph is deprecated:DeprecationWarning:rdflib.plugins.parsers.jsonld", + "ignore::DeprecationWarning:rdflib.*", + "ignore::DeprecationWarning:pyparsing.*", +] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" @@ -130,3 +175,8 @@ exclude_lines = [ omit = [ "*/protocols.py", # Protocol definitions are abstract stubs ] + +[tool.mutmut] +paths_to_mutate = ["src/dppvalidator/validators/"] +tests_dir = "tests/unit/" +runner = "uv run pytest tests/unit/ -x --tb=no -q" diff --git a/scripts/check_error_docs.py b/scripts/check_error_docs.py new file mode 100644 index 0000000..9dc7f99 --- /dev/null +++ b/scripts/check_error_docs.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Check that all error codes in the codebase have documentation. + +This script scans the source code for error codes and verifies that each +has a corresponding documentation file in docs/errors/. + +Exit codes: + 0 - All error codes are documented + 1 - Missing documentation found +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +# Project root (resolve to absolute path for robustness in CI/CD) +PROJECT_ROOT = Path(__file__).resolve().parent.parent +SRC_DIR = PROJECT_ROOT / "src" / "dppvalidator" +DOCS_ERRORS_DIR = PROJECT_ROOT / "docs" / "errors" +MKDOCS_FILE = PROJECT_ROOT / "mkdocs.yml" + +# Error code pattern: 2-3 uppercase letters followed by 3 digits +ERROR_CODE_PATTERN = re.compile(r"\b([A-Z]{2,3}\d{3})\b") + +# Error code prefixes to check +KNOWN_PREFIXES = {"SCH", "PRS", "MDL", "SEM", "JLD", "VOC", "SIG", "CQ", "TXT"} + + +def find_error_codes_in_source() -> set[str]: + """Find all error codes defined in source code.""" + error_codes: set[str] = set() + + # Scan Python files in src + for py_file in SRC_DIR.rglob("*.py"): + content = py_file.read_text(encoding="utf-8") + + # Find all error code matches + for match in ERROR_CODE_PATTERN.finditer(content): + code = match.group(1) + prefix = "".join(c for c in code if c.isalpha()) + if prefix in KNOWN_PREFIXES: + error_codes.add(code) + + return error_codes + + +def find_documented_error_codes() -> set[str]: + """Find all error codes that have documentation files.""" + documented: set[str] = set() + + if not DOCS_ERRORS_DIR.exists(): + return documented + + for md_file in DOCS_ERRORS_DIR.glob("*.md"): + if md_file.stem != "index": + # Filename is the error code (e.g., SCH001.md) + documented.add(md_file.stem) + + return documented + + +def find_error_codes_in_mkdocs() -> set[str]: + """Find error codes referenced in mkdocs.yml navigation.""" + referenced: set[str] = set() + + if not MKDOCS_FILE.exists(): + return referenced + + content = MKDOCS_FILE.read_text(encoding="utf-8") + for match in ERROR_CODE_PATTERN.finditer(content): + referenced.add(match.group(1)) + + return referenced + + +def main() -> int: + """Check error documentation coverage.""" + print("Checking error code documentation...") + print() + + # Find codes + source_codes = find_error_codes_in_source() + documented_codes = find_documented_error_codes() + mkdocs_codes = find_error_codes_in_mkdocs() + + # Calculate differences + missing_docs = source_codes - documented_codes + missing_nav = documented_codes - mkdocs_codes + orphan_docs = documented_codes - source_codes + + # Report + print(f"Error codes in source: {len(source_codes)}") + print(f"Documented error codes: {len(documented_codes)}") + print(f"Error codes in mkdocs nav: {len(mkdocs_codes)}") + print() + + has_errors = False + + if missing_docs: + has_errors = True + print("❌ Missing documentation for error codes:") + for code in sorted(missing_docs): + print(f" - {code}") + print() + print(f" Create files in docs/errors/: {', '.join(sorted(missing_docs))}") + print() + + if missing_nav: + has_errors = True + print("❌ Documented but missing from mkdocs.yml nav:") + for code in sorted(missing_nav): + print(f" - {code}") + print() + + if orphan_docs: + print("⚠️ Documentation exists but code not found in source:") + for code in sorted(orphan_docs): + print(f" - {code}") + print() + + if not has_errors: + print("✅ All error codes are documented and in mkdocs nav!") + return 0 + + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/download_cirpass_vocabularies.py b/scripts/download_cirpass_vocabularies.py new file mode 100644 index 0000000..423af8c --- /dev/null +++ b/scripts/download_cirpass_vocabularies.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Download CIRPASS-2 vocabularies from the DPP Vocabulary Hub. + +This script fetches the latest ontologies, JSON schemas, and API specs +from the CIRPASS-2 project for use in dppvalidator. +""" + +import asyncio +import logging +from pathlib import Path + +import httpx + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + +BASE_URL = "https://dpp.vocabulary-hub.eu" + +# Target directories (resolve for CI/CD robustness) +DATA_DIR = Path(__file__).resolve().parent.parent / "src" / "dppvalidator" / "vocabularies" / "data" +ONTOLOGIES_DIR = DATA_DIR / "ontologies" +SCHEMAS_DIR = DATA_DIR / "schemas" + +# Priority 1: Core Ontology Modules (TTL format) - Latest versions only +PRIORITY_1_ONTOLOGIES = { + "eudpp_core_v1.3.1.ttl": ( + f"{BASE_URL}/api/ontology-version/OntologyVersion_b9d20c7a-a6d2-43b4-96f1-467e66911f3b/export?format=turtle" + ), + "product_dpp_v1.7.1.ttl": ( + f"{BASE_URL}/api/ontology-version/OntologyVersion_d8046709-cc88-453f-a374-8e964101a3db/export?format=turtle" + ), + "actors_roles_v1.5.1.ttl": ( + f"{BASE_URL}/api/ontology-version/OntologyVersion_40443b4b-c2eb-4a14-a0f8-5b653d441a5e/export?format=turtle" + ), + "soc_v1.4.7.ttl": ( + f"{BASE_URL}/api/ontology-version/OntologyVersion_0ed82e77-a4ac-4baa-908f-ae36ce0692c0/export?format=turtle" + ), + "lca_v2.0.ttl": ( + f"{BASE_URL}/api/ontology-version/OntologyVersion_08381633-1ffe-4fd8-bc38-88d47062aab5/export?format=turtle" + ), +} + +# Priority 2: JSON Schema (for validation) +PRIORITY_2_SCHEMAS = { + "cirpass_dpp_schema.json": ( + f"{BASE_URL}/api/wizard/export/Message_5cbe085e-4445-4daa-b3db-82fd162ef73d/json/schema?format=json" + ), + "cirpass_dpp_schema.yaml": ( + f"{BASE_URL}/api/wizard/export/Message_5cbe085e-4445-4daa-b3db-82fd162ef73d/json/schema?format=yaml" + ), + "cirpass_dpp_shacl.ttl": ( + f"{BASE_URL}/api/wizard/export/Message_5cbe085e-4445-4daa-b3db-82fd162ef73d/rdf/shacl?format=ttl" + ), +} + +# Priority 3: API Specs (optional) +PRIORITY_3_API_SPECS = { + "cirpass_dpp_openapi.json": ( + f"{BASE_URL}/api/wizard/export/Message_5cbe085e-4445-4daa-b3db-82fd162ef73d/openapi?format=json" + ), + "cirpass_dpp_schema.xsd": ( + f"{BASE_URL}/api/wizard/export/Message_5cbe085e-4445-4daa-b3db-82fd162ef73d/xml/schema?style=doll" + ), +} + + +async def download_file( + client: httpx.AsyncClient, + url: str, + target_path: Path, + *, + timeout: float = 60.0, +) -> bool: + """Download a file from URL to target path.""" + try: + logger.info(f"Downloading: {target_path.name}") + response = await client.get(url, timeout=timeout, follow_redirects=True) + response.raise_for_status() + + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_bytes(response.content) + + size_kb = len(response.content) / 1024 + logger.info(f" ✓ {target_path.name} ({size_kb:.1f} KB)") + return True + + except httpx.HTTPStatusError as e: + logger.error(f" ✗ {target_path.name}: HTTP {e.response.status_code}") + return False + except httpx.RequestError as e: + logger.error(f" ✗ {target_path.name}: {e}") + return False + + +async def download_resources( + resources: dict[str, str], + target_dir: Path, + category: str, +) -> tuple[int, int]: + """Download a category of resources.""" + logger.info(f"\n{'=' * 60}") + logger.info(f"{category}") + logger.info(f"{'=' * 60}") + + success = 0 + failed = 0 + + async with httpx.AsyncClient() as client: + tasks = [ + download_file(client, url, target_dir / filename) for filename, url in resources.items() + ] + results = await asyncio.gather(*tasks) + + for result in results: + if result: + success += 1 + else: + failed += 1 + + return success, failed + + +async def main() -> None: + """Download all CIRPASS-2 vocabulary resources.""" + logger.info("CIRPASS-2 Vocabulary Downloader") + logger.info("================================\n") + + total_success = 0 + total_failed = 0 + + # Priority 1: Ontologies + s, f = await download_resources( + PRIORITY_1_ONTOLOGIES, + ONTOLOGIES_DIR, + "Priority 1: Core Ontology Modules", + ) + total_success += s + total_failed += f + + # Priority 2: Schemas + s, f = await download_resources( + PRIORITY_2_SCHEMAS, + SCHEMAS_DIR, + "Priority 2: JSON Schema & SHACL", + ) + total_success += s + total_failed += f + + # Priority 3: API Specs + s, f = await download_resources( + PRIORITY_3_API_SPECS, + SCHEMAS_DIR, + "Priority 3: API Specifications", + ) + total_success += s + total_failed += f + + # Summary + logger.info(f"\n{'=' * 60}") + logger.info("SUMMARY") + logger.info(f"{'=' * 60}") + logger.info(f"Successfully downloaded: {total_success}") + logger.info(f"Failed: {total_failed}") + + if total_failed > 0: + logger.warning("\nSome downloads failed. Check the logs above for details.") + else: + logger.info("\n✓ All downloads completed successfully!") + + # Show target directories + logger.info("\nFiles saved to:") + logger.info(f" Ontologies: {ONTOLOGIES_DIR}") + logger.info(f" Schemas: {SCHEMAS_DIR}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/fetch_dpp_samples.py b/scripts/fetch_dpp_samples.py new file mode 100644 index 0000000..1ba7ac3 --- /dev/null +++ b/scripts/fetch_dpp_samples.py @@ -0,0 +1,412 @@ +#!/usr/bin/env python3 +""" +Script to fetch and evaluate Digital Product Passport samples for testing. + +Downloads DPP samples from various sources and evaluates their structure +to determine if they are valid candidates for testing the dppvalidator library. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import httpx + +# Raw URLs provided by the user (with duplicates and fragments) +RAW_URLS = """ +https://untp-verifiable-credentials.s3.amazonaws.com/bc075c5f-2304-4b3f-bb24-46d9fa9a8e60.json +https://spherity.github.io/schemas/testing/breathable-t-shirt.json +https://raw.githubusercontent.com/eclipse-tractusx/sldt-semantic-models/main/io.catenax.battery.battery_pass/6.0.0/gen/BatteryPass.json +https://zenodo.org/records/15279026/preview/untp-dpp-instance-0.5.0-computer.json.txt +https://test.uncefact.org/vocabulary/untp/dia/DigitalIdentityAnchor-instance-0.6.1.json +https://opensource.unicc.org/phila/spec-untp/-/raw/main/website/samples/untp-digital-facility-record-v0.3.9.json +https://opensource.unicc.org/11dot2/spec-untp/-/raw/main/website/samples/untp-digital-product-passport-v0.3.10.json +https://test.uncefact.org/vocabulary/untp/dpp/untp-dpp-instance-0.6.0.json +https://batterypass.github.io/BatteryPassDataModel/BatteryPass/io.BatteryPass.GeneralProductInformation/1.2.0/gen/GeneralProductInformation-payload.json +https://batterypass.github.io/BatteryPassDataModel//BatteryPass/io.BatteryPass.CarbonFootprint/1.2.0/gen/CarbonFootprintForBatteries-payload.json +https://raw.githubusercontent.com/batterypass/BatteryPassDataModel/refs/heads/main/BatteryPass/io.BatteryPass.GeneralProductInformation/1.2.0/gen/GeneralProductInformation-ld.json +https://raw.githubusercontent.com/batterypass/BatteryPassDataModel/refs/heads/main/BatteryPass/io.BatteryPass.CarbonFootprint/1.2.0/gen/CarbonFootprintForBatteries-ld.json +https://raw.githubusercontent.com/batterypass/BatteryPassDataModel/refs/heads/main/BatteryPass/io.BatteryPass.Circularity/1.2.0/gen/Circularity-ld.json +https://raw.githubusercontent.com/batterypass/BatteryPassDataModel/refs/heads/main/BatteryPass/io.BatteryPass.MaterialComposition/1.2.0/gen/MaterialComposition-ld.json +https://nfc-forum.org/ndpp/long-dpp-example.json +https://batterypass.github.io/BatteryPassDataModel//BatteryPass/io.BatteryPass.MaterialComposition/1.2.0/gen/MaterialComposition-payload.json +""" + + +@dataclass +class Evaluation: + """Evaluation results for a DPP sample.""" + + is_json_ld: bool = False + has_context: bool = False + has_type: bool = False + detected_type: str | list[str] | None = None + is_verifiable_credential: bool = False + is_dpp_like: bool = False + is_battery_pass: bool = False + is_schema: bool = False + has_product_info: bool = False + top_level_keys: list[str] = field(default_factory=list) + recommendation: str = "unknown" + notes: list[str] = field(default_factory=list) + + +@dataclass +class DPPSample: + """Represents a downloaded DPP sample with metadata.""" + + url: str + filename: str + content: dict[str, Any] | None = None + error: str | None = None + content_hash: str | None = None + evaluation: Evaluation = field(default_factory=Evaluation) + + @property + def is_valid(self) -> bool: + return self.content is not None and self.error is None + + +def clean_and_dedupe_urls(raw_urls: str) -> list[str]: + """Parse, clean (remove fragments), and deduplicate URLs.""" + urls = [] + for line in raw_urls.strip().split("\n"): + line = line.strip() + if not line: + continue + # Handle malformed URLs (two URLs concatenated) + if "https://" in line[8:]: + parts = re.split(r"(?=https://)", line) + for part in parts: + if part: + urls.append(part) + else: + urls.append(line) + + # Remove fragments and deduplicate + cleaned = [] + seen = set() + for url in urls: + parsed = urlparse(url) + # Rebuild URL without fragment, normalize double slashes in path + clean_path = re.sub(r"//+", "/", parsed.path) + clean_url = f"{parsed.scheme}://{parsed.netloc}{clean_path}" + if parsed.query: + clean_url += f"?{parsed.query}" + + if clean_url not in seen: + seen.add(clean_url) + cleaned.append(clean_url) + + return cleaned + + +def derive_filename(url: str) -> str: + """Derive a meaningful filename from the URL.""" + parsed = urlparse(url) + path = parsed.path + + # Get the base filename + basename = Path(path).name + if not basename or basename == "": + basename = "unknown" + + # Remove .txt extension if present (some JSON files have .json.txt) + if basename.endswith(".json.txt"): + basename = basename[:-4] + elif not basename.endswith(".json"): + basename += ".json" + + # Add source prefix for clarity + domain = parsed.netloc.replace(".", "_") + if "github" in domain or "githubusercontent" in domain: + # Extract repo/org info + parts = path.split("/") + if len(parts) >= 2: + org_repo = "_".join(p for p in parts[1:3] if p) + return f"{org_repo}_{basename}" + + return f"{domain}_{basename}" + + +def evaluate_dpp_structure(data: dict[str, Any]) -> Evaluation: + """Evaluate if the JSON structure appears to be a valid DPP candidate.""" + evaluation = Evaluation() + + if not isinstance(data, dict): + evaluation.notes.append("Not a JSON object") + evaluation.recommendation = "reject" + return evaluation + + keys = list(data.keys()) + evaluation.top_level_keys = keys[:20] # Limit for readability + + # Check for JSON-LD markers + if "@context" in data: + evaluation.has_context = True + evaluation.is_json_ld = True + + if "@type" in data or "type" in data: + evaluation.has_type = True + type_val = data.get("@type") or data.get("type") + if isinstance(type_val, list): + evaluation.detected_type = type_val + else: + evaluation.detected_type = str(type_val) if type_val else None + + # Check for Verifiable Credential structure + vc_indicators = ["credentialSubject", "issuer", "issuanceDate", "proof"] + vc_count = sum(1 for k in vc_indicators if k in data) + if vc_count >= 2: + evaluation.is_verifiable_credential = True + + # Check for DPP-like structure + dpp_indicators = [ + "product", + "productIdentifier", + "productName", + "manufacturer", + "manufacturerInformation", + "circularity", + "sustainability", + "materials", + "materialComposition", + "carbonFootprint", + ] + dpp_count = sum(1 for k in dpp_indicators if k in data or k in str(data).lower()) + if dpp_count >= 2: + evaluation.is_dpp_like = True + + # Check for Battery Pass specific fields + battery_indicators = [ + "batteryId", + "batteryModel", + "batteryCategory", + "batteryWeight", + "ratedCapacity", + "batteryStatus", + "stateOfHealth", + "stateOfCharge", + ] + battery_count = sum(1 for k in battery_indicators if k in str(data)) + if battery_count >= 2: + evaluation.is_battery_pass = True + + # Check if it's a schema rather than an instance + schema_indicators = ["$schema", "$id", "properties", "definitions", "required"] + schema_count = sum(1 for k in schema_indicators if k in data) + if schema_count >= 3: + evaluation.is_schema = True + evaluation.notes.append("Appears to be a JSON Schema, not a DPP instance") + + # Check for product information + if "product" in data or "credentialSubject" in data: + evaluation.has_product_info = True + + # Determine recommendation + if evaluation.is_schema: + evaluation.recommendation = "schema_only" + elif evaluation.is_verifiable_credential and evaluation.is_dpp_like: + evaluation.recommendation = "excellent" + evaluation.notes.append("Verifiable Credential with DPP structure") + elif evaluation.is_verifiable_credential: + evaluation.recommendation = "good" + evaluation.notes.append("Verifiable Credential structure") + elif evaluation.is_battery_pass: + evaluation.recommendation = "good" + evaluation.notes.append("Battery Pass data") + elif evaluation.is_dpp_like: + evaluation.recommendation = "moderate" + evaluation.notes.append("DPP-like structure without VC wrapper") + elif evaluation.is_json_ld: + evaluation.recommendation = "maybe" + evaluation.notes.append("JSON-LD but structure unclear") + else: + evaluation.recommendation = "review" + evaluation.notes.append("Structure needs manual review") + + return evaluation + + +def fetch_sample(client: httpx.Client, url: str) -> DPPSample: + """Fetch a single DPP sample from URL.""" + filename = derive_filename(url) + sample = DPPSample(url=url, filename=filename) + + try: + response = client.get(url, follow_redirects=True, timeout=30.0) + response.raise_for_status() + + content_text = response.text + sample.content_hash = hashlib.sha256(content_text.encode()).hexdigest()[:16] + + # Try to parse as JSON + sample.content = json.loads(content_text) + sample.evaluation = evaluate_dpp_structure(sample.content) + + except httpx.HTTPStatusError as e: + sample.error = f"HTTP {e.response.status_code}: {e.response.reason_phrase}" + except httpx.RequestError as e: + sample.error = f"Request error: {e}" + except json.JSONDecodeError as e: + sample.error = f"Invalid JSON: {e}" + except Exception as e: + sample.error = f"Unexpected error: {e}" + + return sample + + +def fetch_all_samples(urls: list[str]) -> list[DPPSample]: + """Fetch all DPP samples from the provided URLs.""" + samples = [] + + with httpx.Client( + headers={ + "User-Agent": "dppvalidator-sample-fetcher/1.0", + "Accept": "application/json, application/ld+json, text/plain", + } + ) as client: + for i, url in enumerate(urls, 1): + print(f"[{i}/{len(urls)}] Fetching: {url[:80]}...") + sample = fetch_sample(client, url) + samples.append(sample) + + if sample.error: + print(f" ❌ Error: {sample.error}") + else: + print(f" ✓ {sample.filename} -> {sample.evaluation.recommendation}") + + return samples + + +def generate_report(samples: list[DPPSample]) -> str: + """Generate a markdown report of the evaluation results.""" + lines = ["# DPP Sample Evaluation Report\n"] + + # Summary + total = len(samples) + success = sum(1 for s in samples if s.is_valid) + failed = total - success + + lines.append("## Summary\n") + lines.append(f"- **Total URLs**: {total}") + lines.append(f"- **Successfully fetched**: {success}") + lines.append(f"- **Failed**: {failed}\n") + + # Group by recommendation + by_rec: dict[str, list[DPPSample]] = {} + for s in samples: + rec = s.evaluation.recommendation if s.is_valid else "failed" + by_rec.setdefault(rec, []).append(s) + + lines.append("## By Recommendation\n") + order = ["excellent", "good", "moderate", "maybe", "schema_only", "review", "failed"] + for rec in order: + if rec in by_rec: + lines.append(f"### {rec.upper()} ({len(by_rec[rec])})\n") + for s in by_rec[rec]: + if s.is_valid: + notes = ", ".join(s.evaluation.notes) + lines.append(f"- `{s.filename}`: {notes}") + else: + lines.append(f"- `{s.filename}`: {s.error}") + lines.append(f" - URL: {s.url}") + lines.append("") + + # Detailed evaluation + lines.append("## Detailed Evaluation\n") + for s in samples: + lines.append(f"### {s.filename}\n") + lines.append(f"- **URL**: {s.url}") + if s.error: + lines.append(f"- **Error**: {s.error}") + else: + lines.append(f"- **Hash**: {s.content_hash}") + lines.append(f"- **Recommendation**: {s.evaluation.recommendation}") + lines.append(f"- **Is JSON-LD**: {s.evaluation.is_json_ld}") + lines.append(f"- **Is VC**: {s.evaluation.is_verifiable_credential}") + lines.append(f"- **Is DPP-like**: {s.evaluation.is_dpp_like}") + lines.append(f"- **Is Battery Pass**: {s.evaluation.is_battery_pass}") + lines.append(f"- **Is Schema**: {s.evaluation.is_schema}") + lines.append(f"- **Type**: {s.evaluation.detected_type}") + lines.append(f"- **Top keys**: {', '.join(s.evaluation.top_level_keys[:10])}") + if s.evaluation.notes: + lines.append(f"- **Notes**: {'; '.join(s.evaluation.notes)}") + lines.append("") + + return "\n".join(lines) + + +def save_samples(samples: list[DPPSample], output_dir: Path) -> None: + """Save valid samples to the output directory.""" + output_dir.mkdir(parents=True, exist_ok=True) + + # Group by hash to avoid duplicates + by_hash: dict[str, DPPSample] = {} + for s in samples: + if s.is_valid and s.content_hash and s.content_hash not in by_hash: + by_hash[s.content_hash] = s + + for sample in by_hash.values(): + filepath = output_dir / sample.filename + # Avoid overwriting - add hash suffix if exists + if filepath.exists(): + stem = filepath.stem + filepath = output_dir / f"{stem}_{sample.content_hash}.json" + + with open(filepath, "w") as f: + json.dump(sample.content, f, indent=2) + print(f"Saved: {filepath.name}") + + +def main() -> None: + """Main entry point.""" + print("=" * 60) + print("DPP Sample Fetcher and Evaluator") + print("=" * 60) + + # Parse and dedupe URLs + urls = clean_and_dedupe_urls(RAW_URLS) + print(f"\nFound {len(urls)} unique URLs to process.\n") + + # Fetch all samples + samples = fetch_all_samples(urls) + + # Generate and save report + report = generate_report(samples) + report_path = Path(__file__).parent.parent / "tests" / "fixtures" / "samples_report.md" + report_path.parent.mkdir(parents=True, exist_ok=True) + with open(report_path, "w") as f: + f.write(report) + print(f"\nReport saved to: {report_path}") + + # Save samples to fixtures directory + samples_dir = Path(__file__).parent.parent / "tests" / "fixtures" / "samples" + print(f"\nSaving samples to: {samples_dir}") + save_samples(samples, samples_dir) + + # Print summary + print("\n" + "=" * 60) + print("SUMMARY") + print("=" * 60) + excellent = [s for s in samples if s.evaluation.recommendation == "excellent"] + good = [s for s in samples if s.evaluation.recommendation == "good"] + moderate = [s for s in samples if s.evaluation.recommendation == "moderate"] + schema_only = [s for s in samples if s.evaluation.recommendation == "schema_only"] + failed = [s for s in samples if not s.is_valid] + + print(f" Excellent candidates: {len(excellent)}") + print(f" Good candidates: {len(good)}") + print(f" Moderate candidates: {len(moderate)}") + print(f" Schema only: {len(schema_only)}") + print(f" Failed to fetch: {len(failed)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/fetch_vocabularies.py b/scripts/fetch_vocabularies.py index 088ea53..849f6e8 100644 --- a/scripts/fetch_vocabularies.py +++ b/scripts/fetch_vocabularies.py @@ -211,7 +211,7 @@ def save_vocabulary(name: str, codes: set[str], description: str) -> None: } output_path = DATA_DIR / f"{name}.json" - output_path.write_text(json.dumps(output, indent=2) + "\n") + output_path.write_text(json.dumps(output, indent=2) + "\n", encoding="utf-8") print(f"Saved {len(codes)} {name} codes to {output_path}") diff --git a/scripts/generate_error_docs.py b/scripts/generate_error_docs.py new file mode 100644 index 0000000..e9adc16 --- /dev/null +++ b/scripts/generate_error_docs.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Generate missing error documentation files. + +This script creates placeholder documentation for error codes that are +defined in the source code but don't have documentation files. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +# Add src to path for imports (resolve for CI/CD robustness) +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT / "src")) + +if TYPE_CHECKING: + pass + +# Late import after path setup - noqa needed for E402 +from dppvalidator.validators.errors import ERROR_REGISTRY # noqa: E402 + +DOCS_ERRORS_DIR = PROJECT_ROOT / "docs" / "errors" + +# Error metadata for generating docs +ERROR_METADATA: dict[str, dict[str, str]] = { + # Schema errors from SCHEMA_ERROR_CODES + "SCH001": {"title": "Schema Not Loaded", "category": "Schema Errors"}, + "SCH002": {"title": "Type Mismatch", "category": "Schema Errors"}, + "SCH003": {"title": "Invalid Enum Value", "category": "Schema Errors"}, + "SCH004": {"title": "Invalid Format", "category": "Schema Errors"}, + "SCH005": {"title": "Pattern Mismatch", "category": "Schema Errors"}, + "SCH006": {"title": "String Too Short", "category": "Schema Errors"}, + "SCH007": {"title": "String Too Long", "category": "Schema Errors"}, + "SCH008": {"title": "Value Below Minimum", "category": "Schema Errors"}, + "SCH009": {"title": "Value Above Maximum", "category": "Schema Errors"}, + "SCH010": {"title": "Additional Properties", "category": "Schema Errors"}, + "SCH011": {"title": "Too Few Items", "category": "Schema Errors"}, + "SCH012": {"title": "Too Many Items", "category": "Schema Errors"}, + "SCH013": {"title": "Duplicate Items", "category": "Schema Errors"}, + "SCH014": {"title": "Const Mismatch", "category": "Schema Errors"}, + "SCH015": {"title": "AllOf Violation", "category": "Schema Errors"}, + "SCH016": {"title": "AnyOf Violation", "category": "Schema Errors"}, + "SCH017": {"title": "OneOf Violation", "category": "Schema Errors"}, + "SCH018": {"title": "Not Violation", "category": "Schema Errors"}, + "SCH019": {"title": "Contains Violation", "category": "Schema Errors"}, + "SCH020": {"title": "PrefixItems Violation", "category": "Schema Errors"}, + "SCH021": {"title": "Reference Resolution", "category": "Schema Errors"}, + "SCH099": {"title": "Unknown Schema Error", "category": "Schema Errors"}, + # Model errors + "MDL001": {"title": "Model Validation Failed", "category": "Model Errors"}, + "MDL002": {"title": "Invalid URL Format", "category": "Model Errors"}, + "MDL003": {"title": "Invalid DateTime Format", "category": "Model Errors"}, + "MDL010": {"title": "Invalid Issuer", "category": "Model Errors"}, + "MDL011": {"title": "Invalid Issuer ID", "category": "Model Errors"}, + "MDL012": {"title": "Invalid Issuer Name", "category": "Model Errors"}, + "MDL013": {"title": "Invalid Issuer Type", "category": "Model Errors"}, + "MDL014": {"title": "Invalid Issuer Location", "category": "Model Errors"}, + "MDL015": {"title": "Invalid Issuer Identifier", "category": "Model Errors"}, + "MDL016": {"title": "Invalid Identifier Scheme", "category": "Model Errors"}, + "MDL020": {"title": "Invalid Credential Subject", "category": "Model Errors"}, + "MDL021": {"title": "Invalid Credential Subject ID", "category": "Model Errors"}, + "MDL022": {"title": "Invalid Credential Subject Type", "category": "Model Errors"}, + "MDL030": {"title": "Invalid Product", "category": "Model Errors"}, + "MDL031": {"title": "Invalid Product ID", "category": "Model Errors"}, + "MDL032": {"title": "Invalid Product Name", "category": "Model Errors"}, + "MDL033": {"title": "Invalid Product Category", "category": "Model Errors"}, + "MDL040": {"title": "Invalid Material", "category": "Model Errors"}, + "MDL041": {"title": "Invalid Material Name", "category": "Model Errors"}, + "MDL042": {"title": "Invalid Material Fraction", "category": "Model Errors"}, + "MDL050": {"title": "Invalid Claim", "category": "Model Errors"}, + "MDL051": {"title": "Invalid Claim Type", "category": "Model Errors"}, + "MDL052": {"title": "Invalid Claim Topic", "category": "Model Errors"}, + "MDL053": {"title": "Invalid Claim Assessment", "category": "Model Errors"}, + "MDL060": {"title": "Invalid Traceability", "category": "Model Errors"}, + "MDL061": {"title": "Invalid Traceability Event", "category": "Model Errors"}, + "MDL070": {"title": "Invalid Circularity", "category": "Model Errors"}, + "MDL071": {"title": "Invalid Circularity Content", "category": "Model Errors"}, + "MDL080": {"title": "Invalid Emission", "category": "Model Errors"}, + "MDL081": {"title": "Invalid Emission Value", "category": "Model Errors"}, + "MDL090": {"title": "Invalid Facility", "category": "Model Errors"}, + "MDL091": {"title": "Invalid Facility Location", "category": "Model Errors"}, + "MDL099": {"title": "Unknown Model Error", "category": "Model Errors"}, + # CIRPASS rules + "CQ001": {"title": "Missing Mandatory Attributes", "category": "CIRPASS Errors"}, + "CQ004": {"title": "Missing CAS Number", "category": "CIRPASS Errors"}, + "CQ011": {"title": "Missing Operator ID", "category": "CIRPASS Errors"}, + "CQ016": {"title": "Missing Validity Period", "category": "CIRPASS Errors"}, + "CQ017": {"title": "Granularity Mismatch", "category": "CIRPASS Errors"}, + "CQ020": {"title": "Missing Weight/Volume", "category": "CIRPASS Errors"}, + # Textile rules + "TXT001": {"title": "Invalid Textile HS Code", "category": "Textile Errors"}, + "TXT002": {"title": "Missing Material Composition", "category": "Textile Errors"}, + "TXT003": {"title": "Missing Microplastic Data", "category": "Textile Errors"}, + "TXT004": {"title": "Missing Durability Info", "category": "Textile Errors"}, + "TXT005": {"title": "Missing Care Instructions", "category": "Textile Errors"}, +} + +TEMPLATE = """# {code} - {title} + +## Description + +{description} + +## Category + +{category} + +## Severity + +`{severity}` + +## Common Causes + +{causes} + +## How to Fix + +{fix} + +## Example + +```json +{example} +``` + +## See Also + +- [Error Overview](index.md) +""" + + +def get_error_info(code: str) -> dict[str, str]: + """Get error info from registry or metadata.""" + meta = ERROR_METADATA.get(code, {}) + registry = ERROR_REGISTRY.get(code, {}) + + prefix = "".join(c for c in code if c.isalpha()) + + # Determine severity based on prefix + severity_map = { + "SCH": "error", + "MDL": "error", + "SEM": "error", + "JLD": "warning", + "VOC": "warning", + "CQ": "error", + "TXT": "warning", + } + + return { + "title": meta.get("title", registry.get("title", f"Error {code}")), + "category": meta.get("category", f"{prefix} Errors"), + "severity": severity_map.get(prefix, "error"), + "description": registry.get("suggestion", f"Validation error {code}."), + "causes": "- Input data does not meet validation requirements", + "fix": registry.get("suggestion", "Review the error message and correct the input data."), + "example": registry.get("example", "// Example will vary based on error"), + } + + +def generate_doc(code: str) -> str: + """Generate documentation content for an error code.""" + info = get_error_info(code) + return TEMPLATE.format(code=code, **info) + + +def main() -> int: + """Generate missing error documentation files.""" + DOCS_ERRORS_DIR.mkdir(parents=True, exist_ok=True) + + # Find missing codes + from check_error_docs import find_documented_error_codes, find_error_codes_in_source + + source_codes = find_error_codes_in_source() + documented = find_documented_error_codes() + missing = source_codes - documented + + if not missing: + print("All error codes are documented!") + return 0 + + print(f"Generating {len(missing)} error documentation files...") + + for code in sorted(missing): + doc_path = DOCS_ERRORS_DIR / f"{code}.md" + content = generate_doc(code) + doc_path.write_text(content, encoding="utf-8") + print(f" Created: {doc_path.name}") + + print(f"\n✅ Generated {len(missing)} documentation files") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 4228a78..0000000 --- a/setup.cfg +++ /dev/null @@ -1,4 +0,0 @@ -[mutmut] -paths_to_mutate=src/dppvalidator/validators/ -tests_dir=tests/unit/ -runner=uv run pytest tests/unit/ -x --tb=no -q diff --git a/src/dppvalidator/__init__.py b/src/dppvalidator/__init__.py index 9b33245..0d52155 100644 --- a/src/dppvalidator/__init__.py +++ b/src/dppvalidator/__init__.py @@ -4,6 +4,7 @@ from dppvalidator.logging import configure_logging, get_logger from dppvalidator.models.passport import DigitalProductPassport +from dppvalidator.validators.deep import DeepValidationResult, DeepValidator from dppvalidator.validators.engine import ValidationEngine from dppvalidator.validators.results import ValidationError, ValidationResult @@ -11,10 +12,16 @@ __all__ = [ "__version__", + # Logging "configure_logging", "get_logger", + # Models "DigitalProductPassport", + # Validation "ValidationEngine", "ValidationError", "ValidationResult", + # Deep validation + "DeepValidator", + "DeepValidationResult", ] diff --git a/src/dppvalidator/cli/commands/completions.py b/src/dppvalidator/cli/commands/completions.py index ec685f6..7bee768 100644 --- a/src/dppvalidator/cli/commands/completions.py +++ b/src/dppvalidator/cli/commands/completions.py @@ -70,7 +70,7 @@ return ;; --version) - COMPREPLY=($(compgen -W "0.6.0 0.6.1" -- "${cur}")) + COMPREPLY=($(compgen -W "__SCHEMA_VERSIONS__" -- "${cur}")) return ;; esac @@ -129,7 +129,7 @@ ) schema_opts=( - '--version[Schema version]:version:(0.6.0 0.6.1)' + '--version[Schema version]:version:(__SCHEMA_VERSIONS__)' '--help[Show help]' ) @@ -221,7 +221,7 @@ complete -c dppvalidator -n "__fish_seen_subcommand_from schema; and not __fish_seen_subcommand_from list info download" -a list -d "List available schemas" complete -c dppvalidator -n "__fish_seen_subcommand_from schema; and not __fish_seen_subcommand_from list info download" -a info -d "Show schema information" complete -c dppvalidator -n "__fish_seen_subcommand_from schema; and not __fish_seen_subcommand_from list info download" -a download -d "Download a schema" -complete -c dppvalidator -n "__fish_seen_subcommand_from schema" -l version -d "Schema version" -xa "0.6.0 0.6.1" +complete -c dppvalidator -n "__fish_seen_subcommand_from schema" -l version -d "Schema version" -xa "__SCHEMA_VERSIONS__" # completions options complete -c dppvalidator -n "__fish_seen_subcommand_from completions" -a "bash zsh fish" -d "Shell type" @@ -254,6 +254,23 @@ def add_parser(subparsers: _SubParsersAction[argparse.ArgumentParser]) -> None: ) +_SCHEMA_VERSIONS_SENTINEL = "__SCHEMA_VERSIONS__" + + +def _expand_schema_versions(template: str) -> str: + """Expand the ``__SCHEMA_VERSIONS__`` sentinel with live registry values. + + The completion templates carry a sentinel rather than a hardcoded version + list so that ``dppvalidator completions `` always reflects whatever + is registered in ``SCHEMA_REGISTRY`` at the moment the user generates the + completion. See docs/plans/UNTP_0.7.0_MIGRATION.md §Phase 1 / §7.1. + """ + from dppvalidator.schemas.registry import SCHEMA_REGISTRY + + versions = " ".join(sorted(SCHEMA_REGISTRY)) + return template.replace(_SCHEMA_VERSIONS_SENTINEL, versions) + + def run(args: argparse.Namespace) -> int: """Run the completions command. @@ -278,5 +295,5 @@ def run(args: argparse.Namespace) -> int: print(f"Unknown shell: {shell}", file=sys.stderr) return 2 - print(completions[shell]) + print(_expand_schema_versions(completions[shell])) return 0 diff --git a/src/dppvalidator/cli/commands/doctor.py b/src/dppvalidator/cli/commands/doctor.py index c48a044..483a43b 100644 --- a/src/dppvalidator/cli/commands/doctor.py +++ b/src/dppvalidator/cli/commands/doctor.py @@ -138,23 +138,46 @@ def _check_pydantic(console: Console) -> tuple[bool, str, bool]: def _check_optional_deps(console: Console) -> tuple[bool, str, bool]: - """Check optional dependencies.""" + """Check optional and core dependencies.""" + # Core dependencies (required, should always be present) + core_deps = { + "httpx": "HTTP client for deep validation", + "jsonschema": "JSON Schema validation", + "pyld": "JSON-LD expansion", + "cryptography": "Signature verification", + } + + # Optional dependencies optional_deps = { "rich": ("CLI formatting", "pip install 'dppvalidator[cli]'"), - "httpx": ("HTTP schema fetching", "pip install 'dppvalidator[http]'"), - "jsonschema": ("JSON Schema validation", "pip install 'dppvalidator[jsonschema]'"), } - missing = [] + # Check core dependencies + core_missing = [] + for dep, purpose in core_deps.items(): + try: + dep_ver = pkg_version(dep) + console.print(f" [green]✓[/green] {dep} {dep_ver} ({purpose})") + except Exception: + core_missing.append((dep, purpose)) + + if core_missing: + for dep, purpose in core_missing: + console.print(f" [red]✗[/red] {dep} not installed ({purpose})", style="red") + console.print(" 💡 Run: pip install --force-reinstall dppvalidator") + return False, "Core dependencies missing - reinstall dppvalidator", False + + # Check optional dependencies + optional_missing = [] for dep, (purpose, install_cmd) in optional_deps.items(): try: dep_ver = pkg_version(dep) console.print(f" [green]✓[/green] {dep} {dep_ver} ({purpose})") except Exception: - missing.append((dep, purpose, install_cmd)) + optional_missing.append((dep, purpose, install_cmd)) - if missing: - for dep, purpose, install_cmd in missing: + if optional_missing: + for dep, purpose, install_cmd in optional_missing: console.print(f" [yellow]○[/yellow] {dep} not installed ({purpose})") console.print(f" 💡 Run: {install_cmd}") return True, "Some optional dependencies missing", True @@ -177,7 +200,7 @@ def _check_schema_cache(console: Console) -> tuple[bool, str, bool]: valid_count = 0 for sf in schema_files: try: - json.loads(sf.read_text()) + json.loads(sf.read_text(encoding="utf-8")) valid_count += 1 except (json.JSONDecodeError, OSError): pass diff --git a/src/dppvalidator/cli/commands/export.py b/src/dppvalidator/cli/commands/export.py index f7b3099..5a619d3 100644 --- a/src/dppvalidator/cli/commands/export.py +++ b/src/dppvalidator/cli/commands/export.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any from dppvalidator.logging import get_logger +from dppvalidator.schemas.registry import DEFAULT_SCHEMA_VERSION if TYPE_CHECKING: from dppvalidator.cli.console import Console @@ -43,8 +44,8 @@ def add_parser(subparsers: Any) -> argparse.ArgumentParser: ) parser.add_argument( "--schema-version", - default="0.6.1", - help="Schema version (default: 0.6.1)", + default=DEFAULT_SCHEMA_VERSION, + help=f"Schema version (default: {DEFAULT_SCHEMA_VERSION})", ) parser.add_argument( "--compact", @@ -102,7 +103,7 @@ def _load_input(input_path: str, console: Console) -> dict[str, Any] | None: logger.error("File not found: %s", input_path) console.print_error(f"File not found: {input_path}") return None - content = path.read_text() + content = path.read_text(encoding="utf-8") return json.loads(content) except json.JSONDecodeError as e: diff --git a/src/dppvalidator/cli/commands/init.py b/src/dppvalidator/cli/commands/init.py index 5db95ea..1efef0c 100644 --- a/src/dppvalidator/cli/commands/init.py +++ b/src/dppvalidator/cli/commands/init.py @@ -5,10 +5,14 @@ import argparse import json import re +from collections.abc import Callable +from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import TYPE_CHECKING, Any +from dppvalidator.schemas.registry import DEFAULT_SCHEMA_VERSION + if TYPE_CHECKING: from dppvalidator.cli.console import Console @@ -150,15 +154,19 @@ ## Documentation - [dppvalidator docs](https://artiso-ai.github.io/dppvalidator/) -- [UNTP DPP Schema](https://uncefact.github.io/spec-untp/docs/specification/DigitalProductPassport) +- [UNTP DPP Schema](https://untp.unece.org/specification/DigitalProductPassport) """ -DPPVALIDATOR_CONFIG = { +DPPVALIDATOR_CONFIG: dict[str, Any] = { "$schema": "https://artiso-ai.github.io/dppvalidator/schemas/config.json", "version": "1.0", "validation": { "strict": False, - "schema_version": "0.6.1", + # Resolved at template-emission time so a generated project always + # gets the validator's currently-shipping default UNTP version. + # Phase 3 will introduce a `--schema-version` flag on `init` so users + # can pin a specific version (e.g. `0.7.0`) at scaffolding time. + "schema_version": DEFAULT_SCHEMA_VERSION, "fail_on_warning": False, }, "paths": { @@ -179,6 +187,82 @@ """ +@dataclass +class FileSpec: + """Specification for a file to create (Data-Driven Pattern).""" + + relative_path: str + content_factory: Callable[[InitContext], str] + condition: Callable[[argparse.Namespace], bool] = lambda _: True + display_name: str | None = None + + def get_display_name(self) -> str: + """Get name for console output.""" + return self.display_name or self.relative_path + + +@dataclass +class InitContext: + """Context for file creation.""" + + project_path: Path + project_name: str + args: argparse.Namespace + + +def _create_file( + spec: FileSpec, + context: InitContext, + console: Console, +) -> tuple[int, int]: + """Create a single file from spec. Returns (created, skipped) counts.""" + if not spec.condition(context.args): + return 0, 0 + + filepath = context.project_path / spec.relative_path + filepath.parent.mkdir(parents=True, exist_ok=True) + + if filepath.exists() and not context.args.force: + console.print(f" [yellow]○[/yellow] {spec.get_display_name()} exists (skipped)") + return 0, 1 + + content = spec.content_factory(context) + filepath.write_text(content, encoding="utf-8") + console.print(f" [green]✓[/green] Created {spec.get_display_name()}") + return 1, 0 + + +def _build_file_specs() -> list[FileSpec]: + """Build the list of file specifications.""" + return [ + FileSpec( + relative_path="data/sample_passport.json", + content_factory=lambda ctx: json.dumps(_get_template(ctx.args.template), indent=2) + + "\n", + display_name="data/sample_passport.json", + ), + FileSpec( + relative_path=".gitignore", + content_factory=lambda _: GITIGNORE_CONTENT, + ), + FileSpec( + relative_path="README.md", + content_factory=lambda ctx: README_TEMPLATE.format(project_name=ctx.project_name), + condition=lambda args: args.readme, + ), + FileSpec( + relative_path=".dppvalidator.json", + content_factory=lambda _: json.dumps(DPPVALIDATOR_CONFIG, indent=2) + "\n", + condition=lambda args: args.config, + ), + FileSpec( + relative_path=".pre-commit-config.yaml", + content_factory=lambda _: PRE_COMMIT_CONFIG, + condition=lambda args: args.pre_commit, + ), + ] + + def add_parser(subparsers: Any) -> argparse.ArgumentParser: """Add init subparser.""" parser = subparsers.add_parser( @@ -234,116 +318,82 @@ def add_parser(subparsers: Any) -> argparse.ArgumentParser: return parser -def run(args: argparse.Namespace, console: Console) -> int: - """Execute init command.""" +def _validate_project(args: argparse.Namespace, console: Console) -> tuple[Path, str] | None: + """Validate project setup. Returns (project_path, project_name) or None on error.""" project_path = Path(args.path).resolve() project_name = args.name or project_path.name - # Validate project name if not PROJECT_NAME_PATTERN.match(project_name): console.print_error( f"Invalid project name: '{project_name}'. " "Use letters, numbers, hyphens, and underscores only." ) + return None + + return project_path, project_name + + +def _setup_project_directory( + project_path: Path, args: argparse.Namespace, console: Console +) -> bool: + """Set up project directory. Returns True on success.""" + if project_path.exists() and any(project_path.iterdir()) and not args.force: + console.print(" [yellow]⚠[/yellow] Directory not empty. Use --force to overwrite files.") + + project_path.mkdir(parents=True, exist_ok=True) + + try: + test_file = project_path / ".dppvalidator_init_test" + test_file.write_text("test", encoding="utf-8") + test_file.unlink() + except PermissionError: + console.print_error(f"No write permission for: {project_path}") + return False + + return True + + +def _print_success_message(files_created: int, console: Console) -> None: + """Print success message with next steps.""" + if files_created > 0: + console.print("\n[bold green]✓ Project initialized successfully![/bold green]") + console.print("\nNext steps:") + console.print(" 1. Edit data/sample_passport.json with your product data") + console.print(" 2. Run: dppvalidator validate data/sample_passport.json") + console.print(" 3. Run: dppvalidator doctor (to check your environment)") + else: + console.print("\n[yellow]No files created.[/yellow] Use --force to overwrite.") + + +def run(args: argparse.Namespace, console: Console) -> int: + """Execute init command.""" + result = _validate_project(args, console) + if result is None: return EXIT_ERROR + project_path, project_name = result console.print(f"\n📦 Initializing DPP project in [bold]{project_path}[/bold]\n") try: - # Check if path exists and is not empty - if project_path.exists() and any(project_path.iterdir()) and not args.force: - console.print( - " [yellow]⚠[/yellow] Directory not empty. Use --force to overwrite files." - ) - - project_path.mkdir(parents=True, exist_ok=True) - - # Verify write permissions - try: - test_file = project_path / ".dppvalidator_init_test" - test_file.write_text("test", encoding="utf-8") - test_file.unlink() - except PermissionError: - console.print_error(f"No write permission for: {project_path}") + if not _setup_project_directory(project_path, args, console): return EXIT_ERROR + context = InitContext( + project_path=project_path, + project_name=project_name, + args=args, + ) + files_created = 0 files_skipped = 0 - # Create data directory - data_dir = project_path / "data" - data_dir.mkdir(exist_ok=True) - - # Create sample DPP file with current dates - template = _get_template(args.template) - dpp_file = data_dir / "sample_passport.json" - - if dpp_file.exists() and not args.force: - console.print(f" [yellow]○[/yellow] {dpp_file.name} exists (skipped)") - files_skipped += 1 - else: - dpp_file.write_text(json.dumps(template, indent=2) + "\n", encoding="utf-8") - console.print(f" [green]✓[/green] Created {dpp_file.relative_to(project_path)}") - files_created += 1 - - # Create .gitignore - gitignore = project_path / ".gitignore" - if gitignore.exists() and not args.force: - console.print(" [yellow]○[/yellow] .gitignore exists (skipped)") - files_skipped += 1 - else: - gitignore.write_text(GITIGNORE_CONTENT, encoding="utf-8") - console.print(" [green]✓[/green] Created .gitignore") - files_created += 1 - - # Create README.md - if args.readme: - readme = project_path / "README.md" - if readme.exists() and not args.force: - console.print(" [yellow]○[/yellow] README.md exists (skipped)") - files_skipped += 1 - else: - readme.write_text( - README_TEMPLATE.format(project_name=project_name), encoding="utf-8" - ) - console.print(" [green]✓[/green] Created README.md") - files_created += 1 - - # Create config file if requested - if args.config: - config_file = project_path / ".dppvalidator.json" - if config_file.exists() and not args.force: - console.print(" [yellow]○[/yellow] .dppvalidator.json exists (skipped)") - files_skipped += 1 - else: - config_file.write_text( - json.dumps(DPPVALIDATOR_CONFIG, indent=2) + "\n", encoding="utf-8" - ) - console.print(" [green]✓[/green] Created .dppvalidator.json") - files_created += 1 - - # Create pre-commit config if requested - if args.pre_commit: - precommit = project_path / ".pre-commit-config.yaml" - if precommit.exists() and not args.force: - console.print(f" [yellow]○[/yellow] {precommit.name} exists (skipped)") - files_skipped += 1 - else: - precommit.write_text(PRE_COMMIT_CONFIG, encoding="utf-8") - console.print(" [green]✓[/green] Created .pre-commit-config.yaml") - files_created += 1 - - # Summary - console.print(f"\n Files created: {files_created}, skipped: {files_skipped}") + for spec in _build_file_specs(): + created, skipped = _create_file(spec, context, console) + files_created += created + files_skipped += skipped - if files_created > 0: - console.print("\n[bold green]✓ Project initialized successfully![/bold green]") - console.print("\nNext steps:") - console.print(" 1. Edit data/sample_passport.json with your product data") - console.print(" 2. Run: dppvalidator validate data/sample_passport.json") - console.print(" 3. Run: dppvalidator doctor (to check your environment)") - else: - console.print("\n[yellow]No files created.[/yellow] Use --force to overwrite.") + console.print(f"\n Files created: {files_created}, skipped: {files_skipped}") + _print_success_message(files_created, console) return EXIT_SUCCESS diff --git a/src/dppvalidator/cli/commands/migrate.py b/src/dppvalidator/cli/commands/migrate.py new file mode 100644 index 0000000..36f0f55 --- /dev/null +++ b/src/dppvalidator/cli/commands/migrate.py @@ -0,0 +1,213 @@ +"""Migrate command: rewrite a v0.6.x DPP into v0.7.0 shape. + +Phase 4 of ``docs/plans/UNTP_0.7.0_MIGRATION.md`` introduces this +command. It runs the compat shim (see +:mod:`dppvalidator.compat.upgrade_0_6_to_0_7`) over a single input file +and writes the upgraded JSON to ``-o`` / ``--in-place``. + +By default, the command refuses to write the upgraded file when the +shim emits any ``warning``- or ``error``-severity warnings — the user +must opt in with ``--accept-warnings``. ``info``-severity events are +informational and never block. A sidecar ``.warnings.json`` +captures every warning whenever any non-info warning fires, regardless +of whether the write went through. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from dataclasses import asdict +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from dppvalidator.logging import get_logger + +if TYPE_CHECKING: + from dppvalidator.cli.console import Console + +logger = get_logger(__name__) + +EXIT_OK = 0 +EXIT_BLOCKED = 1 +EXIT_ERROR = 2 + + +def add_parser(subparsers: Any) -> argparse.ArgumentParser: + """Register the ``migrate`` subcommand.""" + parser = subparsers.add_parser( + "migrate", + help="Upgrade a v0.6.x DPP to v0.7.0 shape via the compat shim", + description=( + "Run the compat shim over a v0.6.x DPP and write the upgraded " + "JSON. Refuses to write when warnings fire unless " + "--accept-warnings is given. A sidecar warnings file is always " + "produced when warnings fire." + ), + ) + parser.add_argument( + "input", + help="Input file path, or '-' for stdin", + ) + parser.add_argument( + "-o", + "--output", + default=None, + help="Output file path (default: stdout)", + ) + parser.add_argument( + "--in-place", + action="store_true", + help="Write the upgraded JSON back to the input path (overwrites).", + ) + parser.add_argument( + "--accept-warnings", + action="store_true", + help=( + "Write the upgraded JSON even when the shim emits warnings or " + "errors. Without this, the command exits with code 1 on any " + "non-info warning." + ), + ) + parser.add_argument( + "--from", + dest="source_version", + default="0.6.x", + help=( + "Source UNTP version family (default: 0.6.x). Pass an explicit " + "X.Y.Z value to pin a specific source version." + ), + ) + return parser + + +def run(args: argparse.Namespace, console: Console) -> int: + """Execute the migrate command.""" + from dppvalidator.compat.upgrade_0_6_to_0_7 import ( + UpgradeSeverity, + upgrade, + ) + + data = _load_input(args.input, console) + if data is None: + return EXIT_ERROR + + if not args.source_version.startswith("0.6"): + console.print_error( + f"No upgrade shim registered for source version {args.source_version!r}.", + ) + return EXIT_ERROR + + try: + upgraded, warnings = upgrade(data) + except Exception as exc: + logger.exception("Upgrade shim crashed") + console.print_error(f"Upgrade failed: {exc}") + return EXIT_ERROR + + blocking = [w for w in warnings if w.severity != UpgradeSeverity.INFO] + + output_path = _resolve_output_path(args, console) + if output_path is None and args.in_place: + return EXIT_ERROR + + # Always write a sidecar warnings file when *any* blocking-grade + # warning fired, regardless of whether the main write goes through. + sidecar_path: Path | None = None + if blocking and output_path is not None: + sidecar_path = output_path.with_suffix(output_path.suffix + ".warnings.json") + _write_warnings_sidecar(sidecar_path, warnings) + console.print_warning( + f"{len(warnings)} warning(s) recorded in {sidecar_path}", + ) + + if blocking and not args.accept_warnings: + console.print_error( + f"Upgrade emitted {len(blocking)} blocking warning(s); refusing to " + "write. Re-run with --accept-warnings to override, or fix the " + "issues listed in the sidecar warnings file.", + ) + for w in warnings: + console.print(f" [{w.code}] ({w.severity.value}) {w.path}: {w.message}") + return EXIT_BLOCKED + + _write_output(upgraded, output_path, console) + + if warnings: + console.print(f"Upgraded with {len(warnings)} warning(s).") + for w in warnings: + console.print(f" [{w.code}] ({w.severity.value}) {w.path}: {w.message}") + else: + console.print_success("Upgraded with no warnings.") + + return EXIT_OK + + +def _resolve_output_path(args: argparse.Namespace, console: Console) -> Path | None: + """Return the resolved output path or ``None`` when stdout is the target.""" + if args.in_place and args.output: + console.print_error("--in-place and -o/--output are mutually exclusive.") + return None + if args.in_place: + if args.input == "-": + console.print_error("--in-place is incompatible with stdin input.") + return None + return Path(args.input) + if args.output: + return Path(args.output) + return None + + +def _load_input(input_path: str, console: Console) -> dict[str, Any] | None: + """Load JSON from a file path or stdin.""" + try: + if input_path == "-": + if hasattr(sys.stdin, "reconfigure"): + sys.stdin.reconfigure(encoding="utf-8") # type: ignore[union-attr] + content = sys.stdin.read() + else: + path = Path(input_path) + if not path.exists(): + console.print_error(f"File not found: {input_path}") + return None + content = path.read_text(encoding="utf-8") + return json.loads(content) + except json.JSONDecodeError as exc: + console.print_error(f"Invalid JSON: {exc}") + return None + except Exception as exc: + logger.exception("Unexpected error loading input") + console.print_error(str(exc)) + return None + + +def _write_output(payload: dict[str, Any], path: Path | None, console: Console) -> None: + """Write the upgraded JSON to ``path`` (or stdout if ``None``).""" + serialised = json.dumps(payload, indent=2, ensure_ascii=False, default=str) + if path is None: + # Stdout — bypass Rich so the output is pipe-friendly. + print(serialised) + return + path.write_text(serialised + "\n", encoding="utf-8") + console.print(f"Wrote {path}") + + +def _write_warnings_sidecar(path: Path, warnings: list[Any]) -> None: + """Persist the full warning list as JSON next to the upgraded payload.""" + from dppvalidator.schemas.registry import SCHEMA_REGISTRY + + target_candidates = [ + v for v in SCHEMA_REGISTRY if v.split(".")[0] == "0" and v.split(".")[1] == "7" + ] + target_version = ( + max(target_candidates, key=lambda v: tuple(int(x) for x in v.split("."))) + if target_candidates + else "0.7.x" + ) + payload = { + "schema_version_from": "0.6.x", + "schema_version_to": target_version, + "warnings": [{**asdict(w), "severity": w.severity.value} for w in warnings], + } + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") diff --git a/src/dppvalidator/cli/commands/schema.py b/src/dppvalidator/cli/commands/schema.py index ca848b5..a075332 100644 --- a/src/dppvalidator/cli/commands/schema.py +++ b/src/dppvalidator/cli/commands/schema.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any from dppvalidator.logging import get_logger +from dppvalidator.schemas.registry import DEFAULT_SCHEMA_VERSION if TYPE_CHECKING: from dppvalidator.cli.console import Console @@ -38,8 +39,8 @@ def add_parser(subparsers: Any) -> argparse.ArgumentParser: download_parser.add_argument( "-v", "--version", - default="0.6.1", - help="Schema version to download (default: 0.6.1)", + default=DEFAULT_SCHEMA_VERSION, + help=f"Schema version to download (default: {DEFAULT_SCHEMA_VERSION})", ) download_parser.add_argument( "-o", @@ -54,8 +55,8 @@ def add_parser(subparsers: Any) -> argparse.ArgumentParser: info_parser.add_argument( "-v", "--version", - default="0.6.1", - help="Schema version (default: 0.6.1)", + default=DEFAULT_SCHEMA_VERSION, + help=f"Schema version (default: {DEFAULT_SCHEMA_VERSION})", ) return parser @@ -75,27 +76,49 @@ def run(args: argparse.Namespace, console: Console) -> int: def _list_schemas(console: Console) -> int: - """List available schema versions.""" - from dppvalidator.exporters.contexts import CONTEXTS, DEFAULT_VERSION + """List available schema versions registered in ``SCHEMA_REGISTRY``. + + Source of truth is ``SCHEMA_REGISTRY``; ``CONTEXTS`` is consulted for the + JSON-LD context URLs of each version. Both are kept in lock-step — see + docs/plans/UNTP_0.7.0_MIGRATION.md §Phase 1. + """ + from dppvalidator.exporters.contexts import CONTEXTS + from dppvalidator.schemas.registry import SCHEMA_REGISTRY, SchemaRegistry + + default_version = SchemaRegistry().default_version table = console.create_table(title="Available UNTP DPP Schema Versions") table.add_column("Version") table.add_column("Default", justify="center") + table.add_column("Bundled", justify="center") table.add_column("Contexts") - for version, ctx in CONTEXTS.items(): - is_default = "✓" if version == DEFAULT_VERSION else "" - contexts = ", ".join(ctx.contexts) - table.add_row(version, is_default, contexts) + for version in sorted(SCHEMA_REGISTRY): + schema = SCHEMA_REGISTRY[version] + is_default = "✓" if version == default_version else "" + # ``sha256 is not None`` is the proxy for "we ship this file in-tree"; + # versions without a hash are registered but rely on a custom path + # being supplied at validate-time. + is_bundled = "✓" if schema.sha256 is not None else "" + ctx = CONTEXTS.get(version) + contexts = ", ".join(ctx.contexts) if ctx else "(no @context registered)" + table.add_row(version, is_default, is_bundled, contexts) console.print_table(table) return EXIT_VALID def _download_schema(version: str, output_dir: str | None, console: Console) -> int: - """Download schema for a version.""" + """Download a schema by version using the URL recorded in ``SCHEMA_REGISTRY``.""" from pathlib import Path + from dppvalidator.schemas.registry import SCHEMA_REGISTRY + + if version not in SCHEMA_REGISTRY: + console.print_error(f"Unknown version: {version}") + console.print(f"Available: {', '.join(sorted(SCHEMA_REGISTRY))}") + return EXIT_ERROR + try: import httpx except ImportError: @@ -103,7 +126,7 @@ def _download_schema(version: str, output_dir: str | None, console: Console) -> console.print("Install with: pip install 'dppvalidator[http]'") return EXIT_ERROR - schema_url = f"https://test.uncefact.org/vocabulary/untp/dpp/untp-dpp-schema-{version}.json" + schema_url = SCHEMA_REGISTRY[version].url try: logger.info("Downloading schema %s from %s", version, schema_url) @@ -126,26 +149,30 @@ def _download_schema(version: str, output_dir: str | None, console: Console) -> def _show_info(version: str, console: Console) -> int: - """Show schema information.""" + """Show schema information for ``version`` from the registries.""" from dppvalidator.exporters.contexts import CONTEXTS + from dppvalidator.schemas.registry import SCHEMA_REGISTRY - if version not in CONTEXTS: + if version not in SCHEMA_REGISTRY: console.print_error(f"Unknown version: {version}") - console.print(f"Available: {', '.join(CONTEXTS.keys())}") + console.print(f"Available: {', '.join(sorted(SCHEMA_REGISTRY))}") return EXIT_ERROR - ctx = CONTEXTS[version] - schema_url = f"https://test.uncefact.org/vocabulary/untp/dpp/untp-dpp-schema-{version}.json" + schema = SCHEMA_REGISTRY[version] + ctx = CONTEXTS.get(version) + type_arr = ctx.default_type if ctx else () + sha = schema.sha256 or "(not bundled — fetched on demand)" info = f"""[bold]UNTP DPP Schema v{version}[/bold] -Type: {", ".join(ctx.default_type)} +Type: {", ".join(type_arr) or "(no @context registered)"} Contexts: """ - for url in ctx.contexts: + for url in ctx.contexts if ctx else (): info += f" • {url}\n" - info += f"\nSchema URL:\n {schema_url}" + info += f"\nSchema URL:\n {schema.url}\n" + info += f"\nSHA-256:\n {sha}" console.print_panel(info, title=f"Schema v{version}") return EXIT_VALID diff --git a/src/dppvalidator/cli/commands/validate.py b/src/dppvalidator/cli/commands/validate.py index 6cc3ffe..6f057ff 100644 --- a/src/dppvalidator/cli/commands/validate.py +++ b/src/dppvalidator/cli/commands/validate.py @@ -3,12 +3,14 @@ from __future__ import annotations import argparse +import glob import json import sys from pathlib import Path from typing import TYPE_CHECKING, Any from dppvalidator.logging import get_logger +from dppvalidator.schemas.registry import DEFAULT_SCHEMA_VERSION if TYPE_CHECKING: from dppvalidator.cli.console import Console @@ -29,7 +31,8 @@ def add_parser(subparsers: Any) -> argparse.ArgumentParser: ) parser.add_argument( "input", - help="Input file path or '-' for stdin", + nargs="+", + help="Input file path(s), glob pattern(s), or '-' for stdin", ) parser.add_argument( "-s", @@ -46,8 +49,8 @@ def add_parser(subparsers: Any) -> argparse.ArgumentParser: ) parser.add_argument( "--schema-version", - default="0.6.1", - help="Schema version (default: 0.6.1)", + default=DEFAULT_SCHEMA_VERSION, + help=f"Schema version (default: {DEFAULT_SCHEMA_VERSION})", ) parser.add_argument( "--fail-fast", @@ -60,6 +63,15 @@ def add_parser(subparsers: Any) -> argparse.ArgumentParser: default=100, help="Maximum errors to report (default: 100)", ) + parser.add_argument( + "--upgrade-from", + default=None, + help=( + "Run the input through the compat shim from the named UNTP version " + "(e.g. 0.6.1) up to --schema-version before validating. Upgrade " + "warnings are reported alongside validation issues." + ), + ) return parser @@ -67,8 +79,9 @@ def run(args: argparse.Namespace, console: Console) -> int: """Execute validate command.""" from dppvalidator.validators import ValidationEngine - data = _load_input(args.input, console) - if data is None: + # Resolve input patterns to file paths + files = _resolve_inputs(args.input, console) + if not files: return EXIT_ERROR engine = ValidationEngine( @@ -76,21 +89,134 @@ def run(args: argparse.Namespace, console: Console) -> int: strict_mode=args.strict, ) - result = engine.validate( - data, - fail_fast=args.fail_fast, - max_errors=args.max_errors, - ) + upgrade_from = getattr(args, "upgrade_from", None) + if upgrade_from is not None: + _verify_upgrade_path(upgrade_from, args.schema_version, console) + + all_valid = True + has_load_error = False + results: list[tuple[str, Any]] = [] + + for file_path in files: + data = _load_input(file_path, console) + if data is None: + has_load_error = True + continue + + if upgrade_from is not None: + data, upgrade_warnings = _apply_upgrade(data, upgrade_from, file_path, console) + if upgrade_warnings: + _print_upgrade_warnings(upgrade_warnings, file_path, console) + + result = engine.validate( + data, + fail_fast=args.fail_fast, + max_errors=args.max_errors, + ) + results.append((file_path, result)) + if not result.valid: + all_valid = False - _output_result(result, args.format, args.input, console) + # If no files were successfully loaded, return error + if not results and has_load_error: + return EXIT_ERROR - return EXIT_VALID if result.valid else EXIT_INVALID + # Output results + if len(results) == 1: + _output_result(results[0][1], args.format, results[0][0], console) + elif results: + _output_batch_results(results, args.format, console) + + # Return error if any file failed to load, invalid if validation failed + if has_load_error: + return EXIT_ERROR + return EXIT_VALID if all_valid else EXIT_INVALID + + +def _resolve_inputs(inputs: list[str], console: Console) -> list[str]: + """Resolve input patterns to file paths, expanding globs. + + Cross-platform considerations: + - Windows: shell doesn't expand globs, so patterns arrive as-is + - Unix: shell may expand globs before reaching Python (if unquoted) + - Path separators are normalized for consistent output + """ + files: list[str] = [] + + for pattern in inputs: + if pattern == "-": + files.append("-") + continue + + # Normalize path separators for cross-platform glob matching + # Windows accepts forward slashes, and glob works better with them + normalized_pattern = pattern.replace("\\", "/") + + # Try glob expansion (works on all platforms) + expanded = glob.glob(normalized_pattern, recursive=True) + if expanded: + # Normalize output paths for consistent cross-platform display + files.extend(sorted(str(Path(f)) for f in expanded)) + elif Path(pattern).exists(): + # Pattern didn't match glob but file exists (exact path) + files.append(str(Path(pattern))) + else: + console.print_error(f"No files match pattern: {pattern}") + + return files + + +def _verify_upgrade_path(source: str, target: str, console: Console) -> None: + """Confirm we have a registered shim for ``source → target``. + + The current matrix is just ``0.6.x → 0.7.0`` (Phase 4). Anything else + is reported as a warning so the caller knows their flag had no effect; + we don't raise so the rest of the validation still runs. + """ + from dppvalidator.compat.upgrade_0_6_to_0_7 import upgrade as _u # noqa: F401 + + if not (source.startswith("0.6") and target.startswith("0.7")): + console.print_warning( + f"No upgrade shim registered for {source!r} → {target!r}; " + "input will be validated without transformation.", + ) + + +def _apply_upgrade( + data: dict[str, Any], source: str, path: str, console: Console +) -> tuple[dict[str, Any], list[Any]]: + """Run the v0.6 → v0.7 shim on ``data`` and return ``(upgraded, warnings)``.""" + if source.startswith("0.6"): + from dppvalidator.compat.upgrade_0_6_to_0_7 import upgrade + + try: + return upgrade(data) + except Exception as exc: # pragma: no cover — defensive + logger.exception("Upgrade shim crashed on %s", path) + console.print_error(f"Upgrade shim failed for {path}: {exc}") + return data, [] + return data, [] + + +def _print_upgrade_warnings(warnings: list[Any], input_path: str, console: Console) -> None: + """Print upgrade-shim warnings inline with validation output.""" + if not warnings: + return + console.print( + f"\n[bold yellow]Upgrade warnings ({len(warnings)})[/bold yellow] — {input_path}", + style="yellow", + ) + for w in warnings: + console.print(f" [{w.code}] ({w.severity.value}) {w.path}: {w.message}") def _load_input(input_path: str, console: Console) -> dict[str, Any] | None: """Load input data from file or stdin.""" try: if input_path == "-": + # Ensure UTF-8 encoding for stdin on all platforms (if supported) + if hasattr(sys.stdin, "reconfigure"): + sys.stdin.reconfigure(encoding="utf-8") # type: ignore[union-attr] content = sys.stdin.read() else: path = Path(input_path) @@ -98,7 +224,7 @@ def _load_input(input_path: str, console: Console) -> dict[str, Any] | None: logger.error("File not found: %s", input_path) console.print_error(f"File not found: {input_path}") return None - content = path.read_text() + content = path.read_text(encoding="utf-8") return json.loads(content) @@ -115,7 +241,8 @@ def _load_input(input_path: str, console: Console) -> dict[str, Any] | None: def _output_result(result: Any, fmt: str, input_path: str, console: Console) -> None: """Output validation result in specified format.""" if fmt == "json": - console.print(result.to_json()) + # Use plain print for JSON to avoid Rich formatting/ANSI codes + print(result.to_json()) return if fmt == "table": @@ -125,6 +252,30 @@ def _output_result(result: Any, fmt: str, input_path: str, console: Console) -> _output_text(result, input_path, console) +def _format_issue(issue: Any, console: Console) -> None: + """Format and print a single validation issue with optional fields.""" + console.print(f" [{issue.code}] {issue.path}: {issue.message}") + + if getattr(issue, "did_you_mean", None): + suggestions = ", ".join(f'"{v}"' for v in issue.did_you_mean) + console.print(f" Did you mean: {suggestions}?", style="cyan") + + if issue.suggestion: + console.print(f" 💡 {issue.suggestion}", style="dim") + + if issue.docs_url: + console.print(f" 📖 {issue.docs_url}", style="dim blue") + + +def _print_issues(issues: list[Any], label: str, style: str, console: Console) -> None: + """Print a section of issues with header.""" + if not issues: + return + console.print(f"\n[bold {style}]{label} ({len(issues)}):[/bold {style}]", style=style) + for issue in issues: + _format_issue(issue, console) + + def _output_text(result: Any, input_path: str, console: Console) -> None: """Output result as text.""" if result.valid: @@ -134,28 +285,8 @@ def _output_text(result: Any, input_path: str, console: Console) -> None: console.print(f"Schema version: {result.schema_version}") - if result.errors: - console.print(f"\n[bold red]Errors ({len(result.errors)}):[/bold red]", style="red") - for err in result.errors: - console.print(f" [{err.code}] {err.path}: {err.message}") - if err.did_you_mean: - suggestions = ", ".join(f'"{v}"' for v in err.did_you_mean) - console.print(f" Did you mean: {suggestions}?", style="cyan") - if err.suggestion: - console.print(f" 💡 {err.suggestion}", style="dim") - if err.docs_url: - console.print(f" 📖 {err.docs_url}", style="dim blue") - - if result.warnings: - console.print( - f"\n[bold yellow]Warnings ({len(result.warnings)}):[/bold yellow]", style="yellow" - ) - for warn in result.warnings: - console.print(f" [{warn.code}] {warn.path}: {warn.message}") - if warn.suggestion: - console.print(f" 💡 {warn.suggestion}", style="dim") - if warn.docs_url: - console.print(f" 📖 {warn.docs_url}", style="dim blue") + _print_issues(result.errors, "Errors", "red", console) + _print_issues(result.warnings, "Warnings", "yellow", console) if result.info: console.print(f"\nInfo ({len(result.info)}):") @@ -190,3 +321,61 @@ def _output_table(result: Any, input_path: str, console: Console) -> None: issues.add_row("WARNING", warn.code, warn.path, warn.message[:50]) console.print_table(issues) + + +def _output_batch_results(results: list[tuple[str, Any]], fmt: str, console: Console) -> None: + """Output results for multiple files.""" + if fmt == "json": + batch_output = { + "files": [{"path": path, "result": result.to_dict()} for path, result in results], + "summary": { + "total": len(results), + "valid": sum(1 for _, r in results if r.valid), + "invalid": sum(1 for _, r in results if not r.valid), + }, + } + print(json.dumps(batch_output, indent=2, default=str)) + return + + if fmt == "table": + _output_batch_table(results, console) + return + + # Text format - output each result + for path, result in results: + _output_text(result, path, console) + + # Summary (use ASCII hyphen for cross-platform compatibility) + valid_count = sum(1 for _, r in results if r.valid) + invalid_count = len(results) - valid_count + console.print(f"\n{'-' * 40}") + console.print(f"[bold]Summary:[/bold] {len(results)} files processed") + console.print(f" [green]✓ Valid:[/green] {valid_count}") + console.print(f" [red]✗ Invalid:[/red] {invalid_count}") + + +def _output_batch_table(results: list[tuple[str, Any]], console: Console) -> None: + """Output batch results as table.""" + summary = console.create_table(title="Batch Validation Results") + summary.add_column("Status", style="bold") + summary.add_column("Path") + summary.add_column("Errors", justify="right") + summary.add_column("Warnings", justify="right") + + for path, result in results: + status = "[green]✓[/green]" if result.valid else "[red]✗[/red]" + summary.add_row( + status, + path, + str(result.error_count), + str(result.warning_count), + ) + + console.print_table(summary) + + valid_count = sum(1 for _, r in results if r.valid) + console.print( + f"\n[bold]Total:[/bold] {len(results)} files | " + f"[green]{valid_count} valid[/green] | " + f"[red]{len(results) - valid_count} invalid[/red]" + ) diff --git a/src/dppvalidator/cli/commands/watch.py b/src/dppvalidator/cli/commands/watch.py index 4089efc..c72089a 100644 --- a/src/dppvalidator/cli/commands/watch.py +++ b/src/dppvalidator/cli/commands/watch.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +from dppvalidator.schemas.registry import DEFAULT_SCHEMA_VERSION from dppvalidator.validators import ValidationEngine if TYPE_CHECKING: @@ -47,6 +48,142 @@ def record_validation(self, valid: bool, errors: int, warnings: int) -> None: self.total_warnings += warnings +@dataclass +class FileWatcher: + """Handles file change detection with debouncing (Single Responsibility).""" + + watch_path: Path + pattern: str + debounce_ms: int = DEBOUNCE_MS + + _file_mtimes: dict[Path, float] = field(default_factory=dict) + _last_change_time: dict[Path, float] = field(default_factory=dict) + + def initialize(self, files: list[Path]) -> None: + """Initialize tracking for given files.""" + for f in files: + try: + if f.exists(): + self._file_mtimes[f] = f.stat().st_mtime + except OSError: + pass + + def get_changed_files(self, console: Console) -> list[Path]: + """Detect changed files since last check.""" + changed: list[Path] = [] + current_time = time.time() + + if self.watch_path.is_dir(): + self._scan_for_new_and_removed(changed, console) + + self._check_modifications(changed, current_time) + return changed + + def _scan_for_new_and_removed(self, changed: list[Path], console: Console) -> None: + """Scan directory for new and removed files.""" + try: + current_files = set(_find_json_files(self.watch_path, self.pattern)) + watched_files = set(self._file_mtimes.keys()) + + for f in current_files - watched_files: + self._track_new_file(f, changed, console) + + for f in watched_files - current_files: + self._handle_removed(f, console) + except OSError as e: + console.print(f"\n [yellow]⚠[/yellow] Scan error: {e}") + + def _track_new_file(self, f: Path, changed: list[Path], console: Console) -> None: + """Track a newly discovered file.""" + try: + if f.exists(): + self._file_mtimes[f] = f.stat().st_mtime + changed.append(f) + console.print(f"\n [blue]+[/blue] New file: {f.name}") + except OSError: + pass + + def _handle_removed(self, f: Path, console: Console) -> None: + """Handle a removed file.""" + if f in self._file_mtimes: + del self._file_mtimes[f] + console.print(f"\n [yellow]-[/yellow] Removed: {f.name}") + + def _check_modifications(self, changed: list[Path], current_time: float) -> None: + """Check for modified files with debouncing.""" + for f in list(self._file_mtimes.keys()): + try: + if not f.exists(): + del self._file_mtimes[f] + continue + + current_mtime = f.stat().st_mtime + if current_mtime > self._file_mtimes[f]: + if self._is_debounced(f, current_time): + continue + + self._file_mtimes[f] = current_mtime + self._last_change_time[f] = current_time + + if f not in changed: + changed.append(f) + except OSError: + if f in self._file_mtimes: + del self._file_mtimes[f] + + def _is_debounced(self, f: Path, current_time: float) -> bool: + """Check if file change should be debounced.""" + if f not in self._last_change_time: + return False + return (current_time - self._last_change_time[f]) * 1000 < self.debounce_ms + + +@dataclass +class WatchLoop: + """Orchestrates the watch loop lifecycle (Single Responsibility).""" + + watcher: FileWatcher + engine: ValidationEngine + console: Console + stats: WatchStats + interval: float + + def run(self, initial_files: list[Path]) -> int: + """Execute the main watch loop.""" + self._print_header(initial_files) + self.watcher.initialize(initial_files) + _validate_all(initial_files, self.engine, self.console, self.stats) + + try: + self._loop() + except KeyboardInterrupt: + _print_summary(self.console, self.stats) + return EXIT_SUCCESS + return EXIT_SUCCESS # pragma: no cover + + def _print_header(self, files: list[Path]) -> None: + """Print watch session header.""" + self.stats.files_watched = len(files) + self.console.print(f" Interval: {self.interval}s") + self.console.print(f" Strict: {self.engine.strict_mode}") + self.console.print("\nPress Ctrl+C to stop\n") + self.console.print("─" * 50) + + def _loop(self) -> None: + """Main polling loop.""" + while True: # pragma: no cover - infinite loop tested via KeyboardInterrupt + time.sleep(self.interval) + changed = self.watcher.get_changed_files(self.console) + if changed: + self._on_change(changed) + + def _on_change(self, files: list[Path]) -> None: + """Handle detected file changes.""" + timestamp = time.strftime("%H:%M:%S") + self.console.print(f"\n[{timestamp}] Changes detected:") + _validate_files(files, self.engine, self.console, self.stats) + + def add_parser(subparsers: Any) -> argparse.ArgumentParser: """Add watch subparser.""" parser = subparsers.add_parser( @@ -80,142 +217,82 @@ def add_parser(subparsers: Any) -> argparse.ArgumentParser: ) parser.add_argument( "--schema-version", - default="0.6.1", - help="Schema version (default: 0.6.1)", + default=DEFAULT_SCHEMA_VERSION, + help=f"Schema version (default: {DEFAULT_SCHEMA_VERSION})", ) return parser -def run(args: argparse.Namespace, console: Console) -> int: - """Execute watch command.""" +def _validate_args(args: argparse.Namespace, console: Console) -> Path | None: + """Validate command arguments. Returns watch_path or None on error.""" watch_path = Path(args.path).resolve() if not watch_path.exists(): console.print_error(f"Path not found: {watch_path}") - return EXIT_ERROR + return None - # Validate interval if args.interval < 0.1: console.print_error("Interval must be at least 0.1 seconds") - return EXIT_ERROR + return None + if args.interval > 60: console.print_error("Interval cannot exceed 60 seconds") - return EXIT_ERROR + return None - try: - engine = ValidationEngine( - schema_version=args.schema_version, - strict_mode=args.strict, - ) - except Exception as e: - console.print_error(f"Failed to initialize validation engine: {e}") - return EXIT_ERROR + return watch_path - stats = WatchStats() - # Determine files to watch +def _discover_files(watch_path: Path, pattern: str, console: Console) -> list[Path] | None: + """Discover files to watch. Returns file list or None on error.""" if watch_path.is_file(): if not _is_valid_json_file(watch_path): console.print_error(f"Not a valid JSON file: {watch_path}") - return EXIT_ERROR - files_to_watch = [watch_path] + return None console.print(f"\n👁️ Watching: {watch_path}") - else: - files_to_watch = _find_json_files(watch_path, args.pattern) - console.print(f"\n👁️ Watching: {watch_path}/{args.pattern}") - console.print(f" Found {len(files_to_watch)} file(s)") + return [watch_path] - if not files_to_watch: - console.print_error(f"No files matching pattern: {args.pattern}") - console.print(" 💡 Create some .json files or check your pattern") - return EXIT_ERROR + files = _find_json_files(watch_path, pattern) + console.print(f"\n👁️ Watching: {watch_path}/{pattern}") + console.print(f" Found {len(files)} file(s)") - stats.files_watched = len(files_to_watch) - console.print(f" Interval: {args.interval}s") - console.print(f" Strict: {args.strict}") - console.print("\nPress Ctrl+C to stop\n") - console.print("─" * 50) + if not files: + console.print_error(f"No files matching pattern: {pattern}") + console.print(" 💡 Create some .json files or check your pattern") + return None - # Track file modification times with debouncing - file_mtimes: dict[Path, float] = {} - last_change_time: dict[Path, float] = {} + return files - for f in files_to_watch: - try: - if f.exists(): - file_mtimes[f] = f.stat().st_mtime - except OSError: # pragma: no cover - pass # File may have been deleted - # Initial validation - _validate_all(files_to_watch, engine, console, stats) +def run(args: argparse.Namespace, console: Console) -> int: + """Execute watch command.""" + watch_path = _validate_args(args, console) + if watch_path is None: + return EXIT_ERROR try: - while True: # pragma: no cover - infinite loop tested via KeyboardInterrupt - time.sleep(args.interval) - - changed_files: list[Path] = [] - current_time = time.time() - - # Re-scan for new files if watching directory - if watch_path.is_dir(): - try: - current_files = set(_find_json_files(watch_path, args.pattern)) - watched_files = set(file_mtimes.keys()) - - # New files - for f in current_files - watched_files: - try: - if f.exists(): - file_mtimes[f] = f.stat().st_mtime - changed_files.append(f) - console.print(f"\n [blue]+[/blue] New file: {f.name}") - except OSError: - pass - - # Removed files - for f in watched_files - current_files: - if f in file_mtimes: - del file_mtimes[f] - console.print(f"\n [yellow]-[/yellow] Removed: {f.name}") - except OSError as e: - console.print(f"\n [yellow]⚠[/yellow] Scan error: {e}") - - # Check modification times with debouncing - for f in list(file_mtimes.keys()): - try: - if not f.exists(): - del file_mtimes[f] - continue - - current_mtime = f.stat().st_mtime - if current_mtime > file_mtimes[f]: - # Debounce: wait for file to settle - if ( - f in last_change_time - and (current_time - last_change_time[f]) * 1000 < DEBOUNCE_MS - ): - continue - - file_mtimes[f] = current_mtime - last_change_time[f] = current_time + engine = ValidationEngine( + schema_version=args.schema_version, + strict_mode=args.strict, + ) + except Exception as e: + console.print_error(f"Failed to initialize validation engine: {e}") + return EXIT_ERROR - if f not in changed_files: - changed_files.append(f) - except OSError: - # File may have been deleted or is inaccessible - if f in file_mtimes: - del file_mtimes[f] + files_to_watch = _discover_files(watch_path, args.pattern, console) + if files_to_watch is None: + return EXIT_ERROR - # Validate changed files - if changed_files: - timestamp = time.strftime("%H:%M:%S") - console.print(f"\n[{timestamp}] Changes detected:") - _validate_files(changed_files, engine, console, stats) + watcher = FileWatcher(watch_path=watch_path, pattern=args.pattern) + stats = WatchStats() + loop = WatchLoop( + watcher=watcher, + engine=engine, + console=console, + stats=stats, + interval=args.interval, + ) - except KeyboardInterrupt: - _print_summary(console, stats) - return EXIT_SUCCESS + return loop.run(files_to_watch) def _find_json_files(directory: Path, pattern: str) -> list[Path]: @@ -238,7 +315,7 @@ def _is_valid_json_file(filepath: Path) -> bool: if filepath.suffix.lower() != ".json": return False try: - json.loads(filepath.read_text()) + json.loads(filepath.read_text(encoding="utf-8")) return True except (json.JSONDecodeError, OSError): return False diff --git a/src/dppvalidator/cli/console.py b/src/dppvalidator/cli/console.py index e64a50a..fa829cd 100644 --- a/src/dppvalidator/cli/console.py +++ b/src/dppvalidator/cli/console.py @@ -122,9 +122,9 @@ def print_panel(self, content: str, title: str | None = None, style: str = "blue self._rich.print(Panel(content, title=title, border_style=style)) else: if title: - print(f"\n{title}") - print("-" * 40) - print(content) + print(f"\n{title}", file=self._file) + print("-" * 40, file=self._file) + print(content, file=self._file) def create_table(self, title: str | None = None) -> Table | _FallbackTable: """Create a table for output. diff --git a/src/dppvalidator/cli/main.py b/src/dppvalidator/cli/main.py index f7f9da1..ceaf136 100644 --- a/src/dppvalidator/cli/main.py +++ b/src/dppvalidator/cli/main.py @@ -7,7 +7,16 @@ from collections.abc import Callable from typing import NoReturn -from dppvalidator.cli.commands import completions, doctor, export, init, schema, validate, watch +from dppvalidator.cli.commands import ( + completions, + doctor, + export, + init, + migrate, + schema, + validate, + watch, +) from dppvalidator.cli.console import Console from dppvalidator.logging import configure_logging @@ -26,6 +35,7 @@ "init": init.run, "doctor": doctor.run, "watch": watch.run, + "migrate": migrate.run, "completions": lambda args, _: completions.run(args), } @@ -72,6 +82,7 @@ def create_parser() -> argparse.ArgumentParser: init.add_parser(subparsers) doctor.add_parser(subparsers) watch.add_parser(subparsers) + migrate.add_parser(subparsers) completions.add_parser(subparsers) return parser diff --git a/src/dppvalidator/compat/__init__.py b/src/dppvalidator/compat/__init__.py new file mode 100644 index 0000000..1687992 --- /dev/null +++ b/src/dppvalidator/compat/__init__.py @@ -0,0 +1,69 @@ +"""Cross-version compatibility utilities for dppvalidator. + +This package houses two distinct concerns: + +1. **Active-version helpers** — :func:`active_version` and + :func:`is_version` give callers a single import-stable way to check + "what version of UNTP DPP is the engine defaulting to right now?" + without having to reach into :mod:`dppvalidator.schemas.registry`. + +2. **Compatibility shims** — modules named ``upgrade__to_`` + that take a payload in the older shape and rewrite it in place to + match the newer one. They emit structured :class:`UpgradeWarning` + entries when a transformation is lossy or has to synthesise a value; + the caller decides whether to accept the result or surface the + warnings to the end user. + +The :func:`active_version` and :func:`is_version` helpers are listed in +``.claude/rules/untp-versioning.md`` (cardinal rule 1) as the +canonical alternative to literal version strings outside the +:mod:`dppvalidator.schemas.registry` and +:mod:`dppvalidator.exporters.contexts` registries. + +See ``docs/plans/UNTP_0.7.0_MIGRATION.md`` §Phase 4 for the design. +""" + +from __future__ import annotations + +from dppvalidator.compat.upgrade_0_6_to_0_7 import ( + UPG_CODE_LOSSY, + UPG_CODE_REQUIRED_FIELD_MISSING, + UPG_CODE_SYNTHESISED, + UPG_CODE_UNMAPPED_COUNTRY, + UpgradeSeverity, + UpgradeWarning, + upgrade, +) + + +def active_version() -> str: + """Return the UNTP DPP version this build of dppvalidator targets. + + This is the value of :data:`DEFAULT_SCHEMA_VERSION` from the schema + registry, surfaced as a function so callers don't have to import the + registry directly. Use this whenever you need a "current default" + version literal in feature code — the no-version-literals guard + test (``tests/unit/test_no_version_literals.py``) refuses to let you + hardcode the string. + """ + from dppvalidator.schemas.registry import DEFAULT_SCHEMA_VERSION + + return DEFAULT_SCHEMA_VERSION + + +def is_version(version: str) -> bool: + """Return ``True`` if ``version`` matches the active default version.""" + return version == active_version() + + +__all__ = [ + "UPG_CODE_LOSSY", + "UPG_CODE_REQUIRED_FIELD_MISSING", + "UPG_CODE_SYNTHESISED", + "UPG_CODE_UNMAPPED_COUNTRY", + "UpgradeSeverity", + "UpgradeWarning", + "active_version", + "is_version", + "upgrade", +] diff --git a/src/dppvalidator/compat/upgrade_0_6_to_0_7.py b/src/dppvalidator/compat/upgrade_0_6_to_0_7.py new file mode 100644 index 0000000..b9c0840 --- /dev/null +++ b/src/dppvalidator/compat/upgrade_0_6_to_0_7.py @@ -0,0 +1,972 @@ +"""Compatibility shim: rewrite UNTP DPP 0.6.x payloads into 0.7.0 shape. + +This module is the operational core of Phase 4 of +``docs/plans/UNTP_0.7.0_MIGRATION.md``. It takes a JSON ``dict`` whose +shape matches the UNTP DPP 0.6.x wire format and returns a new ``dict`` +in 0.7.0 shape, plus a list of :class:`UpgradeWarning` entries +describing each transformation that was lossy, synthesised, or skipped. + +Design principles: + +- **Pure transformation, no validation.** The shim never raises on + malformed input; it makes a best-effort upgrade and lets the caller + validate the result against the v0.7 model. Anything that *can't* be + upgraded faithfully is surfaced as an :class:`UpgradeWarning`. +- **Structural over semantic.** The shim rewires field names and shapes + but never invents content. ``materialType`` cannot be guessed from + ``name``; ``massFraction`` cannot be inferred. Missing required-in-0.7 + fields therefore produce ``UPG004`` warnings, not synthesised values. +- **Side-effect-free.** The input ``dict`` is deep-copied; callers can + upgrade-then-keep-original without surprises. +- **Deterministic.** Two runs against the same input produce + byte-identical output and the same warning set in the same order. + +The 17 transformation steps and 4 warning codes are documented inline. +The transformation order matters: e.g. step 1 (context URL) must run +before step 3 (envelope flattening) so that detection helpers continue +to work on the partially-upgraded payload. +""" + +from __future__ import annotations + +import base64 +import re +from collections.abc import Mapping +from copy import deepcopy +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from dppvalidator.logging import get_logger +from dppvalidator.vocabularies.loader import get_bundled_country_codes + +logger = get_logger(__name__) + + +# ============================================================================= +# Warning codes and types +# ============================================================================= + + +# Codes from docs/plans/UNTP_0.7.0_MIGRATION.md §Phase 4. The ``UPG`` prefix +# distinguishes them from validator codes (``CQ``, ``MDL``, ``JLD``, ``VER``). +UPG_CODE_LOSSY = "UPG001" +UPG_CODE_SYNTHESISED = "UPG002" +UPG_CODE_UNMAPPED_COUNTRY = "UPG003" +UPG_CODE_REQUIRED_FIELD_MISSING = "UPG004" + + +class UpgradeSeverity(str, Enum): + """Severity levels for :class:`UpgradeWarning`. + + ``info`` is for changes the caller can safely ignore (e.g. type + arrays stripped from embedded objects). ``warning`` is for + transformations that may need manual review (synthesised values, + unknown country codes). ``error`` is for required-in-0.7 fields + that the shim cannot synthesise — the result will not validate + cleanly until the caller fills these in. + """ + + INFO = "info" + WARNING = "warning" + ERROR = "error" + + +@dataclass(frozen=True, slots=True) +class UpgradeWarning: + """A single transformation event surfaced by the upgrade shim. + + Attributes: + code: One of the ``UPG`` codes above. Stable across releases — + consumers may pattern-match on the code to decide whether + to accept the upgrade. + path: JSONPath-like locator (e.g. + ``credentialSubject.materialProvenance[2].originCountry``) + pointing at where the transformation was applied. + message: Human-readable explanation. Include enough context to + be actionable when read in isolation. + severity: One of :class:`UpgradeSeverity`. Drives whether + ``dppvalidator migrate`` refuses to write the upgraded JSON + without ``--accept-warnings``. + """ + + code: str + path: str + message: str + severity: UpgradeSeverity = UpgradeSeverity.WARNING + + +# ============================================================================= +# Constants +# ============================================================================= + + +# v0.6.x → v0.7.0 context URL substitution. The v0.6.x family stored its +# UNTP context under ``test.uncefact.org/vocabulary/untp/dpp//``; +# v0.7.0 moved to ``vocabulary.uncefact.org/untp/0.7.0/context/``. We treat +# the patch level on the v0.6 side as flexible because the same shape +# applies to 0.6.0 and 0.6.1. +_V06_CONTEXT_RE = re.compile( + r"^https://test\.uncefact\.org/vocabulary/untp/dpp/0\.6(?:\.\d+)?/?$", +) +_V07_CONTEXT_URL = "https://vocabulary.uncefact.org/untp/0.7.0/context/" + +# v0.7.0 ``Image`` requires ``name``, ``imageData``, ``mediaType``. The shim +# uses these defaults when upgrading a v0.6 ``Material.symbol`` (which was +# a bare base64 string) — image bytes carry the data, the other two +# fields are synthesised and surfaced via UPG002. +_DEFAULT_IMAGE_MEDIA_TYPE = "image/png" +_DEFAULT_IMAGE_NAME = "Material symbol" + +# Embedded objects whose v0.6 wire shape carried a ``type: [...]`` +# discriminator that v0.7 dropped. Stripping these keeps strict-mode +# validation against the v0.7 schema clean. +_TYPES_TO_STRIP_ON: frozenset[str] = frozenset( + { + # Product-level container keys whose embedded objects no longer + # carry a ``type`` discriminator in v0.7 (Dimension, Characteristics). + "dimensions", + "characteristics", + # Measure-shaped sub-fields under Dimension and Performance.metricValue. + "weight", + "length", + "width", + "height", + "volume", + "mass", + "measure", + "thresholdValue", + "metricValue", + # Claim-shape descriptor (we keep the top-level Claim type but drop + # type arrays on lifted scorecard claims to match the v0.7 schema). + "claim", + } +) + +# Fields where Material.symbol may sit. v0.6 used a single string; v0.7 +# expects an Image object. Sentinel placeholders that are clearly not +# real base64 (e.g. "undefined" in our own legacy fixtures) are dropped +# rather than smuggled through as fake image bytes. +_KNOWN_SYMBOL_PLACEHOLDERS: frozenset[str] = frozenset({"undefined", "null", "none", ""}) + + +# ============================================================================= +# Public entry point +# ============================================================================= + + +def upgrade( + data: dict[str, Any], + *, + country_lookup: Mapping[str, str] | None = None, +) -> tuple[dict[str, Any], list[UpgradeWarning]]: + """Upgrade a UNTP DPP v0.6.x payload to v0.7.0 shape. + + Args: + data: The v0.6.x payload as a parsed JSON ``dict``. The input is + deep-copied; the caller's object is never mutated. + country_lookup: Optional ISO-3166-1 alpha-2 → country-name map. + When supplied, scalar country codes (e.g. ``"DE"``) are + wrapped as ``{countryCode: "DE", countryName: "Germany"}`` + using this mapping. When omitted, only ``countryCode`` is + populated and a UPG002 warning fires per wrapped scalar. + The bundled :data:`get_bundled_country_codes` set is always + used as the validity check (UPG003 fires for unknown codes). + + Returns: + Tuple of ``(upgraded_dict, warnings)``. The dict is ready to be + validated against the v0.7.0 model. Warnings are ordered by + the transformation step that emitted them, then by document + position. + + The 17 transformation steps execute in the order documented in the + migration plan. Each step is a private helper; the entry point is + this thin orchestrator. + """ + if not isinstance(data, dict): + raise TypeError( + f"upgrade() requires a dict, got {type(data).__name__!r}", + ) + + upgraded = deepcopy(data) + warnings: list[UpgradeWarning] = [] + valid_codes = get_bundled_country_codes() + + # Step 1 — context URL substitution. + _step1_replace_context(upgraded, warnings) + + # Step 3 — drop ProductPassport envelope (run before steps 2/4-13 so + # the rest of the pipeline operates on the flattened Product). Steps + # 4 (materialsProvenance), 5 (dueDiligenceDeclaration), 6 + # (conformityClaim), 7 (scorecards), and granularityLevel migration + # all read from the original ProductPassport envelope and write to + # the new credentialSubject (now a Product). + _step3_flatten_envelope(upgraded, warnings) + + # Step 2 — envelope ``name`` / ``validFrom`` (synthesise if absent). + # Runs after step 3 so we can synthesise ``name`` from + # ``credentialSubject.name`` (which by now is the product's name). + _step2_envelope_required_fields(upgraded, warnings) + + cs = upgraded.get("credentialSubject") + if not isinstance(cs, dict): + # No product to upgrade further — return what we have. + return upgraded, warnings + + # Steps 10–17 act on the credentialSubject (now a Product). + _step10_wrap_product_category(cs, warnings) + _step11_party_to_related_party(cs, warnings) + _step12_further_information_to_related_document(cs, warnings) + _step13_drop_registered_id(cs, warnings) + _step9_wrap_country_codes(cs, warnings, country_lookup, valid_codes) + _step14_material_symbol_to_image(cs, warnings) + _step17_material_required_fields(cs, warnings) + _step8_wrap_claim_references(cs, warnings) + _step16_rename_scheme_id(cs, warnings) + _step15_strip_embedded_types(cs, warnings) + + return upgraded, warnings + + +# ============================================================================= +# Step 1 — context URL substitution +# ============================================================================= + + +def _step1_replace_context( + data: dict[str, Any], + warnings: list[UpgradeWarning], # noqa: ARG001 — uniform signature; no warnings emitted +) -> None: + """Swap the v0.6 UNTP context URL for the v0.7.0 one. + + The W3C VC v2 context entry is left untouched. Anything that doesn't + look like a v0.6.x UNTP context URL is also left as-is — third-party + extensions plug into the context list, and we shouldn't smuggle + their references away. + """ + ctx = data.get("@context") + if not isinstance(ctx, list): + return + for i, entry in enumerate(ctx): + if isinstance(entry, str) and _V06_CONTEXT_RE.match(entry): + ctx[i] = _V07_CONTEXT_URL + + +# ============================================================================= +# Step 2 — envelope required fields +# ============================================================================= + + +def _step2_envelope_required_fields(data: dict[str, Any], warnings: list[UpgradeWarning]) -> None: + """Ensure the envelope has the v0.7-required ``name`` and ``validFrom``. + + ``name`` becomes a top-level required field in v0.7 (was implicit + via the product). ``validFrom`` was already common but the schema + elevates it to required — defensively add a placeholder if missing + so the result at least has a parsable shape. + """ + if not data.get("name"): + cs = data.get("credentialSubject") + synthesised: str | None = None + if isinstance(cs, dict): + cs_name = cs.get("name") + if isinstance(cs_name, str) and cs_name: + synthesised = cs_name + if synthesised: + data["name"] = synthesised + warnings.append( + UpgradeWarning( + code=UPG_CODE_SYNTHESISED, + path="name", + message=( + "Top-level ``name`` missing in v0.6 payload; " + "synthesised from credentialSubject.name." + ), + ), + ) + else: + warnings.append( + UpgradeWarning( + code=UPG_CODE_REQUIRED_FIELD_MISSING, + path="name", + message=( + "Top-level ``name`` is required in v0.7.0 and could " + "not be synthesised — provide manually." + ), + severity=UpgradeSeverity.ERROR, + ), + ) + + if not data.get("validFrom"): + warnings.append( + UpgradeWarning( + code=UPG_CODE_REQUIRED_FIELD_MISSING, + path="validFrom", + message=( + "Envelope ``validFrom`` is required in v0.7.0 and was " + "absent in the source payload." + ), + severity=UpgradeSeverity.ERROR, + ), + ) + + +# ============================================================================= +# Step 3 — drop ProductPassport envelope +# ============================================================================= + + +def _step3_flatten_envelope(data: dict[str, Any], warnings: list[UpgradeWarning]) -> None: + """Replace ``credentialSubject`` (the ProductPassport) with its product. + + The v0.6 ``ProductPassport`` carried siblings of the product + (``conformityClaim``, scorecards, ``materialsProvenance``, + ``dueDiligenceDeclaration``, ``granularityLevel``). v0.7 hangs all + of those off the Product itself. We move them in this step so later + steps can operate on a single flat object. + + ``granularityLevel`` becomes ``idGranularity`` here. + """ + cs = data.get("credentialSubject") + if not isinstance(cs, dict): + return + + pp_types = cs.get("type") + is_product_passport = ( + isinstance(pp_types, list) and "ProductPassport" in pp_types + ) or "product" in cs + + if not is_product_passport: + # Already flat — nothing to do. + return + + product = cs.get("product") + if not isinstance(product, dict): + # ProductPassport with no product — wrap an empty product so the + # rest of the pipeline has a target to write into. + product = {} + + # Carry siblings from ProductPassport onto the new credentialSubject. + granularity = cs.get("granularityLevel") + materials_provenance = cs.get("materialsProvenance") + conformity_claim = cs.get("conformityClaim") + emissions_scorecard = cs.get("emissionsScorecard") + circularity_scorecard = cs.get("circularityScorecard") + traceability_information = cs.get("traceabilityInformation") + due_diligence = cs.get("dueDiligenceDeclaration") + pp_id = cs.get("id") + + new_cs: dict[str, Any] = dict(product) + new_cs["type"] = ["Product"] + # Field rename: ``serialNumber`` (v0.6) → ``itemNumber`` (v0.7). + if "serialNumber" in new_cs and "itemNumber" not in new_cs: + new_cs["itemNumber"] = new_cs.pop("serialNumber") + if isinstance(granularity, str): + new_cs.setdefault("idGranularity", granularity) + if isinstance(materials_provenance, list): + new_cs["materialProvenance"] = materials_provenance + # Preserve the ProductPassport.id if the inner product had none — it + # was the credential-subject identifier in v0.6. + if pp_id and not new_cs.get("id"): + new_cs["id"] = pp_id + + performance_claim: list[dict[str, Any]] = [] + # Step 6 — conformityClaim → performanceClaim (kept whole here; field + # renames inside each Claim happen in step 8/16/15 below). + if isinstance(conformity_claim, list): + performance_claim.extend(_v06_claim_to_v07_claim(c) for c in conformity_claim) + + # Step 7 — scorecards → Claim entries. + if isinstance(emissions_scorecard, dict): + performance_claim.append( + _scorecard_to_claim( + topic="emissions", + scorecard=emissions_scorecard, + topic_name="Emissions", + ), + ) + if isinstance(circularity_scorecard, dict): + performance_claim.append( + _scorecard_to_claim( + topic="circularity", + scorecard=circularity_scorecard, + topic_name="Circularity", + ), + ) + if isinstance(traceability_information, list): + for entry in traceability_information: + if isinstance(entry, dict): + performance_claim.append( + _scorecard_to_claim( + topic="traceability", + scorecard=entry, + topic_name="Traceability", + ), + ) + + if performance_claim: + new_cs["performanceClaim"] = performance_claim + + # Step 5 — dueDiligenceDeclaration → relatedDocument[]. + if isinstance(due_diligence, dict): + related = list(new_cs.get("relatedDocument") or []) + link = dict(due_diligence) + link.setdefault("name", "Due diligence declaration") + related.append(link) + new_cs["relatedDocument"] = related + + data["credentialSubject"] = new_cs + + if conformity_claim or emissions_scorecard or circularity_scorecard or traceability_information: + warnings.append( + UpgradeWarning( + code=UPG_CODE_LOSSY, + path="credentialSubject.performanceClaim", + message=( + "v0.6 conformityClaim and scorecard arrays were folded into " + "v0.7 performanceClaim. Inner field shapes were rewritten " + "best-effort; review for fidelity." + ), + severity=UpgradeSeverity.WARNING, + ), + ) + + +def _v06_claim_to_v07_claim(claim: dict[str, Any]) -> dict[str, Any]: + """Rewire a single v0.6 ``Claim`` into v0.7 shape. + + Field renames (handled here, not in shared helpers, because they're + Claim-specific): + + - ``assessmentDate`` → ``claimDate`` + - ``assessmentCriteria`` (list[Criterion]) → ``referenceCriteria`` (list[dict]) + - ``declaredValue`` (list[Metric]) → ``claimedPerformance`` (list[Performance]) + - ``conformityTopic`` (str) → ``conformityTopic`` (list[ConformityTopic]) + - ``conformityEvidence`` (SecureLink) → ``evidence`` ([Link]) + - ``referenceStandard`` (Standard) → ``referenceStandard`` (list[dict]) + - ``referenceRegulation`` (Regulation) → ``referenceRegulation`` (list[dict]) + - ``conformance`` (bool) — dropped (no v0.7 equivalent at top level) + + The shape rewrite is best-effort: ``Performance`` requires a + ``metric`` plus at least one of ``measure``/``score``, so v0.6 + ``Metric`` rows get split (``metricName`` → ``metric.name``, + ``metricValue`` → ``measure``, ``score`` → ``score.code``). + """ + out: dict[str, Any] = {"type": ["Claim"]} + + # Pass-through fields. + for k in ("id", "description"): + if k in claim: + out[k] = claim[k] + # ``name`` is required in v0.7 Claim — fall back to description if absent. + name = claim.get("name") or claim.get("description") + if name: + out["name"] = name + + # Date rename. + if "assessmentDate" in claim: + out["claimDate"] = claim["assessmentDate"] + + # Criteria rename. + criteria = claim.get("assessmentCriteria") + if isinstance(criteria, list): + out["referenceCriteria"] = [_clean_v06_object(c) for c in criteria if isinstance(c, dict)] + + # Standard / Regulation: scalar → single-element list (step 8). + if isinstance(claim.get("referenceStandard"), dict): + out["referenceStandard"] = [claim["referenceStandard"]] + elif isinstance(claim.get("referenceStandard"), list): + out["referenceStandard"] = claim["referenceStandard"] + if isinstance(claim.get("referenceRegulation"), dict): + out["referenceRegulation"] = [claim["referenceRegulation"]] + elif isinstance(claim.get("referenceRegulation"), list): + out["referenceRegulation"] = claim["referenceRegulation"] + + # Performance readings. + declared = claim.get("declaredValue") + if isinstance(declared, list): + perf = [_v06_metric_to_v07_performance(m) for m in declared if isinstance(m, dict)] + if perf: + out["claimedPerformance"] = perf + + # Conformity topic. + topic = claim.get("conformityTopic") + if isinstance(topic, str) and topic: + out["conformityTopic"] = [{"type": ["ConformityTopic"], "name": topic, "id": topic}] + elif isinstance(topic, list): + out["conformityTopic"] = topic + + # Evidence link. + evidence = claim.get("conformityEvidence") + if isinstance(evidence, dict): + out["evidence"] = [evidence] + + return out + + +def _v06_metric_to_v07_performance(metric: dict[str, Any]) -> dict[str, Any]: + """Turn a v0.6 ``Metric`` into a v0.7 ``Performance``.""" + perf: dict[str, Any] = { + "metric": { + "type": ["PerformanceMetric"], + "name": metric.get("metricName") or "", + }, + } + metric_value = metric.get("metricValue") + if isinstance(metric_value, dict): + perf["measure"] = metric_value + score = metric.get("score") + if score: + perf["score"] = {"code": str(score)} + return perf + + +def _scorecard_to_claim( + *, topic: str, scorecard: dict[str, Any], topic_name: str +) -> dict[str, Any]: + """Materialise a v0.6 scorecard as a v0.7 ``Claim``. + + The conversion is structural: each numeric field on the scorecard + becomes a ``Performance`` entry with the field name as the metric + label. Free-form Link fields (e.g. ``recyclingInformation``) are + promoted to ``evidence`` entries. + """ + perfs: list[dict[str, Any]] = [] + evidence: list[dict[str, Any]] = [] + for k, v in scorecard.items(): + if k in {"type", "reportingStandard"}: + continue + if isinstance(v, (int, float)): + # Carbon footprints, recyclable-content fractions, MCI, etc. + perfs.append( + { + "metric": {"type": ["PerformanceMetric"], "name": k}, + "measure": {"value": float(v), "unit": "C62"}, # dimensionless + }, + ) + elif isinstance(v, dict) and ("linkURL" in v or "href" in v): + link = dict(v) + link.setdefault("name", k) + evidence.append(link) + + claim: dict[str, Any] = { + "type": ["Claim"], + "id": f"urn:dppvalidator:upgrade:{topic}-claim", + "name": f"{topic_name} (upgraded from v0.6 scorecard)", + "description": ( + f"Synthesised from the v0.6 {topic} scorecard. Field-by-field " + "fidelity is best-effort; review before publishing." + ), + "conformityTopic": [ + { + "type": ["ConformityTopic"], + "id": f"https://vocabulary.uncefact.org/conformity-topic/{topic}", + "name": topic_name, + }, + ], + } + if perfs: + claim["claimedPerformance"] = perfs + if evidence: + claim["evidence"] = evidence + reporting_standard = scorecard.get("reportingStandard") + if isinstance(reporting_standard, dict): + claim["referenceStandard"] = [reporting_standard] + return claim + + +def _clean_v06_object(obj: dict[str, Any]) -> dict[str, Any]: + """Pass through a v0.6 reference object largely untouched. + + Steps 15 (strip type) and 16 (rename schemeID) walk the whole tree + later, so we don't need to recurse here. The reason this helper + exists at all is to ensure we get a *new* dict reference (not a + shared mutable from the input), which keeps the deep-copy + invariant tight. + """ + return dict(obj) + + +# ============================================================================= +# Step 8 — wrap scalar Standard / Regulation into single-element lists +# ============================================================================= + + +def _step8_wrap_claim_references( + cs: dict[str, Any], + warnings: list[UpgradeWarning], # noqa: ARG001 — uniform signature; no warnings emitted +) -> None: + """Ensure ``referenceStandard`` / ``referenceRegulation`` are lists. + + Step 6 already wrapped scalars during the conformityClaim → performanceClaim + rewrite. This step is the safety net for performanceClaim entries + that *originated* in 0.7 shape (e.g. mixed payloads or already + upgraded data run through the shim a second time). + """ + claims = cs.get("performanceClaim") + if not isinstance(claims, list): + return + for claim in claims: + if not isinstance(claim, dict): + continue + for key in ("referenceStandard", "referenceRegulation"): + value = claim.get(key) + if isinstance(value, dict): + claim[key] = [value] + + +# ============================================================================= +# Step 9 — wrap scalar country codes +# ============================================================================= + + +def _step9_wrap_country_codes( + cs: dict[str, Any], + warnings: list[UpgradeWarning], + country_lookup: Mapping[str, str] | None, + valid_codes: frozenset[str], +) -> None: + """Wrap ISO-2 strings into ``{countryCode, countryName}`` objects. + + Walks ``credentialSubject.countryOfProduction`` (Product-level) and + every ``Material.originCountry`` under ``materialProvenance``. + + - If the code is not in :data:`get_bundled_country_codes`, emit + ``UPG003`` and still wrap (the v0.7 model regex will catch it + downstream, but the structural shape is at least correct). + - If the caller supplied a ``country_lookup`` map and the code + resolves, populate ``countryName``. Otherwise leave it unset and + emit ``UPG002`` so the caller can fill it in manually. + """ + _wrap_country_at( + cs, "countryOfProduction", "credentialSubject", warnings, country_lookup, valid_codes + ) + materials = cs.get("materialProvenance") + if isinstance(materials, list): + for i, m in enumerate(materials): + if isinstance(m, dict): + _wrap_country_at( + m, + "originCountry", + f"credentialSubject.materialProvenance[{i}]", + warnings, + country_lookup, + valid_codes, + ) + + +def _wrap_country_at( + obj: dict[str, Any], + field: str, + path: str, + warnings: list[UpgradeWarning], + country_lookup: Mapping[str, str] | None, + valid_codes: frozenset[str], +) -> None: + """Wrap a scalar at ``obj[field]`` into a Country object in place.""" + value = obj.get(field) + if not isinstance(value, str): + # Already an object, missing, or a list — nothing to do. + return + code = value.strip().upper() + out: dict[str, Any] = {"countryCode": code} + if code not in valid_codes: + warnings.append( + UpgradeWarning( + code=UPG_CODE_UNMAPPED_COUNTRY, + path=f"{path}.{field}", + message=( + f"Country code {code!r} is not in the bundled ISO-3166-1 " + "alpha-2 list — wrapped structurally but the value will " + "fail v0.7 validation." + ), + ), + ) + elif country_lookup is not None and code in country_lookup: + out["countryName"] = country_lookup[code] + else: + warnings.append( + UpgradeWarning( + code=UPG_CODE_SYNTHESISED, + path=f"{path}.{field}.countryName", + message=( + "Country wrapped without ``countryName`` — pass a " + "country_lookup mapping to populate it." + ), + severity=UpgradeSeverity.INFO, + ), + ) + obj[field] = out + + +# ============================================================================= +# Step 10 — wrap Product.productCategory: Classification into a list +# ============================================================================= + + +def _step10_wrap_product_category( + cs: dict[str, Any], + warnings: list[UpgradeWarning], # noqa: ARG001 — uniform signature; no warnings emitted +) -> None: + """``productCategory`` was scalar in 0.6, list in 0.7.""" + pc = cs.get("productCategory") + if isinstance(pc, dict): + cs["productCategory"] = [pc] + + +# ============================================================================= +# Step 11 — Product.producedByParty → relatedParty[] +# ============================================================================= + + +def _step11_party_to_related_party( + cs: dict[str, Any], + warnings: list[UpgradeWarning], # noqa: ARG001 — uniform signature; no warnings emitted +) -> None: + """Wrap ``producedByParty: Party`` into ``relatedParty: [PartyRole]``. + + The role is fixed to ``"manufacturer"`` because that's the implied + relationship of a producedByParty in v0.6. We append rather than + overwrite ``relatedParty`` to be safe with mixed-source payloads. + """ + party = cs.pop("producedByParty", None) + if not isinstance(party, dict): + return + related = list(cs.get("relatedParty") or []) + related.append({"role": "manufacturer", "party": party}) + cs["relatedParty"] = related + + +# ============================================================================= +# Step 12 — Product.furtherInformation → relatedDocument[] +# ============================================================================= + + +def _step12_further_information_to_related_document( + cs: dict[str, Any], + warnings: list[UpgradeWarning], # noqa: ARG001 — uniform signature; no warnings emitted +) -> None: + """Append ``furtherInformation`` links to ``relatedDocument``.""" + further = cs.pop("furtherInformation", None) + if not further: + return + related = list(cs.get("relatedDocument") or []) + if isinstance(further, list): + related.extend(further) + elif isinstance(further, dict): + related.append(further) + cs["relatedDocument"] = related + + +# ============================================================================= +# Step 13 — drop Product.registeredId +# ============================================================================= + + +def _step13_drop_registered_id(cs: dict[str, Any], warnings: list[UpgradeWarning]) -> None: + """``registeredId`` moves from Product to Party in v0.7.""" + if "registeredId" in cs: + cs.pop("registeredId") + warnings.append( + UpgradeWarning( + code=UPG_CODE_LOSSY, + path="credentialSubject.registeredId", + message=( + "v0.6 Product.registeredId has no v0.7 equivalent on " + "Product — the field has moved to Party. The value was " + "dropped; if required, re-attach it to " + "Product.relatedParty[*].party.registeredId manually." + ), + ), + ) + + +# ============================================================================= +# Step 14 — Material.symbol base64 string → Image object +# ============================================================================= + + +def _step14_material_symbol_to_image(cs: dict[str, Any], warnings: list[UpgradeWarning]) -> None: + """Convert the inline base64 ``symbol`` string to a v0.7 ``Image``. + + Sentinel placeholders (``"undefined"`` and friends) are dropped — + smuggling them through as fake image bytes would just trip the v0.7 + schema downstream. + """ + materials = cs.get("materialProvenance") + if not isinstance(materials, list): + return + for i, m in enumerate(materials): + if not isinstance(m, dict): + continue + symbol = m.get("symbol") + if not isinstance(symbol, str): + continue + if symbol.lower() in _KNOWN_SYMBOL_PLACEHOLDERS: + m.pop("symbol", None) + warnings.append( + UpgradeWarning( + code=UPG_CODE_LOSSY, + path=f"credentialSubject.materialProvenance[{i}].symbol", + message=( + f"Material.symbol placeholder {symbol!r} dropped during " + "upgrade — provide a real Image object if needed." + ), + severity=UpgradeSeverity.INFO, + ), + ) + continue + if not _looks_like_base64(symbol): + m.pop("symbol", None) + warnings.append( + UpgradeWarning( + code=UPG_CODE_LOSSY, + path=f"credentialSubject.materialProvenance[{i}].symbol", + message=( + "Material.symbol value did not look like base64 — " + "dropped during upgrade. Provide a v0.7 Image object." + ), + ), + ) + continue + m["symbol"] = { + "type": ["Image"], + "name": _DEFAULT_IMAGE_NAME, + "imageData": symbol, + "mediaType": _DEFAULT_IMAGE_MEDIA_TYPE, + } + warnings.append( + UpgradeWarning( + code=UPG_CODE_SYNTHESISED, + path=f"credentialSubject.materialProvenance[{i}].symbol", + message=( + "Material.symbol upgraded to v0.7 Image with synthesised " + f"name={_DEFAULT_IMAGE_NAME!r} and " + f"mediaType={_DEFAULT_IMAGE_MEDIA_TYPE!r}." + ), + ), + ) + + +def _looks_like_base64(value: str) -> bool: + """Heuristic: would this string decode as base64? + + We tolerate URL-safe variants and strip whitespace before checking. + Anything under 16 characters is treated as not-an-image to weed out + short sentinels. This is intentionally conservative — false negatives + just route the value into the lossy-drop path with a UPG001 warning. + """ + s = value.strip() + if len(s) < 16: + return False + try: + base64.b64decode(s, validate=True) + except (ValueError, base64.binascii.Error): # type: ignore[attr-defined] + return False + return True + + +# ============================================================================= +# Step 15 — strip type arrays on embedded objects +# ============================================================================= + + +def _step15_strip_embedded_types( + cs: dict[str, Any], + warnings: list[UpgradeWarning], # noqa: ARG001 — uniform signature; no warnings emitted +) -> None: + """Strip ``type`` from embedded objects whose v0.7 schema dropped it. + + Walks the credential subject tree and removes the ``type`` field on + any sub-object whose key matches :data:`_TYPES_TO_STRIP_ON`. The + Product-level ``type`` (``["Product"]``) is preserved. + """ + _strip_types_recursive(cs, parent_key=None) + + +def _strip_types_recursive(node: Any, parent_key: str | None) -> None: + if isinstance(node, dict): + if parent_key in _TYPES_TO_STRIP_ON and "type" in node: + node.pop("type", None) + for k, v in node.items(): + _strip_types_recursive(v, k) + elif isinstance(node, list): + for item in node: + _strip_types_recursive(item, parent_key) + + +# ============================================================================= +# Step 16 — rename Classification.schemeID → schemeId +# ============================================================================= + + +def _step16_rename_scheme_id( + cs: dict[str, Any], + warnings: list[UpgradeWarning], # noqa: ARG001 — uniform signature; no warnings emitted +) -> None: + """Rename ``schemeID`` (v0.6) to ``schemeId`` (v0.7) everywhere. + + Walks the credential subject tree without restriction — Classification + objects can hide under ``materialType``, ``productCategory[*]``, + ``performanceClaim[*].referenceCriteria[*].category[*]``, and so on. + A blind rename is correct because no other v0.6 schema uses the + uppercase spelling. + """ + _rename_key_recursive(cs, "schemeID", "schemeId") + + +def _rename_key_recursive(node: Any, old: str, new: str) -> None: + if isinstance(node, dict): + if old in node: + node[new] = node.pop(old) + for v in node.values(): + _rename_key_recursive(v, old, new) + elif isinstance(node, list): + for item in node: + _rename_key_recursive(item, old, new) + + +# ============================================================================= +# Step 17 — Material required-field detection +# ============================================================================= + + +def _step17_material_required_fields(cs: dict[str, Any], warnings: list[UpgradeWarning]) -> None: + """Emit UPG004 per Material missing ``materialType`` or ``massFraction``. + + These were optional in v0.6 and are required in v0.7. We don't + fabricate them — the caller has to supply real values before the + upgraded payload can validate. + """ + materials = cs.get("materialProvenance") + if not isinstance(materials, list): + return + for i, m in enumerate(materials): + if not isinstance(m, dict): + continue + if not m.get("materialType"): + warnings.append( + UpgradeWarning( + code=UPG_CODE_REQUIRED_FIELD_MISSING, + path=f"credentialSubject.materialProvenance[{i}].materialType", + message=( + "Material.materialType is required in v0.7.0. The " + "v0.6 source did not supply one; provide a " + "Classification object before validating." + ), + severity=UpgradeSeverity.ERROR, + ), + ) + if "massFraction" not in m or m.get("massFraction") is None: + warnings.append( + UpgradeWarning( + code=UPG_CODE_REQUIRED_FIELD_MISSING, + path=f"credentialSubject.materialProvenance[{i}].massFraction", + message=( + "Material.massFraction is required in v0.7.0. The " + "v0.6 source did not supply one; provide a numeric " + "value before validating." + ), + severity=UpgradeSeverity.ERROR, + ), + ) diff --git a/src/dppvalidator/exporters/__init__.py b/src/dppvalidator/exporters/__init__.py index 231e71a..7e8ce2d 100644 --- a/src/dppvalidator/exporters/__init__.py +++ b/src/dppvalidator/exporters/__init__.py @@ -6,6 +6,16 @@ ContextDefinition, ContextManager, ) +from dppvalidator.exporters.eudpp_jsonld import ( + EUDPP_CONTEXT_URL, + EUDPPJsonLDExporter, + EUDPPTermMapper, + export_eudpp_jsonld, + export_eudpp_jsonld_dict, + get_eudpp_jsonld_context, + get_term_mapping_summary, + validate_eudpp_export, +) from dppvalidator.exporters.json import JSONExporter, export_json from dppvalidator.exporters.jsonld import JSONLDExporter, export_jsonld from dppvalidator.exporters.protocols import Exporter @@ -20,4 +30,13 @@ "ContextDefinition", "CONTEXTS", "DEFAULT_VERSION", + # EU DPP Export (Phase 9) + "EUDPPJsonLDExporter", + "EUDPPTermMapper", + "EUDPP_CONTEXT_URL", + "export_eudpp_jsonld", + "export_eudpp_jsonld_dict", + "get_eudpp_jsonld_context", + "get_term_mapping_summary", + "validate_eudpp_export", ] diff --git a/src/dppvalidator/exporters/contexts.py b/src/dppvalidator/exporters/contexts.py index 6b36c4f..80e697a 100644 --- a/src/dppvalidator/exporters/contexts.py +++ b/src/dppvalidator/exporters/contexts.py @@ -31,9 +31,21 @@ class ContextDefinition: ), default_type=("DigitalProductPassport", "VerifiableCredential"), ), + # UNTP 0.7.0 unifies DPP, DCC, DFR, DIA and DTE under one context served + # at vocabulary.uncefact.org/untp/0.7.0/context/. The DigitalProductPassport + # type itself is unchanged in name; the credentialSubject envelope is what + # restructures (see docs/plans/UNTP_0.7.0_MIGRATION.md §2.3). + "0.7.0": ContextDefinition( + version="0.7.0", + contexts=( + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/", + ), + default_type=("DigitalProductPassport", "VerifiableCredential"), + ), } -DEFAULT_VERSION = "0.6.1" +DEFAULT_VERSION = "0.6.1" # Phase 9 will flip this to "0.7.0" in dppvalidator 0.5.0. class ContextManager: diff --git a/src/dppvalidator/exporters/eudpp_jsonld.py b/src/dppvalidator/exporters/eudpp_jsonld.py new file mode 100644 index 0000000..98d8dae --- /dev/null +++ b/src/dppvalidator/exporters/eudpp_jsonld.py @@ -0,0 +1,581 @@ +"""EU DPP-aligned JSON-LD export for Digital Product Passports. + +Provides optional EU DPP-aligned JSON-LD output that transforms UNTP models +to the EU DPP Core Ontology vocabulary. The UNTP models remain unchanged; +this export layer performs vocabulary mapping at export time. + +Source: EU DPP Core Ontology v1.7.1 +Namespace: http://dpp.taltech.ee/EUDPP# +""" + +from __future__ import annotations + +import json +from copy import deepcopy +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from dppvalidator.logging import get_logger +from dppvalidator.schemas.registry import DEFAULT_SCHEMA_VERSION, SCHEMA_REGISTRY +from dppvalidator.vocabularies.ontology import ( + TERM_MAPPINGS, + EUDPPNamespace, + OntologyMapper, + get_eudpp_context, +) + +if TYPE_CHECKING: + from dppvalidator.models.passport import DigitalProductPassport + +logger = get_logger(__name__) + + +# ============================================================================= +# EU DPP Context Definitions +# ============================================================================= + + +EUDPP_CONTEXT_URL = "https://dpp.vocabulary-hub.eu/context/v1" + + +def get_eudpp_jsonld_context() -> list[Any]: + """Get the EU DPP JSON-LD @context array. + + Returns a context that includes: + - W3C Verifiable Credentials v2 + - EU DPP Core Ontology namespaces + - Term mappings for UNTP → EU DPP + + Returns: + List of context definitions for JSON-LD @context + """ + return [ + EUDPPNamespace.VC2.value, + get_eudpp_context(), + ] + + +# ============================================================================= +# Term Mapping +# ============================================================================= + + +class EUDPPTermMapper: + """Map UNTP terms to EU DPP Core Ontology terms. + + Phase 3c of docs/plans/UNTP_0.7.0_MIGRATION.md added a ``schema_version`` + parameter so the mapper picks the right column out of + :data:`TERM_MAPPINGS`. The default value (:data:`DEFAULT_SCHEMA_VERSION`) + preserves the pre-Phase-3c behaviour for callers that don't pass an + explicit version. Note: the dispatch is purely a forward-mapping concern + — the EU DPP target URI is the same across UNTP versions; only the + source-side spelling (e.g. ``itemNumber`` vs ``serialNumber``) shifts. + + Terms removed in a given version (carrying the :data:`TERM_REMOVED` + sentinel for that column) are excluded from that version's mapper — + e.g. ``gtin`` does not appear in a v0.7 mapper's index because v0.7 + has no ``gtin`` field on the wire. + """ + + def __init__(self, schema_version: str = DEFAULT_SCHEMA_VERSION) -> None: + """Initialize term mapper. + + Args: + schema_version: UNTP version whose source-side spellings to + index. Defaults to :data:`DEFAULT_SCHEMA_VERSION` for + backward compatibility with the Phase 3c-pre API. + """ + self.schema_version = schema_version + self._mapper = OntologyMapper() + self._untp_to_eudpp: dict[str, str] = {} + self._eudpp_to_untp: dict[str, str] = {} + + for mapping in TERM_MAPPINGS: + term = mapping.term_for(schema_version) + if term is None: + # Skip rows whose ``untp_v0_X`` is TERM_REMOVED for this + # version — there's no source-side spelling to map from. + continue + # Extract local name from compact URI (e.g. ``eudpp:Product`` → + # ``Product``). + eudpp_local = ( + mapping.cirpass_uri.split(":")[-1] + if ":" in mapping.cirpass_uri + else mapping.cirpass_uri + ) + self._untp_to_eudpp[term] = eudpp_local + # The reverse index is "last write wins" — multiple UNTP + # spellings can resolve to the same eudpp_local across the + # canonical and per-version columns; this matches the v0.6 + # default-mapper behaviour pre-Phase-3c. + self._eudpp_to_untp[eudpp_local] = term + + def map_key(self, untp_key: str) -> str: + """Map a UNTP key to EU DPP equivalent. + + Args: + untp_key: UNTP vocabulary key + + Returns: + EU DPP key (or original if no mapping exists) + """ + return self._untp_to_eudpp.get(untp_key, untp_key) + + def map_type(self, untp_type: str) -> str: + """Map a UNTP type to EU DPP equivalent. + + Args: + untp_type: UNTP type name + + Returns: + EU DPP type with namespace prefix + """ + mapped = self._untp_to_eudpp.get(untp_type) + if mapped: + return f"eudpp:{mapped}" + return untp_type + + def get_eudpp_key(self, untp_key: str) -> str | None: + """Get EU DPP key for UNTP key if mapped. + + Args: + untp_key: UNTP vocabulary key + + Returns: + EU DPP key or None if not mapped + """ + return self._untp_to_eudpp.get(untp_key) + + @property + def mapped_keys(self) -> list[str]: + """List of UNTP keys that have EU DPP mappings.""" + return list(self._untp_to_eudpp.keys()) + + +# ============================================================================= +# EU DPP JSON-LD Exporter +# ============================================================================= + + +class EUDPPJsonLDExporter: + """Export UNTP models as EU DPP-aligned JSON-LD. + + This exporter transforms UNTP DPP models to use EU DPP Core Ontology + vocabulary while preserving the original data structure. The UNTP + models remain unchanged; only the export representation uses EU DPP terms. + + Phase 3c of docs/plans/UNTP_0.7.0_MIGRATION.md added a + ``schema_version`` argument so the exporter dispatches to the right + column of :data:`TERM_MAPPINGS`. When omitted, it picks a sensible + default by inspecting the source passport class — v0.7 passports get + the v0.7 mapper, v0.6 passports get the v0.6 mapper. Callers that + pin an explicit version override that auto-detection. + + Example: + >>> exporter = EUDPPJsonLDExporter() + >>> jsonld = exporter.export(passport) + >>> # Result uses EU DPP vocabulary with @context + + Attributes: + include_untp_context: Include UNTP context alongside EU DPP + map_terms: Apply term mapping (UNTP → EU DPP) + schema_version: UNTP source version. When ``None``, auto-detected + from the passport's module path the first time + :meth:`export_dict` is called. + """ + + def __init__( + self, + *, + include_untp_context: bool = False, + map_terms: bool = True, + schema_version: str | None = None, + ) -> None: + """Initialize EU DPP exporter. + + Args: + include_untp_context: Include UNTP context in output + map_terms: Map UNTP terms to EU DPP equivalents + schema_version: UNTP source version, e.g. v0.7.0 written as a + SemVer string. When ``None``, the version is detected from + the passport class at export time. Pass an explicit version + when you need deterministic dispatch (e.g. when the exporter + is reused across calls with mixed-version inputs). + """ + self._include_untp = include_untp_context + self._map_terms = map_terms + self._explicit_version = schema_version + # Cache of EUDPPTermMapper instances keyed on the resolved version. + # We don't build a default mapper eagerly because the version may + # only become known when ``export`` is called. + self._term_mapper_cache: dict[str, EUDPPTermMapper] = {} + # Eagerly populate the cache when the caller pinned a version. + if schema_version is not None: + self._term_mapper_cache[schema_version] = EUDPPTermMapper( + schema_version=schema_version, + ) + + @property + def schema_version(self) -> str | None: + """Return the explicitly-configured version, or ``None`` for auto-detect.""" + return self._explicit_version + + @property + def _term_mapper(self) -> EUDPPTermMapper: + """Backward-compat alias. + + Pre-Phase-3c the exporter exposed a single ``self._term_mapper`` and + external code (notably the existing test suite) reaches in to read + it. Resolving on access keeps the lookup deterministic when the + caller pinned ``schema_version`` and falls back to the global + default otherwise. + """ + version = self._explicit_version or DEFAULT_SCHEMA_VERSION + return self._mapper_for(version) + + def _mapper_for(self, version: str) -> EUDPPTermMapper: + """Return (and cache) the term mapper for ``version``.""" + cached = self._term_mapper_cache.get(version) + if cached is None: + cached = EUDPPTermMapper(schema_version=version) + self._term_mapper_cache[version] = cached + return cached + + @staticmethod + def _detect_version_from_passport( + passport: DigitalProductPassport, + ) -> str: + """Best-effort detection of the source UNTP version. + + Looks at the passport class's module path (``v0_6`` or ``v0_7``) + against :data:`SCHEMA_REGISTRY`. Each registered version's + ``major.minor`` becomes a ``vMAJOR_MINOR`` namespace key — the + resolver picks the registered version whose namespace appears in + the module path. When two registry entries share the same module + namespace (e.g. 0.6.0 and 0.6.1 both live under ``v0_6``) the + highest-patch version wins; that matches the package-canonical + spelling for the namespace. + + Falls back to :data:`DEFAULT_SCHEMA_VERSION` for unrecognised + layouts (e.g. third-party subclasses outside the in-tree + version-namespaced packages). + """ + module = type(passport).__module__ + candidates: list[str] = [] + for version in SCHEMA_REGISTRY: + major_minor = ".".join(version.split(".")[:2]) + ns_dotted = f".v{major_minor.replace('.', '_')}" + if f"{ns_dotted}." in module or module.endswith(ns_dotted): + candidates.append(version) + if not candidates: + return DEFAULT_SCHEMA_VERSION + return max(candidates, key=lambda v: tuple(int(p) for p in v.split("."))) + + def export( + self, + passport: DigitalProductPassport, + *, + indent: int | None = 2, + ) -> str: + """Export passport to EU DPP JSON-LD string. + + Args: + passport: Validated DigitalProductPassport + indent: JSON indentation (None for compact) + + Returns: + EU DPP JSON-LD formatted string + """ + data = self.export_dict(passport) + return json.dumps(data, indent=indent, ensure_ascii=False, default=str) + + def export_dict( + self, + passport: DigitalProductPassport, + ) -> dict[str, Any]: + """Export passport to EU DPP JSON-LD dictionary. + + Args: + passport: Validated DigitalProductPassport + + Returns: + EU DPP JSON-LD formatted dictionary + """ + # Resolve which version's mapper to use. Explicit ``schema_version`` + # passed to the constructor wins; otherwise we auto-detect from the + # passport class's module path. This is what lets a single exporter + # instance serve mixed v0.6/v0.7 input without configuration. + version = self._explicit_version or self._detect_version_from_passport(passport) + mapper = self._mapper_for(version) + + # Get base UNTP JSON-LD representation + base = passport.model_dump(mode="json", by_alias=True, exclude_none=True) + + # Apply EU DPP context + data = self._apply_eudpp_context(base) + + # Map terms if enabled + if self._map_terms: + data = self._map_document_terms(data, mapper) + + # Add EU DPP-specific metadata + data = self._add_eudpp_metadata(data, passport) + + logger.debug("Exported DPP to EU DPP JSON-LD format (version=%s)", version) + return data + + def export_to_file( + self, + passport: DigitalProductPassport, + path: Path | str, + *, + indent: int | None = 2, + ) -> None: + """Export passport to EU DPP JSON-LD file. + + Args: + passport: Validated DigitalProductPassport + path: Output file path + indent: JSON indentation + """ + content = self.export(passport, indent=indent) + Path(path).write_text(content, encoding="utf-8") + logger.info("Exported EU DPP JSON-LD to %s", path) + + def _apply_eudpp_context(self, data: dict[str, Any]) -> dict[str, Any]: + """Apply EU DPP @context to the document. + + Args: + data: JSON-LD dictionary + + Returns: + Dictionary with EU DPP @context + """ + result = deepcopy(data) + + # Build context array + context: list[Any] = [EUDPPNamespace.VC2.value] + + # Add EU DPP namespace context + context.append(get_eudpp_context()) + + # Optionally include UNTP context + if self._include_untp: + context.append({"untp": EUDPPNamespace.UNTP_DPP.value}) + + result["@context"] = context + return result + + def _map_document_terms( + self, + data: dict[str, Any], + mapper: EUDPPTermMapper | None = None, + ) -> dict[str, Any]: + """Recursively map UNTP terms to EU DPP equivalents. + + Args: + data: JSON-LD dictionary + mapper: The version-specific term mapper to use. Defaults to + the exporter's resolved mapper (Phase 3c added the + ``mapper`` parameter so :meth:`export_dict` can thread the + version it picked into the recursive walk). + + Returns: + Dictionary with mapped terms + """ + if not isinstance(data, dict): + return data + + if mapper is None: + mapper = self._term_mapper + + result: dict[str, Any] = {} + + for key, value in data.items(): + # Don't map special JSON-LD keys + if key.startswith("@"): + result[key] = value + continue + + # Map the key + mapped_key = mapper.map_key(key) + + # Recursively process values + if isinstance(value, dict): + result[mapped_key] = self._map_document_terms(value, mapper) + elif isinstance(value, list): + result[mapped_key] = [ + self._map_document_terms(item, mapper) if isinstance(item, dict) else item + for item in value + ] + else: + result[mapped_key] = value + + # Map type values + if "type" in result: + result["type"] = self._map_type_value(result["type"], mapper) + + return result + + def _map_type_value( + self, + type_value: Any, + mapper: EUDPPTermMapper | None = None, + ) -> Any: + """Map type values to EU DPP equivalents. + + Args: + type_value: Type string or list + mapper: Term mapper to use; defaults to the exporter's resolved + mapper for backward compatibility with pre-Phase-3c calls. + + Returns: + Mapped type value(s) + """ + if mapper is None: + mapper = self._term_mapper + if isinstance(type_value, str): + return mapper.map_type(type_value) + elif isinstance(type_value, list): + return [mapper.map_type(t) if isinstance(t, str) else t for t in type_value] + return type_value + + def _add_eudpp_metadata( + self, data: dict[str, Any], passport: DigitalProductPassport + ) -> dict[str, Any]: + """Add EU DPP-specific metadata to the document. + + Args: + data: JSON-LD dictionary + passport: Source passport + + Returns: + Dictionary with EU DPP metadata + """ + # Add schema reference + if "schemaVersion" not in data: + data["schemaVersion"] = "CIRPASS-2 v1.3.0" + + # Surface granularity at the document root with the EU DPP key. + # In v0.6.x this lived at ``credentialSubject.granularity_level``; + # in v0.7 it's ``credentialSubject.id_granularity`` (Product is + # the credential subject directly). Both attributes are checked so + # the metadata line works for either envelope without a version + # branch — see the ``term_for`` mapping in ontology.py. + cs = getattr(passport, "credential_subject", None) + if cs is not None: + granularity = getattr(cs, "granularity_level", None) or getattr( + cs, "id_granularity", None + ) + if granularity: + data["granularity"] = granularity + + return data + + +# ============================================================================= +# Convenience Functions +# ============================================================================= + + +def export_eudpp_jsonld( + passport: DigitalProductPassport, + *, + indent: int = 2, + map_terms: bool = True, + schema_version: str | None = None, +) -> str: + """Export a DPP to EU DPP-aligned JSON-LD format. + + Convenience function for simple exports. For more control, + use EUDPPJsonLDExporter directly. + + Args: + passport: Validated DigitalProductPassport + indent: JSON indentation + map_terms: Map UNTP terms to EU DPP equivalents + schema_version: UNTP source version. When ``None``, auto-detected + from the passport's class (Phase 3c). + + Returns: + EU DPP JSON-LD formatted string + """ + exporter = EUDPPJsonLDExporter(map_terms=map_terms, schema_version=schema_version) + return exporter.export(passport, indent=indent) + + +def export_eudpp_jsonld_dict( + passport: DigitalProductPassport, + *, + map_terms: bool = True, + schema_version: str | None = None, +) -> dict[str, Any]: + """Export a DPP to EU DPP-aligned JSON-LD dictionary. + + Convenience function for dictionary output. + + Args: + passport: Validated DigitalProductPassport + map_terms: Map UNTP terms to EU DPP equivalents + schema_version: UNTP source version. When ``None``, auto-detected + from the passport's class (Phase 3c). + + Returns: + EU DPP JSON-LD dictionary + """ + exporter = EUDPPJsonLDExporter(map_terms=map_terms, schema_version=schema_version) + return exporter.export_dict(passport) + + +def validate_eudpp_export(data: dict[str, Any]) -> list[str]: + """Validate EU DPP JSON-LD export structure. + + Checks that the export contains required EU DPP elements. + + Args: + data: Exported JSON-LD dictionary + + Returns: + List of validation issues (empty if valid) + """ + issues: list[str] = [] + + # Check @context + if "@context" not in data: + issues.append("Missing @context") + else: + context = data["@context"] + if not isinstance(context, list): + issues.append("@context should be a list") + else: + # Check for VC2 context + if EUDPPNamespace.VC2.value not in context: + issues.append("Missing W3C VC2 context") + + # Check for EU DPP namespace + has_eudpp = any(isinstance(c, dict) and "eudpp" in c for c in context) + if not has_eudpp: + issues.append("Missing EU DPP namespace in context") + + # Check for type + if "type" not in data: + issues.append("Missing type") + + return issues + + +def get_term_mapping_summary( + schema_version: str = DEFAULT_SCHEMA_VERSION, +) -> dict[str, str]: + """Get a summary of UNTP to EU DPP term mappings. + + Args: + schema_version: UNTP version to summarise (Phase 3c). Defaults to + :data:`DEFAULT_SCHEMA_VERSION` to preserve the pre-Phase-3c + output for callers that don't specify. + + Returns: + Dictionary mapping UNTP terms to EU DPP terms for ``schema_version``. + """ + mapper = EUDPPTermMapper(schema_version=schema_version) + return {term: mapper.map_key(term) for term in mapper.mapped_keys} diff --git a/src/dppvalidator/models/claims.py b/src/dppvalidator/models/claims.py index 92b7bc0..4515d61 100644 --- a/src/dppvalidator/models/claims.py +++ b/src/dppvalidator/models/claims.py @@ -1,164 +1,30 @@ -"""Claim and conformity-related models for UNTP DPP.""" +"""Backward-compatibility re-export of v0.6.x ``claims``. -from __future__ import annotations - -from datetime import date -from typing import Annotated, ClassVar - -from pydantic import Field - -from dppvalidator.models.base import UNTPBaseModel, UNTPStrictModel -from dppvalidator.models.enums import ConformityTopic, CriterionStatus -from dppvalidator.models.identifiers import Party -from dppvalidator.models.primitives import Classification, FlexibleUri, Measure, SecureLink - - -class Metric(UNTPStrictModel): - """Performance metric with value and optional score.""" - - _jsonld_type: ClassVar[list[str]] = ["Metric"] - - metric_name: Annotated[ - str, - Field(..., alias="metricName", description="Human readable metric name"), - ] - metric_value: Annotated[ - Measure, - Field(..., alias="metricValue", description="Numeric value and unit"), - ] - score: str | None = Field(default=None, description="Score or rank for this metric") - accuracy: float | None = Field( - default=None, - ge=0, - le=1, - description="Accuracy as percentage (0-1)", - ) - - -class Criterion(UNTPBaseModel): - """Specific rule or criterion within a standard or regulation.""" - - _jsonld_type: ClassVar[list[str]] = ["Criterion"] +The actual class definitions live in :mod:`dppvalidator.models.v0_6.claims`. +This shim preserves the import path used by third-party plugins (e.g. +``from dppvalidator.models.claims import Claim``) — see Phase 3 of +docs/plans/UNTP_0.7.0_MIGRATION.md and the public-API stability contract in +§7.6 of that plan. - id: Annotated[ - FlexibleUri, - Field(..., description="Unique identifier for the criterion"), - ] - name: str = Field(..., description="Criterion name") - description: str = Field(..., description="Full text description of the criterion") - conformity_topic: Annotated[ - ConformityTopic, - Field(..., alias="conformityTopic", description="Conformity topic category"), - ] - status: CriterionStatus = Field(..., description="Lifecycle status") - sub_criterion: Annotated[ - list[Criterion] | None, - Field(default=None, alias="subCriterion", description="Subordinate criteria"), - ] - threshold_value: Annotated[ - Metric | None, - Field(default=None, alias="thresholdValue", description="Minimum compliance threshold"), - ] - performance_level: Annotated[ - str | None, - Field(default=None, alias="performanceLevel", description="Performance category code"), - ] - category: Annotated[ - list[Classification] | None, - Field(default=None, description="Product categories the criterion applies to"), - ] - tag: Annotated[ - list[str] | None, - Field(default=None, description="Tags for stakeholder/commodity types"), - ] +Through the 0.4.x line this re-exports v0.6.x classes (the current default +schema version). Phase 9 (validator 0.5.0) will switch the default to v0.7 +and update this shim accordingly. +""" +from __future__ import annotations -class Standard(UNTPStrictModel): - """Standard that specifies conformance criteria (e.g., ISO 14000).""" - - _jsonld_type: ClassVar[list[str]] = ["Standard"] - - id: Annotated[ - FlexibleUri | None, - Field(default=None, description="Unique identifier for the standard"), - ] - name: str | None = Field(default=None, description="Name of the standard") - issuing_party: Annotated[ - Party, - Field(..., alias="issuingParty", description="Party that issued the standard"), - ] - issue_date: Annotated[ - date | None, - Field(default=None, alias="issueDate", description="Date the standard was issued"), - ] - - -class Regulation(UNTPStrictModel): - """Regulation that defines assessment criteria.""" - - _jsonld_type: ClassVar[list[str]] = ["Regulation"] - - id: Annotated[ - FlexibleUri | None, - Field(default=None, description="Globally unique identifier of the regulation"), - ] - name: str | None = Field(default=None, description="Name of the regulation or act") - jurisdiction_country: Annotated[ - str | None, - Field( - default=None, - alias="jurisdictionCountry", - description="ISO 3166-1 jurisdiction country code", - ), - ] - administered_by: Annotated[ - Party, - Field(..., alias="administeredBy", description="Issuing body of the regulation"), - ] - effective_date: Annotated[ - date | None, - Field(default=None, alias="effectiveDate", description="Date regulation came into effect"), - ] - - -class Claim(UNTPBaseModel): - """Declaration of conformance with standard or regulation criteria.""" - - _jsonld_type: ClassVar[list[str]] = ["Claim", "Declaration"] - - id: Annotated[ - FlexibleUri, - Field(..., description="Unique identifier for the declaration"), - ] - description: str | None = Field(default=None, description="Textual description of the claim") - reference_standard: Annotated[ - Standard | None, - Field(default=None, alias="referenceStandard", description="Reference standard"), - ] - reference_regulation: Annotated[ - Regulation | None, - Field(default=None, alias="referenceRegulation", description="Reference regulation"), - ] - assessment_criteria: Annotated[ - list[Criterion] | None, - Field(default=None, alias="assessmentCriteria", description="Assessment specifications"), - ] - assessment_date: Annotated[ - date | None, - Field(default=None, alias="assessmentDate", description="Date of assessment"), - ] - declared_value: Annotated[ - list[Metric] | None, - Field(default=None, alias="declaredValue", description="Measured values"), - ] - conformance: bool = Field(..., description="Whether the claim conforms to criteria") - conformity_topic: Annotated[ - ConformityTopic, - Field(..., alias="conformityTopic", description="Conformity topic category"), - ] - conformity_evidence: Annotated[ - SecureLink | None, - Field( - default=None, alias="conformityEvidence", description="Evidence supporting the claim" - ), - ] +from dppvalidator.models.v0_6.claims import ( + Claim, + Criterion, + Metric, + Regulation, + Standard, +) + +__all__ = [ + "Claim", + "Criterion", + "Metric", + "Regulation", + "Standard", +] diff --git a/src/dppvalidator/models/credential.py b/src/dppvalidator/models/credential.py index 2215e99..b1f2ff7 100644 --- a/src/dppvalidator/models/credential.py +++ b/src/dppvalidator/models/credential.py @@ -1,154 +1,26 @@ -"""Credential and ProductPassport models for UNTP DPP.""" +"""Backward-compatibility re-export of v0.6.x ``credential``. -from __future__ import annotations +The actual class definitions live in :mod:`dppvalidator.models.v0_6.credential`. +This shim preserves the import path used by third-party plugins (e.g. +``from dppvalidator.models.credential import CredentialIssuer``) — see Phase 3 of +docs/plans/UNTP_0.7.0_MIGRATION.md and the public-API stability contract in +§7.6 of that plan. -from typing import Annotated, ClassVar +Through the 0.4.x line this re-exports v0.6.x classes (the current default +schema version). Phase 9 (validator 0.5.0) will switch the default to v0.7 +and update this shim accordingly. +""" -from pydantic import Field +from __future__ import annotations -from dppvalidator.models.base import UNTPBaseModel, UNTPStrictModel -from dppvalidator.models.claims import Claim -from dppvalidator.models.enums import GranularityLevel -from dppvalidator.models.identifiers import Party -from dppvalidator.models.materials import Material -from dppvalidator.models.performance import ( - CircularityPerformance, - EmissionsPerformance, - TraceabilityPerformance, +from dppvalidator.models.v0_6.credential import ( + CredentialIssuer, + CredentialStatus, + ProductPassport, ) -from dppvalidator.models.primitives import FlexibleUri, Link -from dppvalidator.models.product import Product - - -class CredentialStatus(UNTPBaseModel): - """Credential status for revocation checking per W3C VC v2. - - Used to check if a credential has been revoked or suspended. - Supports multiple status mechanisms (BitstringStatusList, StatusList2021, etc.). - """ - - _jsonld_type: ClassVar[list[str]] = ["CredentialStatus"] - - id: Annotated[ - FlexibleUri, - Field(..., description="URI identifying the status entry"), - ] - type: Annotated[ - str, - Field( - ..., - description="Status type (e.g., BitstringStatusListEntry, StatusList2021Entry)", - ), - ] - status_purpose: Annotated[ - str | None, - Field( - default=None, - alias="statusPurpose", - description="Purpose of status (revocation, suspension)", - ), - ] - status_list_index: Annotated[ - str | None, - Field( - default=None, - alias="statusListIndex", - description="Index in the status list", - ), - ] - status_list_credential: Annotated[ - FlexibleUri | None, - Field( - default=None, - alias="statusListCredential", - description="URI of the status list credential", - ), - ] - - -class CredentialIssuer(UNTPStrictModel): - """Issuer of a verifiable credential.""" - - _jsonld_type: ClassVar[list[str]] = ["CredentialIssuer"] - - id: Annotated[ - FlexibleUri, - Field(..., description="W3C DID of the issuer (did:web, did:webvh, or https URL)"), - ] - name: str = Field(..., description="Name of the issuer person or organisation") - issuer_also_known_as: Annotated[ - list[Party] | None, - Field( - default=None, - alias="issuerAlsoKnownAs", - description="Other registered identifiers for this issuer", - ), - ] - - -class ProductPassport(UNTPBaseModel): - """Product passport credential subject.""" - - _jsonld_type: ClassVar[list[str]] = ["ProductPassport"] - id: Annotated[ - FlexibleUri | None, - Field(default=None, description="Identifier for the credential subject (URI)"), - ] - product: Product | None = Field(default=None, description="Product information") - granularity_level: Annotated[ - GranularityLevel | None, - Field( - default=None, - alias="granularityLevel", - description="Item, batch, or model level passport", - ), - ] - conformity_claim: Annotated[ - list[Claim] | None, - Field( - default=None, - alias="conformityClaim", - description="Conformity claims about the product", - ), - ] - emissions_scorecard: Annotated[ - EmissionsPerformance | None, - Field( - default=None, - alias="emissionsScorecard", - description="Emissions performance scorecard", - ), - ] - traceability_information: Annotated[ - list[TraceabilityPerformance] | None, - Field( - default=None, - alias="traceabilityInformation", - description="Traceability events by value chain process", - ), - ] - circularity_scorecard: Annotated[ - CircularityPerformance | None, - Field( - default=None, - alias="circularityScorecard", - description="Circularity performance scorecard", - ), - ] - due_diligence_declaration: Annotated[ - Link | None, - Field( - default=None, - alias="dueDiligenceDeclaration", - description="Due diligence declaration link", - ), - ] - materials_provenance: Annotated[ - list[Material] | None, - Field( - default=None, - alias="materialsProvenance", - description="Material origin and mass fraction information", - ), - ] +__all__ = [ + "CredentialIssuer", + "CredentialStatus", + "ProductPassport", +] diff --git a/src/dppvalidator/models/enums.py b/src/dppvalidator/models/enums.py index 944bac1..2c3114c 100644 --- a/src/dppvalidator/models/enums.py +++ b/src/dppvalidator/models/enums.py @@ -1,70 +1,32 @@ -"""Enumeration types for UNTP DPP models.""" +"""Backward-compatibility re-export of v0.6.x ``enums``. -from __future__ import annotations - -from enum import Enum - - -class ConformityTopic(str, Enum): - """Conformity topic categories per UNTP specification.""" - - ENVIRONMENT_ENERGY = "environment.energy" - ENVIRONMENT_EMISSIONS = "environment.emissions" - ENVIRONMENT_WATER = "environment.water" - ENVIRONMENT_WASTE = "environment.waste" - ENVIRONMENT_DEFORESTATION = "environment.deforestation" - ENVIRONMENT_BIODIVERSITY = "environment.biodiversity" - CIRCULARITY_CONTENT = "circularity.content" - CIRCULARITY_DESIGN = "circularity.design" - SOCIAL_LABOUR = "social.labour" - SOCIAL_RIGHTS = "social.rights" - SOCIAL_COMMUNITY = "social.community" - SOCIAL_SAFETY = "social.safety" - GOVERNANCE_ETHICS = "governance.ethics" - GOVERNANCE_COMPLIANCE = "governance.compliance" - GOVERNANCE_TRANSPARENCY = "governance.transparency" - - -class GranularityLevel(str, Enum): - """Granularity level for product passports.""" - - ITEM = "item" - BATCH = "batch" - MODEL = "model" - - -class OperationalScope(str, Enum): - """Operational scope for emissions performance. +The actual class definitions live in :mod:`dppvalidator.models.v0_6.enums`. +This shim preserves the import path used by third-party plugins (e.g. +``from dppvalidator.models.enums import ConformityTopic``) — see Phase 3 of +docs/plans/UNTP_0.7.0_MIGRATION.md and the public-API stability contract in +§7.6 of that plan. - Supports both GHG Protocol scopes (Scope1/2/3) and lifecycle - assessment boundaries (CradleToGate/CradleToGrave). - """ +Through the 0.4.x line this re-exports v0.6.x classes (the current default +schema version). Phase 9 (validator 0.5.0) will switch the default to v0.7 +and update this shim accordingly. +""" - NONE = "None" - SCOPE1 = "Scope1" - SCOPE2 = "Scope2" - SCOPE3 = "Scope3" - CRADLE_TO_GATE = "CradleToGate" - CRADLE_TO_GRAVE = "CradleToGrave" - - -class HashMethod(str, Enum): - """Hash algorithm for secure links.""" - - SHA_256 = "SHA-256" - SHA_1 = "SHA-1" - - -class EncryptionMethod(str, Enum): - """Encryption method for secure links.""" - - NONE = "none" - AES = "AES" - - -class CriterionStatus(str, Enum): - """Lifecycle status of a criterion.""" +from __future__ import annotations - PROPOSED = "proposed" - ACTIVE = "active" - DEPRECATED = "deprecated" +from dppvalidator.models.v0_6.enums import ( + ConformityTopic, + CriterionStatus, + EncryptionMethod, + GranularityLevel, + HashMethod, + OperationalScope, +) + +__all__ = [ + "ConformityTopic", + "CriterionStatus", + "EncryptionMethod", + "GranularityLevel", + "HashMethod", + "OperationalScope", +] diff --git a/src/dppvalidator/models/identifiers.py b/src/dppvalidator/models/identifiers.py index 0b0ac19..6b06349 100644 --- a/src/dppvalidator/models/identifiers.py +++ b/src/dppvalidator/models/identifiers.py @@ -1,68 +1,26 @@ -"""Identifier-related models for UNTP DPP.""" +"""Backward-compatibility re-export of v0.6.x ``identifiers``. -from __future__ import annotations - -from typing import Annotated, ClassVar - -from pydantic import Field - -from dppvalidator.models.base import UNTPBaseModel, UNTPStrictModel -from dppvalidator.models.primitives import FlexibleUri - - -class IdentifierScheme(UNTPStrictModel): - """Identifier registration scheme for products, facilities, or organisations.""" - - _jsonld_type: ClassVar[list[str]] = ["IdentifierScheme"] +The actual class definitions live in :mod:`dppvalidator.models.v0_6.identifiers`. +This shim preserves the import path used by third-party plugins (e.g. +``from dppvalidator.models.identifiers import Facility``) — see Phase 3 of +docs/plans/UNTP_0.7.0_MIGRATION.md and the public-API stability contract in +§7.6 of that plan. - id: Annotated[ - FlexibleUri | None, - Field( - default=None, - description="Globally unique identifier of the registration scheme", - ), - ] - name: Annotated[ - str | None, - Field(default=None, description="Name of the identifier scheme"), - ] +Through the 0.4.x line this re-exports v0.6.x classes (the current default +schema version). Phase 9 (validator 0.5.0) will switch the default to v0.7 +and update this shim accordingly. +""" +from __future__ import annotations -class Party(UNTPBaseModel): - """A party (person or organisation) with identifier.""" - - _jsonld_type: ClassVar[list[str]] = ["Party"] - - id: Annotated[ - FlexibleUri, - Field(..., description="Globally unique ID of the party as a URI"), - ] - name: str = Field(..., description="Registered name of the party") - registered_id: Annotated[ - str | None, - Field( - default=None, - alias="registeredId", - description="Registration number within the register", - ), - ] - - -class Facility(UNTPBaseModel): - """A facility where products are manufactured.""" - - _jsonld_type: ClassVar[list[str]] = ["Facility"] - - id: Annotated[ - FlexibleUri, - Field(..., description="Globally unique ID of the facility as URI"), - ] - name: str = Field(..., description="Registered name of the facility") - registered_id: Annotated[ - str | None, - Field( - default=None, - alias="registeredId", - description="Registration number within the identifier scheme", - ), - ] +from dppvalidator.models.v0_6.identifiers import ( + Facility, + IdentifierScheme, + Party, +) + +__all__ = [ + "Facility", + "IdentifierScheme", + "Party", +] diff --git a/src/dppvalidator/models/materials.py b/src/dppvalidator/models/materials.py index 794ca5f..814f94d 100644 --- a/src/dppvalidator/models/materials.py +++ b/src/dppvalidator/models/materials.py @@ -1,78 +1,22 @@ -"""Material provenance models for UNTP DPP.""" +"""Backward-compatibility re-export of v0.6.x ``materials``. -from __future__ import annotations - -from typing import Annotated, ClassVar - -from pydantic import Field, model_validator +The actual class definitions live in :mod:`dppvalidator.models.v0_6.materials`. +This shim preserves the import path used by third-party plugins (e.g. +``from dppvalidator.models.materials import Material``) — see Phase 3 of +docs/plans/UNTP_0.7.0_MIGRATION.md and the public-API stability contract in +§7.6 of that plan. -from dppvalidator.models.base import UNTPBaseModel -from dppvalidator.models.primitives import Classification, Link, Measure +Through the 0.4.x line this re-exports v0.6.x classes (the current default +schema version). Phase 9 (validator 0.5.0) will switch the default to v0.7 +and update this shim accordingly. +""" +from __future__ import annotations -class Material(UNTPBaseModel): - """Material origin and mass fraction information.""" - - _jsonld_type: ClassVar[list[str]] = ["Material"] - - name: str = Field(..., description="Material name (e.g., 'Egyptian Cotton')") - origin_country: Annotated[ - str | None, - Field( - default=None, - alias="originCountry", - description="ISO 3166-1 country of origin", - ), - ] - material_type: Annotated[ - Classification | None, - Field( - default=None, - alias="materialType", - description="Material classification (e.g., UNFC)", - ), - ] - mass_fraction: Annotated[ - float | None, - Field( - default=None, - ge=0, - le=1, - alias="massFraction", - description="Mass fraction of product (0-1, sum should equal 1)", - ), - ] - mass: Measure | None = Field(default=None, description="Mass of the material component") - recycled_mass_fraction: Annotated[ - float | None, - Field( - default=None, - ge=0, - le=1, - alias="recycledMassFraction", - description="Fraction of material that is recycled (0-1)", - ), - ] - hazardous: bool | None = Field( - default=None, - description="Whether material is hazardous", - ) - symbol: str | None = Field( - default=None, - description="Base64 encoded visual symbol for the material", - ) - material_safety_information: Annotated[ - Link | None, - Field( - default=None, - alias="materialSafetyInformation", - description="Link to material safety data sheet", - ), - ] +from dppvalidator.models.v0_6.materials import ( + Material, +) - @model_validator(mode="after") - def validate_hazardous_requires_safety_info(self) -> Material: - """Ensure hazardous materials have safety information.""" - if self.hazardous and not self.material_safety_information: - raise ValueError("materialSafetyInformation is required when hazardous is true") - return self +__all__ = [ + "Material", +] diff --git a/src/dppvalidator/models/passport.py b/src/dppvalidator/models/passport.py index c6c5c0c..0a1ac92 100644 --- a/src/dppvalidator/models/passport.py +++ b/src/dppvalidator/models/passport.py @@ -1,94 +1,22 @@ -"""Digital Product Passport root model per UNTP/UNCEFACT v0.6.1.""" +"""Backward-compatibility re-export of v0.6.x ``passport``. -from __future__ import annotations +The actual class definitions live in :mod:`dppvalidator.models.v0_6.passport`. +This shim preserves the import path used by third-party plugins (e.g. +``from dppvalidator.models.passport import DigitalProductPassport``) — see Phase 3 of +docs/plans/UNTP_0.7.0_MIGRATION.md and the public-API stability contract in +§7.6 of that plan. -from datetime import datetime -from typing import Annotated, ClassVar +Through the 0.4.x line this re-exports v0.6.x classes (the current default +schema version). Phase 9 (validator 0.5.0) will switch the default to v0.7 +and update this shim accordingly. +""" -from pydantic import Field, model_validator +from __future__ import annotations -from dppvalidator.models.base import UNTPBaseModel -from dppvalidator.models.credential import ( - CredentialIssuer, - CredentialStatus, - ProductPassport, +from dppvalidator.models.v0_6.passport import ( + DigitalProductPassport, ) -from dppvalidator.models.primitives import FlexibleUri - - -class DigitalProductPassport(UNTPBaseModel): - """Digital Product Passport as a Verifiable Credential. - - Root model for UNTP DPP v0.6.1, combining DigitalProductPassport - and VerifiableCredential types per W3C VC v2 specification. - """ - - _jsonld_type: ClassVar[list[str]] = ["DigitalProductPassport", "VerifiableCredential"] - - context: Annotated[ - list[str], - Field( - alias="@context", - default=[ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", - ], - description="JSON-LD context URIs", - ), - ] - id: Annotated[ - FlexibleUri, - Field(..., description="Unique identifier (URI) for this passport"), - ] - issuer: CredentialIssuer = Field(..., description="Organisation issuing this credential") - valid_from: Annotated[ - datetime | None, - Field(default=None, alias="validFrom", description="Credential validity start"), - ] - valid_until: Annotated[ - datetime | None, - Field(default=None, alias="validUntil", description="Credential expiry date"), - ] - credential_subject: Annotated[ - ProductPassport | None, - Field( - default=None, - alias="credentialSubject", - description="The product passport content", - ), - ] - credential_status: Annotated[ - CredentialStatus | list[CredentialStatus] | None, - Field( - default=None, - alias="credentialStatus", - description="Credential revocation/suspension status per W3C VC v2", - ), - ] - - @model_validator(mode="after") - def validate_dates(self) -> DigitalProductPassport: - """Ensure validFrom is before validUntil if both are present.""" - if self.valid_from and self.valid_until and self.valid_from >= self.valid_until: - raise ValueError("validFrom must be before validUntil") - return self - - @model_validator(mode="after") - def validate_material_mass_fractions(self) -> DigitalProductPassport: - """Validate material mass fractions don't exceed 1.0. - Per UNTP spec, mass fractions can be partial declarations (sum < 1.0). - Only sum > 1.0 is physically impossible and should error. - Semantic validation checks for exact sum when appropriate. - """ - if not self.credential_subject: - return self - materials = self.credential_subject.materials_provenance - if not materials: - return self - fractions = [m.mass_fraction for m in materials if m.mass_fraction is not None] - if fractions: - total = sum(fractions) - if total > 1.01: # Allow small tolerance for floating point - raise ValueError(f"Material mass fractions cannot exceed 1.0, got {total:.3f}") - return self +__all__ = [ + "DigitalProductPassport", +] diff --git a/src/dppvalidator/models/performance.py b/src/dppvalidator/models/performance.py index c0bbcc9..c91669b 100644 --- a/src/dppvalidator/models/performance.py +++ b/src/dppvalidator/models/performance.py @@ -1,156 +1,26 @@ -"""Performance scorecard models for UNTP DPP.""" +"""Backward-compatibility re-export of v0.6.x ``performance``. -from __future__ import annotations - -from typing import Annotated, ClassVar - -from pydantic import Field - -from dppvalidator.models.base import UNTPBaseModel -from dppvalidator.models.claims import Standard -from dppvalidator.models.enums import OperationalScope -from dppvalidator.models.primitives import Link, SecureLink - - -class EmissionsPerformance(UNTPBaseModel): - """Emissions performance scorecard.""" - - _jsonld_type: ClassVar[list[str]] = ["EmissionsPerformance"] +The actual class definitions live in :mod:`dppvalidator.models.v0_6.performance`. +This shim preserves the import path used by third-party plugins (e.g. +``from dppvalidator.models.performance import CircularityPerformance``) — see Phase 3 of +docs/plans/UNTP_0.7.0_MIGRATION.md and the public-API stability contract in +§7.6 of that plan. - carbon_footprint: Annotated[ - float, - Field( - ..., - alias="carbonFootprint", - description="Carbon footprint in KgCO2e per declared unit", - ), - ] - declared_unit: Annotated[ - str, - Field( - ..., - alias="declaredUnit", - description="Unit of product (EA, KGM, LTR) for carbon footprint basis", - ), - ] - operational_scope: Annotated[ - OperationalScope | None, - Field( - default=None, - alias="operationalScope", - description="Emissions operational scope (GHG Protocol or lifecycle boundary)", - ), - ] - primary_sourced_ratio: Annotated[ - float, - Field( - ..., - ge=0, - le=1, - alias="primarySourcedRatio", - description="Ratio of primary source emissions data (0-1)", - ), - ] - reporting_standard: Annotated[ - Standard | None, - Field( - default=None, - alias="reportingStandard", - description="Reporting standard (GHG Protocol, IFRS S2, etc.)", - ), - ] +Through the 0.4.x line this re-exports v0.6.x classes (the current default +schema version). Phase 9 (validator 0.5.0) will switch the default to v0.7 +and update this shim accordingly. +""" +from __future__ import annotations -class TraceabilityPerformance(UNTPBaseModel): - """Traceability performance for a value chain process.""" - - _jsonld_type: ClassVar[list[str]] = ["TraceabilityPerformance"] - - value_chain_process: Annotated[ - str | None, - Field( - default=None, - alias="valueChainProcess", - description="Industry-specific value chain process name", - ), - ] - verified_ratio: Annotated[ - float | None, - Field( - default=None, - ge=0, - le=1, - alias="verifiedRatio", - description="Proportion of traced materials (0-1)", - ), - ] - traceability_event: Annotated[ - list[SecureLink] | None, - Field( - default=None, - alias="traceabilityEvent", - description="Links to traceability events", - ), - ] - - -class CircularityPerformance(UNTPBaseModel): - """Circularity performance scorecard.""" - - _jsonld_type: ClassVar[list[str]] = ["CircularityPerformance"] - - recycling_information: Annotated[ - Link | None, - Field( - default=None, - alias="recyclingInformation", - description="Link to recycling information", - ), - ] - repair_information: Annotated[ - Link | None, - Field( - default=None, - alias="repairInformation", - description="Link to repair instructions", - ), - ] - recyclable_content: Annotated[ - float | None, - Field( - default=None, - ge=0, - le=1, - alias="recyclableContent", - description="Fraction designed to be recyclable (0-1)", - ), - ] - recycled_content: Annotated[ - float | None, - Field( - default=None, - ge=0, - le=1, - alias="recycledContent", - description="Fraction of recycled content (0-1)", - ), - ] - utility_factor: Annotated[ - float | None, - Field( - default=None, - ge=0, - alias="utilityFactor", - description="Durability indicator (lifetime / industry avg)", - ), - ] - material_circularity_indicator: Annotated[ - float | None, - Field( - default=None, - ge=0, - le=1, - alias="materialCircularityIndicator", - description="Overall circularity indicator (0-1)", - ), - ] +from dppvalidator.models.v0_6.performance import ( + CircularityPerformance, + EmissionsPerformance, + TraceabilityPerformance, +) + +__all__ = [ + "CircularityPerformance", + "EmissionsPerformance", + "TraceabilityPerformance", +] diff --git a/src/dppvalidator/models/primitives.py b/src/dppvalidator/models/primitives.py index 11db322..1e5ae11 100644 --- a/src/dppvalidator/models/primitives.py +++ b/src/dppvalidator/models/primitives.py @@ -1,132 +1,30 @@ -"""Primitive types for UNTP DPP models.""" +"""Backward-compatibility re-export of v0.6.x ``primitives``. -from __future__ import annotations - -import re -from typing import Annotated, ClassVar +The actual class definitions live in :mod:`dppvalidator.models.v0_6.primitives`. +This shim preserves the import path used by third-party plugins (e.g. +``from dppvalidator.models.primitives import Classification``) — see Phase 3 of +docs/plans/UNTP_0.7.0_MIGRATION.md and the public-API stability contract in +§7.6 of that plan. -from pydantic import AfterValidator, Field, HttpUrl +Through the 0.4.x line this re-exports v0.6.x classes (the current default +schema version). Phase 9 (validator 0.5.0) will switch the default to v0.7 +and update this shim accordingly. +""" -from dppvalidator.models.base import UNTPStrictModel -from dppvalidator.models.enums import EncryptionMethod, HashMethod +from __future__ import annotations -# URI pattern supporting: -# - HTTP/HTTPS URLs (https://example.com) -# - DIDs (did:web:example.com, did:webvh:example.com) -# - URNs (urn:uuid:123, urn:isbn:123) -# - Custom schemes (example:product/1234, gs1:01/1234) -_URI_PATTERN = re.compile( - r"^[a-zA-Z][a-zA-Z0-9+.-]*:" # scheme (RFC 3986) - r".+$", # scheme-specific part (non-empty) - re.ASCII, +from dppvalidator.models.v0_6.primitives import ( + Classification, + FlexibleUri, + Link, + Measure, + SecureLink, ) - -def _validate_uri(value: str) -> str: - """Validate that a string is a valid URI per RFC 3986. - - Supports HTTP URLs, DIDs, URNs, and custom URI schemes. - """ - if not _URI_PATTERN.match(value): - raise ValueError( - f"Invalid URI: '{value}'. Must have format 'scheme:path' " - "(e.g., 'https://...', 'did:web:...', 'urn:uuid:...')" - ) - return value - - -# Flexible URI type for W3C VC / UNTP compatibility -# Accepts HTTP URLs, DIDs (did:web:, did:webvh:), URNs, and custom schemes -FlexibleUri = Annotated[str, AfterValidator(_validate_uri)] - - -class Measure(UNTPStrictModel): - """Numeric value with unit of measure (UNECE Rec20).""" - - _jsonld_type: ClassVar[list[str]] = ["Measure"] - - value: float = Field(..., description="The numeric value of the measure") - unit: str = Field( - ..., - description="Unit of measure from UNECE Rec20 (e.g., KGM, LTR, EA)", - ) - - -class Link(UNTPStrictModel): - """URL link with metadata.""" - - _jsonld_type: ClassVar[list[str]] = ["Link"] - - link_url: Annotated[ - HttpUrl | None, - Field(default=None, alias="linkURL", description="The URL of the target resource"), - ] - link_name: Annotated[ - str | None, - Field(default=None, alias="linkName", description="Display name for the target resource"), - ] - link_type: Annotated[ - str | None, - Field(default=None, alias="linkType", description="Type of the target resource"), - ] - - -class SecureLink(UNTPStrictModel): - """Link with hash and optional encryption for tamper evidence.""" - - _jsonld_type: ClassVar[list[str]] = ["SecureLink", "Link"] - - link_url: Annotated[ - HttpUrl | None, - Field(default=None, alias="linkURL", description="The URL of the target resource"), - ] - link_name: Annotated[ - str | None, - Field(default=None, alias="linkName", description="Display name for the target resource"), - ] - link_type: Annotated[ - str | None, - Field(default=None, alias="linkType", description="Type of the target resource"), - ] - hash_digest: Annotated[ - str | None, - Field(default=None, alias="hashDigest", description="Hash of the file"), - ] - hash_method: Annotated[ - HashMethod | None, - Field( - default=None, alias="hashMethod", description="Hashing algorithm (SHA-256 recommended)" - ), - ] - encryption_method: Annotated[ - EncryptionMethod | None, - Field( - default=None, - alias="encryptionMethod", - description="Encryption algorithm (AES recommended)", - ), - ] - - -class Classification(UNTPStrictModel): - """Classification scheme and code representing a category value.""" - - _jsonld_type: ClassVar[list[str]] = ["Classification"] - - id: Annotated[ - FlexibleUri, - Field(..., description="Globally unique URI representing the classifier value"), - ] - code: Annotated[ - str | None, - Field(default=None, description="Classification code within the scheme"), - ] - name: str = Field(..., description="Name of the classification") - scheme_id: Annotated[ - FlexibleUri | None, - Field(default=None, alias="schemeID", description="Classification scheme ID"), - ] - scheme_name: Annotated[ - str | None, - Field(default=None, alias="schemeName", description="Name of the classification scheme"), - ] +__all__ = [ + "Classification", + "FlexibleUri", + "Link", + "Measure", + "SecureLink", +] diff --git a/src/dppvalidator/models/product.py b/src/dppvalidator/models/product.py index 7be0780..b23023e 100644 --- a/src/dppvalidator/models/product.py +++ b/src/dppvalidator/models/product.py @@ -1,99 +1,26 @@ -"""Product-related models for UNTP DPP.""" +"""Backward-compatibility re-export of v0.6.x ``product``. -from __future__ import annotations - -from datetime import date -from typing import Annotated, ClassVar - -from pydantic import Field - -from dppvalidator.models.base import UNTPBaseModel -from dppvalidator.models.identifiers import Facility, IdentifierScheme, Party -from dppvalidator.models.primitives import Classification, FlexibleUri, Link, Measure - - -class Dimension(UNTPBaseModel): - """Physical dimensions (length, width, height) and weight/volume.""" - - _jsonld_type: ClassVar[list[str]] = ["Dimension"] +The actual class definitions live in :mod:`dppvalidator.models.v0_6.product`. +This shim preserves the import path used by third-party plugins (e.g. +``from dppvalidator.models.product import Characteristics``) — see Phase 3 of +docs/plans/UNTP_0.7.0_MIGRATION.md and the public-API stability contract in +§7.6 of that plan. - weight: Measure | None = Field(default=None, description="Weight of the product") - length: Measure | None = Field(default=None, description="Length of the product") - width: Measure | None = Field(default=None, description="Width of the product") - height: Measure | None = Field(default=None, description="Height of the product") - volume: Measure | None = Field(default=None, description="Displacement volume") +Through the 0.4.x line this re-exports v0.6.x classes (the current default +schema version). Phase 9 (validator 0.5.0) will switch the default to v0.7 +and update this shim accordingly. +""" +from __future__ import annotations -class Characteristics(UNTPBaseModel): - """Extension point for industry/product-specific characteristics.""" - - _jsonld_type: ClassVar[list[str]] = ["Characteristics"] - - -class Product(UNTPBaseModel): - """Product information including identification and manufacturer details.""" - - _jsonld_type: ClassVar[list[str]] = ["Product"] - - id: Annotated[ - FlexibleUri, - Field(..., description="Globally unique ID of the product as a URI"), - ] - name: str = Field(..., description="Registered name of the product") - registered_id: Annotated[ - str | None, - Field( - default=None, - alias="registeredId", - description="Registration number within the register", - ), - ] - id_scheme: Annotated[ - IdentifierScheme | None, - Field(default=None, alias="idScheme", description="Identifier scheme for this product"), - ] - batch_number: Annotated[ - str | None, - Field(default=None, alias="batchNumber", description="Production batch identifier"), - ] - product_image: Annotated[ - Link | None, - Field(default=None, alias="productImage", description="Reference to product image"), - ] - description: str | None = Field(default=None, description="Textual product description") - characteristics: Characteristics | None = Field( - default=None, description="Industry-specific characteristics" - ) - product_category: Annotated[ - list[Classification] | None, - Field(default=None, alias="productCategory", description="Product classification codes"), - ] - further_information: Annotated[ - list[Link] | None, - Field(default=None, alias="furtherInformation", description="Additional information links"), - ] - produced_by_party: Annotated[ - Party | None, - Field(default=None, alias="producedByParty", description="Manufacturing party"), - ] - produced_at_facility: Annotated[ - Facility | None, - Field(default=None, alias="producedAtFacility", description="Manufacturing facility"), - ] - production_date: Annotated[ - date | None, - Field(default=None, alias="productionDate", description="ISO 8601 production date"), - ] - country_of_production: Annotated[ - str | None, - Field( - default=None, - alias="countryOfProduction", - description="ISO 3166-1 country code of production", - ), - ] - serial_number: Annotated[ - str | None, - Field(default=None, alias="serialNumber", description="Serialised item identifier"), - ] - dimensions: Dimension | None = Field(default=None, description="Physical dimensions") +from dppvalidator.models.v0_6.product import ( + Characteristics, + Dimension, + Product, +) + +__all__ = [ + "Characteristics", + "Dimension", + "Product", +] diff --git a/src/dppvalidator/models/v0_6/__init__.py b/src/dppvalidator/models/v0_6/__init__.py new file mode 100644 index 0000000..8916c81 --- /dev/null +++ b/src/dppvalidator/models/v0_6/__init__.py @@ -0,0 +1,93 @@ +"""Pydantic models for UNTP DPP **v0.6.x**. + +Phase 3 of docs/plans/UNTP_0.7.0_MIGRATION.md splits the model package into +version-namespaced subpackages so 0.6.x and 0.7.x classes can coexist. This +subpackage holds the legacy 0.6.x shapes; the top-level +``dppvalidator.models`` and ``dppvalidator.models.passport`` re-export from +here for backward compatibility through the 0.4.x line. Phase 9 (validator +release 0.5.0) flips the default and re-exports v0.7 instead; Phase 10 +(release 0.6.0) removes this subpackage entirely. + +The ``examples/dppvalidator_example_plugin/`` and any third-party plugin +that imports ``from dppvalidator.models.passport import DigitalProductPassport`` +keeps working because of the re-export shim — see §4.1.8 / §7.6 of the plan. +""" + +from dppvalidator.models.v0_6.claims import ( + Claim, + Criterion, + Metric, + Regulation, + Standard, +) +from dppvalidator.models.v0_6.credential import ( + CredentialIssuer, + CredentialStatus, + ProductPassport, +) +from dppvalidator.models.v0_6.enums import ( + ConformityTopic, + CriterionStatus, + EncryptionMethod, + GranularityLevel, + HashMethod, + OperationalScope, +) +from dppvalidator.models.v0_6.identifiers import Facility, IdentifierScheme, Party +from dppvalidator.models.v0_6.materials import Material +from dppvalidator.models.v0_6.passport import DigitalProductPassport +from dppvalidator.models.v0_6.performance import ( + CircularityPerformance, + EmissionsPerformance, + TraceabilityPerformance, +) +from dppvalidator.models.v0_6.primitives import ( + Classification, + FlexibleUri, + Link, + Measure, + SecureLink, +) +from dppvalidator.models.v0_6.product import Characteristics, Dimension, Product + +__all__ = [ + # Claims + "Claim", + "Criterion", + "Metric", + "Regulation", + "Standard", + # Credential + "CredentialIssuer", + "CredentialStatus", + "ProductPassport", + # Enums + "ConformityTopic", + "CriterionStatus", + "EncryptionMethod", + "GranularityLevel", + "HashMethod", + "OperationalScope", + # Identifiers + "Facility", + "IdentifierScheme", + "Party", + # Materials + "Material", + # Passport (envelope) + "DigitalProductPassport", + # Performance scorecards (collapse into Claim.claimedPerformance in v0.7) + "CircularityPerformance", + "EmissionsPerformance", + "TraceabilityPerformance", + # Primitives + "Classification", + "FlexibleUri", + "Link", + "Measure", + "SecureLink", + # Product + "Characteristics", + "Dimension", + "Product", +] diff --git a/src/dppvalidator/models/v0_6/claims.py b/src/dppvalidator/models/v0_6/claims.py new file mode 100644 index 0000000..f4475ae --- /dev/null +++ b/src/dppvalidator/models/v0_6/claims.py @@ -0,0 +1,164 @@ +"""Claim and conformity-related models for UNTP DPP.""" + +from __future__ import annotations + +from datetime import date +from typing import Annotated, ClassVar + +from pydantic import Field + +from dppvalidator.models.base import UNTPBaseModel, UNTPStrictModel +from dppvalidator.models.v0_6.enums import ConformityTopic, CriterionStatus +from dppvalidator.models.v0_6.identifiers import Party +from dppvalidator.models.v0_6.primitives import Classification, FlexibleUri, Measure, SecureLink + + +class Metric(UNTPStrictModel): + """Performance metric with value and optional score.""" + + _jsonld_type: ClassVar[list[str]] = ["Metric"] + + metric_name: Annotated[ + str, + Field(..., alias="metricName", description="Human readable metric name"), + ] + metric_value: Annotated[ + Measure, + Field(..., alias="metricValue", description="Numeric value and unit"), + ] + score: str | None = Field(default=None, description="Score or rank for this metric") + accuracy: float | None = Field( + default=None, + ge=0, + le=1, + description="Accuracy as percentage (0-1)", + ) + + +class Criterion(UNTPBaseModel): + """Specific rule or criterion within a standard or regulation.""" + + _jsonld_type: ClassVar[list[str]] = ["Criterion"] + + id: Annotated[ + FlexibleUri, + Field(..., description="Unique identifier for the criterion"), + ] + name: str = Field(..., description="Criterion name") + description: str = Field(..., description="Full text description of the criterion") + conformity_topic: Annotated[ + ConformityTopic, + Field(..., alias="conformityTopic", description="Conformity topic category"), + ] + status: CriterionStatus = Field(..., description="Lifecycle status") + sub_criterion: Annotated[ + list[Criterion] | None, + Field(default=None, alias="subCriterion", description="Subordinate criteria"), + ] + threshold_value: Annotated[ + Metric | None, + Field(default=None, alias="thresholdValue", description="Minimum compliance threshold"), + ] + performance_level: Annotated[ + str | None, + Field(default=None, alias="performanceLevel", description="Performance category code"), + ] + category: Annotated[ + list[Classification] | None, + Field(default=None, description="Product categories the criterion applies to"), + ] + tag: Annotated[ + list[str] | None, + Field(default=None, description="Tags for stakeholder/commodity types"), + ] + + +class Standard(UNTPStrictModel): + """Standard that specifies conformance criteria (e.g., ISO 14000).""" + + _jsonld_type: ClassVar[list[str]] = ["Standard"] + + id: Annotated[ + FlexibleUri | None, + Field(default=None, description="Unique identifier for the standard"), + ] + name: str | None = Field(default=None, description="Name of the standard") + issuing_party: Annotated[ + Party, + Field(..., alias="issuingParty", description="Party that issued the standard"), + ] + issue_date: Annotated[ + date | None, + Field(default=None, alias="issueDate", description="Date the standard was issued"), + ] + + +class Regulation(UNTPStrictModel): + """Regulation that defines assessment criteria.""" + + _jsonld_type: ClassVar[list[str]] = ["Regulation"] + + id: Annotated[ + FlexibleUri | None, + Field(default=None, description="Globally unique identifier of the regulation"), + ] + name: str | None = Field(default=None, description="Name of the regulation or act") + jurisdiction_country: Annotated[ + str | None, + Field( + default=None, + alias="jurisdictionCountry", + description="ISO 3166-1 jurisdiction country code", + ), + ] + administered_by: Annotated[ + Party, + Field(..., alias="administeredBy", description="Issuing body of the regulation"), + ] + effective_date: Annotated[ + date | None, + Field(default=None, alias="effectiveDate", description="Date regulation came into effect"), + ] + + +class Claim(UNTPBaseModel): + """Declaration of conformance with standard or regulation criteria.""" + + _jsonld_type: ClassVar[list[str]] = ["Claim", "Declaration"] + + id: Annotated[ + FlexibleUri, + Field(..., description="Unique identifier for the declaration"), + ] + description: str | None = Field(default=None, description="Textual description of the claim") + reference_standard: Annotated[ + Standard | None, + Field(default=None, alias="referenceStandard", description="Reference standard"), + ] + reference_regulation: Annotated[ + Regulation | None, + Field(default=None, alias="referenceRegulation", description="Reference regulation"), + ] + assessment_criteria: Annotated[ + list[Criterion] | None, + Field(default=None, alias="assessmentCriteria", description="Assessment specifications"), + ] + assessment_date: Annotated[ + date | None, + Field(default=None, alias="assessmentDate", description="Date of assessment"), + ] + declared_value: Annotated[ + list[Metric] | None, + Field(default=None, alias="declaredValue", description="Measured values"), + ] + conformance: bool = Field(..., description="Whether the claim conforms to criteria") + conformity_topic: Annotated[ + ConformityTopic, + Field(..., alias="conformityTopic", description="Conformity topic category"), + ] + conformity_evidence: Annotated[ + SecureLink | None, + Field( + default=None, alias="conformityEvidence", description="Evidence supporting the claim" + ), + ] diff --git a/src/dppvalidator/models/v0_6/credential.py b/src/dppvalidator/models/v0_6/credential.py new file mode 100644 index 0000000..6fa80c0 --- /dev/null +++ b/src/dppvalidator/models/v0_6/credential.py @@ -0,0 +1,154 @@ +"""Credential and ProductPassport models for UNTP DPP.""" + +from __future__ import annotations + +from typing import Annotated, ClassVar + +from pydantic import Field + +from dppvalidator.models.base import UNTPBaseModel, UNTPStrictModel +from dppvalidator.models.v0_6.claims import Claim +from dppvalidator.models.v0_6.enums import GranularityLevel +from dppvalidator.models.v0_6.identifiers import Party +from dppvalidator.models.v0_6.materials import Material +from dppvalidator.models.v0_6.performance import ( + CircularityPerformance, + EmissionsPerformance, + TraceabilityPerformance, +) +from dppvalidator.models.v0_6.primitives import FlexibleUri, Link +from dppvalidator.models.v0_6.product import Product + + +class CredentialStatus(UNTPBaseModel): + """Credential status for revocation checking per W3C VC v2. + + Used to check if a credential has been revoked or suspended. + Supports multiple status mechanisms (BitstringStatusList, StatusList2021, etc.). + """ + + _jsonld_type: ClassVar[list[str]] = ["CredentialStatus"] + + id: Annotated[ + FlexibleUri, + Field(..., description="URI identifying the status entry"), + ] + type: Annotated[ + str, + Field( + ..., + description="Status type (e.g., BitstringStatusListEntry, StatusList2021Entry)", + ), + ] + status_purpose: Annotated[ + str | None, + Field( + default=None, + alias="statusPurpose", + description="Purpose of status (revocation, suspension)", + ), + ] + status_list_index: Annotated[ + str | None, + Field( + default=None, + alias="statusListIndex", + description="Index in the status list", + ), + ] + status_list_credential: Annotated[ + FlexibleUri | None, + Field( + default=None, + alias="statusListCredential", + description="URI of the status list credential", + ), + ] + + +class CredentialIssuer(UNTPStrictModel): + """Issuer of a verifiable credential.""" + + _jsonld_type: ClassVar[list[str]] = ["CredentialIssuer"] + + id: Annotated[ + FlexibleUri, + Field(..., description="W3C DID of the issuer (did:web, did:webvh, or https URL)"), + ] + name: str = Field(..., description="Name of the issuer person or organisation") + issuer_also_known_as: Annotated[ + list[Party] | None, + Field( + default=None, + alias="issuerAlsoKnownAs", + description="Other registered identifiers for this issuer", + ), + ] + + +class ProductPassport(UNTPBaseModel): + """Product passport credential subject.""" + + _jsonld_type: ClassVar[list[str]] = ["ProductPassport"] + + id: Annotated[ + FlexibleUri | None, + Field(default=None, description="Identifier for the credential subject (URI)"), + ] + product: Product | None = Field(default=None, description="Product information") + granularity_level: Annotated[ + GranularityLevel | None, + Field( + default=None, + alias="granularityLevel", + description="Item, batch, or model level passport", + ), + ] + conformity_claim: Annotated[ + list[Claim] | None, + Field( + default=None, + alias="conformityClaim", + description="Conformity claims about the product", + ), + ] + emissions_scorecard: Annotated[ + EmissionsPerformance | None, + Field( + default=None, + alias="emissionsScorecard", + description="Emissions performance scorecard", + ), + ] + traceability_information: Annotated[ + list[TraceabilityPerformance] | None, + Field( + default=None, + alias="traceabilityInformation", + description="Traceability events by value chain process", + ), + ] + circularity_scorecard: Annotated[ + CircularityPerformance | None, + Field( + default=None, + alias="circularityScorecard", + description="Circularity performance scorecard", + ), + ] + due_diligence_declaration: Annotated[ + Link | None, + Field( + default=None, + alias="dueDiligenceDeclaration", + description="Due diligence declaration link", + ), + ] + materials_provenance: Annotated[ + list[Material] | None, + Field( + default=None, + alias="materialsProvenance", + description="Material origin and mass fraction information", + ), + ] diff --git a/src/dppvalidator/models/v0_6/enums.py b/src/dppvalidator/models/v0_6/enums.py new file mode 100644 index 0000000..944bac1 --- /dev/null +++ b/src/dppvalidator/models/v0_6/enums.py @@ -0,0 +1,70 @@ +"""Enumeration types for UNTP DPP models.""" + +from __future__ import annotations + +from enum import Enum + + +class ConformityTopic(str, Enum): + """Conformity topic categories per UNTP specification.""" + + ENVIRONMENT_ENERGY = "environment.energy" + ENVIRONMENT_EMISSIONS = "environment.emissions" + ENVIRONMENT_WATER = "environment.water" + ENVIRONMENT_WASTE = "environment.waste" + ENVIRONMENT_DEFORESTATION = "environment.deforestation" + ENVIRONMENT_BIODIVERSITY = "environment.biodiversity" + CIRCULARITY_CONTENT = "circularity.content" + CIRCULARITY_DESIGN = "circularity.design" + SOCIAL_LABOUR = "social.labour" + SOCIAL_RIGHTS = "social.rights" + SOCIAL_COMMUNITY = "social.community" + SOCIAL_SAFETY = "social.safety" + GOVERNANCE_ETHICS = "governance.ethics" + GOVERNANCE_COMPLIANCE = "governance.compliance" + GOVERNANCE_TRANSPARENCY = "governance.transparency" + + +class GranularityLevel(str, Enum): + """Granularity level for product passports.""" + + ITEM = "item" + BATCH = "batch" + MODEL = "model" + + +class OperationalScope(str, Enum): + """Operational scope for emissions performance. + + Supports both GHG Protocol scopes (Scope1/2/3) and lifecycle + assessment boundaries (CradleToGate/CradleToGrave). + """ + + NONE = "None" + SCOPE1 = "Scope1" + SCOPE2 = "Scope2" + SCOPE3 = "Scope3" + CRADLE_TO_GATE = "CradleToGate" + CRADLE_TO_GRAVE = "CradleToGrave" + + +class HashMethod(str, Enum): + """Hash algorithm for secure links.""" + + SHA_256 = "SHA-256" + SHA_1 = "SHA-1" + + +class EncryptionMethod(str, Enum): + """Encryption method for secure links.""" + + NONE = "none" + AES = "AES" + + +class CriterionStatus(str, Enum): + """Lifecycle status of a criterion.""" + + PROPOSED = "proposed" + ACTIVE = "active" + DEPRECATED = "deprecated" diff --git a/src/dppvalidator/models/v0_6/identifiers.py b/src/dppvalidator/models/v0_6/identifiers.py new file mode 100644 index 0000000..394e8a6 --- /dev/null +++ b/src/dppvalidator/models/v0_6/identifiers.py @@ -0,0 +1,68 @@ +"""Identifier-related models for UNTP DPP.""" + +from __future__ import annotations + +from typing import Annotated, ClassVar + +from pydantic import Field + +from dppvalidator.models.base import UNTPBaseModel, UNTPStrictModel +from dppvalidator.models.v0_6.primitives import FlexibleUri + + +class IdentifierScheme(UNTPStrictModel): + """Identifier registration scheme for products, facilities, or organisations.""" + + _jsonld_type: ClassVar[list[str]] = ["IdentifierScheme"] + + id: Annotated[ + FlexibleUri | None, + Field( + default=None, + description="Globally unique identifier of the registration scheme", + ), + ] + name: Annotated[ + str | None, + Field(default=None, description="Name of the identifier scheme"), + ] + + +class Party(UNTPBaseModel): + """A party (person or organisation) with identifier.""" + + _jsonld_type: ClassVar[list[str]] = ["Party"] + + id: Annotated[ + FlexibleUri, + Field(..., description="Globally unique ID of the party as a URI"), + ] + name: str = Field(..., description="Registered name of the party") + registered_id: Annotated[ + str | None, + Field( + default=None, + alias="registeredId", + description="Registration number within the register", + ), + ] + + +class Facility(UNTPBaseModel): + """A facility where products are manufactured.""" + + _jsonld_type: ClassVar[list[str]] = ["Facility"] + + id: Annotated[ + FlexibleUri, + Field(..., description="Globally unique ID of the facility as URI"), + ] + name: str = Field(..., description="Registered name of the facility") + registered_id: Annotated[ + str | None, + Field( + default=None, + alias="registeredId", + description="Registration number within the identifier scheme", + ), + ] diff --git a/src/dppvalidator/models/v0_6/materials.py b/src/dppvalidator/models/v0_6/materials.py new file mode 100644 index 0000000..5af3095 --- /dev/null +++ b/src/dppvalidator/models/v0_6/materials.py @@ -0,0 +1,78 @@ +"""Material provenance models for UNTP DPP.""" + +from __future__ import annotations + +from typing import Annotated, ClassVar + +from pydantic import Field, model_validator + +from dppvalidator.models.base import UNTPBaseModel +from dppvalidator.models.v0_6.primitives import Classification, Link, Measure + + +class Material(UNTPBaseModel): + """Material origin and mass fraction information.""" + + _jsonld_type: ClassVar[list[str]] = ["Material"] + + name: str = Field(..., description="Material name (e.g., 'Egyptian Cotton')") + origin_country: Annotated[ + str | None, + Field( + default=None, + alias="originCountry", + description="ISO 3166-1 country of origin", + ), + ] + material_type: Annotated[ + Classification | None, + Field( + default=None, + alias="materialType", + description="Material classification (e.g., UNFC)", + ), + ] + mass_fraction: Annotated[ + float | None, + Field( + default=None, + ge=0, + le=1, + alias="massFraction", + description="Mass fraction of product (0-1, sum should equal 1)", + ), + ] + mass: Measure | None = Field(default=None, description="Mass of the material component") + recycled_mass_fraction: Annotated[ + float | None, + Field( + default=None, + ge=0, + le=1, + alias="recycledMassFraction", + description="Fraction of material that is recycled (0-1)", + ), + ] + hazardous: bool | None = Field( + default=None, + description="Whether material is hazardous", + ) + symbol: str | None = Field( + default=None, + description="Base64 encoded visual symbol for the material", + ) + material_safety_information: Annotated[ + Link | None, + Field( + default=None, + alias="materialSafetyInformation", + description="Link to material safety data sheet", + ), + ] + + @model_validator(mode="after") + def validate_hazardous_requires_safety_info(self) -> Material: + """Ensure hazardous materials have safety information.""" + if self.hazardous and not self.material_safety_information: + raise ValueError("materialSafetyInformation is required when hazardous is true") + return self diff --git a/src/dppvalidator/models/v0_6/passport.py b/src/dppvalidator/models/v0_6/passport.py new file mode 100644 index 0000000..93643c1 --- /dev/null +++ b/src/dppvalidator/models/v0_6/passport.py @@ -0,0 +1,94 @@ +"""Digital Product Passport root model per UNTP/UNCEFACT v0.6.1.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Annotated, ClassVar + +from pydantic import Field, model_validator + +from dppvalidator.models.base import UNTPBaseModel +from dppvalidator.models.v0_6.credential import ( + CredentialIssuer, + CredentialStatus, + ProductPassport, +) +from dppvalidator.models.v0_6.primitives import FlexibleUri + + +class DigitalProductPassport(UNTPBaseModel): + """Digital Product Passport as a Verifiable Credential. + + Root model for UNTP DPP v0.6.1, combining DigitalProductPassport + and VerifiableCredential types per W3C VC v2 specification. + """ + + _jsonld_type: ClassVar[list[str]] = ["DigitalProductPassport", "VerifiableCredential"] + + context: Annotated[ + list[str], + Field( + alias="@context", + default=[ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ], + description="JSON-LD context URIs", + ), + ] + id: Annotated[ + FlexibleUri, + Field(..., description="Unique identifier (URI) for this passport"), + ] + issuer: CredentialIssuer = Field(..., description="Organisation issuing this credential") + valid_from: Annotated[ + datetime | None, + Field(default=None, alias="validFrom", description="Credential validity start"), + ] + valid_until: Annotated[ + datetime | None, + Field(default=None, alias="validUntil", description="Credential expiry date"), + ] + credential_subject: Annotated[ + ProductPassport | None, + Field( + default=None, + alias="credentialSubject", + description="The product passport content", + ), + ] + credential_status: Annotated[ + CredentialStatus | list[CredentialStatus] | None, + Field( + default=None, + alias="credentialStatus", + description="Credential revocation/suspension status per W3C VC v2", + ), + ] + + @model_validator(mode="after") + def validate_dates(self) -> DigitalProductPassport: + """Ensure validFrom is before validUntil if both are present.""" + if self.valid_from and self.valid_until and self.valid_from >= self.valid_until: + raise ValueError("validFrom must be before validUntil") + return self + + @model_validator(mode="after") + def validate_material_mass_fractions(self) -> DigitalProductPassport: + """Validate material mass fractions don't exceed 1.0. + + Per UNTP spec, mass fractions can be partial declarations (sum < 1.0). + Only sum > 1.0 is physically impossible and should error. + Semantic validation checks for exact sum when appropriate. + """ + if not self.credential_subject: + return self + materials = self.credential_subject.materials_provenance + if not materials: + return self + fractions = [m.mass_fraction for m in materials if m.mass_fraction is not None] + if fractions: + total = sum(fractions) + if total > 1.01: # Allow small tolerance for floating point + raise ValueError(f"Material mass fractions cannot exceed 1.0, got {total:.3f}") + return self diff --git a/src/dppvalidator/models/v0_6/performance.py b/src/dppvalidator/models/v0_6/performance.py new file mode 100644 index 0000000..f7f84da --- /dev/null +++ b/src/dppvalidator/models/v0_6/performance.py @@ -0,0 +1,156 @@ +"""Performance scorecard models for UNTP DPP.""" + +from __future__ import annotations + +from typing import Annotated, ClassVar + +from pydantic import Field + +from dppvalidator.models.base import UNTPBaseModel +from dppvalidator.models.v0_6.claims import Standard +from dppvalidator.models.v0_6.enums import OperationalScope +from dppvalidator.models.v0_6.primitives import Link, SecureLink + + +class EmissionsPerformance(UNTPBaseModel): + """Emissions performance scorecard.""" + + _jsonld_type: ClassVar[list[str]] = ["EmissionsPerformance"] + + carbon_footprint: Annotated[ + float, + Field( + ..., + alias="carbonFootprint", + description="Carbon footprint in KgCO2e per declared unit", + ), + ] + declared_unit: Annotated[ + str, + Field( + ..., + alias="declaredUnit", + description="Unit of product (EA, KGM, LTR) for carbon footprint basis", + ), + ] + operational_scope: Annotated[ + OperationalScope | None, + Field( + default=None, + alias="operationalScope", + description="Emissions operational scope (GHG Protocol or lifecycle boundary)", + ), + ] + primary_sourced_ratio: Annotated[ + float, + Field( + ..., + ge=0, + le=1, + alias="primarySourcedRatio", + description="Ratio of primary source emissions data (0-1)", + ), + ] + reporting_standard: Annotated[ + Standard | None, + Field( + default=None, + alias="reportingStandard", + description="Reporting standard (GHG Protocol, IFRS S2, etc.)", + ), + ] + + +class TraceabilityPerformance(UNTPBaseModel): + """Traceability performance for a value chain process.""" + + _jsonld_type: ClassVar[list[str]] = ["TraceabilityPerformance"] + + value_chain_process: Annotated[ + str | None, + Field( + default=None, + alias="valueChainProcess", + description="Industry-specific value chain process name", + ), + ] + verified_ratio: Annotated[ + float | None, + Field( + default=None, + ge=0, + le=1, + alias="verifiedRatio", + description="Proportion of traced materials (0-1)", + ), + ] + traceability_event: Annotated[ + list[SecureLink] | None, + Field( + default=None, + alias="traceabilityEvent", + description="Links to traceability events", + ), + ] + + +class CircularityPerformance(UNTPBaseModel): + """Circularity performance scorecard.""" + + _jsonld_type: ClassVar[list[str]] = ["CircularityPerformance"] + + recycling_information: Annotated[ + Link | None, + Field( + default=None, + alias="recyclingInformation", + description="Link to recycling information", + ), + ] + repair_information: Annotated[ + Link | None, + Field( + default=None, + alias="repairInformation", + description="Link to repair instructions", + ), + ] + recyclable_content: Annotated[ + float | None, + Field( + default=None, + ge=0, + le=1, + alias="recyclableContent", + description="Fraction designed to be recyclable (0-1)", + ), + ] + recycled_content: Annotated[ + float | None, + Field( + default=None, + ge=0, + le=1, + alias="recycledContent", + description="Fraction of recycled content (0-1)", + ), + ] + utility_factor: Annotated[ + float | None, + Field( + default=None, + ge=0, + alias="utilityFactor", + description="Durability indicator (lifetime / industry avg)", + ), + ] + material_circularity_indicator: Annotated[ + float | None, + Field( + default=None, + ge=0, + le=1, + alias="materialCircularityIndicator", + description="Overall circularity indicator (0-1)", + ), + ] diff --git a/src/dppvalidator/models/v0_6/primitives.py b/src/dppvalidator/models/v0_6/primitives.py new file mode 100644 index 0000000..240b706 --- /dev/null +++ b/src/dppvalidator/models/v0_6/primitives.py @@ -0,0 +1,132 @@ +"""Primitive types for UNTP DPP models.""" + +from __future__ import annotations + +import re +from typing import Annotated, ClassVar + +from pydantic import AfterValidator, Field, HttpUrl + +from dppvalidator.models.base import UNTPStrictModel +from dppvalidator.models.v0_6.enums import EncryptionMethod, HashMethod + +# URI pattern supporting: +# - HTTP/HTTPS URLs (https://example.com) +# - DIDs (did:web:example.com, did:webvh:example.com) +# - URNs (urn:uuid:123, urn:isbn:123) +# - Custom schemes (example:product/1234, gs1:01/1234) +_URI_PATTERN = re.compile( + r"^[a-zA-Z][a-zA-Z0-9+.-]*:" # scheme (RFC 3986) + r".+$", # scheme-specific part (non-empty) + re.ASCII, +) + + +def _validate_uri(value: str) -> str: + """Validate that a string is a valid URI per RFC 3986. + + Supports HTTP URLs, DIDs, URNs, and custom URI schemes. + """ + if not _URI_PATTERN.match(value): + raise ValueError( + f"Invalid URI: '{value}'. Must have format 'scheme:path' " + "(e.g., 'https://...', 'did:web:...', 'urn:uuid:...')" + ) + return value + + +# Flexible URI type for W3C VC / UNTP compatibility +# Accepts HTTP URLs, DIDs (did:web:, did:webvh:), URNs, and custom schemes +FlexibleUri = Annotated[str, AfterValidator(_validate_uri)] + + +class Measure(UNTPStrictModel): + """Numeric value with unit of measure (UNECE Rec20).""" + + _jsonld_type: ClassVar[list[str]] = ["Measure"] + + value: float = Field(..., description="The numeric value of the measure") + unit: str = Field( + ..., + description="Unit of measure from UNECE Rec20 (e.g., KGM, LTR, EA)", + ) + + +class Link(UNTPStrictModel): + """URL link with metadata.""" + + _jsonld_type: ClassVar[list[str]] = ["Link"] + + link_url: Annotated[ + HttpUrl | None, + Field(default=None, alias="linkURL", description="The URL of the target resource"), + ] + link_name: Annotated[ + str | None, + Field(default=None, alias="linkName", description="Display name for the target resource"), + ] + link_type: Annotated[ + str | None, + Field(default=None, alias="linkType", description="Type of the target resource"), + ] + + +class SecureLink(UNTPStrictModel): + """Link with hash and optional encryption for tamper evidence.""" + + _jsonld_type: ClassVar[list[str]] = ["SecureLink", "Link"] + + link_url: Annotated[ + HttpUrl | None, + Field(default=None, alias="linkURL", description="The URL of the target resource"), + ] + link_name: Annotated[ + str | None, + Field(default=None, alias="linkName", description="Display name for the target resource"), + ] + link_type: Annotated[ + str | None, + Field(default=None, alias="linkType", description="Type of the target resource"), + ] + hash_digest: Annotated[ + str | None, + Field(default=None, alias="hashDigest", description="Hash of the file"), + ] + hash_method: Annotated[ + HashMethod | None, + Field( + default=None, alias="hashMethod", description="Hashing algorithm (SHA-256 recommended)" + ), + ] + encryption_method: Annotated[ + EncryptionMethod | None, + Field( + default=None, + alias="encryptionMethod", + description="Encryption algorithm (AES recommended)", + ), + ] + + +class Classification(UNTPStrictModel): + """Classification scheme and code representing a category value.""" + + _jsonld_type: ClassVar[list[str]] = ["Classification"] + + id: Annotated[ + FlexibleUri, + Field(..., description="Globally unique URI representing the classifier value"), + ] + code: Annotated[ + str | None, + Field(default=None, description="Classification code within the scheme"), + ] + name: str = Field(..., description="Name of the classification") + scheme_id: Annotated[ + FlexibleUri | None, + Field(default=None, alias="schemeID", description="Classification scheme ID"), + ] + scheme_name: Annotated[ + str | None, + Field(default=None, alias="schemeName", description="Name of the classification scheme"), + ] diff --git a/src/dppvalidator/models/v0_6/product.py b/src/dppvalidator/models/v0_6/product.py new file mode 100644 index 0000000..ce664cf --- /dev/null +++ b/src/dppvalidator/models/v0_6/product.py @@ -0,0 +1,99 @@ +"""Product-related models for UNTP DPP.""" + +from __future__ import annotations + +from datetime import date +from typing import Annotated, ClassVar + +from pydantic import Field + +from dppvalidator.models.base import UNTPBaseModel +from dppvalidator.models.v0_6.identifiers import Facility, IdentifierScheme, Party +from dppvalidator.models.v0_6.primitives import Classification, FlexibleUri, Link, Measure + + +class Dimension(UNTPBaseModel): + """Physical dimensions (length, width, height) and weight/volume.""" + + _jsonld_type: ClassVar[list[str]] = ["Dimension"] + + weight: Measure | None = Field(default=None, description="Weight of the product") + length: Measure | None = Field(default=None, description="Length of the product") + width: Measure | None = Field(default=None, description="Width of the product") + height: Measure | None = Field(default=None, description="Height of the product") + volume: Measure | None = Field(default=None, description="Displacement volume") + + +class Characteristics(UNTPBaseModel): + """Extension point for industry/product-specific characteristics.""" + + _jsonld_type: ClassVar[list[str]] = ["Characteristics"] + + +class Product(UNTPBaseModel): + """Product information including identification and manufacturer details.""" + + _jsonld_type: ClassVar[list[str]] = ["Product"] + + id: Annotated[ + FlexibleUri, + Field(..., description="Globally unique ID of the product as a URI"), + ] + name: str = Field(..., description="Registered name of the product") + registered_id: Annotated[ + str | None, + Field( + default=None, + alias="registeredId", + description="Registration number within the register", + ), + ] + id_scheme: Annotated[ + IdentifierScheme | None, + Field(default=None, alias="idScheme", description="Identifier scheme for this product"), + ] + batch_number: Annotated[ + str | None, + Field(default=None, alias="batchNumber", description="Production batch identifier"), + ] + product_image: Annotated[ + Link | None, + Field(default=None, alias="productImage", description="Reference to product image"), + ] + description: str | None = Field(default=None, description="Textual product description") + characteristics: Characteristics | None = Field( + default=None, description="Industry-specific characteristics" + ) + product_category: Annotated[ + list[Classification] | None, + Field(default=None, alias="productCategory", description="Product classification codes"), + ] + further_information: Annotated[ + list[Link] | None, + Field(default=None, alias="furtherInformation", description="Additional information links"), + ] + produced_by_party: Annotated[ + Party | None, + Field(default=None, alias="producedByParty", description="Manufacturing party"), + ] + produced_at_facility: Annotated[ + Facility | None, + Field(default=None, alias="producedAtFacility", description="Manufacturing facility"), + ] + production_date: Annotated[ + date | None, + Field(default=None, alias="productionDate", description="ISO 8601 production date"), + ] + country_of_production: Annotated[ + str | None, + Field( + default=None, + alias="countryOfProduction", + description="ISO 3166-1 country code of production", + ), + ] + serial_number: Annotated[ + str | None, + Field(default=None, alias="serialNumber", description="Serialised item identifier"), + ] + dimensions: Dimension | None = Field(default=None, description="Physical dimensions") diff --git a/src/dppvalidator/models/v0_7/__init__.py b/src/dppvalidator/models/v0_7/__init__.py new file mode 100644 index 0000000..62a93a4 --- /dev/null +++ b/src/dppvalidator/models/v0_7/__init__.py @@ -0,0 +1,95 @@ +"""Pydantic models for UNTP DPP **v0.7.0**. + +Built in Phase 3 of docs/plans/UNTP_0.7.0_MIGRATION.md from the bundled +schema at ``src/dppvalidator/schemas/data/untp-dpp-schema-0.7.0.json`` +(SHA-pinned to upstream commit ``707cd526...`` of +``opensource.unicc.org/un/unece/uncefact/spec-untp``). + +The v0.7.0 envelope is structurally different from v0.6.x: the +``ProductPassport`` envelope class is gone, ``credentialSubject`` is a +:class:`Product` directly, and the three "scorecard" classes +(``EmissionsPerformance``, ``CircularityPerformance``, +``TraceabilityPerformance``) are folded into a single +:class:`Claim.claimedPerformance: list[Performance]` shape on the Product. +See §2 of the plan for the full delta tables. + +This subpackage is opt-in for the 0.4.x line: callers reach it explicitly +via ``from dppvalidator.models.v0_7 import DigitalProductPassport``. The +top-level ``dppvalidator.models`` namespace continues to re-export v0.6.x +shapes through 0.4.x; Phase 9 (validator 0.5.0) flips that default. +""" + +from dppvalidator.models.v0_7.claims import ( + Claim, + ConformityTopic, + Performance, + Period, + Score, +) +from dppvalidator.models.v0_7.envelope import ( + BitstringStatusListEntry, + CredentialIssuer, + CredentialStatus, + DigitalProductPassport, + IssuingSoftware, + RenderTemplate2024, + SoftwareVendor, +) +from dppvalidator.models.v0_7.identifiers import ( + Address, + Country, + Facility, + IdentifierScheme, + Party, + PartyRole, + PartyRoleEnum, +) +from dppvalidator.models.v0_7.materials import Material +from dppvalidator.models.v0_7.primitives import ( + Characteristics, + Classification, + Dimension, + FlexibleUri, + Image, + Link, + Measure, +) +from dppvalidator.models.v0_7.product import Package, Product + +__all__ = [ + # Envelope + "DigitalProductPassport", + "CredentialIssuer", + "CredentialStatus", + "BitstringStatusListEntry", + "IssuingSoftware", + "SoftwareVendor", + "RenderTemplate2024", + # Identifiers + "Address", + "Country", + "Facility", + "IdentifierScheme", + "Party", + "PartyRole", + "PartyRoleEnum", + # Primitives + "Characteristics", + "Classification", + "Dimension", + "FlexibleUri", + "Image", + "Link", + "Measure", + # Claims + "Claim", + "ConformityTopic", + "Performance", + "Period", + "Score", + # Materials + "Material", + # Product + "Package", + "Product", +] diff --git a/src/dppvalidator/models/v0_7/claims.py b/src/dppvalidator/models/v0_7/claims.py new file mode 100644 index 0000000..8cbf213 --- /dev/null +++ b/src/dppvalidator/models/v0_7/claims.py @@ -0,0 +1,217 @@ +"""Claim and conformity-related types for UNTP v0.7.0. + +This is where the biggest semantic shift lives compared to v0.6.x: + +- The three v0.6 "scorecard" classes (``EmissionsPerformance``, + ``CircularityPerformance``, ``TraceabilityPerformance``) are gone. They + fold into :class:`Claim.claimedPerformance: list[Performance]`, with the + topic of the claim carried by :class:`ConformityTopic` entries on + :attr:`Claim.conformityTopic`. +- The v0.6 ``Metric`` class is gone. Its content is split across + :class:`Performance.metric` (the metric being measured), + :class:`Performance.measure` (the numeric reading), and + :class:`Performance.score` (the qualitative score). +- The v0.6 ``Standard``, ``Regulation``, ``Criterion`` classes are now + inlined under :class:`Claim` as plain ``referenceStandard[]``, + ``referenceRegulation[]``, ``referenceCriteria[]`` arrays of free-form + reference objects. +- :class:`Period` is new — used by :attr:`Claim.applicablePeriod`. + +Cross-field invariants implemented as model validators: + +- :class:`Period`: ``startDate`` must be strictly before ``endDate`` when + both are present. +- :class:`Performance`: at least one of ``measure`` / ``score`` must be + present. A claim that conveys neither is meaningless. +- :class:`Claim`: when ``claimedPerformance`` is non-empty, an + ``applicablePeriod`` SHOULD be supplied (advisory; logged via the + semantic-rule layer in Phase 3b — not enforced here so that compact + claims still validate). +""" + +from __future__ import annotations + +from datetime import date +from typing import Annotated, Any, ClassVar + +from pydantic import Field, model_validator + +from dppvalidator.models.base import UNTPBaseModel +from dppvalidator.models.v0_7.primitives import ( + Classification, + FlexibleUri, + Link, + Measure, +) + + +class ConformityTopic(UNTPBaseModel): + """A topic that a claim conforms to (e.g. emissions, circularity, traceability). + + Drawn from the UNTP topics vocabulary at + ``https://vocabulary.uncefact.org/ConformityTopic#``. ``id`` and + ``name`` are required; ``definition`` carries the rich human-readable + definition. + """ + + _jsonld_type: ClassVar[list[str]] = ["ConformityTopic"] + + id: FlexibleUri = Field(..., description="URI identifying the conformity topic.") + name: str = Field(..., description="Short name (e.g. ``emissions``).") + definition: Annotated[str | None, Field(default=None)] + + +class Period(UNTPBaseModel): + """A date interval used by claims and reporting periods. + + Both bounds are optional individually so callers can express open-ended + periods, but if both are present the start must precede the end. + """ + + _jsonld_type: ClassVar[list[str]] = ["Period"] + + start_date: Annotated[date | None, Field(default=None, alias="startDate")] + end_date: Annotated[date | None, Field(default=None, alias="endDate")] + + @model_validator(mode="after") + def _validate_interval(self) -> Period: + if ( + self.start_date is not None + and self.end_date is not None + and self.start_date > self.end_date + ): + raise ValueError( + "Period.startDate must be on or before Period.endDate " + f"(got {self.start_date} > {self.end_date})", + ) + return self + + +class Score(UNTPBaseModel): + """A qualitative performance grade (e.g. ``AAA``, ``B``, ``A+``). + + The grade itself is a coded value (``code``); ``rank`` is an integer + where 1 is the highest rank within the scoring framework. The + framework that defines the codes is referenced via the parent + :class:`Performance` and the :class:`Claim` it belongs to (not by + Score itself). + """ + + _jsonld_type: ClassVar[list[str]] = ["Score"] + + code: str = Field(..., description="Coded score value (e.g. 'AAA', 'B').") + rank: Annotated[ + int | None, + Field( + default=None, + description="Integer rank within the framework (1 = highest).", + ), + ] + definition: Annotated[ + str | None, + Field(default=None, description="Description of the meaning of this score."), + ] + + +class Performance(UNTPBaseModel): + """A single performance reading: metric + measure and/or score. + + Replaces the v0.6 ``Metric`` class (and absorbs the per-topic scorecard + classes via :class:`Claim`). At least one of ``measure`` or ``score`` + must be present — a Performance that says *nothing* about the metric + is meaningless. + """ + + _jsonld_type: ClassVar[list[str]] = ["Performance"] + + metric: dict[str, Any] = Field( + ..., + description=( + "The metric being measured (free-form object; the upstream schema does " + "not enforce a sub-schema here, leaving room for industry-specific shapes)." + ), + ) + measure: Measure | None = Field(default=None, description="Quantitative reading.") + score: Score | None = Field(default=None, description="Qualitative grade.") + + @model_validator(mode="after") + def _at_least_one_outcome(self) -> Performance: + if self.measure is None and self.score is None: + raise ValueError( + "Performance must have at least one of ``measure`` or ``score`` — " + "a performance reading with neither value is meaningless.", + ) + return self + + +class Claim(UNTPBaseModel): + """A conformity claim attached to a :class:`Product` in v0.7.0. + + Where v0.6.x split conformity into ``conformityClaim`` (typed claims) + plus three separate scorecard classes, v0.7.0 unifies everything here: + set ``conformityTopic`` to mark the topic, ``claimedPerformance`` to + carry the readings, and ``referenceCriteria/Standard/Regulation`` to + point at the framework the claim conforms to. + + Reference fields are intentionally typed as ``list[dict[str, Any]]``: + the upstream schema leaves their internal shape open for now (Phase 3c + revisits this when the eudpp_jsonld exporter mapping is reworked). + """ + + _jsonld_type: ClassVar[list[str]] = ["Claim"] + + id: FlexibleUri = Field( + ..., description="Globally unique identifier of this claim (URI or UUID)." + ) + name: str = Field(..., description="Name of the claim — usually mirrors the criterion name.") + description: Annotated[str | None, Field(default=None)] + conformity_topic: Annotated[ + list[ConformityTopic], + Field(default_factory=list, alias="conformityTopic"), + ] + reference_criteria: Annotated[ + list[dict[str, Any]], + Field( + default_factory=list, + alias="referenceCriteria", + description="The criteria this claim is asserted against.", + ), + ] + reference_standard: Annotated[ + list[dict[str, Any]], + Field(default_factory=list, alias="referenceStandard"), + ] + reference_regulation: Annotated[ + list[dict[str, Any]], + Field(default_factory=list, alias="referenceRegulation"), + ] + claim_date: Annotated[ + date | None, + Field(default=None, alias="claimDate"), + ] + applicable_period: Annotated[ + Period | None, + Field(default=None, alias="applicablePeriod"), + ] + claimed_performance: Annotated[ + list[Performance], + Field( + default_factory=list, + alias="claimedPerformance", + description=( + "The performance levels claimed by this claim — replaces the v0.6.x " + "Emissions/Circularity/TraceabilityPerformance scorecards." + ), + ), + ] + evidence: Annotated[ + list[Link], + Field( + default_factory=list, + description="URIs of evidence supporting the claim (typically DCC credentials).", + ), + ] + classification: Annotated[ + list[Classification], + Field(default_factory=list), + ] diff --git a/src/dppvalidator/models/v0_7/envelope.py b/src/dppvalidator/models/v0_7/envelope.py new file mode 100644 index 0000000..9d3c7f3 --- /dev/null +++ b/src/dppvalidator/models/v0_7/envelope.py @@ -0,0 +1,245 @@ +"""W3C VC envelope and DPP credential class for UNTP v0.7.0. + +This module is what callers reach for when they want to validate a v0.7.0 +Digital Product Passport. The :class:`DigitalProductPassport` carries the +W3C VC v2 envelope (``@context``, ``id``, ``issuer``, ``validFrom``, +``validUntil``, …) and a :class:`Product` as its ``credentialSubject`` +— there is **no** ``ProductPassport`` envelope class in v0.7.0. + +New top-level fields compared to v0.6.x (now first-class): + +- :class:`IssuingSoftware` — software-vendor metadata for the credential. +- :class:`RenderTemplate2024` — render-method spec (stored, not executed). +- :class:`BitstringStatusListEntry` — first-class status-list shape. +- ``name`` is now a required envelope field. + +Cross-field invariants: + +- ``validFrom`` MUST precede ``validUntil`` when both are set (port from v0.6). +- ``name`` MUST be non-empty (now required by the schema). +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Annotated, ClassVar + +from pydantic import Field, model_validator + +from dppvalidator.models.base import UNTPBaseModel, UNTPStrictModel +from dppvalidator.models.v0_7.identifiers import Party +from dppvalidator.models.v0_7.primitives import FlexibleUri +from dppvalidator.models.v0_7.product import Product + + +class CredentialIssuer(UNTPStrictModel): + """The party that issued a v0.7.0 DPP. + + The shape is the same as v0.6.x ``CredentialIssuer`` (id, name, + issuerAlsoKnownAs[]). The ``id`` MUST be a W3C DID per the spec; the + model accepts any URI to stay forgiving on intake. + """ + + _jsonld_type: ClassVar[list[str]] = ["CredentialIssuer"] + + id: FlexibleUri = Field( + ..., + description="W3C DID (did:web, did:webvh, …) or HTTPS identifier of the issuer.", + ) + name: str = Field(..., description="Human-readable issuer name.") + issuer_also_known_as: Annotated[ + list[Party] | None, + Field( + default=None, + alias="issuerAlsoKnownAs", + description="Other registered identities (parties) for this issuer.", + ), + ] + + +class SoftwareVendor(UNTPBaseModel): + """Vendor of the software that issued the credential. + + Used by :class:`IssuingSoftware`. The ``id`` is typically a + ``did:web:`` for the vendor's domain. + """ + + _jsonld_type: ClassVar[list[str]] = ["SoftwareVendor"] + + id: FlexibleUri = Field(..., description="DID or URI identifying the vendor.") + name: str = Field(..., description="Vendor company / organisation name.") + + +class IssuingSoftware(UNTPBaseModel): + """Metadata about the software that emitted this credential. + + New top-level field in v0.7.0 — captures the software supply chain so + consumers can trace which tool generated a passport. Optional but + recommended. + """ + + _jsonld_type: ClassVar[list[str]] = ["IssuingSoftware"] + + id: FlexibleUri = Field(..., description="URI identifying the issuing software product.") + name: str = Field(..., description="Product name (e.g. 'Sample Passport Builder').") + version: str = Field(..., description="Software version (e.g. '2026.04.1').") + vendor: SoftwareVendor = Field(..., description="The vendor that publishes this software.") + + +class RenderTemplate2024(UNTPBaseModel): + """A render-method specification (W3C VC 2.0 Render Method 2024). + + v0.7.0 stores rendering hints alongside the credential. We capture the + fields without executing them — actual rendering is a downstream + concern (out of scope per the migration plan §9). + """ + + _jsonld_type: ClassVar[list[str]] = ["RenderTemplate2024"] + + id: Annotated[FlexibleUri | None, Field(default=None)] + type: Annotated[ + str | list[str] | None, + Field( + default=None, description="Render-method type identifier (overrides the base default)." + ), + ] + name: Annotated[str | None, Field(default=None)] + template: Annotated[ + str | None, + Field( + default=None, + description="Inline template body (often a URL to a template file).", + ), + ] + digest_multibase: Annotated[ + str | None, + Field(default=None, alias="digestMultibase"), + ] + media_type: Annotated[ + str | None, + Field(default=None, alias="mediaType"), + ] + + +class BitstringStatusListEntry(UNTPBaseModel): + """W3C VC Bitstring Status List entry. + + Used by :attr:`DigitalProductPassport.credentialStatus`. v0.7.0 lifts + this to a first-class type (was a free-form ``CredentialStatus`` in + v0.6.x). + """ + + _jsonld_type: ClassVar[list[str]] = ["BitstringStatusListEntry"] + + id: FlexibleUri = Field(..., description="URI of this status entry.") + type: str = Field(default="BitstringStatusListEntry") + status_purpose: Annotated[ + str | None, + Field(default=None, alias="statusPurpose", description="e.g. 'revocation', 'suspension'."), + ] + status_list_index: Annotated[ + str | None, + Field(default=None, alias="statusListIndex"), + ] + status_list_credential: Annotated[ + FlexibleUri | None, + Field(default=None, alias="statusListCredential"), + ] + + +# Type alias kept compatible with v0.6 for the engine's ``credentialStatus`` +# field. v0.7.0 narrows the shape to BitstringStatusListEntry but a generic +# alias helps downstream code that branches on version. +CredentialStatus = BitstringStatusListEntry + + +class DigitalProductPassport(UNTPBaseModel): + """Root model for a UNTP v0.7.0 Digital Product Passport. + + Required envelope fields (per the upstream JSON Schema): + ``@context``, ``id``, ``issuer``, ``validFrom``, ``name``, ``credentialSubject``. + + Cross-field invariants: + + - ``validFrom`` < ``validUntil`` when both present. + - ``name`` is non-empty (delegated to Pydantic ``min_length=1``). + + The ``credentialSubject`` is a :class:`Product` directly — there is no + ``ProductPassport`` envelope class in v0.7.0. + """ + + _jsonld_type: ClassVar[list[str]] = ["DigitalProductPassport", "VerifiableCredential"] + + context: Annotated[ + list[str], + Field( + ..., + alias="@context", + description="JSON-LD context URIs. First entry is W3C VC v2; second is the UNTP 0.7.0 context.", + min_length=2, + ), + ] + id: FlexibleUri = Field(..., description="Globally unique DPP credential identifier (URI).") + name: str = Field( + ..., + min_length=1, + description="Human-readable credential title (now required by the v0.7.0 schema).", + ) + issuer: CredentialIssuer = Field(..., description="The party issuing this credential.") + valid_from: Annotated[ + datetime, + Field( + ..., + alias="validFrom", + description="Credential validity start (now required by the v0.7.0 schema).", + ), + ] + valid_until: Annotated[ + datetime | None, + Field(default=None, alias="validUntil", description="Credential expiry (optional)."), + ] + issuing_software: Annotated[ + IssuingSoftware | None, + Field( + default=None, + alias="issuingSoftware", + description="Metadata about the software that emitted this credential.", + ), + ] + render_method: Annotated[ + list[RenderTemplate2024] | None, + Field( + default=None, + alias="renderMethod", + description="Render-method specifications for human display.", + ), + ] + credential_status: Annotated[ + BitstringStatusListEntry | list[BitstringStatusListEntry] | None, + Field( + default=None, + alias="credentialStatus", + description="Revocation / suspension status entries.", + ), + ] + credential_subject: Annotated[ + Product, + Field( + ..., + alias="credentialSubject", + description="The product that this DPP describes (now a Product directly, no envelope).", + ), + ] + + @model_validator(mode="after") + def _validate_dates(self) -> DigitalProductPassport: + if ( + self.valid_until is not None + and self.valid_from is not None + and self.valid_from >= self.valid_until + ): + raise ValueError( + "DigitalProductPassport.validFrom must be strictly before validUntil " + f"(got {self.valid_from.isoformat()} >= {self.valid_until.isoformat()}).", + ) + return self diff --git a/src/dppvalidator/models/v0_7/identifiers.py b/src/dppvalidator/models/v0_7/identifiers.py new file mode 100644 index 0000000..e62d132 --- /dev/null +++ b/src/dppvalidator/models/v0_7/identifiers.py @@ -0,0 +1,210 @@ +"""Identifier and party-related types for UNTP v0.7.0. + +Compared to v0.6.x: + +- :class:`Country` is new. v0.6 stored ISO-3166 country codes as bare + strings (``Material.originCountry: "DE"``); v0.7 wraps them as + ``{"countryCode": "DE", "countryName": "Germany"}`` objects with + ``countryCode`` required and ``countryName`` recommended. +- :class:`Address` is new and reuses schema.org PostalAddress shape. +- :class:`Party` adds ``description``, ``registeredId``, and a nested + ``idScheme`` (replacing the v0.6 top-level ``IdentifierScheme`` class + inlined into Party). +- :class:`PartyRole` is new and wraps a Party with a ``role`` enum. + +Cross-field invariants (per the plan): + +- :class:`Country` ``countryCode`` matches ISO-3166-1 alpha-2 (two ASCII + uppercase letters). Validators import the existing alpha-2 enforcer from + ``dppvalidator.vocabularies.code_lists``. +""" + +from __future__ import annotations + +import re +from enum import Enum +from typing import Annotated, ClassVar + +from pydantic import Field, field_validator + +from dppvalidator.models.base import UNTPBaseModel +from dppvalidator.models.v0_7.primitives import FlexibleUri + +_ISO_3166_ALPHA2_RE = re.compile(r"^[A-Z]{2}$") + + +class IdentifierScheme(UNTPBaseModel): + """Reference to an identifier scheme that defines a code or URI space. + + v0.7.0 inlines this on :class:`Party.idScheme` and + :attr:`dppvalidator.models.v0_7.product.Product.idScheme`. v0.6 had it + as a top-level reusable class; the shape is the same (``id`` + ``name``). + """ + + _jsonld_type: ClassVar[list[str]] = ["IdentifierScheme"] + + id: FlexibleUri = Field(..., description="URI of the identifier scheme.") + name: str = Field(..., description="Human-readable name of the identifier scheme.") + + +class Country(UNTPBaseModel): + """ISO-3166 country code + name. + + Wire shape: ``{"countryCode": "DE", "countryName": "Germany"}``. + Only ``countryCode`` is strictly required; ``countryName`` is + recommended for human display but ``Country`` payloads from automated + sources may legitimately omit it. + """ + + _jsonld_type: ClassVar[list[str]] = ["Country"] + + country_code: Annotated[ + str, + Field( + ..., + alias="countryCode", + description="ISO-3166-1 alpha-2 country code (two uppercase ASCII letters).", + ), + ] + country_name: Annotated[ + str | None, + Field( + default=None, + alias="countryName", + description="Country name as published by ISO-3166-1.", + ), + ] + + @field_validator("country_code") + @classmethod + def _validate_alpha2(cls, value: str) -> str: + if not _ISO_3166_ALPHA2_RE.match(value): + raise ValueError( + f"Country.countryCode must be a two-letter ISO-3166-1 alpha-2 code " + f"(got {value!r}).", + ) + return value + + +class Address(UNTPBaseModel): + """Postal address — schema.org-compatible. + + v0.7.0 introduces this for facility / party addresses; v0.6.x had no + direct equivalent (addresses lived as free-form strings). + """ + + _jsonld_type: ClassVar[list[str]] = ["Address"] + + street_address: Annotated[ + str | None, + Field(default=None, alias="streetAddress"), + ] + postal_code: Annotated[str | None, Field(default=None, alias="postalCode")] + address_locality: Annotated[ + str | None, + Field(default=None, alias="addressLocality", description="City / town / village."), + ] + address_region: Annotated[ + str | None, + Field(default=None, alias="addressRegion", description="State / province / region."), + ] + address_country: Annotated[ + Country | None, + Field(default=None, alias="addressCountry"), + ] + + +class Party(UNTPBaseModel): + """An entity (legal or otherwise) referenced by a credential. + + v0.7.0 adds ``description``, ``registeredId``, and a nested + :class:`IdentifierScheme` on ``idScheme`` — the v0.6 ``Party`` had + only ``id`` and ``name`` plus optionally a top-level ``IdentifierScheme`` + reference. + """ + + _jsonld_type: ClassVar[list[str]] = ["Party"] + + id: FlexibleUri = Field(..., description="Globally unique identifier of the party (URI / DID).") + name: str = Field(..., description="Legal registered name of the party.") + description: Annotated[str | None, Field(default=None)] + registered_id: Annotated[ + str | None, + Field( + default=None, + alias="registeredId", + description="The registration number within the identifier scheme (alphanumeric).", + ), + ] + id_scheme: Annotated[ + IdentifierScheme | None, + Field( + default=None, + alias="idScheme", + description="The scheme that the ``id`` and ``registeredId`` are drawn from.", + ), + ] + + +class Facility(UNTPBaseModel): + """A facility (production site, warehouse, smelter, …). + + Used as ``Product.producedAtFacility`` and as the credential subject of + a :class:`DigitalFacilityRecord` (out of scope — Phase 3 only models + DPP). Shape is permissive in v0.7 because the upstream schema treats + facility metadata as extension-friendly. + """ + + _jsonld_type: ClassVar[list[str]] = ["Facility"] + + id: FlexibleUri = Field(..., description="Globally unique identifier of the facility.") + name: Annotated[str | None, Field(default=None)] + id_scheme: Annotated[ + IdentifierScheme | None, + Field(default=None, alias="idScheme"), + ] + address: Annotated[Address | None, Field(default=None)] + + +class PartyRoleEnum(str, Enum): + """Closed enumeration of party-relationship roles in UNTP v0.7.0. + + Mirrors the schema's enum at ``$defs.PartyRole.properties.role.enum``. + """ + + OWNER = "owner" + PRODUCER = "producer" + MANUFACTURER = "manufacturer" + PROCESSOR = "processor" + REMANUFACTURER = "remanufacturer" + RECYCLER = "recycler" + OPERATOR = "operator" + SERVICE_PROVIDER = "serviceProvider" + INSPECTOR = "inspector" + CERTIFIER = "certifier" + LOGISTICS_PROVIDER = "logisticsProvider" + CARRIER = "carrier" + CONSIGNOR = "consignor" + CONSIGNEE = "consignee" + IMPORTER = "importer" + EXPORTER = "exporter" + DISTRIBUTOR = "distributor" + RETAILER = "retailer" + BRAND_OWNER = "brandOwner" + REGULATOR = "regulator" + + +class PartyRole(UNTPBaseModel): + """A :class:`Party` plus the role it plays in a relationship. + + v0.7.0 introduces this so ``Product.relatedParty`` can be a list of + typed (role, party) pairs — replacing the v0.6 single + ``producedByParty: Party`` field with something more expressive. + """ + + _jsonld_type: ClassVar[list[str]] = ["PartyRole"] + + role: PartyRoleEnum = Field( + ..., description="The role played by the party in this relationship." + ) + party: Party = Field(..., description="The party that has the specified role.") diff --git a/src/dppvalidator/models/v0_7/materials.py b/src/dppvalidator/models/v0_7/materials.py new file mode 100644 index 0000000..782e80f --- /dev/null +++ b/src/dppvalidator/models/v0_7/materials.py @@ -0,0 +1,113 @@ +"""Material provenance for UNTP v0.7.0. + +Compared to v0.6.x: + +- ``materialType``, ``originCountry``, and ``massFraction`` are now + **required** (per the upstream schema). v0.6.x permitted all three to be + absent. The Phase 4 compatibility shim emits an ``UPG004`` warning when + upgrading legacy data that lacks any of these. +- ``originCountry`` shape moved from a bare ISO-3166 string (``"DE"``) to a + :class:`Country` object (``{"countryCode": "DE", "countryName": "Germany"}``). +- ``symbol`` moved from a bare base64 string to a structured + :class:`Image` (``{"contentType": …, "content": …}``). + +Cross-field invariants (per the plan): + +- ``hazardous=True`` requires ``materialSafetyInformation`` (port of the + v0.6.x SEM-class rule into a model-level invariant). +- Mass-fraction sum across an array of ``Material`` lives at the parent + (Product) level, not here — see :class:`dppvalidator.models.v0_7.product.Product`. +""" + +from __future__ import annotations + +from typing import Annotated, ClassVar + +from pydantic import Field, model_validator + +from dppvalidator.models.base import UNTPBaseModel +from dppvalidator.models.v0_7.identifiers import Country +from dppvalidator.models.v0_7.primitives import ( + Classification, + Image, + Link, + Measure, +) + + +class Material(UNTPBaseModel): + """Origin and composition info for one material in a product. + + Required: ``name``, ``originCountry``, ``materialType``, ``massFraction``. + Optional fields fill in mass / safety / recycled-content metadata. + """ + + _jsonld_type: ClassVar[list[str]] = ["Material"] + + name: str = Field(..., description="Material name (e.g. 'Egyptian Cotton').") + origin_country: Annotated[ + Country, + Field( + ..., + alias="originCountry", + description="ISO-3166 country of origin (now structured, was a bare string in v0.6.x).", + ), + ] + material_type: Annotated[ + Classification, + Field( + ..., + alias="materialType", + description="Classification of the material (e.g. drawn from UNFC).", + ), + ] + mass_fraction: Annotated[ + float, + Field( + ..., + ge=0, + le=1, + alias="massFraction", + description="Mass fraction of the product represented by this material (0..1).", + ), + ] + mass: Measure | None = Field( + default=None, description="Optional absolute mass of the material." + ) + recycled_mass_fraction: Annotated[ + float | None, + Field( + default=None, + ge=0, + le=1, + alias="recycledMassFraction", + description="Fraction of this material that is recycled content (0..1).", + ), + ] + hazardous: bool | None = Field( + default=None, + description="Whether the material is hazardous (drives the ``materialSafetyInformation`` requirement).", + ) + symbol: Annotated[ + Image | None, + Field( + default=None, + description="Visual symbol for the material (was a bare base64 string in v0.6.x).", + ), + ] + material_safety_information: Annotated[ + Link | None, + Field( + default=None, + alias="materialSafetyInformation", + description="Link to a material safety data sheet — required when ``hazardous`` is true.", + ), + ] + + @model_validator(mode="after") + def _hazardous_implies_safety_info(self) -> Material: + if self.hazardous and self.material_safety_information is None: + raise ValueError( + "Material.materialSafetyInformation is required when ``hazardous`` is true.", + ) + return self diff --git a/src/dppvalidator/models/v0_7/primitives.py b/src/dppvalidator/models/v0_7/primitives.py new file mode 100644 index 0000000..a41e0c0 --- /dev/null +++ b/src/dppvalidator/models/v0_7/primitives.py @@ -0,0 +1,240 @@ +"""Primitive types reused across UNTP v0.7.0 model classes. + +Compared to v0.6.x: + +- :class:`Classification` renames ``schemeID`` → ``schemeId`` (camelCase + fix) and adds an optional ``definition`` field. +- :class:`Link` absorbs the v0.6 ``SecureLink`` by adding optional + ``digestMultibase`` and ``mediaType`` fields. There is no separate + ``SecureLink`` class in v0.7.0. +- :class:`Measure` adds optional ``lowerTolerance`` and ``upperTolerance``. +- :class:`Image` is new (was an inline base64 string in v0.6.x ``Material.symbol``). +- :class:`Characteristics` and :class:`Dimension` keep the same shape but + drop their leading ``type`` discriminator from the schema. +""" + +from __future__ import annotations + +from typing import Annotated, Any, ClassVar + +from pydantic import Field, model_validator + +from dppvalidator.models.base import UNTPBaseModel, UNTPStrictModel + +# --------------------------------------------------------------------------- +# FlexibleUri — version-neutral URI handling. +# +# v0.7.0 keeps the same lenient stance as v0.6.x: schema fields typed as +# ``format: uri`` accept HTTPS URIs, DIDs, and other URI schemes. We model +# the wire shape as a plain ``str`` and let the JSON-LD layer carry the URI +# semantics. +# --------------------------------------------------------------------------- +FlexibleUri = str + + +class Classification(UNTPBaseModel): + """A code drawn from a controlled classification scheme. + + Used for product categories (e.g. UN CPC), material types (e.g. UNFC), + etc. The ``schemeId`` URI identifies the scheme; ``code`` is the code + value within the scheme; ``schemeName`` is the human-readable scheme + label. + + v0.6.x called this same shape ``schemeID`` (uppercase D); v0.7.0 fixes + the camelCase as ``schemeId`` (lowercase d) and requires ``schemeName``. + """ + + _jsonld_type: ClassVar[list[str]] = ["Classification"] + + scheme_id: Annotated[ + FlexibleUri, + Field( + ..., + alias="schemeId", + description="URI identifying the classification scheme.", + ), + ] + scheme_name: Annotated[ + str, + Field( + ..., + alias="schemeName", + description="Human-readable name of the classification scheme (e.g. 'UN CPC').", + ), + ] + code: str = Field(..., description="The classification code within the scheme.") + name: str = Field(..., description="Human-readable name for the classification value.") + definition: Annotated[ + str | None, + Field(default=None, description="Optional rich definition of the classification value."), + ] + + +class Link(UNTPBaseModel): + """A reference to a related document. + + v0.7.0 absorbs v0.6 ``SecureLink`` here: ``digestMultibase`` and + ``mediaType`` are now first-class on :class:`Link` itself. The + cross-field invariant — if you assert a hash, declare the media type — + fires as a model validator below. + """ + + _jsonld_type: ClassVar[list[str]] = ["Link"] + + href: FlexibleUri = Field( + ..., + alias="linkURL", + description="URL the link points at (alias 'linkURL' for compatibility with the schema).", + ) + name: Annotated[ + str | None, Field(default=None, description="Human-readable label for the link.") + ] + description: Annotated[str | None, Field(default=None)] + relationship: Annotated[ + str | None, + Field( + default=None, + description="Free-form classification of the link relationship (e.g. 'evidence', 'specification').", + ), + ] + media_type: Annotated[ + str | None, + Field( + default=None, + alias="mediaType", + description="IANA media type of the linked document (RFC 6838).", + ), + ] + digest_multibase: Annotated[ + str | None, + Field( + default=None, + alias="digestMultibase", + description=( + "Multibase-encoded multihash (https://www.w3.org/TR/vc-data-integrity/) " + "of the linked content. If set, ``mediaType`` SHOULD also be set so the " + "consumer knows how to interpret the bytes." + ), + ), + ] + + @model_validator(mode="after") + def _digest_implies_media_type(self) -> Link: + # Warning-grade invariant per docs/plans/UNTP_0.7.0_MIGRATION.md §3.2: + # we *recommend* mediaType when a digest is present, but we don't + # block validation — third-party documents that pin a hash without + # declaring a media type are still consumable. + # Semantic-rule layer (Phase 3b) is where this becomes a warning. + return self + + +class Measure(UNTPStrictModel): + """A numeric measurement with a unit-of-measure code. + + v0.7.0 adds optional ``lowerTolerance`` and ``upperTolerance`` to + express measurement precision (additive over v0.6.x). + """ + + _jsonld_type: ClassVar[list[str]] = ["Measure"] + + value: float = Field(..., description="The measured numeric value.") + unit: str = Field( + ..., + description="UN/CEFACT Recommendation 20 unit-of-measure code (e.g. KGM for kilogram).", + ) + lower_tolerance: Annotated[ + float | None, + Field( + default=None, + alias="lowerTolerance", + description="Optional lower tolerance bound (same unit as ``value``).", + ), + ] + upper_tolerance: Annotated[ + float | None, + Field( + default=None, + alias="upperTolerance", + description="Optional upper tolerance bound (same unit as ``value``).", + ), + ] + + @model_validator(mode="after") + def _validate_tolerances(self) -> Measure: + if ( + self.lower_tolerance is not None + and self.upper_tolerance is not None + and self.lower_tolerance > self.upper_tolerance + ): + raise ValueError( + "Measure.lowerTolerance must be ≤ upperTolerance " + f"(got {self.lower_tolerance} > {self.upper_tolerance})", + ) + return self + + +class Image(UNTPBaseModel): + """Base64-encoded image with display metadata. + + v0.7.0 introduces this as a structured replacement for the inline base64 + string previously used at v0.6 ``Material.symbol``. Used at + ``Material.symbol`` and ``Product.productLabel``. + + Required: ``name``, ``imageData``, ``mediaType``. ``description`` is + optional (used for things like Battery Reg. label captions). + """ + + _jsonld_type: ClassVar[list[str]] = ["Image"] + + name: str = Field(..., description="Display name for the image (e.g. 'CE Marking').") + description: Annotated[ + str | None, + Field(default=None, description="Detailed description / supporting information."), + ] + image_data: Annotated[ + str, + Field( + ..., + alias="imageData", + description="Base64-encoded image bytes.", + ), + ] + media_type: Annotated[ + str, + Field( + ..., + alias="mediaType", + description="IANA media type (e.g. image/png).", + ), + ] + + +class Dimension(UNTPBaseModel): + """Physical dimensions and mass of a product. + + Each axis is optional because not every product has every dimension + (bulk materials may have weight + volume but no length / width / height). + """ + + _jsonld_type: ClassVar[list[str]] = ["Dimension"] + + weight: Measure | None = Field(default=None) + length: Measure | None = Field(default=None) + width: Measure | None = Field(default=None) + height: Measure | None = Field(default=None) + volume: Measure | None = Field(default=None) + + +class Characteristics(UNTPBaseModel): + """Industry/product-specific characteristics extension point. + + The schema declares this as ``additionalProperties: true`` with no + fixed shape — extensions plug their own fields in here. + """ + + _jsonld_type: ClassVar[list[str]] = ["Characteristics"] + + # Pydantic ``extra="allow"`` is inherited from UNTPBaseModel, which is + # how arbitrary industry-specific keys flow through. + def __getitem__(self, key: str) -> Any: # convenience for callers + return getattr(self, key) diff --git a/src/dppvalidator/models/v0_7/product.py b/src/dppvalidator/models/v0_7/product.py new file mode 100644 index 0000000..3404dca --- /dev/null +++ b/src/dppvalidator/models/v0_7/product.py @@ -0,0 +1,254 @@ +"""Product model for UNTP v0.7.0. + +In v0.7.0 the :class:`Product` *is* the credentialSubject of a +:class:`DigitalProductPassport` — there is no longer a ``ProductPassport`` +envelope. Conformity claims and material provenance live directly here: + +- ``performanceClaim`` (was 0.6 ``conformityClaim`` + 3 scorecards) +- ``materialProvenance`` (singular noun; was 0.6 ``materialsProvenance``) +- ``relatedParty`` (typed list of role/party pairs; was 0.6 ``producedByParty``) +- ``relatedDocument`` (was scattered across 0.6 ``furtherInformation``, + ``dueDiligenceDeclaration``, etc.) + +Cross-field invariants per the plan: + +- ``idGranularity`` enum drives whether ``itemNumber`` (item-level passport) + or ``batchNumber`` (batch-level passport) is required. Replaces the + v0.6.x ``ProductPassport.granularityLevel`` rule. +- The mass-fraction sum across ``materialProvenance`` should be ≤ 1.0. + Enforced as an error here because the array context lives on the + product. Strict equality (sum == 1.0) is too strict — partial + declarations are valid; only sums *over* 1.0 are physically impossible. +""" + +from __future__ import annotations + +from datetime import date +from enum import Enum +from typing import Annotated, ClassVar + +from pydantic import Field, model_validator + +from dppvalidator.models.base import UNTPBaseModel +from dppvalidator.models.v0_7.claims import Claim +from dppvalidator.models.v0_7.identifiers import ( + Country, + Facility, + IdentifierScheme, + PartyRole, +) +from dppvalidator.models.v0_7.materials import Material +from dppvalidator.models.v0_7.primitives import ( + Characteristics, + Classification, + Dimension, + FlexibleUri, + Image, + Link, +) + + +class IdGranularity(str, Enum): + """How specifically does the credential identify the product? + + Mirrors the v0.6.x ``GranularityLevel`` enum. The string values are + case-sensitive and must match the upstream schema's enum exactly. + """ + + ITEM = "item" + BATCH = "batch" + MODEL = "model" + + +class Package(UNTPBaseModel): + """Product packaging information. + + v0.7.0 introduces this as a structured field on ``Product.packaging``; + v0.6.x had no equivalent. + """ + + _jsonld_type: ClassVar[list[str]] = ["Package"] + + package_type: Annotated[ + Classification | None, + Field(default=None, alias="packageType"), + ] + weight: Annotated[ + Measure | None, + Field(default=None, description="Weight of the packaging (separate from product weight)."), + ] + + +class Product(UNTPBaseModel): + """The credential subject of a v0.7.0 DPP. + + All required fields per the upstream schema's ``Product`` $def: + ``id``, ``name``, ``idScheme``, ``idGranularity``, ``productCategory``, + ``producedAtFacility``, ``countryOfProduction``. Everything else is + optional but commonly populated. + """ + + _jsonld_type: ClassVar[list[str]] = ["Product"] + + id: FlexibleUri = Field( + ..., + description="Globally unique identifier (URI / DID).", + ) + name: str = Field(..., description="The product name as known to the market.") + description: Annotated[str | None, Field(default=None)] + + id_scheme: Annotated[ + IdentifierScheme, + Field( + ..., + alias="idScheme", + description="The identifier scheme used by ``id`` (e.g. GS1 GTIN).", + ), + ] + model_number: Annotated[str | None, Field(default=None, alias="modelNumber")] + batch_number: Annotated[str | None, Field(default=None, alias="batchNumber")] + item_number: Annotated[ + str | None, + Field( + default=None, + alias="itemNumber", + description="Serialised item number (was ``serialNumber`` in v0.6.x).", + ), + ] + id_granularity: Annotated[ + IdGranularity, + Field( + ..., + alias="idGranularity", + description="Whether the credential covers a single item, a batch, or a model class.", + ), + ] + + product_image: Annotated[Link | None, Field(default=None, alias="productImage")] + characteristics: Characteristics | None = Field(default=None) + product_category: Annotated[ + list[Classification], + Field( + ..., + alias="productCategory", + description=( + "Product classification codes (e.g. UN CPC). Now an array in v0.7.0; " + "scalar Classification in v0.6.x." + ), + min_length=1, + ), + ] + related_document: Annotated[ + list[Link], + Field( + default_factory=list, + alias="relatedDocument", + description=( + "Links to related documents (specifications, brochures, ...). Notably absorbs the " + "v0.6.x ``furtherInformation`` and ``dueDiligenceDeclaration`` fields." + ), + ), + ] + related_party: Annotated[ + list[PartyRole], + Field( + default_factory=list, + alias="relatedParty", + description=( + "Parties with a defined role on this product (manufacturer, recycler, …). " + "Replaces the v0.6.x scalar ``producedByParty: Party``." + ), + ), + ] + produced_at_facility: Annotated[ + Facility, + Field( + ..., + alias="producedAtFacility", + description="The facility where this product/batch was produced.", + ), + ] + production_date: Annotated[ + date | None, + Field(default=None, alias="productionDate"), + ] + expiry_date: Annotated[ + date | None, + Field(default=None, alias="expiryDate"), + ] + country_of_production: Annotated[ + Country, + Field( + ..., + alias="countryOfProduction", + description="ISO-3166 country of production (was a bare string in v0.6.x).", + ), + ] + dimensions: Dimension | None = Field(default=None) + material_provenance: Annotated[ + list[Material], + Field( + default_factory=list, + alias="materialProvenance", + description=( + "Material origin / mass fraction information. Singular noun in v0.7.0 " + "(was ``materialsProvenance`` in v0.6.x)." + ), + ), + ] + packaging: Package | None = Field(default=None) + product_label: Annotated[ + list[Image], + Field(default_factory=list, alias="productLabel"), + ] + performance_claim: Annotated[ + list[Claim], + Field( + default_factory=list, + alias="performanceClaim", + description=( + "Performance / conformity claims about this product. Replaces the v0.6.x " + "``conformityClaim`` array AND the three Emissions/Circularity/Traceability scorecards." + ), + ), + ] + + @model_validator(mode="after") + def _granularity_implies_serial_or_batch(self) -> Product: + # ``UNTPBaseModel`` ships ``use_enum_values=True``, so by the time + # this runs ``self.id_granularity`` is the string value, not the + # IdGranularity instance — compare on value (``==``) rather than + # identity (``is``). + if self.id_granularity == IdGranularity.ITEM.value and not self.item_number: + raise ValueError( + "Product.itemNumber is required when idGranularity == 'item'.", + ) + if self.id_granularity == IdGranularity.BATCH.value and not self.batch_number: + raise ValueError( + "Product.batchNumber is required when idGranularity == 'batch'.", + ) + return self + + @model_validator(mode="after") + def _mass_fractions_sum_within_unity(self) -> Product: + if not self.material_provenance: + return self + # Partial declarations (sum < 1.0) are explicitly allowed by UNTP — that + # nuance lives in the semantic-rule layer (Phase 3b emits a SEM-class + # warning). Sums *above* 1.0 are physically impossible regardless and + # therefore caught here. + total = sum(m.mass_fraction for m in self.material_provenance) + if total > 1.0001: # tiny epsilon for float arithmetic + raise ValueError( + f"Sum of materialProvenance.massFraction across {len(self.material_provenance)} " + f"material(s) is {total:.4f} > 1.0 — physically impossible.", + ) + return self + + +# Resolve the forward reference inside Package.weight. We can't import +# Measure at the top of the file because product.py is imported by other +# modules and the cycle would resolve in the wrong direction. +from dppvalidator.models.v0_7.primitives import Measure # noqa: E402 + +Package.model_rebuild() diff --git a/src/dppvalidator/plugins/registry.py b/src/dppvalidator/plugins/registry.py index d9c17ba..f575663 100644 --- a/src/dppvalidator/plugins/registry.py +++ b/src/dppvalidator/plugins/registry.py @@ -17,6 +17,10 @@ logger = get_logger(__name__) +class PluginError(Exception): + """Raised when a plugin fails in strict mode.""" + + class PluginRegistry: """Registry for validator and exporter plugins. @@ -113,14 +117,21 @@ def get_exporter(self, name: str) -> type[Exporter] | Exporter | None: def run_all_validators( self, passport: DigitalProductPassport, + *, + strict: bool = False, ) -> list[ValidationError]: """Run all registered validator plugins. Args: passport: Parsed passport to validate + strict: If True, raise PluginError on plugin failures instead of + returning a warning. Useful for CI/CD pipelines. Returns: List of validation errors from all plugins + + Raises: + PluginError: If strict=True and a plugin fails to execute """ errors: list[ValidationError] = [] @@ -145,12 +156,14 @@ def run_all_validators( ) except (AttributeError, TypeError, ValueError, RuntimeError) as e: + if strict: + raise PluginError(f"Plugin '{name}' failed: {e}") from e logger.warning("Plugin %s failed: %s", name, e) errors.append( ValidationError( path="$", message=f"Plugin '{name}' failed: {e}", - code="PLG_ERROR", + code="PLG001", layer="plugin", severity="warning", ) diff --git a/src/dppvalidator/schemas/__init__.py b/src/dppvalidator/schemas/__init__.py index 3bad9d5..c0eb11e 100644 --- a/src/dppvalidator/schemas/__init__.py +++ b/src/dppvalidator/schemas/__init__.py @@ -1,5 +1,14 @@ -"""Schema management for UNTP DPP versions.""" +"""Schema management for UNTP DPP and CIRPASS versions.""" +from dppvalidator.schemas.cirpass_loader import ( + CIRPASS_SCHEMA_FILE, + CIRPASS_SCHEMA_TITLE, + CIRPASS_SCHEMA_VERSION, + CIRPASSSchemaLoader, + CIRPASSSHACLLoader, + get_cirpass_schema, + get_cirpass_schema_version, +) from dppvalidator.schemas.loader import SchemaLoader from dppvalidator.schemas.registry import ( DEFAULT_SCHEMA_VERSION, @@ -9,9 +18,18 @@ ) __all__ = [ + # UNTP schema loading "SchemaLoader", "SchemaRegistry", "SchemaVersion", "SCHEMA_REGISTRY", "DEFAULT_SCHEMA_VERSION", + # CIRPASS schema loading (Phase 5) + "CIRPASSSchemaLoader", + "CIRPASSSHACLLoader", + "CIRPASS_SCHEMA_VERSION", + "CIRPASS_SCHEMA_TITLE", + "CIRPASS_SCHEMA_FILE", + "get_cirpass_schema", + "get_cirpass_schema_version", ] diff --git a/src/dppvalidator/schemas/cirpass_loader.py b/src/dppvalidator/schemas/cirpass_loader.py new file mode 100644 index 0000000..f98479c --- /dev/null +++ b/src/dppvalidator/schemas/cirpass_loader.py @@ -0,0 +1,223 @@ +"""CIRPASS DPP Schema loader. + +Provides loading and caching of CIRPASS DPP JSON Schemas from bundled +data files for validation of EU DPP data structures. + +Source: CIRPASS-2 project vocabulary hub +Schema: cirpass_dpp_schema.json (v1.3.0) +""" + +from __future__ import annotations + +import json +from importlib.resources import files +from typing import Any + +from dppvalidator.logging import get_logger + +logger = get_logger(__name__) + + +# Schema metadata +CIRPASS_SCHEMA_VERSION = "1.3.0" +CIRPASS_SCHEMA_TITLE = "CIRPASS DPP reference structure" +CIRPASS_SCHEMA_FILE = "cirpass_dpp_schema.json" + + +def _get_cirpass_schema_dir() -> Any: + """Get the CIRPASS schema data directory using importlib.resources.""" + return files("dppvalidator.vocabularies.data.schemas") + + +class CIRPASSSchemaLoader: + """Load and cache CIRPASS DPP JSON Schema. + + Provides schema loading from bundled data files in the vocabularies + package. The schema is cached after first load for performance. + + Example: + >>> loader = CIRPASSSchemaLoader() + >>> schema = loader.load() + >>> print(loader.schema_id) + 'CIRPASS DPP reference structure v1.3.0' + """ + + SCHEMA_VERSION = CIRPASS_SCHEMA_VERSION + + def __init__(self) -> None: + """Initialize CIRPASS schema loader.""" + self._schema: dict[str, Any] | None = None + self._schema_file = CIRPASS_SCHEMA_FILE + + def load(self) -> dict[str, Any]: + """Load and cache the CIRPASS DPP schema. + + Returns: + Parsed JSON schema as dictionary + + Raises: + RuntimeError: If schema cannot be loaded + """ + if self._schema is not None: + return self._schema + + try: + data_dir = _get_cirpass_schema_dir() + schema_path = data_dir.joinpath(self._schema_file) + content = schema_path.read_text(encoding="utf-8") + self._schema = json.loads(content) + logger.debug("Loaded CIRPASS schema v%s", self.SCHEMA_VERSION) + return self._schema + except FileNotFoundError as e: + msg = f"CIRPASS schema file not found: {self._schema_file}" + logger.error(msg) + raise RuntimeError(msg) from e + except json.JSONDecodeError as e: + msg = f"Invalid JSON in CIRPASS schema: {e}" + logger.error(msg) + raise RuntimeError(msg) from e + + @property + def schema_id(self) -> str: + """Get the schema title/ID. + + Returns: + Schema title string from the loaded schema + """ + return self.load().get("title", "") + + @property + def schema_version(self) -> str: + """Get the schema version. + + Returns: + Version string extracted from schema title + """ + title = self.schema_id + # Extract version from title like "CIRPASS DPP reference structure v1.3.0" + if " v" in title: + return title.split(" v")[-1] + return self.SCHEMA_VERSION + + @property + def json_schema_draft(self) -> str: + """Get the JSON Schema draft version. + + Returns: + JSON Schema $schema URI + """ + return self.load().get("$schema", "") + + def get_property_names(self) -> list[str]: + """Get all top-level property names from the schema. + + Returns: + List of property names defined in the schema + """ + schema = self.load() + properties = schema.get("properties", {}) + return list(properties.keys()) + + def get_property_schema(self, property_name: str) -> dict[str, Any] | None: + """Get schema definition for a specific property. + + Args: + property_name: Name of the property + + Returns: + Property schema definition, or None if not found + """ + schema = self.load() + properties = schema.get("properties", {}) + return properties.get(property_name) + + def has_property(self, property_name: str) -> bool: + """Check if schema defines a property. + + Args: + property_name: Name of the property + + Returns: + True if property is defined in schema + """ + return self.get_property_schema(property_name) is not None + + def is_additional_properties_allowed(self) -> bool: + """Check if schema allows additional properties. + + Returns: + True if additionalProperties is not false + """ + schema = self.load() + return schema.get("additionalProperties", True) is not False + + def clear_cache(self) -> None: + """Clear the cached schema.""" + self._schema = None + + +class CIRPASSSHACLLoader: + """Load and cache CIRPASS DPP SHACL shapes. + + Provides loading of SHACL constraint shapes from bundled data files + for RDF validation of EU DPP data. + + Note: SHACL validation requires rdflib which is an optional dependency. + """ + + SHACL_FILE = "cirpass_dpp_shacl.ttl" + + def __init__(self) -> None: + """Initialize CIRPASS SHACL loader.""" + self._shapes_text: str | None = None + + def load_text(self) -> str: + """Load SHACL shapes as text. + + Returns: + SHACL shapes file content as string + + Raises: + RuntimeError: If SHACL file cannot be loaded + """ + if self._shapes_text is not None: + return self._shapes_text + + try: + data_dir = _get_cirpass_schema_dir() + shacl_path = data_dir.joinpath(self.SHACL_FILE) + self._shapes_text = shacl_path.read_text(encoding="utf-8") + logger.debug("Loaded CIRPASS SHACL shapes") + return self._shapes_text + except FileNotFoundError as e: + msg = f"CIRPASS SHACL file not found: {self.SHACL_FILE}" + logger.error(msg) + raise RuntimeError(msg) from e + + def clear_cache(self) -> None: + """Clear the cached SHACL shapes.""" + self._shapes_text = None + + +def get_cirpass_schema() -> dict[str, Any]: + """Convenience function to load CIRPASS schema. + + Returns: + Parsed CIRPASS DPP JSON schema + + Example: + >>> schema = get_cirpass_schema() + >>> print(schema["title"]) + 'CIRPASS DPP reference structure v1.3.0' + """ + loader = CIRPASSSchemaLoader() + return loader.load() + + +def get_cirpass_schema_version() -> str: + """Get the CIRPASS schema version. + + Returns: + Version string (e.g., "1.3.0") + """ + return CIRPASS_SCHEMA_VERSION diff --git a/src/dppvalidator/schemas/data/MANIFEST.json b/src/dppvalidator/schemas/data/MANIFEST.json new file mode 100644 index 0000000..2f7842d --- /dev/null +++ b/src/dppvalidator/schemas/data/MANIFEST.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://artiso-ai.github.io/dppvalidator/schemas/manifest-v1.json", + "manifest_version": 1, + "description": "Provenance + integrity manifest for every artefact bundled into the dppvalidator wheel under src/dppvalidator/{schemas,vocabularies}/data/. Each artefact carries the upstream URL it was vendored from and a SHA-256 of the bundled bytes; tests/unit/test_manifest_integrity.py (added in Phase 5) re-computes the hashes on every CI run. See docs/plans/UNTP_0.7.0_MIGRATION.md for the migration context.", + "artefacts": [ + { + "version": "0.6.1", + "kind": "untp-dpp-schema", + "path": "src/dppvalidator/schemas/data/untp-dpp-schema-0.6.1.json", + "source_url": "https://test.uncefact.org/vocabulary/untp/dpp/untp-dpp-schema-0.6.1.json", + "sha256": "c0fdd7da5d23b6aec5d1d0ce198ca8d1cd67ca27609395a1b4961b3d1a8549a8", + "bytes": 49381, + "pulled_at": "2025-01-30", + "notes": "Pulled before this manifest existed; date inferred from git history (commit dd0c84..). Hash verified against the bytes on disk on 2026-05-07." + }, + { + "version": "0.6.1", + "kind": "untp-jsonld-context", + "path": "src/dppvalidator/vocabularies/data/untp-context-0.6.1.jsonld", + "source_url": "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + "sha256": "e956ab00591cebabeb6080eb02cd2f22adbda88c4d7861e8e7f03ea64284370f", + "bytes": 34347, + "pulled_at": "2026-05-07", + "notes": "Vendored in Phase 2 to remove an implicit network dependency. Server: AWS S3, Last-Modified: 2025-06-16T21:40:30Z." + }, + { + "version": "0.7.0", + "kind": "untp-dpp-schema", + "path": "src/dppvalidator/schemas/data/untp-dpp-schema-0.7.0.json", + "source_url": "https://opensource.unicc.org/un/unece/uncefact/spec-untp/-/raw/707cd5267deddede24bb74e453a758561972a109/artefacts/schema/v0.7.0/dpp/DigitalProductPassport.json", + "production_url": "https://untp.unece.org/artefacts/schema/v0.7.0/dpp/DigitalProductPassport.json", + "sha256": "42c51943ab23547d5287899fd12b214b19b006c28d105a70ff390f8551b12653", + "bytes": 50362, + "pulled_at": "2026-05-07", + "upstream_tag": "v0.7.0", + "upstream_commit": "707cd5267deddede24bb74e453a758561972a109", + "notes": "Self-contained: every $ref is internal (#/$defs/...). Product.json from the upstream split layout is intentionally NOT vendored — its $defs are already embedded inside this file's $defs. The bytes at ``production_url`` were re-fetched and verified bit-identical to ``source_url`` on 2026-05-08 (same SHA-256). Known upstream quirk: the embedded ``$defs.Characteristics`` carries an empty ``properties: {}`` and a description that was copy-pasted from ``$defs.Claim`` (\"A declaration of conformance with one or more criteria…\"). The standalone ``Product.json`` upstream file has the canonical, richer Characteristics definition with a ``@context`` field for JSON-LD vocabulary scoping. dppvalidator models Characteristics as ``extra=\"allow\"`` so behaviour is unaffected; this is documented for future readers who hit the discrepancy." + }, + { + "version": "0.7.0", + "kind": "untp-jsonld-context", + "path": "src/dppvalidator/vocabularies/data/untp-context-0.7.0.jsonld", + "source_url": "https://opensource.unicc.org/un/unece/uncefact/spec-untp/-/raw/707cd5267deddede24bb74e453a758561972a109/artefacts/contexts/v0.7.0/untp-context.jsonld", + "production_url": "https://vocabulary.uncefact.org/untp/0.7.0/context/", + "sha256": "fbd4824e30d3cfc5cba949e1efe19b4c9ebaee056abe7aaf1c6b139a7bf91b0c", + "bytes": 105396, + "pulled_at": "2026-05-07", + "upstream_tag": "v0.7.0", + "upstream_commit": "707cd5267deddede24bb74e453a758561972a109", + "notes": "Unified context covering DigitalProductPassport, DigitalConformityCredential, DigitalFacilityRecord, DigitalIdentityAnchor, DigitalTraceabilityEvent." + }, + { + "version": "0.7.0", + "kind": "untp-vocabulary-ontology", + "path": "src/dppvalidator/vocabularies/data/untp-ontology.jsonld", + "source_url": "https://opensource.unicc.org/un/unece/uncefact/spec-untp/-/raw/707cd5267deddede24bb74e453a758561972a109/artefacts/vocabularies/untp-core/untp-ontology.jsonld", + "sha256": "752060cc15c6c77bfcea8b170f173239a705e9da389314c1cb2dacc8a69d93bc", + "bytes": 147724, + "pulled_at": "2026-05-07", + "upstream_tag": "v0.7.0", + "upstream_commit": "707cd5267deddede24bb74e453a758561972a109", + "notes": "Core UNTP RDFS/OWL ontology: 74 classes, 2 properties. Used by SHACL-based semantic validation (Phase 3b)." + }, + { + "version": "0.7.0", + "kind": "untp-vocabulary-metrics", + "path": "src/dppvalidator/vocabularies/data/untp-metrics.jsonld", + "source_url": "https://opensource.unicc.org/un/unece/uncefact/spec-untp/-/raw/707cd5267deddede24bb74e453a758561972a109/artefacts/vocabularies/untp-metrics/untp-metrics.jsonld", + "sha256": "77900ce1138be124976d138750bea24bacb6c8ba327672fe8598b85db99a0a36", + "bytes": 53765, + "pulled_at": "2026-05-07", + "upstream_tag": "v0.7.0", + "upstream_commit": "707cd5267deddede24bb74e453a758561972a109", + "notes": "Controlled metric vocabulary referenced by Performance.metric in the DPP schema." + }, + { + "version": "0.7.0", + "kind": "untp-vocabulary-topics", + "path": "src/dppvalidator/vocabularies/data/untp-topics.jsonld", + "source_url": "https://opensource.unicc.org/un/unece/uncefact/spec-untp/-/raw/707cd5267deddede24bb74e453a758561972a109/artefacts/vocabularies/untp-topics/untp-topics.jsonld", + "sha256": "49affcb265bdf2a7a92d1b171c49a27543bfb4915bcbd11dd6e571252a57bb12", + "bytes": 61045, + "pulled_at": "2026-05-07", + "upstream_tag": "v0.7.0", + "upstream_commit": "707cd5267deddede24bb74e453a758561972a109", + "notes": "Conformity-topic taxonomy referenced by Claim.conformityTopic in the DPP schema." + } + ] +} diff --git a/src/dppvalidator/schemas/data/README.md b/src/dppvalidator/schemas/data/README.md index 2d61337..90a50e8 100644 --- a/src/dppvalidator/schemas/data/README.md +++ b/src/dppvalidator/schemas/data/README.md @@ -1,35 +1,79 @@ # UNTP DPP Schema Files -This directory contains bundled JSON Schema files for the UN Transparency Protocol -(UNTP) Digital Product Passport (DPP) specification. +This directory contains bundled JSON Schema files for the UN +Transparency Protocol (UNTP) Digital Product Passport (DPP) +specification. ## Source -Schemas are sourced from the official UN/CEFACT vocabulary repository: +Schemas are sourced from the official UN/CEFACT vocabulary +repositories: -- **URL**: -- **Specification**: +- **v0.6.x**: +- **v0.7.0**: +- **Specification**: -## Included Schemas +## Included schemas -| File | Version | Source URL | -| ---------------------------- | ------- | ------------------------------------------------------------------------------------ | -| `untp-dpp-schema-0.6.1.json` | 0.6.1 | [Download](https://test.uncefact.org/vocabulary/untp/dpp/untp-dpp-schema-0.6.1.json) | + + +| File | Version | Bytes | SHA-256 (LF-normalised) | +| ---------------------------- | ------- | -----: | ------------------------------------------------------------------ | +| `untp-dpp-schema-0.6.1.json` | 0.6.1 | 49 381 | `c0fdd7da5d23b6aec5d1d0ce198ca8d1cd67ca27609395a1b4961b3d1a8549a8` | +| `untp-dpp-schema-0.7.0.json` | 0.7.0 | 50 362 | `42c51943ab23547d5287899fd12b214b19b006c28d105a70ff390f8551b12653` | + + + +The full provenance + integrity record (source URL, production +mirror URL, upstream commit, pull date, notes) for each file lives +in [`MANIFEST.json`](MANIFEST.json) and is enforced by +[`tests/unit/test_manifest_integrity.py`](../../../../tests/unit/test_manifest_integrity.py). + +`v0.6.0` is registered in +[`schemas/registry.py`](../registry.py) but its bytes are **not** +bundled — it shares the wire shape of `v0.6.1` and the engine +defaults v0.6.x callers to the bundled `v0.6.1` schema. + +## Manifest + +Every artefact under this directory and under +`src/dppvalidator/vocabularies/data/` is required to appear in +[`MANIFEST.json`](MANIFEST.json) with version, source URL, +production URL (when set), SHA-256, and pull date. CI enforces this +contract via the manifest-integrity test; adding a vendored file +without a manifest entry trips the drift catch. + +The "two URLs per artefact" pattern records: + +- **`source_url`** — the SHA-pinned upstream URL the bundled bytes + came from. Immutable; used for re-pulling and integrity diffs. +- **`production_url`** — the canonical production hosting (e.g. + `untp.unece.org` for v0.7.0). Human-friendly; used for + documentation links. Verified bit-identical to `source_url` at + vendor time. ## License The UNTP specification and schemas are published by UN/CEFACT under -open governance. See the [UNTP specification](https://uncefact.github.io/spec-untp/) +open governance. See the [UNTP specification](https://untp.unece.org/) for licensing details. ## Updates -To update schemas, use the `SchemaLoader.download_schema()` method or fetch -directly from the source URLs above. +For routine refreshes (new patch level on an existing version), +re-fetch from the production URL and verify the SHA-256 matches the +manifest pin. If the upstream bytes changed, update the manifest +hash in the same change. + +For a new minor/major UNTP version, the recipe lives in +[`.claude/skills/untp-migrate/SKILL.md`](../../../../.claude/skills/untp-migrate/SKILL.md) +(invocable as `/untp-bump ` in Claude Code). The minimum +touch list and full versioning rules are in +[`.claude/rules/untp-versioning.md`](../../../../.claude/rules/untp-versioning.md). ```python from dppvalidator.schemas import SchemaLoader loader = SchemaLoader() -loader.download_schema("0.6.1", output_dir="./schemas/data") +loader.download_schema("0.7.0", output_dir="./schemas/data") ``` diff --git a/src/dppvalidator/schemas/data/untp-dpp-schema-0.7.0.json b/src/dppvalidator/schemas/data/untp-dpp-schema-0.7.0.json new file mode 100644 index 0000000..dcdf7db --- /dev/null +++ b/src/dppvalidator/schemas/data/untp-dpp-schema-0.7.0.json @@ -0,0 +1,1455 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "DigitalProductPassport", + "VerifiableCredential" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "DigitalProductPassport", + "minContains": 1 + } + }, + { + "contains": { + "const": "VerifiableCredential", + "minContains": 1 + } + } + ] + }, + "@context": { + "example": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/" + ], + "type": "array", + "items": { + "type": "string" + }, + "description": "A list of JSON-LD context URIs that define the semantic meaning of properties within the credential. ", + "readOnly": true, + "prefixItems": [ + { + "const": "https://www.w3.org/ns/credentials/v2", + "type": "string" + }, + { + "const": "https://vocabulary.uncefact.org/untp/0.7.0/context/", + "type": "string" + } + ], + "default": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/" + ], + "minItems": 2, + "uniqueItems": true + }, + "id": { + "example": "https://example-company.com/credentials/2a423366-a0d6-4855-ba65-2e0c926d09b0", + "type": "string", + "format": "uri", + "description": "A unique identifier (URI) assigned to this verifiable credential." + }, + "issuer": { + "$ref": "#/$defs/CredentialIssuer", + "description": "The organisation that is the issuer of this VC. Note that the \"id\" property MUST be a W3C DID. Other identifiers such as tax registration numbers can be listed in the \"otherIdentifiers\" property." + }, + "validFrom": { + "example": "2024-03-15T12:00:00Z", + "type": "string", + "format": "date-time", + "description": "The date and time from which the credential is valid." + }, + "validUntil": { + "example": "2034-03-15T12:00:00Z", + "type": "string", + "format": "date-time", + "description": "The expiry date (if applicable) of this verifiable credential." + }, + "name": { + "example": "Some name", + "type": "string", + "description": "Name of this verifiable credential instance (eg the title of a digital product passport, facility record, lifecycle event, or conformity credential)" + }, + "credentialStatus": { + "$ref": "#/$defs/BitstringStatusListEntry", + "description": "A W3C VCDM2.0 compliant object containing credential status information." + }, + "renderMethod": { + "type": "array", + "items": { + "$ref": "#/$defs/RenderTemplate2024" + }, + "description": "Human rendering information for this credential. An array of render methods (eg RenderTemplate2024) that may be used to display the credential." + }, + "credentialSubject": { + "$ref": "#/$defs/Product", + "description": "The product that is the subject of this digital product passport." + }, + "issuingSoftware": { + "$ref": "#/$defs/IssuingSoftware", + "description": "Optional metadata identifying the software product (and its vendor) that issued this credential. Useful for vendor traceability and conformity testing. Issuers MAY omit this property." + } + }, + "description": "A digital Product Passport (DPP) credential.", + "required": [ + "@context", + "id", + "issuer", + "validFrom", + "name", + "credentialSubject" + ], + "$defs": { + "CredentialIssuer": { + "type": "object", + "additionalProperties": true, + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "CredentialIssuer" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "CredentialIssuer", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "did:web:identifiers.example-company.com:12345", + "type": "string", + "format": "uri", + "description": "The W3C DID of the issuer - should be a did:web or did:webvh" + }, + "name": { + "example": "Example Company Pty Ltd", + "type": "string", + "description": "The name of the issuer person or organisation" + }, + "issuerAlsoKnownAs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Party" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Party", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://sample-business-register.gov/123456789", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this party. Typically represented as a URI identifierScheme/Identifier URI" + }, + "name": { + "example": "Sample Company Ltd", + "type": "string", + "description": "Legal registered name of this party." + }, + "registeredId": { + "example": 90664869327, + "type": "string", + "description": "The registration number (alphanumeric) of the Party within the register. Unique within the register." + } + }, + "required": [ + "id", + "name" + ] + }, + "description": "An optional list of other registered identifiers for this credential issuer " + } + }, + "description": "The issuer party (person or organisation) of a verifiable credential.", + "required": [ + "id", + "name" + ] + }, + "BitstringStatusListEntry": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "BitstringStatusListEntry" + ], + "example": "BitstringStatusListEntry", + "description": "The type of status list - must be set to \"The type property MUST be BitstringStatusListEntry.\"" + }, + "id": { + "example": "https://example-cab.com/credentials/status/3#94567\"", + "type": "string", + "format": "uri", + "description": "optional identifier of this status list entry." + }, + "statusPurpose": { + "type": "string", + "enum": [ + "refresh", + "revocation", + "suspension", + "message" + ], + "example": "refresh", + "description": "Status purpose drawn from a standard list but extensible as per w3c bitstring status list specification." + }, + "statusListIndex": { + "minimum": 0, + "example": 94567, + "type": "integer", + "description": "\tThe statusListIndex property MUST be an arbitrary size integer greater than or equal to 0, expressed as a string in base 10. The value identifies the position of the status of the verifiable credential." + }, + "statusListCredential": { + "example": "https://example-cab.com/credentials/status/4", + "type": "string", + "format": "uri", + "description": "The statusListCredential property MUST be a URL to a verifiable credential. When the URL is dereferenced, the resulting verifiable credential MUST have type property that includes the BitstringStatusListCredential value." + } + }, + "description": "A privacy-preserving, space-efficient, and high-performance mechanism for publishing status information such as suspension or revocation of Verifiable Credentials through use of bitstrings. See https://www.w3.org/TR/vc-bitstring-status-list/ for full details.", + "required": [ + "type", + "statusPurpose", + "statusListIndex", + "statusListCredential" + ] + }, + "RenderTemplate2024": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "RenderTemplate2024" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "RenderTemplate2024", + "minContains": 1 + } + } + ] + }, + "name": { + "type": "string", + "description": "Human facing display name for selection" + }, + "mediaQuery": { + "type": "string", + "description": "Media query as defined in https://www.w3.org/TR/mediaqueries-4/" + }, + "template": { + "type": "string", + "description": "An inline template field for use cases where remote retrieval of a render method is suboptimal" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL for remotely hosted template" + }, + "mediaType": { + "type": "string", + "description": "media type of the rendered output (eg text/html)" + }, + "digestMultibase": { + "type": "string", + "description": "Used for resource integrity and/or validation of the inline `template`" + } + }, + "description": "A single template format focused render method where the content/media type decision becomes secondary (and is expressed separately).See https://github.com/w3c-ccg/vc-render-method/issues/9" + }, + "IssuingSoftware": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "example": "https://yourdomain.com/.well-known/untp/software/yourproduct/2026.04.1", + "type": "string", + "format": "uri", + "description": "A resolvable identifier for the specific version of the software product that issued this credential." + }, + "name": { + "example": "Your Product Name", + "type": "string", + "description": "The name of the software product that issued this credential." + }, + "version": { + "example": "2026.04.1", + "type": "string", + "description": "The version of the software product that issued this credential." + }, + "vendor": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "example": "did:web:yourdomain.com", + "type": "string", + "format": "uri", + "description": "The decentralised identifier (DID) or other resolvable identifier of the software vendor." + }, + "name": { + "example": "Your Vendor Name", + "type": "string", + "description": "The name of the software vendor." + } + }, + "required": [ + "id", + "name" + ], + "description": "The vendor of the software product that issued this credential." + } + }, + "required": [ + "id", + "name", + "version", + "vendor" + ], + "description": "Optional metadata identifying the software product (and its vendor) that issued the parent credential. When present, all listed sub-properties MUST be populated; when absent, the credential is still valid (verifiers MUST treat the property as optional)." + }, + "Product": { + "type": "object", + "additionalProperties": true, + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Product" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Product", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "did:web:manufacturer.com:product:123456789", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this product. Typically represented as a URI identifierScheme/Identifier URI or, if self-issued, as a did." + }, + "name": { + "example": "600 Ah Lithium Battery", + "type": "string", + "description": "The product name as known to the market." + }, + "description": { + "type": "string", + "description": "Description of the product." + }, + "idScheme": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "IdentifierScheme" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "IdentifierScheme", + "minContains": 1 + } + } + ] + }, + "id": { + "type": "string", + "format": "uri", + "description": "The URI of this identifier scheme" + }, + "name": { + "example": "Global Identifier Scheme Name", + "type": "string", + "description": "The name of the identifier scheme. " + } + }, + "required": [ + "id", + "name" + ], + "description": "The identifier scheme for this product. Eg a GS1 GTIN or an AU Livestock NLIS, or similar. If self issued then use the party ID of the issuer. " + }, + "modelNumber": { + "type": "string", + "description": "Where available, the model number (for manufactured products) or material identification (for bulk materials)" + }, + "batchNumber": { + "example": 6789, + "type": "string", + "description": "Identifier of the specific production batch of the product. Unique within the product class." + }, + "itemNumber": { + "example": 12345678, + "type": "string", + "description": "A number or code representing a specific serialised item of the product. Unique within product class." + }, + "idGranularity": { + "type": "string", + "enum": [ + "model", + "batch", + "item" + ], + "example": "model", + "description": "The identification granularity for this product (item, batch, model)" + }, + "productImage": { + "$ref": "#/$defs/Link", + "description": "Reference information (location, type, name) of an image of the product." + }, + "characteristics": { + "$ref": "#/$defs/Characteristics", + "description": "A set of industry specific product information. " + }, + "productCategory": { + "type": "array", + "items": { + "$ref": "#/$defs/Classification" + }, + "description": "A code representing the product's class, typically using the UN CPC (United Nations Central Product Classification) https://unstats.un.org/unsd/classifications/Econ/cpc" + }, + "relatedDocument": { + "type": "array", + "items": { + "$ref": "#/$defs/Link" + }, + "description": "A list of links to documents providing additional product information. Documents that support a conformity claim (e.g. permits or certificates) SHOULD be referenced as claim evidence rather than here." + }, + "relatedParty": { + "type": "array", + "items": { + "$ref": "#/$defs/PartyRole" + }, + "description": "A list of parties with a defined relationship to this product" + }, + "producedAtFacility": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Facility" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Facility", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://sample-location-register.com/987654321", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this facility. Typically represented as a URI identifierScheme/Identifier URI" + }, + "name": { + "example": "Sample Factory A", + "type": "string", + "description": "Name of this facility as defined the location register." + }, + "registeredId": { + "example": 1234567, + "type": "string", + "description": "The registration number (alphanumeric) of the facility within the identifier scheme. Unique within the register." + } + }, + "required": [ + "id", + "name" + ], + "description": "The Facility where the product batch was produced / manufactured." + }, + "productionDate": { + "example": "2024-04-25", + "type": "string", + "format": "date", + "description": "The ISO 8601 date on which the product batch or individual serialised item was manufactured." + }, + "expiryDate": { + "example": "2027-04-25", + "type": "string", + "format": "date", + "description": "The date at which this product is no longer fit for use. Typically used for a food product use-by date but may also represent the usable life of any product." + }, + "countryOfProduction": { + "$ref": "#/$defs/Country", + "description": "The country in which this item was produced / manufactured.using ISO-3166 code and name." + }, + "dimensions": { + "$ref": "#/$defs/Dimension", + "description": "The physical dimensions of the product. Not every dimension is relevant to every products. For example bulk materials may have weight and volume but not length, width, or height.\"weight\":{\"value\":10, \"unit\":\"KGM\"}" + }, + "materialProvenance": { + "type": "array", + "items": { + "$ref": "#/$defs/Material" + }, + "description": "A list of materials provenance objects providing details on the origin and mass fraction of materials of the product or batch." + }, + "packaging": { + "$ref": "#/$defs/Package", + "description": "The packaging for this product." + }, + "productLabel": { + "type": "array", + "items": { + "$ref": "#/$defs/Image" + }, + "description": "An array of labels that may appear on the product such as certification marks or regulatory labels." + }, + "performanceClaim": { + "type": "array", + "items": { + "$ref": "#/$defs/Claim" + }, + "description": "A list of performance claims (eg emissions intensity) for this product." + } + }, + "description": "The ProductInformation class encapsulates detailed information regarding a specific product, including its identification details, manufacturer, and other pertinent details.", + "required": [ + "id", + "name", + "idScheme", + "idGranularity", + "productCategory", + "producedAtFacility", + "countryOfProduction" + ] + }, + "Link": { + "type": "object", + "additionalProperties": false, + "properties": { + "linkURL": { + "example": "https://files.example-certifier.com/1234567.json", + "type": "string", + "format": "uri", + "description": "The URL of the target resource. " + }, + "linkName": { + "type": "string", + "description": "Display name for this link." + }, + "digestMultibase": { + "example": "abc123-example-digest-invalid", + "type": "string", + "description": "An optional multi-base encoded digest to ensure the content of the link has not changed. See https://www.w3.org/TR/vc-data-integrity/#resource-integrity for more information." + }, + "mediaType": { + "example": "application/ld+json", + "type": "string", + "description": "The media type of the target resource." + }, + "linkType": { + "example": "https://test.uncefact.org/vocabulary/linkTypes/dcc", + "type": "string", + "description": "The type of the target resource - drawn from a controlled vocabulary " + } + }, + "description": "A structure to provide a URL link plus metadata associated with the link.", + "required": [ + "linkURL", + "linkName" + ] + }, + "Characteristics": { + "type": "object", + "additionalProperties": true, + "properties": {}, + "description": "A declaration of conformance with one or more criteria from a specific standard or regulation. " + }, + "Classification": { + "type": "object", + "additionalProperties": false, + "properties": { + "code": { + "example": 46410, + "type": "string", + "description": "classification code within the scheme" + }, + "name": { + "example": "Primary cells and primary batteries", + "type": "string", + "description": "Name of the classification represented by the code" + }, + "definition": { + "type": "string", + "description": "A rich definition of this classification code." + }, + "schemeId": { + "example": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "type": "string", + "format": "uri", + "description": "Classification scheme ID" + }, + "schemeName": { + "example": "UN Central Product Classification (CPC)", + "type": "string", + "description": "The name of the classification scheme" + } + }, + "description": "A classification scheme and code / name representing a category value for a product, entity, or facility.", + "required": [ + "code", + "name", + "schemeId", + "schemeName" + ] + }, + "PartyRole": { + "type": "object", + "additionalProperties": false, + "properties": { + "role": { + "type": "string", + "enum": [ + "owner", + "producer", + "manufacturer", + "processor", + "remanufacturer", + "recycler", + "operator", + "serviceProvider", + "inspector", + "certifier", + "logisticsProvider", + "carrier", + "consignor", + "consignee", + "importer", + "exporter", + "distributor", + "retailer", + "brandOwner", + "regulator" + ], + "example": "owner", + "description": "The role played by the party in this relationship" + }, + "party": { + "$ref": "#/$defs/Party", + "description": "The party that has the specified role." + } + }, + "description": "A party with a defined relationship to the referencing entity", + "required": [ + "role", + "party" + ] + }, + "Party": { + "type": "object", + "additionalProperties": true, + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Party" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Party", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://sample-business-register.gov/123456789", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this party. Typically represented as a URI identifierScheme/Identifier URI" + }, + "name": { + "example": "Sample Company Ltd", + "type": "string", + "description": "Legal registered name of this party." + }, + "description": { + "type": "string", + "description": "Description of the party including function and other names." + }, + "registeredId": { + "example": 90664869327, + "type": "string", + "description": "The registration number (alphanumeric) of the Party within the register. Unique within the register." + }, + "idScheme": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "IdentifierScheme" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "IdentifierScheme", + "minContains": 1 + } + } + ] + }, + "id": { + "type": "string", + "format": "uri", + "description": "The URI of this identifier scheme" + }, + "name": { + "example": "Global Identifier Scheme Name", + "type": "string", + "description": "The name of the identifier scheme. " + } + }, + "required": [ + "id", + "name" + ], + "description": "The identifier scheme of the party. Typically a national business register or a global scheme such as GLEIF. " + }, + "registrationCountry": { + "$ref": "#/$defs/Country", + "description": "the country in which this organisation is registered - using ISO-3166 code and name." + }, + "partyAddress": { + "$ref": "#/$defs/Address", + "description": "The address of the party" + }, + "organisationWebsite": { + "example": "https://example-company.com", + "type": "string", + "format": "uri", + "description": "Website for this organisation" + }, + "industryCategory": { + "type": "array", + "items": { + "$ref": "#/$defs/Classification" + }, + "description": "The industry categories for this organisation. Recommend use of UNCPC as the category scheme. for example - unstats.un.org/isic/1030" + }, + "partyAlsoKnownAs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Party" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Party", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://sample-business-register.gov/123456789", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this party. Typically represented as a URI identifierScheme/Identifier URI" + }, + "name": { + "example": "Sample Company Ltd", + "type": "string", + "description": "Legal registered name of this party." + }, + "registeredId": { + "example": 90664869327, + "type": "string", + "description": "The registration number (alphanumeric) of the Party within the register. Unique within the register." + } + }, + "required": [ + "id", + "name" + ] + }, + "description": "An optional list of other registered identifiers for this organisation. For example DUNS, GLN, LEI, etc" + } + }, + "description": "An organisation. May be a supply chain actor, a certifier, a government agency.", + "required": [ + "id", + "name" + ] + }, + "Country": { + "type": "object", + "additionalProperties": false, + "properties": { + "countryCode": { + "type": "string", + "x-external-enumeration": "https://vocabulary.uncefact.org/CountryId#", + "description": "ISO 3166 country code\n\n This is an enumerated value, but the list of valid values are too big, or change too often to include here. You can access the list of allowable values at this URL: https://vocabulary.uncefact.org/CountryId#\n " + }, + "countryName": { + "type": "string", + "description": "Country Name as defined in ISO 3166" + } + }, + "description": "Country Code and Name from ISO 3166", + "required": [ + "countryCode" + ] + }, + "Address": { + "type": "object", + "additionalProperties": false, + "properties": { + "streetAddress": { + "example": "level 11, 15 London Circuit", + "type": "string", + "description": "the street address as an unstructured string." + }, + "postalCode": { + "example": 2601, + "type": "string", + "description": "The postal code or zip code for this address." + }, + "addressLocality": { + "example": "Acton", + "type": "string", + "description": "The city, suburb or township name." + }, + "addressRegion": { + "example": "ACT", + "type": "string", + "description": "The state or territory or province" + }, + "addressCountry": { + "$ref": "#/$defs/Country", + "description": "The address country as an ISO-3166 two letter country code and name." + } + }, + "description": "A postal address.", + "required": [ + "streetAddress", + "postalCode", + "addressLocality", + "addressRegion", + "addressCountry" + ] + }, + "Dimension": { + "type": "object", + "additionalProperties": true, + "properties": { + "weight": { + "$ref": "#/$defs/Measure", + "description": "the weight of the product. EG {\"value\":10, \"unit\":\"KGM\"}" + }, + "length": { + "$ref": "#/$defs/Measure", + "description": "The length of the product or packaging eg {\"value\":840, \"unit\":\"MMT\"}" + }, + "width": { + "$ref": "#/$defs/Measure", + "description": "The width of the product or packaging. eg {\"value\":150, \"unit\":\"MMT\"}" + }, + "height": { + "$ref": "#/$defs/Measure", + "description": "The height of the product or packaging. eg {\"value\":220, \"unit\":\"MMT\"}" + }, + "volume": { + "$ref": "#/$defs/Measure", + "description": "The displacement volume of the product. eg {\"value\":7.5, \"unit\":\"LTR\"}" + } + }, + "description": "Overall (length, width, height) dimensions and weight/volume of an item." + }, + "Measure": { + "type": "object", + "additionalProperties": false, + "properties": { + "value": { + "example": 10, + "type": "number", + "description": "The numeric value of the measure" + }, + "upperTolerance": { + "type": "number", + "description": "The upper tolerance associated with this measure expressed in the same units as the measure. For example value=10, upperTolerance=0.1, unit=KGM would mean that this measure is 10kg + 0.1kg" + }, + "lowerTolerance": { + "type": "number", + "description": "The lower tolerance associated with this measure expressed in the same units as the measure. For example value=10, lowerTolerance=0.1, unit=KGM would mean that this measure is 10kg - 0.1kg" + }, + "unit": { + "type": "string", + "x-external-enumeration": "https://vocabulary.uncefact.org/UnitMeasureCode#", + "description": "Unit of measure drawn from the UNECE Rec20 measure code list.\n\n This is an enumerated value, but the list of valid values are too big, or change too often to include here. You can access the list of allowable values at this URL: https://vocabulary.uncefact.org/UnitMeasureCode#\n " + } + }, + "description": "The measure class defines a numeric measured value (eg 10) and a coded unit of measure (eg KG). There is an optional upper and lower tolerance which can be used to specify uncertainty in the measure. ", + "required": [ + "value", + "unit" + ] + }, + "Material": { + "type": "object", + "additionalProperties": true, + "properties": { + "name": { + "example": "Lithium Spodumene", + "type": "string", + "description": "Name of this material (eg \"Egyptian Cotton\")" + }, + "originCountry": { + "$ref": "#/$defs/Country", + "description": "A ISO 3166-1 code representing the country of origin of the component or ingredient." + }, + "materialType": { + "$ref": "#/$defs/Classification", + "description": "The type of this material - as a value drawn from a controlled vocabulary eg from UN Framework Classification for Resources (UNFC)." + }, + "massFraction": { + "maximum": 1, + "minimum": 0, + "example": 0.2, + "type": "number", + "description": "The mass fraction as a decimal of the product (or facility reporting period) represented by this material. " + }, + "mass": { + "$ref": "#/$defs/Measure", + "description": "The mass of the material component." + }, + "recycledMassFraction": { + "maximum": 1, + "minimum": 0, + "example": 0.5, + "type": "number", + "description": "Mass fraction of this material that is recycled (eg 50% recycled Lithium)" + }, + "hazardous": { + "type": "boolean", + "description": "Indicates whether this material is hazardous. If true then the materialSafetyInformation property must be present" + }, + "symbol": { + "$ref": "#/$defs/Image", + "description": "Based 64 encoded binary used to represent a visual symbol for a given material. " + }, + "materialSafetyInformation": { + "$ref": "#/$defs/Link", + "description": "Reference to further information about safe handling of this hazardous material (for example a link to a material safety data sheet)" + } + }, + "description": "The material class encapsulates details about the origin or source of raw materials in a product, including the country of origin and the mass fraction.", + "required": [ + "name", + "originCountry", + "materialType", + "massFraction" + ] + }, + "Image": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "example": "certification trust mark", + "type": "string", + "description": "the display name for this image" + }, + "description": { + "type": "string", + "description": "The detailed description / supporting information for this image." + }, + "imageData": { + "type": "string", + "format": "byte", + "description": "The image data encoded as a base64 string." + }, + "mediaType": { + "type": "string", + "x-external-enumeration": "https://mimetype.io/", + "description": "The media type of this image (eg image/png)\n\n This is an enumerated value, but the list of valid values are too big, or change too often to include here. You can access the list of allowable values at this URL: https://mimetype.io/\n " + } + }, + "description": "A binary image encoded as base64 text and embedded into the data. Use this for small images like certification trust marks or regulated labels. Large images should be external links.", + "required": [ + "name", + "imageData", + "mediaType" + ] + }, + "Package": { + "type": "object", + "additionalProperties": true, + "properties": { + "description": { + "type": "string", + "description": "Description of the packaging." + }, + "dimensions": { + "$ref": "#/$defs/Dimension", + "description": "dimensions of the packaging" + }, + "materialUsed": { + "type": "array", + "items": { + "$ref": "#/$defs/Material" + }, + "description": "materials used for the packaging." + }, + "packageLabel": { + "type": "array", + "items": { + "$ref": "#/$defs/Image" + }, + "description": "An array of package labels that may appear on the packaging together with their meaning. Use for small images that represent certification marks or regulatory requirements. Large images should be linked as evidence to claims." + }, + "performanceClaim": { + "type": "array", + "items": { + "$ref": "#/$defs/Claim" + }, + "description": "conformity claims made about the packaging." + } + }, + "description": "Details of product packaging", + "required": [ + "description", + "dimensions", + "materialUsed" + ] + }, + "Claim": { + "type": "object", + "additionalProperties": true, + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Claim" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Claim", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://sample-company.com/claim/e78dab5d-b6f6-4bc4-a458-7feb039f6cb3", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this claim. Typically represented as a URI companyURL/claimID URI or a UUID" + }, + "name": { + "example": "Sample company Forced Labour claim", + "type": "string", + "description": "Name of this claim - typically similar or the same as the referenced criterion name." + }, + "description": { + "type": "string", + "description": "Description of this conformity claim" + }, + "referenceCriteria": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Criterion" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Criterion", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://vocabulary.sample-scheme.org/criterion/lb/v1.0", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this conformity criterion. Typically represented as a URI SchemeOwner/CriterionID URI" + }, + "name": { + "example": "Forced labour assessment criterion", + "type": "string", + "description": "Name of this criterion as defined by the scheme owner." + } + }, + "required": [ + "id", + "name" + ] + }, + "description": "The criterion against which the claim is made." + }, + "referenceRegulation": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Regulation" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Regulation", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://regulations.country.gov/ABC-12345", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this standard. Typically represented as a URI government/regulation URI" + }, + "name": { + "example": "Due Diligence Directove", + "type": "string", + "description": "Name of this regulation as defined by the regulator." + } + }, + "required": [ + "id", + "name" + ] + }, + "description": "List of references to regulation to which conformity is claimed claimed for this product" + }, + "referenceStandard": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Standard" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Standard", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://sample-standards.org/A1234", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this standard. Typically represented as a URI issuer/standard URI" + }, + "name": { + "example": "Labour rights standard", + "type": "string", + "description": "Name for this standard" + } + }, + "required": [ + "id", + "name" + ] + }, + "description": "List of references to standards to which conformity is claimed claimed for this product" + }, + "claimDate": { + "type": "string", + "format": "date", + "description": "That date on which the claimed performance is applicable." + }, + "applicablePeriod": { + "$ref": "#/$defs/Period", + "description": "The applicable reporting period for this facility record." + }, + "claimedPerformance": { + "type": "array", + "items": { + "$ref": "#/$defs/Performance" + }, + "description": "The claimed performance level " + }, + "evidence": { + "type": "array", + "items": { + "$ref": "#/$defs/Link" + }, + "description": "A URI pointing to the evidence supporting the claim. SHOULD be a URL to a UNTP Digital Conformity Credential (DCC)" + }, + "conformityTopic": { + "type": "array", + "items": { + "$ref": "#/$defs/ConformityTopic" + }, + "description": "The conformity topic category for this assessment" + } + }, + "description": "A performance claim about a product, facility, or organisation that is made against a well defined criterion.", + "required": [ + "id", + "name", + "referenceCriteria", + "claimDate", + "claimedPerformance", + "conformityTopic" + ] + }, + "Period": { + "type": "object", + "additionalProperties": false, + "properties": { + "startDate": { + "type": "string", + "format": "date", + "description": "The period start date" + }, + "endDate": { + "type": "string", + "format": "date", + "description": "The period end date" + }, + "periodInformation": { + "type": "string", + "description": "Additional information relevant to this reporting period" + } + }, + "description": "A period of time, typically a month, quarter or a year, which defines the context boundary for reported facts.", + "required": [ + "startDate", + "endDate" + ] + }, + "Performance": { + "type": "object", + "additionalProperties": false, + "properties": { + "metric": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "PerformanceMetric" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "PerformanceMetric", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://authority.gov/schemeABC/123456789", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this reporting metric. " + }, + "name": { + "example": "emissions intensity", + "type": "string", + "description": "A human readable name for this metric (for example \"water usage per Kg of material\")" + } + }, + "required": [ + "id", + "name" + ], + "description": "The metric (eg material emissions intensity CO2e/Kg or percentage of young workers) that is measured." + }, + "measure": { + "$ref": "#/$defs/Measure", + "description": "The measured performance value" + }, + "score": { + "$ref": "#/$defs/Score", + "description": "A performance score (eg \"AA\") drawn from a scoring framework defined by the scheme or criterion." + } + }, + "description": "A claimed, assessed, or required performance level defined either by a scoring system or a numeric measure. When a numeric measure is provided, the metric classifying the measurement is required. When only a score is provided, the scoring framework is discoverable via the conformity scheme or criterion.", + "dependentRequired": { + "measure": [ + "metric" + ] + } + }, + "Score": { + "type": "object", + "additionalProperties": false, + "properties": { + "code": { + "type": "string", + "description": "The coded value for this score (eg \"AAA\")" + }, + "rank": { + "type": "integer", + "description": "The ranking of this score within the scoring framework - using an integer where \"1\" is the highest rank." + }, + "definition": { + "type": "string", + "description": "A description of the meaning of this score." + } + }, + "description": "A single score within a scoring framework. ", + "required": [ + "code" + ] + }, + "ConformityTopic": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "ConformityTopic" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "ConformityTopic", + "minContains": 1 + } + } + ] + }, + "id": { + "type": "string", + "format": "uri", + "description": "The unique identifier for this conformity topic" + }, + "name": { + "example": "forced-labour", + "type": "string", + "description": "The human readable name for this conformity topic." + }, + "definition": { + "type": "string", + "description": "The rich definition of this conformity topic." + } + }, + "description": "The UNTP standard classification scheme for conformity topic. see http://vocabulary.uncefact.org/ConformityTopic", + "required": [ + "id", + "name" + ] + } + } +} diff --git a/src/dppvalidator/schemas/loader.py b/src/dppvalidator/schemas/loader.py index c5d7890..35ba1ee 100644 --- a/src/dppvalidator/schemas/loader.py +++ b/src/dppvalidator/schemas/loader.py @@ -7,6 +7,8 @@ from pathlib import Path from typing import Any +import httpx + from dppvalidator.logging import get_logger from dppvalidator.schemas.registry import ( DEFAULT_SCHEMA_VERSION, @@ -14,15 +16,19 @@ SchemaVersion, ) -try: - import httpx +logger = get_logger(__name__) - HAS_HTTPX = True -except ImportError: - httpx = None # type: ignore[assignment] - HAS_HTTPX = False +# Module-level schema cache shared across all SchemaLoader instances +# This prevents redundant schema loading when multiple ValidationEngine instances are created +_MODULE_SCHEMA_CACHE: dict[str, dict[str, Any]] = {} -logger = get_logger(__name__) + +def clear_schema_cache() -> None: + """Clear the global module-level schema cache. + + Call this to force schema reloading, e.g., after updating schema files. + """ + _MODULE_SCHEMA_CACHE.clear() def _get_schema_data_dir() -> Any: @@ -58,10 +64,11 @@ def load_schema(self, version: str | None = None) -> dict[str, Any]: """Load schema for a version. Attempts loading in order: - 1. Memory cache - 2. Bundled local file - 3. Disk cache - 4. Remote URL (with caching) + 1. Module-level cache (shared across instances) + 2. Instance cache + 3. Bundled local file + 4. Disk cache + 5. Remote URL (with caching) Args: version: Schema version. Uses default if None. @@ -76,6 +83,11 @@ def load_schema(self, version: str | None = None) -> dict[str, Any]: v = version or DEFAULT_SCHEMA_VERSION schema_def = self._registry.get_schema(v) + # Check module-level cache first (shared across instances) + if v in _MODULE_SCHEMA_CACHE: + return _MODULE_SCHEMA_CACHE[v] + + # Check instance cache if v in self._schema_cache: return self._schema_cache[v] @@ -88,6 +100,8 @@ def load_schema(self, version: str | None = None) -> dict[str, Any]: if schema is None: raise RuntimeError(f"Failed to load schema {v}") + # Store in both module and instance cache + _MODULE_SCHEMA_CACHE[v] = schema self._schema_cache[v] = schema return schema @@ -124,10 +138,6 @@ def _load_cached(self, schema_def: SchemaVersion) -> dict[str, Any] | None: def _fetch_remote(self, schema_def: SchemaVersion) -> dict[str, Any] | None: """Fetch schema from remote URL and cache.""" - if not HAS_HTTPX: - logger.warning("httpx not installed, cannot fetch remote schema") - return None - try: with httpx.Client(timeout=self.timeout_seconds) as client: response = client.get(schema_def.url, follow_redirects=True) @@ -184,9 +194,6 @@ def download_schema(self, version: str, output_dir: Path | None = None) -> Path: out_dir.mkdir(parents=True, exist_ok=True) out_path = out_dir / f"untp-dpp-schema-{version}.json" - if not HAS_HTTPX: - raise RuntimeError("httpx required for download. Install with: pip install httpx") - try: with httpx.Client(timeout=self.timeout_seconds) as client: response = client.get(schema_def.url, follow_redirects=True) diff --git a/src/dppvalidator/schemas/registry.py b/src/dppvalidator/schemas/registry.py index f845fd4..ed1272f 100644 --- a/src/dppvalidator/schemas/registry.py +++ b/src/dppvalidator/schemas/registry.py @@ -9,12 +9,31 @@ @dataclass(frozen=True, slots=True) class SchemaVersion: - """Schema version definition with integrity metadata.""" + """Schema version definition with integrity metadata. + + Attributes: + version: SemVer version string (e.g. ``0.6.1``, ``0.7.0``). + url: SHA-pinned upstream URL the bundled bytes were vendored from. + Used for re-pulling and integrity diffs; not for runtime fetch. + sha256: SHA-256 of the LF-normalised bundled bytes; ``None`` when + the schema isn't bundled (legacy 0.6.0 entry). + context_urls: JSON-LD context URIs that pair with this schema + version (W3C VC + UNTP DPP context per version). + production_url: Optional canonical production URL — the + human-friendly "how-the-spec-publishes-it" URL (e.g. + ``https://untp.unece.org/...``). When set, the bytes at + this URL are byte-for-byte identical to those at :attr:`url` + (verified at vendor time); the SHA pin is enforced against + the bundled copy regardless. The two-URL split lets the + registry record provenance ("where does this schema *live*?") + separately from integrity ("what bytes did we ship?"). + """ version: str url: str sha256: str | None context_urls: tuple[str, ...] + production_url: str | None = None def verify_integrity(self, content: bytes) -> bool: """Verify content matches expected SHA-256 hash. @@ -52,9 +71,34 @@ def verify_integrity(self, content: bytes) -> bool: "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", ), ), + # UNTP 0.7.0. Two URLs are tracked: ``url`` is the SHA-pinned upstream + # raw URL we vendored from; ``production_url`` is the canonical + # production hosting at ``untp.unece.org`` (verified bit-identical to + # the SHA-pinned source on 2026-05-08 — same SHA-256). The + # production CloudFront mirror for the JSON-LD context is captured + # under ``context_urls`` below. The ``sha256`` pins the bundled file + # at src/dppvalidator/schemas/data/untp-dpp-schema-0.7.0.json + # (vendored in Phase 2, see docs/plans/UNTP_0.7.0_MIGRATION.md). The + # hash is cross-verified by tests/unit/test_manifest_integrity.py. + "0.7.0": SchemaVersion( + version="0.7.0", + url=( + "https://opensource.unicc.org/un/unece/uncefact/spec-untp/-/raw/" + "707cd5267deddede24bb74e453a758561972a109/artefacts/schema/v0.7.0/dpp/" + "DigitalProductPassport.json" + ), + sha256="42c51943ab23547d5287899fd12b214b19b006c28d105a70ff390f8551b12653", + context_urls=( + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/", + ), + production_url=( + "https://untp.unece.org/artefacts/schema/v0.7.0/dpp/DigitalProductPassport.json" + ), + ), } -DEFAULT_SCHEMA_VERSION = "0.6.1" +DEFAULT_SCHEMA_VERSION = "0.6.1" # Phase 9 will flip this to "0.7.0" in dppvalidator 0.5.0. class SchemaRegistry: @@ -103,6 +147,20 @@ def get_context_urls(self, version: str | None = None) -> tuple[str, ...]: """ return self.get_schema(version).context_urls + def get_production_url(self, version: str | None = None) -> str | None: + """Return the canonical production URL for the schema, if known. + + The production URL is the human-friendly hosting (e.g. + ``https://untp.unece.org/...``) — distinct from the SHA-pinned + :meth:`get_schema_url`, which points at the immutable source the + bytes were vendored from. Both URLs serve the same bytes; the + split lets callers reach for whichever URL is appropriate + (documentation links → production_url; integrity diff → + ``url``). Returns ``None`` for versions that have no published + production URL recorded. + """ + return self.get_schema(version).production_url + @property def available_versions(self) -> list[str]: """List of available schema versions.""" diff --git a/src/dppvalidator/validators/__init__.py b/src/dppvalidator/validators/__init__.py index 4bbf545..d98a910 100644 --- a/src/dppvalidator/validators/__init__.py +++ b/src/dppvalidator/validators/__init__.py @@ -1,6 +1,16 @@ -"""Three-layer DPP validation module.""" +"""Multi-layer DPP validation module.""" +from dppvalidator.validators.deep import ( + DeepValidationResult, + DeepValidator, + validate_deep, +) +from dppvalidator.validators.detection import detect_schema_version, is_dpp_document from dppvalidator.validators.engine import ValidationEngine +from dppvalidator.validators.jsonld_semantic import ( + JSONLDValidator, + validate_jsonld, +) from dppvalidator.validators.model import ModelValidator from dppvalidator.validators.protocols import AsyncValidator, SemanticRule, Validator from dppvalidator.validators.results import ( @@ -8,10 +18,28 @@ ValidationException, ValidationResult, ) -from dppvalidator.validators.schema import SchemaValidator +from dppvalidator.validators.schema import SchemaType, SchemaValidator from dppvalidator.validators.semantic import SemanticValidator +from dppvalidator.validators.shacl import ( + CIRPASS_SHAPES, + OfficialSHACLLoader, + RDFSHACLValidator, + SHACLNodeShape, + SHACLPropertyShape, + SHACLSeverity, + SHACLValidationResult, + SHACLValidator, + get_cirpass_shapes, + is_shacl_validation_available, + load_official_shacl_shapes, + validate_jsonld_with_official_shacl, + validate_with_shacl, +) __all__ = [ + # Detection + "detect_schema_version", + "is_dpp_document", # Engine "ValidationEngine", # Results @@ -19,11 +47,33 @@ "ValidationError", "ValidationException", # Validators + "SchemaType", "SchemaValidator", "ModelValidator", "SemanticValidator", + "JSONLDValidator", + "validate_jsonld", + # Deep validation + "DeepValidator", + "DeepValidationResult", + "validate_deep", # Protocols "Validator", "AsyncValidator", "SemanticRule", + # SHACL validation + "SHACLValidator", + "SHACLValidationResult", + "SHACLNodeShape", + "SHACLPropertyShape", + "SHACLSeverity", + "CIRPASS_SHAPES", + "get_cirpass_shapes", + "validate_with_shacl", + # Official SHACL (Phase 8) + "OfficialSHACLLoader", + "RDFSHACLValidator", + "is_shacl_validation_available", + "load_official_shacl_shapes", + "validate_jsonld_with_official_shacl", ] diff --git a/src/dppvalidator/validators/deep.py b/src/dppvalidator/validators/deep.py new file mode 100644 index 0000000..1b5860d --- /dev/null +++ b/src/dppvalidator/validators/deep.py @@ -0,0 +1,494 @@ +"""Deep/Recursive validation for Digital Product Passports. + +This module provides async crawling and validation of linked DPP documents, +enabling full supply chain traceability validation. +""" + +from __future__ import annotations + +import asyncio +import time +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import urlparse + +import httpx + +from dppvalidator.logging import get_logger +from dppvalidator.schemas.registry import DEFAULT_SCHEMA_VERSION +from dppvalidator.validators.results import ValidationError, ValidationResult + +logger = get_logger(__name__) + + +# Per-version JSON paths the deep crawler follows when looking for linked +# documents. Each entry is a ``credentialSubject``-rooted dotted path; the +# crawler walks the parsed payload and dereferences any URL it finds at +# those paths. Lists in the path use the ``[*]`` syntax to mean "every +# entry" (handled in :class:`DeepValidator._extract_links`). +# +# Adding a new UNTP version means adding one entry here — see Phase 3b of +# docs/plans/UNTP_0.7.0_MIGRATION.md and the cardinal rule in +# .claude/rules/untp-versioning.md (rule 2: "one source of truth per +# surface"). +LINK_PATHS_BY_VERSION: dict[str, list[str]] = { + "0.6.0": [ + "credentialSubject.traceabilityEvents", + "credentialSubject.conformityClaim", + "credentialSubject.product.traceabilityInfo", + "credentialSubject.materialsProvenance", + ], + "0.6.1": [ + "credentialSubject.traceabilityEvents", + "credentialSubject.conformityClaim", + "credentialSubject.product.traceabilityInfo", + "credentialSubject.materialsProvenance", + ], + "0.7.0": [ + # v0.7.0 envelope: credentialSubject IS the Product, conformity + # claims live on it directly, and material provenance is the + # singular noun. ``[*]`` means "iterate every list element". + "credentialSubject.evidence", + "credentialSubject.relatedDocument", + "credentialSubject.performanceClaim[*].evidence", + "credentialSubject.materialProvenance[*].materialSafetyInformation", + "credentialSubject.relatedParty[*].party.id", + ], +} + +# Backward-compat alias. Pre-Phase-3b callers that imported +# ``DEFAULT_LINK_PATHS`` see the v0.6.x list — same value the constant +# carried before the version-keyed dispatch landed. New code should use +# :data:`LINK_PATHS_BY_VERSION` keyed on the active schema version. +DEFAULT_LINK_PATHS = LINK_PATHS_BY_VERSION["0.6.1"] + + +@dataclass +class LinkInfo: + """Information about a discovered link.""" + + url: str + path: str + depth: int + parent_url: str | None = None + + +@dataclass +class DeepValidationResult: + """Result of deep/recursive validation. + + Attributes: + root_result: Validation result for the root document + link_graph: Map of URL to ValidationResult for all fetched documents + visited_urls: Set of all visited URLs + failed_urls: Map of URL to error message for failed fetches + total_documents: Total number of documents processed + max_depth_reached: Maximum depth reached during traversal + cycle_detected: Whether any cycles were detected + elapsed_time_ms: Total time for deep validation + + """ + + root_result: ValidationResult + link_graph: dict[str, ValidationResult] = field(default_factory=dict) + visited_urls: set[str] = field(default_factory=set) + failed_urls: dict[str, str] = field(default_factory=dict) + total_documents: int = 1 + max_depth_reached: int = 0 + cycle_detected: bool = False + elapsed_time_ms: float = 0.0 + + @property + def valid(self) -> bool: + """Check if all validated documents are valid.""" + if not self.root_result.valid: + return False + return all(r.valid for r in self.link_graph.values()) + + @property + def all_errors(self) -> list[tuple[str, ValidationError]]: + """Get all errors from all documents with their source URL.""" + errors = [("root", e) for e in self.root_result.errors] + for url, result in self.link_graph.items(): + errors.extend((url, e) for e in result.errors) + return errors + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + "valid": self.valid, + "root_result": self.root_result.to_dict(), + "link_graph": {url: r.to_dict() for url, r in self.link_graph.items()}, + "visited_urls": list(self.visited_urls), + "failed_urls": self.failed_urls, + "total_documents": self.total_documents, + "max_depth_reached": self.max_depth_reached, + "cycle_detected": self.cycle_detected, + "elapsed_time_ms": self.elapsed_time_ms, + } + + +@dataclass +class RateLimiter: + """Simple rate limiter for HTTP requests.""" + + requests_per_second: float = 10.0 + _last_request_time: float = field(default=0.0, init=False) + _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False) + + async def acquire(self) -> None: + """Wait until a request can be made.""" + async with self._lock: + now = time.monotonic() + min_interval = 1.0 / self.requests_per_second + elapsed = now - self._last_request_time + + if elapsed < min_interval: + await asyncio.sleep(min_interval - elapsed) + + self._last_request_time = time.monotonic() + + +@dataclass +class RetryConfig: + """Configuration for retry logic.""" + + max_retries: int = 3 + base_delay: float = 1.0 + max_delay: float = 30.0 + exponential_base: float = 2.0 + + def get_delay(self, attempt: int) -> float: + """Calculate delay for a given attempt number.""" + delay = self.base_delay * (self.exponential_base**attempt) + return min(delay, self.max_delay) + + +class DeepValidator: + """Async crawler for deep/recursive DPP validation. + + Follows links in DPP documents and validates each linked document, + building a complete validation graph of the supply chain. + """ + + def __init__( + self, + validator_factory: Callable[[], Any] | None = None, + max_depth: int = 3, + follow_links: list[str] | None = None, + rate_limiter: RateLimiter | None = None, + retry_config: RetryConfig | None = None, + timeout: float = 30.0, + auth_header: dict[str, str] | None = None, + schema_version: str = DEFAULT_SCHEMA_VERSION, + ) -> None: + """Initialize the deep validator. + + Args: + validator_factory: Factory to create ValidationEngine instances + max_depth: Maximum depth to traverse (0 = root only) + follow_links: JSON paths to follow for links. When ``None``, + the path list is selected from :data:`LINK_PATHS_BY_VERSION` + keyed on ``schema_version``. The v0.7.0 paths + (``performanceClaim[*].evidence``, ``relatedDocument``, + ``materialProvenance[*].materialSafetyInformation``, + ``relatedParty[*].party.id``, ``evidence``) differ + substantially from the v0.6.x set — see Phase 3b of + docs/plans/UNTP_0.7.0_MIGRATION.md. + rate_limiter: Rate limiter for HTTP requests + retry_config: Retry configuration for failed requests + timeout: HTTP request timeout in seconds + auth_header: Authorization headers for requests + schema_version: UNTP DPP version. Drives the default + ``follow_links`` selection from + :data:`LINK_PATHS_BY_VERSION`. Ignored when + ``follow_links`` is supplied explicitly. + + """ + self._validator_factory = validator_factory + self.max_depth = max_depth + self.schema_version = schema_version + if follow_links is not None: + self.follow_links = follow_links + else: + self.follow_links = LINK_PATHS_BY_VERSION.get(schema_version, DEFAULT_LINK_PATHS) + self.rate_limiter = rate_limiter or RateLimiter() + self.retry_config = retry_config or RetryConfig() + self.timeout = timeout + self.auth_header = auth_header or {} + + # State for current validation run + self._visited: set[str] = set() + self._pending: asyncio.Queue[LinkInfo] = asyncio.Queue() + self._results: dict[str, ValidationResult] = {} + self._failed: dict[str, str] = {} + self._cycle_detected = False + self._max_depth_reached = 0 + + async def validate( + self, + root_data: dict[str, Any], + root_url: str | None = None, + ) -> DeepValidationResult: + """Perform deep validation starting from root document. + + Args: + root_data: The root DPP document data + root_url: Optional URL of the root document (for cycle detection) + + Returns: + DeepValidationResult with all validation results + + """ + start_time = time.monotonic() + + # Reset state + self._visited = set() + self._results = {} + self._failed = {} + self._cycle_detected = False + self._max_depth_reached = 0 + + # Create validator + if self._validator_factory: + validator = self._validator_factory() + else: + from dppvalidator.validators.engine import ValidationEngine + + validator = ValidationEngine() + + # Validate root document + root_result = validator.validate(root_data) + + # Mark root as visited + if root_url: + self._visited.add(root_url) + + # Extract and queue links from root + links = self._extract_links(root_data, depth=0, parent_url=root_url) + for link in links: + if link.url not in self._visited: + await self._pending.put(link) + + # Process queue + await self._process_queue(validator) + + elapsed = (time.monotonic() - start_time) * 1000 + + return DeepValidationResult( + root_result=root_result, + link_graph=self._results, + visited_urls=self._visited, + failed_urls=self._failed, + total_documents=1 + len(self._results), + max_depth_reached=self._max_depth_reached, + cycle_detected=self._cycle_detected, + elapsed_time_ms=elapsed, + ) + + async def _process_queue(self, validator: Any) -> None: + """Process the pending links queue.""" + async with httpx.AsyncClient(timeout=self.timeout) as client: + while not self._pending.empty(): + link = await self._pending.get() + + # Check depth limit + if link.depth > self.max_depth: + continue + + # Check for cycles + if link.url in self._visited: + self._cycle_detected = True + continue + + # Track max depth + self._max_depth_reached = max(self._max_depth_reached, link.depth) + + # Mark as visited + self._visited.add(link.url) + + # Fetch and validate + try: + data = await self._fetch_with_retry(client, link.url) + if data: + result = validator.validate(data) + self._results[link.url] = result + + # Extract and queue more links + new_links = self._extract_links( + data, + depth=link.depth + 1, + parent_url=link.url, + ) + for new_link in new_links: + if new_link.url not in self._visited: + await self._pending.put(new_link) + + except Exception as e: + self._failed[link.url] = str(e) + logger.warning("Failed to process %s: %s", link.url, e) + + async def _fetch_with_retry( + self, + client: Any, + url: str, + ) -> dict[str, Any] | None: + """Fetch a URL with rate limiting and retries.""" + for attempt in range(self.retry_config.max_retries): + try: + # Rate limit + await self.rate_limiter.acquire() + + # Make request + headers = {"Accept": "application/json", **self.auth_header} + response = await client.get(url, headers=headers) + response.raise_for_status() + + return response.json() + + except httpx.HTTPStatusError as e: + if e.response.status_code in (429, 503): + # Rate limited or service unavailable - retry + delay = self.retry_config.get_delay(attempt) + logger.debug("Rate limited, retrying in %.1fs", delay) + await asyncio.sleep(delay) + else: + # Other HTTP error - don't retry + raise + + except httpx.RequestError: + # Network error - retry + delay = self.retry_config.get_delay(attempt) + await asyncio.sleep(delay) + + return None + + def _extract_links( + self, + data: dict[str, Any], + depth: int, + parent_url: str | None = None, + ) -> list[LinkInfo]: + """Extract links from a DPP document based on configured paths.""" + links = [] + + for path in self.follow_links: + urls = self._get_urls_at_path(data, path) + for url in urls: + if self._is_valid_url(url): + links.append( + LinkInfo( + url=url, + path=path, + depth=depth + 1, + parent_url=parent_url, + ) + ) + + return links + + def _get_urls_at_path(self, data: dict[str, Any], path: str) -> list[str]: + """Get URLs from a JSON path in the data. + + Supports two syntactic forms for list traversal: + + - **Implicit:** ``credentialSubject.materialsProvenance.name`` — + when the resolver hits a list, it collects ``name`` from every + item. This is the v0.6.x convention. + - **Explicit:** ``credentialSubject.performanceClaim[*].evidence`` + — the ``[*]`` token is normalised away (v0.7.0 paths use this + form for clarity). + + Both forms produce the same result; ``[*]`` is purely a readability + marker for the v0.7 path table in :data:`LINK_PATHS_BY_VERSION`. + """ + urls = [] + # Normalise ``segment[*]`` → ``segment`` so the resolver below + # doesn't have to know about the explicit list-iteration token. + parts = [segment.replace("[*]", "") for segment in path.split(".")] + current: Any = data + + for part in parts: + if current is None: + break + if isinstance(current, dict): + current = current.get(part) + elif isinstance(current, list): + # Handle arrays - collect from all items + results = [] + for item in current: + if isinstance(item, dict): + val = item.get(part) + if val is not None: + results.append(val) + current = results if results else None + else: + current = None + + # Extract URLs from the final value + if current is not None: + urls.extend(self._extract_urls_from_value(current)) + + return urls + + def _extract_urls_from_value(self, value: Any) -> list[str]: + """Extract URLs from a value (string, dict, or list).""" + urls = [] + + if isinstance(value, str): + if self._is_valid_url(value): + urls.append(value) + elif isinstance(value, dict): + # Check common URL fields + for key in ("id", "url", "href", "linkURL", "linkUrl"): + if key in value and isinstance(value[key], str) and self._is_valid_url(value[key]): + urls.append(value[key]) + break + elif isinstance(value, list): + for item in value: + urls.extend(self._extract_urls_from_value(item)) + + return urls + + def _is_valid_url(self, url: str) -> bool: + """Check if a string is a valid HTTP(S) URL.""" + try: + parsed = urlparse(url) + return parsed.scheme in ("http", "https") and bool(parsed.netloc) + except Exception: + return False + + +async def validate_deep( + data: dict[str, Any], + max_depth: int = 3, + follow_links: list[str] | None = None, + timeout: float = 30.0, + auth_header: dict[str, str] | None = None, + schema_version: str = DEFAULT_SCHEMA_VERSION, +) -> DeepValidationResult: + """Perform deep validation with default settings. + + Args: + data: Root DPP document data + max_depth: Maximum depth to traverse + follow_links: JSON paths to follow for links. When ``None``, + picked from :data:`LINK_PATHS_BY_VERSION` based on + ``schema_version`` (Phase 3b). + schema_version: UNTP DPP version. Selects the default link paths. + timeout: HTTP request timeout + auth_header: Authorization headers + + Returns: + DeepValidationResult + + """ + validator = DeepValidator( + max_depth=max_depth, + follow_links=follow_links, + timeout=timeout, + auth_header=auth_header, + schema_version=schema_version, + ) + return await validator.validate(data) diff --git a/src/dppvalidator/validators/detection.py b/src/dppvalidator/validators/detection.py new file mode 100644 index 0000000..47621f5 --- /dev/null +++ b/src/dppvalidator/validators/detection.py @@ -0,0 +1,199 @@ +"""Schema version auto-detection from DPP data.""" + +from __future__ import annotations + +import re +from typing import Any + +from dppvalidator.schemas.registry import DEFAULT_SCHEMA_VERSION, SCHEMA_REGISTRY + +# Patterns for extracting version from URLs. +# +# Two URL conventions are recognised, in priority order: +# 1. Legacy (UNTP 0.6.x): schema basename `untp-dpp-schema-X.Y.Z.json` and +# context `/untp/dpp/X.Y.Z/` under `test.uncefact.org`. +# 2. Modern (UNTP 0.7.0+): schema lives at any path containing the version +# as a `/X.Y.Z/` or `/vX.Y.Z/` segment followed by a credential-type +# basename (`DigitalProductPassport.json`), and the context lives at +# `/untp/X.Y.Z/context/?` under `vocabulary.uncefact.org`. +# +# False positives are guarded by the `version in SCHEMA_REGISTRY` membership +# check at the call sites, so unrelated `/X.Y.Z/` path segments cannot smuggle +# in unsupported versions. Adding a new URL convention means appending a +# pattern here — see docs/plans/UNTP_0.7.0_MIGRATION.md §Phase 1 and §7. +_SCHEMA_URL_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile(r"untp-dpp-schema-(\d+\.\d+\.\d+)\.json"), + re.compile(r"/v?(\d+\.\d+\.\d+)/[^?#]*DigitalProductPassport[^?#]*\.json"), +) +_CONTEXT_URL_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile(r"/untp/dpp/(\d+\.\d+\.\d+)/?"), + re.compile(r"/untp/(\d+\.\d+\.\d+)/(?:context/?)?"), +) + +# Expected types for UNTP DPP +_DPP_TYPES = frozenset({"DigitalProductPassport", "VerifiableCredential"}) + + +def detect_schema_version(data: dict[str, Any]) -> str: + """Detect UNTP DPP schema version from data. + + Detection priority: + 1. `$schema` URL (explicit schema reference) + 2. `@context` URLs (JSON-LD context with version) + 3. `type` array (confirms DPP, uses default version) + 4. Falls back to default version + + Args: + data: Raw DPP JSON data + + Returns: + Detected schema version string (one of `SCHEMA_REGISTRY.keys()`). + Falls back to ``DEFAULT_SCHEMA_VERSION`` when no marker is present. + """ + # Priority 1: Check $schema URL + version = _detect_from_schema_url(data) + if version: + return version + + # Priority 2: Check @context URLs + version = _detect_from_context(data) + if version: + return version + + # Priority 3: Check type array for DPP marker, use default + if _is_dpp_type(data): + return DEFAULT_SCHEMA_VERSION + + # Fallback to default + return DEFAULT_SCHEMA_VERSION + + +def detect_declared_version(data: dict[str, Any]) -> str | None: + """Return the version a payload **explicitly declares**, or ``None``. + + Distinct from :func:`detect_schema_version`: this helper returns + ``None`` when no UNTP-versioned ``$schema`` URL or UNTP-versioned + ``@context`` URL is present, instead of falling back to a default. The + engine uses this for the VER001 mismatch check (see Phase 3.3 of + docs/plans/UNTP_0.7.0_MIGRATION.md): if a payload declares a version + that conflicts with the engine's explicitly-configured version, fail + fast — but if the payload declares no version at all, trust the + user's configuration without complaint. + + Args: + data: Raw DPP JSON data + + Returns: + The declared version (one of ``SCHEMA_REGISTRY.keys()``), or + ``None`` when the payload carries no version markers. + """ + return _detect_from_schema_url(data) or _detect_from_context(data) + + +def _detect_from_schema_url(data: dict[str, Any]) -> str | None: + """Extract version from $schema URL. + + Args: + data: Raw DPP JSON data + + Returns: + Version string or None if not found/invalid + """ + schema_url = data.get("$schema") + if not isinstance(schema_url, str): + return None + + for pattern in _SCHEMA_URL_PATTERNS: + match = pattern.search(schema_url) + if match and match.group(1) in SCHEMA_REGISTRY: + return match.group(1) + + return None + + +def _detect_from_context(data: dict[str, Any]) -> str | None: + """Extract version from @context URLs. + + Args: + data: Raw DPP JSON data + + Returns: + Version string or None if not found/invalid + """ + context = data.get("@context") + if context is None: + return None + + # Normalize to list + if isinstance(context, str): + urls = [context] + elif isinstance(context, list): + urls = [u for u in context if isinstance(u, str)] + else: + return None + + # Search for version pattern in any context URL + for url in urls: + for pattern in _CONTEXT_URL_PATTERNS: + match = pattern.search(url) + if match and match.group(1) in SCHEMA_REGISTRY: + return match.group(1) + + return None + + +def _is_dpp_type(data: dict[str, Any]) -> bool: + """Check if data has DigitalProductPassport type. + + Args: + data: Raw DPP JSON data + + Returns: + True if type array contains DigitalProductPassport + """ + type_value = data.get("type") + if type_value is None: + return False + + if isinstance(type_value, str): + return type_value in _DPP_TYPES + + if isinstance(type_value, list): + return any(t in _DPP_TYPES for t in type_value if isinstance(t, str)) + + return False + + +def is_dpp_document(data: dict[str, Any]) -> bool: + """Check if data appears to be a Digital Product Passport. + + Checks for presence of: + - `type` containing "DigitalProductPassport" + - `@context` with UNTP vocabulary + - `credentialSubject` field + + Args: + data: Raw JSON data + + Returns: + True if data appears to be a DPP + """ + if not isinstance(data, dict): + return False + + # Check for DPP type + if _is_dpp_type(data): + return True + + # Check for credentialSubject (VC structure) + if "credentialSubject" in data: + return True + + # Check for UNTP context + context = data.get("@context") + if context: + context_str = str(context) + if "untp" in context_str.lower() or "uncefact" in context_str.lower(): + return True + + return False diff --git a/src/dppvalidator/validators/engine.py b/src/dppvalidator/validators/engine.py index 8f71ea1..775e5a2 100644 --- a/src/dppvalidator/validators/engine.py +++ b/src/dppvalidator/validators/engine.py @@ -1,4 +1,14 @@ -"""Three-layer DPP validation engine.""" +"""Multi-layer DPP validation engine. + +Implements seven validation layers: +1. Schema: JSON Schema validation (Draft 2020-12) +2. Model: Pydantic model validation +3. Semantic: Business rule validation +4. JSON-LD: Context expansion validation +5. Vocabulary: External vocabulary validation +6. Plugin: Custom validator plugins +7. Signature: Verifiable Credential verification +""" from __future__ import annotations @@ -9,24 +19,65 @@ from typing import TYPE_CHECKING, Any, Literal from dppvalidator.logging import get_logger +from dppvalidator.validators.detection import ( + detect_declared_version, + detect_schema_version, +) +from dppvalidator.validators.layers import ( + JsonLdLayer, + ModelLayer, + PluginLayer, + SchemaLayer, + SemanticLayer, + SignatureLayer, + ValidationContext, + ValidationLayer, + VocabularyLayer, +) from dppvalidator.validators.model import ModelValidator from dppvalidator.validators.results import ValidationError, ValidationResult from dppvalidator.validators.schema import SchemaValidator from dppvalidator.validators.semantic import SemanticValidator +from dppvalidator.vocabularies.rdf_loader import is_shacl_available if TYPE_CHECKING: - from dppvalidator.models.passport import DigitalProductPassport + from dppvalidator.validators.deep import DeepValidationResult + + +def _is_jsonld_available() -> bool: + """Check if JSON-LD dependencies (pyld) are available.""" + try: + import pyld # noqa: F401 + + return True + except ImportError: + return False + + +def _is_crypto_available() -> bool: + """Check if cryptography dependencies are available for signature verification.""" + try: + import cryptography # noqa: F401 + + return True + except ImportError: + return False + logger = get_logger(__name__) class ValidationEngine: - """Three-layer validation engine for Digital Product Passports. + """Seven-layer validation engine for Digital Product Passports. - Provides configurable validation through three layers: + Provides configurable validation through seven layers: 1. Schema validation (JSON Schema Draft 2020-12) 2. Model validation (Pydantic v2) 3. Semantic validation (Business rules) + 4. JSON-LD validation (Context expansion and term resolution) + 5. Vocabulary validation (External code lists and ontologies) + 6. Plugin validation (Custom validator plugins) + 7. Signature verification (Verifiable Credential proofs) Following the Result pattern, validation never raises exceptions. Check `result.valid` and inspect `result.errors` for details. @@ -37,47 +88,129 @@ class ValidationEngine: def __init__( self, - schema_version: str = "0.6.1", + schema_version: str = "auto", strict_mode: bool = False, validate_vocabularies: bool = False, - layers: list[Literal["schema", "model", "semantic"]] | None = None, + layers: list[Literal["schema", "model", "semantic", "jsonld"]] | None = None, + validate_jsonld: bool = False, + verify_signatures: bool = False, load_plugins: bool = True, max_input_size: int | None = None, + enable_shacl: bool = False, ) -> None: """Initialize the validation engine. Args: - schema_version: UNTP DPP schema version to validate against + schema_version: UNTP DPP schema version to validate against. + Use "auto" to detect version from input data (default). strict_mode: If True, enables strict JSON Schema validation validate_vocabularies: If True, validates external vocabulary values layers: Specific layers to run. None means all layers. + validate_jsonld: If True, enables JSON-LD semantic validation (requires pyld) + verify_signatures: If True, verifies VC signatures (requires cryptography) load_plugins: If True, discovers and loads plugin validators max_input_size: Maximum input size in bytes. None uses default (10MB). Set to 0 to disable size limits. + enable_shacl: If True, enables SHACL validation against official + CIRPASS-2 shapes. Requires: uv add dppvalidator[rdf] or + pip install dppvalidator[rdf] + + Raises: + ImportError: If optional features are enabled but dependencies not installed: + - enable_shacl=True requires [rdf] extra + - validate_jsonld=True requires pyld (included in base install) + - verify_signatures=True requires cryptography (included in base install) """ + # Explicit feature detection for optional dependencies + if enable_shacl and not is_shacl_available(): + raise ImportError( + "SHACL validation requires the [rdf] extra. " + "Install with: uv add dppvalidator[rdf] or pip install dppvalidator[rdf]" + ) + + if validate_jsonld and not _is_jsonld_available(): + raise ImportError( + "JSON-LD validation requires pyld. Install with: uv add pyld or pip install pyld" + ) + + if verify_signatures and not _is_crypto_available(): + raise ImportError( + "Signature verification requires cryptography. " + "Install with: uv add cryptography or pip install cryptography" + ) + self._auto_detect = schema_version == "auto" self.schema_version = schema_version self.strict_mode = strict_mode self.validate_vocabularies = validate_vocabularies + self.validate_jsonld = validate_jsonld + self.verify_signatures = verify_signatures + self.enable_shacl = enable_shacl self.layers = layers or ["schema", "model", "semantic"] self._load_plugins = load_plugins self.max_input_size = ( max_input_size if max_input_size is not None else self.DEFAULT_MAX_INPUT_SIZE ) - self._schema_validator = SchemaValidator(schema_version, strict=strict_mode) - self._model_validator = ModelValidator(schema_version) - self._semantic_validator = SemanticValidator(schema_version) + # Defer validator initialization if auto-detecting + self._schema_validator: SchemaValidator | None = None + self._model_validator: ModelValidator | None = None + self._semantic_validator: SemanticValidator | None = None + self._jsonld_validator: Any = None # JSONLDValidator (optional) + self._credential_verifier: Any = None # CredentialVerifier (optional) + + if not self._auto_detect: + self._init_validators(schema_version) # Initialize vocabulary loader if needed self._vocab_loader = None if validate_vocabularies: self._init_vocabulary_loader() + # Initialize credential verifier if needed + if verify_signatures: + self._init_credential_verifier() + # Initialize plugin registry if needed self._plugin_registry = None if load_plugins: self._init_plugin_registry() + def _init_validators(self, version: str) -> None: + """Initialize validators for a specific schema version. + + Args: + version: Schema version string + + """ + self._schema_validator = SchemaValidator(version, strict=self.strict_mode) + self._model_validator = ModelValidator(version) + self._semantic_validator = SemanticValidator(version) + + # Initialize JSON-LD validator if enabled + if self.validate_jsonld or "jsonld" in self.layers: + self._init_jsonld_validator(version) + + def _init_jsonld_validator(self, version: str) -> None: + """Initialize JSON-LD semantic validator. + + Args: + version: Schema version string + + """ + try: + from dppvalidator.validators.jsonld_semantic import JSONLDValidator + + self._jsonld_validator = JSONLDValidator( + schema_version=version, + strict=self.strict_mode, + ) + logger.debug("JSON-LD validator initialized") + except ImportError: + logger.warning( + "pyld import failed - JSON-LD validation disabled. " + "Try: pip install --force-reinstall dppvalidator" + ) + def _init_vocabulary_loader(self) -> None: """Initialize the vocabulary loader for external vocabulary validation.""" try: @@ -88,12 +221,30 @@ def _init_vocabulary_loader(self) -> None: except ImportError: logger.warning("Vocabulary loader not available") + def _init_credential_verifier(self) -> None: + """Initialize the credential verifier for VC signature verification.""" + try: + from dppvalidator.verifier.verifier import CredentialVerifier + + self._credential_verifier = CredentialVerifier() + logger.debug("Credential verifier initialized") + except ImportError: + logger.warning( + "cryptography import failed - signature verification disabled. " + "Try: pip install --force-reinstall dppvalidator" + ) + def _init_plugin_registry(self) -> None: - """Initialize the plugin registry for plugin validators.""" + """Initialize the plugin registry for plugin validators. + + Uses the singleton registry via get_default_registry() to avoid + redundant plugin discovery when multiple ValidationEngine instances + are created. + """ try: - from dppvalidator.plugins.registry import PluginRegistry + from dppvalidator.plugins.registry import get_default_registry - self._plugin_registry = PluginRegistry(auto_discover=True) + self._plugin_registry = get_default_registry() logger.debug( "Plugin registry initialized with %d validators", self._plugin_registry.validator_count, @@ -117,6 +268,7 @@ def validate( Returns: ValidationResult with parsed passport if valid + """ start_time = time.perf_counter() @@ -124,130 +276,104 @@ def validate( if isinstance(parsed_data, ValidationResult): return parsed_data + # Auto-detect schema version if enabled + effective_version = self.schema_version + if self._auto_detect: + effective_version = detect_schema_version(parsed_data) + self._init_validators(effective_version) + logger.debug("Auto-detected schema version: %s", effective_version) + parse_time = (time.perf_counter() - start_time) * 1000 - result = ValidationResult( - valid=True, - schema_version=self.schema_version, - parse_time_ms=parse_time, + context = ValidationContext( + parsed_data=parsed_data, + schema_version=effective_version, + strict_mode=self.strict_mode, + fail_fast=fail_fast, + max_errors=max_errors, ) - - passport: DigitalProductPassport | None = None + context.result.parse_time_ms = parse_time + + # VER001: when the user explicitly configured a version (NOT + # auto-detect) and the payload itself declares a different version + # via ``$schema`` or ``@context`` URLs, fail fast. UNTPBaseModel + # has ``extra="allow"``, so without this check a mis-versioned + # payload would silently lose fields. See plan §4.1.7. + if not self._auto_detect: + declared = detect_declared_version(parsed_data) + if declared is not None and declared != effective_version: + context.result.errors.append( + ValidationError( + path="$", + message=( + f"Payload declares UNTP version {declared!r} but the engine is " + f"configured for {effective_version!r}. Re-run with " + f"`schema_version={declared!r}` (or `schema_version='auto'`) " + "to validate against the version the payload actually claims." + ), + code="VER001", + layer="engine", + severity="error", + context={ + "declared_version": declared, + "configured_version": effective_version, + }, + ), + ) + context.result.valid = False + context.result.schema_version = effective_version + return context.result + + # Build and execute validation layers + validation_layers = self._build_layers(effective_version) + for layer in validation_layers: + if layer.should_run(context): + layer_result = layer.execute(context) + context.merge_result(layer_result) + self._apply_signature_fields(context.result, layer_result, layer) + if context.should_stop(): + break + + context.result.passport = context.passport + return context.result + + def _build_layers(self, schema_version: str) -> list[ValidationLayer]: + """Build the ordered list of validation layers based on configuration.""" + layers: list[ValidationLayer] = [] if "schema" in self.layers: - schema_result = self._schema_validator.validate(parsed_data) - result = result.merge(schema_result) - if fail_fast and not result.valid: - return result - if result.error_count >= max_errors: - return result + layers.append(SchemaLayer(self._schema_validator)) if "model" in self.layers: - model_result = self._model_validator.validate(parsed_data) - result = result.merge(model_result) - passport = model_result.passport - if fail_fast and not result.valid: - return result - if result.error_count >= max_errors: - return result - - if "semantic" in self.layers and passport: - semantic_result = self._semantic_validator.validate(passport) - result = result.merge(semantic_result) - - # Run vocabulary validation if enabled - if self.validate_vocabularies and passport: - vocab_result = self._validate_vocabularies(passport) - result = result.merge(vocab_result) - - # Run plugin validators if enabled - if self._load_plugins and self._plugin_registry and passport: - plugin_result = self._run_plugin_validators(passport) - result = result.merge(plugin_result) - - result.passport = passport - return result - - def _validate_vocabularies(self, passport: DigitalProductPassport) -> ValidationResult: - """Validate vocabulary values in the passport. - - Args: - passport: Parsed passport to validate - - Returns: - ValidationResult with vocabulary violations - """ - if not self._vocab_loader: - return ValidationResult(valid=True, schema_version=self.schema_version) - - warnings: list[ValidationError] = [] - - # Validate country codes in materials provenance - if passport.credential_subject and passport.credential_subject.materials_provenance: - for i, material in enumerate(passport.credential_subject.materials_provenance): - origin = getattr(material, "origin_country", None) - if origin and not self._vocab_loader.is_valid_country(origin): - warnings.append( - ValidationError( - path=f"$.credentialSubject.materialsProvenance[{i}].originCountry", - message=f"Invalid country code: '{material.origin_country}'", - code="VOC001", - layer="vocabulary", - severity="warning", - suggestion="Use ISO 3166-1 alpha-2 country codes", - ) - ) - - # Validate unit codes in measures (dimensions, emissions, etc.) - if passport.credential_subject and passport.credential_subject.product: - product = passport.credential_subject.product - dims = getattr(product, "dimensions", None) - if dims: - for field_name in ["weight", "length", "width", "height", "volume"]: - measure = getattr(dims, field_name, None) - unit = getattr(measure, "unit", None) if measure else None - if unit and not self._vocab_loader.is_valid_unit(unit): - warnings.append( - ValidationError( - path=f"$.credentialSubject.product.dimensions.{field_name}.unit", - message=f"Invalid unit code: '{unit}'", - code="VOC002", - layer="vocabulary", - severity="warning", - suggestion="Use UNECE Rec20 unit codes", - ) - ) + layers.append(ModelLayer(self._model_validator)) - return ValidationResult( - valid=True, # Vocabulary issues are warnings, not errors - warnings=warnings, - schema_version=self.schema_version, - ) + if "semantic" in self.layers: + layers.append(SemanticLayer(self._semantic_validator)) - def _run_plugin_validators(self, passport: DigitalProductPassport) -> ValidationResult: - """Run all registered plugin validators. + if "jsonld" in self.layers or self.validate_jsonld: + layers.append(JsonLdLayer(self._jsonld_validator)) - Args: - passport: Parsed passport to validate + if self.validate_vocabularies: + layers.append(VocabularyLayer(self._vocab_loader, schema_version)) - Returns: - ValidationResult with plugin validation results - """ - if not self._plugin_registry: - return ValidationResult(valid=True, schema_version=self.schema_version) + if self._load_plugins: + layers.append(PluginLayer(self._plugin_registry, schema_version)) - plugin_errors = self._plugin_registry.run_all_validators(passport) + if self.verify_signatures: + layers.append(SignatureLayer(self._credential_verifier, schema_version)) - errors = [e for e in plugin_errors if e.severity == "error"] - warnings = [e for e in plugin_errors if e.severity == "warning"] - info = [e for e in plugin_errors if e.severity == "info"] + return layers - return ValidationResult( - valid=len(errors) == 0, - errors=errors, - warnings=warnings, - info=info, - schema_version=self.schema_version, - ) + def _apply_signature_fields( + self, + result: ValidationResult, + layer_result: ValidationResult, + layer: ValidationLayer, + ) -> None: + """Copy signature verification fields from SignatureLayer result.""" + if layer.name == "signature": + result.signature_valid = layer_result.signature_valid + result.issuer_did = layer_result.issuer_did + result.verification_method = layer_result.verification_method def validate_file(self, path: Path | str) -> ValidationResult: """Validate a JSON file. @@ -257,17 +383,32 @@ def validate_file(self, path: Path | str) -> ValidationResult: Returns: ValidationResult + """ return self.validate(Path(path)) async def validate_async(self, data: dict[str, Any]) -> ValidationResult: - """Validate data asynchronously. + """Validate data asynchronously (thread-offloaded). + + This method wraps the synchronous `validate()` method using + `asyncio.to_thread()` to avoid blocking the event loop. Use this + for integrating with async frameworks like FastAPI or aiohttp. + + Note: + For natively async operations (network I/O), use `validate_deep()` + which uses httpx.AsyncClient for non-blocking HTTP requests. Args: data: Raw JSON dict Returns: ValidationResult + + Example: + >>> async def handler(request): + ... result = await engine.validate_async(request.json()) + ... return {"valid": result.valid} + """ return await asyncio.to_thread(self.validate, data) @@ -285,6 +426,7 @@ async def validate_batch( Returns: List of ValidationResults in same order as input + """ semaphore = asyncio.Semaphore(concurrency) @@ -294,11 +436,59 @@ async def validate_with_semaphore(item: dict[str, Any]) -> ValidationResult: return await asyncio.gather(*[validate_with_semaphore(item) for item in items]) + async def validate_deep( + self, + data: dict[str, Any], + *, + max_depth: int = 3, + follow_links: list[str] | None = None, + timeout: float = 30.0, + auth_header: dict[str, str] | None = None, + ) -> DeepValidationResult: + """Perform deep/recursive validation following linked documents. + + Crawls the supply chain by following links in the DPP and validates + each linked document, building a complete validation graph. + + Args: + data: Root DPP document data + max_depth: Maximum depth to traverse (0 = root only) + follow_links: JSON paths to follow for links (uses defaults if None) + timeout: HTTP request timeout in seconds + auth_header: Authorization headers for authenticated requests + + Returns: + DeepValidationResult with all validation results and link graph + + """ + from dppvalidator.validators.deep import DeepValidator + + def validator_factory() -> ValidationEngine: + return ValidationEngine( + schema_version=self.schema_version, + strict_mode=self.strict_mode, + validate_vocabularies=self.validate_vocabularies, + validate_jsonld=self.validate_jsonld, + verify_signatures=self.verify_signatures, + load_plugins=self._load_plugins, + ) + + deep_validator = DeepValidator( + validator_factory=validator_factory, + max_depth=max_depth, + follow_links=follow_links, + timeout=timeout, + auth_header=auth_header, + ) + + return await deep_validator.validate(data) + def _parse_input(self, data: dict[str, Any] | str | Path) -> dict[str, Any] | ValidationResult: """Parse input data to dict. Returns: Parsed dict or ValidationResult with parse error + """ # Check input size for string inputs (DoS protection) if ( @@ -329,7 +519,31 @@ def _parse_input(self, data: dict[str, Any] | str | Path) -> dict[str, Any] | Va if isinstance(data, Path): try: - return json.loads(data.read_text()) + # Check file size before reading (DoS protection) + if self.max_input_size > 0: + try: + file_size = data.stat().st_size + if file_size > self.max_input_size: + return ValidationResult( + valid=False, + errors=[ + ValidationError( + path="$", + message=( + f"File size ({file_size:,} bytes) exceeds maximum " + f"allowed ({self.max_input_size:,} bytes)." + ), + code="PRS005", + layer="model", + severity="error", + ) + ], + schema_version=self.schema_version, + ) + except OSError: + pass # File doesn't exist yet, will be caught below + + return json.loads(data.read_text(encoding="utf-8")) except FileNotFoundError: return ValidationResult( valid=False, diff --git a/src/dppvalidator/validators/errors.py b/src/dppvalidator/validators/errors.py index 5d118ee..7737b97 100644 --- a/src/dppvalidator/validators/errors.py +++ b/src/dppvalidator/validators/errors.py @@ -109,16 +109,66 @@ class ErrorSuggestionDict(TypedDict, total=False): "suggestion": "Specify operationalScope with carbonFootprint data.", "example": '"operationalScope": "Scope1"', }, + # Plugin errors + "PLG001": { + "title": "Plugin Execution Failed", + "suggestion": "Check plugin implementation for errors or incompatibilities.", + "example": None, + }, + # Parse errors (additional) + "PRS004": { + "title": "Input Size Exceeded", + "suggestion": "Reduce input size or split into smaller documents.", + "example": None, + }, + "PRS005": { + "title": "File Size Exceeded", + "suggestion": "Reduce file size or adjust max_input_size limit.", + "example": None, + }, } -# Known valid values for "Did you mean?" suggestions +# Known valid values for "Did you mean?" suggestions. +# +# Field names are the on-the-wire (camelCase) spellings. Both UNTP +# version's spellings appear here for fields that were renamed across +# v0.6 → v0.7 — e.g. ``granularityLevel`` (v0.6) and ``idGranularity`` +# (v0.7) share the same enum values, so users on either version get +# typo suggestions. +_GRANULARITY_VALUES: list[str] = ["item", "batch", "model"] +_PARTY_ROLE_VALUES: list[str] = [ + # Mirrors dppvalidator.models.v0_7.identifiers.PartyRoleEnum. + "owner", + "producer", + "manufacturer", + "processor", + "remanufacturer", + "recycler", + "operator", + "serviceProvider", + "inspector", + "certifier", + "logisticsProvider", + "carrier", + "consignor", + "consignee", + "importer", + "exporter", + "distributor", + "retailer", + "brandOwner", + "regulator", +] + KNOWN_VALUES: dict[str, list[str]] = { "type": [ "DigitalProductPassport", "VerifiableCredential", "EnvelopedVerifiableCredential", ], - "granularityLevel": ["item", "batch", "model"], + "granularityLevel": _GRANULARITY_VALUES, # v0.6 spelling + "idGranularity": _GRANULARITY_VALUES, # v0.7 spelling + "role": _PARTY_ROLE_VALUES, # v0.7 PartyRole.role "operationalScope": ["None", "Scope1", "Scope2", "Scope3", "CradleToGate", "CradleToGrave"], "claimType": [ "Certification", diff --git a/src/dppvalidator/validators/jsonld_semantic.py b/src/dppvalidator/validators/jsonld_semantic.py new file mode 100644 index 0000000..d573d5f --- /dev/null +++ b/src/dppvalidator/validators/jsonld_semantic.py @@ -0,0 +1,517 @@ +"""JSON-LD semantic validation layer using PyLD expansion.""" + +from __future__ import annotations + +import json +import time +from functools import lru_cache +from importlib import resources +from typing import Any + +from pyld import jsonld +from pyld.jsonld import JsonLdError + +from dppvalidator.logging import get_logger +from dppvalidator.schemas.registry import DEFAULT_SCHEMA_VERSION, SCHEMA_REGISTRY +from dppvalidator.validators.results import ValidationError, ValidationResult + +logger = get_logger(__name__) + +# UNTP/UNCEFACT context URL patterns +UNTP_CONTEXT_PATTERNS = ( + "uncefact.org", + "untp", + "w3.org/ns/credentials", +) + +# Pre-bundled W3C VC v2 context to avoid network fetch +# This is the minimal subset needed for DPP validation +_BUNDLED_VC_V2_CONTEXT = { + "@context": { + "@protected": True, + "id": "@id", + "type": "@type", + "VerifiableCredential": { + "@id": "https://www.w3.org/2018/credentials#VerifiableCredential", + "@context": { + "@protected": True, + "id": "@id", + "type": "@type", + "credentialSubject": { + "@id": "https://www.w3.org/2018/credentials#credentialSubject" + }, + "issuer": {"@id": "https://www.w3.org/2018/credentials#issuer", "@type": "@id"}, + "validFrom": { + "@id": "https://www.w3.org/2018/credentials#validFrom", + "@type": "http://www.w3.org/2001/XMLSchema#dateTime", + }, + "validUntil": { + "@id": "https://www.w3.org/2018/credentials#validUntil", + "@type": "http://www.w3.org/2001/XMLSchema#dateTime", + }, + "proof": { + "@id": "https://w3id.org/security#proof", + "@type": "@id", + "@container": "@graph", + }, + }, + }, + } +} + + +def _load_bundled_untp_contexts() -> dict[str, dict[str, Any]]: + """Map every registered UNTP context URL to its bundled document. + + Walks ``SCHEMA_REGISTRY`` and reads + ``src/dppvalidator/vocabularies/data/untp-context-.jsonld`` for + each entry. Each ``SchemaVersion.context_urls`` tuple is ``(VC v2 URL, + UNTP URL)``; this function maps that second URL → bundled JSON-LD doc. + + Versions whose context file is not vendored are silently skipped, so a + partial install still works (and the network fallback in + :class:`CachingDocumentLoader` handles the rest). + + Returns: + Mapping ``{untp_context_url: parsed_jsonld_document}`` registered by + Phase 2 of docs/plans/UNTP_0.7.0_MIGRATION.md. + """ + out: dict[str, dict[str, Any]] = {} + for version, schema in SCHEMA_REGISTRY.items(): + if len(schema.context_urls) < 2: + continue + untp_url = schema.context_urls[1] + try: + ctx_file = resources.files("dppvalidator.vocabularies.data").joinpath( + f"untp-context-{version}.jsonld" + ) + content = ctx_file.read_text(encoding="utf-8") + except (FileNotFoundError, ModuleNotFoundError): + continue + try: + out[untp_url] = json.loads(content) + except json.JSONDecodeError: + logger.warning( + "Bundled UNTP context for %s is not valid JSON; skipping bundling", + version, + ) + return out + + +# URLs that map to bundled contexts. The W3C VC v2 entry is hand-crafted (the +# real document is huge but we only need a minimal subset for DPP expansion); +# the UNTP entries come from the bundled files registered in Phase 2 so the +# JSON-LD layer is fully offline-capable for both the legacy 0.6.x and the +# modern 0.7.0 namespaces. +BUNDLED_CONTEXT_URLS: dict[str, dict[str, Any]] = { + "https://www.w3.org/ns/credentials/v2": _BUNDLED_VC_V2_CONTEXT, + **_load_bundled_untp_contexts(), +} + + +class CachingDocumentLoader: + """Document loader with LRU caching for remote contexts. + + Reduces network overhead by caching fetched JSON-LD contexts. + """ + + def __init__(self, cache_size: int = 32, timeout: float = 10.0) -> None: + """Initialize caching document loader. + + Args: + cache_size: Maximum number of contexts to cache + timeout: HTTP request timeout in seconds + """ + self._cache_size = cache_size + self._timeout = timeout + # Pre-populate cache with bundled contexts + self._cache: dict[str, dict[str, Any]] = { + url: {"document": ctx, "documentUrl": url, "contextUrl": None} + for url, ctx in BUNDLED_CONTEXT_URLS.items() + } + + def __call__(self, url: str, options: dict[str, Any] | None = None) -> dict[str, Any]: + """Load a remote document, using cache if available. + + Args: + url: URL to fetch + options: PyLD loader options + + Returns: + Document dict with 'document' key containing parsed JSON + """ + if url in self._cache: + logger.debug("Context cache hit: %s", url) + return self._cache[url] + + # Use PyLD's default loader for actual fetching + result = jsonld.get_document_loader()(url, options) + + # Cache the result (LRU eviction) + if len(self._cache) >= self._cache_size: + # Remove oldest entry + oldest = next(iter(self._cache)) + del self._cache[oldest] + + self._cache[url] = result + logger.debug("Context cached: %s", url) + return result + + def clear_cache(self) -> None: + """Clear the context cache.""" + self._cache.clear() + + +class JSONLDValidator: + """JSON-LD semantic validation using PyLD expansion. + + Validates that: + 1. @context is present and valid (JLD001) + 2. All terms resolve during expansion (JLD002) + 3. Custom terms use proper namespacing (JLD003) + + Uses PyLD's expansion algorithm to detect undefined terms. + """ + + name: str = "jsonld" + layer: str = "jsonld" + + def __init__( + self, + schema_version: str = DEFAULT_SCHEMA_VERSION, + strict: bool = False, + cache_contexts: bool = True, + ) -> None: + """Initialize JSON-LD validator. + + Args: + schema_version: UNTP DPP schema version + strict: If True, undefined terms are errors instead of warnings + cache_contexts: If True, cache fetched remote contexts + """ + self.schema_version = schema_version + self.strict = strict + self._document_loader = CachingDocumentLoader() if cache_contexts else None + + def validate(self, data: dict[str, Any]) -> ValidationResult: + """Validate JSON-LD semantics via expansion. + + Args: + data: Raw DPP JSON data with @context + + Returns: + ValidationResult with semantic violations + """ + start_time = time.perf_counter() + + errors: list[ValidationError] = [] + warnings: list[ValidationError] = [] + + # JLD001: Check @context presence + context_result = self._validate_context_presence(data) + if context_result: + errors.append(context_result) + # Cannot proceed without valid context + return ValidationResult( + valid=False, + errors=errors, + schema_version=self.schema_version, + validation_time_ms=(time.perf_counter() - start_time) * 1000, + ) + + # Perform expansion to detect undefined terms + try: + options: dict[str, Any] = {} + if self._document_loader: + options["documentLoader"] = self._document_loader + + expanded = jsonld.expand(data, options) + + # JLD002: Detect dropped/undefined terms + dropped_terms = self._find_dropped_terms(data, expanded) + for term, path in dropped_terms: + violation = ValidationError( + path=path, + message=f"Term '{term}' not defined in @context, dropped during expansion", + code="JLD002", + layer="jsonld", + severity="error" if self.strict else "warning", + suggestion=f"Add '{term}' to @context or use a prefixed term", + ) + if self.strict: + errors.append(violation) + else: + warnings.append(violation) + + # JLD003: Check for unprefixed custom terms + unprefixed = self._find_unprefixed_custom_terms(data) + for term, path in unprefixed: + warnings.append( + ValidationError( + path=path, + message=f"Custom term '{term}' lacks namespace prefix", + code="JLD003", + layer="jsonld", + severity="warning", + suggestion="Use prefixed terms like 'ex:customField' for custom extensions", + ) + ) + + except JsonLdError as e: + errors.append( + ValidationError( + path="$['@context']", + message=f"JSON-LD expansion failed: {e}", + code="JLD001", + layer="jsonld", + severity="error", + ) + ) + except Exception as e: + # Network errors, timeouts, etc. + warnings.append( + ValidationError( + path="$['@context']", + message=f"JSON-LD context resolution failed: {e}", + code="JLD004", + layer="jsonld", + severity="warning", + suggestion="Check network connectivity or use offline mode", + ) + ) + + validation_time = (time.perf_counter() - start_time) * 1000 + + return ValidationResult( + valid=len(errors) == 0, + errors=errors, + warnings=warnings, + schema_version=self.schema_version, + validation_time_ms=validation_time, + ) + + def _validate_context_presence(self, data: dict[str, Any]) -> ValidationError | None: + """Validate @context is present and appears valid. + + Args: + data: Raw JSON data + + Returns: + ValidationError if invalid, None if valid + """ + context = data.get("@context") + + if context is None: + return ValidationError( + path="$", + message="Missing @context: JSON-LD documents require @context", + code="JLD001", + layer="jsonld", + severity="error", + suggestion="Add @context with UNTP vocabulary URL", + ) + + # Check if context contains UNTP/W3C vocabulary + context_str = str(context).lower() + has_untp = any(pattern in context_str for pattern in UNTP_CONTEXT_PATTERNS) + + if not has_untp: + return ValidationError( + path="$['@context']", + message="@context missing UNTP/W3C vocabulary references", + code="JLD001", + layer="jsonld", + severity="error", + suggestion="Include 'https://www.w3.org/ns/credentials/v2' and UNTP vocabulary", + ) + + return None + + def _find_dropped_terms( + self, + original: dict[str, Any], + expanded: list[dict[str, Any]], + ) -> list[tuple[str, str]]: + """Find terms that were dropped during expansion. + + When PyLD expands JSON-LD, terms not defined in @context are dropped. + This detects those dropped terms. + + Args: + original: Original JSON data + expanded: Expanded JSON-LD (list of objects) + + Returns: + List of (term_name, json_path) tuples for dropped terms + """ + dropped: list[tuple[str, str]] = [] + + # Get all keys from original (excluding JSON-LD keywords) + original_keys = self._collect_keys(original, "$") + + # Get all expanded IRIs + expanded_iris: set[str] = set() + if expanded: + self._collect_expanded_iris(expanded[0] if expanded else {}, expanded_iris) + + # Check which original keys didn't expand + for key, path in original_keys: + if key.startswith("@"): + continue # Skip JSON-LD keywords + + # Check if key or a term ending with this key exists in expanded + key_expanded = any( + iri.endswith(f"/{key}") or iri.endswith(f"#{key}") or key in iri + for iri in expanded_iris + ) + + if not key_expanded and key not in ("type", "id"): + # Also check if it's a standard term that maps to @type or @id + dropped.append((key, path)) + + return dropped + + def _collect_keys( + self, + obj: Any, + path: str, + ) -> list[tuple[str, str]]: + """Recursively collect all keys from an object. + + Args: + obj: Object to traverse + path: Current JSON path + + Returns: + List of (key, path) tuples + """ + keys: list[tuple[str, str]] = [] + + if isinstance(obj, dict): + for key, value in obj.items(): + key_path = f"{path}['{key}']" if not path.endswith("$") else f"$.{key}" + if not key.startswith("@"): + keys.append((key, key_path)) + keys.extend(self._collect_keys(value, key_path)) + elif isinstance(obj, list): + for i, item in enumerate(obj): + keys.extend(self._collect_keys(item, f"{path}[{i}]")) + + return keys + + def _collect_expanded_iris(self, obj: Any, iris: set[str]) -> None: + """Collect all IRIs from expanded JSON-LD. + + Args: + obj: Expanded JSON-LD object + iris: Set to add IRIs to (mutated) + """ + if isinstance(obj, dict): + for key, value in obj.items(): + if key.startswith("http://") or key.startswith("https://"): + iris.add(key) + self._collect_expanded_iris(value, iris) + elif isinstance(obj, list): + for item in obj: + self._collect_expanded_iris(item, iris) + + def _find_unprefixed_custom_terms( + self, + data: dict[str, Any], + ) -> list[tuple[str, str]]: + """Find custom terms that lack namespace prefixes. + + Terms not in the standard UNTP vocabulary should use prefixes + to avoid namespace pollution. + + Args: + data: Original JSON data + + Returns: + List of (term_name, json_path) tuples + """ + # Standard UNTP/VC terms (not exhaustive, common ones) + standard_terms = frozenset( + { + "type", + "id", + "@context", + "@type", + "@id", + "credentialSubject", + "issuer", + "validFrom", + "validUntil", + "proof", + "credentialStatus", + "credentialSchema", + "name", + "description", + "image", + "product", + "manufacturer", + "facility", + "conformityClaim", + "materialsProvenance", + "circularityScorecard", + "emissionsScorecard", + "traceabilityInformation", + "guaranteedUntil", + "granularityLevel", + "serialNumber", + "batchNumber", + "productCategory", + "dimensions", + "characteristics", + "value", + "unit", + "code", + "schemeId", + "schemeName", + "massFraction", + "recycledContent", + "recyclableContent", + "hazardous", + "materialSafetyInformation", + "carbonFootprint", + "operationalScope", + "originCountry", + "materialCode", + "materialName", + } + ) + + unprefixed: list[tuple[str, str]] = [] + all_keys = self._collect_keys(data, "$") + + for key, path in all_keys: + if key.startswith("@"): + continue + if key in standard_terms: + continue + # Check if prefixed (contains colon but not URL) + if ":" in key and not key.startswith("http"): + continue + # Likely a custom unprefixed term + unprefixed.append((key, path)) + + return unprefixed + + +@lru_cache(maxsize=1) +def _get_default_validator() -> JSONLDValidator: + """Get default JSON-LD validator instance.""" + return JSONLDValidator() + + +def validate_jsonld(data: dict[str, Any]) -> ValidationResult: + """Convenience function for JSON-LD validation. + + Args: + data: DPP JSON data + + Returns: + ValidationResult + """ + validator = _get_default_validator() + return validator.validate(data) diff --git a/src/dppvalidator/validators/layers.py b/src/dppvalidator/validators/layers.py new file mode 100644 index 0000000..e26127d --- /dev/null +++ b/src/dppvalidator/validators/layers.py @@ -0,0 +1,318 @@ +"""Validation layer abstractions following the Strategy Pattern. + +This module provides the ValidationContext and ValidationLayer protocol +for decoupling validation logic from the engine orchestration. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from dppvalidator.validators.protocols import Validator +from dppvalidator.validators.results import ValidationError, ValidationResult + +if TYPE_CHECKING: + from dppvalidator.models.passport import DigitalProductPassport + from dppvalidator.plugins.registry import PluginRegistry + from dppvalidator.validators.semantic import SemanticValidator + from dppvalidator.verifier.verifier import CredentialVerifier + from dppvalidator.vocabularies.loader import VocabularyLoader + + +@dataclass +class ValidationContext: + """Shared context passed through validation layers. + + Holds the current validation state and configuration, enabling + layers to access shared data without tight coupling. + """ + + parsed_data: dict[str, Any] + schema_version: str + strict_mode: bool = False + fail_fast: bool = False + max_errors: int = 100 + + passport: DigitalProductPassport | None = None + result: ValidationResult = field(init=False) + + def __post_init__(self) -> None: + """Initialize the result after dataclass init.""" + self.result = ValidationResult( + valid=True, + schema_version=self.schema_version, + ) + + def merge_result(self, layer_result: ValidationResult) -> None: + """Merge a layer's result into the cumulative result.""" + self.result = self.result.merge(layer_result) + if layer_result.passport is not None: + self.passport = layer_result.passport + + def should_stop(self) -> bool: + """Check if validation should stop early.""" + if self.fail_fast and not self.result.valid: + return True + return self.result.error_count >= self.max_errors + + +class ValidationLayer(ABC): + """Abstract base class for validation layers (Strategy Pattern). + + Each layer encapsulates a single validation responsibility, + following the Single Responsibility Principle. + """ + + @property + @abstractmethod + def name(self) -> str: + """Unique identifier for this layer.""" + + @abstractmethod + def should_run(self, context: ValidationContext) -> bool: + """Determine if this layer should execute given the context.""" + + @abstractmethod + def execute(self, context: ValidationContext) -> ValidationResult: + """Execute validation and return results.""" + + +class SchemaLayer(ValidationLayer): + """JSON Schema validation layer.""" + + def __init__(self, validator: Validator | None) -> None: + self._validator = validator + + @property + def name(self) -> str: + return "schema" + + def should_run(self, context: ValidationContext) -> bool: # noqa: ARG002 + return self._validator is not None + + def execute(self, context: ValidationContext) -> ValidationResult: + if self._validator is None: + return ValidationResult(valid=True, schema_version=context.schema_version) + return self._validator.validate(context.parsed_data) + + +class ModelLayer(ValidationLayer): + """Pydantic model validation layer.""" + + def __init__(self, validator: Validator | None) -> None: + self._validator = validator + + @property + def name(self) -> str: + return "model" + + def should_run(self, context: ValidationContext) -> bool: # noqa: ARG002 + return self._validator is not None + + def execute(self, context: ValidationContext) -> ValidationResult: + if self._validator is None: + return ValidationResult(valid=True, schema_version=context.schema_version) + result = self._validator.validate(context.parsed_data) + if result.passport is not None: + context.passport = result.passport + return result + + +class SemanticLayer(ValidationLayer): + """Business rules semantic validation layer.""" + + def __init__(self, validator: SemanticValidator | None) -> None: + self._validator = validator + + @property + def name(self) -> str: + return "semantic" + + def should_run(self, context: ValidationContext) -> bool: + return self._validator is not None and context.passport is not None + + def execute(self, context: ValidationContext) -> ValidationResult: + if self._validator is None or context.passport is None: + return ValidationResult(valid=True, schema_version=context.schema_version) + return self._validator.validate(context.passport) + + +class JsonLdLayer(ValidationLayer): + """JSON-LD context expansion and term validation layer.""" + + def __init__(self, validator: Validator | None) -> None: + self._validator = validator + + @property + def name(self) -> str: + return "jsonld" + + def should_run(self, context: ValidationContext) -> bool: # noqa: ARG002 + return self._validator is not None + + def execute(self, context: ValidationContext) -> ValidationResult: + if self._validator is None: + return ValidationResult(valid=True, schema_version=context.schema_version) + return self._validator.validate(context.parsed_data) + + +class VocabularyLayer(ValidationLayer): + """External vocabulary validation layer.""" + + def __init__(self, loader: VocabularyLoader | None, schema_version: str) -> None: + self._loader = loader + self._schema_version = schema_version + + @property + def name(self) -> str: + return "vocabulary" + + def should_run(self, context: ValidationContext) -> bool: + return self._loader is not None and context.passport is not None + + def execute(self, context: ValidationContext) -> ValidationResult: + if context.passport is None: + return ValidationResult(valid=True, schema_version=self._schema_version) + + warnings: list[ValidationError] = [] + passport = context.passport + + if passport.credential_subject and passport.credential_subject.materials_provenance: + for i, material in enumerate(passport.credential_subject.materials_provenance): + origin = getattr(material, "origin_country", None) + if ( + origin + and self._loader is not None + and not self._loader.is_valid_country(origin) + ): + warnings.append( + ValidationError( + path=f"$.credentialSubject.materialsProvenance[{i}].originCountry", + message=f"Invalid country code: '{origin}'", + code="VOC001", + layer="vocabulary", + severity="warning", + suggestion="Use ISO 3166-1 alpha-2 country codes", + ) + ) + + if passport.credential_subject and passport.credential_subject.product: + product = passport.credential_subject.product + dims = getattr(product, "dimensions", None) + if dims: + for field_name in ["weight", "length", "width", "height", "volume"]: + measure = getattr(dims, field_name, None) + unit = getattr(measure, "unit", None) if measure else None + if unit and self._loader is not None and not self._loader.is_valid_unit(unit): + warnings.append( + ValidationError( + path=f"$.credentialSubject.product.dimensions.{field_name}.unit", + message=f"Invalid unit code: '{unit}'", + code="VOC002", + layer="vocabulary", + severity="warning", + suggestion="Use UNECE Rec20 unit codes", + ) + ) + + return ValidationResult( + valid=True, + warnings=warnings, + schema_version=self._schema_version, + ) + + +class PluginLayer(ValidationLayer): + """Plugin validators execution layer.""" + + def __init__(self, registry: PluginRegistry | None, schema_version: str) -> None: + self._registry = registry + self._schema_version = schema_version + + @property + def name(self) -> str: + return "plugin" + + def should_run(self, context: ValidationContext) -> bool: + return self._registry is not None and context.passport is not None + + def execute(self, context: ValidationContext) -> ValidationResult: + if context.passport is None: + return ValidationResult(valid=True, schema_version=self._schema_version) + + if self._registry is None: + return ValidationResult(valid=True, schema_version=self._schema_version) + plugin_errors = self._registry.run_all_validators(context.passport) + + errors = [e for e in plugin_errors if e.severity == "error"] + warnings = [e for e in plugin_errors if e.severity == "warning"] + info = [e for e in plugin_errors if e.severity == "info"] + + return ValidationResult( + valid=len(errors) == 0, + errors=errors, + warnings=warnings, + info=info, + schema_version=self._schema_version, + ) + + +class SignatureLayer(ValidationLayer): + """Verifiable Credential signature verification layer.""" + + def __init__(self, verifier: CredentialVerifier | None, schema_version: str) -> None: + self._verifier = verifier + self._schema_version = schema_version + + @property + def name(self) -> str: + return "signature" + + def should_run(self, context: ValidationContext) -> bool: # noqa: ARG002 + return self._verifier is not None + + def execute(self, context: ValidationContext) -> ValidationResult: + if self._verifier is None: + return ValidationResult(valid=True, schema_version=self._schema_version) + vc_result = self._verifier.verify(context.parsed_data) + + errors: list[ValidationError] = [] + warnings: list[ValidationError] = [] + + for error_msg in vc_result.errors: + errors.append( + ValidationError( + path="$.proof", + message=error_msg, + code="VC001", + layer="semantic", + severity="error", + suggestion="Check issuer DID and proof signature", + docs_url="https://artiso-ai.github.io/dppvalidator/errors/VC001", + ) + ) + + for warning_msg in vc_result.warnings: + warnings.append( + ValidationError( + path="$.proof", + message=warning_msg, + code="VC002", + layer="semantic", + severity="warning", + ) + ) + + result = ValidationResult( + valid=vc_result.valid and len(errors) == 0, + errors=errors, + warnings=warnings, + schema_version=self._schema_version, + ) + result.signature_valid = vc_result.signature_valid + result.issuer_did = vc_result.issuer_did + result.verification_method = vc_result.verification_method + + return result diff --git a/src/dppvalidator/validators/model.py b/src/dppvalidator/validators/model.py index 58165a8..208cb01 100644 --- a/src/dppvalidator/validators/model.py +++ b/src/dppvalidator/validators/model.py @@ -3,13 +3,32 @@ from __future__ import annotations import time -from typing import Any +from typing import TYPE_CHECKING, Any +from pydantic import BaseModel from pydantic import ValidationError as PydanticValidationError -from dppvalidator.models.passport import DigitalProductPassport +from dppvalidator.models import v0_6, v0_7 +from dppvalidator.schemas.registry import DEFAULT_SCHEMA_VERSION from dppvalidator.validators.results import ValidationError, ValidationResult +if TYPE_CHECKING: + pass + +# Single dispatch table for "which Pydantic root class validates which UNTP +# version". Adding a new version here is a one-line change — see Phase 3.3 +# of docs/plans/UNTP_0.7.0_MIGRATION.md and the cardinal rules in +# .claude/rules/untp-versioning.md (rule 3). +# +# Keep keys aligned with ``SCHEMA_REGISTRY`` keys; the +# ``test_model_dispatch_covers_registry`` test in +# tests/unit/test_v07_models.py guarantees this. +_MODEL_BY_VERSION: dict[str, type[BaseModel]] = { + "0.6.0": v0_6.DigitalProductPassport, + "0.6.1": v0_6.DigitalProductPassport, + "0.7.0": v0_7.DigitalProductPassport, +} + # Stable error code mapping based on Pydantic error types # See: https://docs.pydantic.dev/latest/errors/validation_errors/ PYDANTIC_ERROR_CODES: dict[str, str] = { @@ -70,7 +89,7 @@ class ModelValidator: name: str = "model" layer: str = "model" - def __init__(self, schema_version: str = "0.6.1") -> None: + def __init__(self, schema_version: str = DEFAULT_SCHEMA_VERSION) -> None: """Initialize model validator. Args: @@ -81,6 +100,11 @@ def __init__(self, schema_version: str = "0.6.1") -> None: def validate(self, data: dict[str, Any]) -> ValidationResult: """Validate data using Pydantic models. + The Pydantic root class is selected from :data:`_MODEL_BY_VERSION` + keyed on ``self.schema_version``. Adding a new UNTP version means + adding one entry there (and shipping the model package); no + changes are needed in this method. + Args: data: Raw JSON data to validate @@ -89,27 +113,51 @@ def validate(self, data: dict[str, Any]) -> ValidationResult: """ start_time = time.perf_counter() errors: list[ValidationError] = [] - passport: DigitalProductPassport | None = None - - try: - passport = DigitalProductPassport.model_validate(data) - except PydanticValidationError as e: - for error in e.errors(): - json_path = self._loc_to_path(error.get("loc", ())) - error_type = error.get("type", "unknown") - errors.append( - ValidationError( - path=json_path, - message=error.get("msg", "Validation error"), - code=self._get_error_code(error_type), - layer="model", - severity="error", - context={ - "type": error_type, - "input": self._safe_input(error.get("input")), - }, + # Annotated as ``BaseModel | None`` rather than the v0.6 + # ``DigitalProductPassport`` so ``_MODEL_BY_VERSION`` can return + # either a v0.6 or a v0.7 root class. Callers downcast or use + # ``isinstance`` when they need a specific shape — see + # docs/plans/UNTP_0.7.0_MIGRATION.md §3.3. + passport: BaseModel | None = None + + model_cls = _MODEL_BY_VERSION.get(self.schema_version) + if model_cls is None: + # Unsupported version — fail fast with a structured error rather + # than silently coercing to whatever the default model accepts. + available = ", ".join(sorted(_MODEL_BY_VERSION)) + errors.append( + ValidationError( + path="$", + message=( + f"No Pydantic model registered for schema version " + f"{self.schema_version!r}. Registered: {available}." + ), + code="MDL098", + layer="model", + severity="error", + context={"requested_version": self.schema_version}, + ), + ) + else: + try: + passport = model_cls.model_validate(data) + except PydanticValidationError as e: + for error in e.errors(): + json_path = self._loc_to_path(error.get("loc", ())) + error_type = error.get("type", "unknown") + errors.append( + ValidationError( + path=json_path, + message=error.get("msg", "Validation error"), + code=self._get_error_code(error_type), + layer="model", + severity="error", + context={ + "type": error_type, + "input": self._safe_input(error.get("input")), + }, + ) ) - ) validation_time = (time.perf_counter() - start_time) * 1000 @@ -117,7 +165,10 @@ def validate(self, data: dict[str, Any]) -> ValidationResult: valid=len(errors) == 0, errors=errors, schema_version=self.schema_version, - passport=passport, + # `passport` is a v0.6 or v0.7 DigitalProductPassport at runtime; + # ``ValidationResult.passport`` is annotated with the v0.6 type + # under TYPE_CHECKING for backward compat (see plan §3.3). + passport=passport, # type: ignore[arg-type] validation_time_ms=validation_time, ) diff --git a/src/dppvalidator/validators/results.py b/src/dppvalidator/validators/results.py index 87250e1..e95c9d0 100644 --- a/src/dppvalidator/validators/results.py +++ b/src/dppvalidator/validators/results.py @@ -7,6 +7,8 @@ from datetime import datetime from typing import TYPE_CHECKING, Any, Literal +from dppvalidator.schemas.registry import DEFAULT_SCHEMA_VERSION + if TYPE_CHECKING: from dppvalidator.models.passport import DigitalProductPassport @@ -30,7 +32,7 @@ class ValidationError: path: str message: str code: str - layer: Literal["schema", "model", "semantic", "plugin", "vocabulary"] + layer: Literal["schema", "model", "semantic", "jsonld", "plugin", "vocabulary", "engine"] severity: Literal["error", "warning", "info"] = "error" suggestion: str | None = None docs_url: str | None = None @@ -79,11 +81,15 @@ class ValidationResult: errors: list[ValidationError] = field(default_factory=list) warnings: list[ValidationError] = field(default_factory=list) info: list[ValidationError] = field(default_factory=list) - schema_version: str = "0.6.1" + schema_version: str = DEFAULT_SCHEMA_VERSION validated_at: datetime = field(default_factory=datetime.now) passport: DigitalProductPassport | None = None parse_time_ms: float = 0.0 validation_time_ms: float = 0.0 + # Signature verification fields + signature_valid: bool | None = None + issuer_did: str | None = None + verification_method: str | None = None @property def error_count(self) -> int: @@ -102,7 +108,7 @@ def all_issues(self) -> list[ValidationError]: def to_dict(self) -> dict[str, Any]: """Convert to dictionary for JSON serialization.""" - return { + result = { "valid": self.valid, "errors": [e.to_dict() for e in self.errors], "warnings": [w.to_dict() for w in self.warnings], @@ -112,6 +118,13 @@ def to_dict(self) -> dict[str, Any]: "parse_time_ms": self.parse_time_ms, "validation_time_ms": self.validation_time_ms, } + if self.signature_valid is not None: + result["signature_valid"] = self.signature_valid + if self.issuer_did: + result["issuer_did"] = self.issuer_did + if self.verification_method: + result["verification_method"] = self.verification_method + return result def to_json(self, *, indent: int | None = 2) -> str: """Serialize result to JSON string.""" diff --git a/src/dppvalidator/validators/rules/__init__.py b/src/dppvalidator/validators/rules/__init__.py index d29e8bd..41a1ca2 100644 --- a/src/dppvalidator/validators/rules/__init__.py +++ b/src/dppvalidator/validators/rules/__init__.py @@ -1,32 +1,106 @@ -"""Pluggable semantic validation rules.""" +"""Pluggable semantic validation rules. -from dppvalidator.validators.rules.base import ( +Phase 3b of docs/plans/UNTP_0.7.0_MIGRATION.md split this package into +:mod:`dppvalidator.validators.rules.v0_6` and +:mod:`dppvalidator.validators.rules.v0_7` so 0.6.x and 0.7.0 rule sets can +coexist. This module exposes: + +* :data:`ALL_RULES_BY_VERSION` — the dispatch table consumed by + :class:`dppvalidator.validators.semantic.SemanticValidator` when no + custom ``rules`` list is passed. +* :data:`ALL_RULES` — the default rule set (kept as the v0.6.x list for + the 0.4.x line so existing callers continue to see the same behaviour). +* All v0.6 rule classes — re-exported for backward compatibility, so + ``from dppvalidator.validators.rules import MassFractionSumRule`` keeps + working. + +Adding a new UNTP version: + +1. Build the ported rules under ``rules/v0_X/`` (one module per topic). +2. Import its ``ALL_RULES_V0_X`` list here and add it to + :data:`ALL_RULES_BY_VERSION`. +3. The :class:`SemanticValidator` picks it up automatically — no further + wiring required. See ``.claude/rules/untp-versioning.md`` (rule 2). +""" + +from __future__ import annotations + +# v0.6 (default in the 0.4.x line) — re-export for backward compat. +from dppvalidator.validators.rules.v0_6 import ( + ALL_RULES_V0_6, + CIRPASS_RULES, + TEXTILE_RULES, CircularityContentRule, + CIRPASSGranularityConsistencyRule, + CIRPASSMandatoryAttributesRule, + CIRPASSOperatorIdentifierRule, + CIRPASSSubstancesOfConcernRule, + CIRPASSValidityPeriodRule, + CIRPASSWeightVolumeRule, ConformityClaimRule, GranularitySerialNumberRule, + GTINChecksumRule, HazardousMaterialRule, + HSCodeRule, MassFractionSumRule, + MaterialCodeRule, OperationalScopeRule, + TextileCareInstructionsRule, + TextileDurabilityRule, + TextileEnvironmentalCategory, + TextileHSCodeRule, + TextileMaterialCompositionRule, + TextileMicroplasticRule, ValidityDateRule, + get_textile_environmental_categories, + is_textile_product, ) +from dppvalidator.validators.rules.v0_7 import ALL_RULES_V0_7 -ALL_RULES = [ - MassFractionSumRule(), - ValidityDateRule(), - HazardousMaterialRule(), - CircularityContentRule(), - ConformityClaimRule(), - GranularitySerialNumberRule(), - OperationalScopeRule(), -] +# Backward-compat default. The 0.4.x line ships with v0.6 as the default +# schema version, so ``ALL_RULES`` points at the v0.6 list. Phase 9 flips +# this to the v0.7 list when ``DEFAULT_SCHEMA_VERSION`` flips. +ALL_RULES = list(ALL_RULES_V0_6) + +# Version-keyed dispatch table consumed by ``SemanticValidator``. Both +# 0.6.0 and 0.6.1 share the same rule set because the model shape is the +# same; 0.7.0 has its own. +ALL_RULES_BY_VERSION: dict[str, list] = { + "0.6.0": ALL_RULES_V0_6, + "0.6.1": ALL_RULES_V0_6, + "0.7.0": ALL_RULES_V0_7, +} __all__ = [ "ALL_RULES", - "MassFractionSumRule", - "ValidityDateRule", - "HazardousMaterialRule", + "ALL_RULES_BY_VERSION", + "ALL_RULES_V0_6", + "ALL_RULES_V0_7", + # v0.6 re-exports (backward compat) + "CIRPASS_RULES", + "CIRPASSGranularityConsistencyRule", + "CIRPASSMandatoryAttributesRule", + "CIRPASSOperatorIdentifierRule", + "CIRPASSSubstancesOfConcernRule", + "CIRPASSValidityPeriodRule", + "CIRPASSWeightVolumeRule", "CircularityContentRule", "ConformityClaimRule", + "GTINChecksumRule", "GranularitySerialNumberRule", + "HSCodeRule", + "HazardousMaterialRule", + "MassFractionSumRule", + "MaterialCodeRule", "OperationalScopeRule", + "TEXTILE_RULES", + "TextileCareInstructionsRule", + "TextileDurabilityRule", + "TextileEnvironmentalCategory", + "TextileHSCodeRule", + "TextileMaterialCompositionRule", + "TextileMicroplasticRule", + "ValidityDateRule", + "get_textile_environmental_categories", + "is_textile_product", ] diff --git a/src/dppvalidator/validators/rules/base.py b/src/dppvalidator/validators/rules/base.py index 0e0d7ef..81c172b 100644 --- a/src/dppvalidator/validators/rules/base.py +++ b/src/dppvalidator/validators/rules/base.py @@ -1,235 +1,39 @@ -"""Semantic validation rule implementations.""" +"""Backward-compatibility re-export of v0.6.x base UNTP semantic rules. -from __future__ import annotations - -from typing import TYPE_CHECKING, Literal - -if TYPE_CHECKING: - from dppvalidator.models.passport import DigitalProductPassport - - -class MassFractionSumRule: - """SEM001: Material mass fractions should sum to 1.0. - - Per UNTP spec, partial declarations (sum < 1.0) are valid but should - be flagged as a warning. Only sum > 1.0 is physically impossible. - """ - - rule_id: str = "SEM001" - description: str = "Material mass fractions should sum to 1.0" - severity: Literal["error", "warning", "info"] = "warning" - suggestion: str = "Adjust material mass fractions to sum to 1.0 (100%)" - docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/SEM001" - - def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: - """Check mass fraction sum.""" - violations: list[tuple[str, str]] = [] - - if not passport.credential_subject: - return violations - - materials = passport.credential_subject.materials_provenance - if not materials: - return violations - - fractions = [m.mass_fraction for m in materials if m.mass_fraction is not None] - if fractions: - total = sum(fractions) - # Only flag if significantly different from 1.0 - # Partial declarations (< 1.0) are valid per UNTP spec - if abs(total - 1.0) > 0.01: - violations.append( - ( - "$.credentialSubject.materialsProvenance", - f"Mass fractions sum to {total:.3f}, expected 1.0 (partial declaration)", - ) - ) - - return violations - - -class ValidityDateRule: - """SEM002: validFrom must be before validUntil.""" - - rule_id: str = "SEM002" - description: str = "validFrom must be before validUntil" - severity: Literal["error", "warning", "info"] = "error" - suggestion: str = "Ensure validFrom is before validUntil" - docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/SEM002" - - def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: - """Check validity date ordering.""" - violations: list[tuple[str, str]] = [] - - if ( - passport.valid_from - and passport.valid_until - and passport.valid_from >= passport.valid_until - ): - violations.append( - ( - "$.validFrom", - f"validFrom ({passport.valid_from}) must be before validUntil ({passport.valid_until})", - ) - ) - - return violations - - -class HazardousMaterialRule: - """SEM003: hazardous=true requires materialSafetyInformation.""" - - rule_id: str = "SEM003" - description: str = "Hazardous materials require safety information" - severity: Literal["error", "warning", "info"] = "error" - suggestion: str = "Add materialSafetyInformation for hazardous materials" - docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/SEM003" - - def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: - """Check hazardous material safety info.""" - violations: list[tuple[str, str]] = [] - - if not passport.credential_subject: - return violations - - materials = passport.credential_subject.materials_provenance - if not materials: - return violations - - for i, material in enumerate(materials): - if material.hazardous and not material.material_safety_information: - violations.append( - ( - f"$.credentialSubject.materialsProvenance[{i}]", - f"Material '{material.name}' is hazardous but missing materialSafetyInformation", - ) - ) - - return violations - - -class CircularityContentRule: - """SEM004: recycledContent should not exceed recyclableContent.""" +Phase 3b of docs/plans/UNTP_0.7.0_MIGRATION.md split semantic rules into +``rules/v0_6/`` and ``rules/v0_7/`` subpackages. This shim preserves the +import path used by existing tests and any third-party plugin +(``from dppvalidator.validators.rules.base import CircularityContentRule``) — see +the public-API stability contract in §7.6 of the plan. - rule_id: str = "SEM004" - description: str = "recycledContent should not exceed recyclableContent" - severity: Literal["error", "warning", "info"] = "warning" - suggestion: str = "recycledContent cannot exceed recyclableContent" - docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/SEM004" +Through the 0.4.x line this re-exports v0.6.x rules. Phase 9 will switch +the default to v0.7 and update this shim accordingly. +""" - def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: - """Check circularity content consistency.""" - violations: list[tuple[str, str]] = [] - - if not passport.credential_subject: - return violations - - scorecard = passport.credential_subject.circularity_scorecard - if not scorecard: - return violations - - recycled = scorecard.recycled_content - recyclable = scorecard.recyclable_content - - if recycled is not None and recyclable is not None and recycled > recyclable: - violations.append( - ( - "$.credentialSubject.circularityScorecard", - f"recycledContent ({recycled}) exceeds recyclableContent ({recyclable})", - ) - ) - - return violations - - -class ConformityClaimRule: - """SEM005: At least one conformityClaim is recommended.""" - - rule_id: str = "SEM005" - description: str = "At least one conformityClaim is recommended" - severity: Literal["error", "warning", "info"] = "info" - suggestion: str = "Add conformity claims for sustainability or compliance" - docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/SEM005" - - def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: - """Check for conformity claims.""" - violations: list[tuple[str, str]] = [] - - if not passport.credential_subject: - return violations - - claims = passport.credential_subject.conformity_claim - if not claims: - violations.append( - ( - "$.credentialSubject.conformityClaim", - "No conformity claims present. Consider adding sustainability or compliance claims.", - ) - ) - - return violations - - -class GranularitySerialNumberRule: - """SEM006: granularityLevel=item requires serialNumber.""" - - rule_id: str = "SEM006" - description: str = "Item-level passports require serial numbers" - severity: Literal["error", "warning", "info"] = "warning" - suggestion: str = "Add serialNumber for item-level granularity passports" - docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/SEM006" - - def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: - """Check serial number for item-level passports.""" - violations: list[tuple[str, str]] = [] - - if not passport.credential_subject: - return violations - - granularity = passport.credential_subject.granularity_level - product = passport.credential_subject.product - - if ( - granularity - and str(granularity) == "item" - and (not product or not product.serial_number) - ): - violations.append( - ( - "$.credentialSubject.product.serialNumber", - "granularityLevel is 'item' but serialNumber is missing", - ) - ) - - return violations - - -class OperationalScopeRule: - """SEM007: carbonFootprint should have operationalScope defined.""" - - rule_id: str = "SEM007" - description: str = "Emissions data should specify operational scope" - severity: Literal["error", "warning", "info"] = "warning" - suggestion: str = "Specify operationalScope with carbonFootprint data" - docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/SEM007" - - def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: - """Check operational scope for emissions data.""" - violations: list[tuple[str, str]] = [] - - if not passport.credential_subject: - return violations - - scorecard = passport.credential_subject.emissions_scorecard - if not scorecard: - return violations - - if scorecard.carbon_footprint and not scorecard.operational_scope: - violations.append( - ( - "$.credentialSubject.emissionsScorecard.operationalScope", - "carbonFootprint is specified but operationalScope is missing", - ) - ) +from __future__ import annotations - return violations +from dppvalidator.validators.rules.v0_6.base import ( + CircularityContentRule, + ConformityClaimRule, + GranularitySerialNumberRule, + GTINChecksumRule, + HazardousMaterialRule, + HSCodeRule, + MassFractionSumRule, + MaterialCodeRule, + OperationalScopeRule, + ValidityDateRule, +) + +__all__ = [ + "CircularityContentRule", + "ConformityClaimRule", + "GTINChecksumRule", + "GranularitySerialNumberRule", + "HSCodeRule", + "HazardousMaterialRule", + "MassFractionSumRule", + "MaterialCodeRule", + "OperationalScopeRule", + "ValidityDateRule", +] diff --git a/src/dppvalidator/validators/rules/cirpass.py b/src/dppvalidator/validators/rules/cirpass.py new file mode 100644 index 0000000..7fbdf10 --- /dev/null +++ b/src/dppvalidator/validators/rules/cirpass.py @@ -0,0 +1,33 @@ +"""Backward-compatibility re-export of v0.6.x CIRPASS-2 CQ-based rules. + +Phase 3b of docs/plans/UNTP_0.7.0_MIGRATION.md split semantic rules into +``rules/v0_6/`` and ``rules/v0_7/`` subpackages. This shim preserves the +import path used by existing tests and any third-party plugin +(``from dppvalidator.validators.rules.cirpass import CIRPASS_RULES``) — see +the public-API stability contract in §7.6 of the plan. + +Through the 0.4.x line this re-exports v0.6.x rules. Phase 9 will switch +the default to v0.7 and update this shim accordingly. +""" + +from __future__ import annotations + +from dppvalidator.validators.rules.v0_6.cirpass import ( + CIRPASS_RULES, + CIRPASSGranularityConsistencyRule, + CIRPASSMandatoryAttributesRule, + CIRPASSOperatorIdentifierRule, + CIRPASSSubstancesOfConcernRule, + CIRPASSValidityPeriodRule, + CIRPASSWeightVolumeRule, +) + +__all__ = [ + "CIRPASS_RULES", + "CIRPASSGranularityConsistencyRule", + "CIRPASSMandatoryAttributesRule", + "CIRPASSOperatorIdentifierRule", + "CIRPASSSubstancesOfConcernRule", + "CIRPASSValidityPeriodRule", + "CIRPASSWeightVolumeRule", +] diff --git a/src/dppvalidator/validators/rules/textile.py b/src/dppvalidator/validators/rules/textile.py new file mode 100644 index 0000000..1ea5044 --- /dev/null +++ b/src/dppvalidator/validators/rules/textile.py @@ -0,0 +1,37 @@ +"""Backward-compatibility re-export of v0.6.x textile-sector rules. + +Phase 3b of docs/plans/UNTP_0.7.0_MIGRATION.md split semantic rules into +``rules/v0_6/`` and ``rules/v0_7/`` subpackages. This shim preserves the +import path used by existing tests and any third-party plugin +(``from dppvalidator.validators.rules.textile import TEXTILE_RULES``) — see +the public-API stability contract in §7.6 of the plan. + +Through the 0.4.x line this re-exports v0.6.x rules. Phase 9 will switch +the default to v0.7 and update this shim accordingly. +""" + +from __future__ import annotations + +from dppvalidator.validators.rules.v0_6.textile import ( + TEXTILE_RULES, + TextileCareInstructionsRule, + TextileDurabilityRule, + TextileEnvironmentalCategory, + TextileHSCodeRule, + TextileMaterialCompositionRule, + TextileMicroplasticRule, + get_textile_environmental_categories, + is_textile_product, +) + +__all__ = [ + "TEXTILE_RULES", + "TextileCareInstructionsRule", + "TextileDurabilityRule", + "TextileEnvironmentalCategory", + "TextileHSCodeRule", + "TextileMaterialCompositionRule", + "TextileMicroplasticRule", + "get_textile_environmental_categories", + "is_textile_product", +] diff --git a/src/dppvalidator/validators/rules/v0_6/__init__.py b/src/dppvalidator/validators/rules/v0_6/__init__.py new file mode 100644 index 0000000..7e84f01 --- /dev/null +++ b/src/dppvalidator/validators/rules/v0_6/__init__.py @@ -0,0 +1,102 @@ +"""Semantic validation rules for UNTP v0.6.x. + +Phase 3b of docs/plans/UNTP_0.7.0_MIGRATION.md split the rule modules into +version-namespaced subpackages so the v0.6.x rules and v0.7.0 rules can +coexist. This subpackage holds the legacy v0.6.x rules, preserved verbatim. + +Each rule walks the v0.6 model shape (``passport.credential_subject.product.*``, +``passport.credential_subject.materials_provenance``, scorecard classes, etc.). +Rules that no longer apply to v0.7.0 — like +:class:`OperationalScopeRule`, which inspects +``EmissionsPerformance.operational_scope`` (a class that doesn't exist in +v0.7.0) — are kept here but excluded from the v0.7.0 rule set in +``rules/v0_7/__init__.py``. +""" + +from __future__ import annotations + +from dppvalidator.validators.rules.v0_6.base import ( + CircularityContentRule, + ConformityClaimRule, + GranularitySerialNumberRule, + GTINChecksumRule, + HazardousMaterialRule, + HSCodeRule, + MassFractionSumRule, + MaterialCodeRule, + OperationalScopeRule, + ValidityDateRule, +) +from dppvalidator.validators.rules.v0_6.cirpass import ( + CIRPASS_RULES, + CIRPASSGranularityConsistencyRule, + CIRPASSMandatoryAttributesRule, + CIRPASSOperatorIdentifierRule, + CIRPASSSubstancesOfConcernRule, + CIRPASSValidityPeriodRule, + CIRPASSWeightVolumeRule, +) +from dppvalidator.validators.rules.v0_6.textile import ( + TEXTILE_RULES, + TextileCareInstructionsRule, + TextileDurabilityRule, + TextileEnvironmentalCategory, + TextileHSCodeRule, + TextileMaterialCompositionRule, + TextileMicroplasticRule, + get_textile_environmental_categories, + is_textile_product, +) + +# The default v0.6.x rule list — includes everything in the base file plus +# CIRPASS-2 CQ-based rules. Textile rules are *not* part of the default set +# (they're sector-specific and opt-in). This list is what +# ``ALL_RULES_BY_VERSION["0.6.x"]`` resolves to. +ALL_RULES_V0_6 = [ + # Base UNTP rules + MassFractionSumRule(), + ValidityDateRule(), + HazardousMaterialRule(), + CircularityContentRule(), + ConformityClaimRule(), + GranularitySerialNumberRule(), + OperationalScopeRule(), + MaterialCodeRule(), + HSCodeRule(), + GTINChecksumRule(), + # CIRPASS-2 rules (CQ-based) + *CIRPASS_RULES, +] + +__all__ = [ + "ALL_RULES_V0_6", + # Base rules + "CircularityContentRule", + "ConformityClaimRule", + "GTINChecksumRule", + "GranularitySerialNumberRule", + "HSCodeRule", + "HazardousMaterialRule", + "MassFractionSumRule", + "MaterialCodeRule", + "OperationalScopeRule", + "ValidityDateRule", + # CIRPASS rules + "CIRPASS_RULES", + "CIRPASSGranularityConsistencyRule", + "CIRPASSMandatoryAttributesRule", + "CIRPASSOperatorIdentifierRule", + "CIRPASSSubstancesOfConcernRule", + "CIRPASSValidityPeriodRule", + "CIRPASSWeightVolumeRule", + # Textile sector rules + "TEXTILE_RULES", + "TextileCareInstructionsRule", + "TextileDurabilityRule", + "TextileEnvironmentalCategory", + "TextileHSCodeRule", + "TextileMaterialCompositionRule", + "TextileMicroplasticRule", + "get_textile_environmental_categories", + "is_textile_product", +] diff --git a/src/dppvalidator/validators/rules/v0_6/base.py b/src/dppvalidator/validators/rules/v0_6/base.py new file mode 100644 index 0000000..0b39d53 --- /dev/null +++ b/src/dppvalidator/validators/rules/v0_6/base.py @@ -0,0 +1,398 @@ +"""Semantic validation rule implementations.""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from typing import TYPE_CHECKING, Literal + +from dppvalidator.vocabularies.code_lists import ( + is_valid_hs_code as _is_valid_hs_code, +) +from dppvalidator.vocabularies.code_lists import ( + is_valid_material_code as _is_valid_material_code, +) +from dppvalidator.vocabularies.code_lists import ( + validate_gtin as _validate_gtin, +) + +if TYPE_CHECKING: + from dppvalidator.models.passport import DigitalProductPassport + + +class MassFractionSumRule: + """SEM001: Material mass fractions should sum to 1.0. + + Per UNTP spec, partial declarations (sum < 1.0) are valid but should + be flagged as a warning. Only sum > 1.0 is physically impossible. + """ + + rule_id: str = "SEM001" + description: str = "Material mass fractions should sum to 1.0" + severity: Literal["error", "warning", "info"] = "warning" + suggestion: str = "Adjust material mass fractions to sum to 1.0 (100%)" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/SEM001" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + """Check mass fraction sum.""" + violations: list[tuple[str, str]] = [] + + if not passport.credential_subject: + return violations + + materials = passport.credential_subject.materials_provenance + if not materials: + return violations + + fractions = [m.mass_fraction for m in materials if m.mass_fraction is not None] + if fractions: + total = sum(fractions) + # Only flag if significantly different from 1.0 + # Partial declarations (< 1.0) are valid per UNTP spec + if abs(total - 1.0) > 0.01: + violations.append( + ( + "$.credentialSubject.materialsProvenance", + f"Mass fractions sum to {total:.3f}, expected 1.0 (partial declaration)", + ) + ) + + return violations + + +class ValidityDateRule: + """SEM002: validFrom must be before validUntil.""" + + rule_id: str = "SEM002" + description: str = "validFrom must be before validUntil" + severity: Literal["error", "warning", "info"] = "error" + suggestion: str = "Ensure validFrom is before validUntil" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/SEM002" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + """Check validity date ordering.""" + violations: list[tuple[str, str]] = [] + + if ( + passport.valid_from + and passport.valid_until + and passport.valid_from >= passport.valid_until + ): + violations.append( + ( + "$.validFrom", + f"validFrom ({passport.valid_from}) must be before validUntil ({passport.valid_until})", + ) + ) + + return violations + + +class HazardousMaterialRule: + """SEM003: hazardous=true requires materialSafetyInformation.""" + + rule_id: str = "SEM003" + description: str = "Hazardous materials require safety information" + severity: Literal["error", "warning", "info"] = "error" + suggestion: str = "Add materialSafetyInformation for hazardous materials" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/SEM003" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + """Check hazardous material safety info.""" + violations: list[tuple[str, str]] = [] + + if not passport.credential_subject: + return violations + + materials = passport.credential_subject.materials_provenance + if not materials: + return violations + + for i, material in enumerate(materials): + if material.hazardous and not material.material_safety_information: + violations.append( + ( + f"$.credentialSubject.materialsProvenance[{i}]", + f"Material '{material.name}' is hazardous but missing materialSafetyInformation", + ) + ) + + return violations + + +class CircularityContentRule: + """SEM004: recycledContent should not exceed recyclableContent.""" + + rule_id: str = "SEM004" + description: str = "recycledContent should not exceed recyclableContent" + severity: Literal["error", "warning", "info"] = "warning" + suggestion: str = "recycledContent cannot exceed recyclableContent" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/SEM004" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + """Check circularity content consistency.""" + violations: list[tuple[str, str]] = [] + + if not passport.credential_subject: + return violations + + scorecard = passport.credential_subject.circularity_scorecard + if not scorecard: + return violations + + recycled = scorecard.recycled_content + recyclable = scorecard.recyclable_content + + if recycled is not None and recyclable is not None and recycled > recyclable: + violations.append( + ( + "$.credentialSubject.circularityScorecard", + f"recycledContent ({recycled}) exceeds recyclableContent ({recyclable})", + ) + ) + + return violations + + +class ConformityClaimRule: + """SEM005: At least one conformityClaim is recommended.""" + + rule_id: str = "SEM005" + description: str = "At least one conformityClaim is recommended" + severity: Literal["error", "warning", "info"] = "info" + suggestion: str = "Add conformity claims for sustainability or compliance" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/SEM005" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + """Check for conformity claims.""" + violations: list[tuple[str, str]] = [] + + if not passport.credential_subject: + return violations + + claims = passport.credential_subject.conformity_claim + if not claims: + violations.append( + ( + "$.credentialSubject.conformityClaim", + "No conformity claims present. Consider adding sustainability or compliance claims.", + ) + ) + + return violations + + +class GranularitySerialNumberRule: + """SEM006: granularityLevel=item requires serialNumber.""" + + rule_id: str = "SEM006" + description: str = "Item-level passports require serial numbers" + severity: Literal["error", "warning", "info"] = "warning" + suggestion: str = "Add serialNumber for item-level granularity passports" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/SEM006" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + """Check serial number for item-level passports.""" + violations: list[tuple[str, str]] = [] + + if not passport.credential_subject: + return violations + + granularity = passport.credential_subject.granularity_level + product = passport.credential_subject.product + + if ( + granularity + and str(granularity) == "item" + and (not product or not product.serial_number) + ): + violations.append( + ( + "$.credentialSubject.product.serialNumber", + "granularityLevel is 'item' but serialNumber is missing", + ) + ) + + return violations + + +class OperationalScopeRule: + """SEM007: carbonFootprint should have operationalScope defined.""" + + rule_id: str = "SEM007" + description: str = "Emissions data should specify operational scope" + severity: Literal["error", "warning", "info"] = "warning" + suggestion: str = "Specify operationalScope with carbonFootprint data" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/SEM007" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + """Check operational scope for emissions data.""" + violations: list[tuple[str, str]] = [] + + if not passport.credential_subject: + return violations + + scorecard = passport.credential_subject.emissions_scorecard + if not scorecard: + return violations + + if scorecard.carbon_footprint and not scorecard.operational_scope: + violations.append( + ( + "$.credentialSubject.emissionsScorecard.operationalScope", + "carbonFootprint is specified but operationalScope is missing", + ) + ) + + return violations + + +class MaterialCodeRule: + """VOC003: Material code must be valid per UNECE Rec 46. + + Validates material codes in materialsProvenance against the + UNECE Recommendation 46 material classification codes. + """ + + rule_id: str = "VOC003" + description: str = "Material code must be in UNECE Rec 46" + severity: Literal["error", "warning", "info"] = "warning" + suggestion: str = "Use a valid UNECE Rec 46 material code" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/VOC003" + + def __init__( + self, + material_validator: Callable[[str], bool] | None = None, + ) -> None: + """Initialize with optional custom validator.""" + self._is_valid_material_code = material_validator or _is_valid_material_code + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + """Check material codes against UNECE Rec 46.""" + violations: list[tuple[str, str]] = [] + + if not passport.credential_subject: + return violations + + materials = passport.credential_subject.materials_provenance + if not materials: + return violations + + for i, material in enumerate(materials): + # Check material_type.code if present + if material.material_type and material.material_type.code: + code = material.material_type.code + if not self._is_valid_material_code(code): + violations.append( + ( + f"$.credentialSubject.materialsProvenance[{i}].materialType.code", + f"Invalid material code '{code}' - not found in UNECE Rec 46", + ) + ) + + return violations + + +class HSCodeRule: + """VOC004: HS code must be valid for product category. + + Validates HS codes in product information against the + Harmonized System textile chapters (50-63). + """ + + rule_id: str = "VOC004" + description: str = "HS code must be valid for product category" + severity: Literal["error", "warning", "info"] = "warning" + suggestion: str = "Use a valid HS code for textiles (chapters 50-63)" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/VOC004" + + def __init__( + self, + hs_validator: Callable[[str], bool] | None = None, + ) -> None: + """Initialize with optional custom validator.""" + self._is_valid_hs_code = hs_validator or _is_valid_hs_code + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + """Check HS codes for validity.""" + violations: list[tuple[str, str]] = [] + + if not passport.credential_subject: + return violations + + product = passport.credential_subject.product + if not product: + return violations + + # Check product category classifications for HS codes + if product.product_category: + for i, classification in enumerate(product.product_category): + code = classification.code if classification.code else "" + # Only validate if it looks like an HS code (4+ digits) + if code.isdigit() and len(code) >= 4 and not self._is_valid_hs_code(code): + violations.append( + ( + f"$.credentialSubject.product.productCategory[{i}].code", + f"Invalid HS code '{code}' - not found in textile chapters (50-63)", + ) + ) + + return violations + + +class GTINChecksumRule: + """VOC005: GTIN must have valid check digit. + + Validates GTIN (Global Trade Item Number) checksums in product + identifiers using the GS1 check digit algorithm. + """ + + rule_id: str = "VOC005" + description: str = "GTIN must have valid check digit" + severity: Literal["error", "warning", "info"] = "error" + suggestion: str = "Verify the GTIN check digit using GS1 algorithm" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/VOC005" + + def __init__( + self, + gtin_validator: Callable[[str], bool] | None = None, + ) -> None: + """Initialize with optional custom validator.""" + self._validate_gtin = gtin_validator or _validate_gtin + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + """Check GTIN checksums.""" + violations: list[tuple[str, str]] = [] + + if not passport.credential_subject: + return violations + + product = passport.credential_subject.product + if not product: + return violations + + # Check product ID if it looks like a GTIN + if product.id: + product_id = product.id + # Extract GTIN from GS1 Digital Link or plain number + if "/01/" in product_id: + match = re.search(r"/01/(\d{8,14})", product_id) + if match: + gtin = match.group(1) + if not self._validate_gtin(gtin): + violations.append( + ( + "$.credentialSubject.product.id", + f"Invalid GTIN checksum in '{gtin}'", + ) + ) + elif product_id.isdigit() and len(product_id) in (8, 12, 13, 14): + if not self._validate_gtin(product_id): + violations.append( + ( + "$.credentialSubject.product.id", + f"Invalid GTIN checksum in '{product_id}'", + ) + ) + + return violations diff --git a/src/dppvalidator/validators/rules/v0_6/cirpass.py b/src/dppvalidator/validators/rules/v0_6/cirpass.py new file mode 100644 index 0000000..441e43f --- /dev/null +++ b/src/dppvalidator/validators/rules/v0_6/cirpass.py @@ -0,0 +1,306 @@ +"""CIRPASS-2 semantic validation rules based on Competency Questions. + +Source: Ontology Requirements Specification for an EU DPP Core Ontology Proposal +DOI: 10.5281/zenodo.14892665 + +These rules implement validation checks derived from CIRPASS-2 Competency Questions +(CQs) which define functional requirements for the EU DPP Core Ontology. +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from dppvalidator.models.passport import DigitalProductPassport + + +class CIRPASSMandatoryAttributesRule: + """CQ001: DPP must have mandatory attributes per ESPR. + + Based on CQ1: "What are the values of all or selected mandatory attributes + of a DPP required by ESPR and delegated acts?" + + Checks that essential DPP attributes are present. + """ + + rule_id: str = "CQ001" + description: str = "DPP must have mandatory ESPR attributes" + severity: Literal["error", "warning", "info"] = "error" + suggestion: str = "Add required attributes: issuer, validFrom, credentialSubject" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/CQ001" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + """Check mandatory DPP attributes are present.""" + violations: list[tuple[str, str]] = [] + + # Check issuer (ESPR Annex III (g)) + if not passport.issuer: + violations.append(("$.issuer", "Missing issuer - required per ESPR Annex III (g)")) + + # Check validFrom (ESPR Art 9(2i)) + if not passport.valid_from: + violations.append(("$.validFrom", "Missing validFrom - required per ESPR Art 9(2i)")) + + # Check credentialSubject + if not passport.credential_subject: + violations.append( + ( + "$.credentialSubject", + "Missing credentialSubject - DPP has no product data", + ) + ) + return violations + + # Check product (core content) + if not passport.credential_subject.product: + violations.append( + ( + "$.credentialSubject.product", + "Missing product information - required per ESPR", + ) + ) + + return violations + + +class CIRPASSSubstancesOfConcernRule: + """CQ004: Substances of concern must have proper identification. + + Based on CQ4: "What are the names and numeric codes of substances of + concern present in the product?" + + Checks that substances of concern have CAS numbers or other identifiers. + """ + + rule_id: str = "CQ004" + description: str = "Substances of concern must have CAS numbers" + severity: Literal["error", "warning", "info"] = "error" + suggestion: str = "Add CAS number or EINECS number for each substance of concern" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/CQ004" + + # CAS number pattern: NNNNNN-NN-N (digits with hyphens) + CAS_PATTERN = re.compile(r"^\d{2,7}-\d{2}-\d$") + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + """Check substances of concern have proper identification.""" + violations: list[tuple[str, str]] = [] + + if not passport.credential_subject: + return violations + + materials = passport.credential_subject.materials_provenance + if not materials: + return violations + + for i, material in enumerate(materials): + # Check if material is flagged as hazardous but lacks identifier + if material.hazardous: + has_cas = False + # Check if name contains CAS-like pattern or has ID + if material.name and self.CAS_PATTERN.search(material.name): + has_cas = True + # Check material_type for code + if material.material_type and material.material_type.code: + code = material.material_type.code + if self.CAS_PATTERN.match(code) or code.startswith("EINECS"): + has_cas = True + + if not has_cas: + violations.append( + ( + f"$.credentialSubject.materialsProvenance[{i}]", + f"Hazardous material '{material.name}' missing CAS/EINECS " + "number per ESPR Art 7(5a)", + ) + ) + + return violations + + +class CIRPASSOperatorIdentifierRule: + """CQ011: Manufacturer must have unique operator identifier. + + Based on CQ11: "What is the manufacturer's unique operator identifier + of a product?" + + Checks that the issuer (manufacturer) has a proper identifier. + """ + + rule_id: str = "CQ011" + description: str = "Manufacturer must have unique operator identifier" + severity: Literal["error", "warning", "info"] = "error" + suggestion: str = "Add issuer.id with GLN, DUNS, or LEI identifier" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/CQ011" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + """Check manufacturer has unique operator identifier.""" + violations: list[tuple[str, str]] = [] + + if not passport.issuer: + violations.append( + ( + "$.issuer", + "Missing issuer - cannot verify operator identifier", + ) + ) + return violations + + # Check issuer has an ID + if not passport.issuer.id: + violations.append( + ( + "$.issuer.id", + "Missing issuer.id - manufacturer requires unique operator " + "identifier per ESPR Annex III (g)", + ) + ) + + return violations + + +class CIRPASSValidityPeriodRule: + """CQ016: DPP must have validity period information. + + Based on CQ16: "What is the date the product was placed on the EU market + and what is the duration of validity period of the DPP?" + + Checks that DPP has market placement date and validity period. + """ + + rule_id: str = "CQ016" + description: str = "DPP must have validity period" + severity: Literal["error", "warning", "info"] = "warning" + suggestion: str = "Add validFrom and validUntil dates per ESPR Art 9(2i)" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/CQ016" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + """Check DPP has validity period.""" + violations: list[tuple[str, str]] = [] + + if not passport.valid_from: + violations.append( + ( + "$.validFrom", + "Missing validFrom - market placement date required per ESPR Art 9(2i)", + ) + ) + + if not passport.valid_until: + violations.append( + ( + "$.validUntil", + "Missing validUntil - DPP validity duration recommended per ESPR Art 9(2i)", + ) + ) + + return violations + + +class CIRPASSWeightVolumeRule: + """CQ020: Product must declare weight and volume. + + Based on CQ20: "What are the weight and volume of the product and its + packaging, and the product-to-packaging ratio?" + + Checks that product has weight/volume declarations per ESPR Annex I(j). + """ + + rule_id: str = "CQ020" + description: str = "Product should declare weight and volume" + severity: Literal["error", "warning", "info"] = "warning" + suggestion: str = "Add product dimension (weight/volume) per ESPR Annex I(j)" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/CQ020" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + """Check product has weight/volume declarations.""" + violations: list[tuple[str, str]] = [] + + if not passport.credential_subject: + return violations + + product = passport.credential_subject.product + if not product: + return violations + + # Check for dimensions data + has_dimension = False + if product.dimensions: + dim = product.dimensions + if dim.weight or dim.length or dim.width or dim.height or dim.volume: + has_dimension = True + + if not has_dimension: + violations.append( + ( + "$.credentialSubject.product.dimension", + "Missing weight/volume - product dimensions recommended per ESPR Annex I(j)", + ) + ) + + return violations + + +class CIRPASSGranularityConsistencyRule: + """CQ017: Granularity level must be consistent with identifiers. + + Based on CQ17 and Standardisation Request 5423: granularityLevel must + align with available identifiers (model/batch/item). + + Checks granularity level consistency with identifier presence. + """ + + rule_id: str = "CQ017" + description: str = "Granularity level must match identifier granularity" + severity: Literal["error", "warning", "info"] = "warning" + suggestion: str = "Ensure granularityLevel matches available identifiers" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/CQ017" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + """Check granularity level consistency.""" + violations: list[tuple[str, str]] = [] + + if not passport.credential_subject: + return violations + + granularity = passport.credential_subject.granularity_level + product = passport.credential_subject.product + + if not granularity or not product: + return violations + + granularity_str = str(granularity).lower() + + # Item level requires serial number + if granularity_str == "item" and not product.serial_number: + violations.append( + ( + "$.credentialSubject.product.serialNumber", + "granularityLevel is 'item' but serialNumber is missing - " + "per SR5423 Annex II Part B 1.1(4)", + ) + ) + + # Batch level requires batch number + if granularity_str == "batch" and not product.batch_number: + violations.append( + ( + "$.credentialSubject.product.batchNumber", + "granularityLevel is 'batch' but batchNumber is missing - " + "per SR5423 Annex II Part B 1.1(3)", + ) + ) + + return violations + + +# List of all CIRPASS rules for easy registration +CIRPASS_RULES = [ + CIRPASSMandatoryAttributesRule(), + CIRPASSSubstancesOfConcernRule(), + CIRPASSOperatorIdentifierRule(), + CIRPASSValidityPeriodRule(), + CIRPASSWeightVolumeRule(), + CIRPASSGranularityConsistencyRule(), +] diff --git a/src/dppvalidator/validators/rules/v0_6/textile.py b/src/dppvalidator/validators/rules/v0_6/textile.py new file mode 100644 index 0000000..a227008 --- /dev/null +++ b/src/dppvalidator/validators/rules/v0_6/textile.py @@ -0,0 +1,359 @@ +"""CIRPASS-2 textile sector validation rules. + +Source: DPP Granularity Options for Textiles/Apparel +DOI: 10.5281/zenodo.17582219 + +These rules implement textile-specific validation based on CIRPASS-2 +granularity analysis and JRC preparatory study environmental categories. +""" + +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from dppvalidator.models.passport import DigitalProductPassport + + +class TextileEnvironmentalCategory(str, Enum): + """Environmental impact categories for textiles per JRC preparatory study.""" + + WATER_CONSUMPTION = "water_consumption" + ENERGY_CONSUMPTION = "energy_consumption" + CHEMICAL_SUBSTANCES = "chemical_substances" + TEXTILE_WASTE = "textile_waste" + GHG_EMISSIONS = "ghg_emissions" + MICROPLASTIC_RELEASE = "microplastic_release" + POLLUTION = "pollution" # COD/NOx/SOx + + +# HS code chapters for textiles (50-63) +TEXTILE_HS_CHAPTERS = frozenset(str(i) for i in range(50, 64)) + +# Textile fiber material codes (UNECE Rec 46 subset) +TEXTILE_MATERIAL_CODES = frozenset( + [ + "COTTON", + "CO", + "WOOL", + "WO", + "SILK", + "SE", + "LINEN", + "LI", + "POLYESTER", + "PL", + "NYLON", + "PA", + "ACRYLIC", + "PC", + "VISCOSE", + "VI", + "ELASTANE", + "EL", + "MODAL", + "MD", + "LYOCELL", + "CLY", + "HEMP", + "HA", + "JUTE", + "JU", + "RAMIE", + "RA", + "CASHMERE", + "WS", + "ALPACA", + "WP", + "MOHAIR", + "WM", + "ANGORA", + "WA", + ] +) + + +class TextileHSCodeRule: + """TXT001: Product must have valid textile HS code. + + Validates that textile products have HS codes in chapters 50-63. + """ + + rule_id: str = "TXT001" + description: str = "Textile product must have valid HS code (chapters 50-63)" + severity: Literal["error", "warning", "info"] = "warning" + suggestion: str = "Add product category with HS code in chapters 50-63" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/TXT001" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + """Check textile product has valid HS code.""" + violations: list[tuple[str, str]] = [] + + if not passport.credential_subject: + return violations + + product = passport.credential_subject.product + if not product: + return violations + + # Check product category for HS codes + if not product.product_category: + violations.append( + ( + "$.credentialSubject.product.productCategory", + "Textile product missing product category with HS code", + ) + ) + return violations + + has_textile_hs = False + for classification in product.product_category: + if classification.code: + code = classification.code.replace(".", "").replace(" ", "") + if len(code) >= 2 and code[:2] in TEXTILE_HS_CHAPTERS: + has_textile_hs = True + break + + if not has_textile_hs: + violations.append( + ( + "$.credentialSubject.product.productCategory", + "No textile HS code found (chapters 50-63 required)", + ) + ) + + return violations + + +class TextileMaterialCompositionRule: + """TXT002: Textile must declare material composition. + + Per ESPR requirements, textile products must declare fiber composition. + """ + + rule_id: str = "TXT002" + description: str = "Textile must declare material composition" + severity: Literal["error", "warning", "info"] = "error" + suggestion: str = "Add materialsProvenance with fiber types and mass fractions" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/TXT002" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + """Check textile has material composition.""" + violations: list[tuple[str, str]] = [] + + if not passport.credential_subject: + return violations + + materials = passport.credential_subject.materials_provenance + if not materials: + violations.append( + ( + "$.credentialSubject.materialsProvenance", + "Textile product missing material composition declaration", + ) + ) + return violations + + # Check at least one material has mass fraction + has_fraction = False + for material in materials: + if material.mass_fraction is not None: + has_fraction = True + break + + if not has_fraction: + violations.append( + ( + "$.credentialSubject.materialsProvenance", + "Textile materials missing mass fraction (fiber %) declaration", + ) + ) + + return violations + + +class TextileMicroplasticRule: + """TXT003: Synthetic textiles should declare microplastic release. + + Per JRC preparatory study, synthetic fiber products should declare + microplastic release potential. + """ + + rule_id: str = "TXT003" + description: str = "Synthetic textiles should declare microplastic release" + severity: Literal["error", "warning", "info"] = "info" + suggestion: str = "Add microplastic release data for synthetic fiber products" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/TXT003" + + # Synthetic fiber codes that may release microplastics + SYNTHETIC_FIBERS = frozenset( + [ + "POLYESTER", + "PL", + "NYLON", + "PA", + "ACRYLIC", + "PC", + "ELASTANE", + "EL", + "POLYPROPYLENE", + "PP", + ] + ) + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + """Check synthetic textile declares microplastic release.""" + violations: list[tuple[str, str]] = [] + + if not passport.credential_subject: + return violations + + materials = passport.credential_subject.materials_provenance + if not materials: + return violations + + # Check if product contains synthetic fibers + has_synthetic = False + for material in materials: + if material.name: + name_upper = material.name.upper() + if any(fiber in name_upper for fiber in self.SYNTHETIC_FIBERS): + has_synthetic = True + break + if material.material_type and material.material_type.code: + code_upper = material.material_type.code.upper() + if code_upper in self.SYNTHETIC_FIBERS: + has_synthetic = True + break + + if not has_synthetic: + return violations + + # Check for environmental footprint data + scorecard = passport.credential_subject.circularity_scorecard + emissions = passport.credential_subject.emissions_scorecard + + # If synthetic but no environmental data, suggest microplastic info + if not scorecard and not emissions: + violations.append( + ( + "$.credentialSubject", + "Synthetic textile product - consider adding microplastic " + "release data per JRC preparatory study", + ) + ) + + return violations + + +class TextileDurabilityRule: + """TXT004: Textile products should have durability information. + + Per ESPR Annex I, durability is a key product parameter for textiles. + """ + + rule_id: str = "TXT004" + description: str = "Textile should declare durability information" + severity: Literal["error", "warning", "info"] = "info" + suggestion: str = "Add product characteristics with durability data" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/TXT004" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + """Check textile has durability information.""" + violations: list[tuple[str, str]] = [] + + if not passport.credential_subject: + return violations + + product = passport.credential_subject.product + if not product: + return violations + + # Check for characteristics (where durability info would be) + if not product.characteristics: + violations.append( + ( + "$.credentialSubject.product.characteristics", + "Textile product - consider adding durability characteristics " + "per ESPR Annex I requirements", + ) + ) + + return violations + + +class TextileCareInstructionsRule: + """TXT005: Textile products should have care instructions. + + Care instructions are required for consumer textiles per EU labeling + requirements and extend product lifetime. + """ + + rule_id: str = "TXT005" + description: str = "Textile should have care instructions" + severity: Literal["error", "warning", "info"] = "info" + suggestion: str = "Add furtherInformation link to care instructions" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/TXT005" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + """Check textile has care instructions.""" + violations: list[tuple[str, str]] = [] + + if not passport.credential_subject: + return violations + + product = passport.credential_subject.product + if not product: + return violations + + # Check for further information links + if not product.further_information: + violations.append( + ( + "$.credentialSubject.product.furtherInformation", + "Textile product - consider adding care instructions link", + ) + ) + + return violations + + +# List of all textile rules for easy registration +TEXTILE_RULES = [ + TextileHSCodeRule(), + TextileMaterialCompositionRule(), + TextileMicroplasticRule(), + TextileDurabilityRule(), + TextileCareInstructionsRule(), +] + + +def is_textile_product(passport: DigitalProductPassport) -> bool: + """Check if a passport represents a textile product. + + Args: + passport: Digital Product Passport to check + + Returns: + True if product has textile HS code (chapters 50-63) + """ + if not passport.credential_subject: + return False + + product = passport.credential_subject.product + if not product or not product.product_category: + return False + + for classification in product.product_category: + if classification.code: + code = classification.code.replace(".", "").replace(" ", "") + if len(code) >= 2 and code[:2] in TEXTILE_HS_CHAPTERS: + return True + + return False + + +def get_textile_environmental_categories() -> list[str]: + """Get list of textile environmental impact categories.""" + return [cat.value for cat in TextileEnvironmentalCategory] diff --git a/src/dppvalidator/validators/rules/v0_7/__init__.py b/src/dppvalidator/validators/rules/v0_7/__init__.py new file mode 100644 index 0000000..54e2f9f --- /dev/null +++ b/src/dppvalidator/validators/rules/v0_7/__init__.py @@ -0,0 +1,96 @@ +"""Semantic validation rules for UNTP v0.7.0. + +Phase 3b of docs/plans/UNTP_0.7.0_MIGRATION.md added this subpackage to +hold semantic rules adapted to the v0.7.0 model shape. Each rule walks +the new envelope (``credentialSubject`` is :class:`Product` directly, +``materialProvenance`` is the singular noun, scorecards collapse into +``performanceClaim``). Rules whose underlying field/class no longer +exists in v0.7 are dropped — see +:data:`dppvalidator.validators.rules.v0_7.base.DROPPED_RULES_V0_6_TO_V0_7`. + +The v0.7 :data:`ALL_RULES_V0_7` list mirrors the v0.6 default-set with +those drops applied (e.g. ``OperationalScopeRule``). +""" + +from __future__ import annotations + +from dppvalidator.validators.rules.v0_7.base import ( + DROPPED_RULES_V0_6_TO_V0_7, + CircularityContentRule, + ConformityClaimRule, + GranularitySerialNumberRule, + GTINChecksumRule, + HazardousMaterialRule, + HSCodeRule, + MassFractionSumRule, + MaterialCodeRule, + ValidityDateRule, +) +from dppvalidator.validators.rules.v0_7.cirpass import ( + CIRPASS_RULES_V0_7, + CIRPASSGranularityConsistencyRule, + CIRPASSMandatoryAttributesRule, + CIRPASSOperatorIdentifierRule, + CIRPASSSubstancesOfConcernRule, + CIRPASSValidityPeriodRule, + CIRPASSWeightVolumeRule, +) +from dppvalidator.validators.rules.v0_7.textile import ( + TEXTILE_RULES_V0_7, + TextileCareInstructionsRule, + TextileDurabilityRule, + TextileEnvironmentalCategory, + TextileHSCodeRule, + TextileMaterialCompositionRule, + TextileMicroplasticRule, + is_textile_product, +) + +# v0.7 default rule list. Note the absence of OperationalScopeRule +# (SEM007 — folded into Claim/Performance in v0.7; see DROPPED_RULES). +ALL_RULES_V0_7 = [ + # Base UNTP rules (v0.7 shape) + MassFractionSumRule(), + ValidityDateRule(), + HazardousMaterialRule(), + CircularityContentRule(), + ConformityClaimRule(), + GranularitySerialNumberRule(), + MaterialCodeRule(), + HSCodeRule(), + GTINChecksumRule(), + # CIRPASS-2 CQ-coded rules (v0.7 shape) + *CIRPASS_RULES_V0_7, +] + +__all__ = [ + "ALL_RULES_V0_7", + "DROPPED_RULES_V0_6_TO_V0_7", + # Base + "CircularityContentRule", + "ConformityClaimRule", + "GTINChecksumRule", + "GranularitySerialNumberRule", + "HSCodeRule", + "HazardousMaterialRule", + "MassFractionSumRule", + "MaterialCodeRule", + "ValidityDateRule", + # CIRPASS + "CIRPASS_RULES_V0_7", + "CIRPASSGranularityConsistencyRule", + "CIRPASSMandatoryAttributesRule", + "CIRPASSOperatorIdentifierRule", + "CIRPASSSubstancesOfConcernRule", + "CIRPASSValidityPeriodRule", + "CIRPASSWeightVolumeRule", + # Textile + "TEXTILE_RULES_V0_7", + "TextileCareInstructionsRule", + "TextileDurabilityRule", + "TextileEnvironmentalCategory", + "TextileHSCodeRule", + "TextileMaterialCompositionRule", + "TextileMicroplasticRule", + "is_textile_product", +] diff --git a/src/dppvalidator/validators/rules/v0_7/base.py b/src/dppvalidator/validators/rules/v0_7/base.py new file mode 100644 index 0000000..f277e88 --- /dev/null +++ b/src/dppvalidator/validators/rules/v0_7/base.py @@ -0,0 +1,429 @@ +"""Base UNTP semantic rules adapted for the v0.7.0 model shape. + +The rules carry the same SEM/VOC error codes as their v0.6.x counterparts in +:mod:`dppvalidator.validators.rules.v0_6.base`, but they walk the new +v0.7.0 envelope: ``credentialSubject`` is :class:`Product` directly (no +``ProductPassport`` wrapper), ``materialProvenance`` is the singular noun, +and the three v0.6 scorecard classes are folded into +``performanceClaim: list[Claim]`` keyed by ``conformityTopic``. + +Rules dropped (not in :data:`ALL_RULES_V0_7`): + +- ``OperationalScopeRule`` (SEM007). v0.7 doesn't expose + ``EmissionsPerformance.operationalScope`` because the + ``EmissionsPerformance`` class itself is gone. The plan §Phase 3b says + to drop with a deprecation note rather than retro-fitting onto a + metadata claim — see :data:`DROPPED_RULES_V0_6_TO_V0_7`. + +Rules ported: + +- ``MassFractionSumRule`` (SEM001) — walks ``credential_subject.material_provenance``. +- ``ValidityDateRule`` (SEM002) — same envelope shape; defensive duplicate + of the model-level ``_validate_validity_window`` invariant. +- ``HazardousMaterialRule`` (SEM003) — walks ``material_provenance``. +- ``CircularityContentRule`` (SEM004) — walks ``performance_claim`` filtered + by ``conformityTopic.name == "circularity"`` and reads from + ``claimedPerformance[*].measure`` / ``score``. +- ``ConformityClaimRule`` (SEM005) — walks ``performance_claim``. +- ``GranularitySerialNumberRule`` (SEM006) — defensive duplicate of the + model-level ``_granularity_implies_serial_or_batch`` invariant; covers + ``id_granularity == 'item'`` ↔ ``item_number`` and the new + ``id_granularity == 'batch'`` ↔ ``batch_number`` rule from v0.7. +- ``MaterialCodeRule`` (VOC003) — walks ``material_provenance[*].material_type.code``. +- ``HSCodeRule`` (VOC004) — walks ``credential_subject.product_category[*].code``. +- ``GTINChecksumRule`` (VOC005) — walks ``credential_subject.id``. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from typing import TYPE_CHECKING, Literal + +from dppvalidator.vocabularies.code_lists import ( + is_valid_hs_code as _is_valid_hs_code, +) +from dppvalidator.vocabularies.code_lists import ( + is_valid_material_code as _is_valid_material_code, +) +from dppvalidator.vocabularies.code_lists import ( + validate_gtin as _validate_gtin, +) + +if TYPE_CHECKING: + from dppvalidator.models.v0_7.claims import Claim + from dppvalidator.models.v0_7.envelope import DigitalProductPassport + +# A note for callers building rule sets manually: these rules don't apply to +# v0.7 because the underlying field/class no longer exists. Phase 3b §Plan +# §3.2 captures the rationale. +DROPPED_RULES_V0_6_TO_V0_7: dict[str, str] = { + "SEM007": ( + "OperationalScopeRule was tied to v0.6 EmissionsPerformance.operational_scope. " + "v0.7.0 folds emissions data into Claim.claimedPerformance keyed by " + "ConformityTopic, so there's no longer a discrete operationalScope field " + "to validate. Drop the rule rather than retro-fitting." + ), +} + + +def _circularity_topic(claim: Claim) -> bool: + """True when the claim's conformityTopic includes ``"circularity"``. + + Loose match: the schema's ConformityTopic.name field is free-form, + so we check both the topic name and the topic ID for the substring + ``"circularity"`` (case-insensitive). Falls back to ``False`` if the + claim has no conformityTopic entries. + """ + topics = getattr(claim, "conformity_topic", None) or [] + for t in topics: + name = (getattr(t, "name", "") or "").lower() + ident = (getattr(t, "id", "") or "").lower() + if "circularity" in name or "circularity" in ident: + return True + return False + + +class MassFractionSumRule: + """SEM001 (v0.7): material mass-fractions should sum to 1.0. + + The model-level ``Product._mass_fractions_sum_within_unity`` invariant + catches sums *above* 1.0 (which are physically impossible). This + semantic rule warns on partial declarations (sum < 1.0) so the user is + aware their material list is incomplete. + """ + + rule_id: str = "SEM001" + description: str = "Material mass fractions should sum to 1.0" + severity: Literal["error", "warning", "info"] = "warning" + suggestion: str = "Adjust material mass fractions to sum to 1.0 (100%)" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/SEM001" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + product = passport.credential_subject + if product is None: + return violations + + materials = getattr(product, "material_provenance", []) or [] + if not materials: + return violations + + fractions = [m.mass_fraction for m in materials if m.mass_fraction is not None] + if not fractions: + return violations + + total = sum(fractions) + if abs(total - 1.0) > 0.01: + violations.append( + ( + "$.credentialSubject.materialProvenance", + f"Mass fractions sum to {total:.3f}, expected 1.0 (partial declaration)", + ) + ) + return violations + + +class ValidityDateRule: + """SEM002 (v0.7): validFrom must be before validUntil. + + Defensive duplicate — the v0.7 envelope already enforces this as a + Pydantic model-level invariant (``DigitalProductPassport._validate_validity_window``). + The rule is kept so semantic-layer error reporting is consistent with + v0.6.x, where the same check is *only* at the semantic layer. + """ + + rule_id: str = "SEM002" + description: str = "validFrom must be before validUntil" + severity: Literal["error", "warning", "info"] = "error" + suggestion: str = "Ensure validFrom is before validUntil" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/SEM002" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + if ( + passport.valid_from + and passport.valid_until + and passport.valid_from >= passport.valid_until + ): + violations.append( + ( + "$.validFrom", + f"validFrom ({passport.valid_from}) must be before validUntil ({passport.valid_until})", + ) + ) + return violations + + +class HazardousMaterialRule: + """SEM003 (v0.7): hazardous=True requires materialSafetyInformation.""" + + rule_id: str = "SEM003" + description: str = "Hazardous materials require safety information" + severity: Literal["error", "warning", "info"] = "error" + suggestion: str = "Add materialSafetyInformation for hazardous materials" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/SEM003" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + product = passport.credential_subject + if product is None: + return violations + + materials = getattr(product, "material_provenance", []) or [] + for i, material in enumerate(materials): + if material.hazardous and not material.material_safety_information: + violations.append( + ( + f"$.credentialSubject.materialProvenance[{i}]", + f"Material '{material.name}' is hazardous but missing materialSafetyInformation", + ) + ) + return violations + + +class CircularityContentRule: + """SEM004 (v0.7): recycledContent should not exceed recyclableContent. + + v0.7 has no ``CircularityScorecard`` class; circularity is expressed as + a :class:`Claim` with ``conformityTopic`` containing "circularity", + whose ``claimedPerformance`` array carries the readings. This rule + inspects each circularity claim and pairs values whose ``metric.id`` + looks like a content-fraction metric. + + To keep the rule shape-tolerant — the upstream schema doesn't + constrain ``Performance.metric`` to a specific shape — we look up the + metric by name keywords (``recycled``, ``recyclable``) and compare + pairs. Tests in ``test_semantic_rules_v07.py`` exercise this with + canonical metric IDs. + """ + + rule_id: str = "SEM004" + description: str = "recycledContent should not exceed recyclableContent" + severity: Literal["error", "warning", "info"] = "warning" + suggestion: str = "recycledContent cannot exceed recyclableContent" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/SEM004" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + product = passport.credential_subject + if product is None: + return violations + + for claim_idx, claim in enumerate(getattr(product, "performance_claim", []) or []): + if not _circularity_topic(claim): + continue + recycled = recyclable = None + for perf in getattr(claim, "claimed_performance", []) or []: + metric = perf.metric or {} + key = " ".join(str(metric.get(k, "")) for k in ("id", "name", "label")).lower() + value = perf.measure.value if perf.measure else None + if value is None: + continue + if "recycledcontent" in key.replace(" ", "") or "recycled content" in key: + recycled = value + elif "recyclablecontent" in key.replace(" ", "") or "recyclable content" in key: + recyclable = value + if recycled is not None and recyclable is not None and recycled > recyclable: + violations.append( + ( + f"$.credentialSubject.performanceClaim[{claim_idx}]", + f"recycledContent ({recycled}) exceeds recyclableContent ({recyclable})", + ) + ) + return violations + + +class ConformityClaimRule: + """SEM005 (v0.7): at least one performanceClaim is recommended. + + v0.6's ``conformityClaim`` array is now ``performanceClaim`` on Product; + the warning text is updated accordingly so users searching for "no + conformity claims" are still pointed at the right field. + """ + + rule_id: str = "SEM005" + description: str = "At least one performanceClaim is recommended" + severity: Literal["error", "warning", "info"] = "info" + suggestion: str = "Add performance / conformity claims for sustainability or compliance" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/SEM005" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + product = passport.credential_subject + if product is None: + return violations + + claims = getattr(product, "performance_claim", []) or [] + if not claims: + violations.append( + ( + "$.credentialSubject.performanceClaim", + "No performance claims present. Consider adding sustainability or compliance claims.", + ) + ) + return violations + + +class GranularitySerialNumberRule: + """SEM006 (v0.7): item-/batch-level granularity require itemNumber/batchNumber. + + Defensive duplicate of the v0.7 model-level + ``Product._granularity_implies_serial_or_batch`` invariant. Issued at + semantic layer for parity with v0.6.x reporting (where it's the + only place the check happens). + """ + + rule_id: str = "SEM006" + description: str = "Item-/batch-level passports require an item or batch number" + severity: Literal["error", "warning", "info"] = "warning" + suggestion: str = "Add itemNumber for 'item' granularity or batchNumber for 'batch' granularity" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/SEM006" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + product = passport.credential_subject + if product is None: + return violations + + granularity = str(getattr(product, "id_granularity", "") or "") + if granularity == "item" and not getattr(product, "item_number", None): + violations.append( + ( + "$.credentialSubject.itemNumber", + "idGranularity is 'item' but itemNumber is missing", + ) + ) + elif granularity == "batch" and not getattr(product, "batch_number", None): + violations.append( + ( + "$.credentialSubject.batchNumber", + "idGranularity is 'batch' but batchNumber is missing", + ) + ) + return violations + + +class MaterialCodeRule: + """VOC003 (v0.7): material code must be valid per UNECE Rec 46.""" + + rule_id: str = "VOC003" + description: str = "Material code must be in UNECE Rec 46" + severity: Literal["error", "warning", "info"] = "warning" + suggestion: str = "Use a valid UNECE Rec 46 material code" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/VOC003" + + def __init__( + self, + material_validator: Callable[[str], bool] | None = None, + ) -> None: + self._is_valid_material_code = material_validator or _is_valid_material_code + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + product = passport.credential_subject + if product is None: + return violations + + materials = getattr(product, "material_provenance", []) or [] + for i, material in enumerate(materials): + mt = getattr(material, "material_type", None) + code = getattr(mt, "code", None) if mt else None + if code and not self._is_valid_material_code(code): + violations.append( + ( + f"$.credentialSubject.materialProvenance[{i}].materialType.code", + f"Invalid material code '{code}' - not found in UNECE Rec 46", + ) + ) + return violations + + +class HSCodeRule: + """VOC004 (v0.7): HS code must be valid for product category. + + v0.7 ``Product.product_category`` is a list of :class:`Classification` + (was a single Classification in v0.6); this rule iterates over them. + """ + + rule_id: str = "VOC004" + description: str = "HS code must be valid for product category" + severity: Literal["error", "warning", "info"] = "warning" + suggestion: str = "Use a valid HS code for textiles (chapters 50-63)" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/VOC004" + + def __init__( + self, + hs_validator: Callable[[str], bool] | None = None, + ) -> None: + self._is_valid_hs_code = hs_validator or _is_valid_hs_code + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + product = passport.credential_subject + if product is None: + return violations + + categories = getattr(product, "product_category", []) or [] + for i, classification in enumerate(categories): + code = getattr(classification, "code", None) or "" + if code.isdigit() and len(code) >= 4 and not self._is_valid_hs_code(code): + violations.append( + ( + f"$.credentialSubject.productCategory[{i}].code", + f"Invalid HS code '{code}' - not found in textile chapters (50-63)", + ) + ) + return violations + + +class GTINChecksumRule: + """VOC005 (v0.7): GTIN must have valid check digit. + + Walks ``credential_subject.id`` (was ``credentialSubject.product.id`` + in v0.6) and validates GS1 check-digits when the URI looks like a + GS1 Digital Link or a bare GTIN. + """ + + rule_id: str = "VOC005" + description: str = "GTIN must have valid check digit" + severity: Literal["error", "warning", "info"] = "error" + suggestion: str = "Verify the GTIN check digit using GS1 algorithm" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/VOC005" + + def __init__( + self, + gtin_validator: Callable[[str], bool] | None = None, + ) -> None: + self._validate_gtin = gtin_validator or _validate_gtin + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + product = passport.credential_subject + if product is None: + return violations + + product_id = getattr(product, "id", None) + if not product_id: + return violations + + if "/01/" in product_id: + match = re.search(r"/01/(\d{8,14})", product_id) + if match: + gtin = match.group(1) + if not self._validate_gtin(gtin): + violations.append( + ( + "$.credentialSubject.id", + f"Invalid GTIN checksum in '{gtin}'", + ) + ) + elif product_id.isdigit() and len(product_id) in (8, 12, 13, 14): + if not self._validate_gtin(product_id): + violations.append( + ( + "$.credentialSubject.id", + f"Invalid GTIN checksum in '{product_id}'", + ) + ) + return violations diff --git a/src/dppvalidator/validators/rules/v0_7/cirpass.py b/src/dppvalidator/validators/rules/v0_7/cirpass.py new file mode 100644 index 0000000..a1c2aa4 --- /dev/null +++ b/src/dppvalidator/validators/rules/v0_7/cirpass.py @@ -0,0 +1,240 @@ +"""CIRPASS-2 CQ-based rules adapted for the UNTP v0.7.0 envelope. + +Same CQ-coded rules as :mod:`dppvalidator.validators.rules.v0_6.cirpass`, +walking the new v0.7.0 shape: + +- ``credentialSubject`` is :class:`Product` directly (no + ``ProductPassport`` wrapper). The ``CIRPASSMandatoryAttributesRule`` + check that v0.6.x ran for ``credentialSubject.product`` is dropped + because the *presence* of the product subject IS the + ``credentialSubject``. +- ``materialsProvenance`` is renamed ``materialProvenance``. +- ``granularityLevel`` is renamed ``idGranularity`` and lives on + ``credentialSubject`` directly (not nested inside a ProductPassport). +- ``product.serial_number`` is now ``credentialSubject.item_number``. +- All CQ codes (CQ001, CQ004, CQ011, CQ016, CQ017, CQ020) are preserved. +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from dppvalidator.models.v0_7.envelope import DigitalProductPassport + + +class CIRPASSMandatoryAttributesRule: + """CQ001 (v0.7): DPP must have mandatory attributes per ESPR. + + For v0.7.0 the check simplifies: there is no ``credentialSubject.product`` + nesting, so we verify ``credentialSubject`` itself is present (which is + a Product) plus the mandatory envelope fields (``issuer``, ``validFrom``). + """ + + rule_id: str = "CQ001" + description: str = "DPP must have mandatory ESPR attributes" + severity: Literal["error", "warning", "info"] = "error" + suggestion: str = "Add required attributes: issuer, validFrom, credentialSubject" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/CQ001" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + + if not passport.issuer: + violations.append(("$.issuer", "Missing issuer - required per ESPR Annex III (g)")) + + if not passport.valid_from: + violations.append(("$.validFrom", "Missing validFrom - required per ESPR Art 9(2i)")) + + if not passport.credential_subject: + violations.append( + ( + "$.credentialSubject", + "Missing credentialSubject - DPP has no product data", + ) + ) + + return violations + + +class CIRPASSSubstancesOfConcernRule: + """CQ004 (v0.7): substances of concern must have proper identification.""" + + rule_id: str = "CQ004" + description: str = "Substances of concern must have CAS numbers" + severity: Literal["error", "warning", "info"] = "error" + suggestion: str = "Add CAS number or EINECS number for each substance of concern" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/CQ004" + + CAS_PATTERN = re.compile(r"^\d{2,7}-\d{2}-\d$") + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + product = passport.credential_subject + if product is None: + return violations + + materials = getattr(product, "material_provenance", []) or [] + for i, material in enumerate(materials): + if material.hazardous: + has_cas = False + if material.name and self.CAS_PATTERN.search(material.name): + has_cas = True + mt = getattr(material, "material_type", None) + code = getattr(mt, "code", None) if mt else None + if code and (self.CAS_PATTERN.match(code) or code.startswith("EINECS")): + has_cas = True + + if not has_cas: + violations.append( + ( + f"$.credentialSubject.materialProvenance[{i}]", + f"Hazardous material '{material.name}' missing CAS/EINECS " + "number per ESPR Art 7(5a)", + ) + ) + return violations + + +class CIRPASSOperatorIdentifierRule: + """CQ011 (v0.7): manufacturer must have unique operator identifier. + + Same shape as v0.6 (issuer.id is unchanged across versions); the rule + is duplicated only so the v0.7 ALL_RULES set carries it independently. + """ + + rule_id: str = "CQ011" + description: str = "Manufacturer must have unique operator identifier" + severity: Literal["error", "warning", "info"] = "error" + suggestion: str = "Add issuer.id with GLN, DUNS, or LEI identifier" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/CQ011" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + + if not passport.issuer: + violations.append(("$.issuer", "Missing issuer - cannot verify operator identifier")) + return violations + + if not passport.issuer.id: + violations.append( + ( + "$.issuer.id", + "Missing issuer.id - manufacturer requires unique operator " + "identifier per ESPR Annex III (g)", + ) + ) + return violations + + +class CIRPASSValidityPeriodRule: + """CQ016 (v0.7): DPP must have validity period information.""" + + rule_id: str = "CQ016" + description: str = "DPP must have validity period" + severity: Literal["error", "warning", "info"] = "warning" + suggestion: str = "Add validFrom and validUntil dates per ESPR Art 9(2i)" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/CQ016" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + + if not passport.valid_from: + violations.append( + ( + "$.validFrom", + "Missing validFrom - market placement date required per ESPR Art 9(2i)", + ) + ) + + if not passport.valid_until: + violations.append( + ( + "$.validUntil", + "Missing validUntil - DPP validity duration recommended per ESPR Art 9(2i)", + ) + ) + return violations + + +class CIRPASSWeightVolumeRule: + """CQ020 (v0.7): product should declare weight and volume.""" + + rule_id: str = "CQ020" + description: str = "Product should declare weight and volume" + severity: Literal["error", "warning", "info"] = "warning" + suggestion: str = "Add product dimensions (weight/volume) per ESPR Annex I(j)" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/CQ020" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + product = passport.credential_subject + if product is None: + return violations + + dim = getattr(product, "dimensions", None) + has_dimension = bool(dim) and any( + getattr(dim, attr, None) is not None + for attr in ("weight", "length", "width", "height", "volume") + ) + + if not has_dimension: + violations.append( + ( + "$.credentialSubject.dimensions", + "Missing weight/volume - product dimensions recommended per ESPR Annex I(j)", + ) + ) + return violations + + +class CIRPASSGranularityConsistencyRule: + """CQ017 (v0.7): granularity level must be consistent with identifiers. + + v0.7 renames ``granularityLevel`` → ``idGranularity`` and + ``serialNumber`` → ``itemNumber``. Codes and ESPR references unchanged. + """ + + rule_id: str = "CQ017" + description: str = "Granularity level must match identifier granularity" + severity: Literal["error", "warning", "info"] = "warning" + suggestion: str = "Ensure idGranularity matches available identifiers" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/CQ017" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + product = passport.credential_subject + if product is None: + return violations + + granularity = str(getattr(product, "id_granularity", "") or "").lower() + + if granularity == "item" and not getattr(product, "item_number", None): + violations.append( + ( + "$.credentialSubject.itemNumber", + "idGranularity is 'item' but itemNumber is missing - " + "per SR5423 Annex II Part B 1.1(4)", + ) + ) + + if granularity == "batch" and not getattr(product, "batch_number", None): + violations.append( + ( + "$.credentialSubject.batchNumber", + "idGranularity is 'batch' but batchNumber is missing - " + "per SR5423 Annex II Part B 1.1(3)", + ) + ) + return violations + + +CIRPASS_RULES_V0_7 = [ + CIRPASSMandatoryAttributesRule(), + CIRPASSSubstancesOfConcernRule(), + CIRPASSOperatorIdentifierRule(), + CIRPASSValidityPeriodRule(), + CIRPASSWeightVolumeRule(), + CIRPASSGranularityConsistencyRule(), +] diff --git a/src/dppvalidator/validators/rules/v0_7/textile.py b/src/dppvalidator/validators/rules/v0_7/textile.py new file mode 100644 index 0000000..c12206d --- /dev/null +++ b/src/dppvalidator/validators/rules/v0_7/textile.py @@ -0,0 +1,277 @@ +"""Textile sector rules adapted for the UNTP v0.7.0 envelope. + +Same TXT-coded rules as :mod:`dppvalidator.validators.rules.v0_6.textile`, +walking the v0.7 shape: + +- ``credentialSubject`` is the :class:`Product` directly (no + ``credentialSubject.product`` traversal). +- ``materialsProvenance`` → ``materialProvenance`` (singular). +- ``furtherInformation`` (v0.6 ``Product.furtherInformation: Link``) is + replaced by ``relatedDocument: list[Link]`` (v0.7 absorbs both + ``furtherInformation`` and ``dueDiligenceDeclaration`` into this array). +- The two scorecard classes (``circularityScorecard``, ``emissionsScorecard``) + are gone; the microplastic-data presence check now looks at + ``performanceClaim`` instead. + +The shared helpers — :class:`TextileEnvironmentalCategory`, +``TEXTILE_HS_CHAPTERS``, ``TEXTILE_MATERIAL_CODES`` — are imported from the +v0.6 module so they live in one place. They're version-neutral data tables. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +from dppvalidator.validators.rules.v0_6.textile import ( + TEXTILE_HS_CHAPTERS, + TextileEnvironmentalCategory, # re-export — version-neutral enum +) + +if TYPE_CHECKING: + from dppvalidator.models.v0_7.envelope import DigitalProductPassport + + +__all__ = [ + "TEXTILE_HS_CHAPTERS", + "TEXTILE_RULES_V0_7", + "TextileCareInstructionsRule", + "TextileDurabilityRule", + "TextileEnvironmentalCategory", + "TextileHSCodeRule", + "TextileMaterialCompositionRule", + "TextileMicroplasticRule", + "is_textile_product", +] + + +class TextileHSCodeRule: + """TXT001 (v0.7): textile product must have valid HS code.""" + + rule_id: str = "TXT001" + description: str = "Textile product must have valid HS code (chapters 50-63)" + severity: Literal["error", "warning", "info"] = "warning" + suggestion: str = "Add product category with HS code in chapters 50-63" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/TXT001" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + product = passport.credential_subject + if product is None: + return violations + + categories = getattr(product, "product_category", []) or [] + if not categories: + violations.append( + ( + "$.credentialSubject.productCategory", + "Textile product missing product category with HS code", + ) + ) + return violations + + has_textile_hs = False + for classification in categories: + code = getattr(classification, "code", None) or "" + stripped = code.replace(".", "").replace(" ", "") + if len(stripped) >= 2 and stripped[:2] in TEXTILE_HS_CHAPTERS: + has_textile_hs = True + break + + if not has_textile_hs: + violations.append( + ( + "$.credentialSubject.productCategory", + "No textile HS code found (chapters 50-63 required)", + ) + ) + return violations + + +class TextileMaterialCompositionRule: + """TXT002 (v0.7): textile must declare material composition.""" + + rule_id: str = "TXT002" + description: str = "Textile must declare material composition" + severity: Literal["error", "warning", "info"] = "error" + suggestion: str = "Add materialProvenance with fiber types and mass fractions" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/TXT002" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + product = passport.credential_subject + if product is None: + return violations + + materials = getattr(product, "material_provenance", []) or [] + if not materials: + violations.append( + ( + "$.credentialSubject.materialProvenance", + "Textile product missing material composition declaration", + ) + ) + return violations + + if not any(m.mass_fraction is not None for m in materials): + violations.append( + ( + "$.credentialSubject.materialProvenance", + "Textile materials missing mass fraction (fiber %) declaration", + ) + ) + return violations + + +class TextileMicroplasticRule: + """TXT003 (v0.7): synthetic textiles should declare microplastic release. + + v0.7 has no scorecard classes; the heuristic for "did the producer + declare environmental data?" is now "is there at least one + performanceClaim?". This is a deliberate softening — Phase 5/Phase 7 + can refine this when the topic taxonomy is settled. + """ + + rule_id: str = "TXT003" + description: str = "Synthetic textiles should declare microplastic release" + severity: Literal["error", "warning", "info"] = "info" + suggestion: str = "Add microplastic release data for synthetic fiber products" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/TXT003" + + SYNTHETIC_FIBERS = frozenset( + [ + "POLYESTER", + "PL", + "NYLON", + "PA", + "ACRYLIC", + "PC", + "ELASTANE", + "EL", + "POLYPROPYLENE", + "PP", + ] + ) + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + product = passport.credential_subject + if product is None: + return violations + + materials = getattr(product, "material_provenance", []) or [] + if not materials: + return violations + + has_synthetic = False + for material in materials: + name = (material.name or "").upper() + if any(fiber in name for fiber in self.SYNTHETIC_FIBERS): + has_synthetic = True + break + mt = getattr(material, "material_type", None) + code = (getattr(mt, "code", None) or "").upper() if mt else "" + if code in self.SYNTHETIC_FIBERS: + has_synthetic = True + break + + if not has_synthetic: + return violations + + # In v0.7 the "is there environmental data?" heuristic is the + # presence of any performanceClaim entry. If there's nothing, hint + # at adding microplastic data. + claims = getattr(product, "performance_claim", []) or [] + if not claims: + violations.append( + ( + "$.credentialSubject", + "Synthetic textile product - consider adding microplastic " + "release data per JRC preparatory study", + ) + ) + return violations + + +class TextileDurabilityRule: + """TXT004 (v0.7): textile products should have durability information.""" + + rule_id: str = "TXT004" + description: str = "Textile should declare durability information" + severity: Literal["error", "warning", "info"] = "info" + suggestion: str = "Add product characteristics with durability data" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/TXT004" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + product = passport.credential_subject + if product is None: + return violations + + if not getattr(product, "characteristics", None): + violations.append( + ( + "$.credentialSubject.characteristics", + "Textile product - consider adding durability characteristics " + "per ESPR Annex I requirements", + ) + ) + return violations + + +class TextileCareInstructionsRule: + """TXT005 (v0.7): textile products should have care instructions. + + v0.7 absorbs the v0.6 ``furtherInformation`` field into + ``relatedDocument: list[Link]``. The check now succeeds when at least + one link is present in ``relatedDocument``. + """ + + rule_id: str = "TXT005" + description: str = "Textile should have care instructions" + severity: Literal["error", "warning", "info"] = "info" + suggestion: str = "Add a relatedDocument link with care instructions" + docs_url: str = "https://artiso-ai.github.io/dppvalidator/errors/TXT005" + + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + product = passport.credential_subject + if product is None: + return violations + + documents = getattr(product, "related_document", []) or [] + if not documents: + violations.append( + ( + "$.credentialSubject.relatedDocument", + "Textile product - consider adding care instructions link", + ) + ) + return violations + + +TEXTILE_RULES_V0_7 = [ + TextileHSCodeRule(), + TextileMaterialCompositionRule(), + TextileMicroplasticRule(), + TextileDurabilityRule(), + TextileCareInstructionsRule(), +] + + +def is_textile_product(passport: DigitalProductPassport) -> bool: + """Return True when the v0.7 DPP describes a textile product. + + Mirrors the v0.6 helper, but walks the new envelope shape + (``passport.credential_subject.product_category`` instead of + ``passport.credential_subject.product.product_category``). + """ + product = passport.credential_subject + if product is None: + return False + + for classification in getattr(product, "product_category", []) or []: + code = (getattr(classification, "code", None) or "").replace(".", "").replace(" ", "") + if len(code) >= 2 and code[:2] in TEXTILE_HS_CHAPTERS: + return True + + return False diff --git a/src/dppvalidator/validators/schema.py b/src/dppvalidator/validators/schema.py index 3081216..50b49dc 100644 --- a/src/dppvalidator/validators/schema.py +++ b/src/dppvalidator/validators/schema.py @@ -7,20 +7,15 @@ import time from importlib import resources from pathlib import Path -from typing import Any +from typing import Any, Literal -from dppvalidator.validators.results import ValidationError, ValidationResult - -try: - from jsonschema import Draft202012Validator - from jsonschema import ValidationError as JsonSchemaError +from jsonschema import Draft202012Validator - HAS_JSONSCHEMA = True -except ImportError: - HAS_JSONSCHEMA = False - Draft202012Validator = None # type: ignore[misc, assignment] - JsonSchemaError = Exception # type: ignore[misc, assignment] +from dppvalidator.schemas.registry import DEFAULT_SCHEMA_VERSION +from dppvalidator.validators.results import ValidationError, ValidationResult +# Schema type for dual-mode validation (Phase 6) +SchemaType = Literal["untp", "cirpass"] # Stable error code mapping based on JSON Schema validator type SCHEMA_ERROR_CODES: dict[str, str] = { @@ -60,18 +55,29 @@ class SchemaValidator: def __init__( self, - schema_version: str = "0.6.1", + schema_version: str = DEFAULT_SCHEMA_VERSION, + schema_type: SchemaType = "untp", schema_path: Path | None = None, strict: bool = False, ) -> None: """Initialize schema validator. Args: - schema_version: UNTP DPP schema version + schema_version: Schema version. For ``schema_type="untp"`` use a + version registered in ``dppvalidator.schemas.SCHEMA_REGISTRY``; + for ``schema_type="cirpass"`` use a CIRPASS DPP version. Defaults + to ``DEFAULT_SCHEMA_VERSION``. + schema_type: Schema type - "untp" (default) or "cirpass" for EU DPP schema_path: Optional custom schema path. If None, uses bundled schema. strict: If True, disallows additional properties not in schema + + Raises: + ValueError: If schema_type is not "untp" or "cirpass" """ + if schema_type not in ("untp", "cirpass"): + raise ValueError(f"Invalid schema_type '{schema_type}'. Must be 'untp' or 'cirpass'.") self.schema_version = schema_version + self.schema_type = schema_type self.strict = strict self._schema: dict[str, Any] | None = None self._schema_path = schema_path @@ -83,16 +89,11 @@ def _load_schema(self) -> dict[str, Any]: return self._schema if self._schema_path: - self._schema = json.loads(self._schema_path.read_text()) + self._schema = json.loads(self._schema_path.read_text(encoding="utf-8")) + elif self.schema_type == "cirpass": + self._schema = self._load_cirpass_schema() else: - try: - schema_file = resources.files("dppvalidator.schemas.data").joinpath( - f"untp-dpp-schema-{self.schema_version}.json" - ) - self._schema = json.loads(schema_file.read_text()) - except (FileNotFoundError, ModuleNotFoundError): - # No bundled schema available - validation will be skipped - self._schema = {} + self._schema = self._load_untp_schema() # Apply strict mode: set additionalProperties to false if self.strict and self._schema: @@ -100,6 +101,34 @@ def _load_schema(self) -> dict[str, Any]: return self._schema + def _load_untp_schema(self) -> dict[str, Any]: + """Load UNTP DPP schema from bundled resources.""" + try: + schema_file = resources.files("dppvalidator.schemas.data").joinpath( + f"untp-dpp-schema-{self.schema_version}.json" + ) + return json.loads(schema_file.read_text(encoding="utf-8")) + except (FileNotFoundError, ModuleNotFoundError): + # No bundled schema available - validation will be skipped + return {} + + def _load_cirpass_schema(self) -> dict[str, Any]: + """Load CIRPASS DPP schema from bundled resources.""" + try: + from dppvalidator.schemas.cirpass_loader import CIRPASSSchemaLoader + + loader = CIRPASSSchemaLoader() + return loader.load() + except (ImportError, RuntimeError): + # Fall back to direct file loading + try: + schema_file = resources.files("dppvalidator.vocabularies.data.schemas").joinpath( + "cirpass_dpp_schema.json" + ) + return json.loads(schema_file.read_text(encoding="utf-8")) + except (FileNotFoundError, ModuleNotFoundError): + return {} + def _apply_strict_mode(self, schema: dict[str, Any]) -> dict[str, Any]: """Apply strict mode by setting additionalProperties to false. @@ -143,9 +172,6 @@ def _set_additional_properties_false(self, obj: dict[str, Any]) -> None: def _get_validator(self) -> Any: """Get or create the JSON Schema validator.""" - if not HAS_JSONSCHEMA: - return None - if self._validator is None: schema = self._load_schema() if schema: @@ -164,22 +190,6 @@ def validate(self, data: dict[str, Any]) -> ValidationResult: """ start_time = time.perf_counter() - if not HAS_JSONSCHEMA: - return ValidationResult( - valid=True, - warnings=[ - ValidationError( - path="$", - message="jsonschema not installed, skipping schema validation", - code="SCH000", - layer="schema", - severity="warning", - ) - ], - schema_version=self.schema_version, - validation_time_ms=(time.perf_counter() - start_time) * 1000, - ) - validator = self._get_validator() if validator is None: return ValidationResult( diff --git a/src/dppvalidator/validators/semantic.py b/src/dppvalidator/validators/semantic.py index 422ad11..d95d2b0 100644 --- a/src/dppvalidator/validators/semantic.py +++ b/src/dppvalidator/validators/semantic.py @@ -5,8 +5,9 @@ import time from typing import TYPE_CHECKING, Any, Literal +from dppvalidator.schemas.registry import DEFAULT_SCHEMA_VERSION from dppvalidator.validators.results import ValidationError, ValidationResult -from dppvalidator.validators.rules import ALL_RULES +from dppvalidator.validators.rules import ALL_RULES, ALL_RULES_BY_VERSION if TYPE_CHECKING: from dppvalidator.models.passport import DigitalProductPassport @@ -16,7 +17,12 @@ class SemanticValidator: """Semantic validation layer for business rules. Applies domain-specific validation rules that go beyond - schema and type validation. + schema and type validation. Rule-set selection is **version-aware**: + when ``rules`` is left at the default ``None``, the validator looks + up the right rule set in :data:`ALL_RULES_BY_VERSION` keyed on + ``schema_version``. This is what stops the v0.6 ``CQ001`` rule from + firing as a false positive on a v0.7 payload — see Phase 3b of + docs/plans/UNTP_0.7.0_MIGRATION.md. """ name: str = "semantic" @@ -24,17 +30,27 @@ class SemanticValidator: def __init__( self, - schema_version: str = "0.6.1", + schema_version: str = DEFAULT_SCHEMA_VERSION, rules: list[Any] | None = None, ) -> None: """Initialize semantic validator. Args: - schema_version: UNTP DPP schema version - rules: Custom rules list. If None, uses ALL_RULES. + schema_version: UNTP DPP schema version. Used to pick the + appropriate rule set from :data:`ALL_RULES_BY_VERSION` + when ``rules`` is ``None``. + rules: Custom rules list. If supplied, it overrides the + version-keyed dispatch — callers can still inject a + hand-curated subset for tests or plugin scenarios. + If ``None``, the version-keyed lookup runs; if the + version is unknown to the registry the dispatch falls + back to :data:`ALL_RULES` (the default v0.6.x set). """ self.schema_version = schema_version - self.rules = rules if rules is not None else ALL_RULES + if rules is not None: + self.rules = rules + else: + self.rules = ALL_RULES_BY_VERSION.get(schema_version, ALL_RULES) def validate( self, diff --git a/src/dppvalidator/validators/shacl.py b/src/dppvalidator/validators/shacl.py new file mode 100644 index 0000000..fa262cb --- /dev/null +++ b/src/dppvalidator/validators/shacl.py @@ -0,0 +1,645 @@ +"""SHACL validation layer for EU DPP Core Ontology alignment. + +Provides SHACL-based validation using official CIRPASS-2 SHACL shapes +from the DPP Vocabulary Hub. Supports both placeholder shapes (for +structural validation) and official RDF-based validation with pyshacl. + +Note: Full SHACL validation requires the [rdf] extra: + pip install dppvalidator[rdf] +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from importlib.resources import files +from typing import TYPE_CHECKING, Any + +from dppvalidator.logging import get_logger + +if TYPE_CHECKING: + from dppvalidator.models.passport import DigitalProductPassport + +logger = get_logger(__name__) + + +class SHACLSeverity(str, Enum): + """SHACL constraint severity levels.""" + + VIOLATION = "sh:Violation" + WARNING = "sh:Warning" + INFO = "sh:Info" + + +class SHACLConstraintType(str, Enum): + """Common SHACL constraint types.""" + + MIN_COUNT = "sh:minCount" + MAX_COUNT = "sh:maxCount" + DATATYPE = "sh:datatype" + PATTERN = "sh:pattern" + MIN_INCLUSIVE = "sh:minInclusive" + MAX_INCLUSIVE = "sh:maxInclusive" + CLASS = "sh:class" + NODE = "sh:node" + HAS_VALUE = "sh:hasValue" + IN = "sh:in" + + +@dataclass(frozen=True, slots=True) +class SHACLPropertyShape: + """SHACL property shape definition.""" + + path: str + name: str + description: str + min_count: int | None = None + max_count: int | None = None + datatype: str | None = None + pattern: str | None = None + node_kind: str | None = None + severity: SHACLSeverity = SHACLSeverity.VIOLATION + + +@dataclass(frozen=True, slots=True) +class SHACLNodeShape: + """SHACL node shape definition.""" + + target_class: str + name: str + description: str + properties: tuple[SHACLPropertyShape, ...] = field(default_factory=tuple) + + +@dataclass +class SHACLValidationResult: + """Result of SHACL validation.""" + + conforms: bool + violations: list[dict[str, Any]] = field(default_factory=list) + warnings: list[dict[str, Any]] = field(default_factory=list) + info: list[dict[str, Any]] = field(default_factory=list) + + +# CIRPASS DPP Core shapes (placeholder definitions based on ESPR requirements) +# These will be replaced with official shapes when published +CIRPASS_DPP_SHAPE = SHACLNodeShape( + target_class="cirpass:DigitalProductPassport", + name="CIRPASSDPPShape", + description="CIRPASS-2 Digital Product Passport shape", + properties=( + SHACLPropertyShape( + path="cirpass:uniqueProductIdentifier", + name="UniqueProductIdentifier", + description="Unique product identifier per ESPR Art 9(1)", + min_count=1, + datatype="xsd:string", + ), + SHACLPropertyShape( + path="cirpass:economicOperator", + name="EconomicOperator", + description="Economic operator per ESPR Annex III (g)", + min_count=1, + node_kind="sh:IRI", + ), + SHACLPropertyShape( + path="cirpass:marketPlacementDate", + name="MarketPlacementDate", + description="Market placement date per ESPR Art 9(2i)", + min_count=1, + datatype="xsd:dateTime", + ), + SHACLPropertyShape( + path="cirpass:granularityLevel", + name="GranularityLevel", + description="DPP granularity level per SR5423", + min_count=1, + datatype="xsd:string", + severity=SHACLSeverity.WARNING, + ), + ), +) + +CIRPASS_PRODUCT_SHAPE = SHACLNodeShape( + target_class="cirpass:Product", + name="CIRPASSProductShape", + description="CIRPASS-2 Product shape", + properties=( + SHACLPropertyShape( + path="cirpass:productName", + name="ProductName", + description="Product name per ESPR Annex III", + min_count=1, + datatype="xsd:string", + ), + SHACLPropertyShape( + path="cirpass:materialComposition", + name="MaterialComposition", + description="Material composition per ESPR Annex I (a)", + min_count=0, + severity=SHACLSeverity.WARNING, + ), + ), +) + +CIRPASS_MATERIAL_SHAPE = SHACLNodeShape( + target_class="cirpass:MaterialComposition", + name="CIRPASSMaterialShape", + description="CIRPASS-2 Material composition shape", + properties=( + SHACLPropertyShape( + path="cirpass:materialName", + name="MaterialName", + description="Material name", + min_count=1, + datatype="xsd:string", + ), + SHACLPropertyShape( + path="cirpass:massPercentage", + name="MassPercentage", + description="Mass percentage per ESPR Annex I (a)", + datatype="xsd:decimal", + severity=SHACLSeverity.WARNING, + ), + SHACLPropertyShape( + path="cirpass:substanceOfConcern", + name="SubstanceOfConcern", + description="Substance of concern flag per ESPR Art 7(5a)", + datatype="xsd:boolean", + ), + ), +) + +# All CIRPASS shapes +CIRPASS_SHAPES: tuple[SHACLNodeShape, ...] = ( + CIRPASS_DPP_SHAPE, + CIRPASS_PRODUCT_SHAPE, + CIRPASS_MATERIAL_SHAPE, +) + + +class SHACLValidator: + """SHACL validation foundation. + + Provides infrastructure for SHACL-based validation. Full validation + requires pyshacl library and RDF graph conversion. + """ + + def __init__(self, shapes: tuple[SHACLNodeShape, ...] | None = None) -> None: + """Initialize SHACL validator. + + Args: + shapes: SHACL shapes to use. Defaults to CIRPASS shapes. + """ + self._shapes = shapes or CIRPASS_SHAPES + + def validate_structure(self, passport: DigitalProductPassport) -> SHACLValidationResult: + """Validate passport structure against SHACL shapes. + + This performs structural validation without full RDF/SHACL processing. + For full SHACL validation, use validate_rdf with pyshacl. + + Args: + passport: Digital Product Passport to validate + + Returns: + SHACLValidationResult with conformance status + """ + result = SHACLValidationResult(conforms=True) + + # Check DPP shape constraints + self._check_dpp_constraints(passport, result) + + # Check product constraints if present + if passport.credential_subject and passport.credential_subject.product: + self._check_product_constraints(passport, result) + + # Check material constraints if present + if passport.credential_subject and passport.credential_subject.materials_provenance: + self._check_material_constraints(passport, result) + + result.conforms = len(result.violations) == 0 + return result + + def _check_dpp_constraints( + self, passport: DigitalProductPassport, result: SHACLValidationResult + ) -> None: + """Check DPP-level SHACL constraints.""" + # Check issuer (economicOperator) + if not passport.issuer or not passport.issuer.id: + result.violations.append( + { + "path": "cirpass:economicOperator", + "message": "Missing economic operator identifier", + "shape": "CIRPASSDPPShape", + "constraint": SHACLConstraintType.MIN_COUNT.value, + } + ) + + # Check validFrom (marketPlacementDate) + if not passport.valid_from: + result.violations.append( + { + "path": "cirpass:marketPlacementDate", + "message": "Missing market placement date", + "shape": "CIRPASSDPPShape", + "constraint": SHACLConstraintType.MIN_COUNT.value, + } + ) + + # Check granularity level (warning) + if passport.credential_subject and not passport.credential_subject.granularity_level: + result.warnings.append( + { + "path": "cirpass:granularityLevel", + "message": "Missing granularity level (model/batch/item)", + "shape": "CIRPASSDPPShape", + "constraint": SHACLConstraintType.MIN_COUNT.value, + } + ) + + def _check_product_constraints( + self, passport: DigitalProductPassport, result: SHACLValidationResult + ) -> None: + """Check product-level SHACL constraints.""" + product = passport.credential_subject.product # type: ignore[union-attr] + if product is None: + return + + # Check product name + if not getattr(product, "name", None): + result.violations.append( + { + "path": "cirpass:productName", + "message": "Missing product name", + "shape": "CIRPASSProductShape", + "constraint": SHACLConstraintType.MIN_COUNT.value, + } + ) + + def _check_material_constraints( + self, passport: DigitalProductPassport, result: SHACLValidationResult + ) -> None: + """Check material-level SHACL constraints.""" + materials = passport.credential_subject.materials_provenance # type: ignore[union-attr] + if materials is None: + return + + for i, material in enumerate(materials): + # Check material name + if not material.name: + result.violations.append( + { + "path": f"cirpass:materialComposition[{i}]/cirpass:materialName", + "message": f"Material {i} missing name", + "shape": "CIRPASSMaterialShape", + "constraint": SHACLConstraintType.MIN_COUNT.value, + } + ) + + # Check mass percentage (warning) + if material.mass_fraction is None: + result.warnings.append( + { + "path": f"cirpass:materialComposition[{i}]/cirpass:massPercentage", + "message": f"Material '{material.name}' missing mass percentage", + "shape": "CIRPASSMaterialShape", + "constraint": SHACLConstraintType.MIN_COUNT.value, + } + ) + + @property + def shape_count(self) -> int: + """Number of registered SHACL shapes.""" + return len(self._shapes) + + @property + def shape_names(self) -> list[str]: + """List of registered shape names.""" + return [s.name for s in self._shapes] + + def get_shape(self, name: str) -> SHACLNodeShape | None: + """Get shape by name.""" + for shape in self._shapes: + if shape.name == name: + return shape + return None + + +def get_cirpass_shapes() -> tuple[SHACLNodeShape, ...]: + """Get all CIRPASS SHACL shapes. + + Returns: + Tuple of CIRPASS node shapes + """ + return CIRPASS_SHAPES + + +def validate_with_shacl(passport: DigitalProductPassport) -> SHACLValidationResult: + """Validate passport against CIRPASS SHACL shapes. + + Convenience function for SHACL validation. + + Args: + passport: Digital Product Passport to validate + + Returns: + SHACLValidationResult with conformance status + """ + validator = SHACLValidator() + return validator.validate_structure(passport) + + +# ============================================================================= +# Official SHACL Loading (Phase 8) +# ============================================================================= + + +class OfficialSHACLLoader: + """Load official CIRPASS SHACL shapes from bundled TTL files. + + Provides loading of the official CIRPASS-2 SHACL constraint shapes + for RDF-based validation. Requires the [rdf] extra. + + Example: + >>> loader = OfficialSHACLLoader() + >>> if loader.is_available(): + ... graph = loader.load_shapes_graph() + ... print(f"Loaded {len(graph)} triples") + """ + + SHACL_FILE = "cirpass_dpp_shacl.ttl" + + def __init__(self) -> None: + """Initialize official SHACL loader.""" + self._shapes_graph: Any | None = None + self._shapes_text: str | None = None + + def is_available(self) -> bool: + """Check if RDF/SHACL dependencies are available. + + Returns: + True if rdflib and pyshacl are installed + """ + try: + import pyshacl # noqa: F401 + import rdflib # noqa: F401 + + return True + except ImportError: + return False + + def load_shapes_text(self) -> str: + """Load SHACL shapes as text. + + Returns: + SHACL shapes file content as string + + Raises: + FileNotFoundError: If SHACL file not found + """ + if self._shapes_text is not None: + return self._shapes_text + + try: + data_dir = files("dppvalidator.vocabularies.data.schemas") + shacl_path = data_dir.joinpath(self.SHACL_FILE) + self._shapes_text = shacl_path.read_text(encoding="utf-8") + logger.debug("Loaded CIRPASS SHACL shapes text") + return self._shapes_text + except FileNotFoundError as e: + raise FileNotFoundError(f"CIRPASS SHACL file not found: {self.SHACL_FILE}") from e + + def load_shapes_graph(self) -> Any: + """Load SHACL shapes as RDF graph. + + Returns: + rdflib.Graph with SHACL shapes + + Raises: + ImportError: If rdflib not installed + FileNotFoundError: If SHACL file not found + """ + if self._shapes_graph is not None: + return self._shapes_graph + + try: + from rdflib import Graph + except ImportError as e: + raise ImportError( + "rdflib required for RDF SHACL validation. " + "Install with: pip install dppvalidator[rdf]" + ) from e + + content = self.load_shapes_text() + self._shapes_graph = Graph() + self._shapes_graph.parse(data=content, format="turtle") + logger.debug("Parsed CIRPASS SHACL shapes graph (%d triples)", len(self._shapes_graph)) + return self._shapes_graph + + def clear_cache(self) -> None: + """Clear cached shapes.""" + self._shapes_graph = None + self._shapes_text = None + + +class RDFSHACLValidator: + """Full RDF-based SHACL validator using official CIRPASS shapes. + + Provides complete SHACL validation using pyshacl and the official + CIRPASS-2 SHACL constraint shapes. Requires the [rdf] extra. + + Example: + >>> validator = RDFSHACLValidator() + >>> if validator.is_available(): + ... result = validator.validate_graph(data_graph) + ... print(f"Conforms: {result.conforms}") + """ + + def __init__(self, use_official_shapes: bool = True) -> None: + """Initialize RDF SHACL validator. + + Args: + use_official_shapes: Use official CIRPASS shapes (default: True) + """ + self._use_official = use_official_shapes + self._loader = OfficialSHACLLoader() + + def is_available(self) -> bool: + """Check if SHACL validation is available. + + Returns: + True if pyshacl is installed + """ + return self._loader.is_available() + + def load_shapes(self) -> Any: + """Load SHACL shapes graph. + + Returns: + rdflib.Graph with shapes + + Raises: + ImportError: If rdflib not installed + """ + if self._use_official: + return self._loader.load_shapes_graph() + return self._load_placeholder_shapes() + + def _load_placeholder_shapes(self) -> Any: + """Load placeholder shapes as RDF graph for backward compatibility.""" + try: + from rdflib import Graph + except ImportError as e: + raise ImportError("rdflib required. Install with: pip install dppvalidator[rdf]") from e + + # Create minimal placeholder shapes + g = Graph() + # Placeholder - just return empty graph for now + return g + + def validate_graph(self, data_graph: Any) -> SHACLValidationResult: + """Validate RDF data graph against SHACL shapes. + + Args: + data_graph: rdflib.Graph with data to validate + + Returns: + SHACLValidationResult with conformance status + + Raises: + ImportError: If pyshacl not installed + """ + try: + import pyshacl + except ImportError as e: + raise ImportError( + "pyshacl required for SHACL validation. Install with: pip install dppvalidator[rdf]" + ) from e + + shapes_graph = self.load_shapes() + + conforms, results_graph, results_text = pyshacl.validate( + data_graph, + shacl_graph=shapes_graph, + inference="rdfs", + abort_on_first=False, + ) + + result = SHACLValidationResult(conforms=conforms) + + # Parse results from results_graph + if not conforms: + result = self._parse_validation_results(results_graph, result) + + logger.debug( + "SHACL validation: conforms=%s, violations=%d", conforms, len(result.violations) + ) + return result + + def _parse_validation_results( + self, results_graph: Any, result: SHACLValidationResult + ) -> SHACLValidationResult: + """Parse SHACL validation results from results graph.""" + from rdflib import Namespace + + SH_NS = Namespace("http://www.w3.org/ns/shacl#") + + for report in results_graph.subjects(predicate=SH_NS.conforms): + for validation_result in results_graph.objects(report, SH_NS.result): + severity = None + message = "" + path = "" + focus_node = "" + + for sev in results_graph.objects(validation_result, SH_NS.resultSeverity): + severity = str(sev) + for msg in results_graph.objects(validation_result, SH_NS.resultMessage): + message = str(msg) + for p in results_graph.objects(validation_result, SH_NS.resultPath): + path = str(p) + for fn in results_graph.objects(validation_result, SH_NS.focusNode): + focus_node = str(fn) + + violation_info = { + "path": path, + "message": message, + "focusNode": focus_node, + "severity": severity, + } + + if severity and "Violation" in severity: + result.violations.append(violation_info) + elif severity and "Warning" in severity: + result.warnings.append(violation_info) + else: + result.info.append(violation_info) + + return result + + def validate_jsonld(self, jsonld_data: dict[str, Any]) -> SHACLValidationResult: + """Validate JSON-LD data against SHACL shapes. + + Args: + jsonld_data: JSON-LD dictionary to validate + + Returns: + SHACLValidationResult with conformance status + + Raises: + ImportError: If rdflib/pyshacl not installed + """ + try: + from rdflib import Graph + except ImportError as e: + raise ImportError("rdflib required. Install with: pip install dppvalidator[rdf]") from e + + import json + + data_graph = Graph() + data_graph.parse(data=json.dumps(jsonld_data), format="json-ld") + + return self.validate_graph(data_graph) + + +def is_shacl_validation_available() -> bool: + """Check if full SHACL validation is available. + + Returns: + True if pyshacl and rdflib are installed + """ + loader = OfficialSHACLLoader() + return loader.is_available() + + +def load_official_shacl_shapes() -> Any: + """Load official CIRPASS SHACL shapes as RDF graph. + + Convenience function to load the official SHACL shapes. + + Returns: + rdflib.Graph with CIRPASS SHACL shapes + + Raises: + ImportError: If rdflib not installed + """ + loader = OfficialSHACLLoader() + return loader.load_shapes_graph() + + +def validate_jsonld_with_official_shacl(jsonld_data: dict[str, Any]) -> SHACLValidationResult: + """Validate JSON-LD against official CIRPASS SHACL shapes. + + Convenience function for full SHACL validation. + + Args: + jsonld_data: JSON-LD dictionary to validate + + Returns: + SHACLValidationResult with conformance status + + Raises: + ImportError: If rdflib/pyshacl not installed + """ + validator = RDFSHACLValidator(use_official_shapes=True) + return validator.validate_jsonld(jsonld_data) diff --git a/src/dppvalidator/verifier/__init__.py b/src/dppvalidator/verifier/__init__.py new file mode 100644 index 0000000..fe58b6c --- /dev/null +++ b/src/dppvalidator/verifier/__init__.py @@ -0,0 +1,26 @@ +"""Verifiable Credential verification module.""" + +from dppvalidator.verifier.did import DIDDocument, DIDResolver, resolve_did +from dppvalidator.verifier.signatures import ( + SignatureVerifier, + verify_signature, +) +from dppvalidator.verifier.verifier import ( + CredentialVerifier, + VerificationResult, + verify_credential, +) + +__all__ = [ + # DID Resolution + "DIDResolver", + "DIDDocument", + "resolve_did", + # Signature Verification + "SignatureVerifier", + "verify_signature", + # Credential Verification + "CredentialVerifier", + "VerificationResult", + "verify_credential", +] diff --git a/src/dppvalidator/verifier/did.py b/src/dppvalidator/verifier/did.py new file mode 100644 index 0000000..34a7b98 --- /dev/null +++ b/src/dppvalidator/verifier/did.py @@ -0,0 +1,389 @@ +"""DID (Decentralized Identifier) resolution for did:web and did:key methods.""" + +from __future__ import annotations + +import base64 +from dataclasses import dataclass, field +from typing import Any + +import base58 +import httpx + +from dppvalidator.logging import get_logger + +logger = get_logger(__name__) + +# Multicodec prefixes for did:key +MULTICODEC_ED25519_PUB = b"\xed\x01" # 0xed01 +MULTICODEC_P256_PUB = b"\x80\x24" # 0x8024 (secp256r1/P-256) +MULTICODEC_P384_PUB = b"\x81\x24" # 0x8124 (secp384r1/P-384) + + +@dataclass +class VerificationMethod: + """A verification method from a DID document.""" + + id: str + type: str + controller: str + public_key_jwk: dict[str, Any] | None = None + public_key_multibase: str | None = None + public_key_base58: str | None = None + + @property + def key_type(self) -> str | None: + """Determine the key type (Ed25519, P-256, etc.).""" + if self.public_key_jwk: + crv = self.public_key_jwk.get("crv") + kty = self.public_key_jwk.get("kty") + if kty == "OKP" and crv == "Ed25519": + return "Ed25519" + if kty == "EC" and crv == "P-256": + return "P-256" + if kty == "EC" and crv == "P-384": + return "P-384" + if self.type == "Ed25519VerificationKey2020": + return "Ed25519" + if self.type == "JsonWebKey2020" and self.public_key_jwk: + return self.key_type # Recurse with JWK + return None + + +@dataclass +class DIDDocument: + """Parsed DID Document.""" + + id: str + context: list[str] = field(default_factory=list) + verification_method: list[VerificationMethod] = field(default_factory=list) + authentication: list[str] = field(default_factory=list) + assertion_method: list[str] = field(default_factory=list) + raw: dict[str, Any] = field(default_factory=dict) + + def get_verification_method(self, method_id: str) -> VerificationMethod | None: + """Get a verification method by ID.""" + # Handle fragment references + if method_id.startswith("#"): + method_id = f"{self.id}{method_id}" + + for vm in self.verification_method: + if vm.id == method_id: + return vm + # Also check fragment-only match + if vm.id.endswith(method_id) or method_id.endswith(vm.id.split("#")[-1]): + return vm + return None + + def get_assertion_methods(self) -> list[VerificationMethod]: + """Get all verification methods valid for assertions.""" + methods = [] + for ref in self.assertion_method: + vm = self.get_verification_method(ref) + if vm: + methods.append(vm) + return methods + + +class DIDResolver: + """Resolver for DID documents supporting did:web and did:key methods.""" + + def __init__(self, cache_size: int = 100, timeout: float = 10.0) -> None: + """Initialize the DID resolver. + + Args: + cache_size: Maximum number of DID documents to cache + timeout: HTTP request timeout in seconds + + """ + self.cache_size = cache_size + self.timeout = timeout + self._cache: dict[str, DIDDocument] = {} + + def resolve(self, did: str) -> DIDDocument | None: + """Resolve a DID to its DID document. + + Args: + did: The DID to resolve (e.g., "did:web:example.com" or "did:key:z...") + + Returns: + DIDDocument or None if resolution fails + + """ + if did in self._cache: + return self._cache[did] + + if did.startswith("did:web:"): + doc = self._resolve_did_web(did) + elif did.startswith("did:key:"): + doc = self._resolve_did_key(did) + else: + logger.warning( + "Unsupported DID method: %s", did.split(":")[1] if ":" in did else "unknown" + ) + return None + + if doc and len(self._cache) < self.cache_size: + self._cache[did] = doc + + return doc + + def _resolve_did_web(self, did: str) -> DIDDocument | None: + """Resolve a did:web identifier. + + did:web:example.com -> https://example.com/.well-known/did.json + did:web:example.com:path:to:doc -> https://example.com/path/to/doc/did.json + """ + try: + # Parse did:web format + parts = did[8:].split(":") # Remove "did:web:" prefix + domain = parts[0].replace("%3A", ":") # Handle port encoding + + if len(parts) > 1: + path = "/".join(parts[1:]) + url = f"https://{domain}/{path}/did.json" + else: + url = f"https://{domain}/.well-known/did.json" + + # Fetch the DID document + with httpx.Client(timeout=self.timeout) as client: + response = client.get(url) + response.raise_for_status() + data = response.json() + + return self._parse_did_document(data) + + except Exception as e: + logger.warning("Failed to resolve did:web %s: %s", did, e) + return None + + def _resolve_did_key(self, did: str) -> DIDDocument | None: + """Resolve a did:key identifier. + + did:key is self-describing - the public key is encoded in the identifier. + """ + try: + # Extract the multibase-encoded key + key_id = did[8:] # Remove "did:key:" prefix + + if not key_id.startswith("z"): + logger.warning("Unsupported multibase encoding for did:key: %s", key_id[0]) + return None + + # Decode base58btc (multibase 'z' prefix) + key_bytes = self._decode_base58btc(key_id[1:]) + if not key_bytes: + return None + + # Determine key type from multicodec prefix + vm = self._parse_multicodec_key(did, key_bytes) + if not vm: + return None + + # Build self-describing DID document + doc = DIDDocument( + id=did, + context=["https://www.w3.org/ns/did/v1"], + verification_method=[vm], + authentication=[vm.id], + assertion_method=[vm.id], + raw={ + "@context": ["https://www.w3.org/ns/did/v1"], + "id": did, + "verificationMethod": [ + { + "id": vm.id, + "type": vm.type, + "controller": vm.controller, + "publicKeyJwk": vm.public_key_jwk, + } + ], + "authentication": [vm.id], + "assertionMethod": [vm.id], + }, + ) + + return doc + + except Exception as e: + logger.warning("Failed to resolve did:key %s: %s", did, e) + return None + + def _parse_multicodec_key(self, did: str, key_bytes: bytes) -> VerificationMethod | None: + """Parse a multicodec-prefixed public key.""" + if key_bytes[:2] == MULTICODEC_ED25519_PUB: + # Ed25519 public key (32 bytes after prefix) + pub_key = key_bytes[2:] + if len(pub_key) != 32: + return None + + return VerificationMethod( + id=f"{did}#{did[8:]}", + type="Ed25519VerificationKey2020", + controller=did, + public_key_jwk={ + "kty": "OKP", + "crv": "Ed25519", + "x": base64.urlsafe_b64encode(pub_key).rstrip(b"=").decode(), + }, + ) + + if key_bytes[:2] == MULTICODEC_P256_PUB: + # P-256 public key (compressed or uncompressed) + pub_key = key_bytes[2:] + # For compressed keys, we'd need to decompress + # For now, store as-is in multibase format + return VerificationMethod( + id=f"{did}#{did[8:]}", + type="JsonWebKey2020", + controller=did, + public_key_jwk={ + "kty": "EC", + "crv": "P-256", + # Note: Would need proper decompression for x, y coordinates + }, + public_key_multibase=f"z{self._encode_base58btc(key_bytes)}", + ) + + logger.warning("Unsupported multicodec prefix: %s", key_bytes[:2].hex()) + return None + + def _decode_base58btc(self, encoded: str) -> bytes | None: + """Decode a base58btc string using the base58 library. + + Args: + encoded: Base58-encoded string + + Returns: + Decoded bytes, or None if decoding fails + """ + try: + return base58.b58decode(encoded) + except (ValueError, Exception): + return None + + def _encode_base58btc(self, data: bytes) -> str: + """Encode bytes as base58btc using the base58 library. + + Args: + data: Bytes to encode + + Returns: + Base58-encoded string + """ + return base58.b58encode(data).decode("ascii") + + def _parse_did_document(self, data: dict[str, Any]) -> DIDDocument: + """Parse a DID document from JSON.""" + verification_methods = [] + + for vm_data in data.get("verificationMethod", []): + vm = VerificationMethod( + id=vm_data.get("id", ""), + type=vm_data.get("type", ""), + controller=vm_data.get("controller", ""), + public_key_jwk=vm_data.get("publicKeyJwk"), + public_key_multibase=vm_data.get("publicKeyMultibase"), + public_key_base58=vm_data.get("publicKeyBase58"), + ) + verification_methods.append(vm) + + # Handle @context as string or list + context = data.get("@context", []) + if isinstance(context, str): + context = [context] + + return DIDDocument( + id=data.get("id", ""), + context=context, + verification_method=verification_methods, + authentication=data.get("authentication", []), + assertion_method=data.get("assertionMethod", []), + raw=data, + ) + + def clear_cache(self) -> None: + """Clear the DID document cache.""" + self._cache.clear() + + async def resolve_async(self, did: str) -> DIDDocument | None: + """Resolve a DID asynchronously. + + Async variant of resolve() that uses httpx.AsyncClient for network + operations. Useful when called from async contexts to avoid blocking. + + Args: + did: The DID to resolve (e.g., "did:web:example.com" or "did:key:z...") + + Returns: + DIDDocument or None if resolution fails + """ + if did in self._cache: + return self._cache[did] + + if did.startswith("did:web:"): + doc = await self._resolve_did_web_async(did) + elif did.startswith("did:key:"): + # did:key is self-describing, no network needed + doc = self._resolve_did_key(did) + else: + logger.warning( + "Unsupported DID method: %s", did.split(":")[1] if ":" in did else "unknown" + ) + return None + + if doc and len(self._cache) < self.cache_size: + self._cache[did] = doc + + return doc + + async def _resolve_did_web_async(self, did: str) -> DIDDocument | None: + """Resolve a did:web identifier asynchronously. + + did:web:example.com -> https://example.com/.well-known/did.json + did:web:example.com:path:to:doc -> https://example.com/path/to/doc/did.json + """ + try: + parts = did[8:].split(":") + domain = parts[0].replace("%3A", ":") + + if len(parts) > 1: + path = "/".join(parts[1:]) + url = f"https://{domain}/{path}/did.json" + else: + url = f"https://{domain}/.well-known/did.json" + + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.get(url) + response.raise_for_status() + data = response.json() + + return self._parse_did_document(data) + + except Exception as e: + logger.warning("Failed to resolve did:web async %s: %s", did, e) + return None + + +# Module-level resolver instance +_default_resolver: DIDResolver | None = None + + +def get_resolver() -> DIDResolver: + """Get the default DID resolver instance.""" + global _default_resolver + if _default_resolver is None: + _default_resolver = DIDResolver() + return _default_resolver + + +def resolve_did(did: str) -> DIDDocument | None: + """Resolve a DID using the default resolver. + + Args: + did: The DID to resolve + + Returns: + DIDDocument or None if resolution fails + + """ + return get_resolver().resolve(did) diff --git a/src/dppvalidator/verifier/signatures.py b/src/dppvalidator/verifier/signatures.py new file mode 100644 index 0000000..40f9403 --- /dev/null +++ b/src/dppvalidator/verifier/signatures.py @@ -0,0 +1,261 @@ +"""Signature verification for Verifiable Credentials.""" + +from __future__ import annotations + +import base64 +from dataclasses import dataclass +from typing import Any + +import jwt +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 + +from dppvalidator.logging import get_logger +from dppvalidator.verifier.did import VerificationMethod + +logger = get_logger(__name__) + + +@dataclass +class SignatureInfo: + """Information about a signature.""" + + algorithm: str + signature_bytes: bytes + message_bytes: bytes + key_id: str | None = None + + +class SignatureVerifier: + """Verify cryptographic signatures using various algorithms.""" + + SUPPORTED_ALGORITHMS = ["Ed25519", "ES256", "ES384"] + + def __init__(self) -> None: + """Initialize the signature verifier.""" + pass + + def verify( + self, + signature: bytes, + message: bytes, + public_key: bytes | dict[str, Any], + algorithm: str, + ) -> bool: + """Verify a signature. + + Args: + signature: The signature bytes + message: The message that was signed + public_key: Public key as bytes or JWK dict + algorithm: Signature algorithm (Ed25519, ES256, ES384) + + Returns: + True if signature is valid, False otherwise + + """ + try: + if algorithm == "Ed25519": + return self._verify_ed25519(signature, message, public_key) + elif algorithm in ("ES256", "P-256"): + return self._verify_ecdsa(signature, message, public_key, "P-256") + elif algorithm in ("ES384", "P-384"): + return self._verify_ecdsa(signature, message, public_key, "P-384") + else: + logger.warning("Unsupported signature algorithm: %s", algorithm) + return False + except Exception as e: + logger.warning("Signature verification failed: %s", e) + return False + + def _verify_ed25519( + self, + signature: bytes, + message: bytes, + public_key: bytes | dict[str, Any], + ) -> bool: + """Verify an Ed25519 signature.""" + try: + # Convert JWK to bytes if needed + if isinstance(public_key, dict): + public_key = self._jwk_to_ed25519_public_key(public_key) + + key = ed25519.Ed25519PublicKey.from_public_bytes(public_key) + key.verify(signature, message) + return True + except InvalidSignature: + return False + except Exception as e: + logger.warning("Ed25519 verification error: %s", e) + return False + + def _verify_ecdsa( + self, + signature: bytes, + message: bytes, + public_key: bytes | dict[str, Any], + curve: str, + ) -> bool: + """Verify an ECDSA signature (P-256 or P-384).""" + try: + # Convert JWK to key object if needed + if isinstance(public_key, dict): + key = self._jwk_to_ec_public_key(public_key) + else: + # Assume raw bytes + if curve == "P-256": + key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), public_key) + else: + key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP384R1(), public_key) + + # Determine hash algorithm + hash_alg = hashes.SHA256() if curve == "P-256" else hashes.SHA384() + + # Verify signature + key.verify(signature, message, ec.ECDSA(hash_alg)) + return True + except InvalidSignature: + return False + except Exception as e: + logger.warning("ECDSA verification error: %s", e) + return False + + def _jwk_to_ed25519_public_key(self, jwk: dict[str, Any]) -> bytes: + """Convert a JWK to Ed25519 public key bytes.""" + if jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519": + raise ValueError("Invalid JWK for Ed25519") + + x = jwk.get("x", "") + # Add padding if needed + x += "=" * (4 - len(x) % 4) if len(x) % 4 else "" + return base64.urlsafe_b64decode(x) + + def _jwk_to_ec_public_key(self, jwk: dict[str, Any]) -> Any: + """Convert a JWK to EC public key object.""" + if jwk.get("kty") != "EC": + raise ValueError("Invalid JWK for EC key") + + crv = jwk.get("crv") + x = jwk.get("x", "") + y = jwk.get("y", "") + + # Add padding if needed + x += "=" * (4 - len(x) % 4) if len(x) % 4 else "" + y += "=" * (4 - len(y) % 4) if len(y) % 4 else "" + + x_bytes = base64.urlsafe_b64decode(x) + y_bytes = base64.urlsafe_b64decode(y) + + # Create uncompressed point + point = b"\x04" + x_bytes + y_bytes + + if crv == "P-256": + return ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), point) + elif crv == "P-384": + return ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP384R1(), point) + else: + raise ValueError(f"Unsupported curve: {crv}") + + def verify_from_method( + self, + signature: bytes, + message: bytes, + verification_method: VerificationMethod, + ) -> bool: + """Verify a signature using a DID verification method. + + Args: + signature: The signature bytes + message: The message that was signed + verification_method: DID verification method with public key + + Returns: + True if signature is valid, False otherwise + + """ + if not verification_method.public_key_jwk: + logger.warning("No JWK in verification method") + return False + + key_type = verification_method.key_type + if not key_type: + logger.warning("Could not determine key type from verification method") + return False + + return self.verify( + signature, + message, + verification_method.public_key_jwk, + key_type, + ) + + +def verify_jws(jws_token: str, public_key: dict[str, Any]) -> tuple[bool, dict | None]: + """Verify a JWS (JSON Web Signature) token. + + Args: + jws_token: The JWS token (compact serialization) + public_key: Public key as JWK dict + + Returns: + Tuple of (is_valid, payload_dict or None) + + """ + try: + # Determine algorithm from JWK + kty = public_key.get("kty") + crv = public_key.get("crv") + + if kty == "OKP" and crv == "Ed25519": + algorithm = "EdDSA" + elif kty == "EC" and crv == "P-256": + algorithm = "ES256" + elif kty == "EC" and crv == "P-384": + algorithm = "ES384" + else: + logger.warning("Unsupported key type for JWS: kty=%s, crv=%s", kty, crv) + return False, None + + # Convert JWK to PEM for PyJWT + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + + if kty == "OKP": + key_bytes = SignatureVerifier()._jwk_to_ed25519_public_key(public_key) + key = ed25519.Ed25519PublicKey.from_public_bytes(key_bytes) + else: + key = SignatureVerifier()._jwk_to_ec_public_key(public_key) + + pem = key.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo) + + # Verify and decode + payload = jwt.decode(jws_token, pem, algorithms=[algorithm]) + return True, payload + + except jwt.InvalidSignatureError: + return False, None + except Exception as e: + logger.warning("JWS verification failed: %s", e) + return False, None + + +def verify_signature( + signature: bytes, + message: bytes, + public_key: bytes | dict[str, Any], + algorithm: str, +) -> bool: + """Verify a signature using the default verifier. + + Args: + signature: The signature bytes + message: The message that was signed + public_key: Public key as bytes or JWK dict + algorithm: Signature algorithm (Ed25519, ES256, ES384) + + Returns: + True if signature is valid, False otherwise + + """ + verifier = SignatureVerifier() + return verifier.verify(signature, message, public_key, algorithm) diff --git a/src/dppvalidator/verifier/verifier.py b/src/dppvalidator/verifier/verifier.py new file mode 100644 index 0000000..3830933 --- /dev/null +++ b/src/dppvalidator/verifier/verifier.py @@ -0,0 +1,417 @@ +"""Verifiable Credential verification for Digital Product Passports. + +This module provides verification of W3C Verifiable Credentials embedded +in Digital Product Passports. + +Supported Features: + - Data Integrity Proofs (Ed25519Signature2020, DataIntegrityProof) + - JWS Proofs (JsonWebSignature2020) + - JWT Credentials (ES256, ES384, Ed25519) + - DID Resolution (did:web, did:key) + - URDNA2015 RDF Canonicalization +""" + +from __future__ import annotations + +import base64 +import json +from dataclasses import dataclass, field +from typing import Any + +import base58 +import jwt +from pyld import jsonld +from pyld.jsonld import JsonLdError + +from dppvalidator.logging import get_logger +from dppvalidator.verifier.did import DIDResolver +from dppvalidator.verifier.signatures import SignatureVerifier, verify_jws + +logger = get_logger(__name__) + + +@dataclass +class VerificationResult: + """Result of credential verification.""" + + valid: bool + signature_valid: bool | None = None + issuer_did: str | None = None + verification_method: str | None = None + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + @property + def verified(self) -> bool: + """Check if the credential is fully verified.""" + return self.valid and self.signature_valid is True + + +class CredentialVerifier: + """Verify Verifiable Credentials in Digital Product Passports. + + Supports verification of: + - Data Integrity Proofs (embedded proofs) + - Enveloped Proofs (JWS/JWT format) + - did:web and did:key issuer resolution + + Note: + JWT credential verification is experimental. For production use, + prefer Data Integrity Proofs (Ed25519Signature2020). + + Canonicalization uses simplified JSON sorting. For credentials + requiring strict W3C compliance, implement URDNA2015. + """ + + def __init__( + self, + did_resolver: DIDResolver | None = None, + signature_verifier: SignatureVerifier | None = None, + ) -> None: + """Initialize the credential verifier. + + Args: + did_resolver: Custom DID resolver, or None to use default + signature_verifier: Custom signature verifier, or None to use default + + """ + self._did_resolver = did_resolver or DIDResolver() + self._signature_verifier = signature_verifier or SignatureVerifier() + + def verify(self, credential: dict[str, Any]) -> VerificationResult: + """Verify a Verifiable Credential. + + Args: + credential: The credential as a dict (parsed JSON) + + Returns: + VerificationResult with verification status + + """ + result = VerificationResult(valid=True) + + # Extract issuer first (always available) + issuer = self._extract_issuer(credential) + if issuer: + result.issuer_did = issuer + + # Determine proof type and verify + proof = credential.get("proof") + if proof: + # Data Integrity Proof (embedded) + return self._verify_data_integrity_proof(credential, proof, result) + + # Check for JWT/JWS format (enveloped proof) + if self._is_jwt_credential(credential): + return self._verify_jwt_credential(credential, result) + + # No proof found + result.warnings.append("No proof found in credential") + return result + + def _extract_issuer(self, credential: dict[str, Any]) -> str | None: + """Extract the issuer DID from a credential.""" + issuer = credential.get("issuer") + if isinstance(issuer, str): + return issuer + if isinstance(issuer, dict): + return issuer.get("id") + return None + + def _is_jwt_credential(self, _credential: dict[str, Any]) -> bool: + """Check if the credential is in JWT format.""" + # JWT credentials have a specific structure or are passed as strings + return False # We expect parsed credentials, not JWT strings + + def _verify_data_integrity_proof( + self, + credential: dict[str, Any], + proof: dict[str, Any] | list[dict[str, Any]], + result: VerificationResult, + ) -> VerificationResult: + """Verify a Data Integrity Proof.""" + # Handle proof as list or single object + proofs = proof if isinstance(proof, list) else [proof] + + for p in proofs: + proof_type = p.get("type", "") + verification_method = p.get("verificationMethod", "") + result.verification_method = verification_method + + # Resolve the verification method + did = self._extract_did_from_method(verification_method) + if not did: + result.errors.append(f"Could not extract DID from: {verification_method}") + result.valid = False + continue + + did_doc = self._did_resolver.resolve(did) + if not did_doc: + result.errors.append(f"Could not resolve DID: {did}") + result.valid = False + continue + + vm = did_doc.get_verification_method(verification_method) + if not vm: + result.errors.append(f"Verification method not found: {verification_method}") + result.valid = False + continue + + # Verify based on proof type + if proof_type in ("Ed25519Signature2020", "DataIntegrityProof"): + sig_valid = self._verify_ed25519_proof(credential, p, vm) + elif proof_type == "JsonWebSignature2020": + sig_valid = self._verify_jws_proof(credential, p, vm) + else: + result.warnings.append(f"Unsupported proof type: {proof_type}") + sig_valid = None + + result.signature_valid = sig_valid + if sig_valid is False: + result.valid = False + result.errors.append("Signature verification failed") + + return result + + def _extract_did_from_method(self, verification_method: str) -> str | None: + """Extract the DID from a verification method ID.""" + if verification_method.startswith("did:"): + # Format: did:method:identifier#fragment + parts = verification_method.split("#") + return parts[0] + return None + + def _verify_ed25519_proof( + self, + credential: dict[str, Any], + proof: dict[str, Any], + vm: Any, + ) -> bool | None: + """Verify an Ed25519 signature proof.""" + try: + # Get proof value + proof_value = proof.get("proofValue", "") + if not proof_value: + return None + + # Decode multibase-encoded signature (z prefix = base58btc) + if proof_value.startswith("z"): + signature = self._decode_base58btc(proof_value[1:]) + else: + signature = base64.b64decode(proof_value) + + if not signature: + return None + + # Create the message to verify (canonicalized credential minus proof) + message = self._create_verify_data(credential, proof) + if not message: + return None + + # Verify using the verification method + return self._signature_verifier.verify_from_method(signature, message, vm) + + except Exception as e: + logger.warning("Ed25519 proof verification error: %s", e) + return None + + def _verify_jws_proof( + self, + _credential: dict[str, Any], + proof: dict[str, Any], + vm: Any, + ) -> bool | None: + """Verify a JWS proof.""" + try: + jws = proof.get("jws", "") + if not jws: + return None + + if not vm.public_key_jwk: + return None + + valid, _ = verify_jws(jws, vm.public_key_jwk) + return valid + + except Exception as e: + logger.warning("JWS proof verification error: %s", e) + return None + + def _create_verify_data( + self, + credential: dict[str, Any], + proof: dict[str, Any], + ) -> bytes | None: + """Create the data to be verified using URDNA2015 RDF canonicalization. + + Uses pyld's normalize() with URDNA2015 algorithm for W3C Data Integrity + compliant canonicalization. Falls back to simplified JSON canonicalization + if URDNA2015 fails (e.g., missing @context). + + Args: + credential: The credential document (without proof for verification) + proof: The proof object (without proofValue) + + Returns: + Canonicalized bytes for signature verification, or None on failure + """ + try: + # Remove proof from credential for verification + cred_copy = {k: v for k, v in credential.items() if k != "proof"} + + # Create proof options (proof without proofValue) + proof_options = {k: v for k, v in proof.items() if k != "proofValue"} + + # Use URDNA2015 RDF canonicalization via pyld + try: + cred_normalized = jsonld.normalize( + cred_copy, + {"algorithm": "URDNA2015", "format": "application/n-quads"}, + ) + proof_normalized = jsonld.normalize( + proof_options, + {"algorithm": "URDNA2015", "format": "application/n-quads"}, + ) + return (proof_normalized + cred_normalized).encode("utf-8") + + except JsonLdError as e: + # Fallback to simplified canonicalization for non-JSON-LD documents + logger.debug("URDNA2015 failed, using JSON fallback: %s", e) + cred_json = json.dumps(cred_copy, sort_keys=True, separators=(",", ":")) + proof_json = json.dumps(proof_options, sort_keys=True, separators=(",", ":")) + return (proof_json + cred_json).encode("utf-8") + + except Exception as e: + logger.warning("Failed to create verify data: %s", e) + return None + + def _decode_base58btc(self, encoded: str) -> bytes | None: + """Decode a base58btc string using the base58 library. + + Args: + encoded: Base58-encoded string + + Returns: + Decoded bytes, or None if decoding fails + """ + try: + return base58.b58decode(encoded) + except Exception: + return None + + def _verify_jwt_credential( + self, + credential: dict[str, Any], + result: VerificationResult, + ) -> VerificationResult: + """Verify a JWT-encoded credential. + + Supports ES256, ES384, and EdDSA (Ed25519) algorithms. + Resolves the issuer DID to obtain the public key for verification. + + Args: + credential: The JWT credential containing a 'jwt' field or proof.jwt + result: The verification result to update + + Returns: + VerificationResult with verification status + """ + try: + # Extract JWT token from credential + jwt_token = credential.get("jwt") + if not jwt_token: + proof = credential.get("proof", {}) + jwt_token = proof.get("jwt") if isinstance(proof, dict) else None + + if not jwt_token: + result.errors.append("No JWT token found in credential") + result.signature_valid = False + return result + + # Decode header to get algorithm and key ID + try: + header = jwt.get_unverified_header(jwt_token) + except jwt.exceptions.DecodeError as e: + result.errors.append(f"Invalid JWT format: {e}") + result.signature_valid = False + return result + + kid = header.get("kid") + + # Extract issuer DID using existing method + issuer_did = self._extract_issuer(credential) + if not issuer_did: + # Try to get issuer from JWT payload + try: + unverified = jwt.decode(jwt_token, options={"verify_signature": False}) + issuer_did = unverified.get("iss") + except Exception: + pass + + if not issuer_did: + result.errors.append("Cannot extract issuer DID for JWT verification") + result.signature_valid = False + return result + + result.issuer_did = issuer_did + + # Resolve DID document + doc = self._did_resolver.resolve(issuer_did) + if not doc: + result.errors.append(f"Failed to resolve issuer DID: {issuer_did}") + result.signature_valid = False + return result + + # Find verification method + vm = None + if kid: + vm = doc.get_verification_method(kid) + if not vm: + assertion_methods = doc.get_assertion_methods() + vm = assertion_methods[0] if assertion_methods else None + if not vm: + vm = doc.verification_method[0] if doc.verification_method else None + + if not vm or not vm.public_key_jwk: + result.errors.append("No suitable verification method with JWK found") + result.signature_valid = False + return result + + result.verification_method = vm.id + + # Use verify_jws which handles JWK conversion internally + try: + valid, _ = verify_jws(jwt_token, vm.public_key_jwk) + result.signature_valid = valid + if not valid: + result.errors.append("JWT signature verification failed") + except Exception as e: + result.errors.append(f"JWT signature verification failed: {e}") + result.signature_valid = False + + return result + + except Exception as e: + logger.warning("JWT credential verification error: %s", e) + result.errors.append(f"JWT verification error: {e}") + result.signature_valid = False + return result + + +def verify_credential(credential: dict[str, Any]) -> VerificationResult: + """Verify a Verifiable Credential using the default verifier. + + Args: + credential: The credential as a dict + + Returns: + VerificationResult with verification status + + """ + verifier = CredentialVerifier() + return verifier.verify(credential) + + +def has_vc_support() -> bool: + """Check if VC verification dependencies are installed.""" + return True diff --git a/src/dppvalidator/vocabularies/__init__.py b/src/dppvalidator/vocabularies/__init__.py index 05e8fb0..22b59b8 100644 --- a/src/dppvalidator/vocabularies/__init__.py +++ b/src/dppvalidator/vocabularies/__init__.py @@ -1,23 +1,47 @@ -"""External vocabulary loading and validation.""" +"""External vocabulary loading and validation. -from dppvalidator.vocabularies.cache import CacheEntry, VocabularyCache -from dppvalidator.vocabularies.loader import ( - VOCABULARIES, - VocabularyDefinition, - VocabularyLoader, - get_bundled_country_codes, - get_bundled_unit_codes, +This module provides the public API for vocabulary operations. +For advanced usage, import directly from submodules: + + from dppvalidator.vocabularies.code_lists import is_valid_material_code + from dppvalidator.vocabularies.ontology import OntologyMapper + from dppvalidator.vocabularies.rdf_loader import load_ontology + from dppvalidator.vocabularies.eudpp_classes import EUDPPClass + from dppvalidator.vocabularies.eudpp_actors import Actor + from dppvalidator.vocabularies.eudpp_substances import Substance + from dppvalidator.vocabularies.eudpp_lca import ImpactCategory + from dppvalidator.vocabularies.eudpp_relations import ProductRelationMapper + from dppvalidator.vocabularies.cirpass_terms import CIRPASSTerm +""" + +from dppvalidator.vocabularies.cache import VocabularyCache +from dppvalidator.vocabularies.code_lists import ( + is_valid_gs1_digital_link, + is_valid_hs_code, + is_valid_material_code, + validate_gtin, +) +from dppvalidator.vocabularies.loader import VocabularyLoader +from dppvalidator.vocabularies.ontology import OntologyMapper +from dppvalidator.vocabularies.rdf_loader import ( + RDFNotAvailableError, + is_rdf_available, + is_shacl_available, ) __all__ = [ - # Loader + # Core loader "VocabularyLoader", - "VocabularyDefinition", - "VOCABULARIES", - # Cache "VocabularyCache", - "CacheEntry", - # Bundled data accessors - "get_bundled_country_codes", - "get_bundled_unit_codes", + # Code validation (most common use cases) + "is_valid_gs1_digital_link", + "is_valid_hs_code", + "is_valid_material_code", + "validate_gtin", + # Ontology mapping + "OntologyMapper", + # RDF availability checks + "RDFNotAvailableError", + "is_rdf_available", + "is_shacl_available", ] diff --git a/src/dppvalidator/vocabularies/cache.py b/src/dppvalidator/vocabularies/cache.py index 270f5f5..fad0d55 100644 --- a/src/dppvalidator/vocabularies/cache.py +++ b/src/dppvalidator/vocabularies/cache.py @@ -73,7 +73,7 @@ def get(self, url: str) -> frozenset[str] | None: return None try: - cache_data = json.loads(cache_path.read_text()) + cache_data = json.loads(cache_path.read_text(encoding="utf-8")) expires_at = cache_data.get("expires_at", 0) if time.time() >= expires_at: diff --git a/src/dppvalidator/vocabularies/cirpass_terms.py b/src/dppvalidator/vocabularies/cirpass_terms.py new file mode 100644 index 0000000..ca61cc4 --- /dev/null +++ b/src/dppvalidator/vocabularies/cirpass_terms.py @@ -0,0 +1,263 @@ +"""CIRPASS-2 core terms and ESPR Annex I product parameters. + +Source: Ontology Requirements Specification for an EU DPP Core Ontology Proposal +DOI: 10.5281/zenodo.14892665 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import ClassVar + + +@dataclass(frozen=True, slots=True) +class CIRPASSTerm: + """Definition of a CIRPASS core term.""" + + name: str + definition: str + source: str + article: str | None = None + + +class GranularityLevel(str, Enum): + """DPP granularity levels per EU DPP Core Ontology and SR5423. + + Values from official CIRPASS-2 ontology v1.7.1. + Note: The official ontology uses 'product' not 'item'. + """ + + MODEL = "model" # All units of a product version + BATCH = "batch" # Subset from specific plant/time + PRODUCT = "product" # Single unit (official term) + + # Backward compatibility alias + ITEM = "product" # Deprecated: use PRODUCT + + +class ESPRAnnexIParameter(str, Enum): + """Product parameters from ESPR Annex I.""" + + DURABILITY = "durability" + RELIABILITY = "reliability" + EASE_OF_REPAIR = "ease_of_repair" + EASE_OF_UPGRADING = "ease_of_upgrading" + RECYCLABILITY = "recyclability" + SUBSTANCES_OF_CONCERN = "substances_of_concern" + ENERGY_CONSUMPTION = "energy_consumption" + WATER_CONSUMPTION = "water_consumption" + RECYCLED_CONTENT = "recycled_content" + RENEWABLE_CONTENT = "renewable_content" + CARBON_FOOTPRINT = "carbon_footprint" + ENVIRONMENTAL_FOOTPRINT = "environmental_footprint" + MATERIAL_FOOTPRINT = "material_footprint" + MICROPLASTIC_RELEASE = "microplastic_release" + EMISSIONS = "emissions" + WASTE_GENERATED = "waste_generated" + WEIGHT_VOLUME = "weight_volume" + LIGHTWEIGHT_DESIGN = "lightweight_design" + + +CIRPASS_CORE_TERMS: dict[str, CIRPASSTerm] = { + "Product": CIRPASSTerm( + name="Product", + definition="Any physical goods that are placed on the market or put into service.", + source="ESPR", + article="Art 2(1)", + ), + "Component": CIRPASSTerm( + name="Component", + definition="A product intended to be incorporated into another product.", + source="ESPR", + article="Art 2(2)", + ), + "IntermediateProduct": CIRPASSTerm( + name="IntermediateProduct", + definition="A product that requires further manufacturing or transformation.", + source="ESPR", + article="Art 2(3)", + ), + "ProductGroup": CIRPASSTerm( + name="ProductGroup", + definition="A set of products that serve similar purposes and are similar in use.", + source="ESPR", + article="Art 2(4)", + ), + "Model": CIRPASSTerm( + name="Model", + definition="A version of a product of which all units share the same technical " + "characteristics and the same model identifier.", + source="SR5423", + article="Annex II Part B 1.1(2)", + ), + "Batch": CIRPASSTerm( + name="Batch", + definition="A subset of a specific model composed of all products produced in a " + "specific manufacturing plant at a specific moment in time.", + source="SR5423", + article="Annex II Part B 1.1(3)", + ), + "Item": CIRPASSTerm( + name="Item", + definition="A single unit of a model.", + source="SR5423", + article="Annex II Part B 1.1(4)", + ), + "UniqueProductIdentifier": CIRPASSTerm( + name="UniqueProductIdentifier", + definition="A unique string of characters for the identification of a product " + "that also enables a web link to the digital product passport.", + source="ESPR", + article="Art 2(30)", + ), + "DigitalProductPassport": CIRPASSTerm( + name="DigitalProductPassport", + definition="A structured set of product data accessible via electronic means.", + source="ESPR", + article="Art 2(28)", + ), + "DigitalInstructions": CIRPASSTerm( + name="DigitalInstructions", + definition="Instructions in digital format concerning the product in a language " + "that can be easily understood.", + source="ESPR", + article="Art 27(7)", + ), + "PerformanceRequirement": CIRPASSTerm( + name="PerformanceRequirement", + definition="A quantitative or non-quantitative requirement for a product to " + "achieve a certain performance level.", + source="ESPR", + article="Art 2(8)", + ), + "ClassOfPerformance": CIRPASSTerm( + name="ClassOfPerformance", + definition="A range of performance levels in relation to one or more product " + "parameters, ordered to allow for product differentiation.", + source="ESPR", + article="Art 2(15)", + ), + "Durability": CIRPASSTerm( + name="Durability", + definition="The ability of a product to maintain over time its function and " + "performance under specified conditions of use, maintenance and repair.", + source="ESPR", + article="Art 2(22)", + ), + "Reliability": CIRPASSTerm( + name="Reliability", + definition="The probability that a product functions as required under given " + "conditions for a given duration.", + source="ESPR", + article="Art 2(16)", + ), + "ConformityAttestation": CIRPASSTerm( + name="ConformityAttestation", + definition="A formal document stating that compliance with specific standards, " + "regulations, or requirements has been demonstrated.", + source="CIRPASS-2", + article=None, + ), + "ConformityAssessmentBody": CIRPASSTerm( + name="ConformityAssessmentBody", + definition="A body that performs conformity assessment activities including " + "calibration, testing, certification and inspection.", + source="ESPR", + article="Art 2(52)", + ), + "EconomicOperator": CIRPASSTerm( + name="EconomicOperator", + definition="The manufacturer, authorised representative, importer, distributor, " + "dealer or other natural or legal person.", + source="ESPR", + article="Art 2(37)", + ), + "Manufacturer": CIRPASSTerm( + name="Manufacturer", + definition="Any natural or legal person who manufactures a product or has a " + "product designed or manufactured under their name or trademark.", + source="ESPR", + article="Art 2(38)", + ), + "SubstanceOfConcern": CIRPASSTerm( + name="SubstanceOfConcern", + definition="A substance that is hazardous or has negative impacts on human " + "health or the environment.", + source="ESPR", + article="Art 2(28)", + ), + "LifeCycleEvent": CIRPASSTerm( + name="LifeCycleEvent", + definition="An event that occurs during the lifecycle of a product including " + "manufacturing, use, repair, refurbishment and end-of-life.", + source="CIRPASS-2", + article=None, + ), + "CarbonFootprint": CIRPASSTerm( + name="CarbonFootprint", + definition="The sum of greenhouse gas emissions expressed as CO2 equivalent.", + source="ESPR", + article="Annex I(n)", + ), + "EnvironmentalFootprint": CIRPASSTerm( + name="EnvironmentalFootprint", + definition="A quantification of the environmental impact of the product.", + source="ESPR", + article="Annex I(m)", + ), +} + + +ESPR_ANNEX_I_PARAMETERS: frozenset[str] = frozenset(param.value for param in ESPRAnnexIParameter) + + +class CIRPASSVocabulary: + """CIRPASS-2 vocabulary access and validation.""" + + TERMS: ClassVar[dict[str, CIRPASSTerm]] = CIRPASS_CORE_TERMS + + @classmethod + def get_term(cls, name: str) -> CIRPASSTerm | None: + """Get a CIRPASS term definition by name.""" + return cls.TERMS.get(name) + + @classmethod + def is_valid_term(cls, name: str) -> bool: + """Check if a term name is a valid CIRPASS core term.""" + return name in cls.TERMS + + @classmethod + def get_terms_by_source(cls, source: str) -> list[CIRPASSTerm]: + """Get all terms from a specific source (ESPR, SR5423, CIRPASS-2).""" + return [t for t in cls.TERMS.values() if t.source == source] + + @classmethod + def all_term_names(cls) -> frozenset[str]: + """Get all CIRPASS term names.""" + return frozenset(cls.TERMS.keys()) + + +def is_valid_granularity_level(level: str) -> bool: + """Check if a granularity level is valid.""" + try: + GranularityLevel(level.lower()) + return True + except ValueError: + return False + + +def is_valid_espr_parameter(param: str) -> bool: + """Check if a parameter is a valid ESPR Annex I parameter.""" + normalized = param.lower().replace("-", "_").replace(" ", "_") + return normalized in ESPR_ANNEX_I_PARAMETERS + + +def get_espr_parameters() -> frozenset[str]: + """Get all ESPR Annex I parameter names.""" + return ESPR_ANNEX_I_PARAMETERS + + +def get_cirpass_term_count() -> int: + """Get the count of CIRPASS core terms.""" + return len(CIRPASS_CORE_TERMS) diff --git a/src/dppvalidator/vocabularies/code_lists.py b/src/dppvalidator/vocabularies/code_lists.py new file mode 100644 index 0000000..20093e6 --- /dev/null +++ b/src/dppvalidator/vocabularies/code_lists.py @@ -0,0 +1,213 @@ +"""Extended code list validation for materials, HS codes, and GTINs.""" + +from __future__ import annotations + +import json +import re +from functools import lru_cache +from importlib.resources import files +from typing import Any + +from dppvalidator.logging import get_logger + +logger = get_logger(__name__) + + +def _get_data_files() -> Any: + """Get the data directory using importlib.resources.""" + return files("dppvalidator.vocabularies").joinpath("data") + + +@lru_cache(maxsize=4) +def _load_code_list(name: str) -> frozenset[str]: + """Load code list from bundled JSON data file. + + Args: + name: Name of the code list file (without .json extension) + + Returns: + Frozenset of valid codes + """ + try: + data_files = _get_data_files() + data_file = data_files.joinpath(f"{name}.json") + content = data_file.read_text(encoding="utf-8") + data = json.loads(content) + return frozenset(data.get("codes", [])) + except (FileNotFoundError, json.JSONDecodeError, OSError) as e: + logger.warning("Failed to load code list %s: %s", name, e) + return frozenset() + + +def get_material_codes() -> frozenset[str]: + """Get UNECE Rec 46 material codes. + + Returns: + Frozenset of valid material codes + """ + return _load_code_list("materials") + + +def get_hs_codes() -> frozenset[str]: + """Get HS (Harmonized System) codes for textiles. + + Returns: + Frozenset of valid HS codes + """ + return _load_code_list("hs_codes") + + +def is_valid_material_code(code: str) -> bool: + """Check if a material code is valid per UNECE Rec 46. + + Args: + code: Material code to validate + + Returns: + True if valid, False otherwise + """ + if not code: + return False + # Normalize: uppercase, strip whitespace + normalized = code.upper().strip().replace(" ", "_").replace("-", "_") + return normalized in get_material_codes() + + +def is_valid_hs_code(code: str) -> bool: + """Check if an HS code is valid for textiles (Chapters 50-63). + + Args: + code: HS code to validate (4-digit chapter heading) + + Returns: + True if valid, False otherwise + """ + if not code: + return False + # Normalize: remove dots, spaces, take first 4 digits + normalized = re.sub(r"[.\s]", "", code)[:4] + return normalized in get_hs_codes() + + +def is_textile_hs_code(code: str) -> bool: + """Check if an HS code belongs to textile chapters (50-63). + + Args: + code: HS code to check + + Returns: + True if textile chapter, False otherwise + """ + if not code: + return False + normalized = re.sub(r"[.\s]", "", code) + if len(normalized) < 2: + return False + try: + chapter = int(normalized[:2]) + return 50 <= chapter <= 63 + except ValueError: + return False + + +def validate_gtin(gtin: str) -> bool: + """Validate a GTIN (Global Trade Item Number) checksum. + + Supports GTIN-8, GTIN-12, GTIN-13, and GTIN-14. + + Args: + gtin: GTIN string to validate + + Returns: + True if checksum is valid, False otherwise + """ + if not gtin: + return False + + # Remove any non-digit characters + digits = re.sub(r"\D", "", gtin) + + # Valid lengths: 8, 12, 13, 14 + if len(digits) not in (8, 12, 13, 14): + return False + + # Calculate check digit using GS1 algorithm + # Weights alternate 3, 1, 3, 1... from right to left (excluding check digit) + total = 0 + for i, digit in enumerate(reversed(digits[:-1])): + weight = 3 if i % 2 == 0 else 1 + total += int(digit) * weight + + # Check digit is (10 - (total mod 10)) mod 10 + expected_check = (10 - (total % 10)) % 10 + actual_check = int(digits[-1]) + + return expected_check == actual_check + + +def extract_gtin_from_gs1_digital_link(url: str) -> str | None: + """Extract GTIN from a GS1 Digital Link URL. + + Examples: + - https://id.gs1.org/01/09506000134352 + - https://example.com/01/09506000134352/21/12345 + + Args: + url: GS1 Digital Link URL + + Returns: + GTIN string or None if not found + """ + # Pattern: /01/ followed by 8-14 digits + match = re.search(r"/01/(\d{8,14})", url) + if match: + return match.group(1) + return None + + +def is_valid_gs1_digital_link(url: str) -> bool: + """Validate a GS1 Digital Link URL contains a valid GTIN. + + Args: + url: URL to validate + + Returns: + True if contains valid GTIN, False otherwise + """ + gtin = extract_gtin_from_gs1_digital_link(url) + if gtin is None: + return False + return validate_gtin(gtin) + + +def get_hs_chapter_description(code: str) -> str | None: + """Get the description for an HS chapter. + + Args: + code: HS code + + Returns: + Chapter description or None + """ + chapters = { + "50": "Silk", + "51": "Wool, fine or coarse animal hair", + "52": "Cotton", + "53": "Other vegetable textile fibres", + "54": "Man-made filaments", + "55": "Man-made staple fibres", + "56": "Wadding, felt and nonwovens", + "57": "Carpets and other textile floor coverings", + "58": "Special woven fabrics", + "59": "Impregnated, coated, covered or laminated textile fabrics", + "60": "Knitted or crocheted fabrics", + "61": "Articles of apparel, knitted or crocheted", + "62": "Articles of apparel, not knitted or crocheted", + "63": "Other made up textile articles", + } + if not code: + return None + normalized = re.sub(r"[.\s]", "", code) + if len(normalized) >= 2: + return chapters.get(normalized[:2]) + return None diff --git a/src/dppvalidator/vocabularies/data/__init__.py b/src/dppvalidator/vocabularies/data/__init__.py new file mode 100644 index 0000000..3f78747 --- /dev/null +++ b/src/dppvalidator/vocabularies/data/__init__.py @@ -0,0 +1,10 @@ +"""Vocabulary data files for dppvalidator. + +This package contains bundled data files: +- countries.json: ISO country codes +- hs_codes.json: Harmonized System commodity codes +- materials.json: Material type codes +- units.json: Unit of measurement codes +- ontologies/: EU DPP ontology TTL files +- schemas/: CIRPASS schema and SHACL files +""" diff --git a/src/dppvalidator/vocabularies/data/hs_codes.json b/src/dppvalidator/vocabularies/data/hs_codes.json new file mode 100644 index 0000000..c296c3d --- /dev/null +++ b/src/dppvalidator/vocabularies/data/hs_codes.json @@ -0,0 +1,37 @@ +{ + "description": "Harmonized System (HS) codes - Textile chapters (50-63)", + "source": "https://www.wcoomd.org/en/topics/nomenclature/instrument-and-tools/hs-nomenclature-2022-edition.aspx", + "count": 156, + "chapters": { + "50": "Silk", + "51": "Wool, fine or coarse animal hair", + "52": "Cotton", + "53": "Other vegetable textile fibres", + "54": "Man-made filaments", + "55": "Man-made staple fibres", + "56": "Wadding, felt and nonwovens", + "57": "Carpets and other textile floor coverings", + "58": "Special woven fabrics", + "59": "Impregnated, coated, covered or laminated textile fabrics", + "60": "Knitted or crocheted fabrics", + "61": "Articles of apparel, knitted or crocheted", + "62": "Articles of apparel, not knitted or crocheted", + "63": "Other made up textile articles" + }, + "codes": [ + "5001", "5002", "5003", "5004", "5005", "5006", "5007", + "5101", "5102", "5103", "5104", "5105", "5106", "5107", "5108", "5109", "5110", "5111", "5112", "5113", + "5201", "5202", "5203", "5204", "5205", "5206", "5207", "5208", "5209", "5210", "5211", "5212", + "5301", "5302", "5303", "5305", "5306", "5307", "5308", "5309", "5310", "5311", + "5401", "5402", "5403", "5404", "5405", "5406", "5407", "5408", + "5501", "5502", "5503", "5504", "5505", "5506", "5507", "5508", "5509", "5510", "5511", "5512", "5513", "5514", "5515", "5516", + "5601", "5602", "5603", "5604", "5605", "5606", "5607", "5608", "5609", + "5701", "5702", "5703", "5704", "5705", + "5801", "5802", "5803", "5804", "5805", "5806", "5807", "5808", "5809", "5810", "5811", + "5901", "5902", "5903", "5904", "5905", "5906", "5907", "5908", "5909", "5910", "5911", + "6001", "6002", "6003", "6004", "6005", "6006", + "6101", "6102", "6103", "6104", "6105", "6106", "6107", "6108", "6109", "6110", "6111", "6112", "6113", "6114", "6115", "6116", "6117", + "6201", "6202", "6203", "6204", "6205", "6206", "6207", "6208", "6209", "6210", "6211", "6212", "6213", "6214", "6215", "6216", "6217", + "6301", "6302", "6303", "6304", "6305", "6306", "6307", "6308", "6309", "6310" + ] +} diff --git a/src/dppvalidator/vocabularies/data/materials.json b/src/dppvalidator/vocabularies/data/materials.json new file mode 100644 index 0000000..74bfc55 --- /dev/null +++ b/src/dppvalidator/vocabularies/data/materials.json @@ -0,0 +1,92 @@ +{ + "description": "UNECE Recommendation 46 - Material and Article Classification Codes", + "source": "https://unece.org/trade/uncefact/cl-recommendations", + "count": 85, + "codes": [ + "COTTON", + "WOOL", + "SILK", + "LINEN", + "HEMP", + "JUTE", + "RAMIE", + "SISAL", + "COIR", + "ABACA", + "KAPOK", + "FLAX", + "BAMBOO", + "CASHMERE", + "MOHAIR", + "ALPACA", + "ANGORA", + "CAMEL", + "LLAMA", + "VICUNA", + "YAK", + "POLYESTER", + "NYLON", + "POLYAMIDE", + "ACRYLIC", + "MODACRYLIC", + "ELASTANE", + "SPANDEX", + "LYCRA", + "POLYPROPYLENE", + "POLYETHYLENE", + "POLYURETHANE", + "PVC", + "PTFE", + "ARAMID", + "CARBON", + "GLASS", + "BASALT", + "CERAMIC", + "METAL", + "VISCOSE", + "RAYON", + "MODAL", + "LYOCELL", + "TENCEL", + "CUPRO", + "ACETATE", + "TRIACETATE", + "RUBBER", + "LATEX", + "LEATHER", + "SUEDE", + "NUBUCK", + "FUR", + "DOWN", + "FEATHER", + "PAPER", + "CELLULOSE", + "POLYLACTIC", + "PLA", + "RECYCLED_COTTON", + "RECYCLED_POLYESTER", + "RECYCLED_NYLON", + "RECYCLED_WOOL", + "ORGANIC_COTTON", + "ORGANIC_WOOL", + "ORGANIC_LINEN", + "ORGANIC_HEMP", + "BCI_COTTON", + "GOTS_COTTON", + "OEKO_TEX", + "BLUESIGN", + "GRS_RECYCLED", + "FSC_VISCOSE", + "PEFC_VISCOSE", + "ECONYL", + "REPREVE", + "SEACELL", + "PINATEX", + "MYLO", + "VEGEA", + "CORK", + "COCONUT", + "BANANA", + "ORANGE" + ] +} diff --git a/src/dppvalidator/vocabularies/data/ontologies/__init__.py b/src/dppvalidator/vocabularies/data/ontologies/__init__.py new file mode 100644 index 0000000..92f1a8d --- /dev/null +++ b/src/dppvalidator/vocabularies/data/ontologies/__init__.py @@ -0,0 +1 @@ +"""Bundled EU DPP ontology files (TTL format).""" diff --git a/src/dppvalidator/vocabularies/data/ontologies/actors_roles_v1.5.1.ttl b/src/dppvalidator/vocabularies/data/ontologies/actors_roles_v1.5.1.ttl new file mode 100644 index 0000000..640b498 --- /dev/null +++ b/src/dppvalidator/vocabularies/data/ontologies/actors_roles_v1.5.1.ttl @@ -0,0 +1,506 @@ +@prefix owl: . +@prefix dcterms: . +@prefix rdfs: . +@prefix xsd: . +@prefix ns0: . + + + a owl:Ontology ; + dcterms:contributor "Hele-Mai Haav"@en, "Tarmo Robal"@en ; + dcterms:creator "Riina Maigre"@en ; + dcterms:description """Actor module of an EU DPP core ontology proposal of the CIRPASS-2 project. + +For more information, see +Maigre, R., Haav, H.-M., Robal, T., Wolf, M.-A., & Danash, F. (2025). Ontology Requirements Specification for an EU DPP Core Ontology Proposal (1.1). CIRPASS-2 Consortium. https://doi.org/10.5281/zenodo.15270342"""@en ; + dcterms:issued "2025-03-14" ; + dcterms:license "Except otherwise noted, original content on this document is licensed under the Creative Commons Attribution 4.0 International (CC BY 4.0) licence."@en ; + dcterms:modified "2025-10-29" ; + dcterms:title "An Actor module for an EU DPP core ontology proposed by CIRPASS-2 project."@en ; + rdfs:comment "The second iteration of the Actor module of an EU DPP core ontology proposal of the CIRPASS-2 project."@en, "This document is ongoing work and provided as-is with no warranties whatsoever."@en ; + rdfs:label "Actor module of an EU DPP core ontology proposed by the CIRPASS-2 project."@en ; + owl:versionInfo "1.5.1" . + +dcterms:contributor a owl:AnnotationProperty . +dcterms:creator a owl:AnnotationProperty . +dcterms:description a owl:AnnotationProperty . +dcterms:issued a owl:AnnotationProperty . +dcterms:license a owl:AnnotationProperty . +dcterms:modified a owl:AnnotationProperty . +dcterms:title a owl:AnnotationProperty . + + a owl:ObjectProperty ; + owl:inverseOf ; + rdfs:domain ; + rdfs:range ; + rdfs:comment "Relates an actor to a role."@en ; + rdfs:label "Has role"@en . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:range ; + rdfs:comment "Relates a legal person to the location where it is established."@en ; + rdfs:label "Is established in"@en . + + + a owl:ObjectProperty ; + owl:inverseOf ; + rdfs:domain [ + a owl:Class ; + owl:intersectionOf ( + + _:genid3 + ) + ] ; + rdfs:range [ + a owl:Class ; + owl:intersectionOf ( + + _:genid7 + ) + ] ; + rdfs:comment "Relates a manufacturer to an actor who has received a written mandate from manufacturer to act on the manufacturer's behalf."@en ; + rdfs:label "Is represented by"@en . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:range ; + rdfs:comment "Relates a natural person to the location where they reside."@en ; + rdfs:label "Is residing in"@en . + + + a owl:ObjectProperty ; + rdfs:domain [ + a owl:Class ; + owl:intersectionOf ( + + _:genid11 + ) + ] ; + rdfs:comment """Relates an economic operator to the product for which it holds DPP responsibility. + +Product is defined in the Product and DPP module."""@en ; + rdfs:label "Is responsible for product"@en . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:range ; + rdfs:comment "Relates role to an actor."@en ; + rdfs:label "Is role of"@en . + + + a owl:ObjectProperty ; + owl:inverseOf ; + rdfs:domain ; + rdfs:range ; + rdfs:comment "Relates a facility to an actor."@en ; + rdfs:label "Is used by actor"@en . + + + a owl:ObjectProperty ; + rdfs:domain [ + a owl:Class ; + owl:intersectionOf ( + + _:genid15 + ) + ] ; + rdfs:range [ + a owl:Class ; + owl:intersectionOf ( + + _:genid19 + ) + ] ; + rdfs:comment "Relates an authorised representative to the manufacturer it is authorised to represent."@en ; + rdfs:label "Represents manufacturer"@en . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:range ; + rdfs:comment "Relates an actor to facility."@en ; + rdfs:label "Uses facility"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "Name of an actor."@en ; + rdfs:label "Actor name"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "Electronic contact detail for an actor, such as an email address, website, or other digital communication channel."@en ; + rdfs:label "Electronic contact"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "Postal address associated with an actor."@en ; + rdfs:label "Postal address"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "Trade name under which a legal person or business operates and is registered."@en ; + rdfs:label "Registered trade name"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "A trademark officially registered and owned by an actor."@en ; + rdfs:label "Registered trademark"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "Unique facility identifier means a unique string of characters for the identification of locations or buildings involved in a product’s value chain or used by actors involved in a product’s value chain. (ESPR Art 2 (33))"@en ; + rdfs:label "Unique facility ID"@en . + + + a owl:DatatypeProperty ; + rdfs:domain [ + a owl:Class ; + owl:intersectionOf ( + + _:genid23 + ) + ] ; + rdfs:range xsd:string ; + rdfs:comment "Unique operator identifier means a unique string of characters for the identification of an actor involved in a product’s value chain. (ESPR Art. 2 (31))"@en ; + rdfs:label "Unique operator ID"@en . + + + a owl:Class ; + rdfs:comment "Actor means a legal or natural person. One actor can take on multiple roles. CIRPASS-2 User Stories 3.0, Glossary, p 80)"@en ; + rdfs:label "Actor"@en . + + + a owl:Class ; + rdfs:subClassOf , [ + a owl:Restriction ; + owl:onProperty ; + owl:someValuesFrom [ + a owl:Class ; + owl:intersectionOf ( + + _:genid28 + ) + ] + ] ; + owl:disjointWith ; + rdfs:comment "Authorised representative means any natural or legal person established in the Union that has received a written mandate from the manufacturer to act on the manufacturer’s behalf in relation to specified tasks with regard to the manufacturer’s obligations under this Regulation. (ESPR Art 2 (43))"@en ; + rdfs:label "Authorised Representative Role"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A role of an actor who exercises authority."@en ; + rdfs:label "Authority Role"@en . + + + a owl:Class ; + rdfs:subClassOf , [ + a owl:Restriction ; + owl:onProperty ; + owl:allValuesFrom + ] ; + rdfs:comment "A conformity assessment body shall be established under national law and have legal personality (DECISION No 768/2008/EC Art R17 (2))"@en, "Conformity assessment body means a body that performs conformity assessment activities including calibration, testing, certification and inspection (ESPR Art 2 (52))"@en ; + rdfs:label "Conformity Assessment Body Role"@en . + + + a owl:Class ; + rdfs:subClassOf , [ + a owl:Restriction ; + owl:onProperty ; + owl:allValuesFrom + ] ; + rdfs:comment "Consumer means any natural person who, in relation to contracts covered by Directive (EU) 2019/771, is acting for purposes which are outside that person's trade, business, craft or profession. (Directive (EU) 2019/771, Art 2 (2))"@en ; + rdfs:label "Consumer Role"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Credential agency means a legal person that provides (professional) credentials to parties, which may be used to make and verify a variety of claims in the DPP. Definition is derived from ESPR Art 11 last paragraph “The Commission may adopt implementing acts setting out procedures to issue and verify the digital credentials of economic operators and other relevant actors that have access rights to data included in the digital product passport”. The credentials agency can provide all credentials except for the unique identifiers and data carriers, which are provided by an issuing agency. (CIRPASS-2 User Stories 3.0)"@en ; + rdfs:label "Credential Agency Role"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Customer means a natural or legal person that purchases, hires or receives a product for their own use whether or not acting for purposes which are outside their trade, business, craft or profession. (ESPR Art 2 (35))"@en ; + rdfs:label "Customer Role"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Customs authorities means customs authorities as defined in point 1 of Article 5 of Regulation (EU) No 952/2013 (Regulation (EU) 2019/1020, Art 3 (24))"@en, "Customs authorities means the customs administrations of the Member States responsible for applying the customs legislation and any other authorities empowered under national law to apply certain customs legislation (Regulation (EU) No 952/2013 Art 5 (1);"@en ; + rdfs:label "Customs Authority Role"@en . + + + a owl:Class ; + rdfs:subClassOf ; + owl:disjointWith ; + rdfs:comment "Digital product passport service provider means a natural or legal person that is an independent third-party authorised by the economic operator which places the product on the market or puts it into service and that processes the digital product passport data for that product for the purpose of making such data available to economic operators and other relevant actors with a right to access those data under this Regulation or other Union law. (ESPR Art 2 (32))"@en ; + rdfs:label "DPP Service Provider Role"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Dealer means a distributor or any other natural or legal person that offers products for sale, hire or hire purchase, or that displays products, to end users in the course of a commercial activity, including through distance selling; and includes any natural or legal person that puts a product into service in the course of a commercial activity. (ESPR Art 2 (55))"@en ; + rdfs:label "Dealer Role"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Distributor means any natural or legal person in the supply chain, other than the manufacturer or the importer, that makes a product available on the market. ESPR Art 2 (45))"@en ; + rdfs:label "Distributor Role"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Economic operator means the manufacturer, the authorised representative, the importer, the distributor, the dealer and the fulfilment service provider. (ESPR Art 2 (46))"@en ; + rdfs:label "Economic Operator Role"@en . + + + a owl:Class ; + rdfs:subClassOf , [ + a owl:Restriction ; + owl:onProperty ; + owl:someValuesFrom [ + a owl:Class ; + owl:intersectionOf ( + + _:genid39 + ) + ] + ] ; + rdfs:comment "End user means any natural or legal person residing or established in the Union, to whom a product has been made available either as a consumer outside of any trade, business, craft or profession or as a professional end user in the course of its industrial or professional activities (Regulation (EU) 2019/1020 Art 3 (21));"@en ; + rdfs:label "End User Role"@en . + + + a owl:Class ; + rdfs:comment "A Facility may be a place where specific activities occur. A production/manufacturing facility means a factory. The term may refer to any place that makes things, or plants that generate electricity."@en, "Business facilities are structures that provide services to a business. This can include space for employees, technology, equipment and inventory. Facilities can be used for business administration, operations or to provide services to customers."@en ; + rdfs:label "Facility"@en ; + rdfs:seeAlso . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Fulfilment service provider means any natural or legal person offering, in the course of commercial activity, at least two of the following services: warehousing, packaging, addressing and dispatching, without having ownership of the products involved, excluding postal services as defined in point 1 of Article 2 of Directive 97/67/EC of the European Parliament and of the Council (31), parcel delivery services as defined in point 2 of Article 2 of Regulation (EU) 2018/644 of the European Parliament and of the Council (32), and any other postal services or freight transport services. (Regulation (EU) 2019/1020 Art 3 (11))."@en ; + rdfs:label "Fulfilment Service Provider Role"@en . + + + a owl:Class ; + rdfs:subClassOf , [ + a owl:Restriction ; + owl:onProperty ; + owl:someValuesFrom [ + a owl:Class ; + owl:intersectionOf ( + + _:genid48 + ) + ] + ] ; + rdfs:comment "Importer means any natural or legal person established in the Union that places a product from a third country on the Union market. (ESPR Art 2 (44))"@en ; + rdfs:label "Importer Role"@en . + + + a owl:Class ; + rdfs:subClassOf ; + owl:disjointWith ; + rdfs:comment "Independent operator means natural or legal person that is independent of the manufacturer and is directly or indirectly involved in the refurbishment, repair, maintenance or repurposing of a product, and includes waste management operators, refurbishers, repairers, manufacturers or distributors of repair equipment, tools or spare parts, as well as publishers of technical information, operators offering inspection and testing services and operators offering training for installers, manufacturers and repairers of equipment. (ESPR Art 2 (47))"@en ; + rdfs:label "Independent Operator Role"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Issuing agency is a legal persons that provide unique identifiers and data carriers Art 12 (4a). Under specific conditions, economic operators may perform the functions associated with an Issuing Agency. (ESPR Art 12 (4b))"@en ; + rdfs:label "Issuing Agency Role"@en . + + + a owl:Class ; + rdfs:subClassOf ; + owl:disjointWith ; + rdfs:comment "A body of persons or an entity (as a corporation) considered as having many of the rights and responsibilities of a natural person and especially the capacity to sue and be sued."@en ; + rdfs:label "Legal Person"@en ; + rdfs:seeAlso . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Manufacturer means any natural or legal person that manufactures a product or that has a product designed or manufactured and markets that product under their name or trademark. (ESPR Art 2 (42))"@en ; + rdfs:label "Manufacturer Role"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Market surveillance authority means an authority designated by a Member State under Article 10 as responsible for carrying out market surveillance in the territory of that Member State (Regulation (EU) 2019/1020 Art 3 (4));"@en ; + rdfs:label "Market Surveillance Authority Role"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A human being as distinguished from a person (as a corporation) created by operation of law"@en ; + rdfs:label "Natural Person"@en ; + rdfs:seeAlso . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Notified body means a conformity assessment body notified in accordance with Chapter IX"@en ; + rdfs:label "Notified Body Role"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Professional repairer means a natural or legal person that provides professional repair or maintenance services for a product, irrespective of whether that person acts within the manufacturer’s distribution system or independently. (ESPR Art 2 (48))"@en ; + rdfs:label "Professional Repairer Role"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Role that can be held by actor performing \"recycling\""@en ; + rdfs:label "Recycler Role"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Role that can be held by actor performing \"refurbishment\""@en ; + rdfs:label "Refurbisher Role"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Role that can be held by actor performing \"remanufacturing\"."@en ; + rdfs:label "Remanufacturer Role"@en . + + + a owl:Class ; + rdfs:comment "Role means a set of tasks typically performed by one actor. (e.g. Responsible Economic Operator, Independent operator). (CIRPASS-2 User Stories 3.0)"@en ; + rdfs:label "Role"@en . + + a owl:Class . + + a owl:NamedIndividual, ; + owl:sameAs ; + ns0:hasRole ns0:authority ; + rdfs:comment "The European Commission, abbreviated as EC, is the executive branch of the European Union."@en ; + rdfs:label "European Commission"@en . + +ns0:EuropeanUnion + a owl:NamedIndividual, ; + owl:sameAs ; + rdfs:comment "European Union is used here as a jurisdictional or geographic entity for legal and regulatory contexts."@en ; + rdfs:label "European Union"@en . + +ns0:authorisedRepresentative a owl:NamedIndividual, ns0:AuthorisedRepresentativeRole . +ns0:authority a owl:NamedIndividual, ns0:AuthorityRole . +ns0:consumer a owl:NamedIndividual, ns0:ConsumerRole . +ns0:credentialAgency a owl:NamedIndividual, ns0:CredentialAgencyRole . +ns0:customer a owl:NamedIndividual, ns0:CustomerRole . +ns0:customsAuthority a owl:NamedIndividual, ns0:CustomsAuthorityRole . +ns0:dealer a owl:NamedIndividual, ns0:DealerRole . +ns0:distributor a owl:NamedIndividual, ns0:DistributorRole . +ns0:dppServiceProvider a owl:NamedIndividual, ns0:DPPServiceProviderRole . +ns0:endUser a owl:NamedIndividual, ns0:EndUserRole . +ns0:fulfilmentServiceProvider a owl:NamedIndividual, ns0:FulfilmentServiceProviderRole . +ns0:importer a owl:NamedIndividual, ns0:ImporterRole . +ns0:independentOperator a owl:NamedIndividual, ns0:IndependentOperatorRole . +ns0:issuingAgency a owl:NamedIndividual, ns0:IssuingAgencyRole . +ns0:manufacturer a owl:NamedIndividual, ns0:ManufacturerRole . +ns0:marketSurveillanceAuthority a owl:NamedIndividual, ns0:MarketSurveillanceAuthorityRole . +ns0:notifiedBody a owl:NamedIndividual, ns0:NotifiedBodyRole . +ns0:professionalRepairer a owl:NamedIndividual, ns0:ProfessionalRepairerRole . +ns0:recycler a owl:NamedIndividual, ns0:RecyclerRole . +ns0:refurbisher a owl:NamedIndividual, ns0:RefurbisherRole . +ns0:remanufacturer a owl:NamedIndividual, ns0:RemanufacturerRole . +_:genid3 + a owl:Restriction ; + owl:onProperty ns0:hasRole ; + owl:someValuesFrom ns0:ManufacturerRole . + +_:genid7 + a owl:Restriction ; + owl:onProperty ns0:hasRole ; + owl:someValuesFrom ns0:AuthorisedRepresentativeRole . + +_:genid11 + a owl:Restriction ; + owl:onProperty ns0:hasRole ; + owl:someValuesFrom ns0:EconomicOperatorRole . + +_:genid15 + a owl:Restriction ; + owl:onProperty ns0:hasRole ; + owl:someValuesFrom ns0:AuthorisedRepresentativeRole . + +_:genid19 + a owl:Restriction ; + owl:onProperty ns0:hasRole ; + owl:someValuesFrom ns0:ManufacturerRole . + +_:genid23 + a owl:Restriction ; + owl:onProperty ns0:hasRole ; + owl:someValuesFrom ns0:EconomicOperatorRole . + +_:genid28 + a owl:Class ; + owl:unionOf ( + _:genid30 + _:genid32 + ) . + +_:genid30 + a owl:Restriction ; + owl:onProperty ns0:isEstablishedIn ; + owl:hasValue ns0:EuropeanUnion . + +_:genid32 + a owl:Restriction ; + owl:onProperty ns0:isResidingIn ; + owl:hasValue ns0:EuropeanUnion . + +_:genid39 + a owl:Class ; + owl:unionOf ( + _:genid41 + _:genid43 + ) . + +_:genid41 + a owl:Restriction ; + owl:onProperty ns0:isEstablishedIn ; + owl:hasValue ns0:EuropeanUnion . + +_:genid43 + a owl:Restriction ; + owl:onProperty ns0:isResidingIn ; + owl:hasValue ns0:EuropeanUnion . + +_:genid48 + a owl:Class ; + owl:unionOf ( + _:genid50 + _:genid52 + ) . + +_:genid50 + a owl:Restriction ; + owl:onProperty ns0:isEstablishedIn ; + owl:hasValue ns0:EuropeanUnion . + +_:genid52 + a owl:Restriction ; + owl:onProperty ns0:isResidingIn ; + owl:hasValue ns0:EuropeanUnion . diff --git a/src/dppvalidator/vocabularies/data/ontologies/eudpp_core_v1.3.1.ttl b/src/dppvalidator/vocabularies/data/ontologies/eudpp_core_v1.3.1.ttl new file mode 100644 index 0000000..b17c500 --- /dev/null +++ b/src/dppvalidator/vocabularies/data/ontologies/eudpp_core_v1.3.1.ttl @@ -0,0 +1,77 @@ +@prefix owl: . +@prefix dcterms: . +@prefix rdfs: . + + + a owl:Ontology ; + owl:imports , , ; + dcterms:contributor "Riina Maigre"@en, "Tarmo Robal"@en ; + dcterms:creator "Hele-Mai Haav"@en ; + dcterms:licence "Except otherwise noted, original content on this document is licensed under the Creative Commons Attribution 4.0 International (CC BY 4.0) licence."@en ; + dcterms:modified "2025-10-31" ; + rdfs:comment """This ontology is the second iteration of the EU DPP core ontology proposal of the CIRPASS-2 project. + +It maps the following 3 modules from the second iteration: Product and DPP, Actors and Roles, and Substance of Concern. + + It reuses a part of International System of Units (SI) ontology in the Product and DPP module (see https://si-digital-framework.org/). + +The following OWL files (RDF/XML) are mapped: +EUDPP_onto_ver_1.7.1.owl +EUDPP_actor_ver_1.5.1.owl +EUDPP_soc_ver_1.4.7.owl"""@en ; + rdfs:label """This ontology is the SECOND iteration of the EU DPP core ontology proposal of the CIRPASS-2 project. + +It maps the following 3 modules: Product and DPP, Actors and Roles, and Substance of Concern."""@en ; + owl:versionInfo "1.3.1" . + + rdfs:range . + rdfs:range [ + owl:intersectionOf ( + + _:genid4 + ) ; + a owl:Class + ] . + rdfs:range [ + owl:intersectionOf ( + + _:genid8 + ) ; + a owl:Class + ] . + rdfs:range [ + owl:intersectionOf ( + + _:genid12 + ) ; + a owl:Class + ] . + rdfs:range [ + owl:intersectionOf ( + + _:genid16 + ) ; + a owl:Class + ] . + rdfs:range . + owl:equivalentClass . + owl:equivalentClass . +_:genid4 + a owl:Restriction ; + owl:onProperty ; + owl:someValuesFrom . + +_:genid8 + a owl:Restriction ; + owl:onProperty ; + owl:someValuesFrom . + +_:genid12 + a owl:Restriction ; + owl:onProperty ; + owl:someValuesFrom . + +_:genid16 + a owl:Restriction ; + owl:onProperty ; + owl:someValuesFrom . diff --git a/src/dppvalidator/vocabularies/data/ontologies/lca_v2.0.ttl b/src/dppvalidator/vocabularies/data/ontologies/lca_v2.0.ttl new file mode 100644 index 0000000..c070f10 --- /dev/null +++ b/src/dppvalidator/vocabularies/data/ontologies/lca_v2.0.ttl @@ -0,0 +1,454 @@ +@prefix owl: . +@prefix dcterms: . +@prefix xsd: . +@prefix dc11: . +@prefix rdfs: . +@prefix ns0: . +@prefix ns1: . + + + a owl:Ontology ; + dcterms:creator "Fatima Danash" ; + dcterms:description "This ontology is the Life Cycle Assessement (LCA) and Environmental Footprint (EF) module of the EU DPP core ontology proposal of the CIRPASS-2 project." ; + dcterms:issued "2025-11-01"^^xsd:date ; + dcterms:license "Except otherwise noted, original content on this document is licensed under the Creative Commons Attribution 4.0 International (CC BY 4.0) licence." ; + dcterms:title "The LCA and EF module of the EU DPP core ontology proposed by the CIRPASS-2 project." . + + a owl:AnnotationProperty . +dc11:description a owl:AnnotationProperty . + a owl:AnnotationProperty . + a owl:AnnotationProperty . + a owl:AnnotationProperty . + a owl:AnnotationProperty . + a owl:AnnotationProperty . + a owl:AnnotationProperty . +xsd:date a rdfs:Datatype . + + a owl:ObjectProperty ; + owl:inverseOf . + + + a owl:ObjectProperty ; + owl:inverseOf ; + rdfs:domain ; + rdfs:range . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:range . + + + a owl:ObjectProperty ; + owl:inverseOf . + + + a owl:ObjectProperty ; + owl:inverseOf ; + rdfs:domain ; + rdfs:range . + + + a owl:ObjectProperty ; + owl:inverseOf ; + rdfs:domain ; + rdfs:range . + + a owl:ObjectProperty . + a owl:ObjectProperty . + a owl:ObjectProperty . + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:range owl:Thing . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:range . + + + a owl:ObjectProperty ; + owl:inverseOf . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:range . + + + a owl:ObjectProperty ; + owl:inverseOf . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:range . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:range . + + + a owl:ObjectProperty ; + owl:inverseOf ; + rdfs:domain ; + rdfs:range . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:range . + + + a owl:ObjectProperty, owl:SymmetricProperty ; + rdfs:domain [ + a owl:Class ; + owl:unionOf ( + + + ) + ] ; + rdfs:range [ + a owl:Class ; + owl:unionOf ( + + + ) + ] . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range [ + a rdfs:Datatype ; + owl:unionOf ( + xsd:decimal + xsd:double + xsd:float + xsd:integer + ) + ] ; + rdfs:isDefinedBy ; + rdfs:label "numeric value" . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "means the sum of greenhouse gas emissions and greenhouse gas removals in a product system, expressed as CO equivalents and based on a life cycle assessment using the single impact category of climate change. (ESPR Art 2 25))" . + + + a owl:Class ; + dc11:description "Also called Impact factors, are factors that quantify the relative impact of different substances within the same category. These factors are used to convert the number of emissions into impact scores for each category. (LCAnet Ontology, SmartACV Project, Version 1.0, 2025)" . + + + a owl:Class ; + dc11:description """A characterization model provides a mathematical framework, as a set of equations, to quantify an impact category based on the chosen method. A characterization model is used by a method. (LCAnet Ontology, SmartACV Project, Version 1.0, 2025) +Example: IPCC (2021) AR6.""" . + + a owl:Class . + + a owl:Class ; + rdfs:comment "means any change to the environment, whether adverse or beneficial, wholly or partially resulting from a product during its life cycle. (ESPR Art 2 (14))" . + + + a owl:Class ; + dc11:description "An impact category represents a specific environmental issue that the LCA aims to assess, such as global warming or ozone depletion. Each category aggregates the effects of different emissions that contribute to the same environmental problem. (LCAnet Ontology, SmartACV Project, Version 1.0, 2025) An impact category is classified as whether is corresponds to environmental impact, social impact, etc." . + + + a owl:Class ; + dc11:description """An impact category refers to a specific metric/measurable quantity that is used to quantify an impact category. (LCAnet Ontology, SmartACV Project, Version 1.0, 2025) +Examples include: Global Warming Potential (GWP) and Ozone Depletion Potential (ODP).""" . + + + a owl:Class ; + dc11:description """An impact result corresponds to the computed value of product system, represented by its reference flow or functional unit. Thus, it is an entity that links an impact category indicator to a product reference flow of a single component of a more complex product, to its exact value (Adapted from LCAnet Ontology, SmartACV Project, Version 1.0, 2025) +Examples: An impact result which shows the GWP-100 (impact category indicator) value of product flow in the unit kg CO2-equivalents, resembles the products “Carbon Footprint”.""" . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "refers to the total amount of raw materials extracted to meet final consumption demands. (ESPR Art 2 (26))." . + + + a owl:Class ; + dc11:description "LCIA methods allow to transform and aggregate emissions to the environment and resource use data into a limited set of interpretable and reportable impact potential scores. (Adapted from LCAnet Ontology, SmartACV Project, Version 1.0, 2025)" . + + + a owl:Class ; + dc11:description """Related LCIA methods can be grouped under LCIA methodologies. (ILCD Handbook – General guidance). +Examples of methodologies include EN15804+A2, EF v3.1, Impact World.""" . + + + a owl:NamedIndividual, ; + rdfs:label "EUTREND model" ; + dcterms:bibliographicCitation "(Struijs et al, 2009) as applied in ReCiPe 2008" . + + + a owl:NamedIndividual, ; + ns0:ICI_assess_IC ns0:Resource_use_minerals_and_metals ; + ns0:ICI_quantified_by_CF ns0:3cde2dec99d14565a0fbd440b2e9a1e5 ; + rdfs:label "Abiotic resource depletion (ADP ultimate reserves)" ; + ns0:has_unit "kg Sb -eq" . + +ns0:Photochemical_ozone_formation_human_health + a owl:NamedIndividual, ns0:Impact_Category ; + rdfs:label "Photochemical ozone formation, human health" . + +ns0:Fraction_of_nutrients_reaching_marine_end_compartment_N + a owl:NamedIndividual, ns0:Impact_Category_Indicator ; + ns0:ICI_assess_IC ns0:Eutrophication_marine ; + ns0:ICI_quantified_by_CF ns0:51a1767a998e43ef85328b952ce81466 ; + rdfs:label "Fraction of nutrients reaching marine end compartment (N)" ; + ns0:has_unit "kg N -eq" . + +ns0:Land_use_occupation_and_transformation + a owl:NamedIndividual, ns0:Impact_Category ; + rdfs:label "Land use (occupation and transformation)" . + +ns0:Ozone_Depletion_Potential_ODP + a owl:NamedIndividual, ns0:Impact_Category_Indicator ; + ns0:ICI_assess_IC ns0:Ozone_depletion ; + ns0:ICI_quantified_by_CF ns0:dc3acd87af0a4fe4b4686ed51b13e934 ; + rdfs:label "Ozone Depletion Potential (ODP)" ; + ns0:has_unit "kg CFC-11-eq" . + +ns0:Accumulated_Exceedance_AE + a owl:NamedIndividual, ns0:Impact_Category_Indicator ; + ns0:ICI_assess_IC ns0:Acidification, ns0:Eutrophication_terrestrial ; + ns0:ICI_quantified_by_CF ns0:3a6dcc343adb4a9eafe7b8216fb820ff, ns0:fcc6b6dc5ce0460b9abf365124c24b60 ; + rdfs:label "Accumulated Exceedance (AE)" ; + ns0:has_unit "mol H+ -eq", "mol N -eq" . + +ns0:Ecotoxicity_freshwater + a owl:NamedIndividual, ns0:Impact_Category ; + rdfs:label "Ecotoxicity, freshwater" . + +ns0:Human_toxicity_non_cancer + a owl:NamedIndividual, ns0:Impact_Category ; + rdfs:label "Human toxicity, non-cancer" . + +ns0:Climate_change_total + a owl:NamedIndividual, ns0:Impact_Category ; + rdfs:label "Climate change, total" . + +ns0:Soil_quality_index_dimensionless + a owl:NamedIndividual, ns0:Impact_Category_Indicator ; + ns0:ICI_assess_IC ns0:Land_use_occupation_and_transformation ; + ns0:ICI_quantified_by_CF ns0:7972ac525f924b94afca491923c5489a ; + rdfs:label "Soil quality index (dimensionless)" . + +ns0:Particulate_matter + a owl:NamedIndividual, ns0:Impact_Category ; + rdfs:label "Particulate matter" . + +ns0:Global_warming_potential_GWP_over_a_100_year_time_horizon + a owl:NamedIndividual, ns0:Characterization_Factor ; + rdfs:label "Global warming potential (GWP) over a 100-year time horizon" ; + ns0:CF_calculated_by_CM ns0:Bern_model_based_on_IPCC_2021 . + +ns0:PM_model + a owl:NamedIndividual, ns0:Characterization_Model ; + rdfs:label "PM model" ; + dcterms:bibliographicCitation "(Fantke et al., 2016 in UNEP 2016)" . + +ns0:User_deprivation_potential_deprivation_weighted_consumption + a owl:NamedIndividual, ns0:Impact_Category_Indicator ; + ns0:ICI_assess_IC ns0:Water_use ; + ns0:ICI_quantified_by_CF ns0:21914d7240fd422597f68af660756198 ; + rdfs:label "User deprivation potential (deprivation-weighted consumption)" ; + ns0:has_unit "m3 world-eq" . + +ns0:Fraction_of_nutrients_reaching_freshwater_end_compartment_P + a owl:NamedIndividual, ns0:Impact_Category_Indicator ; + ns0:ICI_assess_IC ns0:Eutrophication_freshwater ; + ns0:ICI_quantified_by_CF ns0:ac65dd6094a14be2a259f19b78d3016f ; + rdfs:label "Fraction of nutrients reaching freshwater end compartment (P)" ; + ns0:has_unit "kg P -eq" . + +ns0:Human_health_effect_model + a owl:NamedIndividual, ns0:Characterization_Model ; + rdfs:label "Human health effect model" ; + dcterms:bibliographicCitation "as developed by Dreicer et al., 1995)" . + +ns0:Bern_model_based_on_IPCC_2021 + a owl:NamedIndividual, ns0:Characterization_Model ; + rdfs:label "Bern model based on IPCC 2021" ; + dcterms:bibliographicCitation "(Forster et al., 2021)" . + +ns0:Comparative_Toxic_Unit_for_humans_CTUh + a owl:NamedIndividual, ns0:Impact_Category_Indicator ; + ns0:ICI_assess_IC ns0:Human_toxicity_cancer, ns0:Human_toxicity_non_cancer ; + ns0:ICI_quantified_by_CF ns0:43640569fe3c4b1a99c900c9e6bc6980, ns0:9e9c4443b42440c99f790a02bea4d677 ; + rdfs:label "Comparative Toxic Unit for humans (CTUh)" ; + ns0:has_unit "CTUh" . + +ns0:Eutrophication_terrestrial + a owl:NamedIndividual, ns0:Impact_Category ; + rdfs:label "Eutrophication, terrestrial" . + +ns0:Human_exposure_efficiency_relative_to_U235 + a owl:NamedIndividual, ns0:Impact_Category_Indicator ; + ns0:ICI_assess_IC ns0:Ionising_radiation_human_health ; + ns0:ICI_quantified_by_CF ns0:a5795b7bdeb34ab0bf8401320356bd9f ; + rdfs:label "Human exposure efficiency relative to U235" ; + ns0:has_unit "kBq U235 -eq" . + +ns0:Eutrophication_freshwater + a owl:NamedIndividual, ns0:Impact_Category ; + rdfs:label "Eutrophication, freshwater" . + +ns0:Resource_use_fossils + a owl:NamedIndividual, ns0:Impact_Category ; + rdfs:label "Resource use, fossils" . + +ns0:Resource_use_minerals_and_metals + a owl:NamedIndividual, ns0:Impact_Category ; + rdfs:label "Resource use, minerals and metals" . + +ns0:Eutrophication_marine + a owl:NamedIndividual, ns0:Impact_Category ; + rdfs:label "Eutrophication, marine" . + +ns0:Ozone_depletion + a owl:NamedIndividual, ns0:Impact_Category ; + rdfs:label "Ozone depletion" . + +ns0:Accumulated_Exceedance + a owl:NamedIndividual, ns0:Characterization_Model ; + rdfs:label "Accumulated Exceedance" ; + dcterms:bibliographicCitation "(Seppälä et al. 2006, Posch et al, 2008)", "(Seppälä et al., 2006, Posch et al, 2008)" . + +ns0:Comparative_Toxic_Unit_for_ecosystems_CTUe + a owl:NamedIndividual, ns0:Impact_Category_Indicator ; + ns0:ICI_assess_IC ns0:Ecotoxicity_freshwater ; + ns0:ICI_quantified_by_CF ns0:18a6684317da48ff8f8c8c8f36200972 ; + rdfs:label "Comparative Toxic Unit for ecosystems (CTUe)" ; + ns0:has_unit "CTUe" . + +ns0:EDIP_model + a owl:NamedIndividual, ns0:Characterization_Model ; + rdfs:label "EDIP model" ; + dcterms:bibliographicCitation "based on the ODPs of the World Meteorological Organisation (WMO) over an infinite time horizon (WMO 2014 + integrations)" . + +ns0:LANCA_model + a owl:NamedIndividual, ns0:Characterization_Model ; + rdfs:label "LANCA model" ; + dcterms:bibliographicCitation "Soil quality index based on LANCA model (De Laurentiis et al. 2019) and on the LANCA CF version 2.5 (Horn and Maier, 2018)" . + +ns0:Acidification + a owl:NamedIndividual, ns0:Impact_Category ; + rdfs:label "Acidification" . + +ns0:Human_toxicity_cancer + a owl:NamedIndividual, ns0:Impact_Category ; + rdfs:label "Human toxicity, cancer" . + +ns0:Impact_on_human_health + a owl:NamedIndividual, ns0:Impact_Category_Indicator ; + ns0:ICI_assess_IC ns0:Particulate_matter ; + ns0:ICI_quantified_by_CF ns0:0daac3eb16074ecb93922f7338ae93bb ; + rdfs:label "Impact on human health" ; + ns0:has_unit "disease incidence" . + +ns0:LOTOS_EUROS_model + a owl:NamedIndividual, ns0:Characterization_Model ; + rdfs:label "LOTOS-EUROS model" ; + dcterms:bibliographicCitation "(Van Zelm et al, 2008) as applied in ReCiPe 2008" . + +ns0:Global_Warming_Potential_GWP100 + a owl:NamedIndividual, ns0:Impact_Category_Indicator ; + ns0:ICI_assess_IC ns0:Climate_change_total ; + ns0:ICI_quantified_by_CF ns0:Global_warming_potential_GWP_over_a_100_year_time_horizon ; + rdfs:label "Global Warming Potential (GWP100)" ; + ns0:has_unit "kg CO2-eq" . + +ns0:Biotic_resource_depletion_-_fossil_fuels_ADP_fossil + a owl:NamedIndividual, ns0:Impact_Category_Indicator ; + ns0:ICI_assess_IC ns0:Resource_use_fossils ; + ns0:ICI_quantified_by_CF ns0:f941187970724fce99e30b2b40f1a144 ; + rdfs:label "Biotic resource depletion – fossil fuels (ADP-fossil)" ; + ns0:has_unit "MJ" . + +ns0:Available_WAter_REmaining_AWARE_model + a owl:NamedIndividual, ns0:Characterization_Model ; + rdfs:label "Available WAter REmaining (AWARE) model" ; + dcterms:bibliographicCitation "(Boulay et al., 2018; UNEP 2016)" . + +ns0:Ionising_radiation_human_health + a owl:NamedIndividual, ns0:Impact_Category ; + rdfs:label "Ionising radiation, human health" . + +ns0:Tropospheric_ozone_concentration_increase + a owl:NamedIndividual, ns0:Impact_Category_Indicator ; + ns0:ICI_assess_IC ns0:Photochemical_ozone_formation_human_health ; + ns0:ICI_quantified_by_CF ns0:71c84e64a5e5417eafb4d50cc7f26eec ; + rdfs:label "Tropospheric ozone concentration increase" ; + ns0:has_unit "kg NMVOC -eq" . + +ns0:Water_use + a owl:NamedIndividual, ns0:Impact_Category ; + rdfs:label "Water use" . + +ns0:CML_2002_method_v.4.8 + a owl:NamedIndividual, ns0:Characterization_Model ; + rdfs:label "CML 2002 method, v.4.8" ; + dcterms:bibliographicCitation "van Oers et al., 2002" . + +ns0:Based_on_USEtox2.1_model + a owl:NamedIndividual, ns0:Characterization_Model ; + rdfs:label "Based on USEtox2.1 model" ; + dcterms:bibliographicCitation "(Fantke et al. 2017), adapted as in Saouter et al., 2018" . + + a . + a . + a . +[] + a ; + ns1:body ( + _:genid14 + _:genid16 + _:genid18 + _:genid20 + _:genid22 + ) ; + ns1:head ( _:genid24 ) . + +_:genid14 + a ns1:ClassAtom ; + ns1:classPredicate ns0:Impact_potential_result ; + ns1:argument1 . + +_:genid16 + a ns1:ClassAtom ; + ns1:classPredicate ns0:Impact_Category_Indicator ; + ns1:argument1 . + +_:genid18 + a ns1:IndividualPropertyAtom ; + ns1:propertyPredicate ns0:IR_computed_by_ICI ; + ns1:argument1 ; + ns1:argument2 . + +_:genid20 + a ns1:ClassAtom ; + ns1:classPredicate ns0:Impact_Category ; + ns1:argument1 . + +_:genid22 + a ns1:IndividualPropertyAtom ; + ns1:propertyPredicate ns0:ICI_assess_IC ; + ns1:argument1 ; + ns1:argument2 . + +_:genid24 + a ns1:IndividualPropertyAtom ; + ns1:propertyPredicate ns0:corresponds_to_IC ; + ns1:argument1 ; + ns1:argument2 . diff --git a/src/dppvalidator/vocabularies/data/ontologies/product_dpp_v1.7.1.ttl b/src/dppvalidator/vocabularies/data/ontologies/product_dpp_v1.7.1.ttl new file mode 100644 index 0000000..9a1965e --- /dev/null +++ b/src/dppvalidator/vocabularies/data/ontologies/product_dpp_v1.7.1.ttl @@ -0,0 +1,704 @@ +@prefix owl: . +@prefix dcterms: . +@prefix rdfs: . +@prefix skos: . +@prefix xsd: . + + + a owl:Ontology ; + dcterms:contributor "Riina Maigre"@en, "Tarmo Robal"@en, "Theodor Chirvasuta"@en ; + dcterms:creator "Hele-Mai Haav"@en ; + dcterms:description """The EU DPP core ontology proposed in CIRPASS-2 is a shared formal specification of basic concepts and properties/relationships in the cross-sectorial DPP domain to facilitate knowledge sharing across sector-specific DPPs. + +The proposed EU DPP core ontology captures several modules from which the Product and DPP module plays a central role. + +For more information, see +Maigre, R., Haav, H.-M., Robal, T., Wolf, M.-A., & Danash, F. (2025). Ontology Requirements Specification for an EU DPP Core Ontology Proposal (1.1). CIRPASS-2 Consortium. https://doi.org/10.5281/zenodo.15270342"""@en ; + dcterms:issued "2025-02-01" ; + dcterms:license "Except otherwise noted, original content on this document is licensed under the Creative Commons Attribution 4.0 International (CC BY 4.0) licence."@en ; + dcterms:modified "2025-10-30" ; + dcterms:title "The Product and DPP module of an EU DPP core ontology proposed by the CIRPASS-2 project."@en ; + rdfs:comment "The second iteration of the Product and DPP module of an EU DPP core ontology proposed by the CIRPASS-2 project."@en ; + rdfs:label "The Product and DPP module of an EU DPP core ontology proposed by the CIRPASS-2 project."@en ; + owl:versionInfo "1.7.1" . + +dcterms:contributor a owl:AnnotationProperty . +dcterms:creator a owl:AnnotationProperty . +dcterms:description a owl:AnnotationProperty . +dcterms:issued a owl:AnnotationProperty . +dcterms:license a owl:AnnotationProperty . +dcterms:modified a owl:AnnotationProperty . +dcterms:title a owl:AnnotationProperty . +skos:exactMatch a owl:AnnotationProperty . + + a owl:ObjectProperty ; + owl:inverseOf ; + rdfs:domain ; + rdfs:range ; + rdfs:comment "Linking DPP and the product."@en ; + rdfs:label "Applies to product"@en . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:comment """Linking the product to a substance of concern the product contains. + +containsSubstanceOfConcern object property range is defined in the EUDPP Core ontology. + +Substance of concern is modelled by the SubstanceOfConcern class in the module SoC."""@en ; + rdfs:label "Contains substance of concern"@en . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:comment """The reference of the digital product passport service provider hosting the back-up copy of the digital product passport (ESPR Annex III) + +hasBackUpCopyHost object property range is defined in the EUDPP Core ontology and suitable role classes are defined in the ACTOR module."""@en ; + rdfs:label "Has back-up-copy host"@en . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:range ; + rdfs:comment "A product has a DPP ."@en ; + rdfs:label "Has DPP"@en . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:comment """Linking the product to the economic operator. + +hasEconomicOperator object property range is defined in the EUDPP Core ontology and suitable role classes (i.e. the Economic operator class) is defined in the ACTOR module."""@en ; + rdfs:label "Has economic operator"@en . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:comment """Linking DPP with its issuer. + +hasIssuer object property range is defined in the EUDPP Core ontology and suitable role classes are defined in the ACTOR module."""@en ; + rdfs:label "Has issuer"@en . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:comment """A product has a manufacturer. + +hasManufacturer object property range is defined in the EUDPP Core ontology and suitable role classes are defined in the ACTOR module."""@en ; + rdfs:label "Has manufacturer"@en . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:range ; + rdfs:comment "Linking quantitative property to the unit of measurement."@en ; + rdfs:label "Has measurement unit"@en . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:range ; + rdfs:comment """Product group +means a set of products that serve similar purposes and are similar in terms of use, or have similar +functional properties, and are similar in terms of consumer perception (ESPR Art 2 (4)). The product +group or groups covered are given by delegated acts. (ESPR Art 8 (a)) + +This will be probably a classification code from some product classification scheme ."""@en ; + rdfs:label "Has Product group"@en . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:range ; + rdfs:comment """Product has a property that could be a document or some measurable properties (measurands). + +Linking the product to the property."""@en ; + rdfs:label "Has property"@en . + + + a owl:ObjectProperty, owl:TransitiveProperty ; + rdfs:domain ; + rdfs:range ; + rdfs:comment "A product is a component of another product."@en ; + rdfs:label "Is component of"@en . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:range ; + rdfs:comment "A product is a spare part of another product."@en ; + rdfs:label "Is spare part of"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "Global Trade Identification Number as provided for in standard ISO/IEC 15459-6 or equivalent of products or their parts."@en ; + rdfs:label "GTIN"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:anyURI ; + rdfs:comment "The product classification code set."@en ; + rdfs:label "Classification code set"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "Classification code value from the classification code set."@en ; + rdfs:label "Classification code value"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "Relevant commodity codes, such as a TARIC code as defined in Council Regulation (EEC) No 2658/87."@en ; + rdfs:label "Commodity code"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "Content type of a document. Can be only “application” or “pdf”."@en ; + rdfs:label "Content type"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "Product description"@en ; + rdfs:label "Description"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment """The reference to the unique identifier of the data point specification defined in the repository/data dictionary. + +It is formatted as a URI/URL ."""@en ; + rdfs:label "Dictionary reference"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment """Unique facility identifier +means a unique string of characters for the identification of locations or buildings involved in a product’s value chain or used by actors involved in a product’s value chain. (ESPR Art 2 (33)) + +A unique string of characters for the identification of locations or buildings involved in a product’s value chain or used by actors involved in a product’s life cycle."""@en ; + rdfs:label "Unique facility identifier"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range [ + a rdfs:Datatype ; + owl:oneOf ( + " batch" + "model" + "product" + ) + ] ; + rdfs:comment """The product passport should be defined at the level of item, batch or product model, depending on specific needs and complexity of the value chain, the size, nature or impacts of the products considered. (Standardisation Request 5423 7) + +The level of granularity of the ProductID as per ESPR."""@en ; + rdfs:label "granularity"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:boolean ; + rdfs:comment """Energy related product +means any product that has an impact on energy consumption during use. (ESPR Art 2 (4)). + +The boolean data type property indicates is a product energy related or not?"""@en ; + rdfs:label "Energy related"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:dateTimeStamp ; + rdfs:comment "The date and time of the latest update to the DPP instance."@en ; + rdfs:label "Last update"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:anyURI ; + rdfs:comment """Where a new digital product passport is created for a product that already has a digital product +passport, the new digital product passport shall be linked to the original digital product passport or +passports. (ESPR Art 11 (d))"""@en ; + rdfs:label "Link to previous DPP"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:decimal ; + rdfs:comment "The numerical value of the quantity i.e. quantitative property of the product."@en ; + rdfs:label "Numerical value"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:anyURI ; + rdfs:comment "An image of the product."@en ; + rdfs:label "Product image"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "Product name"@en ; + rdfs:label "Product name"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "The reference standard the DPP instance schema refers to."@en ; + rdfs:label "Schema version"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range [ + a rdfs:Datatype ; + owl:oneOf ( + "Active" + "Archived" + ) + ] ; + rdfs:comment "The status of the DPP instance as digital resource."@en ; + rdfs:label "DPP status"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:double ; + rdfs:comment """Engineering tolerance is the permissible variation in measurements deriving from the base measurement. +Tolerances can apply to many different units."""@en ; + rdfs:label "Tolerance"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:anyURI ; + rdfs:comment "A unique identifier (URI) assigned to the product passport."@en ; + rdfs:label "Unique DPP ID"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range [ + a rdfs:Datatype ; + owl:unionOf ( + xsd:anyURI + xsd:string + ) + ] ; + rdfs:comment """Unique product identifier +means a unique string of characters for the identification of a product that also enables a web link to the digital product passport. (ESPR Art 2 (30))"""@en ; + rdfs:label "Unique product identifier"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:dateTimeStamp ; + rdfs:comment "The date and timestamp that the DPP is valid from."@en ; + rdfs:label "Valid from"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:dateTimeStamp ; + rdfs:comment "The date and timestamp that the DPP is valid until."@en ; + rdfs:label "Valid until"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment """Contains the data of the value encapsulated in the Document. + +Value is xsd:string that is formatted as a URL, where to fetch the file."""@en ; + rdfs:label "Value related to the document"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:anyURI ; + rdfs:comment "Web link to an instruction document or video etc."@en ; + rdfs:label "weblink"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Carbon footprint +means the sum of greenhouse gas emissions and greenhouse gas removals in a product system, expressed as CO equivalents and based on a life cycle assessment using the single impact category +of climate change. (ESPR Art 2 25)) + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Carbon footprint"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Circular Economy indicators are measures for relevant monitoring the transition to a circular economy and to measure the effects of new policy and trends. + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Circular Economy Indicator"@en ; + rdfs:seeAlso . + + + a owl:Class ; + rdfs:comment "A classification code from some product classification scheme ."@en ; + rdfs:label "ClassificationCode"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "The concentration of the substances of concern, at the level of the product, its relevant components, or spare parts ESPR Art 7(5)"@en ; + rdfs:label "Concentration of the substance of concern"@en . + + + a owl:Class ; + rdfs:comment """Digital Product Passport (DPP) means a set of data specific to a product that includes the information specified in the applicable +delegated act adopted pursuant to Article 4 and that is accessible via electronic means through a data carrier in accordance with Chapter III. (ESPR Art 2 (28))"""@en ; + rdfs:label "Digital Product Passport (DPP)"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Digital instructions +mean instructions in digital format concerning the product (‘digital instructions’) in a language that can be easily understood. ESPR Art 27(7) +Digital instructions are ensured by manufacturers. These should also ensured by importers. (ESPR Art 29(4)) + +This class can be extended/specialised according to delegated acts or industry standards."""@en ; + rdfs:label "DigitalInstruction"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "The document class describes the semantic representation of any instance of a document that might be required to be included into a digital product passport instance."@en ; + rdfs:label "Document"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Durability +means the ability of a product to maintain over time its function and performance under specified conditions of use, maintenance and repair. (ESPR Art 2 (22)) + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Durability"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Product parameters are listed in the ESPR Annex I : + +(q) emissions to air, water or soil released in one or more lifecycle stages of the product as expressed through quantities and nature of emissions, including noise. + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Emission to air"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Product parameters are listed in the ESPR Annex I : + +(q) emissions to air, water or soil released in one or more lifecycle stages of the product as expressed through quantities and nature of emissions, including noise. + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Emission to soil"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Product parameters are listed in the ESPR Annex I : + +(q) emissions to air, water or soil released in one or more lifecycle stages of the product as expressed through quantities and nature of emissions, including noise. + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Emission to water"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Energy consumption of the product. + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Energy consumption"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Emission is the act or amount of sending out gas, heat, light, etc. It can also refer to harmful substances that are released into the environment, especially carbon dioxide. + + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Environmental emission"@en ; + rdfs:seeAlso . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Environmental footprint +means a quantification of the environmental impacts resulting from a product throughout its life +cycle, whether in relation to a single environmental impact category or an aggregated set of impact +categories based on the Product Environmental Footprint method established by Recommendation +(EU) 2 021/2279 or other scientific methods developed by international organisations, widely tested +in collaboration with different industry sectors and adopted or implemented by the Commission in +other Union law. (ESPR Art 2 (24)). + +Product parameters list ESPR Annex I +(m) The environmental footprint of the product, expressed as a quantification; + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Environmental footprint"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Environmental pollution is the addition of any substance or form of energy to the environment at a rate faster than it can be dispersed or safely stored. + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Environmental pollution"@en ; + rdfs:seeAlso . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Hazardous waste amount of the product. + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Hazardous waste amount"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Height of the product"@en ; + rdfs:label "Height"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Land use of the product. + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Land use"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Length of the product"@en ; + rdfs:label "Length"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Material footprint +refers to the total amount of raw materials extracted to meet final consumption demands. (ESPR Art 2 (26)) + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Material footprint"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Product parameters as listed in the ESPR Annex I : + +(p) microplastic and nano plastic release as expressed through the release during relevant +product life cycle stages, including manufacturing, transport, use and end of life stages; + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Microplastic release"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Product parameters as listed in the ESPR Annex I : + +(p) microplastic and nano plastic release as expressed through the release during relevant +product life cycle stages, including manufacturing, transport, use and end of life stages; + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Nanoplastic release"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Packaging waste amount of the product. + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Packaging waste amount"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Product parameters as listed in the ESPR Annex I : + +(p) microplastic and nano plastic release as expressed through the release during relevant +product life cycle stages, including manufacturing, transport, use and end of life stages; + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Plastic release"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Hazardous waste amount of the product. + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Plastics waste amount"@en . + + + a owl:Class ; + rdfs:comment "Product means any physical goods that are placed on the market or put into service. ESPR Art 2 (1))"@en ; + rdfs:label "Product"@en ; + skos:exactMatch "schema:Product" . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Dimension class covers measurements of length, width, height, volume, and weight of the product required by +ESPR Annex I in its product parameters"""@en ; + rdfs:label "Product dimension"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """ESPR Annex I, the list of product parameters includes the parameter called the product to packaging ratio. + +However, it is not specified in ESPR whether that ratio is defined on the basis of weight or on the basis of volume. + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Product to packaging ratio"@en . + + + a owl:Class ; + rdfs:comment "A property of the product."@en ; + rdfs:label "Property"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Product characteristics like durability and reliability. + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Quality indicator"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "A property (respectively measurand of a property) of the product (model) that is expressed by a quantity value. A specific quantity."@en ; + rdfs:label "Quantitative Property"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."@en ; + rdfs:label "Recoverable rate"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Recycled materials use of the product. + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Recycled materials use"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."@en ; + rdfs:label "Recycling collection rate"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."@en ; + rdfs:label "Recycling rate"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Reliability +means the probability that a product functions as required under given conditions for a given +duration without an occurrence which results in a primary or secondary function of the product no +longer being performed. (ESPR Art 2 (16)) + +Reliability is usually expressed in terms of mean time between failures (MTBF), which is the average length of time that a device will operate without failure. + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Reliability"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Resource consumption of the product. + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Resource consumption"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Sustainable renewable materials use of the product. + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Sustainable renewable materials use"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Relevant threshold of Substance of Concern. + +By default, information on all substances of concern present in a product, above the relevant thresholds, should be included in the DPP [ESPR FAQ 10.115, p59]"""@en ; + rdfs:label "Threshold of substance of concern"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Volume of the product"@en ; + rdfs:label "Volume"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Waste generation amount of the product. + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Waste generation amount"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment """Water consumption of the product. + +This class description needs to be extended/specialised according to the corresponding delegated acts or/and industry standards."""@en ; + rdfs:label "Water consumption"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Weight of the product"@en ; + rdfs:label "Weight"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "Width of the product"@en ; + rdfs:label "Width"@en . + + + a owl:Class ; + rdfs:comment """Measurement unit defining the magnitude of the quantity in respect to the value. +si:MeasurementUnit class is reused"""@en ; + rdfs:label "Unit of measurement"@en . + + a owl:NamedIndividual . + a owl:NamedIndividual . + a owl:NamedIndividual . +[] rdfs:comment "The EU DPP core ontology" . diff --git a/src/dppvalidator/vocabularies/data/ontologies/soc_v1.4.7.ttl b/src/dppvalidator/vocabularies/data/ontologies/soc_v1.4.7.ttl new file mode 100644 index 0000000..55c1c65 --- /dev/null +++ b/src/dppvalidator/vocabularies/data/ontologies/soc_v1.4.7.ttl @@ -0,0 +1,173 @@ +@prefix owl: . +@prefix dcterms: . +@prefix rdfs: . +@prefix xsd: . + + + a owl:Ontology ; + dcterms:contributor "Hele-Mai Haav"@en ; + dcterms:creator "Tarmo Robal"@en ; + dcterms:description """MODULE: +Substances of Concern module of the EU DPP core ontology proposal of the CIRPASS-2 project. + +The information requirements laid out in ESPR Art 7(2) and Art 7(5) for products require the information requirements to make it possible to track the substances of concern, throughout the life cycle of the products concerned. These information requirements for specific product groups are specified in the delegated acts adopted pursuant to Art 4 of ESPR. + +This module addresses only the information requirements referred and stated in the ESPR. +===================================== +CONTEXT: +Regulation (EC) No 1907/2006 Art 3(1) defines Substance: means a chemical element and its compounds in the natural state or obtained by +any manufacturing process, including any additive necessary to preserve its stability and any impurity deriving from the process used, but excluding any solvent which may be separated without affecting the stability of the substance or changing its composition; + +------------------------------------------------------------------- +Known issues: + - Use of http instead of https for namespaces (issue from Protege desktop). + - Still missing final namespace (under negotiation in and beyuond T4.2). + - Still missing permanent URL (connected to previous issue). + - hasLifeCycleStage misses range (Event). + - Status of prefix dpp: tentative and unknown, dependent on namespace choice. + - Consider adding author/contributor ORCIDs. + - Potentially use general namespace (currently EUDPP) and not a distinct one (currently EUDPP/SOC). To be decided when final namespace is clear. + +THIS DOCUMENT IS PROVIDED WITH NO WARRANTIES WHATSOEVER. +All liability for any damages arising from use or misuse of this vocabulary is with the user."""@en ; + dcterms:issued "2025-10-28"@en ; + dcterms:licence "Except otherwise noted, original content on this document is licensed under the Creative Commons Attribution 4.0 International (CC BY 4.0) licence, once published."@en ; + dcterms:title "The Substance of Concern module of the EU DPP core ontology proposed by the CIRPASS-2 project."@en ; + rdfs:comment "Version 1.4.x of the Substances of Concern module of the EU DPP core ontology proposal of the CIRPASS-2 project."@en ; + rdfs:label "The Substances of Concern module of the EU DPP core ontology proposal of the CIRPASS-2 project."@en ; + owl:versionInfo "1.4.7" . + +dcterms:contributor a owl:AnnotationProperty . +dcterms:creator a owl:AnnotationProperty . +dcterms:description a owl:AnnotationProperty . +dcterms:issued a owl:AnnotationProperty . +dcterms:licence a owl:AnnotationProperty . +dcterms:title a owl:AnnotationProperty . + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:range ; + rdfs:comment "Defines the the concentration, maximum concentration or concentration range of the substances of concern, at the level of the product, its relevant components, or spare parts; ESPR Art 7(5)"@en ; + rdfs:label "Has concentration"@en . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:comment "Describes the life cycle stage during which the Substance of Concern occurs for product (used during production, resulting being in product, in waste); ESPR Annex I (f)"@en ; + rdfs:label "Has life cycle stage"@en . + + + a owl:ObjectProperty ; + rdfs:domain ; + rdfs:range ; + rdfs:comment """Describes relevant threshold of Substance of Concern. + +By default, information on all substances of concern present in a product, above the relevant thresholds, should be included in the DPP [ESPR FAQ 10.115, p59]"""@en ; + rdfs:label "Has threshold"@en . + + + a owl:DatatypeProperty ; + rdfs:subPropertyOf ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "The abbreviation used for the Substance of Concern; [ESPR Article 7(5)]"@en ; + rdfs:label "Abbreviation"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "Describes the impacts of Substance of Concern on environment; ESPR Annex I (f)"@en ; + rdfs:label "Has impact on environment"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "Describes the impacts of Substance of Concern on human health; ESPR Annex I (f)"@en ; + rdfs:label "Has impact on human health"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "The Chemical Abstract Service (CAS) name, if available; [ESPR Article 7(5)]"@en ; + rdfs:label "CAS name"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "Name in the International Union of Pure and Applied Chemistry (IUPAC) nomenclature, or another international name when IUPAC name is not available [ESPR Article 7(5)]"@en ; + rdfs:label "IUPAC name"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "The Chemical Abstract Service (CAS) number, if available; [ESPR Article 7(5)]"@en ; + rdfs:label "CAS number"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment """EXAMPLES: + +Mercurous Oxide +EC Number: 239-934-0 +CAS Number: 15829-53-5""", "European Community (EC) number, as indicated in the European Inventory of Existing Commercial Chemical Substances (EINECS), the European List of Notified Chemical Substances (ELINCS) or the No Longer Polymer (NLP) list or the number assigned by the European Chemicals Agency (ECHA), if available and appropriate [ESPR Article 7(5)]"@en ; + rdfs:label "EC number"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "Other names, including usual name, trade name, abbreviation; [ESPR Article 7(5)]"@en ; + rdfs:label "Other names"@en . + + + a owl:DatatypeProperty ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "Location of the substance in the product; ESPR Art 7(5)"@en ; + rdfs:label "Location of substance"@en . + + + a owl:DatatypeProperty ; + rdfs:subPropertyOf ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "The trade name under which the Substance of Concern is known; [ESPR Article 7(5)]"@en ; + rdfs:label "Trade name"@en . + + + a owl:DatatypeProperty ; + rdfs:subPropertyOf ; + rdfs:domain ; + rdfs:range xsd:string ; + rdfs:comment "The usual name under which the Substance of Concern is known; [ESPR Article 7(5)]"@en ; + rdfs:label "Usual name"@en . + + + a owl:Class ; + rdfs:comment "The concentration of the substances of concern, at the level of the product, its relevant components, or spare parts ESPR Art 7(5)"@en ; + rdfs:label "Concentration of the substance of concern"@en . + + + a owl:Class ; + rdfs:comment "Means a chemical element and its compounds in the natural state or obtained by any manufacturing process, including any additive necessary to preserve its stability and any impurity deriving from the process used, but excluding any solvent which may be separated without affecting the stability of the substance or changing its composition; ESPR Art 2, Regulation (EC) No 1907/2006"@en ; + rdfs:label "Substance"@en . + + + a owl:Class ; + rdfs:subClassOf ; + rdfs:comment "means a substance that:(a) meets the criteria laid down in Article 57 of Regulation (EC) No 1907/2006 and is identified in accordance with Article 59(1) of that Regulation; (b) is classified in Part 3 of Annex VI to Regulation (EC) No 1272/2008 in one of the following hazard classes or hazard categories: (i) carcinogenicity categories 1 and 2; (ii) germ cell mutagenicity categories 1 and 2; (iii) reproductive toxicity categories 1 and 2; (iv) endocrine disruption for human health categories 1 and 2; (v) endocrine disruption for the environment categories 1 and 2; (vi) persistent, mobile and toxic or very persistent, very mobile properties; (vii) persistent, bioaccumulative and toxic or very persistent, very bioaccumulative properties; (viii) respiratory sensitisation category 1; (ix) skin sensitisation category 1; (x) hazardous to the aquatic environment — categories chronic 1 to 4; (xi) hazardous to the ozone layer; (xii) specific target organ toxicity — repeated exposure categories 1 and 2; (xiii) specific target organ toxicity — single exposure categories 1 and 2; (c) is regulated under Regulation (EU) 2019/1021; or (d) negatively affects the reuse and recycling of materials in the product in which it is present [ESPR Art 2(10)]"@en ; + rdfs:label "Substance of concern"@en . + + + a owl:Class ; + rdfs:comment """Relevant threshold of Substance of Concern. + +By default, information on all substances of concern present in a product, above the relevant thresholds, should be included in the DPP [ESPR FAQ 10.115, p59]"""@en ; + rdfs:label "Threshold of substance of concern"@en . diff --git a/src/dppvalidator/vocabularies/data/schemas/__init__.py b/src/dppvalidator/vocabularies/data/schemas/__init__.py new file mode 100644 index 0000000..8cd69c2 --- /dev/null +++ b/src/dppvalidator/vocabularies/data/schemas/__init__.py @@ -0,0 +1 @@ +"""Bundled CIRPASS schema and SHACL files.""" diff --git a/src/dppvalidator/vocabularies/data/schemas/cirpass_dpp_openapi.json b/src/dppvalidator/vocabularies/data/schemas/cirpass_dpp_openapi.json new file mode 100644 index 0000000..c026bed --- /dev/null +++ b/src/dppvalidator/vocabularies/data/schemas/cirpass_dpp_openapi.json @@ -0,0 +1,648 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "CIRPASS DPP reference structure", + "version": "1.3.0 (WIP)", + "description": "This OAS Specification was generated by Semantic Treehouse's FIT Wizard on 2026-02-01T12:16:09+00:00. It is meant as a starting point for developers. No rights can be derived from this document." + }, + "servers": [ + { + "url": "https://example.org/api/v1", + "description": "The Example API server." + } + ], + "paths": { + "/DigitalProductPassport": { + "post": { + "tags": [ + "DigitalProductPassport" + ], + "summary": "Create a resource of type DigitalProductPassport.", + "description": "Create a resource of type DigitalProductPassport.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DigitalProductPassport" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successfully created the resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DigitalProductPassport" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorised" + }, + "415": { + "description": "Unsupported Media Type" + } + } + } + }, + "/DigitalProductPassport/{resourceId}": { + "get": { + "tags": [ + "DigitalProductPassport" + ], + "summary": "Retrieve a resource of type DigitalProductPassport by id.", + "description": "Retrieve a resource of type DigitalProductPassport by id.", + "parameters": [ + { + "name": "resourceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DigitalProductPassport" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorised" + }, + "404": { + "description": "Resource not found" + }, + "415": { + "description": "Unsupported Media Type" + } + } + }, + "put": { + "tags": [ + "DigitalProductPassport" + ], + "summary": "Fully update a resource of type DigitalProductPassport or create it if it does not exist.", + "description": "Fully update a resource of type DigitalProductPassport or create it if it does not exist.", + "parameters": [ + { + "name": "resourceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DigitalProductPassport" + } + } + } + }, + "responses": { + "200": { + "description": "Successfully updated (or created) the resource.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DigitalProductPassport" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorised" + }, + "415": { + "description": "Unsupported Media Type" + } + } + } + } + }, + "components": { + "schemas": { + "DigitalProductPassport": { + "title": "CIRPASS DPP reference structure v1.3.0", + "description": "Generated by Semantic Treehouse on 2026-02-01T12:16:09+00:00", + "additionalProperties": false, + "properties": { + "status": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "uniqueDPPID": { + "items": { + "type": "string" + }, + "type": "array" + }, + "validFrom": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "validUntil": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "facilityID": { + "items": { + "type": "string" + }, + "type": "array" + }, + "lastUpdate": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "appliesToProduct": { + "items": { + "additionalProperties": false, + "properties": { + "Has_compliance_information": { + "type": "string", + "format": "uri-reference" + }, + "hasEconomicOperator": { + "items": { + "additionalProperties": false, + "properties": { + "UniqueoperatorID": { + "description": "Unique operator identifier means a unique string of characters for the identification of an actor involved in a product\u2019s value chain. (ESPR Art. 2 (31))", + "type": "string", + "format": "uri-reference" + }, + "registeredTradeName": { + "items": { + "type": "string" + }, + "type": "array" + }, + "postalAddress": { + "items": { + "type": "string" + }, + "type": "array" + }, + "registeredTrademark": { + "items": { + "type": "string" + }, + "type": "array" + }, + "hasRole": { + "items": { + "additionalProperties": false, + "properties": { + "isRoleOf": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "usesFacility": { + "items": { + "additionalProperties": false, + "properties": { + "isUsedByActor": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "uniqueFacilityID": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "electronicContact": { + "items": { + "type": "string" + }, + "type": "array" + }, + "actorName": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "hasDPP": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "hasProductGroup": { + "items": { + "additionalProperties": false, + "properties": { + "codeValue": { + "items": { + "type": "string" + }, + "type": "array" + }, + "codeSet": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "uniqueProductID": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "hasManufacturer": { + "items": { + "additionalProperties": false, + "properties": { + "uniqueOperatorID": { + "description": "Unique operator identifier means a unique string of characters for the identification of an actor involved in a product\u2019s value chain. (ESPR Art. 2 (31))", + "type": "string", + "format": "uri-reference" + }, + "registeredTradeName": { + "items": { + "type": "string" + }, + "type": "array" + }, + "registeredTrademark": { + "items": { + "type": "string" + }, + "type": "array" + }, + "postalAddress": { + "items": { + "type": "string" + }, + "type": "array" + }, + "hasRole": { + "items": { + "additionalProperties": false, + "properties": { + "isRoleOf": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "actorName": { + "items": { + "type": "string" + }, + "type": "array" + }, + "usesFacility": { + "items": { + "additionalProperties": false, + "properties": { + "uniqueFacilityID": { + "items": { + "type": "string" + }, + "type": "array" + }, + "isUsedByActor": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "electronicContact": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "description": { + "items": { + "type": "string" + }, + "type": "array" + }, + "GTIN": { + "items": { + "type": "string" + }, + "type": "array" + }, + "containsSubstanceOfConcern": { + "items": { + "additionalProperties": false, + "properties": { + "numberEC": { + "items": { + "type": "string" + }, + "type": "array" + }, + "abbreviation": { + "items": { + "type": "string" + }, + "type": "array" + }, + "tradeName": { + "items": { + "type": "string" + }, + "type": "array" + }, + "usualName": { + "items": { + "type": "string" + }, + "type": "array" + }, + "numberCAS": { + "items": { + "type": "string" + }, + "type": "array" + }, + "nameCAS": { + "items": { + "type": "string" + }, + "type": "array" + }, + "hasLifeCycleStage": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "hasConcentration": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "hasImpactOnHumanHealth": { + "items": { + "type": "string" + }, + "type": "array" + }, + "nameIUPAC": { + "items": { + "type": "string" + }, + "type": "array" + }, + "otherName": { + "items": { + "type": "string" + }, + "type": "array" + }, + "substanceLocation": { + "items": { + "type": "string" + }, + "type": "array" + }, + "hasThreshold": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "hasImpactOnEnvironment": { + "items": { + "additionalProperties": false, + "properties": { + "corresponds_to_IC": { + "items": { + "description": "An impact category represents a specific environmental issue that the LCA aims to assess, such as global warming or ozone depletion. Each category aggregates the effects of different emissions that contribute to the same environmental problem. (LCAnet Ontology, SmartACV Project, Version 1.0, 2025) An impact category is classified as whether is corresponds to environmental impact, social impact, etc.", + "enum": [ + "http://dpp.cea.fr/EUDPP/LCA#Acidification", + "http://dpp.cea.fr/EUDPP/LCA#Climate_change_total", + "http://dpp.cea.fr/EUDPP/LCA#Ecotoxicity_freshwater", + "http://dpp.cea.fr/EUDPP/LCA#Eutrophication_freshwater", + "http://dpp.cea.fr/EUDPP/LCA#Eutrophication_marine", + "http://dpp.cea.fr/EUDPP/LCA#Eutrophication_terrestrial", + "http://dpp.cea.fr/EUDPP/LCA#Human_toxicity_cancer", + "http://dpp.cea.fr/EUDPP/LCA#Human_toxicity_non_cancer", + "http://dpp.cea.fr/EUDPP/LCA#Ionising_radiation_human_health", + "http://dpp.cea.fr/EUDPP/LCA#Land_use_occupation_and_transformation", + "http://dpp.cea.fr/EUDPP/LCA#Ozone_depletion", + "http://dpp.cea.fr/EUDPP/LCA#Particulate_matter", + "http://dpp.cea.fr/EUDPP/LCA#Photochemical_ozone_formation_human_health", + "http://dpp.cea.fr/EUDPP/LCA#Resource_use_fossils", + "http://dpp.cea.fr/EUDPP/LCA#Resource_use_minerals_and_metals", + "http://dpp.cea.fr/EUDPP/LCA#Water_use" + ], + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "corresponds_to": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "numericValue": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "commodityCode": { + "items": { + "type": "string" + }, + "type": "array" + }, + "productName": { + "items": { + "type": "string" + }, + "type": "array" + }, + "productImage": { + "items": { + "type": "string" + }, + "type": "array" + }, + "isSparePartOf": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "isEnergyRelated": { + "items": { + "type": "boolean" + }, + "type": "array" + }, + "hasProperty": { + "items": { + "additionalProperties": false, + "properties": { + "dictionaryReference": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "isComponentOf": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "linkToPreviousDPP": { + "items": { + "type": "string" + }, + "type": "array" + }, + "schemaVersion": { + "items": { + "type": "string" + }, + "type": "array" + }, + "hasIssuer": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "hasBackUpCopyHost": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "granularity": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + } + }, + "type": "object", + "$id": "urn:cirpass2:eudpp/DigitalProductPassport" + } + } + } +} diff --git a/src/dppvalidator/vocabularies/data/schemas/cirpass_dpp_schema.json b/src/dppvalidator/vocabularies/data/schemas/cirpass_dpp_schema.json new file mode 100644 index 0000000..b2da35a --- /dev/null +++ b/src/dppvalidator/vocabularies/data/schemas/cirpass_dpp_schema.json @@ -0,0 +1,499 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "CIRPASS DPP reference structure v1.3.0", + "description": "Generated by Semantic Treehouse on 2026-02-01T12:16:08+00:00", + "additionalProperties": false, + "properties": { + "status": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "uniqueDPPID": { + "items": { + "type": "string" + }, + "type": "array" + }, + "validFrom": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "validUntil": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "facilityID": { + "items": { + "type": "string" + }, + "type": "array" + }, + "lastUpdate": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "appliesToProduct": { + "items": { + "additionalProperties": false, + "properties": { + "Has_compliance_information": { + "type": "string", + "format": "uri-reference" + }, + "hasEconomicOperator": { + "items": { + "additionalProperties": false, + "properties": { + "UniqueoperatorID": { + "description": "Unique operator identifier means a unique string of characters for the identification of an actor involved in a product\u2019s value chain. (ESPR Art. 2 (31))", + "type": "string", + "format": "uri-reference" + }, + "registeredTradeName": { + "items": { + "type": "string" + }, + "type": "array" + }, + "postalAddress": { + "items": { + "type": "string" + }, + "type": "array" + }, + "registeredTrademark": { + "items": { + "type": "string" + }, + "type": "array" + }, + "hasRole": { + "items": { + "additionalProperties": false, + "properties": { + "isRoleOf": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "usesFacility": { + "items": { + "additionalProperties": false, + "properties": { + "isUsedByActor": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "uniqueFacilityID": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "electronicContact": { + "items": { + "type": "string" + }, + "type": "array" + }, + "actorName": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "hasDPP": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "hasProductGroup": { + "items": { + "additionalProperties": false, + "properties": { + "codeValue": { + "items": { + "type": "string" + }, + "type": "array" + }, + "codeSet": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "uniqueProductID": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "hasManufacturer": { + "items": { + "additionalProperties": false, + "properties": { + "uniqueOperatorID": { + "description": "Unique operator identifier means a unique string of characters for the identification of an actor involved in a product\u2019s value chain. (ESPR Art. 2 (31))", + "type": "string", + "format": "uri-reference" + }, + "registeredTradeName": { + "items": { + "type": "string" + }, + "type": "array" + }, + "registeredTrademark": { + "items": { + "type": "string" + }, + "type": "array" + }, + "postalAddress": { + "items": { + "type": "string" + }, + "type": "array" + }, + "hasRole": { + "items": { + "additionalProperties": false, + "properties": { + "isRoleOf": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "actorName": { + "items": { + "type": "string" + }, + "type": "array" + }, + "usesFacility": { + "items": { + "additionalProperties": false, + "properties": { + "uniqueFacilityID": { + "items": { + "type": "string" + }, + "type": "array" + }, + "isUsedByActor": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "electronicContact": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "description": { + "items": { + "type": "string" + }, + "type": "array" + }, + "GTIN": { + "items": { + "type": "string" + }, + "type": "array" + }, + "containsSubstanceOfConcern": { + "items": { + "additionalProperties": false, + "properties": { + "numberEC": { + "items": { + "type": "string" + }, + "type": "array" + }, + "abbreviation": { + "items": { + "type": "string" + }, + "type": "array" + }, + "tradeName": { + "items": { + "type": "string" + }, + "type": "array" + }, + "usualName": { + "items": { + "type": "string" + }, + "type": "array" + }, + "numberCAS": { + "items": { + "type": "string" + }, + "type": "array" + }, + "nameCAS": { + "items": { + "type": "string" + }, + "type": "array" + }, + "hasLifeCycleStage": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "hasConcentration": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "hasImpactOnHumanHealth": { + "items": { + "type": "string" + }, + "type": "array" + }, + "nameIUPAC": { + "items": { + "type": "string" + }, + "type": "array" + }, + "otherName": { + "items": { + "type": "string" + }, + "type": "array" + }, + "substanceLocation": { + "items": { + "type": "string" + }, + "type": "array" + }, + "hasThreshold": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "hasImpactOnEnvironment": { + "items": { + "additionalProperties": false, + "properties": { + "corresponds_to_IC": { + "items": { + "description": "An impact category represents a specific environmental issue that the LCA aims to assess, such as global warming or ozone depletion. Each category aggregates the effects of different emissions that contribute to the same environmental problem. (LCAnet Ontology, SmartACV Project, Version 1.0, 2025) An impact category is classified as whether is corresponds to environmental impact, social impact, etc.", + "enum": [ + "http://dpp.cea.fr/EUDPP/LCA#Acidification", + "http://dpp.cea.fr/EUDPP/LCA#Climate_change_total", + "http://dpp.cea.fr/EUDPP/LCA#Ecotoxicity_freshwater", + "http://dpp.cea.fr/EUDPP/LCA#Eutrophication_freshwater", + "http://dpp.cea.fr/EUDPP/LCA#Eutrophication_marine", + "http://dpp.cea.fr/EUDPP/LCA#Eutrophication_terrestrial", + "http://dpp.cea.fr/EUDPP/LCA#Human_toxicity_cancer", + "http://dpp.cea.fr/EUDPP/LCA#Human_toxicity_non_cancer", + "http://dpp.cea.fr/EUDPP/LCA#Ionising_radiation_human_health", + "http://dpp.cea.fr/EUDPP/LCA#Land_use_occupation_and_transformation", + "http://dpp.cea.fr/EUDPP/LCA#Ozone_depletion", + "http://dpp.cea.fr/EUDPP/LCA#Particulate_matter", + "http://dpp.cea.fr/EUDPP/LCA#Photochemical_ozone_formation_human_health", + "http://dpp.cea.fr/EUDPP/LCA#Resource_use_fossils", + "http://dpp.cea.fr/EUDPP/LCA#Resource_use_minerals_and_metals", + "http://dpp.cea.fr/EUDPP/LCA#Water_use" + ], + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "corresponds_to": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "numericValue": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "commodityCode": { + "items": { + "type": "string" + }, + "type": "array" + }, + "productName": { + "items": { + "type": "string" + }, + "type": "array" + }, + "productImage": { + "items": { + "type": "string" + }, + "type": "array" + }, + "isSparePartOf": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "isEnergyRelated": { + "items": { + "type": "boolean" + }, + "type": "array" + }, + "hasProperty": { + "items": { + "additionalProperties": false, + "properties": { + "dictionaryReference": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "isComponentOf": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "linkToPreviousDPP": { + "items": { + "type": "string" + }, + "type": "array" + }, + "schemaVersion": { + "items": { + "type": "string" + }, + "type": "array" + }, + "hasIssuer": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "hasBackUpCopyHost": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + }, + "granularity": { + "items": { + "type": "string", + "format": "uri-reference" + }, + "type": "array" + } + }, + "type": "object", + "$id": "urn:cirpass2:eudpp/DigitalProductPassport" +} diff --git a/src/dppvalidator/vocabularies/data/schemas/cirpass_dpp_schema.xsd b/src/dppvalidator/vocabularies/data/schemas/cirpass_dpp_schema.xsd new file mode 100644 index 0000000..6a7b785 --- /dev/null +++ b/src/dppvalidator/vocabularies/data/schemas/cirpass_dpp_schema.xsd @@ -0,0 +1,589 @@ + + + + + + + + + + The status of the DPP instance as digital resource. + + + + + + + A unique identifier (URI) assigned to the product passport. + + + + + + + The date and timestamp that the DPP is valid from. + + + + + + + The date and timestamp that the DPP is valid until. + + + + + + + Unique facility identifier +means a unique string of characters for the identification of locations or buildings involved in a product’s value chain or used by actors involved in a product’s value chain. (ESPR Art 2 (33)) + +A unique string of characters for the identification of locations or buildings involved in a product’s value chain or used by actors involved in a product’s life cycle. + + + + + + + The date and time of the latest update to the DPP instance. + + + + + + + Linking DPP and the product. + + + + + + + + + + + Linking the product to the economic operator. + +hasEconomicOperator object property range is defined in the EUDPP Core ontology and suitable role classes (i.e. the Economic operator class) is defined in the ACTOR module. + + + + + + + + Unique operator identifier means a unique string of characters for the identification of an actor involved in a product’s value chain. (ESPR Art. 2 (31)) + + + + + + + Trade name under which a legal person or business operates and is registered. + + + + + + + Postal address associated with an actor. + + + + + + + A trademark officially registered and owned by an actor. + + + + + + + Relates an actor to a role. + + + + + + + + Relates role to an actor. + + + + + + + + + + Relates an actor to facility. + + + + + + + + Relates a facility to an actor. + + + + + + + Unique facility identifier means a unique string of characters for the identification of locations or buildings involved in a product’s value chain or used by actors involved in a product’s value chain. (ESPR Art 2 (33)) + + + + + + + + + + Electronic contact detail for an actor, such as an email address, website, or other digital communication channel. + + + + + + + Name of an actor. + + + + + + + + + + A product has a DPP . + + + + + + + Product group +means a set of products that serve similar purposes and are similar in terms of use, or have similar +functional properties, and are similar in terms of consumer perception (ESPR Art 2 (4)). The product +group or groups covered are given by delegated acts. (ESPR Art 8 (a)) + +This will be probably a classification code from some product classification scheme . + + + + + + + + Classification code value from the classification code set. + + + + + + + The product classification code set. + + + + + + + + + + Unique product identifier +means a unique string of characters for the identification of a product that also enables a web link to the digital product passport. (ESPR Art 2 (30)) + + + + + + + A product has a manufacturer. + +hasManufacturer object property range is defined in the EUDPP Core ontology and suitable role classes are defined in the ACTOR module. + + + + + + + + Unique operator identifier means a unique string of characters for the identification of an actor involved in a product’s value chain. (ESPR Art. 2 (31)) + + + + + + + Trade name under which a legal person or business operates and is registered. + + + + + + + A trademark officially registered and owned by an actor. + + + + + + + Postal address associated with an actor. + + + + + + + Relates an actor to a role. + + + + + + + + Relates role to an actor. + + + + + + + + + + Name of an actor. + + + + + + + Relates an actor to facility. + + + + + + + + Unique facility identifier means a unique string of characters for the identification of locations or buildings involved in a product’s value chain or used by actors involved in a product’s value chain. (ESPR Art 2 (33)) + + + + + + + Relates a facility to an actor. + + + + + + + + + + Electronic contact detail for an actor, such as an email address, website, or other digital communication channel. + + + + + + + + + + Product description + + + + + + + Global Trade Identification Number as provided for in standard ISO/IEC 15459-6 or equivalent of products or their parts. + + + + + + + Linking the product to a substance of concern the product contains. + +containsSubstanceOfConcern object property range is defined in the EUDPP Core ontology. + +Substance of concern is modelled by the SubstanceOfConcern class in the module SoC. + + + + + + + + EXAMPLES: + +Mercurous Oxide +EC Number: 239-934-0 +CAS Number: 15829-53-5 + European Community (EC) number, as indicated in the European Inventory of Existing Commercial Chemical Substances (EINECS), the European List of Notified Chemical Substances (ELINCS) or the No Longer Polymer (NLP) list or the number assigned by the European Chemicals Agency (ECHA), if available and appropriate [ESPR Article 7(5)] + + + + + + + The abbreviation used for the Substance of Concern; [ESPR Article 7(5)] + + + + + + + The trade name under which the Substance of Concern is known; [ESPR Article 7(5)] + + + + + + + The usual name under which the Substance of Concern is known; [ESPR Article 7(5)] + + + + + + + The Chemical Abstract Service (CAS) number, if available; [ESPR Article 7(5)] + + + + + + + The Chemical Abstract Service (CAS) name, if available; [ESPR Article 7(5)] + + + + + + + Describes the life cycle stage during which the Substance of Concern occurs for product (used during production, resulting being in product, in waste); ESPR Annex I (f) + + + + + + + + Defines the the concentration, maximum concentration or concentration range of the substances of concern, at the level of the product, its relevant components, or spare parts; ESPR Art 7(5) + + + + + + + Describes the impacts of Substance of Concern on human health; ESPR Annex I (f) + + + + + + + Name in the International Union of Pure and Applied Chemistry (IUPAC) nomenclature, or another international name when IUPAC name is not available [ESPR Article 7(5)] + + + + + + + Other names, including usual name, trade name, abbreviation; [ESPR Article 7(5)] + + + + + + + Location of the substance in the product; ESPR Art 7(5) + + + + + + + Describes relevant threshold of Substance of Concern. + +By default, information on all substances of concern present in a product, above the relevant thresholds, should be included in the DPP [ESPR FAQ 10.115, p59] + + + + + + + Describes the impacts of Substance of Concern on environment; ESPR Annex I (f) + + + + + + + + An impact category represents a specific environmental issue that the LCA aims to assess, such as global warming or ozone depletion. Each category aggregates the effects of different emissions that contribute to the same environmental problem. (LCAnet Ontology, SmartACV Project, Version 1.0, 2025) An impact category is classified as whether is corresponds to environmental impact, social impact, etc. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Relevant commodity codes, such as a TARIC code as defined in Council Regulation (EEC) No 2658/87. + + + + + + + Product name + + + + + + + An image of the product. + + + + + + + A product is a spare part of another product. + + + + + + + Energy related product +means any product that has an impact on energy consumption during use. (ESPR Art 2 (4)). + +The boolean data type property indicates is a product energy related or not? + + + + + + + Product has a property that could be a document or some measurable properties (measurands). + +Linking the product to the property. + + + + + + + + The reference to the unique identifier of the data point specification defined in the repository/data dictionary. + +It is formatted as a URI/URL . + + + + + + + + + + A product is a component of another product. + + + + + + + + + + Where a new digital product passport is created for a product that already has a digital product +passport, the new digital product passport shall be linked to the original digital product passport or +passports. (ESPR Art 11 (d)) + + + + + + + The reference standard the DPP instance schema refers to. + + + + + + + Linking DPP with its issuer. + +hasIssuer object property range is defined in the EUDPP Core ontology and suitable role classes are defined in the ACTOR module. + + + + + + + The reference of the digital product passport service provider hosting the back-up copy of the digital product passport (ESPR Annex III) + +hasBackUpCopyHost object property range is defined in the EUDPP Core ontology and suitable role classes are defined in the ACTOR module. + + + + + + + The product passport should be defined at the level of item, batch or product model, depending on specific needs and complexity of the value chain, the size, nature or impacts of the products considered. (Standardisation Request 5423 7) + +The level of granularity of the ProductID as per ESPR. + + + + + + + diff --git a/src/dppvalidator/vocabularies/data/schemas/cirpass_dpp_schema.yaml b/src/dppvalidator/vocabularies/data/schemas/cirpass_dpp_schema.yaml new file mode 100644 index 0000000..e81fc81 --- /dev/null +++ b/src/dppvalidator/vocabularies/data/schemas/cirpass_dpp_schema.yaml @@ -0,0 +1,347 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +title: "CIRPASS DPP reference structure v1.3.0" +description: "Generated by Semantic Treehouse on 2026-02-01T12:16:08+00:00" +additionalProperties: false +properties: + status: + items: + type: string + format: uri-reference + type: array + uniqueDPPID: + items: + type: string + type: array + validFrom: + items: + type: string + format: uri-reference + type: array + validUntil: + items: + type: string + format: uri-reference + type: array + facilityID: + items: + type: string + type: array + lastUpdate: + items: + type: string + format: uri-reference + type: array + appliesToProduct: + items: + additionalProperties: false + properties: + Has_compliance_information: + type: string + format: uri-reference + hasEconomicOperator: + items: + additionalProperties: false + properties: + UniqueoperatorID: + description: "Unique operator identifier means a unique string of characters for the identification of an actor involved in a product’s value chain. (ESPR Art. 2 (31))" + type: string + format: uri-reference + registeredTradeName: + items: + type: string + type: array + postalAddress: + items: + type: string + type: array + registeredTrademark: + items: + type: string + type: array + hasRole: + items: + additionalProperties: false + properties: + isRoleOf: + items: + type: string + format: uri-reference + type: array + type: object + type: array + usesFacility: + items: + additionalProperties: false + properties: + isUsedByActor: + items: + type: string + format: uri-reference + type: array + uniqueFacilityID: + items: + type: string + type: array + type: object + type: array + electronicContact: + items: + type: string + type: array + actorName: + items: + type: string + type: array + type: object + type: array + hasDPP: + items: + type: string + format: uri-reference + type: array + hasProductGroup: + items: + additionalProperties: false + properties: + codeValue: + items: + type: string + type: array + codeSet: + items: + type: string + type: array + type: object + type: array + uniqueProductID: + items: + type: string + format: uri-reference + type: array + hasManufacturer: + items: + additionalProperties: false + properties: + uniqueOperatorID: + description: "Unique operator identifier means a unique string of characters for the identification of an actor involved in a product’s value chain. (ESPR Art. 2 (31))" + type: string + format: uri-reference + registeredTradeName: + items: + type: string + type: array + registeredTrademark: + items: + type: string + type: array + postalAddress: + items: + type: string + type: array + hasRole: + items: + additionalProperties: false + properties: + isRoleOf: + items: + type: string + format: uri-reference + type: array + type: object + type: array + actorName: + items: + type: string + type: array + usesFacility: + items: + additionalProperties: false + properties: + uniqueFacilityID: + items: + type: string + type: array + isUsedByActor: + items: + type: string + format: uri-reference + type: array + type: object + type: array + electronicContact: + items: + type: string + type: array + type: object + type: array + description: + items: + type: string + type: array + GTIN: + items: + type: string + type: array + containsSubstanceOfConcern: + items: + additionalProperties: false + properties: + numberEC: + items: + type: string + type: array + abbreviation: + items: + type: string + type: array + tradeName: + items: + type: string + type: array + usualName: + items: + type: string + type: array + numberCAS: + items: + type: string + type: array + nameCAS: + items: + type: string + type: array + hasLifeCycleStage: + items: + type: string + format: uri-reference + type: array + hasConcentration: + items: + type: string + format: uri-reference + type: array + hasImpactOnHumanHealth: + items: + type: string + type: array + nameIUPAC: + items: + type: string + type: array + otherName: + items: + type: string + type: array + substanceLocation: + items: + type: string + type: array + hasThreshold: + items: + type: string + format: uri-reference + type: array + hasImpactOnEnvironment: + items: + additionalProperties: false + properties: + corresponds_to_IC: + items: + description: "An impact category represents a specific environmental issue that the LCA aims to assess, such as global warming or ozone depletion. Each category aggregates the effects of different emissions that contribute to the same environmental problem. (LCAnet Ontology, SmartACV Project, Version 1.0, 2025) An impact category is classified as whether is corresponds to environmental impact, social impact, etc." + enum: + - "http://dpp.cea.fr/EUDPP/LCA#Acidification" + - "http://dpp.cea.fr/EUDPP/LCA#Climate_change_total" + - "http://dpp.cea.fr/EUDPP/LCA#Ecotoxicity_freshwater" + - "http://dpp.cea.fr/EUDPP/LCA#Eutrophication_freshwater" + - "http://dpp.cea.fr/EUDPP/LCA#Eutrophication_marine" + - "http://dpp.cea.fr/EUDPP/LCA#Eutrophication_terrestrial" + - "http://dpp.cea.fr/EUDPP/LCA#Human_toxicity_cancer" + - "http://dpp.cea.fr/EUDPP/LCA#Human_toxicity_non_cancer" + - "http://dpp.cea.fr/EUDPP/LCA#Ionising_radiation_human_health" + - "http://dpp.cea.fr/EUDPP/LCA#Land_use_occupation_and_transformation" + - "http://dpp.cea.fr/EUDPP/LCA#Ozone_depletion" + - "http://dpp.cea.fr/EUDPP/LCA#Particulate_matter" + - "http://dpp.cea.fr/EUDPP/LCA#Photochemical_ozone_formation_human_health" + - "http://dpp.cea.fr/EUDPP/LCA#Resource_use_fossils" + - "http://dpp.cea.fr/EUDPP/LCA#Resource_use_minerals_and_metals" + - "http://dpp.cea.fr/EUDPP/LCA#Water_use" + type: string + format: uri-reference + type: array + corresponds_to: + items: + type: string + format: uri-reference + type: array + numericValue: + items: + type: string + format: uri-reference + type: array + type: object + type: array + type: object + type: array + commodityCode: + items: + type: string + type: array + productName: + items: + type: string + type: array + productImage: + items: + type: string + type: array + isSparePartOf: + items: + type: string + format: uri-reference + type: array + isEnergyRelated: + items: + type: boolean + type: array + hasProperty: + items: + additionalProperties: false + properties: + dictionaryReference: + items: + type: string + type: array + type: object + type: array + isComponentOf: + items: + type: string + format: uri-reference + type: array + type: object + type: array + linkToPreviousDPP: + items: + type: string + type: array + schemaVersion: + items: + type: string + type: array + hasIssuer: + items: + type: string + format: uri-reference + type: array + hasBackUpCopyHost: + items: + type: string + format: uri-reference + type: array + granularity: + items: + type: string + format: uri-reference + type: array +type: object +$id: "urn:cirpass2:eudpp/DigitalProductPassport" diff --git a/src/dppvalidator/vocabularies/data/schemas/cirpass_dpp_shacl.ttl b/src/dppvalidator/vocabularies/data/schemas/cirpass_dpp_shacl.ttl new file mode 100644 index 0000000..f4158ed --- /dev/null +++ b/src/dppvalidator/vocabularies/data/schemas/cirpass_dpp_shacl.ttl @@ -0,0 +1,614 @@ +@prefix rdf: . +@prefix sh: . +@prefix rdfs: . +@prefix xsd: . +@prefix owl: . + +rdf:nil a rdf:List . +[] + a sh:NodeShape ; + sh:targetClass ; + sh:property [ + a sh:PropertyShape ; + sh:name "status" ; + sh:order 0 ; + rdfs:comment "The status of the DPP instance as digital resource." ; + sh:path ; + sh:class _:genid58 + ], [ + a sh:PropertyShape ; + sh:name "uniqueDPPID" ; + sh:order 0 ; + rdfs:comment "A unique identifier (URI) assigned to the product passport." ; + sh:path ; + sh:datatype xsd:anyURI + ], [ + a sh:PropertyShape ; + sh:name "validFrom" ; + sh:order 0 ; + rdfs:comment "The date and timestamp that the DPP is valid from." ; + sh:path ; + sh:datatype xsd:dateTimeStamp + ], [ + a sh:PropertyShape ; + sh:name "validUntil" ; + sh:order 0 ; + rdfs:comment "The date and timestamp that the DPP is valid until." ; + sh:path ; + sh:datatype xsd:dateTimeStamp + ], [ + a sh:PropertyShape ; + sh:name "facilityID" ; + sh:order 0 ; + rdfs:comment """Unique facility identifier +means a unique string of characters for the identification of locations or buildings involved in a product’s value chain or used by actors involved in a product’s value chain. (ESPR Art 2 (33)) + +A unique string of characters for the identification of locations or buildings involved in a product’s value chain or used by actors involved in a product’s life cycle.""" ; + sh:path ; + sh:datatype xsd:string + ], _:genid7, [ + a sh:PropertyShape ; + sh:name "appliesToProduct" ; + sh:order 0 ; + rdfs:comment "Linking DPP and the product." ; + sh:path ; + sh:class ; + sh:node [ + a sh:NodeShape ; + sh:property [ + a sh:PropertyShape ; + sh:name "Has_compliance_information" ; + sh:path [ a sh:PredicatePath ] ; + sh:class [ ] ; + sh:maxCount 1 + ], [ + a sh:PropertyShape ; + sh:name "hasEconomicOperator" ; + sh:order 0 ; + rdfs:comment """Linking the product to the economic operator. + +hasEconomicOperator object property range is defined in the EUDPP Core ontology and suitable role classes (i.e. the Economic operator class) is defined in the ACTOR module.""" ; + sh:path ; + sh:class ; + sh:node [ + a sh:NodeShape ; + sh:property [ + a sh:PropertyShape ; + sh:name "UniqueoperatorID" ; + sh:description "Unique operator identifier means a unique string of characters for the identification of an actor involved in a product’s value chain. (ESPR Art. 2 (31))" ; + sh:path [ a sh:PredicatePath ] ; + sh:class ; + sh:maxCount 1 + ], [ + a sh:PropertyShape ; + sh:name "registeredTradeName" ; + sh:order 0 ; + rdfs:comment "Trade name under which a legal person or business operates and is registered." ; + sh:path ; + sh:datatype xsd:string + ], [ + a sh:PropertyShape ; + sh:name "postalAddress" ; + sh:order 0 ; + rdfs:comment "Postal address associated with an actor." ; + sh:path ; + sh:datatype xsd:string + ], [ + a sh:PropertyShape ; + sh:name "registeredTrademark" ; + sh:order 0 ; + rdfs:comment "A trademark officially registered and owned by an actor." ; + sh:path ; + sh:datatype xsd:string + ], [ + a sh:PropertyShape ; + sh:name "hasRole" ; + sh:order 0 ; + rdfs:comment "Relates an actor to a role." ; + sh:path ; + sh:class ; + sh:node [ + a sh:NodeShape ; + sh:property [ + a sh:PropertyShape ; + sh:name "isRoleOf" ; + sh:order 0 ; + rdfs:comment "Relates role to an actor." ; + sh:path ; + sh:class + ] ; + sh:closed true + ] + ], [ + a sh:PropertyShape ; + sh:name "usesFacility" ; + sh:order 0 ; + rdfs:comment "Relates an actor to facility." ; + sh:path ; + sh:class ; + sh:node [ + a sh:NodeShape ; + sh:property [ + a sh:PropertyShape ; + sh:name "isUsedByActor" ; + sh:order 0 ; + rdfs:comment "Relates a facility to an actor." ; + sh:path ; + sh:class + ], [ + a sh:PropertyShape ; + sh:name "uniqueFacilityID" ; + sh:order 0 ; + rdfs:comment "Unique facility identifier means a unique string of characters for the identification of locations or buildings involved in a product’s value chain or used by actors involved in a product’s value chain. (ESPR Art 2 (33))" ; + sh:path ; + sh:datatype xsd:string + ] ; + sh:closed true + ] + ], [ + a sh:PropertyShape ; + sh:name "electronicContact" ; + sh:order 0 ; + rdfs:comment "Electronic contact detail for an actor, such as an email address, website, or other digital communication channel." ; + sh:path ; + sh:datatype xsd:string + ], [ + a sh:PropertyShape ; + sh:name "actorName" ; + sh:order 0 ; + rdfs:comment "Name of an actor." ; + sh:path ; + sh:datatype xsd:string + ] ; + sh:closed true + ] + ], [ + a sh:PropertyShape ; + sh:name "hasDPP" ; + sh:order 0 ; + rdfs:comment "A product has a DPP ." ; + sh:path ; + sh:class + ], [ + a sh:PropertyShape ; + sh:name "hasProductGroup" ; + sh:order 0 ; + rdfs:comment """Product group +means a set of products that serve similar purposes and are similar in terms of use, or have similar +functional properties, and are similar in terms of consumer perception (ESPR Art 2 (4)). The product +group or groups covered are given by delegated acts. (ESPR Art 8 (a)) + +This will be probably a classification code from some product classification scheme .""" ; + sh:path ; + sh:class ; + sh:node [ + a sh:NodeShape ; + sh:property [ + a sh:PropertyShape ; + sh:name "codeValue" ; + sh:order 0 ; + rdfs:comment "Classification code value from the classification code set." ; + sh:path ; + sh:datatype xsd:string + ], [ + a sh:PropertyShape ; + sh:name "codeSet" ; + sh:order 0 ; + rdfs:comment "The product classification code set." ; + sh:path ; + sh:datatype xsd:anyURI + ] ; + sh:closed true + ] + ], [ + a sh:PropertyShape ; + sh:name "uniqueProductID" ; + sh:order 0 ; + rdfs:comment """Unique product identifier +means a unique string of characters for the identification of a product that also enables a web link to the digital product passport. (ESPR Art 2 (30))""" ; + sh:path ; + sh:class _:genid61 + ], [ + a sh:PropertyShape ; + sh:name "hasManufacturer" ; + sh:order 0 ; + rdfs:comment """A product has a manufacturer. + +hasManufacturer object property range is defined in the EUDPP Core ontology and suitable role classes are defined in the ACTOR module.""" ; + sh:path ; + sh:class ; + sh:node [ + a sh:NodeShape ; + sh:property [ + a sh:PropertyShape ; + sh:name "uniqueOperatorID" ; + sh:description "Unique operator identifier means a unique string of characters for the identification of an actor involved in a product’s value chain. (ESPR Art. 2 (31))" ; + sh:path [ a sh:PredicatePath ] ; + sh:class ; + sh:maxCount 1 + ], [ + a sh:PropertyShape ; + sh:name "registeredTradeName" ; + sh:order 0 ; + rdfs:comment "Trade name under which a legal person or business operates and is registered." ; + sh:path ; + sh:datatype xsd:string + ], [ + a sh:PropertyShape ; + sh:name "registeredTrademark" ; + sh:order 0 ; + rdfs:comment "A trademark officially registered and owned by an actor." ; + sh:path ; + sh:datatype xsd:string + ], [ + a sh:PropertyShape ; + sh:name "postalAddress" ; + sh:order 0 ; + rdfs:comment "Postal address associated with an actor." ; + sh:path ; + sh:datatype xsd:string + ], [ + a sh:PropertyShape ; + sh:name "hasRole" ; + sh:order 0 ; + rdfs:comment "Relates an actor to a role." ; + sh:path ; + sh:class ; + sh:node [ + a sh:NodeShape ; + sh:property [ + a sh:PropertyShape ; + sh:name "isRoleOf" ; + sh:order 0 ; + rdfs:comment "Relates role to an actor." ; + sh:path ; + sh:class + ] ; + sh:closed true + ] + ], [ + a sh:PropertyShape ; + sh:name "actorName" ; + sh:order 0 ; + rdfs:comment "Name of an actor." ; + sh:path ; + sh:datatype xsd:string + ], [ + a sh:PropertyShape ; + sh:name "usesFacility" ; + sh:order 0 ; + rdfs:comment "Relates an actor to facility." ; + sh:path ; + sh:class ; + sh:node [ + a sh:NodeShape ; + sh:property [ + a sh:PropertyShape ; + sh:name "uniqueFacilityID" ; + sh:order 0 ; + rdfs:comment "Unique facility identifier means a unique string of characters for the identification of locations or buildings involved in a product’s value chain or used by actors involved in a product’s value chain. (ESPR Art 2 (33))" ; + sh:path ; + sh:datatype xsd:string + ], [ + a sh:PropertyShape ; + sh:name "isUsedByActor" ; + sh:order 0 ; + rdfs:comment "Relates a facility to an actor." ; + sh:path ; + sh:class + ] ; + sh:closed true + ] + ], [ + a sh:PropertyShape ; + sh:name "electronicContact" ; + sh:order 0 ; + rdfs:comment "Electronic contact detail for an actor, such as an email address, website, or other digital communication channel." ; + sh:path ; + sh:datatype xsd:string + ] ; + sh:closed true + ] + ], [ + a sh:PropertyShape ; + sh:name "description" ; + sh:order 0 ; + rdfs:comment "Product description" ; + sh:path ; + sh:datatype xsd:string + ], [ + a sh:PropertyShape ; + sh:name "GTIN" ; + sh:order 0 ; + rdfs:comment "Global Trade Identification Number as provided for in standard ISO/IEC 15459-6 or equivalent of products or their parts." ; + sh:path ; + sh:datatype xsd:string + ], [ + a sh:PropertyShape ; + sh:name "containsSubstanceOfConcern" ; + sh:order 0 ; + rdfs:comment """Linking the product to a substance of concern the product contains. + +containsSubstanceOfConcern object property range is defined in the EUDPP Core ontology. + +Substance of concern is modelled by the SubstanceOfConcern class in the module SoC.""" ; + sh:path ; + sh:class ; + sh:node [ + a sh:NodeShape ; + sh:property [ + a sh:PropertyShape ; + sh:name "numberEC" ; + sh:order 0 ; + rdfs:comment """EXAMPLES: + +Mercurous Oxide +EC Number: 239-934-0 +CAS Number: 15829-53-5""", "European Community (EC) number, as indicated in the European Inventory of Existing Commercial Chemical Substances (EINECS), the European List of Notified Chemical Substances (ELINCS) or the No Longer Polymer (NLP) list or the number assigned by the European Chemicals Agency (ECHA), if available and appropriate [ESPR Article 7(5)]" ; + sh:path ; + sh:datatype xsd:string + ], [ + a sh:PropertyShape ; + sh:name "abbreviation" ; + sh:order 0 ; + rdfs:comment "The abbreviation used for the Substance of Concern; [ESPR Article 7(5)]" ; + sh:path ; + sh:datatype xsd:string + ], _:genid54, [ + a sh:PropertyShape ; + sh:name "usualName" ; + sh:order 0 ; + rdfs:comment "The usual name under which the Substance of Concern is known; [ESPR Article 7(5)]" ; + sh:path ; + sh:datatype xsd:string + ], [ + a sh:PropertyShape ; + sh:name "numberCAS" ; + sh:order 0 ; + rdfs:comment "The Chemical Abstract Service (CAS) number, if available; [ESPR Article 7(5)]" ; + sh:path ; + sh:datatype xsd:string + ], [ + a sh:PropertyShape ; + sh:name "nameCAS" ; + sh:order 0 ; + rdfs:comment "The Chemical Abstract Service (CAS) name, if available; [ESPR Article 7(5)]" ; + sh:path ; + sh:datatype xsd:string + ], _:genid58, [ + a sh:PropertyShape ; + sh:name "hasConcentration" ; + sh:order 0 ; + rdfs:comment "Defines the the concentration, maximum concentration or concentration range of the substances of concern, at the level of the product, its relevant components, or spare parts; ESPR Art 7(5)" ; + sh:path ; + sh:class + ], _:genid61, [ + a sh:PropertyShape ; + sh:name "nameIUPAC" ; + sh:order 0 ; + rdfs:comment "Name in the International Union of Pure and Applied Chemistry (IUPAC) nomenclature, or another international name when IUPAC name is not available [ESPR Article 7(5)]" ; + sh:path ; + sh:datatype xsd:string + ], [ + a sh:PropertyShape ; + sh:name "otherName" ; + sh:order 0 ; + rdfs:comment "Other names, including usual name, trade name, abbreviation; [ESPR Article 7(5)]" ; + sh:path ; + sh:datatype xsd:string + ], [ + a sh:PropertyShape ; + sh:name "substanceLocation" ; + sh:order 0 ; + rdfs:comment "Location of the substance in the product; ESPR Art 7(5)" ; + sh:path ; + sh:datatype xsd:string + ], [ + a sh:PropertyShape ; + sh:name "hasThreshold" ; + sh:order 0 ; + rdfs:comment """Describes relevant threshold of Substance of Concern. + +By default, information on all substances of concern present in a product, above the relevant thresholds, should be included in the DPP [ESPR FAQ 10.115, p59]""" ; + sh:path ; + sh:class + ], [ + a sh:PropertyShape ; + sh:name "hasImpactOnEnvironment" ; + sh:order 0 ; + rdfs:comment "Describes the impacts of Substance of Concern on environment; ESPR Annex I (f)" ; + sh:path ; + sh:class ; + sh:node [ + a sh:NodeShape ; + sh:property [ + a sh:PropertyShape ; + sh:name "corresponds_to_IC" ; + sh:description "An impact category represents a specific environmental issue that the LCA aims to assess, such as global warming or ozone depletion. Each category aggregates the effects of different emissions that contribute to the same environmental problem. (LCAnet Ontology, SmartACV Project, Version 1.0, 2025) An impact category is classified as whether is corresponds to environmental impact, social impact, etc." ; + sh:order 0 ; + sh:path ; + sh:class ; + sh:in ( + + + + + + + + + + + + + + + + + ) + ], [ + a sh:PropertyShape ; + sh:name "corresponds_to" ; + sh:order 0 ; + sh:path ; + sh:class owl:Thing + ], [ + a sh:PropertyShape ; + sh:name "numericValue" ; + sh:order 0 ; + sh:path ; + sh:class _:genid7 + ] ; + sh:closed true + ] + ] ; + sh:closed true + ] + ], [ + a sh:PropertyShape ; + sh:name "commodityCode" ; + sh:order 0 ; + rdfs:comment "Relevant commodity codes, such as a TARIC code as defined in Council Regulation (EEC) No 2658/87." ; + sh:path ; + sh:datatype xsd:string + ], [ + a sh:PropertyShape ; + sh:name "productName" ; + sh:order 0 ; + rdfs:comment "Product name" ; + sh:path ; + sh:datatype xsd:string + ], [ + a sh:PropertyShape ; + sh:name "productImage" ; + sh:order 0 ; + rdfs:comment "An image of the product." ; + sh:path ; + sh:datatype xsd:anyURI + ], [ + a sh:PropertyShape ; + sh:name "isSparePartOf" ; + sh:order 0 ; + rdfs:comment "A product is a spare part of another product." ; + sh:path ; + sh:class + ], [ + a sh:PropertyShape ; + sh:name "isEnergyRelated" ; + sh:order 0 ; + rdfs:comment """Energy related product +means any product that has an impact on energy consumption during use. (ESPR Art 2 (4)). + +The boolean data type property indicates is a product energy related or not?""" ; + sh:path ; + sh:datatype xsd:boolean + ], [ + a sh:PropertyShape ; + sh:name "hasProperty" ; + sh:order 0 ; + rdfs:comment """Product has a property that could be a document or some measurable properties (measurands). + +Linking the product to the property.""" ; + sh:path ; + sh:class ; + sh:node [ + a sh:NodeShape ; + sh:property [ + a sh:PropertyShape ; + sh:name "dictionaryReference" ; + sh:order 0 ; + rdfs:comment """The reference to the unique identifier of the data point specification defined in the repository/data dictionary. + +It is formatted as a URI/URL .""" ; + sh:path ; + sh:datatype xsd:string + ] ; + sh:closed true + ] + ], [ + a sh:PropertyShape ; + sh:name "isComponentOf" ; + sh:order 0 ; + rdfs:comment "A product is a component of another product." ; + sh:path ; + sh:class + ] ; + sh:closed true + ] + ], [ + a sh:PropertyShape ; + sh:name "linkToPreviousDPP" ; + sh:order 0 ; + rdfs:comment """Where a new digital product passport is created for a product that already has a digital product +passport, the new digital product passport shall be linked to the original digital product passport or +passports. (ESPR Art 11 (d))""" ; + sh:path ; + sh:datatype xsd:anyURI + ], [ + a sh:PropertyShape ; + sh:name "schemaVersion" ; + sh:order 0 ; + rdfs:comment "The reference standard the DPP instance schema refers to." ; + sh:path ; + sh:datatype xsd:string + ], [ + a sh:PropertyShape ; + sh:name "hasIssuer" ; + sh:order 0 ; + rdfs:comment """Linking DPP with its issuer. + +hasIssuer object property range is defined in the EUDPP Core ontology and suitable role classes are defined in the ACTOR module.""" ; + sh:path ; + sh:class + ], [ + a sh:PropertyShape ; + sh:name "hasBackUpCopyHost" ; + sh:order 0 ; + rdfs:comment """The reference of the digital product passport service provider hosting the back-up copy of the digital product passport (ESPR Annex III) + +hasBackUpCopyHost object property range is defined in the EUDPP Core ontology and suitable role classes are defined in the ACTOR module.""" ; + sh:path ; + sh:class + ], [ + a sh:PropertyShape ; + sh:name "granularity" ; + sh:order 0 ; + rdfs:comment """The product passport should be defined at the level of item, batch or product model, depending on specific needs and complexity of the value chain, the size, nature or impacts of the products considered. (Standardisation Request 5423 7) + +The level of granularity of the ProductID as per ESPR.""" ; + sh:path ; + sh:class _:genid54 + ] ; + sh:closed true . + +_:genid58 + a sh:PropertyShape ; + sh:name "hasLifeCycleStage" ; + sh:order 0 ; + rdfs:comment "Describes the life cycle stage during which the Substance of Concern occurs for product (used during production, resulting being in product, in waste); ESPR Annex I (f)" ; + sh:path ; + sh:class [ ] . + +_:genid7 + a sh:PropertyShape ; + sh:name "lastUpdate" ; + sh:order 0 ; + rdfs:comment "The date and time of the latest update to the DPP instance." ; + sh:path ; + sh:datatype xsd:dateTimeStamp . + +_:genid61 + a sh:PropertyShape ; + sh:name "hasImpactOnHumanHealth" ; + sh:order 0 ; + rdfs:comment "Describes the impacts of Substance of Concern on human health; ESPR Annex I (f)" ; + sh:path ; + sh:datatype xsd:string . + +_:genid54 + a sh:PropertyShape ; + sh:name "tradeName" ; + sh:order 0 ; + rdfs:comment "The trade name under which the Substance of Concern is known; [ESPR Article 7(5)]" ; + sh:path ; + sh:datatype xsd:string . diff --git a/src/dppvalidator/vocabularies/data/untp-context-0.6.1.jsonld b/src/dppvalidator/vocabularies/data/untp-context-0.6.1.jsonld new file mode 100644 index 0000000..fd1aa93 --- /dev/null +++ b/src/dppvalidator/vocabularies/data/untp-context-0.6.1.jsonld @@ -0,0 +1,1164 @@ +{ + "@context": { + "untp-dpp": "https://test.uncefact.org/vocabulary/untp/dpp/0/", + "schemaorg": "https://schema.org/", + "untp-core": "https://test.uncefact.org/vocabulary/untp/core/0/", + "geojson": "https://purl.org/geojson/vocab#", + "renderMethodPrefix": "https://w3id.org/vc/render-method#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "@protected": true, + "@version": 1.1, + "DigitalProductPassport": { + "@protected": true, + "@id": "untp-dpp:DigitalProductPassport" + }, + "IdentifierScheme": { + "@protected": true, + "@id": "untp-core:IdentifierScheme", + "@context": { + "@protected": true, + "name": { + "@id": "schemaorg:name" + } + } + }, + "Classification": { + "@protected": true, + "@id": "untp-core:Classification", + "@context": { + "@protected": true, + "code": { + "@id": "untp-core:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schemaorg:name" + }, + "schemeID": { + "@id": "untp-core:schemeID", + "@type": "xsd:string" + }, + "schemeName": { + "@id": "untp-core:schemeName", + "@type": "xsd:string" + } + } + }, + "Party": { + "@protected": true, + "@id": "untp-core:Party", + "@context": { + "@protected": true, + "name": { + "@id": "schemaorg:name" + }, + "registeredId": { + "@id": "untp-core:registeredId", + "@type": "xsd:string" + }, + "idScheme": { + "@id": "untp-core:idScheme", + "@type": "@id" + }, + "description": { + "@id": "schemaorg:description" + }, + "registrationCountry": { + "@id": "untp-core:registrationCountry", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "organisationWebsite": { + "@id": "untp-core:organisationWebsite", + "@type": "xsd:string" + }, + "industryCategory": { + "@id": "untp-core:industryCategory", + "@type": "@id" + }, + "partyAlsoKnownAs": { + "@id": "untp-core:Party", + "@type": "@id", + "@context": { + "@protected": true, + "name": { + "@id": "schemaorg:name" + }, + "registeredId": { + "@id": "untp-core:registeredId", + "@type": "xsd:string" + }, + "idScheme": { + "@id": "untp-core:idScheme", + "@type": "@id" + } + } + } + } + }, + "CredentialIssuer": { + "@protected": true, + "@id": "untp-core:CredentialIssuer", + "@context": { + "@protected": true, + "name": { + "@id": "schemaorg:name" + }, + "issuerAlsoKnownAs": { + "@id": "untp-core:Party", + "@type": "@id", + "@context": { + "@protected": true, + "name": { + "@id": "schemaorg:name" + }, + "registeredId": { + "@id": "untp-core:registeredId", + "@type": "xsd:string" + }, + "idScheme": { + "@id": "untp-core:idScheme", + "@type": "@id" + } + } + } + } + }, + "Facility": { + "@protected": true, + "@id": "untp-core:Facility", + "@context": { + "@protected": true, + "name": { + "@id": "schemaorg:name" + }, + "registeredId": { + "@id": "untp-core:registeredId", + "@type": "xsd:string" + }, + "idScheme": { + "@id": "untp-core:idScheme", + "@type": "@id" + }, + "description": { + "@id": "schemaorg:description" + }, + "countryOfOperation": { + "@id": "untp-core:countryOfOperation", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "processCategory": { + "@id": "untp-core:processCategory", + "@type": "@id" + }, + "operatedByParty": { + "@id": "untp-core:Party", + "@type": "@id", + "@context": { + "@protected": true, + "name": { + "@id": "schemaorg:name" + }, + "registeredId": { + "@id": "untp-core:registeredId", + "@type": "xsd:string" + }, + "idScheme": { + "@id": "untp-core:idScheme", + "@type": "@id" + } + } + }, + "facilityAlsoKnownAs": { + "@id": "untp-core:Facility", + "@type": "@id", + "@context": { + "@protected": true, + "name": { + "@id": "schemaorg:name" + }, + "registeredId": { + "@id": "untp-core:registeredId", + "@type": "xsd:string" + }, + "idScheme": { + "@id": "untp-core:idScheme", + "@type": "@id" + } + } + }, + "locationInformation": { + "@protected": true, + "@id": "untp-core:locationInformation", + "@context": { + "@protected": true, + "plusCode": { + "@id": "untp-core:plusCode", + "@type": "xsd:string" + }, + "geoLocation": { + "@protected": true, + "@id": "geojson:geoLocation", + "@context": { + "@protected": true, + "type": { + "@id": "geojson:type", + "@type": "xsd:string" + }, + "coordinates": { + "@protected": true, + "@id": "geojson:coordinates", + "@context": { + "@protected": true, + "data": { + "@id": "geojson:data", + "@type": "xsd:double" + } + } + } + } + }, + "geoBoundary": { + "@protected": true, + "@id": "geojson:geoBoundary", + "@context": { + "@protected": true, + "type": { + "@id": "geojson:type", + "@type": "xsd:string" + }, + "coordinates": { + "@protected": true, + "@id": "geojson:coordinates", + "@context": { + "@protected": true, + "data": { + "@protected": true, + "@id": "geojson:data", + "@context": { + "@protected": true, + "data": { + "@id": "geojson:data", + "@type": "xsd:double" + } + } + } + } + } + } + } + } + }, + "address": { + "@protected": true, + "@id": "untp-core:address", + "@context": { + "@protected": true, + "streetAddress": { + "@id": "untp-core:streetAddress", + "@type": "xsd:string" + }, + "postalCode": { + "@id": "untp-core:postalCode", + "@type": "xsd:string" + }, + "addressLocality": { + "@id": "untp-core:addressLocality", + "@type": "xsd:string" + }, + "addressRegion": { + "@id": "untp-core:addressRegion", + "@type": "xsd:string" + }, + "addressCountry": { + "@id": "untp-core:addressCountry", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + } + } + } + } + }, + "Product": { + "@protected": true, + "@id": "untp-core:Product", + "@context": { + "@protected": true, + "name": { + "@id": "schemaorg:name" + }, + "registeredId": { + "@id": "untp-core:registeredId", + "@type": "xsd:string" + }, + "idScheme": { + "@id": "untp-core:idScheme", + "@type": "@id" + }, + "batchNumber": { + "@id": "untp-core:batchNumber", + "@type": "xsd:string" + }, + "productImage": { + "@protected": true, + "@id": "untp-core:productImage", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp-core:linkURL", + "@type": "xsd:string" + }, + "linkName": { + "@id": "untp-core:linkName", + "@type": "xsd:string" + }, + "linkType": { + "@id": "untp-core:linkType", + "@type": "xsd:string" + } + } + }, + "description": { + "@id": "schemaorg:description" + }, + "productCategory": { + "@id": "untp-core:productCategory", + "@type": "@id" + }, + "furtherInformation": { + "@protected": true, + "@id": "untp-core:furtherInformation", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp-core:linkURL", + "@type": "xsd:string" + }, + "linkName": { + "@id": "untp-core:linkName", + "@type": "xsd:string" + }, + "linkType": { + "@id": "untp-core:linkType", + "@type": "xsd:string" + } + } + }, + "producedByParty": { + "@id": "untp-core:Party", + "@type": "@id", + "@context": { + "@protected": true, + "name": { + "@id": "schemaorg:name" + }, + "registeredId": { + "@id": "untp-core:registeredId", + "@type": "xsd:string" + }, + "idScheme": { + "@id": "untp-core:idScheme", + "@type": "@id" + } + } + }, + "producedAtFacility": { + "@id": "untp-core:Facility", + "@type": "@id", + "@context": { + "@protected": true, + "name": { + "@id": "schemaorg:name" + }, + "registeredId": { + "@id": "untp-core:registeredId", + "@type": "xsd:string" + }, + "idScheme": { + "@id": "untp-core:idScheme", + "@type": "@id" + } + } + }, + "productionDate": { + "@id": "untp-core:productionDate", + "@type": "xsd:string" + }, + "countryOfProduction": { + "@id": "untp-core:countryOfProduction", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "serialNumber": { + "@id": "untp-core:serialNumber", + "@type": "xsd:string" + }, + "dimensions": { + "@protected": true, + "@id": "untp-core:dimensions", + "@context": { + "@protected": true, + "weight": { + "@protected": true, + "@id": "untp-core:weight", + "@context": { + "@protected": true, + "value": { + "@id": "untp-core:value", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp-core:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "length": { + "@protected": true, + "@id": "untp-core:length", + "@context": { + "@protected": true, + "value": { + "@id": "untp-core:value", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp-core:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "width": { + "@protected": true, + "@id": "untp-core:width", + "@context": { + "@protected": true, + "value": { + "@id": "untp-core:value", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp-core:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "height": { + "@protected": true, + "@id": "untp-core:height", + "@context": { + "@protected": true, + "value": { + "@id": "untp-core:value", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp-core:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "volume": { + "@protected": true, + "@id": "untp-core:volume", + "@context": { + "@protected": true, + "value": { + "@id": "untp-core:value", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp-core:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + } + } + } + } + }, + "Standard": { + "@protected": true, + "@id": "untp-core:Standard", + "@context": { + "@protected": true, + "name": { + "@id": "schemaorg:name" + }, + "issuingParty": { + "@id": "untp-core:Party", + "@type": "@id", + "@context": { + "@protected": true, + "name": { + "@id": "schemaorg:name" + }, + "registeredId": { + "@id": "untp-core:registeredId", + "@type": "xsd:string" + }, + "idScheme": { + "@id": "untp-core:idScheme", + "@type": "@id" + } + } + }, + "issueDate": { + "@id": "untp-core:issueDate", + "@type": "xsd:string" + } + } + }, + "Regulation": { + "@protected": true, + "@id": "untp-core:Regulation", + "@context": { + "@protected": true, + "name": { + "@id": "schemaorg:name" + }, + "jurisdictionCountry": { + "@id": "untp-core:jurisdictionCountry", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "administeredBy": { + "@id": "untp-core:Party", + "@type": "@id", + "@context": { + "@protected": true, + "name": { + "@id": "schemaorg:name" + }, + "registeredId": { + "@id": "untp-core:registeredId", + "@type": "xsd:string" + }, + "idScheme": { + "@id": "untp-core:idScheme", + "@type": "@id" + } + } + }, + "effectiveDate": { + "@id": "untp-core:effectiveDate", + "@type": "xsd:string" + } + } + }, + "Criterion": { + "@protected": true, + "@id": "untp-core:Criterion", + "@context": { + "@protected": true, + "name": { + "@id": "schemaorg:name" + }, + "description": { + "@id": "schemaorg:description" + }, + "conformityTopic": { + "@id": "untp-core:conformityTopic", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://test.uncefact.org/vocabulary/untp/core/0/conformityTopicCode#" + } + }, + "status": { + "@id": "untp-core:status", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://test.uncefact.org/vocabulary/untp/core/0/statusCode#" + } + }, + "subCriterion": { + "@id": "untp-core:subCriterion", + "@type": "@id" + }, + "thresholdValue": { + "@protected": true, + "@id": "untp-core:thresholdValue", + "@context": { + "@protected": true, + "metricName": { + "@id": "untp-core:metricName", + "@type": "xsd:string" + }, + "metricValue": { + "@protected": true, + "@id": "untp-core:metricValue", + "@context": { + "@protected": true, + "value": { + "@id": "untp-core:value", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp-core:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "score": { + "@id": "untp-core:score", + "@type": "xsd:string" + }, + "accuracy": { + "@id": "untp-core:accuracy", + "@type": "xsd:double" + } + } + }, + "performanceLevel": { + "@id": "untp-core:performanceLevel", + "@type": "xsd:string" + }, + "category": { + "@id": "untp-core:category", + "@type": "@id" + }, + "tag": { + "@id": "untp-core:tag", + "@type": "xsd:string" + } + } + }, + "Claim": { + "@protected": true, + "@id": "untp-core:Claim", + "@context": { + "@protected": true, + "description": { + "@id": "schemaorg:description" + }, + "referenceStandard": { + "@id": "untp-core:referenceStandard", + "@type": "@id" + }, + "referenceRegulation": { + "@id": "untp-core:referenceRegulation", + "@type": "@id" + }, + "assessmentCriteria": { + "@id": "untp-core:assessmentCriteria", + "@type": "@id" + }, + "assessmentDate": { + "@id": "untp-core:assessmentDate", + "@type": "xsd:string" + }, + "declaredValue": { + "@protected": true, + "@id": "untp-core:declaredValue", + "@context": { + "@protected": true, + "metricName": { + "@id": "untp-core:metricName", + "@type": "xsd:string" + }, + "metricValue": { + "@protected": true, + "@id": "untp-core:metricValue", + "@context": { + "@protected": true, + "value": { + "@id": "untp-core:value", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp-core:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "score": { + "@id": "untp-core:score", + "@type": "xsd:string" + }, + "accuracy": { + "@id": "untp-core:accuracy", + "@type": "xsd:double" + } + } + }, + "conformance": { + "@id": "untp-core:conformance", + "@type": "xsd:boolean" + }, + "conformityTopic": { + "@id": "untp-core:conformityTopic", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://test.uncefact.org/vocabulary/untp/core/0/conformityTopicCode#" + } + }, + "conformityEvidence": { + "@protected": true, + "@id": "untp-core:conformityEvidence", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp-core:linkURL", + "@type": "xsd:string" + }, + "linkName": { + "@id": "untp-core:linkName", + "@type": "xsd:string" + }, + "linkType": { + "@id": "untp-core:linkType", + "@type": "xsd:string" + }, + "hashDigest": { + "@id": "untp-core:hashDigest", + "@type": "xsd:string" + }, + "hashMethod": { + "@id": "untp-core:hashMethod", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://test.uncefact.org/vocabulary/untp/core/0/hashMethodCode#" + } + }, + "encryptionMethod": { + "@id": "untp-core:encryptionMethod", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://test.uncefact.org/vocabulary/untp/core/0/encryptionMethodCode#" + } + } + } + } + } + }, + "ProductPassport": { + "@protected": true, + "@id": "untp-dpp:ProductPassport", + "@context": { + "@protected": true, + "product": { + "@id": "untp-core:product", + "@type": "@id" + }, + "granularityLevel": { + "@id": "untp-dpp:granularityLevel", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://test.uncefact.org/vocabulary/untp/dpp/0/granularityCode#" + } + }, + "conformityClaim": { + "@id": "untp-core:conformityClaim", + "@type": "@id" + }, + "emissionsScorecard": { + "@protected": true, + "@id": "untp-core:emissionsScorecard", + "@context": { + "@protected": true, + "carbonFootprint": { + "@id": "untp-core:carbonFootprint", + "@type": "xsd:double" + }, + "declaredUnit": { + "@id": "untp-core:declaredUnit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + }, + "operationalScope": { + "@id": "untp-core:operationalScope", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://test.uncefact.org/vocabulary/untp/core/0/operationalScopeCode#" + } + }, + "primarySourcedRatio": { + "@id": "untp-core:primarySourcedRatio", + "@type": "xsd:double" + }, + "reportingStandard": { + "@id": "untp-core:reportingStandard", + "@type": "@id" + } + } + }, + "traceabilityInformation": { + "@protected": true, + "@id": "untp-dpp:traceabilityInformation", + "@context": { + "@protected": true, + "valueChainProcess": { + "@id": "untp-dpp:valueChainProcess", + "@type": "xsd:string" + }, + "verifiedRatio": { + "@id": "untp-dpp:verifiedRatio", + "@type": "xsd:double" + }, + "traceabilityEvent": { + "@protected": true, + "@id": "untp-core:traceabilityEvent", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp-core:linkURL", + "@type": "xsd:string" + }, + "linkName": { + "@id": "untp-core:linkName", + "@type": "xsd:string" + }, + "linkType": { + "@id": "untp-core:linkType", + "@type": "xsd:string" + }, + "hashDigest": { + "@id": "untp-core:hashDigest", + "@type": "xsd:string" + }, + "hashMethod": { + "@id": "untp-core:hashMethod", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://test.uncefact.org/vocabulary/untp/core/0/hashMethodCode#" + } + }, + "encryptionMethod": { + "@id": "untp-core:encryptionMethod", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://test.uncefact.org/vocabulary/untp/core/0/encryptionMethodCode#" + } + } + } + } + } + }, + "circularityScorecard": { + "@protected": true, + "@id": "untp-core:circularityScorecard", + "@context": { + "@protected": true, + "recyclingInformation": { + "@protected": true, + "@id": "untp-core:recyclingInformation", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp-core:linkURL", + "@type": "xsd:string" + }, + "linkName": { + "@id": "untp-core:linkName", + "@type": "xsd:string" + }, + "linkType": { + "@id": "untp-core:linkType", + "@type": "xsd:string" + } + } + }, + "repairInformation": { + "@protected": true, + "@id": "untp-core:repairInformation", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp-core:linkURL", + "@type": "xsd:string" + }, + "linkName": { + "@id": "untp-core:linkName", + "@type": "xsd:string" + }, + "linkType": { + "@id": "untp-core:linkType", + "@type": "xsd:string" + } + } + }, + "recyclableContent": { + "@id": "untp-core:recyclableContent", + "@type": "xsd:double" + }, + "recycledContent": { + "@id": "untp-core:recycledContent", + "@type": "xsd:double" + }, + "utilityFactor": { + "@id": "untp-core:utilityFactor", + "@type": "xsd:double" + }, + "materialCircularityIndicator": { + "@id": "untp-core:materialCircularityIndicator", + "@type": "xsd:double" + } + } + }, + "dueDiligenceDeclaration": { + "@protected": true, + "@id": "untp-core:dueDiligenceDeclaration", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp-core:linkURL", + "@type": "xsd:string" + }, + "linkName": { + "@id": "untp-core:linkName", + "@type": "xsd:string" + }, + "linkType": { + "@id": "untp-core:linkType", + "@type": "xsd:string" + } + } + }, + "materialsProvenance": { + "@protected": true, + "@id": "untp-core:materialsProvenance", + "@context": { + "@protected": true, + "name": { + "@id": "schemaorg:name" + }, + "originCountry": { + "@id": "untp-core:originCountry", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "materialType": { + "@id": "untp-core:materialType", + "@type": "@id" + }, + "massFraction": { + "@id": "untp-core:massFraction", + "@type": "xsd:double" + }, + "mass": { + "@protected": true, + "@id": "untp-core:mass", + "@context": { + "@protected": true, + "value": { + "@id": "untp-core:value", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp-core:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "recycledMassFraction": { + "@id": "untp-core:recycledMassFraction", + "@type": "xsd:double" + }, + "hazardous": { + "@id": "untp-core:hazardous", + "@type": "xsd:boolean" + }, + "symbol": { + "@id": "untp-core:symbol", + "@type": "xsd:string" + }, + "materialSafetyInformation": { + "@protected": true, + "@id": "untp-core:materialSafetyInformation", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp-core:linkURL", + "@type": "xsd:string" + }, + "linkName": { + "@id": "untp-core:linkName", + "@type": "xsd:string" + }, + "linkType": { + "@id": "untp-core:linkType", + "@type": "xsd:string" + } + } + } + } + } + } + }, + "Declaration": { + "@protected": true, + "@id": "untp-core:Declaration", + "@context": { + "@protected": true, + "description": { + "@id": "schemaorg:description" + }, + "referenceStandard": { + "@id": "untp-core:referenceStandard", + "@type": "@id" + }, + "referenceRegulation": { + "@id": "untp-core:referenceRegulation", + "@type": "@id" + }, + "assessmentCriteria": { + "@id": "untp-core:assessmentCriteria", + "@type": "@id" + }, + "assessmentDate": { + "@id": "untp-core:assessmentDate", + "@type": "xsd:string" + }, + "declaredValue": { + "@protected": true, + "@id": "untp-core:declaredValue", + "@context": { + "@protected": true, + "metricName": { + "@id": "untp-core:metricName", + "@type": "xsd:string" + }, + "metricValue": { + "@protected": true, + "@id": "untp-core:metricValue", + "@context": { + "@protected": true, + "value": { + "@id": "untp-core:value", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp-core:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "score": { + "@id": "untp-core:score", + "@type": "xsd:string" + }, + "accuracy": { + "@id": "untp-core:accuracy", + "@type": "xsd:double" + } + } + }, + "conformance": { + "@id": "untp-core:conformance", + "@type": "xsd:boolean" + }, + "conformityTopic": { + "@id": "untp-core:conformityTopic", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://test.uncefact.org/vocabulary/untp/core/0/conformityTopicCode#" + } + } + } + }, + "RenderTemplate2024": { + "@protected": true, + "@id": "untp-core:RenderTemplate2024", + "@context": { + "@protected": true, + "name": { + "@id": "schemaorg:name" + }, + "mediaQuery": { + "@id": "untp-core:mediaQuery", + "@type": "xsd:string" + }, + "template": { + "@id": "renderMethodPrefix:template", + "@type": "xsd:string" + }, + "url": { + "@id": "renderMethodPrefix:url", + "@type": "xsd:string" + } + } + }, + "WebRenderingTemplate2022": { + "@protected": true, + "@id": "untp-core:WebRenderingTemplate2022", + "@context": { + "@protected": true, + "name": { + "@id": "schemaorg:name" + }, + "template": { + "@id": "renderMethodPrefix:template", + "@type": "xsd:string" + } + } + } + } +} diff --git a/src/dppvalidator/vocabularies/data/untp-context-0.7.0.jsonld b/src/dppvalidator/vocabularies/data/untp-context-0.7.0.jsonld new file mode 100644 index 0000000..bb353a3 --- /dev/null +++ b/src/dppvalidator/vocabularies/data/untp-context-0.7.0.jsonld @@ -0,0 +1,3493 @@ +{ + "@context": { + "untp": "https://vocabulary.uncefact.org/untp/", + "schema": "https://schema.org/", + "renderMethodPrefix": "https://w3id.org/vc/render-method#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "@protected": true, + "@version": 1.1, + "type": "@type", + "id": "@id", + "issuingSoftware": { + "@id": "untp:issuingSoftware", + "@type": "@id", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/IssuingSoftware#", + "name": { + "@id": "schema:name" + }, + "version": { + "@id": "untp:version", + "@type": "xsd:string" + }, + "vendor": { + "@id": "untp:vendor", + "@type": "@id", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/SoftwareVendor#", + "name": { + "@id": "schema:name" + } + } + } + } + }, + "DigitalProductPassport": { + "@protected": true, + "@id": "untp:DigitalProductPassport", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/DigitalProductPassport#" + } + }, + "DigitalConformityCredential": { + "@protected": true, + "@id": "untp:DigitalConformityCredential", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/DigitalConformityCredential#" + } + }, + "DigitalFacilityRecord": { + "@protected": true, + "@id": "untp:DigitalFacilityRecord", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/DigitalFacilityRecord#" + } + }, + "DigitalIdentityAnchor": { + "@protected": true, + "@id": "untp:DigitalIdentityAnchor", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/DigitalIdentityAnchor#" + } + }, + "DigitalTraceabilityEvent": { + "@protected": true, + "@id": "untp:DigitalTraceabilityEvent", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/DigitalTraceabilityEvent#" + } + }, + "RenderTemplate2024": { + "@protected": true, + "@id": "untp:RenderTemplate2024", + "@context": { + "@protected": true, + "mediaQuery": { + "@id": "untp:mediaQuery", + "@type": "xsd:string" + }, + "template": { + "@id": "untp:template", + "@type": "xsd:string" + }, + "url": { + "@id": "untp:url", + "@type": "xsd:anyURI" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + } + } + }, + "IdentifierScheme": { + "@protected": true, + "@id": "untp:IdentifierScheme", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + } + } + }, + "Party": { + "@protected": true, + "@id": "untp:Party", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/Party#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "registeredId": { + "@id": "untp:registeredId", + "@type": "xsd:string" + }, + "idScheme": { + "@id": "untp:idScheme", + "@type": "@id", + "@context": { + "@protected": true, + "id": { + "@id": "untp:id", + "@type": "xsd:anyURI" + }, + "name": { + "@id": "schema:name" + } + } + }, + "registrationCountry": { + "@protected": true, + "@id": "untp:registrationCountry", + "@context": { + "@protected": true, + "countryCode": { + "@id": "untp:countryCode", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "countryName": { + "@id": "untp:countryName", + "@type": "xsd:string" + } + } + }, + "partyAddress": { + "@protected": true, + "@id": "untp:partyAddress", + "@context": { + "@protected": true, + "streetAddress": { + "@id": "schema:streetAddress", + "@type": "xsd:string" + }, + "postalCode": { + "@id": "schema:postalCode", + "@type": "xsd:string" + }, + "addressLocality": { + "@id": "schema:addressLocality", + "@type": "xsd:string" + }, + "addressRegion": { + "@id": "schema:addressRegion", + "@type": "xsd:string" + }, + "addressCountry": { + "@protected": true, + "@id": "untp:addressCountry", + "@context": { + "@protected": true, + "countryCode": { + "@id": "untp:countryCode", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "countryName": { + "@id": "untp:countryName", + "@type": "xsd:string" + } + } + } + } + }, + "organisationWebsite": { + "@id": "untp:organisationWebsite", + "@type": "xsd:anyURI" + }, + "industryCategory": { + "@protected": true, + "@id": "untp:industryCategory", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "partyAlsoKnownAs": { + "@id": "untp:partyAlsoKnownAs", + "@type": "@id" + } + } + }, + "CredentialIssuer": { + "@protected": true, + "@id": "untp:CredentialIssuer", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/CredentialIssuer#", + "name": { + "@id": "schema:name" + }, + "issuerAlsoKnownAs": { + "@id": "untp:issuerAlsoKnownAs", + "@type": "@id" + } + } + }, + "Entity": { + "@protected": false, + "@id": "untp:Entity", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/Entity#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + } + } + }, + "ConformityTopic": { + "@protected": true, + "@id": "untp:ConformityTopic", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + } + } + }, + "PerformanceMetric": { + "@protected": true, + "@id": "untp:PerformanceMetric", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "improvementDirection": { + "@id": "untp:improvementDirection", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ImprovementIndicator#" + } + }, + "aggregationMethod": { + "@id": "untp:aggregationMethod", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/AggregationType#" + } + }, + "allowedUnit": { + "@id": "untp:allowedUnit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "Criterion": { + "@protected": true, + "@id": "untp:Criterion", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/Criterion#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "version": { + "@id": "untp:version", + "@type": "xsd:string" + }, + "status": { + "@id": "untp:status", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/CriterionStatus#" + } + }, + "documentation": { + "@id": "untp:documentation", + "@type": "xsd:anyURI" + }, + "conformityTopic": { + "@id": "untp:conformityTopic", + "@type": "@id", + "@container": "@set" + }, + "tag": { + "@id": "untp:tag", + "@type": "xsd:string" + }, + "requiredPerformance": { + "@protected": true, + "@id": "untp:requiredPerformance", + "@context": { + "@protected": true, + "metric": { + "@id": "untp:metric", + "@type": "@id" + }, + "measure": { + "@protected": true, + "@id": "untp:measure", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "score": { + "@protected": true, + "@id": "untp:score", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "rank": { + "@id": "untp:rank", + "@type": "xsd:integer" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + } + } + } + } + } + } + }, + "Regulation": { + "@protected": true, + "@id": "untp:Regulation", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/Regulation#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "jurisdictionCountry": { + "@protected": true, + "@id": "untp:jurisdictionCountry", + "@context": { + "@protected": true, + "countryCode": { + "@id": "untp:countryCode", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "countryName": { + "@id": "untp:countryName", + "@type": "xsd:string" + } + } + }, + "administeredBy": { + "@id": "untp:administeredBy", + "@type": "@id" + }, + "effectiveDate": { + "@id": "untp:effectiveDate", + "@type": "xsd:date" + } + } + }, + "Standard": { + "@protected": true, + "@id": "untp:Standard", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/Standard#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "issuingParty": { + "@id": "untp:issuingParty", + "@type": "@id" + }, + "issueDate": { + "@id": "untp:issueDate", + "@type": "xsd:date" + } + } + }, + "Claim": { + "@protected": true, + "@id": "untp:Claim", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/Claim#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "referenceCriteria": { + "@id": "untp:referenceCriteria", + "@type": "@id" + }, + "referenceRegulation": { + "@id": "untp:referenceRegulation", + "@type": "@id" + }, + "referenceStandard": { + "@id": "untp:referenceStandard", + "@type": "@id" + }, + "claimDate": { + "@id": "untp:claimDate", + "@type": "xsd:date" + }, + "applicablePeriod": { + "@protected": true, + "@id": "untp:applicablePeriod", + "@context": { + "@protected": true, + "startDate": { + "@id": "untp:startDate", + "@type": "xsd:date" + }, + "endDate": { + "@id": "untp:endDate", + "@type": "xsd:date" + }, + "periodInformation": { + "@id": "untp:periodInformation", + "@type": "xsd:string" + } + } + }, + "claimedPerformance": { + "@protected": true, + "@id": "untp:claimedPerformance", + "@context": { + "@protected": true, + "metric": { + "@id": "untp:metric", + "@type": "@id" + }, + "measure": { + "@protected": true, + "@id": "untp:measure", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "score": { + "@protected": true, + "@id": "untp:score", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "rank": { + "@id": "untp:rank", + "@type": "xsd:integer" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + } + } + } + } + }, + "evidence": { + "@protected": true, + "@id": "untp:evidence", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "conformityTopic": { + "@id": "untp:conformityTopic", + "@type": "@id", + "@container": "@set" + } + } + }, + "Facility": { + "@protected": true, + "@id": "untp:Facility", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/Facility#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "registeredId": { + "@id": "untp:registeredId", + "@type": "xsd:string" + }, + "idScheme": { + "@id": "untp:idScheme", + "@type": "@id", + "@context": { + "@protected": true, + "id": { + "@id": "untp:id", + "@type": "xsd:anyURI" + }, + "name": { + "@id": "schema:name" + } + } + }, + "countryOfOperation": { + "@protected": true, + "@id": "untp:countryOfOperation", + "@context": { + "@protected": true, + "countryCode": { + "@id": "untp:countryCode", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "countryName": { + "@id": "untp:countryName", + "@type": "xsd:string" + } + } + }, + "processCategory": { + "@protected": true, + "@id": "untp:processCategory", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "relatedParty": { + "@protected": true, + "@id": "untp:relatedParty", + "@context": { + "@protected": true, + "role": { + "@id": "untp:role", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/PartyRole#" + } + }, + "party": { + "@id": "untp:party", + "@type": "@id" + } + } + }, + "relatedDocument": { + "@protected": true, + "@id": "untp:relatedDocument", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "facilityAlsoKnownAs": { + "@id": "untp:facilityAlsoKnownAs", + "@type": "@id" + }, + "locationInformation": { + "@protected": true, + "@id": "untp:locationInformation", + "@context": { + "@protected": true, + "plusCode": { + "@id": "untp:plusCode", + "@type": "xsd:anyURI" + }, + "geoLocation": { + "@protected": true, + "@id": "untp:geoLocation", + "@context": { + "@protected": true, + "latitude": { + "@id": "untp:latitude", + "@type": "xsd:double" + }, + "longitude": { + "@id": "untp:longitude", + "@type": "xsd:double" + } + } + }, + "geoBoundary": { + "@protected": true, + "@id": "untp:geoBoundary", + "@context": { + "@protected": true, + "latitude": { + "@id": "untp:latitude", + "@type": "xsd:double" + }, + "longitude": { + "@id": "untp:longitude", + "@type": "xsd:double" + } + } + } + } + }, + "address": { + "@protected": true, + "@id": "untp:address", + "@context": { + "@protected": true, + "streetAddress": { + "@id": "schema:streetAddress", + "@type": "xsd:string" + }, + "postalCode": { + "@id": "schema:postalCode", + "@type": "xsd:string" + }, + "addressLocality": { + "@id": "schema:addressLocality", + "@type": "xsd:string" + }, + "addressRegion": { + "@id": "schema:addressRegion", + "@type": "xsd:string" + }, + "addressCountry": { + "@protected": true, + "@id": "untp:addressCountry", + "@context": { + "@protected": true, + "countryCode": { + "@id": "untp:countryCode", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "countryName": { + "@id": "untp:countryName", + "@type": "xsd:string" + } + } + } + } + }, + "materialUsage": { + "@protected": true, + "@id": "untp:materialUsage", + "@context": { + "@protected": true, + "applicablePeriod": { + "@protected": true, + "@id": "untp:applicablePeriod", + "@context": { + "@protected": true, + "startDate": { + "@id": "untp:startDate", + "@type": "xsd:date" + }, + "endDate": { + "@id": "untp:endDate", + "@type": "xsd:date" + }, + "periodInformation": { + "@id": "untp:periodInformation", + "@type": "xsd:string" + } + } + }, + "materialConsumed": { + "@protected": true, + "@id": "untp:materialConsumed", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "originCountry": { + "@protected": true, + "@id": "untp:originCountry", + "@context": { + "@protected": true, + "countryCode": { + "@id": "untp:countryCode", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "countryName": { + "@id": "untp:countryName", + "@type": "xsd:string" + } + } + }, + "materialType": { + "@protected": true, + "@id": "untp:materialType", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "massFraction": { + "@id": "untp:massFraction", + "@type": "xsd:double" + }, + "mass": { + "@protected": true, + "@id": "untp:mass", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "recycledMassFraction": { + "@id": "untp:recycledMassFraction", + "@type": "xsd:double" + }, + "hazardous": { + "@id": "untp:hazardous", + "@type": "xsd:boolean" + }, + "symbol": { + "@protected": true, + "@id": "untp:symbol", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "imageData": { + "@id": "untp:imageData", + "@type": "xsd:string" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + } + } + }, + "materialSafetyInformation": { + "@protected": true, + "@id": "untp:materialSafetyInformation", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + } + } + } + } + }, + "performanceClaim": { + "@id": "untp:performanceClaim", + "@type": "@id" + } + } + }, + "ConformityScheme": { + "@protected": true, + "@id": "untp:ConformityScheme", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ConformityScheme#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "owner": { + "@id": "untp:owner", + "@type": "@id" + }, + "endorsementLevel": { + "@id": "untp:endorsementLevel", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/SchemeEndorsementLevel#" + } + }, + "endorsement": { + "@protected": true, + "@id": "untp:endorsement", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "trustmark": { + "@protected": true, + "@id": "untp:trustmark", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "imageData": { + "@id": "untp:imageData", + "@type": "xsd:string" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + } + } + }, + "issuingAuthority": { + "@id": "untp:issuingAuthority", + "@type": "@id" + }, + "endorsementEvidence": { + "@protected": true, + "@id": "untp:endorsementEvidence", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + } + } + }, + "schemeScoringFramework": { + "@protected": true, + "@id": "untp:schemeScoringFramework", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "score": { + "@protected": true, + "@id": "untp:score", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "rank": { + "@id": "untp:rank", + "@type": "xsd:integer" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + } + } + } + } + }, + "documentation": { + "@id": "untp:documentation", + "@type": "xsd:anyURI" + }, + "licenseType": { + "@id": "untp:licenseType", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/LicenseType#" + } + }, + "establishedDate": { + "@id": "untp:establishedDate", + "@type": "xsd:date" + }, + "geographicScope": { + "@protected": true, + "@id": "untp:geographicScope", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "industryScope": { + "@protected": true, + "@id": "untp:industryScope", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "trustmark": { + "@protected": true, + "@id": "untp:trustmark", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "imageData": { + "@id": "untp:imageData", + "@type": "xsd:string" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + } + } + }, + "conformsTo": { + "@protected": true, + "@id": "untp:conformsTo", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "includedProfile": { + "@id": "untp:includedProfile", + "@type": "@id" + } + } + }, + "ConformityProfile": { + "@protected": true, + "@id": "untp:ConformityProfile", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ConformityProfile#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "version": { + "@id": "untp:version", + "@type": "xsd:string" + }, + "validFrom": { + "@id": "untp:validFrom", + "@type": "xsd:date" + }, + "status": { + "@id": "untp:status", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/CriterionStatus#" + } + }, + "subjectType": { + "@id": "untp:subjectType", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/AssessmentSubjectType#" + } + }, + "standardAlignment": { + "@protected": true, + "@id": "untp:standardAlignment", + "@context": { + "@protected": true, + "standard": { + "@id": "untp:standard", + "@type": "@id" + }, + "alignmentLevel": { + "@id": "untp:alignmentLevel", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/SchemeAlignmentLevel#" + } + } + } + }, + "regulatoryAlignment": { + "@protected": true, + "@id": "untp:regulatoryAlignment", + "@context": { + "@protected": true, + "regulation": { + "@id": "untp:regulation", + "@type": "@id" + }, + "alignmentLevel": { + "@id": "untp:alignmentLevel", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/SchemeAlignmentLevel#" + } + } + } + }, + "criterionScoringFramework": { + "@protected": true, + "@id": "untp:criterionScoringFramework", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "score": { + "@protected": true, + "@id": "untp:score", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "rank": { + "@id": "untp:rank", + "@type": "xsd:integer" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + } + } + } + } + }, + "documentation": { + "@id": "untp:documentation", + "@type": "xsd:anyURI" + }, + "criterion": { + "@id": "untp:criterion", + "@type": "@id" + }, + "scope": { + "@protected": true, + "@id": "untp:scope", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "scheme": { + "@id": "untp:scheme", + "@type": "@id" + } + } + }, + "Product": { + "@protected": true, + "@id": "untp:Product", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/Product#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "idScheme": { + "@id": "untp:idScheme", + "@type": "@id", + "@context": { + "@protected": true, + "id": { + "@id": "untp:id", + "@type": "xsd:anyURI" + }, + "name": { + "@id": "schema:name" + } + } + }, + "modelNumber": { + "@id": "untp:modelNumber", + "@type": "xsd:string" + }, + "batchNumber": { + "@id": "untp:batchNumber", + "@type": "xsd:string" + }, + "itemNumber": { + "@id": "untp:itemNumber", + "@type": "xsd:string" + }, + "idGranularity": { + "@id": "untp:idGranularity", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ProductIDGranularity#" + } + }, + "characteristics": { + "@id": "untp:characteristics", + "@context": { + "@vocab": "https://vocabulary.uncefact.org/untp/Characteristics#" + } + }, + "productImage": { + "@protected": true, + "@id": "untp:productImage", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "productCategory": { + "@protected": true, + "@id": "untp:productCategory", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "relatedDocument": { + "@protected": true, + "@id": "untp:relatedDocument", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "relatedParty": { + "@protected": true, + "@id": "untp:relatedParty", + "@context": { + "@protected": true, + "role": { + "@id": "untp:role", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/PartyRole#" + } + }, + "party": { + "@id": "untp:party", + "@type": "@id" + } + } + }, + "producedAtFacility": { + "@id": "untp:producedAtFacility", + "@type": "@id" + }, + "productionDate": { + "@id": "untp:productionDate", + "@type": "xsd:date" + }, + "expiryDate": { + "@id": "untp:expiryDate", + "@type": "xsd:date" + }, + "countryOfProduction": { + "@protected": true, + "@id": "untp:countryOfProduction", + "@context": { + "@protected": true, + "countryCode": { + "@id": "untp:countryCode", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "countryName": { + "@id": "untp:countryName", + "@type": "xsd:string" + } + } + }, + "dimensions": { + "@protected": true, + "@id": "untp:dimensions", + "@context": { + "@protected": true, + "weight": { + "@protected": true, + "@id": "untp:weight", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "length": { + "@protected": true, + "@id": "untp:length", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "width": { + "@protected": true, + "@id": "untp:width", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "height": { + "@protected": true, + "@id": "untp:height", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "volume": { + "@protected": true, + "@id": "untp:volume", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + } + } + }, + "materialProvenance": { + "@protected": true, + "@id": "untp:materialProvenance", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "originCountry": { + "@protected": true, + "@id": "untp:originCountry", + "@context": { + "@protected": true, + "countryCode": { + "@id": "untp:countryCode", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "countryName": { + "@id": "untp:countryName", + "@type": "xsd:string" + } + } + }, + "materialType": { + "@protected": true, + "@id": "untp:materialType", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "massFraction": { + "@id": "untp:massFraction", + "@type": "xsd:double" + }, + "mass": { + "@protected": true, + "@id": "untp:mass", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "recycledMassFraction": { + "@id": "untp:recycledMassFraction", + "@type": "xsd:double" + }, + "hazardous": { + "@id": "untp:hazardous", + "@type": "xsd:boolean" + }, + "symbol": { + "@protected": true, + "@id": "untp:symbol", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "imageData": { + "@id": "untp:imageData", + "@type": "xsd:string" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + } + } + }, + "materialSafetyInformation": { + "@protected": true, + "@id": "untp:materialSafetyInformation", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + } + } + }, + "packaging": { + "@protected": true, + "@id": "untp:packaging", + "@context": { + "@protected": true, + "description": { + "@id": "schema:description" + }, + "dimensions": { + "@protected": true, + "@id": "untp:dimensions", + "@context": { + "@protected": true, + "weight": { + "@protected": true, + "@id": "untp:weight", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "length": { + "@protected": true, + "@id": "untp:length", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "width": { + "@protected": true, + "@id": "untp:width", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "height": { + "@protected": true, + "@id": "untp:height", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "volume": { + "@protected": true, + "@id": "untp:volume", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + } + } + }, + "materialUsed": { + "@protected": true, + "@id": "untp:materialUsed", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "originCountry": { + "@protected": true, + "@id": "untp:originCountry", + "@context": { + "@protected": true, + "countryCode": { + "@id": "untp:countryCode", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "countryName": { + "@id": "untp:countryName", + "@type": "xsd:string" + } + } + }, + "materialType": { + "@protected": true, + "@id": "untp:materialType", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "massFraction": { + "@id": "untp:massFraction", + "@type": "xsd:double" + }, + "mass": { + "@protected": true, + "@id": "untp:mass", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "recycledMassFraction": { + "@id": "untp:recycledMassFraction", + "@type": "xsd:double" + }, + "hazardous": { + "@id": "untp:hazardous", + "@type": "xsd:boolean" + }, + "symbol": { + "@protected": true, + "@id": "untp:symbol", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "imageData": { + "@id": "untp:imageData", + "@type": "xsd:string" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + } + } + }, + "materialSafetyInformation": { + "@protected": true, + "@id": "untp:materialSafetyInformation", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + } + } + }, + "packageLabel": { + "@protected": true, + "@id": "untp:packageLabel", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "imageData": { + "@id": "untp:imageData", + "@type": "xsd:string" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + } + } + }, + "performanceClaim": { + "@id": "untp:performanceClaim", + "@type": "@id" + } + } + }, + "productLabel": { + "@protected": true, + "@id": "untp:productLabel", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "imageData": { + "@id": "untp:imageData", + "@type": "xsd:string" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + } + } + }, + "performanceClaim": { + "@id": "untp:performanceClaim", + "@type": "@id" + } + } + }, + "ConformityAssessment": { + "@protected": true, + "@id": "untp:ConformityAssessment", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ConformityAssessment#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "assessmentCriteria": { + "@id": "untp:assessmentCriteria", + "@type": "@id" + }, + "assessmentDate": { + "@id": "untp:assessmentDate", + "@type": "xsd:date" + }, + "assessedPerformance": { + "@protected": true, + "@id": "untp:assessedPerformance", + "@context": { + "@protected": true, + "metric": { + "@id": "untp:metric", + "@type": "@id" + }, + "measure": { + "@protected": true, + "@id": "untp:measure", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "score": { + "@protected": true, + "@id": "untp:score", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "rank": { + "@id": "untp:rank", + "@type": "xsd:integer" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + } + } + } + } + }, + "assessedProduct": { + "@protected": true, + "@id": "untp:assessedProduct", + "@context": { + "@protected": true, + "product": { + "@id": "untp:product", + "@type": "@id" + }, + "idVerifiedByCAB": { + "@id": "untp:idVerifiedByCAB", + "@type": "xsd:boolean" + } + } + }, + "assessedFacility": { + "@protected": true, + "@id": "untp:assessedFacility", + "@context": { + "@protected": true, + "facility": { + "@id": "untp:facility", + "@type": "@id" + }, + "idVerifiedByCAB": { + "@id": "untp:idVerifiedByCAB", + "@type": "xsd:boolean" + } + } + }, + "assessedOrganisation": { + "@id": "untp:assessedOrganisation", + "@type": "@id" + }, + "referenceStandard": { + "@id": "untp:referenceStandard", + "@type": "@id" + }, + "referenceRegulation": { + "@id": "untp:referenceRegulation", + "@type": "@id" + }, + "specifiedCondition": { + "@id": "untp:specifiedCondition", + "@type": "xsd:string" + }, + "evidence": { + "@protected": true, + "@id": "untp:evidence", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "conformityTopic": { + "@id": "untp:conformityTopic", + "@type": "@id", + "@container": "@set" + }, + "conformance": { + "@id": "untp:conformance", + "@type": "xsd:boolean" + } + } + }, + "ConformityAttestation": { + "@protected": true, + "@id": "untp:ConformityAttestation", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ConformityAttestation#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "assessorLevel": { + "@id": "untp:assessorLevel", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/AssessorLevel#" + } + }, + "assessmentLevel": { + "@id": "untp:assessmentLevel", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/AssessmentLevel#" + } + }, + "attestationType": { + "@id": "untp:attestationType", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/AttestationType#" + } + }, + "issuedToParty": { + "@id": "untp:issuedToParty", + "@type": "@id" + }, + "authorisation": { + "@protected": true, + "@id": "untp:authorisation", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "trustmark": { + "@protected": true, + "@id": "untp:trustmark", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "imageData": { + "@id": "untp:imageData", + "@type": "xsd:string" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + } + } + }, + "issuingAuthority": { + "@id": "untp:issuingAuthority", + "@type": "@id" + }, + "endorsementEvidence": { + "@protected": true, + "@id": "untp:endorsementEvidence", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + } + } + }, + "referenceScheme": { + "@id": "untp:referenceScheme", + "@type": "@id" + }, + "referenceProfile": { + "@id": "untp:referenceProfile", + "@type": "@id" + }, + "profileScore": { + "@protected": true, + "@id": "untp:profileScore", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "rank": { + "@id": "untp:rank", + "@type": "xsd:integer" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + } + } + }, + "conformityCertificate": { + "@protected": true, + "@id": "untp:conformityCertificate", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "auditableEvidence": { + "@protected": true, + "@id": "untp:auditableEvidence", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "trustmark": { + "@protected": true, + "@id": "untp:trustmark", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "imageData": { + "@id": "untp:imageData", + "@type": "xsd:string" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + } + } + }, + "conformityAssessment": { + "@id": "untp:conformityAssessment", + "@type": "@id" + } + } + }, + "LifecycleEvent": { + "@protected": true, + "@id": "untp:LifecycleEvent", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/LifecycleEvent#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "eventDate": { + "@id": "untp:eventDate", + "@type": "xsd:datetime" + }, + "sensorData": { + "@protected": true, + "@id": "untp:sensorData", + "@context": { + "@protected": true, + "metric": { + "@id": "untp:metric", + "@type": "@id" + }, + "measure": { + "@protected": true, + "@id": "untp:measure", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "rawData": { + "@protected": true, + "@id": "untp:rawData", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "sensor": { + "@id": "untp:sensor", + "@type": "@id" + }, + "geoLocation": { + "@protected": true, + "@id": "untp:geoLocation", + "@context": { + "@protected": true, + "latitude": { + "@id": "untp:latitude", + "@type": "xsd:double" + }, + "longitude": { + "@id": "untp:longitude", + "@type": "xsd:double" + } + } + } + } + }, + "relatedDocument": { + "@protected": true, + "@id": "untp:relatedDocument", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "activityType": { + "@protected": true, + "@id": "untp:activityType", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "relatedParty": { + "@protected": true, + "@id": "untp:relatedParty", + "@context": { + "@protected": true, + "role": { + "@id": "untp:role", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/PartyRole#" + } + }, + "party": { + "@id": "untp:party", + "@type": "@id" + } + } + } + } + }, + "MakeEvent": { + "@protected": true, + "@id": "untp:MakeEvent", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/MakeEvent#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "eventDate": { + "@id": "untp:eventDate", + "@type": "xsd:datetime" + }, + "sensorData": { + "@protected": true, + "@id": "untp:sensorData", + "@context": { + "@protected": true, + "metric": { + "@id": "untp:metric", + "@type": "@id" + }, + "measure": { + "@protected": true, + "@id": "untp:measure", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "rawData": { + "@protected": true, + "@id": "untp:rawData", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "sensor": { + "@id": "untp:sensor", + "@type": "@id" + }, + "geoLocation": { + "@protected": true, + "@id": "untp:geoLocation", + "@context": { + "@protected": true, + "latitude": { + "@id": "untp:latitude", + "@type": "xsd:double" + }, + "longitude": { + "@id": "untp:longitude", + "@type": "xsd:double" + } + } + } + } + }, + "relatedDocument": { + "@protected": true, + "@id": "untp:relatedDocument", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "activityType": { + "@protected": true, + "@id": "untp:activityType", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "relatedParty": { + "@protected": true, + "@id": "untp:relatedParty", + "@context": { + "@protected": true, + "role": { + "@id": "untp:role", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/PartyRole#" + } + }, + "party": { + "@id": "untp:party", + "@type": "@id" + } + } + }, + "inputProduct": { + "@protected": true, + "@id": "untp:inputProduct", + "@context": { + "@protected": true, + "product": { + "@id": "untp:product", + "@type": "@id" + }, + "quantity": { + "@protected": true, + "@id": "untp:quantity", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "disposition": { + "@id": "untp:disposition", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ProductStatus#" + } + } + } + }, + "outputProduct": { + "@protected": true, + "@id": "untp:outputProduct", + "@context": { + "@protected": true, + "product": { + "@id": "untp:product", + "@type": "@id" + }, + "quantity": { + "@protected": true, + "@id": "untp:quantity", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "disposition": { + "@id": "untp:disposition", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ProductStatus#" + } + } + } + }, + "madeAtFacility": { + "@id": "untp:madeAtFacility", + "@type": "@id" + } + } + }, + "MoveEvent": { + "@protected": true, + "@id": "untp:MoveEvent", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/MoveEvent#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "eventDate": { + "@id": "untp:eventDate", + "@type": "xsd:datetime" + }, + "sensorData": { + "@protected": true, + "@id": "untp:sensorData", + "@context": { + "@protected": true, + "metric": { + "@id": "untp:metric", + "@type": "@id" + }, + "measure": { + "@protected": true, + "@id": "untp:measure", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "rawData": { + "@protected": true, + "@id": "untp:rawData", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "sensor": { + "@id": "untp:sensor", + "@type": "@id" + }, + "geoLocation": { + "@protected": true, + "@id": "untp:geoLocation", + "@context": { + "@protected": true, + "latitude": { + "@id": "untp:latitude", + "@type": "xsd:double" + }, + "longitude": { + "@id": "untp:longitude", + "@type": "xsd:double" + } + } + } + } + }, + "relatedDocument": { + "@protected": true, + "@id": "untp:relatedDocument", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "activityType": { + "@protected": true, + "@id": "untp:activityType", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "relatedParty": { + "@protected": true, + "@id": "untp:relatedParty", + "@context": { + "@protected": true, + "role": { + "@id": "untp:role", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/PartyRole#" + } + }, + "party": { + "@id": "untp:party", + "@type": "@id" + } + } + }, + "movedProduct": { + "@protected": true, + "@id": "untp:movedProduct", + "@context": { + "@protected": true, + "product": { + "@id": "untp:product", + "@type": "@id" + }, + "quantity": { + "@protected": true, + "@id": "untp:quantity", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "disposition": { + "@id": "untp:disposition", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ProductStatus#" + } + } + } + }, + "fromFacility": { + "@id": "untp:fromFacility", + "@type": "@id" + }, + "toFacility": { + "@id": "untp:toFacility", + "@type": "@id" + }, + "consignmentId": { + "@id": "untp:consignmentId", + "@type": "xsd:anyURI" + } + } + }, + "ModifyEvent": { + "@protected": true, + "@id": "untp:ModifyEvent", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ModifyEvent#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "eventDate": { + "@id": "untp:eventDate", + "@type": "xsd:datetime" + }, + "sensorData": { + "@protected": true, + "@id": "untp:sensorData", + "@context": { + "@protected": true, + "metric": { + "@id": "untp:metric", + "@type": "@id" + }, + "measure": { + "@protected": true, + "@id": "untp:measure", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "rawData": { + "@protected": true, + "@id": "untp:rawData", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "sensor": { + "@id": "untp:sensor", + "@type": "@id" + }, + "geoLocation": { + "@protected": true, + "@id": "untp:geoLocation", + "@context": { + "@protected": true, + "latitude": { + "@id": "untp:latitude", + "@type": "xsd:double" + }, + "longitude": { + "@id": "untp:longitude", + "@type": "xsd:double" + } + } + } + } + }, + "relatedDocument": { + "@protected": true, + "@id": "untp:relatedDocument", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "activityType": { + "@protected": true, + "@id": "untp:activityType", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "relatedParty": { + "@protected": true, + "@id": "untp:relatedParty", + "@context": { + "@protected": true, + "role": { + "@id": "untp:role", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/PartyRole#" + } + }, + "party": { + "@id": "untp:party", + "@type": "@id" + } + } + }, + "modifiedProduct": { + "@protected": true, + "@id": "untp:modifiedProduct", + "@context": { + "@protected": true, + "product": { + "@id": "untp:product", + "@type": "@id" + }, + "quantity": { + "@protected": true, + "@id": "untp:quantity", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "disposition": { + "@id": "untp:disposition", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ProductStatus#" + } + } + } + }, + "modifiedAtFacility": { + "@id": "untp:modifiedAtFacility", + "@type": "@id" + } + } + }, + "RegisteredIdentity": { + "@protected": true, + "@id": "untp:RegisteredIdentity", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/RegisteredIdentity#", + "registeredName": { + "@id": "untp:registeredName", + "@type": "xsd:string" + }, + "registeredId": { + "@id": "untp:registeredId", + "@type": "xsd:string" + }, + "registeredDate": { + "@id": "untp:registeredDate", + "@type": "xsd:date" + }, + "publicInformation": { + "@id": "untp:publicInformation", + "@type": "xsd:anyURI" + }, + "idScheme": { + "@id": "untp:idScheme", + "@type": "@id", + "@context": { + "@protected": true, + "id": { + "@id": "untp:id", + "@type": "xsd:anyURI" + }, + "name": { + "@id": "schema:name" + } + } + }, + "registrar": { + "@id": "untp:registrar", + "@type": "@id" + }, + "registerType": { + "@id": "untp:registerType", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/RegistryType#" + } + }, + "registrationScope": { + "@id": "untp:registrationScope", + "@type": "xsd:anyURI" + } + } + } + } +} diff --git a/src/dppvalidator/vocabularies/data/untp-metrics.jsonld b/src/dppvalidator/vocabularies/data/untp-metrics.jsonld new file mode 100644 index 0000000..59dbd80 --- /dev/null +++ b/src/dppvalidator/vocabularies/data/untp-metrics.jsonld @@ -0,0 +1,1146 @@ +{ + "@context": { + "skos": "http://www.w3.org/2004/02/skos/core#", + "dcterms": "http://purl.org/dc/terms/", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "owl": "http://www.w3.org/2002/07/owl#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "untp": "https://vocabulary.uncefact.org/untp/", + "metrics": "https://vocabulary.uncefact.org/performance-metrics/", + "rec20": "https://vocabulary.uncefact.org/rec20/", + "prefLabel": { "@id": "skos:prefLabel", "@language": "en" }, + "definition": { "@id": "skos:definition", "@language": "en" }, + "notation": "skos:notation", + "scopeNote": { "@id": "skos:scopeNote", "@language": "en" }, + "broader": { "@id": "skos:broader", "@type": "@id" }, + "narrower": { "@id": "skos:narrower", "@type": "@id", "@container": "@set" }, + "topConceptOf": { "@id": "skos:topConceptOf", "@type": "@id" }, + "hasTopConcept": { "@id": "skos:hasTopConcept", "@type": "@id", "@container": "@set" }, + "inScheme": { "@id": "skos:inScheme", "@type": "@id" }, + "closeMatch": { "@id": "skos:closeMatch", "@type": "@id", "@container": "@set" }, + "allowedUnit": "untp:allowedUnit", + "aggregationMethod": "untp:aggregationMethod", + "improvementDirection": "untp:improvementDirection" + }, + "@graph": [ + { + "@id": "https://vocabulary.uncefact.org/performance-metrics/", + "@type": "skos:ConceptScheme", + "dcterms:title": { "@value": "UNTP Performance Metrics Vocabulary", "@language": "en" }, + "dcterms:description": { "@value": "A hierarchical vocabulary of standardised performance metrics for tagging fine-grained product and facility-level sustainability claims. Enables automatic roll-up to enterprise-level disclosures aligned with IFRS S1/S2, GRI, ESRS, and EU Battery Regulation. Counterpart to the UNTP Conformity Topic Classification — topics classify what is being assessed, metrics define what is measured.", "@language": "en" }, + "dcterms:creator": "United Nations Economic Commission for Europe (UNECE)", + "dcterms:license": "https://creativecommons.org/licenses/by/4.0/", + "owl:versionInfo": "0.1.0-working", + "dcterms:issued": "2026-03-13", + "dcterms:modified": "2026-03-13", + "hasTopConcept": [ + "metrics:greenhouse-gas-emissions", + "metrics:energy", + "metrics:water", + "metrics:waste-and-circularity", + "metrics:biodiversity-and-land-use", + "metrics:pollution", + "metrics:workforce", + "metrics:governance", + "metrics:product-safety-and-quality", + "metrics:food-safety-and-quality" + ] + }, + + { + "@id": "metrics:greenhouse-gas-emissions", + "@type": "skos:Concept", + "prefLabel": "Greenhouse Gas Emissions", + "definition": "Metrics for measuring, reporting, and reducing greenhouse gas emissions across all scopes, including absolute values, intensities, and reduction progress.", + "notation": "01", + "topConceptOf": "https://vocabulary.uncefact.org/performance-metrics/", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IFRS S2 paras 29–36; ESRS E1; GRI 305; GHG Protocol Corporate Standard.", + "narrower": [ + "metrics:scope-1-ghg-emissions", + "metrics:scope-2-ghg-emissions", + "metrics:scope-3-upstream-emissions", + "metrics:scope-3-downstream-emissions", + "metrics:total-ghg-emissions", + "metrics:ghg-emissions-intensity", + "metrics:product-carbon-footprint", + "metrics:biogenic-emissions", + "metrics:ghg-reduction-progress" + ] + }, + { + "@id": "metrics:scope-1-ghg-emissions", + "@type": "skos:Concept", + "prefLabel": "Scope 1 GHG Emissions", + "definition": "Absolute GHG emissions from sources owned or controlled by the reporting entity, in tonnes CO2 equivalent.", + "notation": "01.01", + "broader": "metrics:greenhouse-gas-emissions", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IFRS S2 para 29(a); ESRS E1-6; GRI 305-1; GHG Protocol Scope 1.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:scope-2-ghg-emissions", + "@type": "skos:Concept", + "prefLabel": "Scope 2 GHG Emissions", + "definition": "Indirect GHG emissions from purchased electricity, steam, heating, and cooling consumed by the reporting entity, in tonnes CO2 equivalent.", + "notation": "01.02", + "broader": "metrics:greenhouse-gas-emissions", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IFRS S2 para 29(a); ESRS E1-6; GRI 305-2; GHG Protocol Scope 2.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:scope-3-upstream-emissions", + "@type": "skos:Concept", + "prefLabel": "Scope 3 Upstream Emissions", + "definition": "Indirect GHG emissions occurring in the upstream value chain including purchased goods, transportation, and business travel, in tonnes CO2 equivalent.", + "notation": "01.03", + "broader": "metrics:greenhouse-gas-emissions", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IFRS S2 para 29(a); ESRS E1-6; GRI 305-3; GHG Protocol Scope 3 categories 1–8.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:scope-3-downstream-emissions", + "@type": "skos:Concept", + "prefLabel": "Scope 3 Downstream Emissions", + "definition": "Indirect GHG emissions occurring in the downstream value chain including product use, end-of-life treatment, and distribution, in tonnes CO2 equivalent.", + "notation": "01.04", + "broader": "metrics:greenhouse-gas-emissions", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IFRS S2 para 29(a); ESRS E1-6; GRI 305-3; GHG Protocol Scope 3 categories 9–15.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:total-ghg-emissions", + "@type": "skos:Concept", + "prefLabel": "Total GHG Emissions", + "definition": "Sum of Scope 1, Scope 2, and Scope 3 greenhouse gas emissions, in tonnes CO2 equivalent.", + "notation": "01.05", + "broader": "metrics:greenhouse-gas-emissions", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IFRS S2 para 29; ESRS E1-6; GRI 305.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:ghg-emissions-intensity", + "@type": "skos:Concept", + "prefLabel": "GHG Emissions Intensity", + "definition": "Greenhouse gas emissions per unit of economic output or physical activity, expressed as kg CO2e per unit of measure.", + "notation": "01.06", + "broader": "metrics:greenhouse-gas-emissions", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IFRS S2 para 29(b); ESRS E1-6; GRI 305-4.", + "allowedUnit": "KGM", + "aggregationMethod": "weighted-average", + "improvementDirection": "lower" + }, + { + "@id": "metrics:product-carbon-footprint", + "@type": "skos:Concept", + "prefLabel": "Product Carbon Footprint", + "definition": "Total lifecycle greenhouse gas emissions attributable to a single product unit, from raw material extraction through end-of-life, in kg CO2 equivalent.", + "notation": "01.07", + "broader": "metrics:greenhouse-gas-emissions", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ISO 14067; EU PEF method; EU Battery Regulation Art. 7.", + "allowedUnit": "KGM", + "aggregationMethod": "weighted-average", + "improvementDirection": "lower" + }, + { + "@id": "metrics:biogenic-emissions", + "@type": "skos:Concept", + "prefLabel": "Biogenic Emissions", + "definition": "CO2 emissions from the combustion or biodegradation of biomass, reported separately from fossil-fuel emissions, in tonnes CO2.", + "notation": "01.08", + "broader": "metrics:greenhouse-gas-emissions", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "GHG Protocol Land Sector and Removals Guidance; GRI 305-1.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:ghg-reduction-progress", + "@type": "skos:Concept", + "prefLabel": "GHG Reduction Target Progress", + "definition": "Percentage of committed GHG reduction target achieved, measured against a declared baseline year.", + "notation": "01.09", + "broader": "metrics:greenhouse-gas-emissions", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IFRS S2 para 33; ESRS E1-4; SBTi Target Validation Protocol.", + "allowedUnit": "P1", + "aggregationMethod": "latest", + "improvementDirection": "higher" + }, + + { + "@id": "metrics:energy", + "@type": "skos:Concept", + "prefLabel": "Energy", + "definition": "Metrics for measuring energy consumption, renewable energy share, and energy efficiency across operations and supply chains.", + "notation": "02", + "topConceptOf": "https://vocabulary.uncefact.org/performance-metrics/", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IFRS S2 para 29; ESRS E1; GRI 302; EU Energy Efficiency Directive.", + "narrower": [ + "metrics:total-energy-consumption", + "metrics:renewable-energy-percentage", + "metrics:energy-intensity", + "metrics:onsite-renewable-generation", + "metrics:non-renewable-energy-consumption" + ] + }, + { + "@id": "metrics:total-energy-consumption", + "@type": "skos:Concept", + "prefLabel": "Total Energy Consumption", + "definition": "Total energy consumed from all sources including fuel, electricity, heating, cooling, and steam, in megawatt hours.", + "notation": "02.01", + "broader": "metrics:energy", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E1-5; GRI 302-1; ISO 50001.", + "allowedUnit": "MWH", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:renewable-energy-percentage", + "@type": "skos:Concept", + "prefLabel": "Renewable Energy Percentage", + "definition": "Share of total energy consumption sourced from renewable sources such as solar, wind, hydro, and geothermal.", + "notation": "02.02", + "broader": "metrics:energy", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E1-5; GRI 302-1; RE100 reporting.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:energy-intensity", + "@type": "skos:Concept", + "prefLabel": "Energy Intensity", + "definition": "Energy consumed per unit of economic output or physical activity, expressed as MWh per unit of measure.", + "notation": "02.03", + "broader": "metrics:energy", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E1-5; GRI 302-3.", + "allowedUnit": "MWH", + "aggregationMethod": "weighted-average", + "improvementDirection": "lower" + }, + { + "@id": "metrics:onsite-renewable-generation", + "@type": "skos:Concept", + "prefLabel": "On-site Renewable Generation", + "definition": "Total renewable energy generated on-site from owned or controlled installations, in megawatt hours.", + "notation": "02.04", + "broader": "metrics:energy", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "GRI 302-1; RE100 reporting methodology.", + "allowedUnit": "MWH", + "aggregationMethod": "sum", + "improvementDirection": "higher" + }, + { + "@id": "metrics:non-renewable-energy-consumption", + "@type": "skos:Concept", + "prefLabel": "Non-Renewable Energy Consumption", + "definition": "Energy consumed from non-renewable sources including fossil fuels and nuclear, in megawatt hours.", + "notation": "02.05", + "broader": "metrics:energy", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E1-5; GRI 302-1.", + "allowedUnit": "MWH", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + + { + "@id": "metrics:water", + "@type": "skos:Concept", + "prefLabel": "Water", + "definition": "Metrics for measuring water withdrawal, consumption, discharge, recycling, and usage intensity across operations.", + "notation": "03", + "topConceptOf": "https://vocabulary.uncefact.org/performance-metrics/", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E3; GRI 303; CEO Water Mandate; Alliance for Water Stewardship.", + "narrower": [ + "metrics:total-water-withdrawal", + "metrics:water-consumption", + "metrics:water-recycling-rate", + "metrics:water-discharge", + "metrics:water-intensity", + "metrics:water-stress-area-withdrawal" + ] + }, + { + "@id": "metrics:total-water-withdrawal", + "@type": "skos:Concept", + "prefLabel": "Total Water Withdrawal", + "definition": "Total volume of water drawn from surface, ground, sea, produced, or third-party sources, in cubic metres.", + "notation": "03.01", + "broader": "metrics:water", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E3-4; GRI 303-3.", + "allowedUnit": "MTQ", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:water-consumption", + "@type": "skos:Concept", + "prefLabel": "Water Consumption", + "definition": "Volume of water withdrawn that is not returned to the original source, representing net water removed from the environment, in cubic metres.", + "notation": "03.02", + "broader": "metrics:water", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E3-4; GRI 303-5.", + "allowedUnit": "MTQ", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:water-recycling-rate", + "@type": "skos:Concept", + "prefLabel": "Water Recycling Rate", + "definition": "Percentage of total water use that is recycled or reused within operations.", + "notation": "03.03", + "broader": "metrics:water", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "GRI 303-3; CEO Water Mandate.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:water-discharge", + "@type": "skos:Concept", + "prefLabel": "Water Discharge", + "definition": "Total volume of effluent water discharged to surface water, groundwater, or third-party treatment, in cubic metres.", + "notation": "03.04", + "broader": "metrics:water", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E3-4; GRI 303-4.", + "allowedUnit": "MTQ", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:water-intensity", + "@type": "skos:Concept", + "prefLabel": "Water Intensity", + "definition": "Water consumed per unit of economic output or physical activity, expressed as litres per unit of measure.", + "notation": "03.05", + "broader": "metrics:water", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E3-4; GRI 303-5.", + "allowedUnit": "LTR", + "aggregationMethod": "weighted-average", + "improvementDirection": "lower" + }, + { + "@id": "metrics:water-stress-area-withdrawal", + "@type": "skos:Concept", + "prefLabel": "Water Stress Area Withdrawal", + "definition": "Volume of water withdrawn from areas classified as high or extremely-high baseline water stress, in cubic metres.", + "notation": "03.06", + "broader": "metrics:water", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E3-4; GRI 303-3; WRI Aqueduct water stress classifications.", + "allowedUnit": "MTQ", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + + { + "@id": "metrics:waste-and-circularity", + "@type": "skos:Concept", + "prefLabel": "Waste and Circularity", + "definition": "Metrics for measuring waste generation, diversion, recycled content, recyclability, and circular economy performance.", + "notation": "04", + "topConceptOf": "https://vocabulary.uncefact.org/performance-metrics/", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E5; GRI 306; EU ESPR Art. 5–8; EU Waste Framework Directive.", + "narrower": [ + "metrics:total-waste-generated", + "metrics:hazardous-waste-generated", + "metrics:waste-diversion-rate", + "metrics:recycled-content-percentage", + "metrics:recyclability-rate", + "metrics:material-recovery-rate", + "metrics:waste-to-landfill", + "metrics:product-durability-index", + "metrics:reuse-remanufacturing-rate" + ] + }, + { + "@id": "metrics:total-waste-generated", + "@type": "skos:Concept", + "prefLabel": "Total Waste Generated", + "definition": "Total weight of hazardous and non-hazardous waste generated by operations, in tonnes.", + "notation": "04.01", + "broader": "metrics:waste-and-circularity", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E5-5; GRI 306-3.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:hazardous-waste-generated", + "@type": "skos:Concept", + "prefLabel": "Hazardous Waste Generated", + "definition": "Total weight of waste classified as hazardous under applicable regulations, in tonnes.", + "notation": "04.02", + "broader": "metrics:waste-and-circularity", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E5-5; GRI 306-3; Basel Convention.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:waste-diversion-rate", + "@type": "skos:Concept", + "prefLabel": "Waste Diversion Rate", + "definition": "Percentage of total waste diverted from landfill and incineration through recycling, composting, or other recovery methods.", + "notation": "04.03", + "broader": "metrics:waste-and-circularity", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E5-5; GRI 306-4.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:recycled-content-percentage", + "@type": "skos:Concept", + "prefLabel": "Recycled Content Percentage", + "definition": "Share of pre-consumer and post-consumer recycled material in the total weight of a product or material input.", + "notation": "04.04", + "broader": "metrics:waste-and-circularity", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EU ESPR Art. 8; EU Battery Regulation Art. 8; ISO 14021; GRI 301-2.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:recyclability-rate", + "@type": "skos:Concept", + "prefLabel": "Recyclability Rate", + "definition": "Percentage of product weight that is technically recyclable at end of life under available infrastructure.", + "notation": "04.05", + "broader": "metrics:waste-and-circularity", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EU ESPR Art. 6; ISO 14021.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:material-recovery-rate", + "@type": "skos:Concept", + "prefLabel": "Material Recovery Rate", + "definition": "Percentage of end-of-life product mass actually recovered through recycling, remanufacturing, or refurbishment processes.", + "notation": "04.06", + "broader": "metrics:waste-and-circularity", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EU ESPR Art. 6; EU Waste Framework Directive Art. 11; GRI 306-4.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:waste-to-landfill", + "@type": "skos:Concept", + "prefLabel": "Waste to Landfill", + "definition": "Total weight of waste disposed via landfill, in tonnes.", + "notation": "04.07", + "broader": "metrics:waste-and-circularity", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E5-5; GRI 306-5.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:product-durability-index", + "@type": "skos:Concept", + "prefLabel": "Product Durability Index", + "definition": "Expected useful life of a product under normal conditions of use, expressed in years or cycles as applicable.", + "notation": "04.08", + "broader": "metrics:waste-and-circularity", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EU ESPR Art. 5 – Durability requirements; ESRS E5.", + "allowedUnit": "ANN", + "aggregationMethod": "average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:reuse-remanufacturing-rate", + "@type": "skos:Concept", + "prefLabel": "Reuse and Remanufacturing Rate", + "definition": "Percentage of product units or components returned to service through reuse, refurbishment, or remanufacturing.", + "notation": "04.09", + "broader": "metrics:waste-and-circularity", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EU ESPR Art. 6; EU Waste Framework Directive.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + + { + "@id": "metrics:biodiversity-and-land-use", + "@type": "skos:Concept", + "prefLabel": "Biodiversity and Land Use", + "definition": "Metrics for measuring deforestation-free sourcing, land-use change, biodiversity impact, and protection of sensitive areas.", + "notation": "05", + "topConceptOf": "https://vocabulary.uncefact.org/performance-metrics/", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E4; GRI 304; TNFD; EU Deforestation Regulation; Kunming-Montreal Global Biodiversity Framework.", + "narrower": [ + "metrics:deforestation-free-sourcing", + "metrics:land-use-change", + "metrics:biodiversity-impact-score", + "metrics:protected-area-impact" + ] + }, + { + "@id": "metrics:deforestation-free-sourcing", + "@type": "skos:Concept", + "prefLabel": "Deforestation-Free Sourcing", + "definition": "Percentage of raw material inputs verified as sourced without associated deforestation or forest degradation after a declared cut-off date.", + "notation": "05.01", + "broader": "metrics:biodiversity-and-land-use", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EU Deforestation Regulation (EUDR); ESRS E4; GRI 304.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:land-use-change", + "@type": "skos:Concept", + "prefLabel": "Land Use Change", + "definition": "Area of natural ecosystems converted to managed land for production or extraction activities, in hectares.", + "notation": "05.02", + "broader": "metrics:biodiversity-and-land-use", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E4; GRI 304-1; GHG Protocol Land Sector Guidance.", + "allowedUnit": "HAR", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:biodiversity-impact-score", + "@type": "skos:Concept", + "prefLabel": "Biodiversity Impact Score", + "definition": "Composite index quantifying the impact of operations on species diversity and ecosystem integrity, using a recognised assessment framework (e.g., STAR, BII).", + "notation": "05.03", + "broader": "metrics:biodiversity-and-land-use", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "TNFD LEAP approach; ESRS E4; SBTN biodiversity targets.", + "allowedUnit": "C62", + "aggregationMethod": "average", + "improvementDirection": "lower" + }, + { + "@id": "metrics:protected-area-impact", + "@type": "skos:Concept", + "prefLabel": "Protected Area Impact", + "definition": "Area of operations, sourcing, or infrastructure footprint located within or adjacent to legally protected or high-biodiversity-value areas, in hectares.", + "notation": "05.04", + "broader": "metrics:biodiversity-and-land-use", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E4; GRI 304-1; IUCN Protected Area categories.", + "allowedUnit": "HAR", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + + { + "@id": "metrics:pollution", + "@type": "skos:Concept", + "prefLabel": "Pollution", + "definition": "Metrics for measuring air pollutant emissions, hazardous substance releases, and chemical safety performance beyond GHG emissions.", + "notation": "06", + "topConceptOf": "https://vocabulary.uncefact.org/performance-metrics/", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E2; GRI 305 (non-GHG); EU Industrial Emissions Directive; Stockholm Convention; Montreal Protocol.", + "narrower": [ + "metrics:sox-emissions", + "metrics:nox-emissions", + "metrics:voc-emissions", + "metrics:particulate-matter-emissions", + "metrics:substances-of-concern", + "metrics:ozone-depleting-emissions" + ] + }, + { + "@id": "metrics:sox-emissions", + "@type": "skos:Concept", + "prefLabel": "SOx Emissions", + "definition": "Total mass of sulphur oxides released to air from stationary and mobile sources, in tonnes.", + "notation": "06.01", + "broader": "metrics:pollution", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E2-4; GRI 305-7; EU Industrial Emissions Directive.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:nox-emissions", + "@type": "skos:Concept", + "prefLabel": "NOx Emissions", + "definition": "Total mass of nitrogen oxides released to air from combustion and industrial processes, in tonnes.", + "notation": "06.02", + "broader": "metrics:pollution", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E2-4; GRI 305-7; EU Industrial Emissions Directive.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:voc-emissions", + "@type": "skos:Concept", + "prefLabel": "VOC Emissions", + "definition": "Total mass of volatile organic compounds released to air from solvents, coatings, and industrial processes, in tonnes.", + "notation": "06.03", + "broader": "metrics:pollution", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E2-4; GRI 305-7; EU Solvents Directive.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:particulate-matter-emissions", + "@type": "skos:Concept", + "prefLabel": "Particulate Matter Emissions", + "definition": "Total mass of fine particulate matter (PM2.5 and PM10) released to air from operations, in tonnes.", + "notation": "06.04", + "broader": "metrics:pollution", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E2-4; GRI 305-7; WHO Air Quality Guidelines.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:substances-of-concern", + "@type": "skos:Concept", + "prefLabel": "Substances of Concern", + "definition": "Total mass of substances of concern or substances of very high concern (SVHC) present in products or released during production, in kilograms.", + "notation": "06.05", + "broader": "metrics:pollution", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EU ESPR Annex I; REACH SVHC candidate list; ESRS E2.", + "allowedUnit": "KGM", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:ozone-depleting-emissions", + "@type": "skos:Concept", + "prefLabel": "Ozone-Depleting Substance Emissions", + "definition": "Total mass of ozone-depleting substances released, measured in kg CFC-11 equivalent.", + "notation": "06.06", + "broader": "metrics:pollution", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "GRI 305-6; Montreal Protocol; ESRS E2.", + "allowedUnit": "KGM", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + + { + "@id": "metrics:workforce", + "@type": "skos:Concept", + "prefLabel": "Workforce", + "definition": "Metrics for measuring labour practices, workplace safety, diversity, equity, and human rights performance across operations and supply chains.", + "notation": "07", + "topConceptOf": "https://vocabulary.uncefact.org/performance-metrics/", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1, S2; GRI 401–409; IFRS S1; ILO Core Conventions; UN Guiding Principles on Business and Human Rights.", + "narrower": [ + "metrics:living-wage-coverage", + "metrics:lost-time-injury-rate", + "metrics:gender-pay-gap", + "metrics:women-in-management", + "metrics:training-hours-per-employee", + "metrics:collective-bargaining-coverage", + "metrics:employee-turnover-rate", + "metrics:child-labor-incidents", + "metrics:forced-labor-incidents", + "metrics:workforce-diversity-ratio" + ] + }, + { + "@id": "metrics:living-wage-coverage", + "@type": "skos:Concept", + "prefLabel": "Living Wage Coverage", + "definition": "Percentage of workers (including contractor and supply-chain workers in scope) receiving at least a verified living wage.", + "notation": "07.01", + "broader": "metrics:workforce", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1-10; GRI 202-1; Global Living Wage Coalition methodology.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:lost-time-injury-rate", + "@type": "skos:Concept", + "prefLabel": "Lost Time Injury Frequency Rate", + "definition": "Number of lost-time injuries per one million hours worked, measuring workplace safety performance.", + "notation": "07.02", + "broader": "metrics:workforce", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1-14; GRI 403-9; ISO 45001.", + "allowedUnit": "C62", + "aggregationMethod": "average", + "improvementDirection": "lower" + }, + { + "@id": "metrics:gender-pay-gap", + "@type": "skos:Concept", + "prefLabel": "Gender Pay Gap", + "definition": "Difference in average compensation between male and female employees as a percentage of male average compensation.", + "notation": "07.03", + "broader": "metrics:workforce", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1-16; GRI 405-2; EU Pay Transparency Directive.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "lower" + }, + { + "@id": "metrics:women-in-management", + "@type": "skos:Concept", + "prefLabel": "Women in Management", + "definition": "Percentage of management and leadership positions held by women.", + "notation": "07.04", + "broader": "metrics:workforce", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1-9; GRI 405-1.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:training-hours-per-employee", + "@type": "skos:Concept", + "prefLabel": "Training Hours per Employee", + "definition": "Average number of hours of training and professional development provided per employee per year.", + "notation": "07.05", + "broader": "metrics:workforce", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1-13; GRI 404-1.", + "allowedUnit": "HUR", + "aggregationMethod": "average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:collective-bargaining-coverage", + "@type": "skos:Concept", + "prefLabel": "Collective Bargaining Coverage", + "definition": "Percentage of employees covered by collective bargaining agreements.", + "notation": "07.06", + "broader": "metrics:workforce", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1-8; GRI 407-1; ILO Convention 98.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:employee-turnover-rate", + "@type": "skos:Concept", + "prefLabel": "Employee Turnover Rate", + "definition": "Percentage of employees who leave the organisation voluntarily or involuntarily during the reporting period.", + "notation": "07.07", + "broader": "metrics:workforce", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1-6; GRI 401-1.", + "allowedUnit": "P1", + "aggregationMethod": "average", + "improvementDirection": "lower" + }, + { + "@id": "metrics:child-labor-incidents", + "@type": "skos:Concept", + "prefLabel": "Child Labor Incidents", + "definition": "Number of confirmed incidents of child labor identified in own operations and supply chain during the reporting period.", + "notation": "07.08", + "broader": "metrics:workforce", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1, S2; GRI 408-1; ILO Conventions 138, 182.", + "allowedUnit": "C62", + "aggregationMethod": "count", + "improvementDirection": "lower" + }, + { + "@id": "metrics:forced-labor-incidents", + "@type": "skos:Concept", + "prefLabel": "Forced Labor Incidents", + "definition": "Number of confirmed incidents of forced, bonded, or compulsory labor identified in own operations and supply chain during the reporting period.", + "notation": "07.09", + "broader": "metrics:workforce", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1, S2; GRI 409-1; ILO Conventions 29, 105.", + "allowedUnit": "C62", + "aggregationMethod": "count", + "improvementDirection": "lower" + }, + { + "@id": "metrics:workforce-diversity-ratio", + "@type": "skos:Concept", + "prefLabel": "Workforce Diversity Ratio", + "definition": "Representation of under-represented groups in the workforce as a percentage of total headcount, covering gender, ethnicity, disability, and other protected characteristics.", + "notation": "07.10", + "broader": "metrics:workforce", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1-9; GRI 405-1.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + + { + "@id": "metrics:governance", + "@type": "skos:Concept", + "prefLabel": "Governance", + "definition": "Metrics for measuring anti-corruption practices, supply chain due diligence, ESG disclosure quality, and grievance mechanism effectiveness.", + "notation": "08", + "topConceptOf": "https://vocabulary.uncefact.org/performance-metrics/", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS G1; GRI 205, 308, 414; IFRS S1; OECD Guidelines Chapter VII.", + "narrower": [ + "metrics:anti-corruption-training-coverage", + "metrics:supplier-due-diligence-coverage", + "metrics:esg-disclosure-score", + "metrics:grievance-response-rate" + ] + }, + { + "@id": "metrics:anti-corruption-training-coverage", + "@type": "skos:Concept", + "prefLabel": "Anti-Corruption Training Coverage", + "definition": "Percentage of employees and governance body members who have received anti-corruption training during the reporting period.", + "notation": "08.01", + "broader": "metrics:governance", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS G1-4; GRI 205-2; OECD Anti-Bribery Convention.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:supplier-due-diligence-coverage", + "@type": "skos:Concept", + "prefLabel": "Supplier Due Diligence Coverage", + "definition": "Percentage of significant suppliers assessed against environmental and social due diligence criteria during the reporting period.", + "notation": "08.02", + "broader": "metrics:governance", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS G1-5; GRI 308-1, 414-1; EU CSDDD.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:esg-disclosure-score", + "@type": "skos:Concept", + "prefLabel": "ESG Disclosure Score", + "definition": "Composite score measuring the completeness, accuracy, and timeliness of environmental, social, and governance public disclosures.", + "notation": "08.03", + "broader": "metrics:governance", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IFRS S1; ESRS 1; CDP Disclosure Scoring Methodology.", + "allowedUnit": "P1", + "aggregationMethod": "latest", + "improvementDirection": "higher" + }, + { + "@id": "metrics:grievance-response-rate", + "@type": "skos:Concept", + "prefLabel": "Grievance Response Rate", + "definition": "Percentage of grievances received through formal mechanisms that were acknowledged and addressed within the defined response timeframe.", + "notation": "08.04", + "broader": "metrics:governance", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1-17, S2-11; GRI 2-25, 2-26; UN Guiding Principles Principle 31.", + "allowedUnit": "P1", + "aggregationMethod": "average", + "improvementDirection": "higher" + }, + + { + "@id": "metrics:product-safety-and-quality", + "@type": "skos:Concept", + "prefLabel": "Product Safety and Quality", + "definition": "Metrics for measuring physical, mechanical, thermal, electrical, chemical, and fire safety properties of products and materials against applicable safety standards and performance requirements.", + "notation": "09", + "topConceptOf": "https://vocabulary.uncefact.org/performance-metrics/", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EU General Product Safety Regulation (EU) 2023/988; ICC International Building Code; ISO/IEC product safety standards; EU Construction Products Regulation.", + "narrower": [ + "metrics:mechanical-strength", + "metrics:impact-resistance", + "metrics:thermal-performance", + "metrics:fire-resistance-rating", + "metrics:electrical-safety-rating", + "metrics:flammability-rating", + "metrics:chemical-substance-concentration", + "metrics:noise-emission-level" + ] + }, + { + "@id": "metrics:mechanical-strength", + "@type": "skos:Concept", + "prefLabel": "Mechanical Strength", + "definition": "Tensile, compressive, or flexural strength of a material or product under specified test conditions, in megapascals.", + "notation": "09.01", + "broader": "metrics:product-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ISO 527 (tensile, plastics); ISO 6892 (tensile, metals); ASTM C39 (compressive, concrete); ICC IBC structural requirements.", + "allowedUnit": "MPA", + "aggregationMethod": "minimum", + "improvementDirection": "higher" + }, + { + "@id": "metrics:impact-resistance", + "@type": "skos:Concept", + "prefLabel": "Impact Resistance", + "definition": "Energy absorbed by a material or product before fracture under impact loading, in joules.", + "notation": "09.02", + "broader": "metrics:product-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ISO 179 (Charpy impact, plastics); ISO 148 (Charpy impact, metals); IEC 62262 (IK rating, equipment enclosures).", + "allowedUnit": "JOU", + "aggregationMethod": "minimum", + "improvementDirection": "higher" + }, + { + "@id": "metrics:thermal-performance", + "@type": "skos:Concept", + "prefLabel": "Thermal Performance", + "definition": "Thermal resistance (R-value) or thermal conductivity of a material or assembly, indicating its ability to insulate against heat transfer.", + "notation": "09.03", + "broader": "metrics:product-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ICC International Energy Conservation Code (IECC); ISO 22007; ASTM C518; EU Energy Performance of Buildings Directive.", + "allowedUnit": "C62", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:fire-resistance-rating", + "@type": "skos:Concept", + "prefLabel": "Fire Resistance Rating", + "definition": "Duration a material or assembly maintains structural integrity, insulation, and limits heat transfer under standard fire exposure conditions, in minutes.", + "notation": "09.04", + "broader": "metrics:product-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ICC IBC Chapter 7; ASTM E119; ISO 834; EU Construction Products Regulation (EN 13501).", + "allowedUnit": "MIN", + "aggregationMethod": "minimum", + "improvementDirection": "higher" + }, + { + "@id": "metrics:electrical-safety-rating", + "@type": "skos:Concept", + "prefLabel": "Electrical Safety Rating", + "definition": "Composite test result or classification for electrical insulation, shock protection, and fault tolerance under applicable safety standards.", + "notation": "09.05", + "broader": "metrics:product-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IEC 60335 (household appliances); IEC 60601 (medical devices); IEC 62368 (AV/IT equipment); UL product safety standards.", + "allowedUnit": "C62", + "aggregationMethod": "minimum", + "improvementDirection": "higher" + }, + { + "@id": "metrics:flammability-rating", + "@type": "skos:Concept", + "prefLabel": "Flammability Rating", + "definition": "Classification of a material's reaction to fire, covering ignitability, flame spread, heat release, and smoke generation under standard test conditions.", + "notation": "09.06", + "broader": "metrics:product-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EN 13501 (EU Euroclasses); UL 94 (plastics); ASTM E84 (surface burning); EU GPSR flammability requirements.", + "allowedUnit": "C62", + "aggregationMethod": "minimum", + "improvementDirection": "higher" + }, + { + "@id": "metrics:chemical-substance-concentration", + "@type": "skos:Concept", + "prefLabel": "Chemical Substance Concentration", + "definition": "Concentration of a specified regulated or restricted substance present in a product, in milligrams per kilogram.", + "notation": "09.07", + "broader": "metrics:product-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EU REACH (SVHCs); EU RoHS Directive; EU ESPR Annex I; EU GPSR chemical safety requirements.", + "allowedUnit": "MK", + "aggregationMethod": "maximum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:noise-emission-level", + "@type": "skos:Concept", + "prefLabel": "Noise Emission Level", + "definition": "Sound power or sound pressure level emitted by a product during normal operation, in decibels.", + "notation": "09.08", + "broader": "metrics:product-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EU Outdoor Noise Directive 2000/14/EC; ISO 3744; IEC 60704 (household appliances); EU Energy Labelling Regulation.", + "allowedUnit": "C62", + "aggregationMethod": "maximum", + "improvementDirection": "lower" + }, + + { + "@id": "metrics:food-safety-and-quality", + "@type": "skos:Concept", + "prefLabel": "Food Safety and Quality", + "definition": "Metrics for measuring microbiological safety, chemical contaminant levels, pesticide and veterinary drug residues, food additive levels, nutritional content, and allergen presence in food products.", + "notation": "10", + "topConceptOf": "https://vocabulary.uncefact.org/performance-metrics/", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "Codex Alimentarius (FAO/WHO); EU General Food Law Regulation (EC) 178/2002; EU food safety regulations; ISO 22000.", + "narrower": [ + "metrics:microbiological-count", + "metrics:chemical-contaminant-level", + "metrics:pesticide-residue-level", + "metrics:veterinary-drug-residue-level", + "metrics:food-additive-level", + "metrics:nutritional-content", + "metrics:allergen-presence", + "metrics:shelf-life-duration" + ] + }, + { + "@id": "metrics:microbiological-count", + "@type": "skos:Concept", + "prefLabel": "Microbiological Count", + "definition": "Colony-forming units of a specified microorganism per unit of food, measuring microbiological safety and hygiene performance.", + "notation": "10.01", + "broader": "metrics:food-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "Codex CAC/GL 21; EU Regulation (EC) 2073/2005 on microbiological criteria for foodstuffs; ISO 4833.", + "allowedUnit": "C62", + "aggregationMethod": "maximum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:chemical-contaminant-level", + "@type": "skos:Concept", + "prefLabel": "Chemical Contaminant Level", + "definition": "Concentration of a specified chemical contaminant (heavy metals, mycotoxins, dioxins, etc.) in food, in milligrams per kilogram.", + "notation": "10.02", + "broader": "metrics:food-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "Codex CXS 193 (General Standard for Contaminants and Toxins); EU Regulation (EC) 1881/2006.", + "allowedUnit": "MK", + "aggregationMethod": "maximum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:pesticide-residue-level", + "@type": "skos:Concept", + "prefLabel": "Pesticide Residue Level", + "definition": "Concentration of a specified pesticide residue in food, measured against the applicable maximum residue limit, in milligrams per kilogram.", + "notation": "10.03", + "broader": "metrics:food-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "Codex Maximum Residue Limits for Pesticides (CX/MRL); EU Regulation (EC) 396/2005.", + "allowedUnit": "MK", + "aggregationMethod": "maximum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:veterinary-drug-residue-level", + "@type": "skos:Concept", + "prefLabel": "Veterinary Drug Residue Level", + "definition": "Concentration of a specified veterinary drug residue in animal-derived food, measured against the applicable maximum residue limit, in micrograms per kilogram.", + "notation": "10.04", + "broader": "metrics:food-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "Codex Maximum Residue Limits for Veterinary Drugs (CX/MRL); EU Regulation (EU) 37/2010.", + "allowedUnit": "MK", + "aggregationMethod": "maximum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:food-additive-level", + "@type": "skos:Concept", + "prefLabel": "Food Additive Level", + "definition": "Concentration of a specified food additive in the final product, measured against the applicable maximum permitted level, in milligrams per kilogram.", + "notation": "10.05", + "broader": "metrics:food-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "Codex General Standard for Food Additives (CXS 192); EU Regulation (EC) 1333/2008.", + "allowedUnit": "MK", + "aggregationMethod": "maximum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:nutritional-content", + "@type": "skos:Concept", + "prefLabel": "Nutritional Content", + "definition": "Amount of a specified nutrient (energy, protein, fat, carbohydrate, sugar, sodium, fibre, vitamins, minerals) per standard serving or per 100 grams of food.", + "notation": "10.06", + "broader": "metrics:food-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "Codex CXS 1-1985 (General Standard for Labelling); Codex CXG 2-1985 (Nutrition Labelling Guidelines); EU Regulation (EU) 1169/2011.", + "allowedUnit": "GRM", + "aggregationMethod": "average", + "improvementDirection": "context-dependent" + }, + { + "@id": "metrics:allergen-presence", + "@type": "skos:Concept", + "prefLabel": "Allergen Presence", + "definition": "Declared presence or measured concentration of a specified allergen in a food product, supporting consumer safety and regulatory labelling requirements.", + "notation": "10.07", + "broader": "metrics:food-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "Codex CXS 1-1985 (allergen labelling); EU Regulation (EU) 1169/2011 Annex II; Codex CXA 4-1989 (allergen classification).", + "allowedUnit": "MK", + "aggregationMethod": "maximum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:shelf-life-duration", + "@type": "skos:Concept", + "prefLabel": "Shelf Life Duration", + "definition": "Expected period during which a food product maintains safety and quality under stated storage conditions, in days.", + "notation": "10.08", + "broader": "metrics:food-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "Codex General Principles of Food Hygiene (CXC 1-1969); EU Regulation (EU) 1169/2011 (date marking); ISO 22000.", + "allowedUnit": "DAY", + "aggregationMethod": "minimum", + "improvementDirection": "higher" + } + ] +} diff --git a/src/dppvalidator/vocabularies/data/untp-ontology.jsonld b/src/dppvalidator/vocabularies/data/untp-ontology.jsonld new file mode 100644 index 0000000..d0054f6 --- /dev/null +++ b/src/dppvalidator/vocabularies/data/untp-ontology.jsonld @@ -0,0 +1,5046 @@ +{ + "@context": { + "untp": "https://vocabulary.uncefact.org/untp/", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "owl": "http://www.w3.org/2002/07/owl#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "schema": "https://schema.org/", + "dcterms": "http://purl.org/dc/terms/", + "vann": "http://purl.org/vocab/vann/", + "foaf": "http://xmlns.com/foaf/0.1/" + }, + "@graph": [ + { + "@id": "https://vocabulary.uncefact.org/untp/", + "@type": "owl:Ontology", + "vann:preferredNamespacePrefix": "untp", + "vann:preferredNamespaceUri": "https://vocabulary.uncefact.org/untp/", + "dcterms:title": "UNTP Core Vocabulary", + "dcterms:description": "Core classes and properties for the UNTP data model (JSON-LD/RDF).", + "owl:versionInfo": "working" + }, + { + "@id": "untp:credentialSubjectType", + "@type": "rdf:Property", + "rdfs:comment": "The expected type of the credentialSubject for this credential class. Used to connect UNTP credential types to the UNTP domain classes that populate the W3C VCDM credentialSubject property, without redefining the W3C property itself.", + "rdfs:label": "credentialSubjectType", + "schema:domainIncludes": [ + { + "@id": "untp:DigitalProductPassport" + }, + { + "@id": "untp:DigitalFacilityRecord" + }, + { + "@id": "untp:DigitalConformityCredential" + }, + { + "@id": "untp:DigitalTraceabilityEvent" + }, + { + "@id": "untp:DigitalIdentityAnchor" + } + ], + "schema:rangeIncludes": { + "@id": "rdfs:Class" + } + }, + { + "@id": "untp:extendsModel", + "@type": "rdf:Property", + "rdfs:comment": "Indicates that this UNTP class reuses and extends a class defined in an external vocabulary (e.g. W3C VCDM, schema.org). The external class defines the envelope or base properties; UNTP defines only the extensions. This annotation enables human-readable renderings to display or link to the inherited properties without redefining them.", + "rdfs:label": "extendsModel", + "schema:domainIncludes": [ + { + "@id": "untp:VerifiableCredential" + }, + { + "@id": "untp:Address" + } + ], + "schema:rangeIncludes": { + "@id": "rdfs:Class" + } + }, + { + "@id": "untp:DigitalProductPassport", + "@type": "rdfs:Class", + "rdfs:comment": "A digital Product Passport (DPP) credential.", + "rdfs:label": "DigitalProductPassport", + "rdfs:subClassOf": "untp:VerifiableCredential", + "untp:credentialSubjectType": { + "@id": "untp:Product" + } + }, + { + "@id": "untp:VerifiableCredential", + "@type": "rdfs:Class", + "rdfs:comment": "A verifiable credential is a digital and verifiable version of everyday credentials such as certificates and licenses. It conforms to the W3C Verifiable Credentials Data Model v2.0 (VCDM).", + "rdfs:label": "VerifiableCredential", + "untp:extendsModel": { + "@id": "https://www.w3.org/2018/credentials#VerifiableCredential" + } + }, + { + "@id": "untp:DigitalFacilityRecord", + "@type": "rdfs:Class", + "rdfs:comment": "A digital Facility Record (DFR) credential.", + "rdfs:label": "DigitalFacilityRecord", + "rdfs:subClassOf": "untp:VerifiableCredential", + "untp:credentialSubjectType": { + "@id": "untp:Facility" + } + }, + { + "@id": "untp:CredentialIssuer", + "@type": "rdfs:Class", + "rdfs:comment": "The issuer party (person or organisation) of a verifiable credential.", + "rdfs:label": "CredentialIssuer" + }, + { + "@id": "untp:IssuingSoftware", + "@type": "rdfs:Class", + "rdfs:comment": "Optional metadata identifying the software product (and its vendor) that issued the parent credential. Used for vendor traceability and conformity testing.", + "rdfs:label": "IssuingSoftware" + }, + { + "@id": "untp:SoftwareVendor", + "@type": "rdfs:Class", + "rdfs:comment": "The vendor of a software product that issued a UNTP credential.", + "rdfs:label": "SoftwareVendor" + }, + { + "@id": "untp:Party", + "@type": "rdfs:Class", + "rdfs:comment": "An organisation. May be a supply chain actor, a certifier, a government agency.", + "rdfs:label": "Party" + }, + { + "@id": "untp:Entity", + "@type": "rdfs:Class", + "rdfs:comment": "A uniquely identified entity", + "rdfs:label": "Entity" + }, + { + "@id": "untp:IdentifierScheme", + "@type": "rdfs:Class", + "rdfs:comment": "An identifier registration scheme for products, facilities, or organisations. Typically operated by a state, national or global authority.", + "rdfs:label": "IdentifierScheme" + }, + { + "@id": "untp:Country", + "@type": "rdfs:Class", + "rdfs:comment": "Country Code and Name from ISO 3166", + "rdfs:label": "Country" + }, + { + "@id": "untp:Address", + "@type": "rdfs:Class", + "rdfs:comment": "A postal address. Reuses streetAddress, postalCode, addressLocality, and addressRegion from schema.org PostalAddress. Extends with addressCountry (an ISO-3166 country code/name structure).", + "rdfs:label": "Address", + "untp:extendsModel": { + "@id": "schema:PostalAddress" + } + }, + { + "@id": "untp:Classification", + "@type": "rdfs:Class", + "rdfs:comment": "A classification scheme and code / name representing a category value for a product, entity, or facility.", + "rdfs:label": "Classification" + }, + { + "@id": "untp:BitstringStatusListEntry", + "@type": "rdfs:Class", + "rdfs:comment": "A privacy-preserving, space-efficient, and high-performance mechanism for publishing status information such as suspension or revocation of Verifiable Credentials through use of bitstrings. See https://www.w3.org/TR/vc-bitstring-status-list/ for full details.", + "rdfs:label": "BitstringStatusListEntry" + }, + { + "@id": "untp:RenderTemplate2024", + "@type": "rdfs:Class", + "rdfs:comment": "A single template format focused render method where the content/media type decision becomes secondary (and is expressed separately).See https://github.com/w3c-ccg/vc-render-method/issues/9", + "rdfs:label": "RenderTemplate2024" + }, + { + "@id": "untp:Facility", + "@type": "rdfs:Class", + "rdfs:comment": "The physical site (eg farm or factory) where the product or materials was produced.", + "rdfs:label": "Facility" + }, + { + "@id": "untp:PartyRole", + "@type": "rdfs:Class", + "rdfs:comment": "A party with a defined relationship to the referencing entity", + "rdfs:label": "PartyRole" + }, + { + "@id": "untp:Link", + "@type": "rdfs:Class", + "rdfs:comment": "A structure to provide a URL link plus metadata associated with the link.", + "rdfs:label": "Link" + }, + { + "@id": "untp:Location", + "@type": "rdfs:Class", + "rdfs:comment": "Location information including address and geo-location of points, areas, and boundaries. At least one of plusCode, geoLocation, or geoBoundary are required.", + "rdfs:label": "Location" + }, + { + "@id": "untp:Coordinate", + "@type": "rdfs:Class", + "rdfs:comment": "A geographic point defined by latitude and longitude using the WGS84 geodetic coordinate reference system (EPSG:4326). Latitude and longitude are expressed in decimal degrees as floating-point numbers. Coordinates follow the conventional order (latitude, longitude) and represent a point on the Earth’s surface.", + "rdfs:label": "Coordinate" + }, + { + "@id": "untp:MaterialUsage", + "@type": "rdfs:Class", + "rdfs:comment": "A material usage record defining the consumption of materials for a given period, typically at an operating facility. Used to specify volumetric consumption and country of origin without specifying specific suppliers.", + "rdfs:label": "MaterialUsage" + }, + { + "@id": "untp:Period", + "@type": "rdfs:Class", + "rdfs:comment": "A period of time, typically a month, quarter or a year, which defines the context boundary for reported facts.", + "rdfs:label": "Period" + }, + { + "@id": "untp:Material", + "@type": "rdfs:Class", + "rdfs:comment": "The material class encapsulates details about the origin or source of raw materials in a product, including the country of origin and the mass fraction.", + "rdfs:label": "Material" + }, + { + "@id": "untp:Measure", + "@type": "rdfs:Class", + "rdfs:comment": "The measure class defines a numeric measured value (eg 10) and a coded unit of measure (eg KG). There is an optional upper and lower tolerance which can be used to specify uncertainty in the measure. ", + "rdfs:label": "Measure" + }, + { + "@id": "untp:Image", + "@type": "rdfs:Class", + "rdfs:comment": "A binary image encoded as base64 text and embedded into the data. Use this for small images like certification trust marks or regulated labels. Large images should be external links.", + "rdfs:label": "Image" + }, + { + "@id": "untp:Claim", + "@type": "rdfs:Class", + "rdfs:comment": "A performance claim about a product, facility, or organisation that is made against a well defined criterion.", + "rdfs:label": "Claim" + }, + { + "@id": "untp:Criterion", + "@type": "rdfs:Class", + "rdfs:comment": "A specific rule or criterion within a standard or regulation. eg a carbon intensity calculation rule within an emissions standard.", + "rdfs:label": "Criterion" + }, + { + "@id": "untp:ConformityTopic", + "@type": "rdfs:Class", + "rdfs:comment": "The UNTP standard classification scheme for conformity topic. see http://vocabulary.uncefact.org/ConformityTopic", + "rdfs:label": "ConformityTopic" + }, + { + "@id": "untp:Performance", + "@type": "rdfs:Class", + "rdfs:comment": "A claimed, assessed, or required performance level defined either by a scoring system or a numeric measure.", + "rdfs:label": "Performance" + }, + { + "@id": "untp:PerformanceMetric", + "@type": "rdfs:Class", + "rdfs:comment": "A standardised data point for performance reporting (eg product carbon footprint)", + "rdfs:label": "PerformanceMetric" + }, + { + "@id": "untp:Score", + "@type": "rdfs:Class", + "rdfs:comment": "A single score within a scoring framework. ", + "rdfs:label": "Score" + }, + { + "@id": "untp:Regulation", + "@type": "rdfs:Class", + "rdfs:comment": "A regulation (eg EU deforestation regulation) that defines the criteria for assessment.", + "rdfs:label": "Regulation" + }, + { + "@id": "untp:Standard", + "@type": "rdfs:Class", + "rdfs:comment": "A standard (eg ISO 14000) that specifies the criteria for conformance.", + "rdfs:label": "Standard" + }, + { + "@id": "untp:DigitalConformityCredential", + "@type": "rdfs:Class", + "rdfs:comment": "A Digital Conformity Credential (DCC) credential.", + "rdfs:label": "DigitalConformityCredential", + "rdfs:subClassOf": "untp:VerifiableCredential", + "untp:credentialSubjectType": { + "@id": "untp:ConformityAttestation" + } + }, + { + "@id": "untp:ConformityAttestation", + "@type": "rdfs:Class", + "rdfs:comment": "A conformity attestation issued by a competent body that defines one or more assessments (eg carbon intensity) about a product (eg battery) against a specification (eg LCA method) defined in a standard or regulation.", + "rdfs:label": "ConformityAttestation" + }, + { + "@id": "untp:Endorsement", + "@type": "rdfs:Class", + "rdfs:comment": "The authority under which a conformity claim is issued. For example a national accreditation authority may authorise a test lab to issue test certificates about a product against a standard. ", + "rdfs:label": "Endorsement" + }, + { + "@id": "untp:ConformityScheme", + "@type": "rdfs:Class", + "rdfs:comment": "A formal governance scheme under which an attestation is issued (eg ACRS structural steel certification) ", + "rdfs:label": "ConformityScheme" + }, + { + "@id": "untp:ScoringFramework", + "@type": "rdfs:Class", + "rdfs:comment": "A scoring framework used for performance level assessments against a criteria or scheme. For example forced labour performance might score A to D depending on the percentage of workforce subject to recruitment fees.", + "rdfs:label": "ScoringFramework" + }, + { + "@id": "untp:ConformityProfile", + "@type": "rdfs:Class", + "rdfs:comment": "A versioned conformity profile, managed under a scheme, which includes a specific list of versioned criteria. A conformity profile represents the precise scope of a conformity attestation. ", + "rdfs:label": "ConformityProfile" + }, + { + "@id": "untp:StandardAlignment", + "@type": "rdfs:Class", + "rdfs:comment": "A voluntary standard and an alignment level (exceeds, meets, partial).", + "rdfs:label": "StandardAlignment" + }, + { + "@id": "untp:RegulatoryAlignment", + "@type": "rdfs:Class", + "rdfs:comment": "A national regulation or international treaty and an alignment level (exceeds, meets, partial).", + "rdfs:label": "RegulatoryAlignment" + }, + { + "@id": "untp:ConformityAssessment", + "@type": "rdfs:Class", + "rdfs:comment": "A specific assessment about the product or facility against a specific specification. Eg the carbon intensity of a given product or batch.", + "rdfs:label": "ConformityAssessment" + }, + { + "@id": "untp:ProductVerification", + "@type": "rdfs:Class", + "rdfs:comment": "The product which is the subject of this conformity assessment", + "rdfs:label": "ProductVerification" + }, + { + "@id": "untp:Product", + "@type": "rdfs:Class", + "rdfs:comment": "The ProductInformation class encapsulates detailed information regarding a specific product, including its identification details, manufacturer, and other pertinent details.", + "rdfs:label": "Product" + }, + { + "@id": "untp:Characteristics", + "@type": "rdfs:Class", + "rdfs:comment": "A declaration of conformance with one or more criteria from a specific standard or regulation. ", + "rdfs:label": "Characteristics" + }, + { + "@id": "untp:Dimension", + "@type": "rdfs:Class", + "rdfs:comment": "Overall (length, width, height) dimensions and weight/volume of an item.", + "rdfs:label": "Dimension" + }, + { + "@id": "untp:Package", + "@type": "rdfs:Class", + "rdfs:comment": "Details of product packaging", + "rdfs:label": "Package" + }, + { + "@id": "untp:FacilityVerification", + "@type": "rdfs:Class", + "rdfs:comment": "The facility which is the subject of this conformity assessment", + "rdfs:label": "FacilityVerification" + }, + { + "@id": "untp:DigitalTraceabilityEvent", + "@type": "rdfs:Class", + "rdfs:comment": "A Digital Traceability Event (DTE) credential.", + "rdfs:label": "DigitalTraceabilityEvent", + "rdfs:subClassOf": "untp:VerifiableCredential", + "untp:credentialSubjectType": { + "@id": "untp:LifecycleEvent" + } + }, + { + "@id": "untp:LifecycleEvent", + "@type": "rdfs:Class", + "rdfs:comment": "This abstract event structure provides a common language to describe product lifecycle events such as shipments, inspections, manufacturing processes, etc.", + "rdfs:label": "LifecycleEvent" + }, + { + "@id": "untp:MakeEvent", + "@type": "rdfs:Class", + "rdfs:comment": "Transformation (manufacture/ production) of input products to output products at a given facility.", + "rdfs:label": "MakeEvent", + "rdfs:subClassOf": "untp:LifecycleEvent" + }, + { + "@id": "untp:SensorData", + "@type": "rdfs:Class", + "rdfs:comment": "A sensor data recording associated with this event", + "rdfs:label": "SensorData" + }, + { + "@id": "untp:EventProduct", + "@type": "rdfs:Class", + "rdfs:comment": "A quantity of products or materials involved in a lifecycle event.", + "rdfs:label": "EventProduct" + }, + { + "@id": "untp:MoveEvent", + "@type": "rdfs:Class", + "rdfs:comment": "Transfer (shipment) of products from one facility to another.", + "rdfs:label": "MoveEvent", + "rdfs:subClassOf": "untp:LifecycleEvent" + }, + { + "@id": "untp:ModifyEvent", + "@type": "rdfs:Class", + "rdfs:comment": "Intervention (eg repair) on a product without changing it's identity at a given facility.", + "rdfs:label": "ModifyEvent", + "rdfs:subClassOf": "untp:LifecycleEvent" + }, + { + "@id": "untp:DigitalIdentityAnchor", + "@type": "rdfs:Class", + "rdfs:comment": "The Digital Identity Anchor (DIA) is a very simple credential that is issued by a trusted authority and asserts an equivalence between a member identity as known to the authority (eg a VAT number) and one or more decentralised identifiers (DIDs) held by the member.", + "rdfs:label": "DigitalIdentityAnchor", + "rdfs:subClassOf": "untp:VerifiableCredential", + "untp:credentialSubjectType": { + "@id": "untp:RegisteredIdentity" + } + }, + { + "@id": "untp:RegisteredIdentity", + "@type": "rdfs:Class", + "rdfs:comment": "The identity anchor is a mapping between a registry member identity and one or more decentralised identifiers owned by the member. It may also list a set of membership scopes.", + "rdfs:label": "RegisteredIdentity" + }, + { + "@id": "untp:id", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:CredentialIssuer" + }, + { + "@id": "untp:Party" + }, + { + "@id": "untp:Entity" + }, + { + "@id": "untp:IdentifierScheme" + }, + { + "@id": "untp:BitstringStatusListEntry" + }, + { + "@id": "untp:Facility" + }, + { + "@id": "untp:Claim" + }, + { + "@id": "untp:Criterion" + }, + { + "@id": "untp:ConformityTopic" + }, + { + "@id": "untp:PerformanceMetric" + }, + { + "@id": "untp:Regulation" + }, + { + "@id": "untp:Standard" + }, + { + "@id": "untp:ConformityAttestation" + }, + { + "@id": "untp:ConformityScheme" + }, + { + "@id": "untp:ConformityProfile" + }, + { + "@id": "untp:ConformityAssessment" + }, + { + "@id": "untp:Product" + }, + { + "@id": "untp:LifecycleEvent" + }, + { + "@id": "untp:MakeEvent" + }, + { + "@id": "untp:MoveEvent" + }, + { + "@id": "untp:ModifyEvent" + }, + { + "@id": "untp:RegisteredIdentity" + } + ], + "rdfs:comment": [ + "The W3C DID of the issuer - should be a did:web or did:webvh", + "Globally unique identifier of this party. Typically represented as a URI identifierScheme/Identifier URI", + "The globally unique identifier of this entity. ", + "The URI of this identifier scheme", + "optional identifier of this status list entry.", + "Globally unique identifier of this facility. Typically represented as a URI identifierScheme/Identifier URI", + "Globally unique identifier of this claim. Typically represented as a URI companyURL/claimID URI or a UUID", + "Globally unique identifier of this conformity criterion. Typically represented as a URI SchemeOwner/CriterionID URI", + "The unique identifier for this conformity topic", + "Globally unique identifier of this reporting metric. ", + "Globally unique identifier of this standard. Typically represented as a URI government/regulation URI", + "Globally unique identifier of this standard. Typically represented as a URI issuer/standard URI", + "Globally unique identifier of this attestation. Typically represented as a URI AssessmentBody/CertificateID URI or a UUID", + "Globally unique identifier of this conformity scheme. Typically represented as a URI SchemeOwner/SchemeName URI", + "Globally unique identifier of this context specific conformity profile. Typically represented as a URI SchemeOwner/profileID URI", + "Globally unique identifier of this assessment. Typically represented as a URI AssessmentBody/Assessment URI or a UUID", + "Globally unique identifier of this product. Typically represented as a URI identifierScheme/Identifier URI or, if self-issued, as a did.", + "Globally unique ID for this lifecycle event. Should be a URI. Can be a UUID.", + "Globally unique ID for this lifecycle event. Should be a URI. Can be a UUID.", + "Globally unique ID for this lifecycle event. Should be a URI. Can be a UUID.", + "Globally unique ID for this lifecycle event. Should be a URI. Can be a UUID.", + "The DID that is controlled by the registered member and is linked to the registeredID through this Identity Anchor credential" + ], + "rdfs:label": "id" + }, + { + "@id": "untp:name", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:CredentialIssuer" + }, + { + "@id": "untp:Party" + }, + { + "@id": "untp:Entity" + }, + { + "@id": "untp:IdentifierScheme" + }, + { + "@id": "untp:Classification" + }, + { + "@id": "untp:RenderTemplate2024" + }, + { + "@id": "untp:Facility" + }, + { + "@id": "untp:Material" + }, + { + "@id": "untp:Image" + }, + { + "@id": "untp:Claim" + }, + { + "@id": "untp:Criterion" + }, + { + "@id": "untp:ConformityTopic" + }, + { + "@id": "untp:PerformanceMetric" + }, + { + "@id": "untp:Regulation" + }, + { + "@id": "untp:Standard" + }, + { + "@id": "untp:ConformityAttestation" + }, + { + "@id": "untp:Endorsement" + }, + { + "@id": "untp:ConformityScheme" + }, + { + "@id": "untp:ScoringFramework" + }, + { + "@id": "untp:ConformityProfile" + }, + { + "@id": "untp:ConformityAssessment" + }, + { + "@id": "untp:Product" + }, + { + "@id": "untp:LifecycleEvent" + }, + { + "@id": "untp:MakeEvent" + }, + { + "@id": "untp:MoveEvent" + }, + { + "@id": "untp:ModifyEvent" + } + ], + "rdfs:comment": [ + "The name of the issuer person or organisation", + "Legal registered name of this party.", + "The name of this entity.", + "The name of the identifier scheme. ", + "Name of the classification represented by the code", + "Human facing display name for selection", + "Name of this facility as defined the location register.", + "Name of this material (eg \"Egyptian Cotton\")", + "the display name for this image", + "Name of this claim - typically similar or the same as the referenced criterion name.", + "Name of this criterion as defined by the scheme owner.", + "The human readable name for this conformity topic.", + "A human readable name for this metric (for example \"water usage per Kg of material\")", + "Name of this regulation as defined by the regulator.", + "Name for this standard", + "Name of this attestation - typically the title of the certificate.", + "The name of the accreditation.", + "Name of this scheme as defined by the scheme owner.", + "A name for this scoring framework. Must be unique within a scheme.", + "Name of this conformity profile as defined by the scheme owner.", + "Name of this assessment - typically similar or the same as the referenced criterion name.", + "The product name as known to the market.", + "The name for this lifecycle event ", + "The name for this lifecycle event ", + "The name for this lifecycle event ", + "The name for this lifecycle event " + ], + "rdfs:label": "name" + }, + { + "@id": "untp:issuerAlsoKnownAs", + "schema:rangeIncludes": { + "@id": "untp:Party" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:CredentialIssuer" + } + ], + "rdfs:comment": [ + "An optional list of other registered identifiers for this credential issuer " + ], + "rdfs:label": "issuerAlsoKnownAs" + }, + { + "@id": "untp:issuingSoftware", + "schema:rangeIncludes": { + "@id": "untp:IssuingSoftware" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:DigitalProductPassport" + }, + { + "@id": "untp:DigitalConformityCredential" + }, + { + "@id": "untp:DigitalFacilityRecord" + }, + { + "@id": "untp:DigitalIdentityAnchor" + }, + { + "@id": "untp:DigitalTraceabilityEvent" + } + ], + "rdfs:comment": [ + "Optional metadata identifying the software product (and its vendor) that issued this credential." + ], + "rdfs:label": "issuingSoftware" + }, + { + "@id": "untp:vendor", + "schema:rangeIncludes": { + "@id": "untp:SoftwareVendor" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:IssuingSoftware" + } + ], + "rdfs:comment": [ + "The vendor of the software product that issued the parent credential." + ], + "rdfs:label": "vendor" + }, + { + "@id": "untp:description", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Party" + }, + { + "@id": "untp:Entity" + }, + { + "@id": "untp:Facility" + }, + { + "@id": "untp:Image" + }, + { + "@id": "untp:Claim" + }, + { + "@id": "untp:Criterion" + }, + { + "@id": "untp:Regulation" + }, + { + "@id": "untp:Standard" + }, + { + "@id": "untp:ConformityAttestation" + }, + { + "@id": "untp:ConformityScheme" + }, + { + "@id": "untp:ScoringFramework" + }, + { + "@id": "untp:ConformityProfile" + }, + { + "@id": "untp:ConformityAssessment" + }, + { + "@id": "untp:Product" + }, + { + "@id": "untp:Package" + }, + { + "@id": "untp:LifecycleEvent" + }, + { + "@id": "untp:MakeEvent" + }, + { + "@id": "untp:MoveEvent" + }, + { + "@id": "untp:ModifyEvent" + }, + { + "@id": "untp:PerformanceMetric" + } + ], + "rdfs:comment": [ + "Description of the party including function and other names.", + "A rich descrition of this identified entity. ", + "Description of the facility including function and other names.", + "The detailed description / supporting information for this image.", + "Description of this conformity claim", + "Description of this criterion", + "Description of this regulation.", + "Description of this standard.", + "Description of this attestation.", + "Description of this conformity scheme", + "A full text description of the criterion that clearly specifies how compliance is achieved and measured. ", + "The description of this versioned and context specific conformity profile.", + "Description of this conformity assessment ", + "Description of the product.", + "Description of the packaging.", + "The description of this lifecycle event.", + "The description of this lifecycle event.", + "The description of this lifecycle event.", + "The description of this lifecycle event.", + "A rich description of this reporting metric." + ], + "rdfs:label": "description" + }, + { + "@id": "untp:registeredId", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Party" + }, + { + "@id": "untp:Facility" + }, + { + "@id": "untp:RegisteredIdentity" + } + ], + "rdfs:comment": [ + "The registration number (alphanumeric) of the Party within the register. Unique within the register.", + "The registration number (alphanumeric) of the facility within the identifier scheme. Unique within the register.", + "The registration number (alphanumeric) of the entity within the register. Unique within the register." + ], + "rdfs:label": "registeredId" + }, + { + "@id": "untp:idScheme", + "schema:rangeIncludes": { + "@id": "untp:IdentifierScheme" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Party" + }, + { + "@id": "untp:Facility" + }, + { + "@id": "untp:Product" + }, + { + "@id": "untp:RegisteredIdentity" + } + ], + "rdfs:comment": [ + "The identifier scheme of the party. Typically a national business register or a global scheme such as GLEIF. ", + "The ID scheme of the facility. eg a GS1 GLN or a National land registry scheme. If self issued then use the party ID of the facility owner. ", + "The identifier scheme for this product. Eg a GS1 GTIN or an AU Livestock NLIS, or similar. If self issued then use the party ID of the issuer. ", + "The identifier scheme for this registered entity ID." + ], + "rdfs:label": "idScheme" + }, + { + "@id": "untp:registrationCountry", + "schema:rangeIncludes": { + "@id": "untp:Country" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Party" + } + ], + "rdfs:comment": [ + "the country in which this organisation is registered - using ISO-3166 code and name." + ], + "rdfs:label": "registrationCountry" + }, + { + "@id": "untp:partyAddress", + "schema:rangeIncludes": { + "@id": "untp:Address" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Party" + } + ], + "rdfs:comment": [ + "The address of the party" + ], + "rdfs:label": "partyAddress" + }, + { + "@id": "untp:organisationWebsite", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Party" + } + ], + "rdfs:comment": [ + "Website for this organisation" + ], + "rdfs:label": "organisationWebsite" + }, + { + "@id": "untp:industryCategory", + "schema:rangeIncludes": { + "@id": "untp:Classification" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Party" + } + ], + "rdfs:comment": [ + "The industry categories for this organisation. Recommend use of UNCPC as the category scheme. for example - unstats.un.org/isic/1030" + ], + "rdfs:label": "industryCategory" + }, + { + "@id": "untp:partyAlsoKnownAs", + "schema:rangeIncludes": { + "@id": "untp:Party" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Party" + } + ], + "rdfs:comment": [ + "An optional list of other registered identifiers for this organisation. For example DUNS, GLN, LEI, etc" + ], + "rdfs:label": "partyAlsoKnownAs" + }, + { + "@id": "untp:countryCode", + "schema:rangeIncludes": { + "@id": "untp:CountryCode" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Country" + } + ], + "rdfs:comment": [ + "ISO 3166 country code" + ], + "rdfs:label": "countryCode" + }, + { + "@id": "untp:countryName", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Country" + } + ], + "rdfs:comment": [ + "Country Name as defined in ISO 3166" + ], + "rdfs:label": "countryName" + }, + { + "@id": "untp:addressCountry", + "schema:rangeIncludes": { + "@id": "untp:Country" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Address" + } + ], + "rdfs:comment": [ + "The address country as an ISO-3166 two letter country code and name." + ], + "rdfs:label": "addressCountry" + }, + { + "@id": "untp:code", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Classification" + }, + { + "@id": "untp:Score" + } + ], + "rdfs:comment": [ + "classification code within the scheme", + "The coded value for this score (eg \"AAA\")" + ], + "rdfs:label": "code" + }, + { + "@id": "untp:definition", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Classification" + }, + { + "@id": "untp:ConformityTopic" + }, + { + "@id": "untp:Score" + } + ], + "rdfs:comment": [ + "A rich definition of this classification code.", + "The rich definition of this conformity topic.", + "A description of the meaning of this score." + ], + "rdfs:label": "definition" + }, + { + "@id": "untp:schemeId", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Classification" + } + ], + "rdfs:comment": [ + "Classification scheme ID" + ], + "rdfs:label": "schemeId" + }, + { + "@id": "untp:schemeName", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Classification" + } + ], + "rdfs:comment": [ + "The name of the classification scheme" + ], + "rdfs:label": "schemeName" + }, + { + "@id": "untp:type", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:BitstringStatusListEntry" + } + ], + "rdfs:comment": [ + "The type of status list - must be set to \"The type property MUST be BitstringStatusListEntry.\"" + ], + "rdfs:label": "type" + }, + { + "@id": "untp:statusPurpose", + "schema:rangeIncludes": { + "@id": "untp:CredentialStatus" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:BitstringStatusListEntry" + } + ], + "rdfs:comment": [ + "Status purpose drawn from a standard list but extensible as per w3c bitstring status list specification." + ], + "rdfs:label": "statusPurpose" + }, + { + "@id": "untp:statusListIndex", + "schema:rangeIncludes": { + "@id": "xsd:integer" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:BitstringStatusListEntry" + } + ], + "rdfs:comment": [ + "\tThe statusListIndex property MUST be an arbitrary size integer greater than or equal to 0, expressed as a string in base 10. The value identifies the position of the status of the verifiable credential." + ], + "rdfs:label": "statusListIndex" + }, + { + "@id": "untp:statusListCredential", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:BitstringStatusListEntry" + } + ], + "rdfs:comment": [ + "The statusListCredential property MUST be a URL to a verifiable credential. When the URL is dereferenced, the resulting verifiable credential MUST have type property that includes the BitstringStatusListCredential value." + ], + "rdfs:label": "statusListCredential" + }, + { + "@id": "untp:mediaQuery", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RenderTemplate2024" + } + ], + "rdfs:comment": [ + "Media query as defined in https://www.w3.org/TR/mediaqueries-4/" + ], + "rdfs:label": "mediaQuery" + }, + { + "@id": "untp:template", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RenderTemplate2024" + } + ], + "rdfs:comment": [ + "An inline template field for use cases where remote retrieval of a render method is suboptimal" + ], + "rdfs:label": "template" + }, + { + "@id": "untp:url", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RenderTemplate2024" + } + ], + "rdfs:comment": [ + "URL for remotely hosted template" + ], + "rdfs:label": "url" + }, + { + "@id": "untp:mediaType", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RenderTemplate2024" + }, + { + "@id": "untp:Link" + }, + { + "@id": "untp:Image" + } + ], + "rdfs:comment": [ + "media type of the rendered output (eg text/html)", + "The media type of the target resource.", + "The media type of this image (eg image/png)" + ], + "rdfs:label": "mediaType" + }, + { + "@id": "untp:digestMultibase", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RenderTemplate2024" + }, + { + "@id": "untp:Link" + } + ], + "rdfs:comment": [ + "Used for resource integrity and/or validation of the inline `template`", + "An optional multi-base encoded digest to ensure the content of the link has not changed. See https://www.w3.org/TR/vc-data-integrity/#resource-integrity for more information." + ], + "rdfs:label": "digestMultibase" + }, + { + "@id": "untp:countryOfOperation", + "schema:rangeIncludes": { + "@id": "untp:Country" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Facility" + } + ], + "rdfs:comment": [ + "The country in which this facility is operating.using ISO-3166 code and name." + ], + "rdfs:label": "countryOfOperation" + }, + { + "@id": "untp:processCategory", + "schema:rangeIncludes": { + "@id": "untp:Classification" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Facility" + } + ], + "rdfs:comment": [ + "The industrial or production processes performed by this facility. Example unstats.un.org/isic/1030." + ], + "rdfs:label": "processCategory" + }, + { + "@id": "untp:relatedParty", + "schema:rangeIncludes": { + "@id": "untp:PartyRole" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Facility" + }, + { + "@id": "untp:Product" + }, + { + "@id": "untp:LifecycleEvent" + }, + { + "@id": "untp:MakeEvent" + }, + { + "@id": "untp:MoveEvent" + }, + { + "@id": "untp:ModifyEvent" + } + ], + "rdfs:comment": [ + "A list of parties with a specified role relationship to this facility ", + "A list of parties with a defined relationship to this product", + "Any related parties and their roles involved in this event (eg the carrier for a shipment event)", + "Any related parties and their roles involved in this event (eg the carrier for a shipment event)", + "Any related parties and their roles involved in this event (eg the carrier for a shipment event)", + "Any related parties and their roles involved in this event (eg the carrier for a shipment event)" + ], + "rdfs:label": "relatedParty" + }, + { + "@id": "untp:relatedDocument", + "schema:rangeIncludes": { + "@id": "untp:Link" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Facility" + }, + { + "@id": "untp:Product" + }, + { + "@id": "untp:LifecycleEvent" + }, + { + "@id": "untp:MakeEvent" + }, + { + "@id": "untp:MoveEvent" + }, + { + "@id": "untp:ModifyEvent" + } + ], + "rdfs:comment": [ + "A list of links to documents providing additional facility information. Documents that support a conformity claim (e.g. permits or certificates) SHOULD be referenced as claim evidence rather than here.", + "A list of links to documents providing additional product information. Documents that support a conformity claim (e.g. permits or certificates) SHOULD be referenced as claim evidence rather than here.", + "A list of links to documentary evidence that supports this event. ", + "A list of links to documentary evidence that supports this event. ", + "A list of links to documentary evidence that supports this event. ", + "A list of links to documentary evidence that supports this event. " + ], + "rdfs:label": "relatedDocument" + }, + { + "@id": "untp:facilityAlsoKnownAs", + "schema:rangeIncludes": { + "@id": "untp:Facility" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Facility" + } + ], + "rdfs:comment": [ + "An optional list of other registered identifiers for this facility - eg GLNs or other schemes." + ], + "rdfs:label": "facilityAlsoKnownAs" + }, + { + "@id": "untp:locationInformation", + "schema:rangeIncludes": { + "@id": "untp:Location" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Facility" + } + ], + "rdfs:comment": [ + "Geo-location information for this facility as a resolvable geographic area (a Plus Code), and/or a geo-located point (latitude / longitude), and/or a defined boundary (GeoJSON Polygon)." + ], + "rdfs:label": "locationInformation" + }, + { + "@id": "untp:address", + "schema:rangeIncludes": { + "@id": "untp:Address" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Facility" + } + ], + "rdfs:comment": [ + "The Postal address of the location." + ], + "rdfs:label": "address" + }, + { + "@id": "untp:materialUsage", + "schema:rangeIncludes": { + "@id": "untp:MaterialUsage" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Facility" + } + ], + "rdfs:comment": [ + "The type and provenance of materials consumed by the facility during the reporting period. " + ], + "rdfs:label": "materialUsage" + }, + { + "@id": "untp:performanceClaim", + "schema:rangeIncludes": { + "@id": "untp:Claim" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Facility" + }, + { + "@id": "untp:Product" + }, + { + "@id": "untp:Package" + } + ], + "rdfs:comment": [ + "A list of performance claims (eg deforestation status) for this facility.", + "A list of performance claims (eg emissions intensity) for this product.", + "conformity claims made about the packaging." + ], + "rdfs:label": "performanceClaim" + }, + { + "@id": "untp:role", + "schema:rangeIncludes": { + "@id": "untp:PartyRole" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:PartyRole" + } + ], + "rdfs:comment": [ + "The role played by the party in this relationship" + ], + "rdfs:label": "role" + }, + { + "@id": "untp:party", + "schema:rangeIncludes": { + "@id": "untp:Party" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:PartyRole" + } + ], + "rdfs:comment": [ + "The party that has the specified role." + ], + "rdfs:label": "party" + }, + { + "@id": "untp:linkURL", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Link" + } + ], + "rdfs:comment": [ + "The URL of the target resource. " + ], + "rdfs:label": "linkURL" + }, + { + "@id": "untp:linkName", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Link" + } + ], + "rdfs:comment": [ + "Display name for this link." + ], + "rdfs:label": "linkName" + }, + { + "@id": "untp:linkType", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Link" + } + ], + "rdfs:comment": [ + "The type of the target resource - drawn from a controlled vocabulary " + ], + "rdfs:label": "linkType" + }, + { + "@id": "untp:plusCode", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Location" + } + ], + "rdfs:comment": [ + "An open location code (https://maps.google.com/pluscodes/) representing this geographic location or region. Open location codes can represent any sized area from a point to a large region and are easily resolved to a visual map location. " + ], + "rdfs:label": "plusCode" + }, + { + "@id": "untp:geoLocation", + "schema:rangeIncludes": { + "@id": "untp:Coordinate" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Location" + }, + { + "@id": "untp:SensorData" + } + ], + "rdfs:comment": [ + "The latitude and longitude coordinates that best represent the specified location. ", + "The geolocation of this sensor data recording event." + ], + "rdfs:label": "geoLocation" + }, + { + "@id": "untp:geoBoundary", + "schema:rangeIncludes": { + "@id": "untp:Coordinate" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Location" + } + ], + "rdfs:comment": [ + "The list of ordered coordinates that define a closed area polygon as a location boundary. The first and last coordinates in the array must match - thereby defining a closed boundary." + ], + "rdfs:label": "geoBoundary" + }, + { + "@id": "untp:latitude", + "schema:rangeIncludes": { + "@id": "xsd:double" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Coordinate" + } + ], + "rdfs:comment": [ + "latitude: Angular distance north or south of the equator, expressed in decimal degrees.Valid range: −90.0 to +90.0." + ], + "rdfs:label": "latitude" + }, + { + "@id": "untp:longitude", + "schema:rangeIncludes": { + "@id": "xsd:double" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Coordinate" + } + ], + "rdfs:comment": [ + "longitude: Angular distance east or west of the Prime Meridian, expressed in decimal degrees.Valid range: −180.0 to +180.0." + ], + "rdfs:label": "longitude" + }, + { + "@id": "untp:applicablePeriod", + "schema:rangeIncludes": { + "@id": "untp:Period" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:MaterialUsage" + }, + { + "@id": "untp:Claim" + } + ], + "rdfs:comment": [ + "The period over which this material consumption is reported", + "The applicable reporting period for this facility record." + ], + "rdfs:label": "applicablePeriod" + }, + { + "@id": "untp:materialConsumed", + "schema:rangeIncludes": { + "@id": "untp:Material" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:MaterialUsage" + } + ], + "rdfs:comment": [ + "An list of materials consumed during the usage period. " + ], + "rdfs:label": "materialConsumed" + }, + { + "@id": "untp:startDate", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Period" + } + ], + "rdfs:comment": [ + "The period start date" + ], + "rdfs:label": "startDate" + }, + { + "@id": "untp:endDate", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Period" + } + ], + "rdfs:comment": [ + "The period end date" + ], + "rdfs:label": "endDate" + }, + { + "@id": "untp:periodInformation", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Period" + } + ], + "rdfs:comment": [ + "Additional information relevant to this reporting period" + ], + "rdfs:label": "periodInformation" + }, + { + "@id": "untp:originCountry", + "schema:rangeIncludes": { + "@id": "untp:Country" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Material" + } + ], + "rdfs:comment": [ + "A ISO 3166-1 code representing the country of origin of the component or ingredient." + ], + "rdfs:label": "originCountry" + }, + { + "@id": "untp:materialType", + "schema:rangeIncludes": { + "@id": "untp:Classification" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Material" + } + ], + "rdfs:comment": [ + "The type of this material - as a value drawn from a controlled vocabulary eg from UN Framework Classification for Resources (UNFC)." + ], + "rdfs:label": "materialType" + }, + { + "@id": "untp:massFraction", + "schema:rangeIncludes": { + "@id": "xsd:double" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Material" + } + ], + "rdfs:comment": [ + "The mass fraction as a decimal of the product (or facility reporting period) represented by this material. " + ], + "rdfs:label": "massFraction" + }, + { + "@id": "untp:mass", + "schema:rangeIncludes": { + "@id": "untp:Measure" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Material" + } + ], + "rdfs:comment": [ + "The mass of the material component." + ], + "rdfs:label": "mass" + }, + { + "@id": "untp:recycledMassFraction", + "schema:rangeIncludes": { + "@id": "xsd:double" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Material" + } + ], + "rdfs:comment": [ + "Mass fraction of this material that is recycled (eg 50% recycled Lithium)" + ], + "rdfs:label": "recycledMassFraction" + }, + { + "@id": "untp:hazardous", + "schema:rangeIncludes": { + "@id": "xsd:boolean" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Material" + } + ], + "rdfs:comment": [ + "Indicates whether this material is hazardous. If true then the materialSafetyInformation property must be present" + ], + "rdfs:label": "hazardous" + }, + { + "@id": "untp:symbol", + "schema:rangeIncludes": { + "@id": "untp:Image" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Material" + } + ], + "rdfs:comment": [ + "Based 64 encoded binary used to represent a visual symbol for a given material. " + ], + "rdfs:label": "symbol" + }, + { + "@id": "untp:materialSafetyInformation", + "schema:rangeIncludes": { + "@id": "untp:Link" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Material" + } + ], + "rdfs:comment": [ + "Reference to further information about safe handling of this hazardous material (for example a link to a material safety data sheet)" + ], + "rdfs:label": "materialSafetyInformation" + }, + { + "@id": "untp:value", + "schema:rangeIncludes": { + "@id": "xsd:double" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Measure" + } + ], + "rdfs:comment": [ + "The numeric value of the measure" + ], + "rdfs:label": "value" + }, + { + "@id": "untp:upperTolerance", + "schema:rangeIncludes": { + "@id": "xsd:double" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Measure" + } + ], + "rdfs:comment": [ + "The upper tolerance associated with this measure expressed in the same units as the measure. For example value=10, upperTolerance=0.1, unit=KGM would mean that this measure is 10kg + 0.1kg" + ], + "rdfs:label": "upperTolerance" + }, + { + "@id": "untp:lowerTolerance", + "schema:rangeIncludes": { + "@id": "xsd:double" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Measure" + } + ], + "rdfs:comment": [ + "The lower tolerance associated with this measure expressed in the same units as the measure. For example value=10, lowerTolerance=0.1, unit=KGM would mean that this measure is 10kg - 0.1kg" + ], + "rdfs:label": "lowerTolerance" + }, + { + "@id": "untp:unit", + "schema:rangeIncludes": { + "@id": "untp:UnitOfMeasure" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Measure" + } + ], + "rdfs:comment": [ + "Unit of measure drawn from the UNECE Rec20 measure code list." + ], + "rdfs:label": "unit" + }, + { + "@id": "untp:imageData", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Image" + } + ], + "rdfs:comment": [ + "The image data encoded as a base64 string." + ], + "rdfs:label": "imageData" + }, + { + "@id": "untp:referenceCriteria", + "schema:rangeIncludes": { + "@id": "untp:Criterion" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Claim" + } + ], + "rdfs:comment": [ + "The criterion against which the claim is made." + ], + "rdfs:label": "referenceCriteria" + }, + { + "@id": "untp:referenceRegulation", + "schema:rangeIncludes": { + "@id": "untp:Regulation" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Claim" + }, + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "List of references to regulation to which conformity is claimed claimed for this product", + "The reference to the regulation that defines the assessment criteria" + ], + "rdfs:label": "referenceRegulation" + }, + { + "@id": "untp:referenceStandard", + "schema:rangeIncludes": { + "@id": "untp:Standard" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Claim" + }, + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "List of references to standards to which conformity is claimed claimed for this product", + "The reference to the standard that defines the specification / criteria" + ], + "rdfs:label": "referenceStandard" + }, + { + "@id": "untp:claimDate", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Claim" + } + ], + "rdfs:comment": [ + "That date on which the claimed performance is applicable." + ], + "rdfs:label": "claimDate" + }, + { + "@id": "untp:claimedPerformance", + "schema:rangeIncludes": { + "@id": "untp:Performance" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Claim" + } + ], + "rdfs:comment": [ + "The claimed performance level " + ], + "rdfs:label": "claimedPerformance" + }, + { + "@id": "untp:evidence", + "schema:rangeIncludes": { + "@id": "untp:Link" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Claim" + }, + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "A URI pointing to the evidence supporting the claim. SHOULD be a URL to a UNTP Digital Conformity Credential (DCC)", + "Evidence to support this specific assessment." + ], + "rdfs:label": "evidence" + }, + { + "@id": "untp:conformityTopic", + "schema:rangeIncludes": { + "@id": "untp:ConformityTopic" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Claim" + }, + { + "@id": "untp:Criterion" + }, + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "The conformity topic category for this assessment", + "A global UN/CEFACT standard conformity topic code. ", + "The UNTP conformity topic used to categorise this assessment. Should match the topic defined by the scheme criterion." + ], + "rdfs:label": "conformityTopic" + }, + { + "@id": "untp:version", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Criterion" + }, + { + "@id": "untp:ConformityProfile" + }, + { + "@id": "untp:IssuingSoftware" + } + ], + "rdfs:comment": [ + "The major.minor version of the criterion. Minor versions represent changes that would not invalidate an assessment made under a previous version.", + "Version of this scheme following SemVer best practice (major.minor.patch). " + ], + "rdfs:label": "version" + }, + { + "@id": "untp:status", + "schema:rangeIncludes": { + "@id": "untp:CriterionStatus" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Criterion" + }, + { + "@id": "untp:ConformityProfile" + } + ], + "rdfs:comment": [ + "The lifecycle status of this criterion. ", + "The status of this conformity profile (draft, active, deprecated)" + ], + "rdfs:label": "status" + }, + { + "@id": "untp:documentation", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Criterion" + }, + { + "@id": "untp:ConformityScheme" + }, + { + "@id": "untp:ConformityProfile" + } + ], + "rdfs:comment": [ + "A web page carrying detailed information about this criterion.", + "A web page providing full documentation of this scheme.", + "A web page that describes this entity in detail." + ], + "rdfs:label": "documentation" + }, + { + "@id": "untp:requiredPerformance", + "schema:rangeIncludes": { + "@id": "untp:Performance" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Criterion" + } + ], + "rdfs:comment": [ + "The required performance level as one or more score and/or a metric that represents compliance defined by the criteria" + ], + "rdfs:label": "requiredPerformance" + }, + { + "@id": "untp:tag", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Criterion" + } + ], + "rdfs:comment": [ + "A set of tags that can be used by the scheme owner to be able to filter or group criterion in a large vocabulary for specific use cases." + ], + "rdfs:label": "tag" + }, + { + "@id": "untp:metric", + "schema:rangeIncludes": { + "@id": "untp:PerformanceMetric" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Performance" + }, + { + "@id": "untp:SensorData" + } + ], + "rdfs:comment": [ + "The metric (eg material emissions intensity CO2e/Kg or percentage of young workers) that is measured.", + "The type of measurement recorded in this sensor data event." + ], + "rdfs:label": "metric" + }, + { + "@id": "untp:improvementDirection", + "schema:rangeIncludes": { + "@id": "untp:ImprovementIndicator" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:PerformanceMetric" + } + ], + "rdfs:comment": [ + "Indicator of whether conforming performance is greater than or less than the defined threshold." + ], + "rdfs:label": "improvementDirection" + }, + { + "@id": "untp:aggregationMethod", + "schema:rangeIncludes": { + "@id": "untp:AggregationType" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:PerformanceMetric" + } + ], + "rdfs:comment": [ + "Indicates how to aggregate multiple values to report a single performance metric." + ], + "rdfs:label": "aggregationMethod" + }, + { + "@id": "untp:measure", + "schema:rangeIncludes": { + "@id": "untp:Measure" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Performance" + }, + { + "@id": "untp:SensorData" + } + ], + "rdfs:comment": [ + "The measured performance value", + "The value measured by this sensor measurement event." + ], + "rdfs:label": "measure" + }, + { + "@id": "untp:score", + "schema:rangeIncludes": { + "@id": "untp:Score" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Performance" + }, + { + "@id": "untp:ScoringFramework" + } + ], + "rdfs:comment": [ + "A performance score (eg \"AA\") drawn from a scoring framework defined by the scheme or criterion.", + "A list of scores and ranks associated with this scoring framework." + ], + "rdfs:label": "score" + }, + { + "@id": "untp:allowedUnit", + "schema:rangeIncludes": { + "@id": "untp:UnitOfMeasure" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:PerformanceMetric" + } + ], + "rdfs:comment": [ + "The allowed units for value reporting against this metric (eg cubic meters)" + ], + "rdfs:label": "allowedUnit" + }, + { + "@id": "untp:rank", + "schema:rangeIncludes": { + "@id": "xsd:integer" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Score" + } + ], + "rdfs:comment": [ + "The ranking of this score within the scoring framework - using an integer where \"1\" is the highest rank." + ], + "rdfs:label": "rank" + }, + { + "@id": "untp:jurisdictionCountry", + "schema:rangeIncludes": { + "@id": "untp:Country" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Regulation" + } + ], + "rdfs:comment": [ + "The legal jurisdiction (country) under which the regulation is issued." + ], + "rdfs:label": "jurisdictionCountry" + }, + { + "@id": "untp:administeredBy", + "schema:rangeIncludes": { + "@id": "untp:Party" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Regulation" + } + ], + "rdfs:comment": [ + "the issuing body of the regulation. For example Australian Government Department of Climate Change, Energy, the Environment and Water" + ], + "rdfs:label": "administeredBy" + }, + { + "@id": "untp:effectiveDate", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Regulation" + } + ], + "rdfs:comment": [ + "the date at which the regulation came into effect." + ], + "rdfs:label": "effectiveDate" + }, + { + "@id": "untp:issuingParty", + "schema:rangeIncludes": { + "@id": "untp:Party" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Standard" + } + ], + "rdfs:comment": [ + "The party that issued the standard " + ], + "rdfs:label": "issuingParty" + }, + { + "@id": "untp:issueDate", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Standard" + } + ], + "rdfs:comment": [ + "The date when the standard was issued." + ], + "rdfs:label": "issueDate" + }, + { + "@id": "untp:assessorLevel", + "schema:rangeIncludes": { + "@id": "untp:AssessorLevel" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "Assurance code pertaining to assessor (relation to the object under assessment)" + ], + "rdfs:label": "assessorLevel" + }, + { + "@id": "untp:assessmentLevel", + "schema:rangeIncludes": { + "@id": "untp:AssessmentLevel" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "Assurance pertaining to assessment (any authority or support for the assessment process)" + ], + "rdfs:label": "assessmentLevel" + }, + { + "@id": "untp:attestationType", + "schema:rangeIncludes": { + "@id": "untp:AttestationType" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "The type of criterion (optional or mandatory)." + ], + "rdfs:label": "attestationType" + }, + { + "@id": "untp:issuedToParty", + "schema:rangeIncludes": { + "@id": "untp:Party" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "The party to whom the conformity attestation was issued." + ], + "rdfs:label": "issuedToParty" + }, + { + "@id": "untp:authorisation", + "schema:rangeIncludes": { + "@id": "untp:Endorsement" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "The authority under which a conformity claim is issued. For example a national accreditation authority may authorise a test lab to issue test certificates about a product against a standard. " + ], + "rdfs:label": "authorisation" + }, + { + "@id": "untp:referenceScheme", + "schema:rangeIncludes": { + "@id": "untp:ConformityScheme" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "The conformity scheme under which this attestation is made." + ], + "rdfs:label": "referenceScheme" + }, + { + "@id": "untp:referenceProfile", + "schema:rangeIncludes": { + "@id": "untp:ConformityProfile" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "The specific versioned conformity profile (comprising a set of versioned criteria) against which this conformity attestation is made." + ], + "rdfs:label": "referenceProfile" + }, + { + "@id": "untp:profileScore", + "schema:rangeIncludes": { + "@id": "untp:Score" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "The overall performance against a scheme level performance measurement framework for the referenced profile or scheme." + ], + "rdfs:label": "profileScore" + }, + { + "@id": "untp:conformityCertificate", + "schema:rangeIncludes": { + "@id": "untp:Link" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "A reference to the human / printable version of this conformity attestation - typically represented as a PDF document. The document may have more details than are represented in the digital attestation." + ], + "rdfs:label": "conformityCertificate" + }, + { + "@id": "untp:auditableEvidence", + "schema:rangeIncludes": { + "@id": "untp:Link" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "Auditable evidence supporting this assessment such as raw measurements, supporting documents. This is usually private data and would normally be encrypted." + ], + "rdfs:label": "auditableEvidence" + }, + { + "@id": "untp:trustmark", + "schema:rangeIncludes": { + "@id": "untp:Image" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + }, + { + "@id": "untp:Endorsement" + }, + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "A trust mark as a small binary image encoded as base64 with a description. Maye be displayed on the conformity credential rendering.", + "The trust mark image awarded by the AB to the CAB to indicate accreditation.", + "The trust mark or seal used by this conformity scheme." + ], + "rdfs:label": "trustmark" + }, + { + "@id": "untp:conformityAssessment", + "schema:rangeIncludes": { + "@id": "untp:ConformityAssessment" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "A list of individual assessment made under this attestation. " + ], + "rdfs:label": "conformityAssessment" + }, + { + "@id": "untp:issuingAuthority", + "schema:rangeIncludes": { + "@id": "untp:Party" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Endorsement" + } + ], + "rdfs:comment": [ + "The competent authority that issued the accreditation." + ], + "rdfs:label": "issuingAuthority" + }, + { + "@id": "untp:endorsementEvidence", + "schema:rangeIncludes": { + "@id": "untp:Link" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Endorsement" + } + ], + "rdfs:comment": [ + "The evidence that supports the authority under which the attestation is issued - for an example an accreditation certificate." + ], + "rdfs:label": "endorsementEvidence" + }, + { + "@id": "untp:owner", + "schema:rangeIncludes": { + "@id": "untp:Party" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "The party that is the owner / maintainer of this conformity scheme." + ], + "rdfs:label": "owner" + }, + { + "@id": "untp:endorsementLevel", + "schema:rangeIncludes": { + "@id": "untp:SchemeEndorsementLevel" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "The scheme assurance type." + ], + "rdfs:label": "endorsementLevel" + }, + { + "@id": "untp:endorsement", + "schema:rangeIncludes": { + "@id": "untp:Endorsement" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "The endorsement provided to the scheme by an external authority such as a regulator, an accreditaiton authority, or a benchmarking scheme." + ], + "rdfs:label": "endorsement" + }, + { + "@id": "untp:schemeScoringFramework", + "schema:rangeIncludes": { + "@id": "untp:ScoringFramework" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "The scheme level overall scoring framework that represents the achievement levels (AA, A, B etc) that maybe be awarded to the subject of an independent assessment under the scheme." + ], + "rdfs:label": "schemeScoringFramework" + }, + { + "@id": "untp:licenseType", + "schema:rangeIncludes": { + "@id": "untp:LicenseType" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "Descriptive name and URL link to the license conditions associated with this scheme." + ], + "rdfs:label": "licenseType" + }, + { + "@id": "untp:establishedDate", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "The date when this scheme was first established. " + ], + "rdfs:label": "establishedDate" + }, + { + "@id": "untp:geographicScope", + "schema:rangeIncludes": { + "@id": "untp:Classification" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "The geographic scope of this scheme as a list of ISO-3166 countries, regions, or code=001, name=Worldwide to indicate global coverage." + ], + "rdfs:label": "geographicScope" + }, + { + "@id": "untp:industryScope", + "schema:rangeIncludes": { + "@id": "untp:Classification" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "A list of UN ISIC code & name indicating the industry scope for this scheme. " + ], + "rdfs:label": "industryScope" + }, + { + "@id": "untp:conformsTo", + "schema:rangeIncludes": { + "@id": "untp:Link" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "The name and URI of the vocabulary standard (eg UNTP CVC) that the machine readable version of this sceme conforms to." + ], + "rdfs:label": "conformsTo" + }, + { + "@id": "untp:includedProfile", + "schema:rangeIncludes": { + "@id": "untp:ConformityProfile" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "The list of versioned conformity profiles included in this scheme" + ], + "rdfs:label": "includedProfile" + }, + { + "@id": "untp:validFrom", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityProfile" + } + ], + "rdfs:comment": [ + "The data from which this scheme version is valid." + ], + "rdfs:label": "validFrom" + }, + { + "@id": "untp:subjectType", + "schema:rangeIncludes": { + "@id": "untp:AssessmentSubjectType" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityProfile" + } + ], + "rdfs:comment": [ + "The type of the subject of assessments made under this conformity profile (eg product, facility, organisation)" + ], + "rdfs:label": "subjectType" + }, + { + "@id": "untp:standardAlignment", + "schema:rangeIncludes": { + "@id": "untp:StandardAlignment" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityProfile" + } + ], + "rdfs:comment": [ + "A list of voluntary standards referenced by this conformity profile and against which some level of compliance can be inferred for subjects that pass an assessment. " + ], + "rdfs:label": "standardAlignment" + }, + { + "@id": "untp:regulatoryAlignment", + "schema:rangeIncludes": { + "@id": "untp:RegulatoryAlignment" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityProfile" + } + ], + "rdfs:comment": [ + "A list of regulations or legally binding conventions referenced by this conformity profile and against which some level of compliance can be inferred for subjects that pass an assessment. " + ], + "rdfs:label": "regulatoryAlignment" + }, + { + "@id": "untp:criterionScoringFramework", + "schema:rangeIncludes": { + "@id": "untp:ScoringFramework" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityProfile" + } + ], + "rdfs:comment": [ + "A list of named scoring frameworks that are applied by criterion within this profile. " + ], + "rdfs:label": "criterionScoringFramework" + }, + { + "@id": "untp:criterion", + "schema:rangeIncludes": { + "@id": "untp:Criterion" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityProfile" + } + ], + "rdfs:comment": [ + "A list of criterion that are included in this conformity profile." + ], + "rdfs:label": "criterion" + }, + { + "@id": "untp:scope", + "schema:rangeIncludes": { + "@id": "untp:Classification" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityProfile" + } + ], + "rdfs:comment": [ + "A set of classification codes that may be used to categorize the applicability of this criteria - for example industry sector, jurisdiction or commodity type - based on a formal vocabulary." + ], + "rdfs:label": "scope" + }, + { + "@id": "untp:scheme", + "schema:rangeIncludes": { + "@id": "untp:ConformityScheme" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityProfile" + } + ], + "rdfs:comment": [ + "The conformity scheme under which this versioned profile is maintained." + ], + "rdfs:label": "scheme" + }, + { + "@id": "untp:standard", + "schema:rangeIncludes": { + "@id": "untp:Standard" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:StandardAlignment" + } + ], + "rdfs:comment": [ + "The standard against which this alignment assessment is made." + ], + "rdfs:label": "standard" + }, + { + "@id": "untp:alignmentLevel", + "schema:rangeIncludes": { + "@id": "untp:SchemeAlignmentLevel" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:StandardAlignment" + }, + { + "@id": "untp:RegulatoryAlignment" + } + ], + "rdfs:comment": [ + "A level of alignment with the referenced standard (exceeds, meets, partial,..)", + "A level of alignment with the referenced standard (exceeds, meets, partial,..)" + ], + "rdfs:label": "alignmentLevel" + }, + { + "@id": "untp:regulation", + "schema:rangeIncludes": { + "@id": "untp:Regulation" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RegulatoryAlignment" + } + ], + "rdfs:comment": [ + "The regulation against which this alignment assessment is made." + ], + "rdfs:label": "regulation" + }, + { + "@id": "untp:assessmentCriteria", + "schema:rangeIncludes": { + "@id": "untp:Criterion" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "The specification against which the assessment is made." + ], + "rdfs:label": "assessmentCriteria" + }, + { + "@id": "untp:assessmentDate", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "The date on which this assessment was made. " + ], + "rdfs:label": "assessmentDate" + }, + { + "@id": "untp:assessedPerformance", + "schema:rangeIncludes": { + "@id": "untp:Performance" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "The assessed performance against criteria." + ], + "rdfs:label": "assessedPerformance" + }, + { + "@id": "untp:assessedProduct", + "schema:rangeIncludes": { + "@id": "untp:ProductVerification" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "The product which is the subject of this assessment." + ], + "rdfs:label": "assessedProduct" + }, + { + "@id": "untp:assessedFacility", + "schema:rangeIncludes": { + "@id": "untp:FacilityVerification" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "The facility which is the subject of this assessment." + ], + "rdfs:label": "assessedFacility" + }, + { + "@id": "untp:assessedOrganisation", + "schema:rangeIncludes": { + "@id": "untp:Party" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "An organisation that is the subject of this assessment." + ], + "rdfs:label": "assessedOrganisation" + }, + { + "@id": "untp:specifiedCondition", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "A list of specific conditions that constrain this conformity assessment. For example a specific jurisdiction, material type, or test method." + ], + "rdfs:label": "specifiedCondition" + }, + { + "@id": "untp:conformance", + "schema:rangeIncludes": { + "@id": "xsd:boolean" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "An indicator (true / false) whether the outcome of this assessment is conformant to the requirements defined by the standard or criterion." + ], + "rdfs:label": "conformance" + }, + { + "@id": "untp:product", + "schema:rangeIncludes": { + "@id": "untp:Product" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ProductVerification" + }, + { + "@id": "untp:EventProduct" + } + ], + "rdfs:comment": [ + "The product, serial or batch that is the subject of this assessment", + "The product item / model / batch subject to this lifecycle event." + ], + "rdfs:label": "product" + }, + { + "@id": "untp:idVerifiedByCAB", + "schema:rangeIncludes": { + "@id": "xsd:boolean" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ProductVerification" + }, + { + "@id": "untp:FacilityVerification" + } + ], + "rdfs:comment": [ + "Indicates whether the conformity assessment body has verified the identity product that is the subject of the assessment.", + "Indicates whether the conformity assessment body has verified the identity of the facility which is the subject of the assessment." + ], + "rdfs:label": "idVerifiedByCAB" + }, + { + "@id": "untp:modelNumber", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "Where available, the model number (for manufactured products) or material identification (for bulk materials)" + ], + "rdfs:label": "modelNumber" + }, + { + "@id": "untp:batchNumber", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "Identifier of the specific production batch of the product. Unique within the product class." + ], + "rdfs:label": "batchNumber" + }, + { + "@id": "untp:itemNumber", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "A number or code representing a specific serialised item of the product. Unique within product class." + ], + "rdfs:label": "itemNumber" + }, + { + "@id": "untp:idGranularity", + "schema:rangeIncludes": { + "@id": "untp:ProductIDGranularity" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "The identification granularity for this product (item, batch, model)" + ], + "rdfs:label": "idGranularity" + }, + { + "@id": "untp:productImage", + "schema:rangeIncludes": { + "@id": "untp:Link" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "Reference information (location, type, name) of an image of the product." + ], + "rdfs:label": "productImage" + }, + { + "@id": "untp:characteristics", + "schema:rangeIncludes": { + "@id": "untp:Characteristics" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "A set of industry specific product information. " + ], + "rdfs:label": "characteristics" + }, + { + "@id": "untp:productCategory", + "schema:rangeIncludes": { + "@id": "untp:Classification" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "A code representing the product's class, typically using the UN CPC (United Nations Central Product Classification) https://unstats.un.org/unsd/classifications/Econ/cpc" + ], + "rdfs:label": "productCategory" + }, + { + "@id": "untp:producedAtFacility", + "schema:rangeIncludes": { + "@id": "untp:Facility" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "The Facility where the product batch was produced / manufactured." + ], + "rdfs:label": "producedAtFacility" + }, + { + "@id": "untp:productionDate", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "The ISO 8601 date on which the product batch or individual serialised item was manufactured." + ], + "rdfs:label": "productionDate" + }, + { + "@id": "untp:expiryDate", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "The date at which this product is no longer fit for use. Typically used for a food product use-by date but may also represent the usable life of any product." + ], + "rdfs:label": "expiryDate" + }, + { + "@id": "untp:countryOfProduction", + "schema:rangeIncludes": { + "@id": "untp:Country" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "The country in which this item was produced / manufactured.using ISO-3166 code and name." + ], + "rdfs:label": "countryOfProduction" + }, + { + "@id": "untp:dimensions", + "schema:rangeIncludes": { + "@id": "untp:Dimension" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + }, + { + "@id": "untp:Package" + } + ], + "rdfs:comment": [ + "The physical dimensions of the product. Not every dimension is relevant to every products. For example bulk materials may have weight and volume but not length, width, or height.\"weight\":{\"value\":10, \"unit\":\"KGM\"}", + "dimensions of the packaging" + ], + "rdfs:label": "dimensions" + }, + { + "@id": "untp:materialProvenance", + "schema:rangeIncludes": { + "@id": "untp:Material" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "A list of materials provenance objects providing details on the origin and mass fraction of materials of the product or batch." + ], + "rdfs:label": "materialProvenance" + }, + { + "@id": "untp:packaging", + "schema:rangeIncludes": { + "@id": "untp:Package" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "The packaging for this product." + ], + "rdfs:label": "packaging" + }, + { + "@id": "untp:productLabel", + "schema:rangeIncludes": { + "@id": "untp:Image" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "An array of labels that may appear on the product such as certification marks or regulatory labels." + ], + "rdfs:label": "productLabel" + }, + { + "@id": "untp:weight", + "schema:rangeIncludes": { + "@id": "untp:Measure" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Dimension" + } + ], + "rdfs:comment": [ + "the weight of the product. EG {\"value\":10, \"unit\":\"KGM\"}" + ], + "rdfs:label": "weight" + }, + { + "@id": "untp:length", + "schema:rangeIncludes": { + "@id": "untp:Measure" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Dimension" + } + ], + "rdfs:comment": [ + "The length of the product or packaging eg {\"value\":840, \"unit\":\"MMT\"}" + ], + "rdfs:label": "length" + }, + { + "@id": "untp:width", + "schema:rangeIncludes": { + "@id": "untp:Measure" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Dimension" + } + ], + "rdfs:comment": [ + "The width of the product or packaging. eg {\"value\":150, \"unit\":\"MMT\"}" + ], + "rdfs:label": "width" + }, + { + "@id": "untp:height", + "schema:rangeIncludes": { + "@id": "untp:Measure" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Dimension" + } + ], + "rdfs:comment": [ + "The height of the product or packaging. eg {\"value\":220, \"unit\":\"MMT\"}" + ], + "rdfs:label": "height" + }, + { + "@id": "untp:volume", + "schema:rangeIncludes": { + "@id": "untp:Measure" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Dimension" + } + ], + "rdfs:comment": [ + "The displacement volume of the product. eg {\"value\":7.5, \"unit\":\"LTR\"}" + ], + "rdfs:label": "volume" + }, + { + "@id": "untp:materialUsed", + "schema:rangeIncludes": { + "@id": "untp:Material" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Package" + } + ], + "rdfs:comment": [ + "materials used for the packaging." + ], + "rdfs:label": "materialUsed" + }, + { + "@id": "untp:packageLabel", + "schema:rangeIncludes": { + "@id": "untp:Image" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Package" + } + ], + "rdfs:comment": [ + "An array of package labels that may appear on the packaging together with their meaning. Use for small images that represent certification marks or regulatory requirements. Large images should be linked as evidence to claims." + ], + "rdfs:label": "packageLabel" + }, + { + "@id": "untp:facility", + "schema:rangeIncludes": { + "@id": "untp:Facility" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:FacilityVerification" + } + ], + "rdfs:comment": [ + "The facility which is the subject of this assessment" + ], + "rdfs:label": "facility" + }, + { + "@id": "untp:eventDate", + "schema:rangeIncludes": { + "@id": "xsd:datetime" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:LifecycleEvent" + }, + { + "@id": "untp:MakeEvent" + }, + { + "@id": "untp:MoveEvent" + }, + { + "@id": "untp:ModifyEvent" + } + ], + "rdfs:comment": [ + "The date and time at which this lifecycle event occurs. use 00:00 for time if only a date is required.", + "The date and time at which this lifecycle event occurs. use 00:00 for time if only a date is required.", + "The date and time at which this lifecycle event occurs. use 00:00 for time if only a date is required.", + "The date and time at which this lifecycle event occurs. use 00:00 for time if only a date is required." + ], + "rdfs:label": "eventDate" + }, + { + "@id": "untp:sensorData", + "schema:rangeIncludes": { + "@id": "untp:SensorData" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:LifecycleEvent" + }, + { + "@id": "untp:MakeEvent" + }, + { + "@id": "untp:MoveEvent" + }, + { + "@id": "untp:ModifyEvent" + } + ], + "rdfs:comment": [ + "A sensor data set associated with this lifecycle event.", + "A sensor data set associated with this lifecycle event.", + "A sensor data set associated with this lifecycle event.", + "A sensor data set associated with this lifecycle event." + ], + "rdfs:label": "sensorData" + }, + { + "@id": "untp:activityType", + "schema:rangeIncludes": { + "@id": "untp:Classification" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:LifecycleEvent" + }, + { + "@id": "untp:MakeEvent" + }, + { + "@id": "untp:MoveEvent" + }, + { + "@id": "untp:ModifyEvent" + } + ], + "rdfs:comment": [ + "The business activity that this event represents (eg shipping, repair, etc) using a standard classification scheme - eg https://ref.gs1.org/cbv/BizStep. This may be replaced with industry specific vocabularies (ginning, spinning, weaving, dyeing, etc in textiles)", + "The business activity that this event represents (eg shipping, repair, etc) using a standard classification scheme - eg https://ref.gs1.org/cbv/BizStep. This may be replaced with industry specific vocabularies (ginning, spinning, weaving, dyeing, etc in textiles)", + "The business activity that this event represents (eg shipping, repair, etc) using a standard classification scheme - eg https://ref.gs1.org/cbv/BizStep. This may be replaced with industry specific vocabularies (ginning, spinning, weaving, dyeing, etc in textiles)", + "The business activity that this event represents (eg shipping, repair, etc) using a standard classification scheme - eg https://ref.gs1.org/cbv/BizStep. This may be replaced with industry specific vocabularies (ginning, spinning, weaving, dyeing, etc in textiles)" + ], + "rdfs:label": "activityType" + }, + { + "@id": "untp:inputProduct", + "schema:rangeIncludes": { + "@id": "untp:EventProduct" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:MakeEvent" + } + ], + "rdfs:comment": [ + "An array of input products and quantities for this production or manufacturing process" + ], + "rdfs:label": "inputProduct" + }, + { + "@id": "untp:outputProduct", + "schema:rangeIncludes": { + "@id": "untp:EventProduct" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:MakeEvent" + } + ], + "rdfs:comment": [ + "An array of output products and quantities for this produciton or manufacturing process" + ], + "rdfs:label": "outputProduct" + }, + { + "@id": "untp:madeAtFacility", + "schema:rangeIncludes": { + "@id": "untp:Facility" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:MakeEvent" + } + ], + "rdfs:comment": [ + "The facility at which this production / manufacturing event happens." + ], + "rdfs:label": "madeAtFacility" + }, + { + "@id": "untp:rawData", + "schema:rangeIncludes": { + "@id": "untp:Link" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:SensorData" + } + ], + "rdfs:comment": [ + "Link to raw data file associated with this sensor reading (eg an image)." + ], + "rdfs:label": "rawData" + }, + { + "@id": "untp:sensor", + "schema:rangeIncludes": { + "@id": "untp:Product" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:SensorData" + } + ], + "rdfs:comment": [ + "The sensor device used for this sensor measurement" + ], + "rdfs:label": "sensor" + }, + { + "@id": "untp:quantity", + "schema:rangeIncludes": { + "@id": "untp:Measure" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:EventProduct" + } + ], + "rdfs:comment": [ + "The quantity of product subject to this lifecycle event. Not needed for serialised items." + ], + "rdfs:label": "quantity" + }, + { + "@id": "untp:disposition", + "schema:rangeIncludes": { + "@id": "untp:ProductStatus" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:EventProduct" + } + ], + "rdfs:comment": [ + "The status of the product after the event has happened." + ], + "rdfs:label": "disposition" + }, + { + "@id": "untp:movedProduct", + "schema:rangeIncludes": { + "@id": "untp:EventProduct" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:MoveEvent" + } + ], + "rdfs:comment": [ + "An array of products and quantities for this movement / shipment process" + ], + "rdfs:label": "movedProduct" + }, + { + "@id": "untp:fromFacility", + "schema:rangeIncludes": { + "@id": "untp:Facility" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:MoveEvent" + } + ], + "rdfs:comment": [ + "The source facility for this movement / shipment of products" + ], + "rdfs:label": "fromFacility" + }, + { + "@id": "untp:toFacility", + "schema:rangeIncludes": { + "@id": "untp:Facility" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:MoveEvent" + } + ], + "rdfs:comment": [ + "The destination facility for this movement / shipment of products" + ], + "rdfs:label": "toFacility" + }, + { + "@id": "untp:consignmentId", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:MoveEvent" + } + ], + "rdfs:comment": [ + "The consignment ID related to this movement of products. Ideally this is a resolvable URL but if not available then use a URN notation such as urn:carrier:waybillNumber." + ], + "rdfs:label": "consignmentId" + }, + { + "@id": "untp:modifiedProduct", + "schema:rangeIncludes": { + "@id": "untp:EventProduct" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ModifyEvent" + } + ], + "rdfs:comment": [ + "An array of products and quantities for this intervention (repair, inspection, etc)" + ], + "rdfs:label": "modifiedProduct" + }, + { + "@id": "untp:modifiedAtFacility", + "schema:rangeIncludes": { + "@id": "untp:Facility" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ModifyEvent" + } + ], + "rdfs:comment": [ + "The facility at which this intervention event happens." + ], + "rdfs:label": "modifiedAtFacility" + }, + { + "@id": "untp:registeredName", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RegisteredIdentity" + } + ], + "rdfs:comment": [ + "The registered name of the entity within the identifier scheme. Examples: product - EV battery 300Ah, Party - Sample Company Pty Ltd, Facility - Green Acres battery factory " + ], + "rdfs:label": "registeredName" + }, + { + "@id": "untp:registeredDate", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RegisteredIdentity" + } + ], + "rdfs:comment": [ + "The date on which this identity was first registered with the registrar." + ], + "rdfs:label": "registeredDate" + }, + { + "@id": "untp:publicInformation", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RegisteredIdentity" + } + ], + "rdfs:comment": [ + "A link to further information about the registered entity on the authoritative registrar site." + ], + "rdfs:label": "publicInformation" + }, + { + "@id": "untp:registrar", + "schema:rangeIncludes": { + "@id": "untp:Party" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RegisteredIdentity" + } + ], + "rdfs:comment": [ + "The registrar party that operates the register." + ], + "rdfs:label": "registrar" + }, + { + "@id": "untp:registerType", + "schema:rangeIncludes": { + "@id": "untp:RegistryType" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RegisteredIdentity" + } + ], + "rdfs:comment": [ + "The thematic purpose of the register - organisations, facilities, products, trademarks, etc" + ], + "rdfs:label": "registerType" + }, + { + "@id": "untp:registrationScope", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RegisteredIdentity" + } + ], + "rdfs:comment": [ + "List of URIs that represent the roles or scopes of membership. For example [\"https://abr.business.gov.au/Help/EntityTypeDescription?Id=19\"]" + ], + "rdfs:label": "registrationScope" + }, + { + "@id": "untp:AssessmentLevel", + "@type": "rdfs:Class", + "rdfs:label": "AssessmentLevel", + "rdfs:comment": "Type of authority endorsement of the assessment process" + }, + { + "@id": "untp:AssessmentLevel#authority-benchmark", + "@type": "untp:AssessmentLevel", + "rdf:value": "authority-benchmark", + "rdfs:label": "Authority-derived assurance: Recognition by approved benchmarking organisation", + "rdfs:comment": "Benchmarking of scheme by an organization approved to UNIDO benchmarking\nprinciples and process. UNIDO Global Best Practice Framework for Organisations Performing Benchmarking Activities for Certification-related Conformity Assessment Schemes 2026" + }, + { + "@id": "untp:AssessmentLevel#authority-mandate", + "@type": "untp:AssessmentLevel", + "rdf:value": "authority-mandate", + "rdfs:label": "Authority-derived assurance: Recognition by government mandate", + "rdfs:comment": "Government mandate for conformity assessment activity. Ownership or mandate provided by national government or intergovernmental entity." + }, + { + "@id": "untp:AssessmentLevel#authority-globalmra", + "@type": "untp:AssessmentLevel", + "rdf:value": "authority-globalmra", + "rdfs:label": "Authority-derived assurance:Global accreditation mutual recognition arrangement", + "rdfs:comment": "Accreditation of CAB under global mutual recognition arrangement by a body peer-evaluated\nto ISO/IEC 17011. Scheme evaluation is a prerequisite for accreditation of CABs by bodies that are signatories to the Global Accreditation Cooperation Incorporated Mutual Recognition Arrangement." + }, + { + "@id": "untp:AssessmentLevel#authority-peer", + "@type": "untp:AssessmentLevel", + "rdf:value": "authority-peer", + "rdfs:label": "Authority-derived assurance: Recognition by a governmental peer assessment authority", + "rdfs:comment": "Peer assessment process managed by government. Ownership or mandate provided by national government or intergovernmental entity." + }, + { + "@id": "untp:AssessmentLevel#authority-extended-mra", + "@type": "untp:AssessmentLevel", + "rdf:value": "authority-extended-mra", + "rdfs:label": "Authority- derived assurance: Peer assessment body recognition for accredited CAB", + "rdfs:comment": "Independent peer assessment for accredited CAB. This pathway applies to CABs accredited under the Mutual Recognition Arrangement of the Global Accreditation Cooperation Incorporated. Schemes used by CABs may be owned by the peer assessment body but the CAB itself shall not be owned by or otherwise related to the peer assessment body." + }, + { + "@id": "untp:AssessmentLevel#scheme-self", + "@type": "untp:AssessmentLevel", + "rdf:value": "scheme-self", + "rdfs:label": "Scheme-derived assurance: Self-declaration by registered scheme", + "rdfs:comment": "Scheme owner directly conducting conformity assessment activities. The linked scheme self-declaration can be used to assist in judging credibility of the scheme." + }, + { + "@id": "untp:AssessmentLevel#scheme-cab", + "@type": "untp:AssessmentLevel", + "rdf:value": "scheme-cab", + "rdfs:label": "Scheme-derived assurance: Recognition of CAB by registered scheme", + "rdfs:comment": "Scheme owner recognition of other parties assessing against the scheme standards. The linked scheme self-declaration can be used to assist in judging credibility of the scheme. Users of conformity credentials issued by a CAB recognised under a scheme may refer to the linked scheme self-declaration for details of the CAB-approval process used by the scheme owner" + }, + { + "@id": "untp:AssessmentLevel#no-endorsement", + "@type": "untp:AssessmentLevel", + "rdf:value": "no-endorsement", + "rdfs:label": "No endorsement.", + "rdfs:comment": "conformity assessment claiming no external authority or else unspecified" + }, + { + "@id": "untp:AssessmentSubjectType", + "@type": "rdfs:Class", + "rdfs:label": "AssessmentSubjectType", + "rdfs:comment": "The type of entity being assessed." + }, + { + "@id": "untp:AssessmentSubjectType#product", + "@type": "untp:AssessmentSubjectType", + "rdf:value": "product", + "rdfs:label": "Product", + "rdfs:comment": "The conformity profile targets products — assessing characteristics, composition, performance, or safety of manufactured goods." + }, + { + "@id": "untp:AssessmentSubjectType#facility", + "@type": "untp:AssessmentSubjectType", + "rdf:value": "facility", + "rdfs:label": "Facility", + "rdfs:comment": "The conformity profile targets facilities — assessing the operational practices, environmental performance, or working conditions at a specific site." + }, + { + "@id": "untp:AssessmentSubjectType#organisation", + "@type": "untp:AssessmentSubjectType", + "rdf:value": "organisation", + "rdfs:label": "Organisation", + "rdfs:comment": "The conformity profile targets organisations — assessing entity-level governance, policies, management systems, or corporate sustainability performance." + }, + { + "@id": "untp:AssessorLevel", + "@type": "rdfs:Class", + "rdfs:label": "AssessorLevel", + "rdfs:comment": "Code that describes the level of independent assurance of the specific assessment" + }, + { + "@id": "untp:AssessorLevel#self", + "@type": "untp:AssessorLevel", + "rdf:value": "self", + "rdfs:label": "Self assessed", + "rdfs:comment": " self-assessment" + }, + { + "@id": "untp:AssessorLevel#commercial", + "@type": "untp:AssessorLevel", + "rdf:value": "commercial", + "rdfs:label": "Commercial assessment", + "rdfs:comment": " conformity assessment by related body or under commercial contract" + }, + { + "@id": "untp:AssessorLevel#buyer", + "@type": "untp:AssessorLevel", + "rdf:value": "buyer", + "rdfs:label": "Buyer assessment", + "rdfs:comment": " conformity assessment by potential purchaser" + }, + { + "@id": "untp:AssessorLevel#membership", + "@type": "untp:AssessorLevel", + "rdf:value": "membership", + "rdfs:label": "Industry body assessment", + "rdfs:comment": " conformity assessment by industry representative body or membership body" + }, + { + "@id": "untp:AssessorLevel#unspecified", + "@type": "untp:AssessorLevel", + "rdf:value": "unspecified", + "rdfs:label": "No independent assessment", + "rdfs:comment": " conformity assessment by party with unspecified relationship " + }, + { + "@id": "untp:AssessorLevel#3rdParty", + "@type": "untp:AssessorLevel", + "rdf:value": "3rdParty", + "rdfs:label": "Independent third party assessment", + "rdfs:comment": " 3rd party (independent) conformity assessment" + }, + { + "@id": "untp:AssessorLevel#hybrid", + "@type": "untp:AssessorLevel", + "rdf:value": "hybrid", + "rdfs:label": "Input from self-declaring parties", + "rdfs:comment": "2nd or 3rd party conformity assessment that is dependent on the accuracy of information provided by self-declaring parties" + }, + { + "@id": "untp:AttestationType", + "@type": "rdfs:Class", + "rdfs:label": "AttestationType", + "rdfs:comment": "A code for the type of the attestation credential" + }, + { + "@id": "untp:AttestationType#certification", + "@type": "untp:AttestationType", + "rdf:value": "certification", + "rdfs:label": "certification", + "rdfs:comment": "A formal third party certification of conformity" + }, + { + "@id": "untp:AttestationType#declaration", + "@type": "untp:AttestationType", + "rdf:value": "declaration", + "rdfs:label": "declaration", + "rdfs:comment": "A self assessed declaration of conformity" + }, + { + "@id": "untp:AttestationType#inspection", + "@type": "untp:AttestationType", + "rdf:value": "inspection", + "rdfs:label": "inspection", + "rdfs:comment": "An Inspection report " + }, + { + "@id": "untp:AttestationType#testing", + "@type": "untp:AttestationType", + "rdf:value": "testing", + "rdfs:label": "testing", + "rdfs:comment": "A test report" + }, + { + "@id": "untp:AttestationType#verification", + "@type": "untp:AttestationType", + "rdf:value": "verification", + "rdfs:label": "verification", + "rdfs:comment": "A verification report" + }, + { + "@id": "untp:AttestationType#validation", + "@type": "untp:AttestationType", + "rdf:value": "validation", + "rdfs:label": "validation", + "rdfs:comment": "A validation report" + }, + { + "@id": "untp:AttestationType#calibration", + "@type": "untp:AttestationType", + "rdf:value": "calibration", + "rdfs:label": "calibration", + "rdfs:comment": "An equipment calibration report" + }, + { + "@id": "untp:CountryCode", + "@type": "rdfs:Class", + "rdfs:label": "CountryCode", + "rdfs:comment": "ISO 2 letter country code" + }, + { + "@id": "untp:CredentialStatus", + "@type": "rdfs:Class", + "rdfs:label": "CredentialStatus", + "rdfs:comment": "The status purpose of a credential status entry within a W3C Verifiable Credential, indicating the type of status check that can be performed (e.g. revocation, suspension, refresh, or message)." + }, + { + "@id": "untp:CredentialStatus#refresh", + "@type": "untp:CredentialStatus", + "rdf:value": "refresh", + "rdfs:label": "refresh", + "rdfs:comment": "Used to signal that an updated verifiable credential is available via the credential's refresh service feature. This status does not invalidate the verifiable credential and is not reversible." + }, + { + "@id": "untp:CredentialStatus#revocation", + "@type": "untp:CredentialStatus", + "rdf:value": "revocation", + "rdfs:label": "revocation", + "rdfs:comment": "Used to cancel the validity of a verifiable credential. This status is not reversible." + }, + { + "@id": "untp:CredentialStatus#suspension", + "@type": "untp:CredentialStatus", + "rdf:value": "suspension", + "rdfs:label": "suspension", + "rdfs:comment": "Used to temporarily prevent the acceptance of a verifiable credential. This status is reversible." + }, + { + "@id": "untp:CredentialStatus#message", + "@type": "untp:CredentialStatus", + "rdf:value": "message", + "rdfs:label": "message", + "rdfs:comment": "Used to indicate a ussuer specified flexible status message associated with a verifiable credential. The status message descriptions MUST be defined in credentialSubject.statusMessages. credentialSubject.statusSize MUST be specified when this statusPurpose value is used." + }, + { + "@id": "untp:CriterionStatus", + "@type": "rdfs:Class", + "rdfs:label": "CriterionStatus", + "rdfs:comment": "The status of the conformity profile or criterion" + }, + { + "@id": "untp:CriterionStatus#proposed", + "@type": "untp:CriterionStatus", + "rdf:value": "proposed", + "rdfs:label": "Proposed", + "rdfs:comment": "The criterion is proposed" + }, + { + "@id": "untp:CriterionStatus#active", + "@type": "untp:CriterionStatus", + "rdf:value": "active", + "rdfs:label": "Active", + "rdfs:comment": "The criterion is in active use." + }, + { + "@id": "untp:CriterionStatus#deprecated", + "@type": "untp:CriterionStatus", + "rdf:value": "deprecated", + "rdfs:label": "Deprecated", + "rdfs:comment": "The criterion is deprecated." + }, + { + "@id": "untp:LicenseType", + "@type": "rdfs:Class", + "rdfs:label": "LicenseType", + "rdfs:comment": "The license type of the published vocabulary" + }, + { + "@id": "untp:LicenseType#proprietary-Code", + "@type": "untp:LicenseType", + "rdf:value": "proprietary-Code", + "rdfs:label": "Proprietary", + "rdfs:comment": "Commercial software, internal docs. Restrictiveness - Very high" + }, + { + "@id": "untp:LicenseType#proprietary-Document", + "@type": "untp:LicenseType", + "rdf:value": "proprietary-Document", + "rdfs:label": "Documentation licenses", + "rdfs:comment": "Manuals, standards. Restrictiveness - Medium" + }, + { + "@id": "untp:LicenseType#permissive-OpenSource", + "@type": "untp:LicenseType", + "rdf:value": "permissive-OpenSource", + "rdfs:label": "Permissive open source", + "rdfs:comment": "Libraries, frameworks. Restrictiveness - Low" + }, + { + "@id": "untp:LicenseType#copyleft", + "@type": "untp:LicenseType", + "rdf:value": "copyleft", + "rdfs:label": "Copyleft", + "rdfs:comment": "Platforms, infrastructure. Restrictiveness - Medium–high" + }, + { + "@id": "untp:LicenseType#creative-Commons", + "@type": "untp:LicenseType", + "rdf:value": "creative-Commons", + "rdfs:label": "Creative Commons", + "rdfs:comment": "Media, publications. Restrictiveness - Variable" + }, + { + "@id": "untp:LicenseType#source-Available", + "@type": "untp:LicenseType", + "rdf:value": "source-Available", + "rdfs:label": "Source-available", + "rdfs:comment": "Commercial SaaS vendors. Restrictiveness - Medium–high" + }, + { + "@id": "untp:LicenseType#public", + "@type": "untp:LicenseType", + "rdf:value": "public", + "rdfs:label": "Public domain", + "rdfs:comment": "Data, examples. Restrictiveness - None" + }, + { + "@id": "untp:MimeType", + "@type": "rdfs:Class", + "rdfs:label": "MimeType", + "rdfs:comment": "IANA multipart media encoding type " + }, + { + "@id": "untp:PartyRole", + "@type": "rdfs:Class", + "rdfs:label": "PartyRole", + "rdfs:comment": "The role for this facility - party or product - party relationship" + }, + { + "@id": "untp:PartyRole#owner", + "@type": "untp:PartyRole", + "rdf:value": "owner", + "rdfs:label": "Party that owns the product or asset" + }, + { + "@id": "untp:PartyRole#producer", + "@type": "untp:PartyRole", + "rdf:value": "producer", + "rdfs:label": "Party that extracts, grows, or produces raw materials" + }, + { + "@id": "untp:PartyRole#manufacturer", + "@type": "untp:PartyRole", + "rdf:value": "manufacturer", + "rdfs:label": "Party that manufactures or assembles the product" + }, + { + "@id": "untp:PartyRole#processor", + "@type": "untp:PartyRole", + "rdf:value": "processor", + "rdfs:label": "Party that processes or transforms materials" + }, + { + "@id": "untp:PartyRole#remanufacturer", + "@type": "untp:PartyRole", + "rdf:value": "remanufacturer", + "rdfs:label": "Party that remanufactures or refurbishes products" + }, + { + "@id": "untp:PartyRole#recycler", + "@type": "untp:PartyRole", + "rdf:value": "recycler", + "rdfs:label": "Party that recovers materials from products" + }, + { + "@id": "untp:PartyRole#operator", + "@type": "untp:PartyRole", + "rdf:value": "operator", + "rdfs:label": "Party operating a facility or process" + }, + { + "@id": "untp:PartyRole#serviceProvider", + "@type": "untp:PartyRole", + "rdf:value": "serviceProvider", + "rdfs:label": "Party providing maintenance or servicing" + }, + { + "@id": "untp:PartyRole#inspector", + "@type": "untp:PartyRole", + "rdf:value": "inspector", + "rdfs:label": "Party performing inspection or testing" + }, + { + "@id": "untp:PartyRole#certifier", + "@type": "untp:PartyRole", + "rdf:value": "certifier", + "rdfs:label": "Party issuing certification or conformity assessment" + }, + { + "@id": "untp:PartyRole#logisticsProvider", + "@type": "untp:PartyRole", + "rdf:value": "logisticsProvider", + "rdfs:label": "Party responsible for logistics operations" + }, + { + "@id": "untp:PartyRole#carrier", + "@type": "untp:PartyRole", + "rdf:value": "carrier", + "rdfs:label": "Party physically transporting the goods" + }, + { + "@id": "untp:PartyRole#consignor", + "@type": "untp:PartyRole", + "rdf:value": "consignor", + "rdfs:label": "Party sending the goods" + }, + { + "@id": "untp:PartyRole#consignee", + "@type": "untp:PartyRole", + "rdf:value": "consignee", + "rdfs:label": "Party receiving the goods" + }, + { + "@id": "untp:PartyRole#importer", + "@type": "untp:PartyRole", + "rdf:value": "importer", + "rdfs:label": "Party importing the goods into a jurisdiction" + }, + { + "@id": "untp:PartyRole#exporter", + "@type": "untp:PartyRole", + "rdf:value": "exporter", + "rdfs:label": "Party exporting the goods from a jurisdiction" + }, + { + "@id": "untp:PartyRole#distributor", + "@type": "untp:PartyRole", + "rdf:value": "distributor", + "rdfs:label": "Party distributing goods in the supply chain" + }, + { + "@id": "untp:PartyRole#retailer", + "@type": "untp:PartyRole", + "rdf:value": "retailer", + "rdfs:label": "Party selling goods to end users" + }, + { + "@id": "untp:PartyRole#brandOwner", + "@type": "untp:PartyRole", + "rdf:value": "brandOwner", + "rdfs:label": "Party responsible for the brand or product specification" + }, + { + "@id": "untp:PartyRole#regulator", + "@type": "untp:PartyRole", + "rdf:value": "regulator", + "rdfs:label": "Authority responsible for regulatory oversight" + }, + { + "@id": "untp:ImprovementIndicator", + "@type": "rdfs:Class", + "rdfs:label": "ImprovementIndicator", + "rdfs:comment": "Indicator of whether conforming performance is greater than or less than the defined threshold." + }, + { + "@id": "untp:ImprovementIndicator#higher", + "@type": "untp:ImprovementIndicator", + "rdf:value": "higher", + "rdfs:label": "higher", + "rdfs:comment": "Performance improves with a higher measured value" + }, + { + "@id": "untp:ImprovementIndicator#lower", + "@type": "untp:ImprovementIndicator", + "rdf:value": "lower", + "rdfs:label": "lower", + "rdfs:comment": "Performance improves with a lower measured value" + }, + { + "@id": "untp:AggregationType", + "@type": "rdfs:Class", + "rdfs:label": "AggregationType", + "rdfs:comment": "Indicates how to aggregate multiple values to report a single performance metric." + }, + { + "@id": "untp:AggregationType#sum", + "@type": "untp:AggregationType", + "rdf:value": "sum", + "rdfs:label": "sum", + "rdfs:comment": "Values add up (e.g. total GHG emissions across all facilities = sum of each facility's emissions)" + }, + { + "@id": "untp:AggregationType#weighted-average", + "@type": "untp:AggregationType", + "rdf:value": "weighted-average", + "rdfs:label": "weighted-average", + "rdfs:comment": "Values must be averaged weighted by volume/output (e.g. emissions intensity per kg across suppliers)" + }, + { + "@id": "untp:AggregationType#latest", + "@type": "untp:AggregationType", + "rdf:value": "latest", + "rdfs:label": "latest", + "rdfs:comment": "Only the most recent value is meaningful (e.g. a biodiversity assessment score where only the current state matters)" + }, + { + "@id": "untp:ProductIDGranularity", + "@type": "rdfs:Class", + "rdfs:label": "ProductIDGranularity", + "rdfs:comment": "Product identification granularity" + }, + { + "@id": "untp:ProductIDGranularity#model", + "@type": "untp:ProductIDGranularity", + "rdf:value": "model", + "rdfs:label": "product model level ID", + "rdfs:comment": "" + }, + { + "@id": "untp:ProductIDGranularity#batch", + "@type": "untp:ProductIDGranularity", + "rdf:value": "batch", + "rdfs:label": "product manufactured batch level ID", + "rdfs:comment": "" + }, + { + "@id": "untp:ProductIDGranularity#item", + "@type": "untp:ProductIDGranularity", + "rdf:value": "item", + "rdfs:label": "serialised item level ID", + "rdfs:comment": "" + }, + { + "@id": "untp:ProductStatus", + "@type": "rdfs:Class", + "rdfs:label": "ProductStatus", + "rdfs:comment": "The lifecycle status of a product, describing its current state from initial production through to eventual disposal or recycling. Used as the value of the disposition property on EventProduct in traceability events." + }, + { + "@id": "untp:ProductStatus#new", + "@type": "untp:ProductStatus", + "rdf:value": "new", + "rdfs:label": "New", + "rdfs:comment": "Product has been newly manufactured or produced and has not yet entered service. Equivalent to GS1 CBV Disp-active." + }, + { + "@id": "untp:ProductStatus#inTransit", + "@type": "untp:ProductStatus", + "rdf:value": "inTransit", + "rdfs:label": "In Transit", + "rdfs:comment": "Product has been shipped and is in transit between facilities. Equivalent to GS1 CBV Disp-in_transit." + }, + { + "@id": "untp:ProductStatus#active", + "@type": "untp:ProductStatus", + "rdf:value": "active", + "rdfs:label": "Active", + "rdfs:comment": "Product is in active service or use by the end customer or a downstream manufacturer. Equivalent to GS1 CBV Disp-retail_sold." + }, + { + "@id": "untp:ProductStatus#repaired", + "@type": "untp:ProductStatus", + "rdf:value": "repaired", + "rdfs:label": "Repaired", + "rdfs:comment": "Product has been repaired or refurbished to restore functionality and returned to service. Equivalent to GS1 CBV Disp-available (after a repairing step)." + }, + { + "@id": "untp:ProductStatus#recalled", + "@type": "untp:ProductStatus", + "rdf:value": "recalled", + "rdfs:label": "Recalled", + "rdfs:comment": "Product has been withdrawn from the market or service due to a safety, quality, or compliance issue. Equivalent to GS1 CBV Disp-recalled." + }, + { + "@id": "untp:ProductStatus#expired", + "@type": "untp:ProductStatus", + "rdf:value": "expired", + "rdfs:label": "Expired", + "rdfs:comment": "Product has passed its use-by, certification, or regulatory expiration date. Equivalent to GS1 CBV Disp-expired." + }, + { + "@id": "untp:ProductStatus#consumed", + "@type": "untp:ProductStatus", + "rdf:value": "consumed", + "rdfs:label": "Consumed", + "rdfs:comment": "Product has been consumed as an input to a manufacturing process and no longer exists as a separate item. No direct GS1 CBV equivalent." + }, + { + "@id": "untp:ProductStatus#recycled", + "@type": "untp:ProductStatus", + "rdf:value": "recycled", + "rdfs:label": "Recycled", + "rdfs:comment": "Product has been processed to recover constituent materials for reuse in new products. No direct GS1 CBV equivalent." + }, + { + "@id": "untp:ProductStatus#disposed", + "@type": "untp:ProductStatus", + "rdf:value": "disposed", + "rdfs:label": "Disposed", + "rdfs:comment": "Product has reached end of life and has been disposed of or destroyed without material recovery. Equivalent to GS1 CBV Disp-disposed and Disp-destroyed." + }, + { + "@id": "untp:RegistryType", + "@type": "rdfs:Class", + "rdfs:label": "RegistryType", + "rdfs:comment": "A registry category code." + }, + { + "@id": "untp:RegistryType#product", + "@type": "untp:RegistryType", + "rdf:value": "product", + "rdfs:label": "Product", + "rdfs:comment": "A register of products or product classes, such as a national product catalogue or a GS1 GTIN registry." + }, + { + "@id": "untp:RegistryType#facility", + "@type": "untp:RegistryType", + "rdf:value": "facility", + "rdfs:label": "Facility", + "rdfs:comment": "A register of facilities or sites, such as a mining cadastre, environmental permit register, or industrial facility directory." + }, + { + "@id": "untp:RegistryType#business", + "@type": "untp:RegistryType", + "rdf:value": "business", + "rdfs:label": "Business", + "rdfs:comment": "A register of business entities or legal persons, such as a national company register, VAT register, or LEI registry." + }, + { + "@id": "untp:RegistryType#trademark", + "@type": "untp:RegistryType", + "rdf:value": "trademark", + "rdfs:label": "Trademark", + "rdfs:comment": "A register of trademarks, certification marks, or other intellectual property identifiers maintained by a national or international IP office." + }, + { + "@id": "untp:RegistryType#land", + "@type": "untp:RegistryType", + "rdf:value": "land", + "rdfs:label": "Land", + "rdfs:comment": "A register of land titles, parcels, or cadastral boundaries, such as a national land registry or territorial cadastre." + }, + { + "@id": "untp:RegistryType#accreditation", + "@type": "untp:RegistryType", + "rdf:value": "accreditation", + "rdfs:label": "Accreditation", + "rdfs:comment": "A register of accredited conformity assessment bodies, maintained by a national or regional accreditation authority." + }, + { + "@id": "untp:SchemeAlignmentLevel", + "@type": "rdfs:Class", + "rdfs:label": "SchemeAlignmentLevel", + "rdfs:comment": "Alignment level of a scheme profile or criterion against a reference standard or regulation" + }, + { + "@id": "untp:SchemeAlignmentLevel#meets", + "@type": "untp:SchemeAlignmentLevel", + "rdf:value": "meets", + "rdfs:label": "Meets", + "rdfs:comment": "The scheme profile or criterion fully satisfies the requirements of the referenced standard or regulation." + }, + { + "@id": "untp:SchemeAlignmentLevel#exceeds", + "@type": "untp:SchemeAlignmentLevel", + "rdf:value": "exceeds", + "rdfs:label": "Exceeds", + "rdfs:comment": "The scheme profile or criterion goes beyond the requirements of the referenced standard or regulation, imposing stricter thresholds or broader scope." + }, + { + "@id": "untp:SchemeAlignmentLevel#partial", + "@type": "untp:SchemeAlignmentLevel", + "rdf:value": "partial", + "rdfs:label": "Partially meets", + "rdfs:comment": "The scheme profile or criterion addresses some but not all requirements of the referenced standard or regulation." + }, + { + "@id": "untp:SchemeEndorsementLevel", + "@type": "rdfs:Class", + "rdfs:label": "SchemeEndorsementLevel", + "rdfs:comment": "The level of endorsement or recognition that a conformity scheme has received from authoritative bodies, indicating the degree of independent assurance over the scheme's credibility and rigour." + }, + { + "@id": "untp:SchemeEndorsementLevel#endorsed_self", + "@type": "untp:SchemeEndorsementLevel", + "rdf:value": "endorsed_self", + "rdfs:label": "Self-declaration by scheme owner", + "rdfs:comment": "Scheme owner self-declaration using the UNTP scheme declaration template" + }, + { + "@id": "untp:SchemeEndorsementLevel#endorsed_mandate", + "@type": "untp:SchemeEndorsementLevel", + "rdf:value": "endorsed_mandate", + "rdfs:label": "Government owned or mandated scheme", + "rdfs:comment": "Ownership of scheme or mandate for adoption of scheme by national government or intergovernmental entity." + }, + { + "@id": "untp:SchemeEndorsementLevel#endorsed_accreditation", + "@type": "untp:SchemeEndorsementLevel", + "rdf:value": "endorsed_accreditation", + "rdfs:label": "Accreditation authority endorsement of scheme suitability", + "rdfs:comment": "Scheme evaluated for suitability by the Global Accreditation Cooperation Incorporated, or by an accreditation body member of the Global Mutual Recognition Arrangement for such scope, or by a Regional Accreditation Cooperation member." + }, + { + "@id": "untp:SchemeEndorsementLevel#endorsed_benchmarked", + "@type": "untp:SchemeEndorsementLevel", + "rdf:value": "endorsed_benchmarked", + "rdfs:label": "Scheme recognition by a benchmarking organisation approved to UNIDO principles and process", + "rdfs:comment": "Benchmarking of scheme by an organization approved to UNIDO benchmarking principles and process. UNIDO Global Best Practice Framework for Organisations Performing Benchmarking Activities for Certification-related Conformity Assessment Schemes 2026" + }, + { + "@id": "untp:UnitOfMeasure", + "@type": "rdfs:Class", + "rdfs:label": "UnitOfMeasure", + "rdfs:comment": "UNECE Recommendation 20 Unit of Measure codelist" + } + ] +} diff --git a/src/dppvalidator/vocabularies/data/untp-topics.jsonld b/src/dppvalidator/vocabularies/data/untp-topics.jsonld new file mode 100644 index 0000000..4d3326b --- /dev/null +++ b/src/dppvalidator/vocabularies/data/untp-topics.jsonld @@ -0,0 +1,1281 @@ +{ + "@context": { + "skos": "http://www.w3.org/2004/02/skos/core#", + "dcterms": "http://purl.org/dc/terms/", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "owl": "http://www.w3.org/2002/07/owl#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "sdg": "http://metadata.un.org/sdg/", + "topics": "https://vocabulary.uncefact.org/conformity-topics/", + "prefLabel": { "@id": "skos:prefLabel", "@language": "en" }, + "definition": { "@id": "skos:definition", "@language": "en" }, + "notation": "skos:notation", + "scopeNote": { "@id": "skos:scopeNote", "@language": "en" }, + "broader": { "@id": "skos:broader", "@type": "@id" }, + "narrower": { "@id": "skos:narrower", "@type": "@id", "@container": "@set" }, + "topConceptOf": { "@id": "skos:topConceptOf", "@type": "@id" }, + "hasTopConcept": { "@id": "skos:hasTopConcept", "@type": "@id", "@container": "@set" }, + "inScheme": { "@id": "skos:inScheme", "@type": "@id" }, + "relatedMatch": { "@id": "skos:relatedMatch", "@type": "@id", "@container": "@set" } + }, + "@graph": [ + { + "@id": "https://vocabulary.uncefact.org/conformity-topics/", + "@type": "skos:ConceptScheme", + "dcterms:title": { "@value": "UNTP Conformity Topic Classification", "@language": "en" }, + "dcterms:description": { "@value": "A hierarchical classification scheme for conformity topics used to categorise conformity criteria published by scheme owners. Encompasses sustainability (environmental, social, governance), product integrity, trade compliance, technical conformity, and information security domains. Designed as a common reference taxonomy for interoperable conformity assessments across regulatory frameworks and voluntary standards.", "@language": "en" }, + "dcterms:creator": "United Nations Economic Commission for Europe (UNECE)", + "dcterms:license": "https://creativecommons.org/licenses/by/4.0/", + "owl:versionInfo": "0.2.0-working", + "dcterms:issued": "2025-01-01", + "dcterms:modified": "2026-03-13", + "hasTopConcept": [ + "topics:ecological-resilience", + "topics:human-equity-and-welfare", + "topics:ethical-governance", + "topics:product-integrity", + "topics:circular-value-chains", + "topics:economic-sustainability", + "topics:health-and-safety", + "topics:systemic-sustainability", + "topics:trade-and-market-access", + "topics:technical-conformity", + "topics:information-security" + ] + }, + + { + "@id": "topics:ecological-resilience", + "@type": "skos:Concept", + "prefLabel": "Ecological Resilience", + "definition": "Environmental protection, resource conservation, and climate resilience. Covers emissions reduction, energy transition, water stewardship, waste prevention, biodiversity, and circular design.", + "notation": "01", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "UN SDGs 6, 7, 12, 13, 14, 15; OECD Guidelines Chapter VI: Environment; EU ESPR Art. 5-8 and Annex I.", + "relatedMatch": ["sdg:6", "sdg:7", "sdg:12", "sdg:13", "sdg:14", "sdg:15"], + "narrower": [ + "topics:greenhouse-gas-emissions", + "topics:renewable-energy-use", + "topics:water-conservation", + "topics:waste-minimization", + "topics:ecosystem-preservation", + "topics:forest-conservation", + "topics:recycled-material-integration", + "topics:sustainable-product-design", + "topics:chemical-safety", + "topics:air-quality-management" + ] + }, + { + "@id": "topics:greenhouse-gas-emissions", + "@type": "skos:Concept", + "prefLabel": "Greenhouse Gas Emissions", + "definition": "Measuring, reporting, and reducing greenhouse gas emissions (CO2, methane, N2O, F-gases) across production, transport, and supply chain activities.", + "notation": "01.01", + "broader": "topics:ecological-resilience", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:13"], + "scopeNote": "EU ESPR Art. 5 - Environmental Sustainability; UNTP environment.emissions." + }, + { + "@id": "topics:renewable-energy-use", + "@type": "skos:Concept", + "prefLabel": "Renewable Energy Use", + "definition": "Transition to sustainable energy sources including solar, wind, hydro, and other renewables in production and operations.", + "notation": "01.02", + "broader": "topics:ecological-resilience", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:7"], + "scopeNote": "EU ESPR Art. 7 - Energy Efficiency; UNTP environment.energy." + }, + { + "@id": "topics:water-conservation", + "@type": "skos:Concept", + "prefLabel": "Water Conservation", + "definition": "Sustainable water management including efficient use, pollution prevention, and watershed protection throughout operations and supply chains.", + "notation": "01.03", + "broader": "topics:ecological-resilience", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:6"], + "scopeNote": "EU ESPR Annex I - Water Use; UNTP environment.water." + }, + { + "@id": "topics:waste-minimization", + "@type": "skos:Concept", + "prefLabel": "Waste Minimization", + "definition": "Reducing waste generation through prevention, reuse, and improved production processes across the product lifecycle.", + "notation": "01.04", + "broader": "topics:ecological-resilience", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 6 - Waste Prevention; UNTP environment.waste." + }, + { + "@id": "topics:ecosystem-preservation", + "@type": "skos:Concept", + "prefLabel": "Ecosystem Preservation", + "definition": "Protecting biodiversity, natural habitats, and ecosystem services from degradation caused by production and extraction activities.", + "notation": "01.05", + "broader": "topics:ecological-resilience", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:15"], + "scopeNote": "EU ESPR Annex I - Biodiversity Impact; UNTP environment.biodiversity." + }, + { + "@id": "topics:forest-conservation", + "@type": "skos:Concept", + "prefLabel": "Forest Conservation", + "definition": "Preventing deforestation and promoting sustainable forestry practices in raw material sourcing and land use.", + "notation": "01.06", + "broader": "topics:ecological-resilience", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:15"], + "scopeNote": "EU ESPR Art. 5 - Resource Use; UNTP environment.deforestation." + }, + { + "@id": "topics:recycled-material-integration", + "@type": "skos:Concept", + "prefLabel": "Recycled Material Integration", + "definition": "Incorporation of secondary and recycled materials into production processes, reducing dependence on virgin resources.", + "notation": "01.07", + "broader": "topics:ecological-resilience", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 8 - Recycled Content; UNTP circularity.content." + }, + { + "@id": "topics:sustainable-product-design", + "@type": "skos:Concept", + "prefLabel": "Sustainable Product Design", + "definition": "Designing products for durability, repairability, recyclability, and minimal environmental impact throughout their lifecycle.", + "notation": "01.08", + "broader": "topics:ecological-resilience", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 5 - Durability and Recyclability; UNTP circularity.design." + }, + { + "@id": "topics:chemical-safety", + "@type": "skos:Concept", + "prefLabel": "Chemical Safety", + "definition": "Restriction and responsible management of hazardous substances in materials, products, and production processes.", + "notation": "01.09", + "broader": "topics:ecological-resilience", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Annex I - Substance Restrictions." + }, + { + "@id": "topics:air-quality-management", + "@type": "skos:Concept", + "prefLabel": "Air Quality Management", + "definition": "Controlling and reducing non-GHG air pollutant emissions including SOx, NOx, VOCs, particulates, and ozone-depleting substances from operations and production processes.", + "notation": "01.10", + "broader": "topics:ecological-resilience", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3", "sdg:13"], + "scopeNote": "EU ESPR Annex I - Air Emissions; WHO Air Quality Guidelines; Montreal Protocol (ozone-depleting substances)." + }, + + { + "@id": "topics:human-equity-and-welfare", + "@type": "skos:Concept", + "prefLabel": "Human Equity and Welfare", + "definition": "Protection of human rights, promotion of fair labor practices, and support for community wellbeing across operations and supply chains.", + "notation": "02", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "UN SDGs 1, 3, 4, 5, 8, 10; OECD Guidelines Chapter IV: Human Rights and Chapter V: Employment and Industrial Relations; EU ESPR Art. 10.", + "relatedMatch": ["sdg:1", "sdg:3", "sdg:4", "sdg:5", "sdg:8", "sdg:10"], + "narrower": [ + "topics:rights-and-equality", + "topics:decent-work-conditions", + "topics:workplace-safety", + "topics:community-empowerment", + "topics:worker-representation", + "topics:forced-labor-elimination", + "topics:youth-protection", + "topics:gender-equity" + ] + }, + { + "@id": "topics:rights-and-equality", + "@type": "skos:Concept", + "prefLabel": "Rights and Equality", + "definition": "Ensuring non-discrimination and equal treatment regardless of race, gender, religion, disability, or other protected characteristics.", + "notation": "02.01", + "broader": "topics:human-equity-and-welfare", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:10"], + "scopeNote": "EU ESPR Art. 10 - Social Sustainability; UNTP social.rights." + }, + { + "@id": "topics:decent-work-conditions", + "@type": "skos:Concept", + "prefLabel": "Decent Work Conditions", + "definition": "Provision of fair wages, reasonable working hours, and dignified employment conditions throughout the supply chain.", + "notation": "02.02", + "broader": "topics:human-equity-and-welfare", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:8"], + "scopeNote": "EU ESPR Annex I - Supply Chain Due Diligence; UNTP social.labour." + }, + { + "@id": "topics:workplace-safety", + "@type": "skos:Concept", + "prefLabel": "Workplace Safety", + "definition": "Protecting worker health and safety through hazard prevention, protective equipment, and safe working environments.", + "notation": "02.03", + "broader": "topics:human-equity-and-welfare", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3"], + "scopeNote": "EU ESPR Art. 10 - Social Impact; UNTP social.safety." + }, + { + "@id": "topics:community-empowerment", + "@type": "skos:Concept", + "prefLabel": "Community Empowerment", + "definition": "Supporting local community development, livelihoods, and participation in decisions that affect their wellbeing.", + "notation": "02.04", + "broader": "topics:human-equity-and-welfare", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:1"], + "scopeNote": "EU ESPR Art. 10 - Stakeholder Engagement; UNTP social.community." + }, + { + "@id": "topics:worker-representation", + "@type": "skos:Concept", + "prefLabel": "Worker Representation", + "definition": "Respecting freedom of association, collective bargaining rights, and worker participation in workplace governance.", + "notation": "02.05", + "broader": "topics:human-equity-and-welfare", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:8"], + "scopeNote": "EU ESPR Annex I - Labor Rights." + }, + { + "@id": "topics:forced-labor-elimination", + "@type": "skos:Concept", + "prefLabel": "Forced Labor Elimination", + "definition": "Preventing all forms of forced, bonded, or compulsory labor including debt bondage and human trafficking in supply chains.", + "notation": "02.06", + "broader": "topics:human-equity-and-welfare", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:8"], + "scopeNote": "EU ESPR Art. 10 - Human Rights Due Diligence." + }, + { + "@id": "topics:youth-protection", + "@type": "skos:Concept", + "prefLabel": "Youth Protection", + "definition": "Safeguarding young workers from hazardous conditions and eliminating child labor in all forms across supply chains.", + "notation": "02.07", + "broader": "topics:human-equity-and-welfare", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:8"], + "scopeNote": "EU ESPR Annex I - Child Labor Ban." + }, + { + "@id": "topics:gender-equity", + "@type": "skos:Concept", + "prefLabel": "Gender Equity", + "definition": "Promoting gender diversity, equal opportunity, and elimination of gender-based discrimination in employment and business practices.", + "notation": "02.08", + "broader": "topics:human-equity-and-welfare", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:5"], + "scopeNote": "EU ESPR Art. 10 - Social Sustainability." + }, + + { + "@id": "topics:ethical-governance", + "@type": "skos:Concept", + "prefLabel": "Ethical Governance", + "definition": "Promoting organizational integrity, accountability, and transparent practices in business operations and decision-making.", + "notation": "03", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "UN SDG 16; OECD Guidelines Chapter II: General Policies and Chapter VII: Combating Bribery; EU ESPR Art. 11-12.", + "relatedMatch": ["sdg:16"], + "narrower": [ + "topics:anti-corruption-measures", + "topics:open-reporting", + "topics:legal-compliance", + "topics:responsible-procurement", + "topics:stakeholder-inclusion", + "topics:data-privacy", + "topics:ip-protection", + "topics:competitive-fairness" + ] + }, + { + "@id": "topics:anti-corruption-measures", + "@type": "skos:Concept", + "prefLabel": "Anti-Corruption Measures", + "definition": "Preventing bribery, extortion, and corrupt practices through policies, controls, and organizational culture.", + "notation": "03.01", + "broader": "topics:ethical-governance", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 11 - Governance Requirements; UNTP governance.ethics." + }, + { + "@id": "topics:open-reporting", + "@type": "skos:Concept", + "prefLabel": "Open Reporting", + "definition": "Transparent disclosure of environmental, social, and governance performance to stakeholders and the public.", + "notation": "03.02", + "broader": "topics:ethical-governance", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 12 - Information Requirements; UNTP governance.transparency." + }, + { + "@id": "topics:legal-compliance", + "@type": "skos:Concept", + "prefLabel": "Legal Compliance", + "definition": "Adherence to applicable laws, regulations, and legal obligations in all jurisdictions of operation.", + "notation": "03.03", + "broader": "topics:ethical-governance", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 4 - Compliance Obligations; UNTP governance.compliance." + }, + { + "@id": "topics:responsible-procurement", + "@type": "skos:Concept", + "prefLabel": "Responsible Procurement", + "definition": "Ethical sourcing and purchasing practices that consider environmental, social, and governance factors in supplier selection.", + "notation": "03.04", + "broader": "topics:ethical-governance", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 10 - Supply Chain Responsibility." + }, + { + "@id": "topics:stakeholder-inclusion", + "@type": "skos:Concept", + "prefLabel": "Stakeholder Inclusion", + "definition": "Meaningful engagement with affected parties including workers, communities, and civil society in governance processes.", + "notation": "03.05", + "broader": "topics:ethical-governance", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 10 - Stakeholder Dialogue." + }, + { + "@id": "topics:data-privacy", + "@type": "skos:Concept", + "prefLabel": "Data Privacy", + "definition": "Protection of personal information and responsible data handling in compliance with privacy regulations and ethical standards.", + "notation": "03.06", + "broader": "topics:ethical-governance", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 12 - Digital Product Passport." + }, + { + "@id": "topics:ip-protection", + "@type": "skos:Concept", + "prefLabel": "Intellectual Property Protection", + "definition": "Respecting intellectual property rights including patents, trademarks, copyrights, and trade secrets.", + "notation": "03.07", + "broader": "topics:ethical-governance", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 11 - Governance Standards." + }, + { + "@id": "topics:competitive-fairness", + "@type": "skos:Concept", + "prefLabel": "Competitive Fairness", + "definition": "Ensuring fair market practices, preventing anti-competitive behavior, and maintaining a level playing field.", + "notation": "03.08", + "broader": "topics:ethical-governance", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 11 - Governance." + }, + + { + "@id": "topics:product-integrity", + "@type": "skos:Concept", + "prefLabel": "Product Integrity", + "definition": "Ensuring products are safe, reliable, and meet quality and sustainability standards throughout their lifecycle.", + "notation": "04", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "UN SDGs 9, 12; OECD Guidelines Chapter VIII: Consumer Interests; EU ESPR Art. 4-7 and Annex I.", + "relatedMatch": ["sdg:9", "sdg:12"], + "narrower": [ + "topics:product-safety-standards", + "topics:quality-performance", + "topics:substance-control", + "topics:product-longevity", + "topics:standards-adherence", + "topics:supply-chain-traceability", + "topics:consumer-information", + "topics:end-of-life-management" + ] + }, + { + "@id": "topics:product-safety-standards", + "@type": "skos:Concept", + "prefLabel": "Product Safety Standards", + "definition": "Ensuring consumer safety through compliance with product safety requirements, testing, and hazard prevention.", + "notation": "04.01", + "broader": "topics:product-integrity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 5 - Safety Requirements; UNTP social.safety." + }, + { + "@id": "topics:quality-performance", + "@type": "skos:Concept", + "prefLabel": "Quality Performance", + "definition": "Meeting defined performance specifications, functional requirements, and quality benchmarks for products and services.", + "notation": "04.02", + "broader": "topics:product-integrity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "EU ESPR Art. 5 - Performance Standards." + }, + { + "@id": "topics:substance-control", + "@type": "skos:Concept", + "prefLabel": "Substance Control", + "definition": "Banning or restricting harmful materials and substances of concern in product composition and manufacturing.", + "notation": "04.03", + "broader": "topics:product-integrity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Annex I - Substance Restrictions." + }, + { + "@id": "topics:product-longevity", + "@type": "skos:Concept", + "prefLabel": "Product Longevity", + "definition": "Enhancing product durability, repairability, and lifespan to reduce premature obsolescence and waste.", + "notation": "04.04", + "broader": "topics:product-integrity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 5 - Durability; UNTP circularity.design." + }, + { + "@id": "topics:standards-adherence", + "@type": "skos:Concept", + "prefLabel": "Standards Adherence", + "definition": "Compliance with applicable product certifications, industry standards, and regulatory requirements.", + "notation": "04.05", + "broader": "topics:product-integrity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "EU ESPR Art. 4 - Ecodesign Requirements." + }, + { + "@id": "topics:supply-chain-traceability", + "@type": "skos:Concept", + "prefLabel": "Supply Chain Traceability", + "definition": "Tracking product origins, components, and transformations throughout the supply chain to enable transparency and accountability.", + "notation": "04.06", + "broader": "topics:product-integrity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 12 - Digital Product Passport; UNTP governance.transparency." + }, + { + "@id": "topics:consumer-information", + "@type": "skos:Concept", + "prefLabel": "Consumer Information", + "definition": "Providing clear, accurate, and accessible product labeling and information to enable informed consumer choices.", + "notation": "04.07", + "broader": "topics:product-integrity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 7 - Information Obligations." + }, + { + "@id": "topics:end-of-life-management", + "@type": "skos:Concept", + "prefLabel": "End-of-Life Management", + "definition": "Effective collection, recycling, and disposal processes for products at end of useful life, minimizing environmental impact.", + "notation": "04.08", + "broader": "topics:product-integrity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 6 - End-of-Life Requirements." + }, + + { + "@id": "topics:circular-value-chains", + "@type": "skos:Concept", + "prefLabel": "Circular Value Chains", + "definition": "Advancing sustainability, circularity, and responsible practices throughout supply and production networks.", + "notation": "05", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "UN SDGs 8, 12, 17; OECD Guidelines Chapter II: General Policies and Chapter VI: Environment; EU ESPR Art. 8, 10.", + "relatedMatch": ["sdg:8", "sdg:12", "sdg:17"], + "narrower": [ + "topics:ethical-material-sourcing", + "topics:supplier-sustainability", + "topics:resource-circularity", + "topics:energy-optimization", + "topics:supply-chain-labor-rights", + "topics:origin-tracking", + "topics:supplier-development", + "topics:supply-chain-risk-reduction" + ] + }, + { + "@id": "topics:ethical-material-sourcing", + "@type": "skos:Concept", + "prefLabel": "Ethical Material Sourcing", + "definition": "Procuring raw materials through sustainable and responsible practices, avoiding conflict minerals and environmentally destructive extraction.", + "notation": "05.01", + "broader": "topics:circular-value-chains", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 10 - Supply Chain Due Diligence; UNTP governance.transparency." + }, + { + "@id": "topics:supplier-sustainability", + "@type": "skos:Concept", + "prefLabel": "Supplier Sustainability", + "definition": "Ensuring suppliers meet environmental, social, and governance requirements through assessment, monitoring, and collaboration.", + "notation": "05.02", + "broader": "topics:circular-value-chains", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:17"], + "scopeNote": "EU ESPR Art. 10 - Supply Chain Responsibility." + }, + { + "@id": "topics:resource-circularity", + "@type": "skos:Concept", + "prefLabel": "Resource Circularity", + "definition": "Promoting reuse, remanufacturing, and recycling of materials to create closed-loop resource flows.", + "notation": "05.03", + "broader": "topics:circular-value-chains", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 8 - Recycled Content; UNTP circularity.content." + }, + { + "@id": "topics:energy-optimization", + "@type": "skos:Concept", + "prefLabel": "Energy Optimization", + "definition": "Improving energy efficiency across supply chain operations including manufacturing, logistics, and warehousing.", + "notation": "05.04", + "broader": "topics:circular-value-chains", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:7"], + "scopeNote": "EU ESPR Art. 7 - Energy Efficiency." + }, + { + "@id": "topics:supply-chain-labor-rights", + "@type": "skos:Concept", + "prefLabel": "Supply Chain Labor Rights", + "definition": "Ensuring fair treatment of workers throughout the supply chain including subcontractors and informal workers.", + "notation": "05.05", + "broader": "topics:circular-value-chains", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:8"], + "scopeNote": "EU ESPR Annex I - Labor Standards." + }, + { + "@id": "topics:origin-tracking", + "@type": "skos:Concept", + "prefLabel": "Origin Tracking", + "definition": "Transparent documentation and verification of material and product origins throughout the supply chain.", + "notation": "05.06", + "broader": "topics:circular-value-chains", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 12 - Digital Product Passport." + }, + { + "@id": "topics:supplier-development", + "@type": "skos:Concept", + "prefLabel": "Supplier Development", + "definition": "Building supplier capacity and capability to meet sustainability requirements through training, support, and partnership.", + "notation": "05.07", + "broader": "topics:circular-value-chains", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:17"], + "scopeNote": "EU ESPR Art. 10 - Supply Chain Support." + }, + { + "@id": "topics:supply-chain-risk-reduction", + "@type": "skos:Concept", + "prefLabel": "Supply Chain Risk Reduction", + "definition": "Identifying, assessing, and mitigating environmental, social, and operational vulnerabilities in supply networks.", + "notation": "05.08", + "broader": "topics:circular-value-chains", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 10 - Risk Management." + }, + + { + "@id": "topics:economic-sustainability", + "@type": "skos:Concept", + "prefLabel": "Economic Sustainability", + "definition": "Balancing profitability with sustainable economic practices that create shared value for businesses and communities.", + "notation": "06", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "UN SDGs 8, 9; OECD Guidelines Chapter II: General Policies; EU ESPR Art. 5, 7, 10, 11.", + "relatedMatch": ["sdg:8", "sdg:9"], + "narrower": [ + "topics:business-resilience", + "topics:sustainable-investment", + "topics:green-innovation", + "topics:employment-opportunities", + "topics:regional-economic-growth", + "topics:resource-efficiency", + "topics:economic-risk-management", + "topics:supply-network-strength" + ] + }, + { + "@id": "topics:business-resilience", + "@type": "skos:Concept", + "prefLabel": "Business Resilience", + "definition": "Building long-term profitability and organizational resilience through sustainable business models and practices.", + "notation": "06.01", + "broader": "topics:economic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:8"], + "scopeNote": "EU ESPR Art. 11 - Governance for Sustainability." + }, + { + "@id": "topics:sustainable-investment", + "@type": "skos:Concept", + "prefLabel": "Sustainable Investment", + "definition": "Directing capital toward green initiatives, sustainable technologies, and projects with positive environmental and social outcomes.", + "notation": "06.02", + "broader": "topics:economic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "EU ESPR Art. 5 - Resource Efficiency." + }, + { + "@id": "topics:green-innovation", + "@type": "skos:Concept", + "prefLabel": "Green Innovation", + "definition": "Developing sustainable technologies, processes, and business models that reduce environmental impact while creating economic value.", + "notation": "06.03", + "broader": "topics:economic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "EU ESPR Art. 5 - Innovation Requirements." + }, + { + "@id": "topics:employment-opportunities", + "@type": "skos:Concept", + "prefLabel": "Employment Opportunities", + "definition": "Creating decent jobs and fostering inclusive economic participation through sustainable business growth.", + "notation": "06.04", + "broader": "topics:economic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:8"], + "scopeNote": "EU ESPR Art. 10 - Social Impact." + }, + { + "@id": "topics:regional-economic-growth", + "@type": "skos:Concept", + "prefLabel": "Regional Economic Growth", + "definition": "Supporting local economic development and equitable distribution of economic benefits in communities of operation.", + "notation": "06.05", + "broader": "topics:economic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:8"], + "scopeNote": "EU ESPR Art. 10 - Community Benefits; UNTP social.community." + }, + { + "@id": "topics:resource-efficiency", + "@type": "skos:Concept", + "prefLabel": "Resource Efficiency", + "definition": "Optimizing resource utilization to reduce costs and environmental impact while maintaining productivity.", + "notation": "06.06", + "broader": "topics:economic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:8"], + "scopeNote": "EU ESPR Art. 7 - Efficiency Standards." + }, + { + "@id": "topics:economic-risk-management", + "@type": "skos:Concept", + "prefLabel": "Economic Risk Management", + "definition": "Assessing and managing financial risks arising from environmental, social, and governance factors.", + "notation": "06.07", + "broader": "topics:economic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:8"], + "scopeNote": "EU ESPR Art. 11 - Governance." + }, + { + "@id": "topics:supply-network-strength", + "@type": "skos:Concept", + "prefLabel": "Supply Network Strength", + "definition": "Enhancing the stability, diversity, and resilience of value chain networks against disruption.", + "notation": "06.08", + "broader": "topics:economic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "EU ESPR Art. 10 - Supply Chain Resilience." + }, + + { + "@id": "topics:health-and-safety", + "@type": "skos:Concept", + "prefLabel": "Health and Safety Assurance", + "definition": "Prioritizing the health and safety of workers and communities through hazard prevention, preparedness, and wellbeing support.", + "notation": "07", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "UN SDG 3; OECD Guidelines Chapter V: Employment and Industrial Relations; EU ESPR Art. 5, 10 and Annex I.", + "relatedMatch": ["sdg:3"], + "narrower": [ + "topics:workplace-hazard-control", + "topics:emergency-readiness", + "topics:exposure-management", + "topics:living-conditions", + "topics:healthcare-access", + "topics:wellbeing-support", + "topics:nutrition-standards", + "topics:ergonomic-design" + ] + }, + { + "@id": "topics:workplace-hazard-control", + "@type": "skos:Concept", + "prefLabel": "Workplace Hazard Control", + "definition": "Systematic identification, assessment, and mitigation of workplace hazards to reduce risk of injury and illness, including incident reporting, investigation, and corrective action.", + "notation": "07.01", + "broader": "topics:health-and-safety", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3"], + "scopeNote": "EU ESPR Art. 10 - Social Sustainability; UNTP social.safety." + }, + { + "@id": "topics:emergency-readiness", + "@type": "skos:Concept", + "prefLabel": "Emergency Readiness", + "definition": "Preparedness planning, training, and response capabilities for workplace emergencies including fire, chemical spills, and natural disasters.", + "notation": "07.02", + "broader": "topics:health-and-safety", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3"], + "scopeNote": "EU ESPR Annex I - Safety Measures." + }, + { + "@id": "topics:exposure-management", + "@type": "skos:Concept", + "prefLabel": "Exposure Management", + "definition": "Controlling worker exposure to harmful chemical, biological, and physical agents through monitoring and protective measures.", + "notation": "07.03", + "broader": "topics:health-and-safety", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3"], + "scopeNote": "EU ESPR Annex I - Substance Safety." + }, + { + "@id": "topics:living-conditions", + "@type": "skos:Concept", + "prefLabel": "Living Conditions", + "definition": "Ensuring safe, sanitary, and dignified accommodation for workers where employer-provided housing is applicable.", + "notation": "07.04", + "broader": "topics:health-and-safety", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3"], + "scopeNote": "EU ESPR Art. 10 - Worker Welfare." + }, + { + "@id": "topics:healthcare-access", + "@type": "skos:Concept", + "prefLabel": "Healthcare Access", + "definition": "Providing access to medical support, occupational health services, and health insurance for workers.", + "notation": "07.05", + "broader": "topics:health-and-safety", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3"], + "scopeNote": "EU ESPR Art. 10 - Health Provisions." + }, + { + "@id": "topics:wellbeing-support", + "@type": "skos:Concept", + "prefLabel": "Wellbeing Support", + "definition": "Addressing worker mental health, stress management, and overall wellbeing through support programs and workplace culture.", + "notation": "07.06", + "broader": "topics:health-and-safety", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3"], + "scopeNote": "EU ESPR Art. 10 - Social Impact." + }, + { + "@id": "topics:nutrition-standards", + "@type": "skos:Concept", + "prefLabel": "Nutrition Standards", + "definition": "Ensuring safe, adequate, and nutritious food provisions for workers where employer-provided meals are applicable.", + "notation": "07.07", + "broader": "topics:health-and-safety", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3"], + "scopeNote": "EU ESPR Art. 10 - Worker Welfare." + }, + { + "@id": "topics:ergonomic-design", + "@type": "skos:Concept", + "prefLabel": "Ergonomic Design", + "definition": "Designing safe physical work environments that minimize musculoskeletal strain and support worker comfort and productivity.", + "notation": "07.08", + "broader": "topics:health-and-safety", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3"], + "scopeNote": "EU ESPR Art. 5 - Product Safety." + }, + + { + "@id": "topics:systemic-sustainability", + "@type": "skos:Concept", + "prefLabel": "Systemic Sustainability", + "definition": "Establishing management frameworks, policies, and processes for systematic improvement of environmental, social, and governance outcomes.", + "notation": "08", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "UN SDGs 12, 16; OECD Guidelines Chapter II: General Policies; EU ESPR Art. 4, 5, 10-12.", + "relatedMatch": ["sdg:12", "sdg:16"], + "narrower": [ + "topics:sustainability-policies", + "topics:risk-identification", + "topics:outcome-tracking", + "topics:capacity-building", + "topics:process-enhancement", + "topics:feedback-channels", + "topics:compliance-verification", + "topics:transparent-communication" + ] + }, + { + "@id": "topics:sustainability-policies", + "@type": "skos:Concept", + "prefLabel": "Sustainability Policies", + "definition": "Formal organizational commitments, policies, and targets for environmental, social, and governance performance.", + "notation": "08.01", + "broader": "topics:systemic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 11 - Governance Framework." + }, + { + "@id": "topics:risk-identification", + "@type": "skos:Concept", + "prefLabel": "Risk Identification", + "definition": "Systematic assessment and prioritization of environmental, social, and governance risks across operations and supply chains.", + "notation": "08.02", + "broader": "topics:systemic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 10 - Due Diligence." + }, + { + "@id": "topics:outcome-tracking", + "@type": "skos:Concept", + "prefLabel": "Outcome Tracking", + "definition": "Monitoring, measuring, and reporting on sustainability performance against defined targets and indicators.", + "notation": "08.03", + "broader": "topics:systemic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 12 - Reporting Requirements." + }, + { + "@id": "topics:capacity-building", + "@type": "skos:Concept", + "prefLabel": "Capacity Building", + "definition": "Training and developing stakeholder knowledge and skills to implement and maintain sustainability practices.", + "notation": "08.04", + "broader": "topics:systemic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 10 - Stakeholder Support." + }, + { + "@id": "topics:process-enhancement", + "@type": "skos:Concept", + "prefLabel": "Process Enhancement", + "definition": "Continuous improvement of operational processes to achieve better sustainability outcomes over time.", + "notation": "08.05", + "broader": "topics:systemic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 5 - Performance Improvement." + }, + { + "@id": "topics:feedback-channels", + "@type": "skos:Concept", + "prefLabel": "Feedback Channels", + "definition": "Accessible grievance mechanisms, whistleblower protections, and feedback systems for workers, communities, and stakeholders to raise concerns without fear of retaliation.", + "notation": "08.06", + "broader": "topics:systemic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 10 - Stakeholder Engagement." + }, + { + "@id": "topics:compliance-verification", + "@type": "skos:Concept", + "prefLabel": "Compliance Verification", + "definition": "Independent audits, inspections, and verification processes to confirm adherence to sustainability standards and regulations.", + "notation": "08.07", + "broader": "topics:systemic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 4 - Compliance Monitoring." + }, + { + "@id": "topics:transparent-communication", + "@type": "skos:Concept", + "prefLabel": "Transparent Communication", + "definition": "Public disclosure and reporting of sustainability policies, performance, and progress to stakeholders.", + "notation": "08.08", + "broader": "topics:systemic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 12 - Information Disclosure; UNTP governance.transparency." + }, + + { + "@id": "topics:trade-and-market-access", + "@type": "skos:Concept", + "prefLabel": "Trade and Market Access", + "definition": "Adherence to trade regulations, customs requirements, market access rules, and cross-border compliance frameworks.", + "notation": "09", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "WTO TBT and SPS Agreements; UNECE Trade Facilitation Recommendations; WCO Harmonized System.", + "relatedMatch": ["sdg:17"], + "narrower": [ + "topics:import-export-controls", + "topics:customs-classification", + "topics:rules-of-origin", + "topics:sanctions-compliance", + "topics:market-authorization", + "topics:trade-documentation", + "topics:tariff-and-duty-compliance", + "topics:mutual-recognition" + ] + }, + { + "@id": "topics:import-export-controls", + "@type": "skos:Concept", + "prefLabel": "Import and Export Controls", + "definition": "Compliance with cross-border trade restrictions, licensing requirements, and controlled goods regulations.", + "notation": "09.01", + "broader": "topics:trade-and-market-access", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:17"], + "scopeNote": "WTO Trade Facilitation Agreement; national export control regimes." + }, + { + "@id": "topics:customs-classification", + "@type": "skos:Concept", + "prefLabel": "Customs Classification", + "definition": "Accurate tariff classification and customs valuation of goods in accordance with the Harmonized System and national schedules.", + "notation": "09.02", + "broader": "topics:trade-and-market-access", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:17"], + "scopeNote": "WCO Harmonized System Convention." + }, + { + "@id": "topics:rules-of-origin", + "@type": "skos:Concept", + "prefLabel": "Rules of Origin", + "definition": "Verification of product origin to determine eligibility for preferential tariff treatment under trade agreements.", + "notation": "09.03", + "broader": "topics:trade-and-market-access", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:17"], + "scopeNote": "WTO Agreement on Rules of Origin; WCO Revised Kyoto Convention." + }, + { + "@id": "topics:sanctions-compliance", + "@type": "skos:Concept", + "prefLabel": "Sanctions Compliance", + "definition": "Adherence to international trade sanctions, embargoes, and restricted party screening requirements.", + "notation": "09.04", + "broader": "topics:trade-and-market-access", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "UN Security Council sanctions; national sanctions regimes." + }, + { + "@id": "topics:market-authorization", + "@type": "skos:Concept", + "prefLabel": "Market Authorization", + "definition": "Meeting regulatory requirements for market entry including product registration, type approval, and pre-market conformity assessment.", + "notation": "09.05", + "broader": "topics:trade-and-market-access", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:17"], + "scopeNote": "WTO TBT Agreement Art. 5 - Conformity Assessment Procedures." + }, + { + "@id": "topics:trade-documentation", + "@type": "skos:Concept", + "prefLabel": "Trade Documentation", + "definition": "Accuracy, completeness, and digital exchange of trade and customs documentation including certificates, invoices, and declarations.", + "notation": "09.06", + "broader": "topics:trade-and-market-access", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:17"], + "scopeNote": "UNECE Trade Facilitation Recommendations; UN/CEFACT standards." + }, + { + "@id": "topics:tariff-and-duty-compliance", + "@type": "skos:Concept", + "prefLabel": "Tariff and Duty Compliance", + "definition": "Correct assessment, declaration, and payment of applicable customs duties, taxes, and fees.", + "notation": "09.07", + "broader": "topics:trade-and-market-access", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:17"], + "scopeNote": "WCO Revised Kyoto Convention; national customs legislation." + }, + { + "@id": "topics:mutual-recognition", + "@type": "skos:Concept", + "prefLabel": "Mutual Recognition", + "definition": "Acceptance of conformity assessment results, certifications, and test reports across jurisdictions through mutual recognition agreements.", + "notation": "09.08", + "broader": "topics:trade-and-market-access", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:17"], + "scopeNote": "WTO TBT Agreement Art. 6; ILAC and IAF mutual recognition arrangements." + }, + + { + "@id": "topics:technical-conformity", + "@type": "skos:Concept", + "prefLabel": "Technical Conformity", + "definition": "Adherence to technical regulations, voluntary standards, and conformity assessment procedures that ensure product and process fitness for purpose.", + "notation": "10", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "WTO TBT Agreement; ISO/IEC 17000 series conformity assessment standards; Codex Alimentarius.", + "relatedMatch": ["sdg:9"], + "narrower": [ + "topics:technical-regulations", + "topics:voluntary-standards", + "topics:metrology-and-measurement", + "topics:testing-and-certification", + "topics:sanitary-and-phytosanitary", + "topics:interoperability-standards", + "topics:accessibility-requirements", + "topics:performance-specifications" + ] + }, + { + "@id": "topics:technical-regulations", + "@type": "skos:Concept", + "prefLabel": "Technical Regulations", + "definition": "Compliance with mandatory government-imposed technical requirements for products, processes, and production methods.", + "notation": "10.01", + "broader": "topics:technical-conformity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "WTO TBT Agreement Art. 2 - Technical Regulations." + }, + { + "@id": "topics:voluntary-standards", + "@type": "skos:Concept", + "prefLabel": "Voluntary Standards", + "definition": "Adherence to consensus-based standards developed by recognized standards bodies for products, services, and management systems.", + "notation": "10.02", + "broader": "topics:technical-conformity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "WTO TBT Agreement Art. 4 - Standards; ISO, IEC, ITU standards." + }, + { + "@id": "topics:metrology-and-measurement", + "@type": "skos:Concept", + "prefLabel": "Metrology and Measurement", + "definition": "Accuracy and traceability of measurements and calibrations to national and international measurement standards.", + "notation": "10.03", + "broader": "topics:technical-conformity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "BIPM International System of Units; OIML Recommendations." + }, + { + "@id": "topics:testing-and-certification", + "@type": "skos:Concept", + "prefLabel": "Testing and Certification", + "definition": "Third-party conformity assessment including laboratory testing, product certification, and inspection by accredited bodies.", + "notation": "10.04", + "broader": "topics:technical-conformity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "ISO/IEC 17065 Product Certification; ISO/IEC 17025 Testing Laboratories." + }, + { + "@id": "topics:sanitary-and-phytosanitary", + "@type": "skos:Concept", + "prefLabel": "Sanitary and Phytosanitary Measures", + "definition": "Compliance with food safety, animal health, and plant health standards designed to protect human, animal, and plant life.", + "notation": "10.05", + "broader": "topics:technical-conformity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3"], + "scopeNote": "WTO SPS Agreement; Codex Alimentarius; OIE; IPPC." + }, + { + "@id": "topics:interoperability-standards", + "@type": "skos:Concept", + "prefLabel": "Interoperability Standards", + "definition": "Conformity with standards ensuring compatibility, data exchange, and seamless interaction between systems, components, and services.", + "notation": "10.06", + "broader": "topics:technical-conformity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "ISO/IEC JTC 1 information technology standards; W3C web standards." + }, + { + "@id": "topics:accessibility-requirements", + "@type": "skos:Concept", + "prefLabel": "Accessibility Requirements", + "definition": "Compliance with inclusive design and accessibility standards ensuring products and services are usable by people with diverse abilities.", + "notation": "10.07", + "broader": "topics:technical-conformity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:10"], + "scopeNote": "ISO 21542 Accessibility; WCAG 2.1; EN 301 549." + }, + { + "@id": "topics:performance-specifications", + "@type": "skos:Concept", + "prefLabel": "Performance Specifications", + "definition": "Meeting defined functional, reliability, and performance benchmarks established by regulations, standards, or contractual requirements.", + "notation": "10.08", + "broader": "topics:technical-conformity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "Industry-specific performance standards and testing protocols." + }, + + { + "@id": "topics:information-security", + "@type": "skos:Concept", + "prefLabel": "Information Security and Digital Trust", + "definition": "Protection of data, digital systems, and information assets, and the establishment of trust frameworks for digital interactions.", + "notation": "11", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "ISO/IEC 27001 Information Security; GDPR; eIDAS; NIST Cybersecurity Framework.", + "relatedMatch": ["sdg:9", "sdg:16"], + "narrower": [ + "topics:data-protection-and-privacy", + "topics:cybersecurity-controls", + "topics:digital-identity-and-trust", + "topics:access-management", + "topics:incident-response", + "topics:system-integrity", + "topics:encryption-and-data-security", + "topics:audit-and-accountability" + ] + }, + { + "@id": "topics:data-protection-and-privacy", + "@type": "skos:Concept", + "prefLabel": "Data Protection and Privacy", + "definition": "Safeguarding personal and sensitive data in compliance with privacy regulations, consent requirements, and ethical data handling principles.", + "notation": "11.01", + "broader": "topics:information-security", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "GDPR; ISO/IEC 27701 Privacy Information Management." + }, + { + "@id": "topics:cybersecurity-controls", + "@type": "skos:Concept", + "prefLabel": "Cybersecurity Controls", + "definition": "Implementation of technical and organizational security measures to protect digital infrastructure from threats and vulnerabilities.", + "notation": "11.02", + "broader": "topics:information-security", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "ISO/IEC 27001; NIST Cybersecurity Framework; IEC 62443." + }, + { + "@id": "topics:digital-identity-and-trust", + "@type": "skos:Concept", + "prefLabel": "Digital Identity and Trust", + "definition": "Verification and assurance of digital identities, credentials, and trust relationships in electronic transactions and communications.", + "notation": "11.03", + "broader": "topics:information-security", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "eIDAS Regulation; W3C Verifiable Credentials; UNTP Digital Identity Anchor." + }, + { + "@id": "topics:access-management", + "@type": "skos:Concept", + "prefLabel": "Access Management", + "definition": "Controls for authentication, authorization, and system access ensuring only authorized parties can access resources and data.", + "notation": "11.04", + "broader": "topics:information-security", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "ISO/IEC 27001 Annex A - Access Control." + }, + { + "@id": "topics:incident-response", + "@type": "skos:Concept", + "prefLabel": "Incident Response and Recovery", + "definition": "Preparedness planning, detection, response procedures, and recovery capabilities for security breaches and system failures.", + "notation": "11.05", + "broader": "topics:information-security", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "ISO/IEC 27035 Incident Management; NIST SP 800-61." + }, + { + "@id": "topics:system-integrity", + "@type": "skos:Concept", + "prefLabel": "System Integrity and Availability", + "definition": "Ensuring reliability, uptime, and integrity of digital systems through resilient architecture and continuity planning.", + "notation": "11.06", + "broader": "topics:information-security", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "ISO 22301 Business Continuity; ISO/IEC 27001 Availability Controls." + }, + { + "@id": "topics:encryption-and-data-security", + "@type": "skos:Concept", + "prefLabel": "Encryption and Data Security", + "definition": "Protection of data confidentiality and integrity in transit and at rest through cryptographic controls and key management.", + "notation": "11.07", + "broader": "topics:information-security", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "ISO/IEC 19790 Cryptographic Modules; NIST FIPS 140." + }, + { + "@id": "topics:audit-and-accountability", + "@type": "skos:Concept", + "prefLabel": "Audit Trail and Accountability", + "definition": "Logging, monitoring, and accountability mechanisms for digital activities to support compliance verification and forensic analysis.", + "notation": "11.08", + "broader": "topics:information-security", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "ISO/IEC 27001 Annex A - Logging and Monitoring." + } + ] +} diff --git a/src/dppvalidator/vocabularies/eudpp_actors.py b/src/dppvalidator/vocabularies/eudpp_actors.py new file mode 100644 index 0000000..b26214f --- /dev/null +++ b/src/dppvalidator/vocabularies/eudpp_actors.py @@ -0,0 +1,639 @@ +"""EU DPP Core Ontology actor and role definitions. + +Provides dataclass representations of actors and roles from the EU DPP +Core Ontology, based on ESPR Art 2(37-48) economic operator definitions. + +Source: EU DPP Core Ontology v1.5.1 (Actors and Roles module) +Namespace: http://dpp.taltech.ee/EUDPP# +DOI: 10.5281/zenodo.15270342 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING, ClassVar + +if TYPE_CHECKING: + pass + + +# ============================================================================= +# Actor Class Enums +# ============================================================================= + + +class EUDPPActorClass(str, Enum): + """EU DPP Core Ontology actor class URIs.""" + + ACTOR = "eudpp:Actor" + LEGAL_PERSON = "eudpp:LegalPerson" + NATURAL_PERSON = "eudpp:NaturalPerson" + FACILITY = "eudpp:Facility" + + +class EUDPPRoleClass(str, Enum): + """EU DPP Core Ontology role class URIs. + + Role hierarchy per ESPR Art 2(37-55). + """ + + # Base role + ROLE = "eudpp:Role" + + # Economic operators (ESPR Art 2(46)) + ECONOMIC_OPERATOR = "eudpp:EconomicOperatorRole" + MANUFACTURER = "eudpp:ManufacturerRole" + IMPORTER = "eudpp:ImporterRole" + DISTRIBUTOR = "eudpp:DistributorRole" + DEALER = "eudpp:DealerRole" + FULFILMENT_PROVIDER = "eudpp:FulfilmentServiceProviderRole" + AUTHORISED_REP = "eudpp:AuthorisedRepresentativeRole" + + # Authorities + AUTHORITY = "eudpp:AuthorityRole" + MARKET_SURVEILLANCE = "eudpp:MarketSurveillanceAuthorityRole" + CUSTOMS = "eudpp:CustomsAuthorityRole" + + # Customers (ESPR Art 2(35)) + CUSTOMER = "eudpp:CustomerRole" + CONSUMER = "eudpp:ConsumerRole" + END_USER = "eudpp:EndUserRole" + + # Independent operators (ESPR Art 2(47)) + INDEPENDENT_OPERATOR = "eudpp:IndependentOperatorRole" + PROFESSIONAL_REPAIRER = "eudpp:ProfessionalRepairerRole" + + # Circular economy roles + RECYCLER = "eudpp:RecyclerRole" + REFURBISHER = "eudpp:RefurbisherRole" + REMANUFACTURER = "eudpp:RemanufacturerRole" + + # Service providers + DPP_SERVICE_PROVIDER = "eudpp:DPPServiceProviderRole" + CONFORMITY_BODY = "eudpp:ConformityAssessmentBodyRole" + NOTIFIED_BODY = "eudpp:NotifiedBodyRole" + CREDENTIAL_AGENCY = "eudpp:CredentialAgencyRole" + ISSUING_AGENCY = "eudpp:IssuingAgencyRole" + + +# ============================================================================= +# Role Hierarchy +# ============================================================================= + + +ROLE_HIERARCHY: dict[str, list[str]] = { + "eudpp:Role": [ + "eudpp:EconomicOperatorRole", + "eudpp:AuthorityRole", + "eudpp:CustomerRole", + "eudpp:EndUserRole", + "eudpp:IndependentOperatorRole", + "eudpp:ProfessionalRepairerRole", + "eudpp:RecyclerRole", + "eudpp:RefurbisherRole", + "eudpp:RemanufacturerRole", + "eudpp:DPPServiceProviderRole", + "eudpp:ConformityAssessmentBodyRole", + "eudpp:CredentialAgencyRole", + "eudpp:IssuingAgencyRole", + ], + "eudpp:EconomicOperatorRole": [ + "eudpp:ManufacturerRole", + "eudpp:ImporterRole", + "eudpp:DistributorRole", + "eudpp:DealerRole", + "eudpp:FulfilmentServiceProviderRole", + "eudpp:AuthorisedRepresentativeRole", + ], + "eudpp:AuthorityRole": [ + "eudpp:MarketSurveillanceAuthorityRole", + "eudpp:CustomsAuthorityRole", + ], + "eudpp:CustomerRole": [ + "eudpp:ConsumerRole", + ], + "eudpp:ConformityAssessmentBodyRole": [ + "eudpp:NotifiedBodyRole", + ], +} + + +ACTOR_HIERARCHY: dict[str, list[str]] = { + "eudpp:Actor": [ + "eudpp:LegalPerson", + "eudpp:NaturalPerson", + ], +} + + +# ============================================================================= +# Actor Dataclasses +# ============================================================================= + + +@dataclass(frozen=True, slots=True) +class Actor: + """Actor per EU DPP Core Ontology. + + Actor means a legal or natural person. One actor can take on + multiple roles. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPActorClass.ACTOR.value + + actor_name: str | None = None + electronic_contact: str | None = None + postal_address: str | None = None + registered_trade_name: str | None = None + registered_trademark: str | None = None + + +@dataclass(frozen=True, slots=True) +class LegalPerson(Actor): + """Legal person per EU DPP Core Ontology. + + A body of persons or an entity (as a corporation) considered as + having many of the rights and responsibilities of a natural person. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPActorClass.LEGAL_PERSON.value + + unique_operator_id: str | None = None + established_in: str | None = None + + +@dataclass(frozen=True, slots=True) +class NaturalPerson(Actor): + """Natural person per EU DPP Core Ontology. + + A human being as distinguished from a person (as a corporation) + created by operation of law. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPActorClass.NATURAL_PERSON.value + + residing_in: str | None = None + + +@dataclass(frozen=True, slots=True) +class Facility: + """Facility per EU DPP Core Ontology. + + A facility may be a place where specific activities occur, such as + a production/manufacturing facility (factory) or a place that + generates electricity. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPActorClass.FACILITY.value + + unique_facility_id: str + name: str | None = None + location: str | None = None + + +# ============================================================================= +# Role Dataclasses +# ============================================================================= + + +@dataclass(frozen=True, slots=True) +class Role: + """Role per EU DPP Core Ontology. + + Role means a set of tasks typically performed by one actor + (e.g. Responsible Economic Operator, Independent operator). + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.ROLE.value + + role_name: str | None = None + description: str | None = None + + +@dataclass(frozen=True, slots=True) +class EconomicOperatorRole(Role): + """Economic operator role per ESPR Art 2(46). + + Economic operator means the manufacturer, the authorised + representative, the importer, the distributor, the dealer + and the fulfilment service provider. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.ECONOMIC_OPERATOR.value + + +@dataclass(frozen=True, slots=True) +class ManufacturerRole(EconomicOperatorRole): + """Manufacturer role per ESPR Art 2(42). + + Manufacturer means any natural or legal person that manufactures + a product or that has a product designed or manufactured and + markets that product under their name or trademark. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.MANUFACTURER.value + + +@dataclass(frozen=True, slots=True) +class ImporterRole(EconomicOperatorRole): + """Importer role per ESPR Art 2(44). + + Importer means any natural or legal person established in the + Union that places a product from a third country on the Union + market. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.IMPORTER.value + + +@dataclass(frozen=True, slots=True) +class DistributorRole(EconomicOperatorRole): + """Distributor role per ESPR Art 2(45). + + Distributor means any natural or legal person in the supply chain, + other than the manufacturer or the importer, that makes a product + available on the market. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.DISTRIBUTOR.value + + +@dataclass(frozen=True, slots=True) +class DealerRole(EconomicOperatorRole): + """Dealer role per ESPR Art 2(55). + + Dealer means a distributor or any other natural or legal person + that offers products for sale, hire or hire purchase, or that + displays products, to end users in the course of a commercial + activity. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.DEALER.value + + +@dataclass(frozen=True, slots=True) +class FulfilmentServiceProviderRole(EconomicOperatorRole): + """Fulfilment service provider role per Regulation (EU) 2019/1020 Art 3(11). + + Fulfilment service provider means any natural or legal person + offering, in the course of commercial activity, at least two of + the following services: warehousing, packaging, addressing and + dispatching, without having ownership of the products involved. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.FULFILMENT_PROVIDER.value + + +@dataclass(frozen=True, slots=True) +class AuthorisedRepresentativeRole(EconomicOperatorRole): + """Authorised representative role per ESPR Art 2(43). + + Authorised representative means any natural or legal person + established in the Union that has received a written mandate + from the manufacturer to act on the manufacturer's behalf. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.AUTHORISED_REP.value + + +@dataclass(frozen=True, slots=True) +class AuthorityRole(Role): + """Authority role per EU DPP Core Ontology. + + A role of an actor who exercises authority. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.AUTHORITY.value + + +@dataclass(frozen=True, slots=True) +class MarketSurveillanceAuthorityRole(AuthorityRole): + """Market surveillance authority role per Regulation (EU) 2019/1020 Art 3(4). + + Market surveillance authority means an authority designated by a + Member State under Article 10 as responsible for carrying out + market surveillance in the territory of that Member State. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.MARKET_SURVEILLANCE.value + + +@dataclass(frozen=True, slots=True) +class CustomsAuthorityRole(AuthorityRole): + """Customs authority role per Regulation (EU) No 952/2013 Art 5(1). + + Customs authorities means the customs administrations of the Member + States responsible for applying the customs legislation. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.CUSTOMS.value + + +@dataclass(frozen=True, slots=True) +class CustomerRole(Role): + """Customer role per ESPR Art 2(35). + + Customer means a natural or legal person that purchases, hires or + receives a product for their own use whether or not acting for + purposes which are outside their trade, business, craft or profession. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.CUSTOMER.value + + +@dataclass(frozen=True, slots=True) +class ConsumerRole(CustomerRole): + """Consumer role per Directive (EU) 2019/771 Art 2(2). + + Consumer means any natural person who, in relation to contracts + covered by Directive (EU) 2019/771, is acting for purposes which + are outside that person's trade, business, craft or profession. + + Note: ConsumerRole can only be held by NaturalPerson. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.CONSUMER.value + + +@dataclass(frozen=True, slots=True) +class EndUserRole(Role): + """End user role per Regulation (EU) 2019/1020 Art 3(21). + + End user means any natural or legal person residing or established + in the Union, to whom a product has been made available either as + a consumer outside of any trade, business, craft or profession or + as a professional end user in the course of its industrial or + professional activities. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.END_USER.value + + +@dataclass(frozen=True, slots=True) +class IndependentOperatorRole(Role): + """Independent operator role per ESPR Art 2(47). + + Independent operator means natural or legal person that is + independent of the manufacturer and is directly or indirectly + involved in the refurbishment, repair, maintenance or repurposing + of a product. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.INDEPENDENT_OPERATOR.value + + +@dataclass(frozen=True, slots=True) +class ProfessionalRepairerRole(Role): + """Professional repairer role per ESPR Art 2(48). + + Professional repairer means a natural or legal person that provides + professional repair or maintenance services for a product, + irrespective of whether that person acts within the manufacturer's + distribution system or independently. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.PROFESSIONAL_REPAIRER.value + + +@dataclass(frozen=True, slots=True) +class RecyclerRole(Role): + """Recycler role per EU DPP Core Ontology. + + Role that can be held by actor performing "recycling". + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.RECYCLER.value + + +@dataclass(frozen=True, slots=True) +class RefurbisherRole(Role): + """Refurbisher role per EU DPP Core Ontology. + + Role that can be held by actor performing "refurbishment". + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.REFURBISHER.value + + +@dataclass(frozen=True, slots=True) +class RemanufacturerRole(Role): + """Remanufacturer role per EU DPP Core Ontology. + + Role that can be held by actor performing "remanufacturing". + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.REMANUFACTURER.value + + +@dataclass(frozen=True, slots=True) +class DPPServiceProviderRole(Role): + """DPP service provider role per ESPR Art 2(32). + + Digital product passport service provider means a natural or legal + person that is an independent third-party authorised by the economic + operator which places the product on the market or puts it into + service and that processes the digital product passport data for + that product. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.DPP_SERVICE_PROVIDER.value + + +@dataclass(frozen=True, slots=True) +class ConformityAssessmentBodyRole(Role): + """Conformity assessment body role per ESPR Art 2(52). + + Conformity assessment body means a body that performs conformity + assessment activities including calibration, testing, certification + and inspection. + + Note: ConformityAssessmentBodyRole can only be held by LegalPerson. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.CONFORMITY_BODY.value + + +@dataclass(frozen=True, slots=True) +class NotifiedBodyRole(ConformityAssessmentBodyRole): + """Notified body role per ESPR Chapter IX. + + Notified body means a conformity assessment body notified in + accordance with Chapter IX. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.NOTIFIED_BODY.value + + +@dataclass(frozen=True, slots=True) +class CredentialAgencyRole(Role): + """Credential agency role per ESPR Art 11. + + Credential agency means a legal person that provides (professional) + credentials to parties, which may be used to make and verify a + variety of claims in the DPP. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.CREDENTIAL_AGENCY.value + + +@dataclass(frozen=True, slots=True) +class IssuingAgencyRole(Role): + """Issuing agency role per ESPR Art 12(4a). + + Issuing agency is a legal person that provides unique identifiers + and data carriers. Under specific conditions, economic operators + may perform the functions associated with an Issuing Agency. + + Source: actors_roles_v1.5.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPRoleClass.ISSUING_AGENCY.value + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def get_role_hierarchy(role_uri: str) -> list[str]: + """Get all sub-roles of a given role. + + Args: + role_uri: EU DPP role URI (e.g., "eudpp:EconomicOperatorRole") + + Returns: + List of sub-role URIs + """ + return ROLE_HIERARCHY.get(role_uri, []) + + +def get_actor_hierarchy(actor_uri: str) -> list[str]: + """Get all sub-types of a given actor class. + + Args: + actor_uri: EU DPP actor URI (e.g., "eudpp:Actor") + + Returns: + List of actor subclass URIs + """ + return ACTOR_HIERARCHY.get(actor_uri, []) + + +def is_role_subclass_of(child_uri: str, parent_uri: str) -> bool: + """Check if a role is a subclass of another. + + Args: + child_uri: Potential child role URI + parent_uri: Potential parent role URI + + Returns: + True if child is a subclass of parent + """ + if child_uri == parent_uri: + return True + + for parent, children in ROLE_HIERARCHY.items(): + if child_uri in children: + if parent == parent_uri: + return True + return is_role_subclass_of(parent, parent_uri) + + return False + + +def is_economic_operator_role(role_uri: str) -> bool: + """Check if a role is an economic operator role per ESPR Art 2(46). + + Args: + role_uri: Role URI to check + + Returns: + True if role is EconomicOperatorRole or a subclass + """ + return is_role_subclass_of(role_uri, EUDPPRoleClass.ECONOMIC_OPERATOR.value) + + +def get_all_economic_operator_roles() -> list[str]: + """Get all economic operator role URIs. + + Returns: + List of economic operator role URIs + """ + return [ + EUDPPRoleClass.ECONOMIC_OPERATOR.value, + EUDPPRoleClass.MANUFACTURER.value, + EUDPPRoleClass.IMPORTER.value, + EUDPPRoleClass.DISTRIBUTOR.value, + EUDPPRoleClass.DEALER.value, + EUDPPRoleClass.FULFILMENT_PROVIDER.value, + EUDPPRoleClass.AUTHORISED_REP.value, + ] + + +def get_all_circular_economy_roles() -> list[str]: + """Get all circular economy related role URIs. + + Returns: + List of circular economy role URIs + """ + return [ + EUDPPRoleClass.RECYCLER.value, + EUDPPRoleClass.REFURBISHER.value, + EUDPPRoleClass.REMANUFACTURER.value, + EUDPPRoleClass.PROFESSIONAL_REPAIRER.value, + ] diff --git a/src/dppvalidator/vocabularies/eudpp_classes.py b/src/dppvalidator/vocabularies/eudpp_classes.py new file mode 100644 index 0000000..6abb71e --- /dev/null +++ b/src/dppvalidator/vocabularies/eudpp_classes.py @@ -0,0 +1,650 @@ +"""EU DPP Core Ontology class definitions. + +Provides dataclass representations of the EU DPP Core Ontology class hierarchy, +based on the official CIRPASS-2 ontology v1.7.1. + +Source: EU DPP Core Ontology v1.7.1 (Product and DPP module) +Namespace: http://dpp.taltech.ee/EUDPP# +DOI: 10.5281/zenodo.15270342 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from enum import Enum +from typing import ClassVar + + +class EUDPPClass(str, Enum): + """EU DPP Core Ontology class URIs.""" + + # Core classes + DPP = "eudpp:DPP" + PRODUCT = "eudpp:Product" + PROPERTY = "eudpp:Property" + QUANTITATIVE_PROPERTY = "eudpp:QuantitativeProperty" + DOCUMENT = "eudpp:Document" + CLASSIFICATION_CODE = "eudpp:ClassificationCode" + + # Environmental footprint hierarchy + ENVIRONMENTAL_FOOTPRINT = "eudpp:EnvironmentalFootprint" + CARBON_FOOTPRINT = "eudpp:CarbonFootprint" + MATERIAL_FOOTPRINT = "eudpp:MaterialFootprint" + + # Environmental pollution hierarchy + ENVIRONMENTAL_POLLUTION = "eudpp:EnvironmentalPollution" + ENVIRONMENTAL_EMISSION = "eudpp:EnvironmentalEmission" + EMISSION_TO_AIR = "eudpp:EmissionToAir" + EMISSION_TO_WATER = "eudpp:EmissionToWater" + EMISSION_TO_SOIL = "eudpp:EmissionToSoil" + PLASTICS_RELEASE = "eudpp:PlasticsRelease" + MICROPLASTIC_RELEASE = "eudpp:MicroplasticRelease" + NANOPLASTIC_RELEASE = "eudpp:NanoplasticRelease" + + # Resource consumption hierarchy + RESOURCE_CONSUMPTION = "eudpp:ResourceConsumption" + ENERGY_CONSUMPTION = "eudpp:EnergyConsumption" + WATER_CONSUMPTION = "eudpp:WaterConsumption" + LAND_USE = "eudpp:LandUse" + RECYCLED_MATERIALS_USE = "eudpp:RecycledMaterialsUse" + SUSTAINABLE_RENEWABLE_MATERIALS_USE = "eudpp:SustainableRenewableMaterialsUse" + + # Circular economy indicators + CIRCULAR_ECONOMY_INDICATOR = "eudpp:CircularEconomyIndicator" + RECYCLING_RATE = "eudpp:RecyclingRate" + RECYCLING_COLLECTION_RATE = "eudpp:RecyclingCollectionRate" + RECOVERABLE_RATE = "eudpp:RecoverableRate" + + # Waste generation + WASTE_GENERATION_AMOUNT = "eudpp:WasteGenerationAmount" + HAZARDOUS_WASTE_AMOUNT = "eudpp:HazardousWasteAmount" + PACKAGING_WASTE_AMOUNT = "eudpp:PackagingWasteAmount" + PLASTICS_WASTE_AMOUNT = "eudpp:PlasticsWasteAmount" + + # Quality indicators + QUALITY_INDICATOR = "eudpp:QualityIndicator" + DURABILITY = "eudpp:Durability" + RELIABILITY = "eudpp:Reliability" + + # Product dimensions + PRODUCT_DIMENSION = "eudpp:ProductDimension" + HEIGHT = "eudpp:Height" + LENGTH = "eudpp:Length" + WIDTH = "eudpp:Width" + VOLUME = "eudpp:Volume" + WEIGHT = "eudpp:Weight" + + # Substances of concern + CONCENTRATION_OF_SOC = "eudpp:ConcentrationOfSubstanceOfConcern" + THRESHOLD_OF_SOC = "eudpp:ThresholdOfSubstanceOfConcern" + + # Instructions + DIGITAL_INSTRUCTION = "eudpp:DigitalInstruction" + + # Other + PRODUCT_TO_PACKAGING_RATIO = "eudpp:ProductToPackagingRatio" + + +@dataclass(frozen=True, slots=True) +class QuantitativeProperty: + """Base class for quantitative properties per EU DPP Core Ontology. + + A property of the product that is expressed by a quantity value. + """ + + _class_uri: ClassVar[str] = EUDPPClass.QUANTITATIVE_PROPERTY.value + + numerical_value: Decimal + measurement_unit: str + tolerance: Decimal | None = None + dictionary_reference: str | None = None + + +@dataclass(frozen=True, slots=True) +class EnvironmentalFootprint(QuantitativeProperty): + """Environmental footprint per ESPR Art 2(24). + + A quantification of environmental impacts resulting from a product + throughout its life cycle, based on the Product Environmental Footprint + method (Recommendation (EU) 2021/2279). + """ + + _class_uri: ClassVar[str] = EUDPPClass.ENVIRONMENTAL_FOOTPRINT.value + + +@dataclass(frozen=True, slots=True) +class CarbonFootprint(EnvironmentalFootprint): + """Carbon footprint per ESPR Art 2(25). + + The sum of greenhouse gas emissions and removals in a product system, + expressed as CO2 equivalents, based on life cycle assessment using + the single impact category of climate change. + """ + + _class_uri: ClassVar[str] = EUDPPClass.CARBON_FOOTPRINT.value + + +@dataclass(frozen=True, slots=True) +class MaterialFootprint(EnvironmentalFootprint): + """Material footprint per ESPR Art 2(26). + + The total amount of raw materials extracted to meet final consumption + demands. + """ + + _class_uri: ClassVar[str] = EUDPPClass.MATERIAL_FOOTPRINT.value + + +@dataclass(frozen=True, slots=True) +class EnvironmentalPollution(QuantitativeProperty): + """Environmental pollution base class. + + The addition of any substance or form of energy to the environment + at a rate faster than it can be dispersed or safely stored. + """ + + _class_uri: ClassVar[str] = EUDPPClass.ENVIRONMENTAL_POLLUTION.value + + +@dataclass(frozen=True, slots=True) +class EnvironmentalEmission(EnvironmentalPollution): + """Environmental emission base class per ESPR Annex I (q). + + Emissions to air, water or soil released in one or more lifecycle + stages of the product. + """ + + _class_uri: ClassVar[str] = EUDPPClass.ENVIRONMENTAL_EMISSION.value + + lifecycle_stage: str | None = None + + +@dataclass(frozen=True, slots=True) +class EmissionToAir(EnvironmentalEmission): + """Emission to air per ESPR Annex I (q).""" + + _class_uri: ClassVar[str] = EUDPPClass.EMISSION_TO_AIR.value + + +@dataclass(frozen=True, slots=True) +class EmissionToWater(EnvironmentalEmission): + """Emission to water per ESPR Annex I (q).""" + + _class_uri: ClassVar[str] = EUDPPClass.EMISSION_TO_WATER.value + + +@dataclass(frozen=True, slots=True) +class EmissionToSoil(EnvironmentalEmission): + """Emission to soil per ESPR Annex I (q).""" + + _class_uri: ClassVar[str] = EUDPPClass.EMISSION_TO_SOIL.value + + +@dataclass(frozen=True, slots=True) +class PlasticsRelease(EnvironmentalPollution): + """Plastics release base class per ESPR Annex I (p). + + Microplastic and nanoplastic release during relevant product life + cycle stages, including manufacturing, transport, use and end of life. + """ + + _class_uri: ClassVar[str] = EUDPPClass.PLASTICS_RELEASE.value + + lifecycle_stage: str | None = None + + +@dataclass(frozen=True, slots=True) +class MicroplasticRelease(PlasticsRelease): + """Microplastic release per ESPR Annex I (p).""" + + _class_uri: ClassVar[str] = EUDPPClass.MICROPLASTIC_RELEASE.value + + +@dataclass(frozen=True, slots=True) +class NanoplasticRelease(PlasticsRelease): + """Nanoplastic release per ESPR Annex I (p).""" + + _class_uri: ClassVar[str] = EUDPPClass.NANOPLASTIC_RELEASE.value + + +@dataclass(frozen=True, slots=True) +class ResourceConsumption(QuantitativeProperty): + """Resource consumption base class. + + Resource consumption of the product during its lifecycle. + """ + + _class_uri: ClassVar[str] = EUDPPClass.RESOURCE_CONSUMPTION.value + + +@dataclass(frozen=True, slots=True) +class EnergyConsumption(ResourceConsumption): + """Energy consumption of the product.""" + + _class_uri: ClassVar[str] = EUDPPClass.ENERGY_CONSUMPTION.value + + +@dataclass(frozen=True, slots=True) +class WaterConsumption(ResourceConsumption): + """Water consumption of the product.""" + + _class_uri: ClassVar[str] = EUDPPClass.WATER_CONSUMPTION.value + + +@dataclass(frozen=True, slots=True) +class LandUse(ResourceConsumption): + """Land use of the product.""" + + _class_uri: ClassVar[str] = EUDPPClass.LAND_USE.value + + +@dataclass(frozen=True, slots=True) +class RecycledMaterialsUse(ResourceConsumption): + """Recycled materials use of the product.""" + + _class_uri: ClassVar[str] = EUDPPClass.RECYCLED_MATERIALS_USE.value + + +@dataclass(frozen=True, slots=True) +class SustainableRenewableMaterialsUse(ResourceConsumption): + """Sustainable renewable materials use of the product.""" + + _class_uri: ClassVar[str] = EUDPPClass.SUSTAINABLE_RENEWABLE_MATERIALS_USE.value + + +@dataclass(frozen=True, slots=True) +class CircularEconomyIndicator(QuantitativeProperty): + """Circular economy indicator base class. + + Measures for monitoring the transition to a circular economy and + measuring the effects of new policy and trends. + """ + + _class_uri: ClassVar[str] = EUDPPClass.CIRCULAR_ECONOMY_INDICATOR.value + + +@dataclass(frozen=True, slots=True) +class RecyclingRate(CircularEconomyIndicator): + """Recycling rate of the product.""" + + _class_uri: ClassVar[str] = EUDPPClass.RECYCLING_RATE.value + + +@dataclass(frozen=True, slots=True) +class RecyclingCollectionRate(CircularEconomyIndicator): + """Recycling collection rate of the product.""" + + _class_uri: ClassVar[str] = EUDPPClass.RECYCLING_COLLECTION_RATE.value + + +@dataclass(frozen=True, slots=True) +class RecoverableRate(CircularEconomyIndicator): + """Recoverable rate of the product.""" + + _class_uri: ClassVar[str] = EUDPPClass.RECOVERABLE_RATE.value + + +@dataclass(frozen=True, slots=True) +class WasteGenerationAmount(QuantitativeProperty): + """Waste generation amount base class.""" + + _class_uri: ClassVar[str] = EUDPPClass.WASTE_GENERATION_AMOUNT.value + + +@dataclass(frozen=True, slots=True) +class HazardousWasteAmount(WasteGenerationAmount): + """Hazardous waste amount of the product.""" + + _class_uri: ClassVar[str] = EUDPPClass.HAZARDOUS_WASTE_AMOUNT.value + + +@dataclass(frozen=True, slots=True) +class PackagingWasteAmount(WasteGenerationAmount): + """Packaging waste amount of the product.""" + + _class_uri: ClassVar[str] = EUDPPClass.PACKAGING_WASTE_AMOUNT.value + + +@dataclass(frozen=True, slots=True) +class PlasticsWasteAmount(WasteGenerationAmount): + """Plastics waste amount of the product.""" + + _class_uri: ClassVar[str] = EUDPPClass.PLASTICS_WASTE_AMOUNT.value + + +@dataclass(frozen=True, slots=True) +class QualityIndicator(QuantitativeProperty): + """Quality indicator base class. + + Product characteristics like durability and reliability. + """ + + _class_uri: ClassVar[str] = EUDPPClass.QUALITY_INDICATOR.value + + +@dataclass(frozen=True, slots=True) +class Durability(QualityIndicator): + """Durability per ESPR Art 2(22). + + The ability of a product to maintain over time its function and + performance under specified conditions of use, maintenance and repair. + """ + + _class_uri: ClassVar[str] = EUDPPClass.DURABILITY.value + + +@dataclass(frozen=True, slots=True) +class Reliability(QualityIndicator): + """Reliability per ESPR Art 2(16). + + The probability that a product functions as required under given + conditions for a given duration. Usually expressed in terms of + mean time between failures (MTBF). + """ + + _class_uri: ClassVar[str] = EUDPPClass.RELIABILITY.value + + mtbf_hours: Decimal | None = None + + +@dataclass(frozen=True, slots=True) +class ProductDimension(QuantitativeProperty): + """Product dimension base class per ESPR Annex I. + + Measurements of length, width, height, volume, and weight. + """ + + _class_uri: ClassVar[str] = EUDPPClass.PRODUCT_DIMENSION.value + + +@dataclass(frozen=True, slots=True) +class Height(ProductDimension): + """Height of the product.""" + + _class_uri: ClassVar[str] = EUDPPClass.HEIGHT.value + + +@dataclass(frozen=True, slots=True) +class Length(ProductDimension): + """Length of the product.""" + + _class_uri: ClassVar[str] = EUDPPClass.LENGTH.value + + +@dataclass(frozen=True, slots=True) +class Width(ProductDimension): + """Width of the product.""" + + _class_uri: ClassVar[str] = EUDPPClass.WIDTH.value + + +@dataclass(frozen=True, slots=True) +class Volume(ProductDimension): + """Volume of the product.""" + + _class_uri: ClassVar[str] = EUDPPClass.VOLUME.value + + +@dataclass(frozen=True, slots=True) +class Weight(ProductDimension): + """Weight of the product.""" + + _class_uri: ClassVar[str] = EUDPPClass.WEIGHT.value + + +# ============================================================================= +# Core Entity Classes (Phase 1: CIRPASS-2 Integration) +# ============================================================================= + + +@dataclass(frozen=True, slots=True) +class Document: + """Document per EU DPP Core Ontology. + + Represents a document associated with a product or DPP, such as + user manuals, compliance certificates, or technical specifications. + + Source: product_dpp_v1.7.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPClass.DOCUMENT.value + + content_type: str | None = None + web_link: str | None = None + title: str | None = None + language: str | None = None + + +@dataclass(frozen=True, slots=True) +class ClassificationCode: + """Classification code per ESPR Art 2(4). + + Represents a product classification code from a controlled vocabulary + such as TARIC, HS codes, or other classification systems. + + Source: product_dpp_v1.7.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPClass.CLASSIFICATION_CODE.value + + code_set: str + code_value: str + description: str | None = None + + +@dataclass(frozen=True, slots=True) +class DigitalInstruction(Document): + """Digital instruction per ESPR Art 27(7). + + A specific type of document containing instructions for product use, + maintenance, repair, or end-of-life handling in digital format. + + Source: product_dpp_v1.7.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPClass.DIGITAL_INSTRUCTION.value + + instruction_type: str | None = None + + +@dataclass(frozen=True, slots=True) +class EUDPPProduct: + """Product entity per EU DPP Core Ontology. + + Represents a physical product placed on the market, as defined in + ESPR Art 2(1). This is the EU DPP vocabulary representation, separate + from the UNTP Product model to maintain extension-layer separation. + + Source: product_dpp_v1.7.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPClass.PRODUCT.value + + unique_product_id: str | None = None + product_name: str | None = None + description: str | None = None + gtin: str | None = None + commodity_code: str | None = None + product_image: str | None = None + is_energy_related: bool | None = None + + +@dataclass(frozen=True, slots=True) +class EUDPP_DPP: + """Digital Product Passport per EU DPP Core Ontology. + + Represents the DPP entity as defined in ESPR Art 2(28). This is the + EU DPP vocabulary representation, separate from the UNTP + DigitalProductPassport model to maintain extension-layer separation. + + Source: product_dpp_v1.7.1.ttl + """ + + _class_uri: ClassVar[str] = EUDPPClass.DPP.value + + unique_dpp_id: str + granularity: str | None = None + status: str | None = None + valid_from: str | None = None + valid_until: str | None = None + last_update: str | None = None + schema_version: str | None = None + link_to_previous_dpp: str | None = None + + +# Class hierarchy mapping for validation +EUDPP_CLASS_HIERARCHY: dict[str, list[str]] = { + # Core entity types + "eudpp:DPP": [], + "eudpp:Product": [], + # Document subtypes + "eudpp:Document": [ + "eudpp:DigitalInstruction", + ], + # Classification + "eudpp:ClassificationCode": [], + # QuantitativeProperty subtypes + "eudpp:QuantitativeProperty": [ + "eudpp:EnvironmentalFootprint", + "eudpp:EnvironmentalPollution", + "eudpp:ResourceConsumption", + "eudpp:CircularEconomyIndicator", + "eudpp:WasteGenerationAmount", + "eudpp:QualityIndicator", + "eudpp:ProductDimension", + "eudpp:ConcentrationOfSubstanceOfConcern", + "eudpp:ThresholdOfSubstanceOfConcern", + "eudpp:ProductToPackagingRatio", + ], + # Environmental footprint subtypes + "eudpp:EnvironmentalFootprint": [ + "eudpp:CarbonFootprint", + "eudpp:MaterialFootprint", + ], + # Environmental pollution subtypes + "eudpp:EnvironmentalPollution": [ + "eudpp:EnvironmentalEmission", + "eudpp:PlasticsRelease", + ], + # Emission subtypes + "eudpp:EnvironmentalEmission": [ + "eudpp:EmissionToAir", + "eudpp:EmissionToWater", + "eudpp:EmissionToSoil", + ], + # Plastics release subtypes + "eudpp:PlasticsRelease": [ + "eudpp:MicroplasticRelease", + "eudpp:NanoplasticRelease", + ], + # Resource consumption subtypes + "eudpp:ResourceConsumption": [ + "eudpp:EnergyConsumption", + "eudpp:WaterConsumption", + "eudpp:LandUse", + "eudpp:RecycledMaterialsUse", + "eudpp:SustainableRenewableMaterialsUse", + ], + # Circular economy subtypes + "eudpp:CircularEconomyIndicator": [ + "eudpp:RecyclingRate", + "eudpp:RecyclingCollectionRate", + "eudpp:RecoverableRate", + ], + # Waste subtypes + "eudpp:WasteGenerationAmount": [ + "eudpp:HazardousWasteAmount", + "eudpp:PackagingWasteAmount", + "eudpp:PlasticsWasteAmount", + ], + # Quality subtypes + "eudpp:QualityIndicator": [ + "eudpp:Durability", + "eudpp:Reliability", + ], + # Dimension subtypes + "eudpp:ProductDimension": [ + "eudpp:Height", + "eudpp:Length", + "eudpp:Width", + "eudpp:Volume", + "eudpp:Weight", + ], +} + + +def get_class_hierarchy(class_uri: str) -> list[str]: + """Get all subclasses of a given class. + + Args: + class_uri: EU DPP class URI (e.g., "eudpp:EnvironmentalFootprint") + + Returns: + List of subclass URIs + """ + return EUDPP_CLASS_HIERARCHY.get(class_uri, []) + + +def is_subclass_of(child_uri: str, parent_uri: str) -> bool: + """Check if a class is a subclass of another. + + Args: + child_uri: Potential child class URI + parent_uri: Potential parent class URI + + Returns: + True if child is a subclass of parent + """ + if child_uri == parent_uri: + return True + + for parent, children in EUDPP_CLASS_HIERARCHY.items(): + if child_uri in children: + if parent == parent_uri: + return True + # Recursive check + return is_subclass_of(parent, parent_uri) + + return False + + +def get_all_environmental_classes() -> list[str]: + """Get all environmental-related class URIs. + + Returns: + List of environmental class URIs + """ + return [ + EUDPPClass.ENVIRONMENTAL_FOOTPRINT.value, + EUDPPClass.CARBON_FOOTPRINT.value, + EUDPPClass.MATERIAL_FOOTPRINT.value, + EUDPPClass.ENVIRONMENTAL_POLLUTION.value, + EUDPPClass.ENVIRONMENTAL_EMISSION.value, + EUDPPClass.EMISSION_TO_AIR.value, + EUDPPClass.EMISSION_TO_WATER.value, + EUDPPClass.EMISSION_TO_SOIL.value, + EUDPPClass.PLASTICS_RELEASE.value, + EUDPPClass.MICROPLASTIC_RELEASE.value, + EUDPPClass.NANOPLASTIC_RELEASE.value, + EUDPPClass.RESOURCE_CONSUMPTION.value, + EUDPPClass.ENERGY_CONSUMPTION.value, + EUDPPClass.WATER_CONSUMPTION.value, + EUDPPClass.LAND_USE.value, + ] + + +def get_all_circular_economy_classes() -> list[str]: + """Get all circular economy-related class URIs. + + Returns: + List of circular economy class URIs + """ + return [ + EUDPPClass.CIRCULAR_ECONOMY_INDICATOR.value, + EUDPPClass.RECYCLING_RATE.value, + EUDPPClass.RECYCLING_COLLECTION_RATE.value, + EUDPPClass.RECOVERABLE_RATE.value, + EUDPPClass.RECYCLED_MATERIALS_USE.value, + EUDPPClass.SUSTAINABLE_RENEWABLE_MATERIALS_USE.value, + ] diff --git a/src/dppvalidator/vocabularies/eudpp_lca.py b/src/dppvalidator/vocabularies/eudpp_lca.py new file mode 100644 index 0000000..4f7a4b8 --- /dev/null +++ b/src/dppvalidator/vocabularies/eudpp_lca.py @@ -0,0 +1,517 @@ +"""EU DPP Core Ontology LCA and Environmental Footprint definitions. + +Provides dataclass representations of Life Cycle Assessment (LCA) and +Environmental Footprint (EF) entities from the EU DPP Core Ontology, +based on PEF 3.1 methodology and ESPR requirements. + +Source: EU DPP Core Ontology v2.0 (LCA module) +Namespace: http://dpp.cea.fr/EUDPP/LCA# +Note: Different namespace from other modules (cea.fr not taltech.ee) +""" + +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from enum import Enum +from typing import ClassVar + +# ============================================================================= +# LCA Namespace +# ============================================================================= + + +LCA_NAMESPACE = "http://dpp.cea.fr/EUDPP/LCA#" +LCA_PREFIX = "lca" + + +# ============================================================================= +# LCA Class Enums +# ============================================================================= + + +class LCAClass(str, Enum): + """EU DPP LCA module class URIs.""" + + ENVIRONMENTAL_FOOTPRINT = "lca:Environmental_Footprint" + CARBON_FOOTPRINT = "lca:Carbon_Footprint" + MATERIAL_FOOTPRINT = "lca:Material_Footprint" + ENVIRONMENTAL_IMPACT = "lca:Environmental_Impact" + IMPACT_CATEGORY = "lca:Impact_Category" + IMPACT_CATEGORY_INDICATOR = "lca:Impact_Category_Indicator" + IMPACT_POTENTIAL_RESULT = "lca:Impact_potential_result" + CHARACTERIZATION_FACTOR = "lca:Characterization_Factor" + CHARACTERIZATION_MODEL = "lca:Characterization_Model" + METHOD = "lca:Method" + METHODOLOGY = "lca:Methodology" + + +class ImpactCategory(str, Enum): + """PEF 3.1 impact categories per ESPR Annex I. + + The 16 environmental impact categories defined in the Product + Environmental Footprint (PEF) methodology version 3.1. + + Source: lca_v2.0.ttl + """ + + ACIDIFICATION = "lca:Acidification" + CLIMATE_CHANGE = "lca:Climate_change_total" + ECOTOXICITY_FRESHWATER = "lca:Ecotoxicity_freshwater" + EUTROPHICATION_FRESHWATER = "lca:Eutrophication_freshwater" + EUTROPHICATION_MARINE = "lca:Eutrophication_marine" + EUTROPHICATION_TERRESTRIAL = "lca:Eutrophication_terrestrial" + HUMAN_TOXICITY_CANCER = "lca:Human_toxicity_cancer" + HUMAN_TOXICITY_NON_CANCER = "lca:Human_toxicity_non_cancer" + IONISING_RADIATION = "lca:Ionising_radiation_human_health" + LAND_USE = "lca:Land_use_occupation_and_transformation" + OZONE_DEPLETION = "lca:Ozone_depletion" + PARTICULATE_MATTER = "lca:Particulate_matter" + PHOTOCHEMICAL_OZONE = "lca:Photochemical_ozone_formation_human_health" + RESOURCE_FOSSILS = "lca:Resource_use_fossils" + RESOURCE_MINERALS = "lca:Resource_use_minerals_and_metals" + WATER_USE = "lca:Water_use" + + +class ImpactCategoryIndicator(str, Enum): + """PEF 3.1 impact category indicators. + + Metrics used to quantify impact categories. + + Source: lca_v2.0.ttl + """ + + # Climate change + GWP100 = "lca:Global_Warming_Potential_GWP100" + + # Acidification / Eutrophication terrestrial + ACCUMULATED_EXCEEDANCE = "lca:Accumulated_Exceedance_AE" + + # Eutrophication freshwater + FRESHWATER_NUTRIENTS_P = "lca:Fraction_of_nutrients_reaching_freshwater_end_compartment_P" + + # Eutrophication marine + MARINE_NUTRIENTS_N = "lca:Fraction_of_nutrients_reaching_marine_end_compartment_N" + + # Human toxicity (cancer and non-cancer) + CTUH = "lca:Comparative_Toxic_Unit_for_humans_CTUh" + + # Ecotoxicity freshwater + CTUE = "lca:Comparative_Toxic_Unit_for_ecosystems_CTUe" + + # Ionising radiation + HUMAN_EXPOSURE_U235 = "lca:Human_exposure_efficiency_relative_to_U235" + + # Land use + SOIL_QUALITY_INDEX = "lca:Soil_quality_index_dimensionless" + + # Ozone depletion + ODP = "lca:Ozone_Depletion_Potential_ODP" + + # Particulate matter + DISEASE_INCIDENCE = "lca:Impact_on_human_health" + + # Photochemical ozone formation + TROPOSPHERIC_OZONE = "lca:Tropospheric_ozone_concentration_increase" + + # Resource use fossils + ADP_FOSSIL = "lca:Biotic_resource_depletion_-_fossil_fuels_ADP_fossil" + + # Resource use minerals and metals + ADP_ULTIMATE = "lca:Abiotic_resource_depletion_ADP_ultimate_reserves" + + # Water use + AWARE = "lca:User_deprivation_potential_deprivation_weighted_consumption" + + +class CharacterizationModel(str, Enum): + """LCA characterization models per PEF 3.1. + + Mathematical frameworks used to quantify impact categories. + + Source: lca_v2.0.ttl + """ + + # Climate change + IPCC_2021 = "lca:Bern_model_based_on_IPCC_2021" + + # Human/ecotoxicity + USETOX_2_1 = "lca:Based_on_USEtox2.1_model" + + # Water use + AWARE = "lca:Available_WAter_REmaining_AWARE_model" + + # Acidification / Eutrophication terrestrial + ACCUMULATED_EXCEEDANCE = "lca:Accumulated_Exceedance" + + # Eutrophication freshwater/marine + EUTREND = "lca:EUTREND_model" + + # Particulate matter + PM_MODEL = "lca:PM_model" + + # Ionising radiation + HUMAN_HEALTH_EFFECT = "lca:Human_health_effect_model" + + # Ozone depletion + EDIP = "lca:EDIP_model" + + # Land use + LANCA = "lca:LANCA_model" + + # Photochemical ozone formation + LOTOS_EUROS = "lca:LOTOS_EUROS_model" + + # Resource use + CML_2002 = "lca:CML_2002_method_v.4.8" + + +# ============================================================================= +# Impact Category Units +# ============================================================================= + + +IMPACT_CATEGORY_UNITS: dict[str, str] = { + ImpactCategory.CLIMATE_CHANGE.value: "kg CO2-eq", + ImpactCategory.ACIDIFICATION.value: "mol H+-eq", + ImpactCategory.EUTROPHICATION_FRESHWATER.value: "kg P-eq", + ImpactCategory.EUTROPHICATION_MARINE.value: "kg N-eq", + ImpactCategory.EUTROPHICATION_TERRESTRIAL.value: "mol N-eq", + ImpactCategory.ECOTOXICITY_FRESHWATER.value: "CTUe", + ImpactCategory.HUMAN_TOXICITY_CANCER.value: "CTUh", + ImpactCategory.HUMAN_TOXICITY_NON_CANCER.value: "CTUh", + ImpactCategory.IONISING_RADIATION.value: "kBq U235-eq", + ImpactCategory.LAND_USE.value: "Pt", + ImpactCategory.OZONE_DEPLETION.value: "kg CFC-11-eq", + ImpactCategory.PARTICULATE_MATTER.value: "disease incidence", + ImpactCategory.PHOTOCHEMICAL_OZONE.value: "kg NMVOC-eq", + ImpactCategory.RESOURCE_FOSSILS.value: "MJ", + ImpactCategory.RESOURCE_MINERALS.value: "kg Sb-eq", + ImpactCategory.WATER_USE.value: "m³ world-eq", +} + + +# ============================================================================= +# LCA Dataclasses +# ============================================================================= + + +@dataclass(frozen=True, slots=True) +class EnvironmentalFootprint: + """Environmental Footprint per EU DPP LCA module. + + Quantifies environmental impacts of a product system. + + Source: lca_v2.0.ttl + """ + + _class_uri: ClassVar[str] = LCAClass.ENVIRONMENTAL_FOOTPRINT.value + + +@dataclass(frozen=True, slots=True) +class CarbonFootprint(EnvironmentalFootprint): + """Carbon Footprint per ESPR Art 2(25). + + The sum of greenhouse gas emissions and removals in a product system, + expressed as CO2 equivalents based on life cycle assessment using + the single impact category of climate change. + + Source: lca_v2.0.ttl + """ + + _class_uri: ClassVar[str] = LCAClass.CARBON_FOOTPRINT.value + + value: Decimal | None = None + unit: str = "kg CO2-eq" + methodology: str | None = None + scope: str | None = None + + +@dataclass(frozen=True, slots=True) +class MaterialFootprint(EnvironmentalFootprint): + """Material Footprint per ESPR Art 2(26). + + The total amount of raw materials extracted to meet final + consumption demands. + + Source: lca_v2.0.ttl + """ + + _class_uri: ClassVar[str] = LCAClass.MATERIAL_FOOTPRINT.value + + value: Decimal | None = None + unit: str = "kg" + methodology: str | None = None + + +@dataclass(frozen=True, slots=True) +class EnvironmentalImpact: + """Environmental Impact per ESPR Art 2(14). + + Any change to the environment, whether adverse or beneficial, + wholly or partially resulting from a product during its life cycle. + + Source: lca_v2.0.ttl + """ + + _class_uri: ClassVar[str] = LCAClass.ENVIRONMENTAL_IMPACT.value + + description: str | None = None + lifecycle_stage: str | None = None + + +@dataclass(frozen=True, slots=True) +class ImpactResult: + """Impact assessment result per PEF methodology. + + Links an impact category indicator to a product reference flow + with its computed value. + + Source: lca_v2.0.ttl + """ + + _class_uri: ClassVar[str] = LCAClass.IMPACT_POTENTIAL_RESULT.value + + category: str + value: Decimal + unit: str + indicator: str | None = None + model: str | None = None + + def validate(self) -> list[str]: + """Validate impact result values. + + Returns: + List of validation error messages, empty if valid + """ + errors = [] + + # Validate category is known + valid_categories = [c.value for c in ImpactCategory] + if self.category not in valid_categories: + errors.append(f"Unknown impact category: {self.category}") + + return errors + + +@dataclass(frozen=True, slots=True) +class CharacterizationFactor: + """Characterization Factor per LCA methodology. + + Factors that quantify the relative impact of different substances + within the same category, used to convert emissions into impact scores. + + Source: lca_v2.0.ttl + """ + + _class_uri: ClassVar[str] = LCAClass.CHARACTERIZATION_FACTOR.value + + name: str + value: Decimal | None = None + unit: str | None = None + model: str | None = None + + +@dataclass(frozen=True, slots=True) +class LCAMethodology: + """LCA Methodology per PEF framework. + + Groups related LCIA methods. Examples include EN15804+A2, + EF v3.1, Impact World. + + Source: lca_v2.0.ttl + """ + + _class_uri: ClassVar[str] = LCAClass.METHODOLOGY.value + + name: str + version: str | None = None + description: str | None = None + + +@dataclass(frozen=True, slots=True) +class LCAMethod: + """LCA Method per PEF framework. + + LCIA methods allow transforming and aggregating emissions to + environment and resource use data into interpretable impact scores. + + Source: lca_v2.0.ttl + """ + + _class_uri: ClassVar[str] = LCAClass.METHOD.value + + name: str + methodology: str | None = None + characterization_model: str | None = None + + +@dataclass(frozen=True, slots=True) +class ProductEnvironmentalFootprint: + """Product Environmental Footprint (PEF) result. + + Complete PEF assessment result for a product, containing + impact results for all 16 PEF 3.1 categories. + + This is a convenience class for aggregating PEF results. + """ + + _class_uri: ClassVar[str] = "lca:ProductEnvironmentalFootprint" + + product_name: str | None = None + functional_unit: str | None = None + methodology_version: str = "PEF 3.1" + impact_results: tuple[ImpactResult, ...] = () + + def get_impact(self, category: ImpactCategory) -> ImpactResult | None: + """Get impact result for a specific category. + + Args: + category: Impact category to retrieve + + Returns: + ImpactResult for the category, or None if not found + """ + for result in self.impact_results: + if result.category == category.value: + return result + return None + + def has_all_categories(self) -> bool: + """Check if all 16 PEF categories are present. + + Returns: + True if all categories have results + """ + categories_present = {r.category for r in self.impact_results} + all_categories = {c.value for c in ImpactCategory} + return all_categories.issubset(categories_present) + + def missing_categories(self) -> list[str]: + """Get list of missing impact categories. + + Returns: + List of missing category URIs + """ + categories_present = {r.category for r in self.impact_results} + all_categories = {c.value for c in ImpactCategory} + return list(all_categories - categories_present) + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def get_all_impact_categories() -> list[str]: + """Get all PEF 3.1 impact category URIs. + + Returns: + List of 16 impact category URIs + """ + return [c.value for c in ImpactCategory] + + +def get_impact_category_unit(category: str) -> str | None: + """Get the standard unit for an impact category. + + Args: + category: Impact category URI + + Returns: + Unit string, or None if category unknown + """ + return IMPACT_CATEGORY_UNITS.get(category) + + +def get_all_characterization_models() -> list[str]: + """Get all characterization model URIs. + + Returns: + List of characterization model URIs + """ + return [m.value for m in CharacterizationModel] + + +def get_all_impact_indicators() -> list[str]: + """Get all impact category indicator URIs. + + Returns: + List of impact category indicator URIs + """ + return [i.value for i in ImpactCategoryIndicator] + + +def is_climate_related(category: str) -> bool: + """Check if impact category is climate-related. + + Args: + category: Impact category URI + + Returns: + True if category is climate change + """ + return category == ImpactCategory.CLIMATE_CHANGE.value + + +def is_toxicity_related(category: str) -> bool: + """Check if impact category is toxicity-related. + + Args: + category: Impact category URI + + Returns: + True if category relates to human or ecosystem toxicity + """ + toxicity_categories = { + ImpactCategory.HUMAN_TOXICITY_CANCER.value, + ImpactCategory.HUMAN_TOXICITY_NON_CANCER.value, + ImpactCategory.ECOTOXICITY_FRESHWATER.value, + } + return category in toxicity_categories + + +def is_resource_related(category: str) -> bool: + """Check if impact category is resource-related. + + Args: + category: Impact category URI + + Returns: + True if category relates to resource use + """ + resource_categories = { + ImpactCategory.RESOURCE_FOSSILS.value, + ImpactCategory.RESOURCE_MINERALS.value, + ImpactCategory.WATER_USE.value, + ImpactCategory.LAND_USE.value, + } + return category in resource_categories + + +def expand_lca_uri(compact_uri: str) -> str: + """Expand compact LCA URI to full URI. + + Args: + compact_uri: URI with lca: prefix + + Returns: + Full URI with namespace + """ + if compact_uri.startswith(f"{LCA_PREFIX}:"): + return compact_uri.replace(f"{LCA_PREFIX}:", LCA_NAMESPACE) + return compact_uri + + +def compact_lca_uri(full_uri: str) -> str: + """Compact full LCA URI to prefixed form. + + Args: + full_uri: Full URI with namespace + + Returns: + Compact URI with lca: prefix + """ + if full_uri.startswith(LCA_NAMESPACE): + return full_uri.replace(LCA_NAMESPACE, f"{LCA_PREFIX}:") + return full_uri diff --git a/src/dppvalidator/vocabularies/eudpp_relations.py b/src/dppvalidator/vocabularies/eudpp_relations.py new file mode 100644 index 0000000..112a97f --- /dev/null +++ b/src/dppvalidator/vocabularies/eudpp_relations.py @@ -0,0 +1,799 @@ +"""EU DPP Core Ontology product relationship properties. + +Defines object and datatype properties for product relationships +based on the official CIRPASS-2 ontology v1.7.1. + +Source: EU DPP Core Ontology v1.7.1 (Product and DPP module) +Namespace: http://dpp.taltech.ee/EUDPP# +DOI: 10.5281/zenodo.15270342 +""" + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass +from enum import Enum + + +class EUDPPObjectProperty(str, Enum): + """EU DPP Core Ontology object property URIs. + + Object properties define relationships between instances. + """ + + # DPP-Product relations + HAS_DPP = "eudpp:hasDPP" + APPLIES_TO_PRODUCT = "eudpp:appliesToProduct" + + # Product hierarchy relations (transitive) + IS_COMPONENT_OF = "eudpp:isComponentOf" + IS_SPARE_PART_OF = "eudpp:isSparePartOf" + + # Actor relations + HAS_ISSUER = "eudpp:hasIssuer" + HAS_MANUFACTURER = "eudpp:hasManufacturer" + HAS_ECONOMIC_OPERATOR = "eudpp:hasEconomicOperator" + HAS_BACKUP_COPY_HOST = "eudpp:hasBackUpCopyHost" + IS_RESPONSIBLE_FOR_PRODUCT = "eudpp:isResponsibleForProduct" + + # Actor-Role relations (Phase 2) + HAS_ROLE = "eudpp:hasRole" + IS_ROLE_OF = "eudpp:isRoleOf" + + # Actor-Facility relations (Phase 2) + USES_FACILITY = "eudpp:usesFacility" + IS_USED_BY_ACTOR = "eudpp:isUsedByActor" + + # Actor location relations (Phase 2) + IS_ESTABLISHED_IN = "eudpp:isEstablishedIn" + IS_RESIDING_IN = "eudpp:isResidingIn" + + # Authorised representative relations (Phase 2) + REPRESENTS_MANUFACTURER = "eudpp:representsManufacturer" + IS_REPRESENTED_BY = "eudpp:isRepresentedBy" + + # Product classification + HAS_PRODUCT_GROUP = "eudpp:hasProductGroup" + + # Property relations + HAS_PROPERTY = "eudpp:hasProperty" + HAS_MEASUREMENT_UNIT = "eudpp:hasMeasurementUnit" + + # Substance relations + CONTAINS_SUBSTANCE_OF_CONCERN = "eudpp:containsSubstanceOfConcern" + + # Substance of Concern relations (Phase 3) + HAS_CONCENTRATION = "eudpp:hasConcentration" + HAS_LIFECYCLE_STAGE = "eudpp:hasLifeCycleStage" + HAS_THRESHOLD = "eudpp:hasThreshold" + + # LCA relations (Phase 4) + QUANTIFIES = "lca:quantifies" + QUANTIFIED_BY = "lca:quantified_by" + ICI_ASSESS_IC = "lca:ICI_assess_IC" + ICI_COMPUTES_IR = "lca:ICI_computes_IR" + CF_QUANTIFIES_ICI = "lca:CF_quantifies_ICI" + CM_CALCULATES_CF = "lca:CM_calculates_CF" + METHOD_USES_CM = "lca:method_uses_CM" + INCLUDES = "lca:includes" + IMPOSES = "lca:imposes" + CORRESPONDS_TO_IC = "lca:corresponds_to_IC" + + +class EUDPPDatatypeProperty(str, Enum): + """EU DPP Core Ontology datatype property URIs. + + Datatype properties define relationships to literal values. + """ + + # DPP identification + UNIQUE_DPP_ID = "eudpp:uniqueDPPID" + UNIQUE_PRODUCT_ID = "eudpp:uniqueProductID" + GTIN = "eudpp:GTIN" + COMMODITY_CODE = "eudpp:commodityCode" + FACILITY_ID = "eudpp:facilityID" + + # Product information + PRODUCT_NAME = "eudpp:productName" + DESCRIPTION = "eudpp:description" + PRODUCT_IMAGE = "eudpp:productImage" + + # DPP lifecycle + VALID_FROM = "eudpp:validFrom" + VALID_UNTIL = "eudpp:validUntil" + LAST_UPDATE = "eudpp:lastUpdate" + STATUS = "eudpp:status" + SCHEMA_VERSION = "eudpp:schemaVersion" + LINK_TO_PREVIOUS_DPP = "eudpp:linkToPreviousDPP" + + # Granularity + GRANULARITY = "eudpp:granularity" + + # Product characteristics + IS_ENERGY_RELATED = "eudpp:isEnergyRelated" + + # Quantitative values + NUMERICAL_VALUE = "eudpp:numericalValue" + TOLERANCE = "eudpp:tolerance" + VALUE = "eudpp:value" + + # Classification + CODE_SET = "eudpp:codeSet" + CODE_VALUE = "eudpp:codeValue" + DICTIONARY_REFERENCE = "eudpp:dictionaryReference" + + # Documents + CONTENT_TYPE = "eudpp:contentType" + WEB_LINK = "eudpp:webLink" + + # Actor properties (Phase 2) + ACTOR_NAME = "eudpp:actorName" + ELECTRONIC_CONTACT = "eudpp:electronicContact" + POSTAL_ADDRESS = "eudpp:postalAddress" + REGISTERED_TRADE_NAME = "eudpp:registeredTradeName" + REGISTERED_TRADEMARK = "eudpp:registeredTrademark" + UNIQUE_OPERATOR_ID = "eudpp:uniqueOperatorID" + + # Facility properties (Phase 2) + UNIQUE_FACILITY_ID = "eudpp:uniqueFacilityID" + + # Substance of Concern properties (Phase 3) + NAME_IUPAC = "eudpp:nameIUPAC" + NAME_CAS = "eudpp:nameCAS" + NUMBER_CAS = "eudpp:numberCAS" + NUMBER_EC = "eudpp:numberEC" + ABBREVIATION = "eudpp:abbreviation" + TRADE_NAME = "eudpp:tradeName" + USUAL_NAME = "eudpp:usualName" + OTHER_NAME = "eudpp:otherName" + SUBSTANCE_LOCATION = "eudpp:substanceLocation" + HAS_IMPACT_ON_ENVIRONMENT = "eudpp:hasImpactOnEnvironment" + HAS_IMPACT_ON_HUMAN_HEALTH = "eudpp:hasImpactOnHumanHealth" + + # LCA properties (Phase 4) + LCA_HAS_UNIT = "lca:has_unit" + LCA_NUMERIC_VALUE = "qudt:numericValue" + + +@dataclass(frozen=True, slots=True) +class ObjectPropertyDefinition: + """Definition of an EU DPP object property.""" + + uri: str + domain: str + range: str + description: str + is_transitive: bool = False + is_functional: bool = False + espr_reference: str | None = None + + +@dataclass(frozen=True, slots=True) +class DatatypePropertyDefinition: + """Definition of an EU DPP datatype property.""" + + uri: str + domain: str + range: str + description: str + espr_reference: str | None = None + + +# Object property definitions from official ontology +OBJECT_PROPERTIES: tuple[ObjectPropertyDefinition, ...] = ( + # DPP-Product relations + ObjectPropertyDefinition( + uri="eudpp:hasDPP", + domain="eudpp:Product", + range="eudpp:DPP", + description="Product has an associated DPP", + espr_reference="ESPR Art 8", + ), + ObjectPropertyDefinition( + uri="eudpp:appliesToProduct", + domain="eudpp:DPP", + range="eudpp:Product", + description="DPP applies to a product", + espr_reference="ESPR Art 8", + ), + # Product hierarchy (transitive) + ObjectPropertyDefinition( + uri="eudpp:isComponentOf", + domain="eudpp:Product", + range="eudpp:Product", + description="Product is a component of another product", + is_transitive=True, + espr_reference="ESPR Art 2(2)", + ), + ObjectPropertyDefinition( + uri="eudpp:isSparePartOf", + domain="eudpp:Product", + range="eudpp:Product", + description="Product is a spare part of another product", + espr_reference="ESPR Art 2", + ), + # Actor relations + ObjectPropertyDefinition( + uri="eudpp:hasIssuer", + domain="eudpp:DPP", + range="eudpp:Actor", + description="Economic operator issuing the DPP", + espr_reference="ESPR Annex III (g)", + ), + ObjectPropertyDefinition( + uri="eudpp:hasManufacturer", + domain="eudpp:Product", + range="eudpp:Actor", + description="Product manufacturer", + espr_reference="ESPR Annex III (g)", + ), + ObjectPropertyDefinition( + uri="eudpp:hasEconomicOperator", + domain="eudpp:Product", + range="eudpp:Actor", + description="Economic operator responsible for placing product on market", + espr_reference="ESPR Art 2(18)", + ), + ObjectPropertyDefinition( + uri="eudpp:hasBackUpCopyHost", + domain="eudpp:DPP", + range="eudpp:Actor", + description="Actor hosting backup copy of DPP", + espr_reference="ESPR Art 10(4)", + ), + # Product classification + ObjectPropertyDefinition( + uri="eudpp:hasProductGroup", + domain="eudpp:Product", + range="eudpp:ClassificationCode", + description="Product group classification", + espr_reference="ESPR Art 2(4)", + ), + # Properties + ObjectPropertyDefinition( + uri="eudpp:hasProperty", + domain="eudpp:Product", + range="eudpp:Property", + description="Product has a property", + espr_reference="ESPR Annex I", + ), + ObjectPropertyDefinition( + uri="eudpp:hasMeasurementUnit", + domain="eudpp:QuantitativeProperty", + range="si:Unit", + description="Measurement unit for quantitative property", + ), + # Substances + ObjectPropertyDefinition( + uri="eudpp:containsSubstanceOfConcern", + domain="eudpp:Product", + range="eudpp:SubstanceOfConcern", + description="Product contains a substance of concern", + espr_reference="ESPR Art 7(5)", + ), + # Actor-Role relations (Phase 2) + ObjectPropertyDefinition( + uri="eudpp:hasRole", + domain="eudpp:Actor", + range="eudpp:Role", + description="Relates an actor to a role", + ), + ObjectPropertyDefinition( + uri="eudpp:isRoleOf", + domain="eudpp:Role", + range="eudpp:Actor", + description="Relates role to an actor", + ), + # Actor-Facility relations (Phase 2) + ObjectPropertyDefinition( + uri="eudpp:usesFacility", + domain="eudpp:Actor", + range="eudpp:Facility", + description="Relates an actor to facility", + espr_reference="ESPR Art 2(33)", + ), + ObjectPropertyDefinition( + uri="eudpp:isUsedByActor", + domain="eudpp:Facility", + range="eudpp:Actor", + description="Relates a facility to an actor", + espr_reference="ESPR Art 2(33)", + ), + # Actor location relations (Phase 2) + ObjectPropertyDefinition( + uri="eudpp:isEstablishedIn", + domain="eudpp:LegalPerson", + range="dcterms:Location", + description="Relates a legal person to the location where it is established", + ), + ObjectPropertyDefinition( + uri="eudpp:isResidingIn", + domain="eudpp:NaturalPerson", + range="dcterms:Location", + description="Relates a natural person to the location where they reside", + ), + # Authorised representative relations (Phase 2) + ObjectPropertyDefinition( + uri="eudpp:representsManufacturer", + domain="eudpp:Actor", + range="eudpp:Actor", + description="Relates an authorised representative to the manufacturer", + espr_reference="ESPR Art 2(43)", + ), + ObjectPropertyDefinition( + uri="eudpp:isRepresentedBy", + domain="eudpp:Actor", + range="eudpp:Actor", + description="Relates a manufacturer to an authorised representative", + espr_reference="ESPR Art 2(43)", + ), + # Substance of Concern relations (Phase 3) + ObjectPropertyDefinition( + uri="eudpp:hasConcentration", + domain="eudpp:SubstanceOfConcern", + range="eudpp:Concentration", + description="Concentration of substance in product", + espr_reference="ESPR Art 7(5)", + ), + ObjectPropertyDefinition( + uri="eudpp:hasLifeCycleStage", + domain="eudpp:SubstanceOfConcern", + range="eudpp:Event", + description="Life cycle stage during which substance occurs", + espr_reference="ESPR Annex I(f)", + ), + ObjectPropertyDefinition( + uri="eudpp:hasThreshold", + domain="eudpp:SubstanceOfConcern", + range="eudpp:Threshold", + description="Regulatory threshold for substance", + espr_reference="ESPR FAQ 10.115", + ), +) + + +# Datatype property definitions from official ontology +DATATYPE_PROPERTIES: tuple[DatatypePropertyDefinition, ...] = ( + # DPP identification + DatatypePropertyDefinition( + uri="eudpp:uniqueDPPID", + domain="eudpp:DPP", + range="xsd:anyURI", + description="Unique DPP identifier as URI", + espr_reference="ESPR Art 9(1)", + ), + DatatypePropertyDefinition( + uri="eudpp:uniqueProductID", + domain="eudpp:Product", + range="xsd:string", + description="Unique product identifier", + espr_reference="ESPR Art 2(30)", + ), + DatatypePropertyDefinition( + uri="eudpp:GTIN", + domain="eudpp:Product", + range="xsd:string", + description="Global Trade Identification Number", + espr_reference="ISO/IEC 15459-6", + ), + DatatypePropertyDefinition( + uri="eudpp:commodityCode", + domain="eudpp:Product", + range="xsd:string", + description="TARIC or commodity code", + espr_reference="Council Regulation (EEC) No 2658/87", + ), + DatatypePropertyDefinition( + uri="eudpp:facilityID", + domain="eudpp:Product", + range="xsd:string", + description="Unique facility identifier", + espr_reference="ESPR Art 2(33)", + ), + # Product information + DatatypePropertyDefinition( + uri="eudpp:productName", + domain="eudpp:Product", + range="xsd:string", + description="Product name", + espr_reference="ESPR Annex III", + ), + DatatypePropertyDefinition( + uri="eudpp:description", + domain="eudpp:Product", + range="xsd:string", + description="Product description", + espr_reference="ESPR Annex III", + ), + DatatypePropertyDefinition( + uri="eudpp:productImage", + domain="eudpp:Product", + range="xsd:anyURI", + description="Product image URI", + espr_reference="ESPR Annex III", + ), + # DPP lifecycle + DatatypePropertyDefinition( + uri="eudpp:validFrom", + domain="eudpp:DPP", + range="xsd:dateTime", + description="DPP valid from date", + espr_reference="ESPR Art 9(2i)", + ), + DatatypePropertyDefinition( + uri="eudpp:validUntil", + domain="eudpp:DPP", + range="xsd:dateTime", + description="DPP valid until date", + espr_reference="ESPR Art 9(2i)", + ), + DatatypePropertyDefinition( + uri="eudpp:lastUpdate", + domain="eudpp:DPP", + range="xsd:dateTime", + description="Last DPP update timestamp", + espr_reference="ESPR Art 11", + ), + DatatypePropertyDefinition( + uri="eudpp:status", + domain="eudpp:DPP", + range="xsd:string", + description="DPP status (Active/Archived)", + espr_reference="ESPR Art 11", + ), + DatatypePropertyDefinition( + uri="eudpp:schemaVersion", + domain="eudpp:DPP", + range="xsd:string", + description="Reference standard version", + espr_reference="ESPR Art 9", + ), + DatatypePropertyDefinition( + uri="eudpp:linkToPreviousDPP", + domain="eudpp:DPP", + range="xsd:anyURI", + description="Link to previous DPP version", + espr_reference="ESPR Art 11(d)", + ), + # Granularity + DatatypePropertyDefinition( + uri="eudpp:granularity", + domain="eudpp:DPP", + range="xsd:string", + description="DPP granularity (model/batch/product)", + espr_reference="SR5423 Annex II Part B 1.1", + ), + # Product characteristics + DatatypePropertyDefinition( + uri="eudpp:isEnergyRelated", + domain="eudpp:Product", + range="xsd:boolean", + description="Product is energy-related per ESPR Art 2(4)", + espr_reference="ESPR Art 2(4)", + ), + # Quantitative values + DatatypePropertyDefinition( + uri="eudpp:numericalValue", + domain="eudpp:QuantitativeProperty", + range="xsd:decimal", + description="Numerical value of property", + ), + DatatypePropertyDefinition( + uri="eudpp:tolerance", + domain="eudpp:QuantitativeProperty", + range="xsd:decimal", + description="Tolerance of quantitative property", + ), + # Actor properties (Phase 2) + DatatypePropertyDefinition( + uri="eudpp:actorName", + domain="eudpp:Actor", + range="xsd:string", + description="Name of an actor", + ), + DatatypePropertyDefinition( + uri="eudpp:electronicContact", + domain="eudpp:Actor", + range="xsd:string", + description="Electronic contact detail for an actor", + ), + DatatypePropertyDefinition( + uri="eudpp:postalAddress", + domain="eudpp:Actor", + range="xsd:string", + description="Postal address associated with an actor", + ), + DatatypePropertyDefinition( + uri="eudpp:registeredTradeName", + domain="eudpp:Actor", + range="xsd:string", + description="Trade name under which a legal person operates", + ), + DatatypePropertyDefinition( + uri="eudpp:registeredTrademark", + domain="eudpp:Actor", + range="xsd:string", + description="A trademark officially registered and owned by an actor", + ), + DatatypePropertyDefinition( + uri="eudpp:uniqueOperatorID", + domain="eudpp:Actor", + range="xsd:string", + description="Unique operator identifier", + espr_reference="ESPR Art 2(31)", + ), + # Facility properties (Phase 2) + DatatypePropertyDefinition( + uri="eudpp:uniqueFacilityID", + domain="eudpp:Facility", + range="xsd:string", + description="Unique facility identifier", + espr_reference="ESPR Art 2(33)", + ), + # Substance of Concern properties (Phase 3) + DatatypePropertyDefinition( + uri="eudpp:nameIUPAC", + domain="eudpp:SubstanceOfConcern", + range="xsd:string", + description="IUPAC name of substance", + espr_reference="ESPR Art 7(5)", + ), + DatatypePropertyDefinition( + uri="eudpp:nameCAS", + domain="eudpp:SubstanceOfConcern", + range="xsd:string", + description="CAS name of substance", + espr_reference="ESPR Art 7(5)", + ), + DatatypePropertyDefinition( + uri="eudpp:numberCAS", + domain="eudpp:SubstanceOfConcern", + range="xsd:string", + description="CAS number of substance", + espr_reference="ESPR Art 7(5)", + ), + DatatypePropertyDefinition( + uri="eudpp:numberEC", + domain="eudpp:SubstanceOfConcern", + range="xsd:string", + description="EC number of substance", + espr_reference="ESPR Art 7(5)", + ), + DatatypePropertyDefinition( + uri="eudpp:abbreviation", + domain="eudpp:SubstanceOfConcern", + range="xsd:string", + description="Abbreviation for substance", + espr_reference="ESPR Art 7(5)", + ), + DatatypePropertyDefinition( + uri="eudpp:tradeName", + domain="eudpp:SubstanceOfConcern", + range="xsd:string", + description="Trade name of substance", + espr_reference="ESPR Art 7(5)", + ), + DatatypePropertyDefinition( + uri="eudpp:usualName", + domain="eudpp:SubstanceOfConcern", + range="xsd:string", + description="Usual name of substance", + espr_reference="ESPR Art 7(5)", + ), + DatatypePropertyDefinition( + uri="eudpp:otherName", + domain="eudpp:SubstanceOfConcern", + range="xsd:string", + description="Other names for substance", + espr_reference="ESPR Art 7(5)", + ), + DatatypePropertyDefinition( + uri="eudpp:substanceLocation", + domain="eudpp:SubstanceOfConcern", + range="xsd:string", + description="Location of substance in product", + espr_reference="ESPR Art 7(5)", + ), + DatatypePropertyDefinition( + uri="eudpp:hasImpactOnEnvironment", + domain="eudpp:SubstanceOfConcern", + range="xsd:string", + description="Impact of substance on environment", + espr_reference="ESPR Annex I(f)", + ), + DatatypePropertyDefinition( + uri="eudpp:hasImpactOnHumanHealth", + domain="eudpp:SubstanceOfConcern", + range="xsd:string", + description="Impact of substance on human health", + espr_reference="ESPR Annex I(f)", + ), +) + + +class ProductRelationMapper: + """Maps and validates product relationships per EU DPP ontology.""" + + def __init__(self) -> None: + """Initialize mapper with property definitions.""" + self._object_props = {p.uri: p for p in OBJECT_PROPERTIES} + self._datatype_props = {p.uri: p for p in DATATYPE_PROPERTIES} + + def get_object_property(self, uri: str) -> ObjectPropertyDefinition | None: + """Get object property definition by URI.""" + return self._object_props.get(uri) + + def get_datatype_property(self, uri: str) -> DatatypePropertyDefinition | None: + """Get datatype property definition by URI.""" + return self._datatype_props.get(uri) + + def is_transitive(self, uri: str) -> bool: + """Check if an object property is transitive.""" + prop = self._object_props.get(uri) + return prop.is_transitive if prop else False + + def get_domain(self, uri: str) -> str | None: + """Get domain of a property.""" + prop = self._object_props.get(uri) or self._datatype_props.get(uri) + return prop.domain if prop else None + + def get_range(self, uri: str) -> str | None: + """Get range of a property.""" + prop = self._object_props.get(uri) or self._datatype_props.get(uri) + return prop.range if prop else None + + def iter_object_properties(self) -> Iterator[ObjectPropertyDefinition]: + """Iterate over all object property definitions.""" + yield from OBJECT_PROPERTIES + + def iter_datatype_properties(self) -> Iterator[DatatypePropertyDefinition]: + """Iterate over all datatype property definitions.""" + yield from DATATYPE_PROPERTIES + + @property + def object_property_count(self) -> int: + """Number of object properties.""" + return len(OBJECT_PROPERTIES) + + @property + def datatype_property_count(self) -> int: + """Number of datatype properties.""" + return len(DATATYPE_PROPERTIES) + + +def get_product_hierarchy_properties() -> list[str]: + """Get URIs of product hierarchy properties. + + Returns: + List of transitive product relation URIs + """ + return [ + EUDPPObjectProperty.IS_COMPONENT_OF.value, + EUDPPObjectProperty.IS_SPARE_PART_OF.value, + ] + + +def get_actor_properties() -> list[str]: + """Get URIs of actor-related properties. + + Returns: + List of actor relation URIs + """ + return [ + EUDPPObjectProperty.HAS_ISSUER.value, + EUDPPObjectProperty.HAS_MANUFACTURER.value, + EUDPPObjectProperty.HAS_ECONOMIC_OPERATOR.value, + EUDPPObjectProperty.HAS_BACKUP_COPY_HOST.value, + EUDPPObjectProperty.HAS_ROLE.value, + EUDPPObjectProperty.IS_ROLE_OF.value, + EUDPPObjectProperty.USES_FACILITY.value, + EUDPPObjectProperty.IS_USED_BY_ACTOR.value, + EUDPPObjectProperty.IS_ESTABLISHED_IN.value, + EUDPPObjectProperty.IS_RESIDING_IN.value, + EUDPPObjectProperty.REPRESENTS_MANUFACTURER.value, + EUDPPObjectProperty.IS_REPRESENTED_BY.value, + ] + + +def get_actor_datatype_properties() -> list[str]: + """Get URIs of actor datatype properties (Phase 2). + + Returns: + List of actor datatype property URIs + """ + return [ + EUDPPDatatypeProperty.ACTOR_NAME.value, + EUDPPDatatypeProperty.ELECTRONIC_CONTACT.value, + EUDPPDatatypeProperty.POSTAL_ADDRESS.value, + EUDPPDatatypeProperty.REGISTERED_TRADE_NAME.value, + EUDPPDatatypeProperty.REGISTERED_TRADEMARK.value, + EUDPPDatatypeProperty.UNIQUE_OPERATOR_ID.value, + ] + + +def get_facility_properties() -> list[str]: + """Get URIs of facility-related properties (Phase 2). + + Returns: + List of facility property URIs + """ + return [ + EUDPPObjectProperty.USES_FACILITY.value, + EUDPPObjectProperty.IS_USED_BY_ACTOR.value, + EUDPPDatatypeProperty.UNIQUE_FACILITY_ID.value, + ] + + +def get_lifecycle_properties() -> list[str]: + """Get URIs of DPP lifecycle properties. + + Returns: + List of lifecycle datatype property URIs + """ + return [ + EUDPPDatatypeProperty.VALID_FROM.value, + EUDPPDatatypeProperty.VALID_UNTIL.value, + EUDPPDatatypeProperty.LAST_UPDATE.value, + EUDPPDatatypeProperty.STATUS.value, + EUDPPDatatypeProperty.SCHEMA_VERSION.value, + EUDPPDatatypeProperty.LINK_TO_PREVIOUS_DPP.value, + ] + + +def get_substance_properties() -> list[str]: + """Get URIs of substance of concern properties (Phase 3). + + Returns: + List of SOC property URIs + """ + return [ + EUDPPObjectProperty.CONTAINS_SUBSTANCE_OF_CONCERN.value, + EUDPPObjectProperty.HAS_CONCENTRATION.value, + EUDPPObjectProperty.HAS_LIFECYCLE_STAGE.value, + EUDPPObjectProperty.HAS_THRESHOLD.value, + EUDPPDatatypeProperty.NAME_IUPAC.value, + EUDPPDatatypeProperty.NAME_CAS.value, + EUDPPDatatypeProperty.NUMBER_CAS.value, + EUDPPDatatypeProperty.NUMBER_EC.value, + EUDPPDatatypeProperty.ABBREVIATION.value, + EUDPPDatatypeProperty.TRADE_NAME.value, + EUDPPDatatypeProperty.USUAL_NAME.value, + EUDPPDatatypeProperty.OTHER_NAME.value, + EUDPPDatatypeProperty.SUBSTANCE_LOCATION.value, + EUDPPDatatypeProperty.HAS_IMPACT_ON_ENVIRONMENT.value, + EUDPPDatatypeProperty.HAS_IMPACT_ON_HUMAN_HEALTH.value, + ] + + +def get_lca_properties() -> list[str]: + """Get URIs of LCA-related properties (Phase 4). + + Returns: + List of LCA property URIs + """ + return [ + EUDPPObjectProperty.QUANTIFIES.value, + EUDPPObjectProperty.QUANTIFIED_BY.value, + EUDPPObjectProperty.ICI_ASSESS_IC.value, + EUDPPObjectProperty.ICI_COMPUTES_IR.value, + EUDPPObjectProperty.CF_QUANTIFIES_ICI.value, + EUDPPObjectProperty.CM_CALCULATES_CF.value, + EUDPPObjectProperty.METHOD_USES_CM.value, + EUDPPObjectProperty.INCLUDES.value, + EUDPPObjectProperty.IMPOSES.value, + EUDPPObjectProperty.CORRESPONDS_TO_IC.value, + EUDPPDatatypeProperty.LCA_HAS_UNIT.value, + EUDPPDatatypeProperty.LCA_NUMERIC_VALUE.value, + ] + + +def is_product_relation(uri: str) -> bool: + """Check if a URI is a product relationship property. + + Args: + uri: Property URI to check + + Returns: + True if URI is a product relation property + """ + return uri in get_product_hierarchy_properties() diff --git a/src/dppvalidator/vocabularies/eudpp_substances.py b/src/dppvalidator/vocabularies/eudpp_substances.py new file mode 100644 index 0000000..611c665 --- /dev/null +++ b/src/dppvalidator/vocabularies/eudpp_substances.py @@ -0,0 +1,461 @@ +"""EU DPP Core Ontology substances of concern definitions. + +Provides dataclass representations of substances of concern from the EU DPP +Core Ontology, based on ESPR Art 2(28) and Art 7(5) substance tracking. + +Source: EU DPP Core Ontology v1.4.7 (Substances of Concern module) +Namespace: http://dpp.taltech.ee/EUDPP# +DOI: 10.5281/zenodo.15270342 +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from decimal import Decimal +from enum import Enum +from typing import ClassVar + +# ============================================================================= +# SOC Class Enums +# ============================================================================= + + +class EUDPPSubstanceClass(str, Enum): + """EU DPP Core Ontology substance class URIs.""" + + SUBSTANCE = "eudpp:Substance" + SUBSTANCE_OF_CONCERN = "eudpp:SubstanceOfConcern" + CONCENTRATION = "eudpp:Concentration" + THRESHOLD = "eudpp:Threshold" + + +class LifeCycleStage(str, Enum): + """Life cycle stages for substances of concern per ESPR Annex I(f).""" + + PRODUCTION = "production" + IN_PRODUCT = "in_product" + USE = "use" + END_OF_LIFE = "end_of_life" + WASTE = "waste" + RECYCLING = "recycling" + + +class HazardCategory(str, Enum): + """Hazard categories per ESPR Art 2(28) / Regulation (EC) No 1272/2008. + + Substances of concern are classified per these hazard classes. + """ + + # Carcinogenicity (Art 2(28)(b)(i)) + CARCINOGENICITY_1 = "carcinogenicity_cat_1" + CARCINOGENICITY_2 = "carcinogenicity_cat_2" + + # Germ cell mutagenicity (Art 2(28)(b)(ii)) + MUTAGENICITY_1 = "mutagenicity_cat_1" + MUTAGENICITY_2 = "mutagenicity_cat_2" + + # Reproductive toxicity (Art 2(28)(b)(iii)) + REPRODUCTIVE_TOXICITY_1 = "reproductive_toxicity_cat_1" + REPRODUCTIVE_TOXICITY_2 = "reproductive_toxicity_cat_2" + + # Endocrine disruption human health (Art 2(28)(b)(iv)) + ENDOCRINE_DISRUPTION_HUMAN_1 = "endocrine_disruption_human_cat_1" + ENDOCRINE_DISRUPTION_HUMAN_2 = "endocrine_disruption_human_cat_2" + + # Endocrine disruption environment (Art 2(28)(b)(v)) + ENDOCRINE_DISRUPTION_ENV_1 = "endocrine_disruption_env_cat_1" + ENDOCRINE_DISRUPTION_ENV_2 = "endocrine_disruption_env_cat_2" + + # Persistent, mobile, toxic (Art 2(28)(b)(vi)) + PMT = "persistent_mobile_toxic" + VPVM = "very_persistent_very_mobile" + + # Persistent, bioaccumulative, toxic (Art 2(28)(b)(vii)) + PBT = "persistent_bioaccumulative_toxic" + VPVB = "very_persistent_very_bioaccumulative" + + # Respiratory sensitisation (Art 2(28)(b)(viii)) + RESPIRATORY_SENSITISATION_1 = "respiratory_sensitisation_cat_1" + + # Skin sensitisation (Art 2(28)(b)(ix)) + SKIN_SENSITISATION_1 = "skin_sensitisation_cat_1" + + # Aquatic hazard (Art 2(28)(b)(x)) + AQUATIC_CHRONIC_1 = "aquatic_chronic_cat_1" + AQUATIC_CHRONIC_2 = "aquatic_chronic_cat_2" + AQUATIC_CHRONIC_3 = "aquatic_chronic_cat_3" + AQUATIC_CHRONIC_4 = "aquatic_chronic_cat_4" + + # Ozone layer (Art 2(28)(b)(xi)) + OZONE_LAYER = "hazardous_to_ozone_layer" + + # STOT repeated exposure (Art 2(28)(b)(xii)) + STOT_RE_1 = "stot_repeated_exposure_cat_1" + STOT_RE_2 = "stot_repeated_exposure_cat_2" + + # STOT single exposure (Art 2(28)(b)(xiii)) + STOT_SE_1 = "stot_single_exposure_cat_1" + STOT_SE_2 = "stot_single_exposure_cat_2" + + # SVHC per REACH (Art 2(28)(a)) + SVHC = "svhc_reach_art_57" + + # POP per Regulation (EU) 2019/1021 (Art 2(28)(c)) + POP = "persistent_organic_pollutant" + + +# ============================================================================= +# Validation Patterns +# ============================================================================= + + +# CAS Number format: 2-7 digits, hyphen, 2 digits, hyphen, 1 digit +# Example: 50-00-0 (Formaldehyde), 7440-23-5 (Sodium) +CAS_NUMBER_PATTERN = re.compile(r"^\d{2,7}-\d{2}-\d$") + +# EC Number format: 3 digits, hyphen, 3 digits, hyphen, 1 digit +# Example: 200-001-8 (Formaldehyde), 231-132-9 (Sodium) +EC_NUMBER_PATTERN = re.compile(r"^\d{3}-\d{3}-\d$") + + +def is_valid_cas_number(cas_number: str) -> bool: + """Validate CAS number format. + + CAS numbers follow the format: NNNNNNN-NN-N + where N is a digit, with 2-7 digits before the first hyphen. + + Args: + cas_number: CAS number string to validate + + Returns: + True if format is valid + + Examples: + >>> is_valid_cas_number("50-00-0") # Formaldehyde + True + >>> is_valid_cas_number("7440-23-5") # Sodium + True + >>> is_valid_cas_number("invalid") + False + """ + return bool(CAS_NUMBER_PATTERN.match(cas_number)) + + +def is_valid_ec_number(ec_number: str) -> bool: + """Validate EC number format. + + EC numbers follow the format: NNN-NNN-N + where N is a digit. + + Args: + ec_number: EC number string to validate + + Returns: + True if format is valid + + Examples: + >>> is_valid_ec_number("200-001-8") # Formaldehyde + True + >>> is_valid_ec_number("239-934-0") # Mercurous Oxide + True + >>> is_valid_ec_number("invalid") + False + """ + return bool(EC_NUMBER_PATTERN.match(ec_number)) + + +def validate_cas_checksum(cas_number: str) -> bool: + """Validate CAS number checksum. + + The last digit of a CAS number is a checksum digit, calculated + by taking the rightmost digit times 1, the next times 2, etc., + summing the products, and taking modulo 10. + + Args: + cas_number: CAS number string to validate + + Returns: + True if checksum is valid + + Examples: + >>> validate_cas_checksum("50-00-0") # Formaldehyde + True + >>> validate_cas_checksum("50-00-1") # Invalid checksum + False + """ + if not is_valid_cas_number(cas_number): + return False + + # Remove hyphens and get digits + digits = cas_number.replace("-", "") + + # Last digit is the check digit + check_digit = int(digits[-1]) + + # Calculate checksum from remaining digits + checksum = 0 + remaining = digits[:-1] + for i, digit in enumerate(reversed(remaining)): + checksum += int(digit) * (i + 1) + + return checksum % 10 == check_digit + + +# ============================================================================= +# Substance Dataclasses +# ============================================================================= + + +@dataclass(frozen=True, slots=True) +class Substance: + """Substance per Regulation (EC) No 1907/2006 Art 3(1). + + Means a chemical element and its compounds in the natural state or + obtained by any manufacturing process, including any additive necessary + to preserve its stability and any impurity deriving from the process used. + + Source: soc_v1.4.7.ttl + """ + + _class_uri: ClassVar[str] = EUDPPSubstanceClass.SUBSTANCE.value + + name_iupac: str | None = None + name_cas: str | None = None + other_name: str | None = None + + +@dataclass(frozen=True, slots=True) +class SubstanceOfConcern(Substance): + """Substance of concern per ESPR Art 2(28). + + A substance that meets criteria from: + - REACH Article 57 (SVHC) + - CLP Regulation hazard classes (carcinogenicity, mutagenicity, etc.) + - Regulation (EU) 2019/1021 (POPs) + - Or negatively affects reuse/recycling + + Source: soc_v1.4.7.ttl + """ + + _class_uri: ClassVar[str] = EUDPPSubstanceClass.SUBSTANCE_OF_CONCERN.value + + # Identification per ESPR Art 7(5) + number_cas: str | None = None + number_ec: str | None = None + abbreviation: str | None = None + trade_name: str | None = None + usual_name: str | None = None + + # Location & lifecycle per ESPR Art 7(5) / Annex I(f) + substance_location: str | None = None + lifecycle_stage: str | None = None + + # Impact per ESPR Annex I(f) + impact_on_health: str | None = None + impact_on_environment: str | None = None + + # Hazard classification + hazard_category: str | None = None + + def validate_identifiers(self) -> list[str]: + """Validate substance identifiers format. + + Returns: + List of validation error messages, empty if valid + """ + errors = [] + + if self.number_cas and not is_valid_cas_number(self.number_cas): + errors.append( + f"Invalid CAS number format: {self.number_cas}. " + f"Expected format: NN-NN-N to NNNNNNN-NN-N" + ) + + if self.number_ec and not is_valid_ec_number(self.number_ec): + errors.append(f"Invalid EC number format: {self.number_ec}. Expected format: NNN-NNN-N") + + return errors + + def has_valid_identification(self) -> bool: + """Check if substance has at least one valid identifier. + + Per ESPR Art 7(5), substances should be identifiable by at least + one of: IUPAC name, CAS name/number, EC number, or other names. + + Returns: + True if at least one identifier is present + """ + identifiers = [ + self.name_iupac, + self.name_cas, + self.number_cas, + self.number_ec, + self.abbreviation, + self.trade_name, + self.usual_name, + self.other_name, + ] + return any(id is not None for id in identifiers) + + +@dataclass(frozen=True, slots=True) +class Concentration: + """Concentration of substance of concern per ESPR Art 7(5). + + Defines the concentration, maximum concentration, or concentration range + of substances of concern at the level of the product, its relevant + components, or spare parts. + + Source: soc_v1.4.7.ttl + """ + + _class_uri: ClassVar[str] = EUDPPSubstanceClass.CONCENTRATION.value + + value: Decimal + unit: str + range_min: Decimal | None = None + range_max: Decimal | None = None + + def is_range(self) -> bool: + """Check if concentration is specified as a range. + + Returns: + True if both range_min and range_max are specified + """ + return self.range_min is not None and self.range_max is not None + + def validate(self) -> list[str]: + """Validate concentration values. + + Returns: + List of validation error messages, empty if valid + """ + errors = [] + + if self.value < 0: + errors.append(f"Concentration value cannot be negative: {self.value}") + + if self.range_min is not None and self.range_max is not None: + if self.range_min > self.range_max: + errors.append( + f"Range minimum ({self.range_min}) cannot exceed maximum ({self.range_max})" + ) + + if self.range_min < 0: + errors.append(f"Range minimum cannot be negative: {self.range_min}") + + return errors + + +@dataclass(frozen=True, slots=True) +class Threshold: + """Regulatory threshold for substance of concern per ESPR. + + By default, information on all substances of concern present in a + product above the relevant thresholds should be included in the DPP. + + Source: soc_v1.4.7.ttl + """ + + _class_uri: ClassVar[str] = EUDPPSubstanceClass.THRESHOLD.value + + value: Decimal + unit: str + regulation_reference: str | None = None + + def validate(self) -> list[str]: + """Validate threshold value. + + Returns: + List of validation error messages, empty if valid + """ + errors = [] + + if self.value < 0: + errors.append(f"Threshold value cannot be negative: {self.value}") + + return errors + + +@dataclass(frozen=True, slots=True) +class ConcentrationOfSubstanceOfConcern: + """Combined concentration and threshold for a substance of concern. + + Per ESPR Art 7(5), relates a SubstanceOfConcern to its Concentration + and optionally its regulatory Threshold. + + This is a convenience class for linking the ontology entities. + """ + + _class_uri: ClassVar[str] = "eudpp:ConcentrationOfSubstanceOfConcern" + + substance: SubstanceOfConcern + concentration: Concentration + threshold: Threshold | None = None + + def exceeds_threshold(self) -> bool | None: + """Check if concentration exceeds the threshold. + + Returns: + True if exceeds, False if not, None if no threshold specified + """ + if self.threshold is None: + return None + + # Compare in same units (assumes units are compatible) + if self.concentration.unit != self.threshold.unit: + return None # Cannot compare different units + + return self.concentration.value > self.threshold.value + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def get_all_hazard_categories() -> list[str]: + """Get all hazard category values. + + Returns: + List of hazard category strings + """ + return [cat.value for cat in HazardCategory] + + +def get_lifecycle_stages() -> list[str]: + """Get all lifecycle stage values. + + Returns: + List of lifecycle stage strings + """ + return [stage.value for stage in LifeCycleStage] + + +def is_svhc(hazard_category: str) -> bool: + """Check if hazard category is SVHC (Substances of Very High Concern). + + SVHC are identified per REACH Article 57. + + Args: + hazard_category: Hazard category value + + Returns: + True if substance is SVHC + """ + return hazard_category == HazardCategory.SVHC.value + + +def is_pop(hazard_category: str) -> bool: + """Check if hazard category is POP (Persistent Organic Pollutant). + + POPs are regulated under Regulation (EU) 2019/1021. + + Args: + hazard_category: Hazard category value + + Returns: + True if substance is POP + """ + return hazard_category == HazardCategory.POP.value diff --git a/src/dppvalidator/vocabularies/loader.py b/src/dppvalidator/vocabularies/loader.py index 881a80c..d447c9a 100644 --- a/src/dppvalidator/vocabularies/loader.py +++ b/src/dppvalidator/vocabularies/loader.py @@ -9,16 +9,11 @@ from pathlib import Path from typing import Any, ClassVar +import httpx + from dppvalidator.logging import get_logger from dppvalidator.vocabularies.cache import VocabularyCache -try: - import httpx - - HAS_HTTPX = True -except ImportError: - HAS_HTTPX = False - logger = get_logger(__name__) @@ -56,7 +51,7 @@ def _load_bundled_vocabulary(name: str) -> frozenset[str]: try: data_files = _get_data_files() data_file = data_files.joinpath(f"{name}.json") - content = data_file.read_text() + content = data_file.read_text(encoding="utf-8") data = json.loads(content) return frozenset(data.get("codes", [])) except (FileNotFoundError, json.JSONDecodeError, OSError) as e: @@ -124,7 +119,7 @@ def get_vocabulary(self, name: str) -> frozenset[str]: if cached is not None: return cached - if not self.offline_mode and HAS_HTTPX: + if not self.offline_mode: fetched = self._fetch_vocabulary(vocab_def) if fetched is not None: self._cache.set(vocab_def.url, fetched) @@ -178,10 +173,8 @@ def _fetch_vocabulary(self, vocab_def: VocabularyDefinition) -> frozenset[str] | Returns: Set of values or None on failure - """ - if not HAS_HTTPX: - return None + """ try: with httpx.Client(timeout=self.timeout_seconds) as client: response = client.get( @@ -214,25 +207,37 @@ def _extract_values(self, data: dict | list, vocab_name: str) -> frozenset[str] Returns: Set of extracted values """ + if not isinstance(data, dict): + return None + + values = self._extract_from_graph(data) or self._extract_from_members(data) + return frozenset(values) if values else None + + def _extract_from_graph(self, data: dict) -> set[str] | None: + """Extract values from JSON-LD @graph format.""" + if "@graph" not in data: + return None + values: set[str] = set() + for item in data["@graph"]: + if isinstance(item, dict): + code = item.get("@id", "").split("#")[-1] + if code: + values.add(code) + return values or None + + def _extract_from_members(self, data: dict) -> set[str] | None: + """Extract values from SKOS member format.""" + if "member" not in data: + return None - if isinstance(data, dict): - if "@graph" in data: - for item in data["@graph"]: - if isinstance(item, dict): - code = item.get("@id", "").split("#")[-1] - if code: - values.add(code) - elif "member" in data: - for member in data.get("member", []): - if isinstance(member, dict): - code = member.get("notation") or member.get("@id", "").split("#")[-1] - if code: - values.add(code) - - if values: - return frozenset(values) - return None + values: set[str] = set() + for member in data.get("member", []): + if isinstance(member, dict): + code = member.get("notation") or member.get("@id", "").split("#")[-1] + if code: + values.add(code) + return values or None def _get_fallback(self, name: str) -> frozenset[str]: """Get fallback vocabulary values. diff --git a/src/dppvalidator/vocabularies/ontology.py b/src/dppvalidator/vocabularies/ontology.py new file mode 100644 index 0000000..c1ac4a1 --- /dev/null +++ b/src/dppvalidator/vocabularies/ontology.py @@ -0,0 +1,555 @@ +"""EU DPP Core Ontology alignment and namespace mapping. + +Provides term mappings between UNTP vocabulary and the official EU DPP Core +Ontology from CIRPASS-2. + +Source: EU DPP Core Ontology v1.7.1 (Product and DPP module) +Namespace: http://dpp.taltech.ee/EUDPP# +DOI: 10.5281/zenodo.15270342 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterator + + +class EUDPPNamespace(str, Enum): + """EU DPP Core Ontology and related namespaces. + + Based on official CIRPASS-2 ontology v1.7.1. + """ + + # Official EU DPP Core Ontology namespace (TalTech) + EUDPP = "http://dpp.taltech.ee/EUDPP#" + + # LCA module namespace (CEA France) - different origin + LCA = "http://dpp.cea.fr/EUDPP/LCA#" + + # SI Digital Framework (measurement units) + SI = "https://si-digital-framework.org/SI#" + + # QUDT Quantities, Units, Dimensions and Types + QUDT = "http://qudt.org/schema/qudt#" + + # W3C SHACL namespace + SH = "http://www.w3.org/ns/shacl#" + + # EU DPP Vocabulary Hub + DPP_HUB = "https://dpp.vocabulary-hub.eu/" + + # W3C Verifiable Credentials v2 + VC2 = "https://www.w3.org/ns/credentials/v2" + + # UNTP DPP vocabulary + UNTP_DPP = "https://test.uncefact.org/vocabulary/untp/dpp/" + + # Schema.org + SCHEMA = "https://schema.org/" + + # GS1 vocabulary + GS1 = "https://gs1.org/voc/" + + +# Backward compatibility alias +CIRPASSNamespace = EUDPPNamespace + + +class DPPStatus(str, Enum): + """DPP instance status per EU DPP Core Ontology. + + The status of the DPP instance as a digital resource. + """ + + ACTIVE = "Active" + ARCHIVED = "Archived" + + +class DPPGranularity(str, Enum): + """DPP granularity level per ESPR and SR5423. + + The level of granularity of the ProductID as per ESPR. + Values from official EU DPP Core Ontology. + """ + + MODEL = "model" # All units of a product version + BATCH = "batch" # Subset from specific plant/time + PRODUCT = "product" # Single unit (official term, not 'item') + + +# Sentinel that signals "this term has no equivalent in the given UNTP version" +# (e.g. v0.6's ``gtin`` field is gone in v0.7 — encoded as ``Product.id`` plus +# ``idScheme`` on a GS1 scheme). Using a sentinel rather than ``None`` keeps +# the dataclass slots type-clean (``str``) and makes "intentionally absent" +# explicit when reading the table. +TERM_REMOVED: str = "" + + +@dataclass(frozen=True, slots=True) +class TermMapping: + """Mapping between UNTP term(s) and a CIRPASS / EU DPP ontology URI. + + Phase 3c of docs/plans/UNTP_0.7.0_MIGRATION.md added per-version columns + so a single mapping row can carry both UNTP v0.6.x and v0.7.x term + names without duplicating the ESPR reference and description. + + Attributes: + untp_term: The "canonical" UNTP term — historically the v0.6 field + name, kept as the row's primary key so backward-compat callers + and existing tests continue to work without modification. + cirpass_uri: EU DPP Core Ontology URI in compact form + (e.g. ``eudpp:Product``). + description: Human-readable summary of the mapping. + espr_reference: ESPR / SR5423 / ISO citation for traceability. + untp_v0_6: The term spelling used by UNTP 0.6.x. Defaults to + :attr:`untp_term` so unchanged rows don't need to repeat themselves. + untp_v0_7: The term spelling used by UNTP 0.7.0. Defaults to + :attr:`untp_term` (i.e. unchanged across versions). Set + explicitly for renames, or to :data:`TERM_REMOVED` for fields + that no longer exist in v0.7 (e.g. ``gtin``). + """ + + untp_term: str + cirpass_uri: str + description: str + espr_reference: str | None = None + untp_v0_6: str | None = None + untp_v0_7: str | None = None + + def term_for(self, version: str) -> str | None: + """Return the UNTP term for a given version, or ``None`` if removed. + + Resolution rules (in order): + + 1. If a per-version column is set explicitly, use it. + 2. Otherwise fall back to :attr:`untp_term` (the canonical v0.6 spelling). + 3. If the per-version column is :data:`TERM_REMOVED`, return ``None`` + — the field has no equivalent in this UNTP version. + + Unknown version strings (anything that's not a 0.6.x / 0.7.x prefix) + fall back to :attr:`untp_term` so the table is forward-compatible. + """ + explicit: str | None + if version.startswith("0.6"): + explicit = self.untp_v0_6 + elif version.startswith("0.7"): + explicit = self.untp_v0_7 + else: + explicit = None + + chosen = explicit if explicit is not None else self.untp_term + return None if chosen == TERM_REMOVED else chosen + + +# Term mappings from UNTP to EU DPP Core Ontology +# Based on official CIRPASS-2 ontology v1.7.1 +# +# Mapping rows are written so the row's primary ``untp_term`` is the v0.6 +# spelling — this keeps the OntologyMapper's existing semantics. Rows that +# rename in v0.7 carry an explicit ``untp_v0_7`` column. Rows that remove +# in v0.7 use :data:`TERM_REMOVED`. See Phase 3c of +# docs/plans/UNTP_0.7.0_MIGRATION.md. +TERM_MAPPINGS: tuple[TermMapping, ...] = ( + # Core DPP and Product classes (unchanged across versions). + TermMapping( + untp_term="DigitalProductPassport", + cirpass_uri="eudpp:DPP", + description="Digital Product Passport", + espr_reference="ESPR Art 2(28)", + ), + TermMapping( + untp_term="Product", + cirpass_uri="eudpp:Product", + description="Physical product placed on market", + espr_reference="ESPR Art 2(1)", + ), + # Product identification + TermMapping( + untp_term="id", + cirpass_uri="eudpp:uniqueDPPID", + description="Unique DPP identifier (URI)", + espr_reference="ESPR Art 9(1)", + ), + # ``serialNumber`` (v0.6) → ``itemNumber`` (v0.7); same EU DPP target. + TermMapping( + untp_term="serialNumber", + cirpass_uri="eudpp:uniqueProductID", + description="Unique product identifier (item-level)", + espr_reference="ESPR Art 2(30)", + untp_v0_7="itemNumber", + ), + TermMapping( + untp_term="name", + cirpass_uri="eudpp:productName", + description="Product name", + espr_reference="ESPR Annex III", + ), + TermMapping( + untp_term="description", + cirpass_uri="eudpp:description", + description="Product description", + espr_reference="ESPR Annex III", + ), + TermMapping( + untp_term="productImage", + cirpass_uri="eudpp:productImage", + description="Product image URI", + espr_reference="ESPR Annex III", + ), + # ``gtin`` is removed in v0.7. v0.7 encodes GS1 GTINs by combining + # ``Product.id`` with an ``idScheme`` whose URI points at the GS1 + # register — so there's no single field to re-map onto eudpp:GTIN. + TermMapping( + untp_term="gtin", + cirpass_uri="eudpp:GTIN", + description="Global Trade Identification Number", + espr_reference="ISO/IEC 15459-6", + untp_v0_7=TERM_REMOVED, + ), + TermMapping( + untp_term="productCategory", + cirpass_uri="eudpp:commodityCode", + description="TARIC or commodity code", + espr_reference="Council Regulation (EEC) No 2658/87", + ), + # Actor identification + TermMapping( + untp_term="issuer", + cirpass_uri="eudpp:hasIssuer", + description="DPP issuer (economic operator)", + espr_reference="ESPR Annex III (g)", + ), + # ``producedByParty: Party`` (v0.6) → ``relatedParty: list[PartyRole]`` + # (v0.7). The v0.7 field is structurally different (typed list of + # role/party pairs) but the EU DPP target ``hasManufacturer`` is the + # same when the role is "manufacturer" — the exporter handles the + # role filtering separately. + TermMapping( + untp_term="producedByParty", + cirpass_uri="eudpp:hasManufacturer", + description="Product manufacturer", + espr_reference="ESPR Annex III (g)", + untp_v0_7="relatedParty", + ), + TermMapping( + untp_term="producedAtFacility", + cirpass_uri="eudpp:facilityID", + description="Unique facility identifier", + espr_reference="ESPR Art 2(33)", + ), + # Substances of concern + TermMapping( + untp_term="hazardous", + cirpass_uri="eudpp:containsSubstanceOfConcern", + description="Product contains substance of concern", + espr_reference="ESPR Art 7(5)", + ), + # Validity and lifecycle (envelope-level fields — same in both versions). + TermMapping( + untp_term="validFrom", + cirpass_uri="eudpp:validFrom", + description="DPP valid from date", + espr_reference="ESPR Art 9(2i)", + ), + TermMapping( + untp_term="validUntil", + cirpass_uri="eudpp:validUntil", + description="DPP valid until date", + espr_reference="ESPR Art 9(2i)", + ), + TermMapping( + untp_term="lastUpdate", + cirpass_uri="eudpp:lastUpdate", + description="Last DPP update timestamp", + espr_reference="ESPR Art 11", + ), + TermMapping( + untp_term="schemaVersion", + cirpass_uri="eudpp:schemaVersion", + description="Reference standard version", + espr_reference="ESPR Art 9", + ), + TermMapping( + untp_term="previousDPP", + cirpass_uri="eudpp:linkToPreviousDPP", + description="Link to previous DPP", + espr_reference="ESPR Art 11(d)", + ), + # Granularity: ``granularityLevel`` (v0.6) → ``idGranularity`` (v0.7). + TermMapping( + untp_term="granularityLevel", + cirpass_uri="eudpp:granularity", + description="DPP granularity (model/batch/product)", + espr_reference="SR5423 Annex II Part B 1.1", + untp_v0_7="idGranularity", + ), + # Product properties + TermMapping( + untp_term="characteristics", + cirpass_uri="eudpp:hasProperty", + description="Product property", + espr_reference="ESPR Annex I", + ), + TermMapping( + untp_term="isEnergyRelated", + cirpass_uri="eudpp:isEnergyRelated", + description="Energy-related product indicator", + espr_reference="ESPR Art 2(4)", + ), + # Product relations + TermMapping( + untp_term="isComponentOf", + cirpass_uri="eudpp:isComponentOf", + description="Product is component of another", + espr_reference="ESPR Art 2", + ), + TermMapping( + untp_term="isSparePartOf", + cirpass_uri="eudpp:isSparePartOf", + description="Product is spare part of another", + espr_reference="ESPR Art 2", + ), + # ---- v0.7-only mappings ---------------------------------------------- + # ``materialsProvenance`` (v0.6) → ``materialProvenance`` (v0.7, + # singular noun). Both spellings need to map to the same EU DPP + # predicate — we add a row whose canonical ``untp_term`` is the v0.6 + # name and whose v0.7 column carries the new spelling. + TermMapping( + untp_term="materialsProvenance", + cirpass_uri="eudpp:hasMaterialProvenance", + description="Material origin and mass-fraction information", + espr_reference="ESPR Art 7(5)", + untp_v0_7="materialProvenance", + ), + # ``conformityClaim`` (v0.6) collapses with the three scorecard + # classes into ``performanceClaim`` (v0.7). For ontology-mapping + # purposes both target the EU DPP performance/claim predicate. + TermMapping( + untp_term="conformityClaim", + cirpass_uri="eudpp:hasPerformanceClaim", + description="Performance / conformity claim attached to a product", + espr_reference="ESPR Annex III", + untp_v0_7="performanceClaim", + ), +) + + +class OntologyMapper: + """Maps UNTP terms to CIRPASS / EU DPP ontology URIs. + + Phase 3c added per-version awareness: callers that pass a UNTP + ``schema_version`` get the right column out of :data:`TERM_MAPPINGS`. + Callers that don't (the pre-Phase-3c API) keep the v0.6 behaviour — + the ``untp_term`` column remains the canonical key, so existing + forward and reverse lookups work unchanged. + """ + + def __init__(self) -> None: + """Initialize mapper with term mappings.""" + # Forward lookup is keyed on the row's canonical ``untp_term`` + # (v0.6 spelling). v0.7-specific spellings are reachable via + # ``find_mapping_for_term(term, version)``. + self._untp_to_cirpass: dict[str, TermMapping] = {m.untp_term: m for m in TERM_MAPPINGS} + self._cirpass_to_untp: dict[str, TermMapping] = {m.cirpass_uri: m for m in TERM_MAPPINGS} + + # Per-version forward index — populated lazily when needed via + # :meth:`_index_for_version`. Keys are e.g. ``"itemNumber"`` for + # v0.7 lookups. + self._index_cache: dict[str, dict[str, TermMapping]] = {} + + # Secondary index of every non-canonical, non-removed term spelling + # across all per-version columns (e.g. ``itemNumber`` for v0.7). + # This lets ``get_mapping`` and the no-version + # ``find_mapping_for_term`` resolve renamed-only terms without + # branching on a specific UNTP version literal. + secondary: dict[str, TermMapping] = {} + for mapping in TERM_MAPPINGS: + for alt in (mapping.untp_v0_6, mapping.untp_v0_7): + if alt is None or alt == TERM_REMOVED: + continue + if alt == mapping.untp_term: + continue + # Last write wins on collision, matching the per-version + # index behaviour below. + secondary[alt] = mapping + self._secondary_index: dict[str, TermMapping] = secondary + + def to_cirpass(self, untp_term: str) -> str | None: + """Get CIRPASS URI for a UNTP term. + + Args: + untp_term: UNTP vocabulary term (canonical / v0.6 spelling). + + Returns: + CIRPASS ontology URI or None if not mapped + """ + mapping = self._untp_to_cirpass.get(untp_term) + return mapping.cirpass_uri if mapping else None + + def to_untp(self, cirpass_uri: str, version: str | None = None) -> str | None: + """Get UNTP term for a CIRPASS URI, optionally version-aware. + + Args: + cirpass_uri: CIRPASS ontology URI + version: UNTP version SemVer string (e.g. for v0.7.0). If + supplied, the returned term reflects the spelling that + version uses (e.g. ``itemNumber`` for v0.7 instead of + ``serialNumber``). If the term is removed in that version, + returns ``None``. When ``version`` is ``None`` the canonical + (v0.6) spelling is returned — pre-Phase-3c behaviour. + + Returns: + UNTP term or None if not mapped (or removed in this version). + """ + mapping = self._cirpass_to_untp.get(cirpass_uri) + if mapping is None: + return None + if version is None: + return mapping.untp_term + return mapping.term_for(version) + + def get_mapping(self, term: str) -> TermMapping | None: + """Get full mapping for a term (UNTP or CIRPASS). + + Recognises the canonical (v0.6) spelling, every per-version + spelling registered in :data:`TERM_MAPPINGS`, and the EU DPP URI + as keys. Returns ``None`` if no row matches. + """ + return ( + self._untp_to_cirpass.get(term) + or self._cirpass_to_untp.get(term) + or self._secondary_index.get(term) + ) + + def get_espr_reference(self, untp_term: str) -> str | None: + """Get ESPR reference for a UNTP term.""" + mapping = self._untp_to_cirpass.get(untp_term) + return mapping.espr_reference if mapping else None + + def iter_mappings(self) -> Iterator[TermMapping]: + """Iterate over all term mappings.""" + yield from TERM_MAPPINGS + + def find_mapping_for_term(self, term: str, version: str | None = None) -> TermMapping | None: + """Look up a mapping by the version-specific spelling of a term. + + Phase 3c helper: callers that observe a v0.7 field name on the wire + (e.g. ``itemNumber`` or ``materialProvenance``) can resolve it to + the same :class:`TermMapping` row as the v0.6 spelling. When + ``version`` is ``None`` the canonical-key index is consulted first + and the secondary index of all per-version spellings is used as + a fallback. + """ + if version is None: + return self._untp_to_cirpass.get(term) or self._secondary_index.get(term) + return self._index_for_version(version).get(term) + + def _index_for_version(self, version: str) -> dict[str, TermMapping]: + """Build (and cache) the version-keyed forward index.""" + cached = self._index_cache.get(version) + if cached is not None: + return cached + index: dict[str, TermMapping] = {} + for mapping in TERM_MAPPINGS: + term = mapping.term_for(version) + if term is None: + continue + # Last write wins on collision — rows ordered later in + # TERM_MAPPINGS take precedence, which matches Python dict + # initialisation semantics elsewhere in this module. + index[term] = mapping + self._index_cache[version] = index + return index + + @property + def mapped_terms(self) -> list[str]: + """List of all mapped UNTP terms (v0.6 canonical spellings).""" + return list(self._untp_to_cirpass.keys()) + + def mapped_terms_for(self, version: str) -> list[str]: + """List of UNTP terms for a specific version (Phase 3c).""" + return list(self._index_for_version(version).keys()) + + @property + def mapping_count(self) -> int: + """Number of term mappings.""" + return len(TERM_MAPPINGS) + + +def get_eudpp_context() -> dict[str, str]: + """Get JSON-LD context with EU DPP Core Ontology namespace prefixes. + + Returns: + Dictionary of namespace prefixes for JSON-LD @context + """ + return { + "eudpp": EUDPPNamespace.EUDPP.value, + "lca": EUDPPNamespace.LCA.value, + "si": EUDPPNamespace.SI.value, + "qudt": EUDPPNamespace.QUDT.value, + "sh": EUDPPNamespace.SH.value, + "dpp": EUDPPNamespace.DPP_HUB.value, + "untp": EUDPPNamespace.UNTP_DPP.value, + "schema": EUDPPNamespace.SCHEMA.value, + "gs1": EUDPPNamespace.GS1.value, + } + + +# Backward compatibility alias +def get_cirpass_context() -> dict[str, str]: + """Deprecated: Use get_eudpp_context instead.""" + return get_eudpp_context() + + +def expand_eudpp_uri(compact_uri: str) -> str: + """Expand a compact EU DPP URI to full form. + + Args: + compact_uri: URI like "eudpp:Product" + + Returns: + Full URI like "http://dpp.taltech.ee/EUDPP#Product" + """ + if ":" not in compact_uri: + return compact_uri + + prefix, local = compact_uri.split(":", 1) + namespaces = {ns.name.lower(): ns.value for ns in EUDPPNamespace} + + base = namespaces.get(prefix.lower()) + if base: + return f"{base}{local}" + + return compact_uri + + +def compact_eudpp_uri(full_uri: str) -> str: + """Compact a full EU DPP URI to prefixed form. + + Args: + full_uri: Full URI + + Returns: + Compact URI with namespace prefix + """ + for ns in EUDPPNamespace: + if full_uri.startswith(ns.value): + local = full_uri[len(ns.value) :] + return f"{ns.name.lower()}:{local}" + + return full_uri + + +# Backward compatibility aliases +def expand_cirpass_uri(compact_uri: str) -> str: + """Deprecated: Use expand_eudpp_uri instead.""" + return expand_eudpp_uri(compact_uri) + + +def compact_cirpass_uri(full_uri: str) -> str: + """Deprecated: Use compact_eudpp_uri instead.""" + return compact_eudpp_uri(full_uri) diff --git a/src/dppvalidator/vocabularies/rdf_loader.py b/src/dppvalidator/vocabularies/rdf_loader.py new file mode 100644 index 0000000..06a51c5 --- /dev/null +++ b/src/dppvalidator/vocabularies/rdf_loader.py @@ -0,0 +1,391 @@ +"""RDF/TTL ontology loader with optional dependency support. + +Provides utilities for loading and parsing Turtle (TTL) ontology files +from the EU DPP Core Ontology and CIRPASS-2 vocabularies. + +Requires the [rdf] optional extra: + uv add "dppvalidator[rdf]" # or: pip install "dppvalidator[rdf]" + +This module is designed to gracefully handle missing dependencies, +raising informative ImportError messages when rdflib is not installed. +""" + +from __future__ import annotations + +from importlib.resources import files +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from dppvalidator.logging import get_logger + +if TYPE_CHECKING: + from rdflib import Graph + +logger = get_logger(__name__) + + +# ============================================================================= +# RDF Import Checking +# ============================================================================= + + +class RDFNotAvailableError(ImportError): + """Raised when RDF functionality is used without [rdf] extra installed.""" + + def __init__(self, feature: str = "RDF functionality") -> None: + super().__init__( + f"{feature} requires the [rdf] extra. " + "Install with: uv add dppvalidator[rdf] or pip install dppvalidator[rdf]" + ) + + +def _check_rdflib_available() -> None: + """Check if rdflib is available, raise informative error if not.""" + try: + import rdflib # noqa: F401 + except ImportError as e: + raise RDFNotAvailableError("rdflib") from e + + +def is_rdf_available() -> bool: + """Check if RDF dependencies are available. + + Returns: + True if rdflib is installed + """ + try: + import rdflib # noqa: F401 + + return True + except ImportError: + return False + + +def is_shacl_available() -> bool: + """Check if SHACL validation dependencies are available. + + Returns: + True if pyshacl is installed + """ + try: + import pyshacl # noqa: F401 + + return True + except ImportError: + return False + + +# ============================================================================= +# Ontology Loading +# ============================================================================= + + +def load_ontology(path: Path, format: str = "turtle") -> Graph: + """Load an ontology file into an RDF graph. + + Args: + path: Path to the ontology file + format: RDF format (default: "turtle" for .ttl files) + + Returns: + Parsed RDF Graph + + Raises: + RDFNotAvailableError: If rdflib is not installed + FileNotFoundError: If the ontology file doesn't exist + RuntimeError: If parsing fails + """ + _check_rdflib_available() + + from rdflib import Graph + + if not path.exists(): + raise FileNotFoundError(f"Ontology file not found: {path}") + + try: + g = Graph() + g.parse(path, format=format) + logger.debug("Loaded ontology from %s (%d triples)", path, len(g)) + return g + except Exception as e: + raise RuntimeError(f"Failed to parse ontology {path}: {e}") from e + + +def load_ontology_text(content: str, format: str = "turtle") -> Graph: + """Load an ontology from text content into an RDF graph. + + Args: + content: Ontology content as string + format: RDF format (default: "turtle") + + Returns: + Parsed RDF Graph + + Raises: + RDFNotAvailableError: If rdflib is not installed + RuntimeError: If parsing fails + """ + _check_rdflib_available() + + from rdflib import Graph + + try: + g = Graph() + g.parse(data=content, format=format) + logger.debug("Loaded ontology from text (%d triples)", len(g)) + return g + except Exception as e: + raise RuntimeError(f"Failed to parse ontology text: {e}") from e + + +# ============================================================================= +# Bundled Ontology Loading +# ============================================================================= + + +def _get_ontology_data_dir() -> Any: + """Get the ontology data directory using importlib.resources.""" + return files("dppvalidator.vocabularies.data.ontologies") + + +def load_bundled_ontology(filename: str) -> Graph: + """Load a bundled ontology file from the package data. + + Args: + filename: Name of the ontology file (e.g., "soc_v1.4.7.ttl") + + Returns: + Parsed RDF Graph + + Raises: + RDFNotAvailableError: If rdflib is not installed + FileNotFoundError: If the ontology file doesn't exist + RuntimeError: If parsing fails + """ + _check_rdflib_available() + + from rdflib import Graph + + try: + data_dir = _get_ontology_data_dir() + ontology_path = data_dir.joinpath(filename) + content = ontology_path.read_text(encoding="utf-8") + + g = Graph() + g.parse(data=content, format="turtle") + logger.debug("Loaded bundled ontology %s (%d triples)", filename, len(g)) + return g + except FileNotFoundError: + raise FileNotFoundError(f"Bundled ontology not found: {filename}") from None + except Exception as e: + raise RuntimeError(f"Failed to load bundled ontology {filename}: {e}") from e + + +def load_eudpp_core_ontology() -> Graph: + """Load the EU DPP Core Ontology (product_dpp). + + Returns: + Parsed RDF Graph with EU DPP Core Ontology + + Raises: + RDFNotAvailableError: If rdflib is not installed + """ + return load_bundled_ontology("product_dpp_v1.7.1.ttl") + + +def load_soc_ontology() -> Graph: + """Load the Substances of Concern (SOC) ontology. + + Returns: + Parsed RDF Graph with SOC ontology + + Raises: + RDFNotAvailableError: If rdflib is not installed + """ + return load_bundled_ontology("soc_v1.4.7.ttl") + + +def load_lca_ontology() -> Graph: + """Load the LCA/Environmental Footprint ontology. + + Returns: + Parsed RDF Graph with LCA ontology + + Raises: + RDFNotAvailableError: If rdflib is not installed + """ + return load_bundled_ontology("lca_v2.0.ttl") + + +def load_actor_ontology() -> Graph: + """Load the Actor/Role ontology. + + Returns: + Parsed RDF Graph with Actor ontology + + Raises: + RDFNotAvailableError: If rdflib is not installed + """ + return load_bundled_ontology("actors_roles_v1.5.1.ttl") + + +def load_all_eudpp_ontologies() -> Graph: + """Load and merge all EU DPP ontologies into a single graph. + + Returns: + Merged RDF Graph with all EU DPP ontologies + + Raises: + RDFNotAvailableError: If rdflib is not installed + """ + _check_rdflib_available() + + from rdflib import Graph + + merged = Graph() + + ontologies = [ + "eudpp_core_v1.3.1.ttl", + "product_dpp_v1.7.1.ttl", + "actors_roles_v1.5.1.ttl", + "soc_v1.4.7.ttl", + "lca_v2.0.ttl", + ] + + for filename in ontologies: + try: + g = load_bundled_ontology(filename) + merged += g + logger.debug("Merged %s into combined graph", filename) + except FileNotFoundError: + logger.warning("Ontology %s not found, skipping", filename) + + logger.info("Loaded %d ontologies with %d total triples", len(ontologies), len(merged)) + return merged + + +# ============================================================================= +# SHACL Loading +# ============================================================================= + + +def _get_schema_data_dir() -> Any: + """Get the schema data directory using importlib.resources.""" + return files("dppvalidator.vocabularies.data.schemas") + + +def load_cirpass_shacl_shapes() -> Graph: + """Load CIRPASS SHACL shapes for validation. + + Returns: + Parsed RDF Graph with SHACL shapes + + Raises: + RDFNotAvailableError: If rdflib is not installed + FileNotFoundError: If SHACL file doesn't exist + """ + _check_rdflib_available() + + from rdflib import Graph + + try: + data_dir = _get_schema_data_dir() + shacl_path = data_dir.joinpath("cirpass_dpp_shacl.ttl") + content = shacl_path.read_text(encoding="utf-8") + + g = Graph() + g.parse(data=content, format="turtle") + logger.debug("Loaded CIRPASS SHACL shapes (%d triples)", len(g)) + return g + except FileNotFoundError: + raise FileNotFoundError("CIRPASS SHACL shapes not found") from None + except Exception as e: + raise RuntimeError(f"Failed to load CIRPASS SHACL shapes: {e}") from e + + +# ============================================================================= +# Query Utilities +# ============================================================================= + + +def query_ontology(graph: Graph, sparql_query: str) -> list[dict[str, Any]]: + """Execute a SPARQL query on an RDF graph. + + Args: + graph: RDF Graph to query + sparql_query: SPARQL query string + + Returns: + List of result dictionaries + + Raises: + RDFNotAvailableError: If rdflib is not installed + """ + _check_rdflib_available() + + results = [] + for row in graph.query(sparql_query): + result = {} + for i, var in enumerate(row): + result[f"var{i}"] = str(var) if var else None + results.append(result) + + return results + + +def get_ontology_classes(graph: Graph) -> list[str]: + """Get all class URIs from an ontology. + + Args: + graph: RDF Graph to query + + Returns: + List of class URIs + """ + _check_rdflib_available() + + query = """ + SELECT DISTINCT ?class WHERE { + ?class a owl:Class . + } + """ + + try: + results = [] + for row in graph.query(query): + if row[0]: + results.append(str(row[0])) + return results + except Exception: + # Fallback if query fails + return [] + + +def get_ontology_properties(graph: Graph) -> list[str]: + """Get all property URIs from an ontology. + + Args: + graph: RDF Graph to query + + Returns: + List of property URIs (both object and datatype properties) + """ + _check_rdflib_available() + + query = """ + SELECT DISTINCT ?prop WHERE { + { ?prop a owl:ObjectProperty . } + UNION + { ?prop a owl:DatatypeProperty . } + } + """ + + try: + results = [] + for row in graph.query(query): + if row[0]: + results.append(str(row[0])) + return results + except Exception: + # Fallback if query fails + return [] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5fa751b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,201 @@ +"""Shared pytest fixtures for dppvalidator tests. + +Phase 5 of ``docs/plans/UNTP_0.7.0_MIGRATION.md`` parametrises the +:func:`valid_dpp_data` fixture over both supported UNTP DPP versions +(0.6.1 and 0.7.0). Tests that need to pin a specific version use the +``@pytest.mark.dpp_version("X.Y.Z")`` marker; tests without a marker +run against both shapes. + +The version list is read from the schema registry — adding a new +UNTP version automatically expands the matrix. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +import pytest + +from dppvalidator.schemas.registry import SCHEMA_REGISTRY + +if TYPE_CHECKING: + from collections.abc import Iterable + +# The matrix used by parametrised fixtures. We surface "0.6.1" + "0.7.0" +# as canonical entries; "0.6.0" stays in the registry but isn't matrix-tested +# because it shares the v0.6 wire shape with 0.6.1 — adding it would +# duplicate every test slot for no extra coverage. +_MATRIX_DPP_VERSIONS: tuple[str, ...] = ("0.6.1", "0.7.0") + + +def pytest_configure(config: pytest.Config) -> None: + """Register dppvalidator-specific markers.""" + config.addinivalue_line( + "markers", + "dpp_version(version): pin a parametrised dpp fixture to a single " + "UNTP DPP version. Use to keep a test class scoped to one wire shape " + "when its assertions are version-specific (Phase 5).", + ) + + +def _matrix_versions() -> tuple[str, ...]: + """Return the parametrisation matrix, intersected with the registry. + + Defensive: if a matrix version somehow drops out of the registry + we'd silently parametrise over a missing schema. This guards against + that by filtering to the current registry's version set. + """ + return tuple(v for v in _MATRIX_DPP_VERSIONS if v in SCHEMA_REGISTRY) + + +@pytest.fixture(params=_matrix_versions(), ids=lambda v: f"v{v}") +def dpp_version(request: pytest.FixtureRequest) -> str: + """The UNTP DPP version for the current parametrisation slot. + + Tests can pin to a single version with + ``@pytest.mark.dpp_version("0.6.1")``; non-matching params are + skipped so the surviving run is single-version. Tests that don't + apply the marker run twice (once per matrix version). + """ + marker = request.node.get_closest_marker("dpp_version") + if marker is not None: + wanted = marker.args[0] if marker.args else None + if wanted is not None and wanted != request.param: + pytest.skip(f"dpp_version({wanted!r}) marker filters out {request.param!r}") + return request.param + + +def _v06_payload() -> dict[str, Any]: + """Minimal valid 0.6.x DPP payload.""" + return { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ], + "type": ["DigitalProductPassport", "VerifiableCredential"], + "id": "https://example.com/credentials/dpp-001", + "issuer": { + "id": "https://example.com/issuers/001", + "name": "Example Company Ltd", + }, + "validFrom": "2024-01-01T00:00:00Z", + "validUntil": "2034-01-01T00:00:00Z", + "credentialSubject": { + "id": "https://example.com/subject/001", + "type": ["ProductPassport"], + "product": { + "id": "https://example.com/products/001", + "name": "Example Product", + }, + }, + } + + +def _v07_payload() -> dict[str, Any]: + """Minimal valid 0.7.0 DPP payload. + + Includes every v0.7-required field on Product so the engine's + model-layer validation passes without modification. + """ + return { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/", + ], + "type": ["DigitalProductPassport", "VerifiableCredential"], + "id": "https://example.com/credentials/dpp-001", + "name": "Minimal v0.7 DPP", + "issuer": { + "type": ["CredentialIssuer"], + "id": "did:example:issuer-001", + "name": "Example Company Ltd", + }, + "validFrom": "2024-01-01T00:00:00Z", + "validUntil": "2034-01-01T00:00:00Z", + "credentialSubject": { + "type": ["Product"], + "id": "https://example.com/products/001", + "name": "Example Product", + "idScheme": { + "type": ["IdentifierScheme"], + "id": "https://example.com/schemes/internal", + "name": "Internal product scheme", + }, + "idGranularity": "model", + "productCategory": [ + { + "type": ["Classification"], + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification", + "code": "12345", + "name": "Example category", + } + ], + "producedAtFacility": { + "type": ["Facility"], + "id": "https://example.com/facilities/001", + "name": "Example facility", + }, + "countryOfProduction": {"countryCode": "DE", "countryName": "Germany"}, + }, + } + + +_PAYLOAD_BY_VERSION: dict[str, Any] = { + "0.6.1": _v06_payload, + "0.7.0": _v07_payload, +} + + +@pytest.fixture +def valid_dpp_data(dpp_version: str) -> dict[str, Any]: + """Return a minimal valid DPP for the active parametrisation slot. + + Phase 5: this fixture is now parametrised over both supported UNTP + versions via :func:`dpp_version`. Tests that hardcode a specific + UNTP version on the engine should pin themselves with + ``@pytest.mark.dpp_version("X.Y.Z")``. + + The returned payload satisfies: + - CQ001: Mandatory ESPR attributes (issuer, validFrom, credentialSubject.product[/Product]). + - CQ016: Validity period (validFrom, validUntil). + - All v0.7-required Product fields when the version slot is 0.7.0. + """ + factory = _PAYLOAD_BY_VERSION.get(dpp_version) + if factory is None: # pragma: no cover — defensive + raise RuntimeError(f"No fixture payload registered for {dpp_version!r}") + return factory() + + +@pytest.fixture +def valid_dpp_json(valid_dpp_data: dict[str, Any]) -> str: + """Return :func:`valid_dpp_data` serialised as a JSON string.""" + return json.dumps(valid_dpp_data) + + +@pytest.fixture +def minimal_dpp_data() -> dict[str, Any]: + """Return minimal v0.6.x DPP data (may not pass all CIRPASS rules). + + Use this for tests that specifically test partial/incomplete DPPs. + Pinned to v0.6.x because tests using it typically construct a + :class:`dppvalidator.models.passport.DigitalProductPassport` + directly — the top-level alias resolves to the v0.6 model. + """ + return { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ], + "id": "https://example.com/credentials/dpp-001", + "issuer": { + "id": "https://example.com/issuers/001", + "name": "Example Company Ltd", + }, + } + + +def all_matrix_versions() -> Iterable[str]: + """Public helper for tests that need to know the matrix versions.""" + return _matrix_versions() diff --git a/tests/fixtures/invalid/0.7.0/claim_missing_performance.json b/tests/fixtures/invalid/0.7.0/claim_missing_performance.json new file mode 100644 index 0000000..495da17 --- /dev/null +++ b/tests/fixtures/invalid/0.7.0/claim_missing_performance.json @@ -0,0 +1,53 @@ +{ + "type": ["DigitalProductPassport", "VerifiableCredential"], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/" + ], + "id": "https://example.com/credentials/dpp-claim-broken", + "name": "Claim with no claimedPerformance and no score — Performance invariant fails", + "issuer": { + "type": ["CredentialIssuer"], + "id": "did:example:issuer-001", + "name": "Example Company Ltd" + }, + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "type": ["Product"], + "id": "https://example.com/products/001", + "name": "Example Product", + "idScheme": { + "type": ["IdentifierScheme"], + "id": "https://example.com/schemes/internal", + "name": "Internal product scheme" + }, + "idGranularity": "model", + "productCategory": [ + { + "type": ["Classification"], + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification", + "code": "12345", + "name": "Example category" + } + ], + "producedAtFacility": { + "type": ["Facility"], + "id": "https://example.com/facilities/001", + "name": "Example facility" + }, + "countryOfProduction": {"countryCode": "DE", "countryName": "Germany"}, + "performanceClaim": [ + { + "type": ["Claim"], + "id": "https://example.com/claims/broken", + "name": "Broken claim", + "claimedPerformance": [ + { + "metric": {"type": ["PerformanceMetric"], "name": "Carbon footprint"} + } + ] + } + ] + } +} diff --git a/tests/fixtures/invalid/0.7.0/country_string_regression.json b/tests/fixtures/invalid/0.7.0/country_string_regression.json new file mode 100644 index 0000000..b701532 --- /dev/null +++ b/tests/fixtures/invalid/0.7.0/country_string_regression.json @@ -0,0 +1,41 @@ +{ + "type": ["DigitalProductPassport", "VerifiableCredential"], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/" + ], + "id": "https://example.com/credentials/dpp-country-regression", + "name": "Country code as bare string — should fail (v0.7 expects {countryCode, countryName})", + "issuer": { + "type": ["CredentialIssuer"], + "id": "did:example:issuer-001", + "name": "Example Company Ltd" + }, + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "type": ["Product"], + "id": "https://example.com/products/001", + "name": "Example Product", + "idScheme": { + "type": ["IdentifierScheme"], + "id": "https://example.com/schemes/internal", + "name": "Internal product scheme" + }, + "idGranularity": "model", + "productCategory": [ + { + "type": ["Classification"], + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification", + "code": "12345", + "name": "Example category" + } + ], + "producedAtFacility": { + "type": ["Facility"], + "id": "https://example.com/facilities/001", + "name": "Example facility" + }, + "countryOfProduction": "DE" + } +} diff --git a/tests/fixtures/invalid/0.7.0/material_missing_materialType.json b/tests/fixtures/invalid/0.7.0/material_missing_materialType.json new file mode 100644 index 0000000..446c1bd --- /dev/null +++ b/tests/fixtures/invalid/0.7.0/material_missing_materialType.json @@ -0,0 +1,48 @@ +{ + "type": ["DigitalProductPassport", "VerifiableCredential"], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/" + ], + "id": "https://example.com/credentials/dpp-material-missing-type", + "name": "Material missing required materialType — should fail", + "issuer": { + "type": ["CredentialIssuer"], + "id": "did:example:issuer-001", + "name": "Example Company Ltd" + }, + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "type": ["Product"], + "id": "https://example.com/products/001", + "name": "Example Product", + "idScheme": { + "type": ["IdentifierScheme"], + "id": "https://example.com/schemes/internal", + "name": "Internal product scheme" + }, + "idGranularity": "model", + "productCategory": [ + { + "type": ["Classification"], + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification", + "code": "12345", + "name": "Example category" + } + ], + "producedAtFacility": { + "type": ["Facility"], + "id": "https://example.com/facilities/001", + "name": "Example facility" + }, + "countryOfProduction": {"countryCode": "DE", "countryName": "Germany"}, + "materialProvenance": [ + { + "name": "Steel", + "originCountry": {"countryCode": "DE", "countryName": "Germany"}, + "massFraction": 0.5 + } + ] + } +} diff --git a/tests/fixtures/invalid/0.7.0/missing_credentialSubject.json b/tests/fixtures/invalid/0.7.0/missing_credentialSubject.json new file mode 100644 index 0000000..a68a123 --- /dev/null +++ b/tests/fixtures/invalid/0.7.0/missing_credentialSubject.json @@ -0,0 +1,15 @@ +{ + "type": ["DigitalProductPassport", "VerifiableCredential"], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/" + ], + "id": "https://example.com/credentials/dpp-missing-cs", + "name": "Missing credentialSubject — should fail", + "issuer": { + "type": ["CredentialIssuer"], + "id": "did:example:issuer-001", + "name": "Example Company Ltd" + }, + "validFrom": "2024-01-01T00:00:00Z" +} diff --git a/tests/fixtures/invalid/0.7.0/missing_name.json b/tests/fixtures/invalid/0.7.0/missing_name.json new file mode 100644 index 0000000..c8de3c4 --- /dev/null +++ b/tests/fixtures/invalid/0.7.0/missing_name.json @@ -0,0 +1,40 @@ +{ + "type": ["DigitalProductPassport", "VerifiableCredential"], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/" + ], + "id": "https://example.com/credentials/dpp-missing-name", + "issuer": { + "type": ["CredentialIssuer"], + "id": "did:example:issuer-001", + "name": "Example Company Ltd" + }, + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "type": ["Product"], + "id": "https://example.com/products/001", + "name": "Example Product", + "idScheme": { + "type": ["IdentifierScheme"], + "id": "https://example.com/schemes/internal", + "name": "Internal product scheme" + }, + "idGranularity": "model", + "productCategory": [ + { + "type": ["Classification"], + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification", + "code": "12345", + "name": "Example category" + } + ], + "producedAtFacility": { + "type": ["Facility"], + "id": "https://example.com/facilities/001", + "name": "Example facility" + }, + "countryOfProduction": {"countryCode": "DE", "countryName": "Germany"} + } +} diff --git a/tests/fixtures/invalid/0.7.0/missing_validFrom.json b/tests/fixtures/invalid/0.7.0/missing_validFrom.json new file mode 100644 index 0000000..a113af6 --- /dev/null +++ b/tests/fixtures/invalid/0.7.0/missing_validFrom.json @@ -0,0 +1,40 @@ +{ + "type": ["DigitalProductPassport", "VerifiableCredential"], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/" + ], + "id": "https://example.com/credentials/dpp-missing-validFrom", + "name": "Missing validFrom — should fail", + "issuer": { + "type": ["CredentialIssuer"], + "id": "did:example:issuer-001", + "name": "Example Company Ltd" + }, + "credentialSubject": { + "type": ["Product"], + "id": "https://example.com/products/001", + "name": "Example Product", + "idScheme": { + "type": ["IdentifierScheme"], + "id": "https://example.com/schemes/internal", + "name": "Internal product scheme" + }, + "idGranularity": "model", + "productCategory": [ + { + "type": ["Classification"], + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification", + "code": "12345", + "name": "Example category" + } + ], + "producedAtFacility": { + "type": ["Facility"], + "id": "https://example.com/facilities/001", + "name": "Example facility" + }, + "countryOfProduction": {"countryCode": "DE", "countryName": "Germany"} + } +} diff --git a/tests/fixtures/samples/BatteryPassDataModel_BatteryPass_GeneralProductInformation-payload.json b/tests/fixtures/samples/BatteryPassDataModel_BatteryPass_GeneralProductInformation-payload.json new file mode 100644 index 0000000..d6230da --- /dev/null +++ b/tests/fixtures/samples/BatteryPassDataModel_BatteryPass_GeneralProductInformation-payload.json @@ -0,0 +1,37 @@ +{ + "batteryCategory": "lmt", + "operatorInformation": { + "identifier": "VLhpfQGTMDYpsBZxvfBoeygjb", + "emailAddress": "-w-......www-www-w-...--w--w.-w-www--.w...-w.www.w..-w..--.ww@.ww..-www-ww--ww.ww---w.w-w.w.ww...-w%mKozizkOjnxNSVyMliITPITDtxTLqUoUkEZlpZTvFMYBIjfUgqHyJIztdiBfETLcpZPXYcTSCSpSTTFAfi", + "postalAddress": { + "addressCountry": "Germany", + "streetAddress": "Hindenburgstr. 10", + "postalCode": "10719" + }, + "contactName": "RYtGKbgicZaHCBRQDSx", + "webAddress": "ftp://ftp.is.co.za/rfc/rfc1808.txt" + }, + "productIdentifier": "eOMtThyhVNLWUZNRcBaQKxI", + "batteryStatus": "Original", + "puttingIntoService": "2025-06-17T11:14:27.698+02:00", + "batteryMass": 699, + "manufacturingDate": "2025-06-17T11:14:27.698+02:00", + "batteryPassportIdentifier": "urn:bmwk:123456687678", + "warrentyPeriod": "--06", + "manufacturerInformation": { + "identifier": "JxkyvRnL", + "emailAddress": ".ww-...-ww...@ww.-..w.-www-.-.-w--w.ww--..-.w.ww.-...w.-.w.jGphZZQBHAFyIqSqHUNGwnomwanuIDiDpbLftOdDNZMwzMeqjAQpLVSSkKExPOypZXmRDeCGjVwKMtHHEhhmd", + "postalAddress": { + "addressCountry": "Germany", + "streetAddress": "Hindenburgstr. 10", + "postalCode": "10719" + }, + "contactName": "yedUsFwdkelQbxeTeQOvaScfqIOOmaa", + "webAddress": "telnet://192.0.2.16:80/" + }, + "manufacturingPlace": { + "addressCountry": "Germany", + "streetAddress": "Hindenburgstr. 10", + "postalCode": "10719" + } +} diff --git a/tests/fixtures/samples/batterypass_BatteryPassDataModel_CarbonFootprintForBatteries-ld.json b/tests/fixtures/samples/batterypass_BatteryPassDataModel_CarbonFootprintForBatteries-ld.json new file mode 100644 index 0000000..d77a246 --- /dev/null +++ b/tests/fixtures/samples/batterypass_BatteryPassDataModel_CarbonFootprintForBatteries-ld.json @@ -0,0 +1,244 @@ +{ + "@graph": [ + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#CarbonFootprintPerLifecycleStageEntity", + "samm:properties": { + "@list": [ + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#lifecycleStage" + }, + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#carbonFootprint" + } + ] + }, + "samm:description": { + "@language": "en", + "@value": "The carbon footprint of the battery as share of total Battery Carbon Footprint, differentiated per life cycle stage raw material extraction, main production, distribution and end of \u00b4\u2510\u00a2ife and recycling." + }, + "@type": "samm:Entity" + }, + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#batteryCarbonFootprint", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#BatteryCarbonFootprint" + }, + "samm:description": { + "@language": "en", + "@value": "The carbon footprint of the battery, calculated as kg of carbon dioxide equivalent per one kWh of the total energy provided by the battery over its expected service life, as declared in the Carbon Footprint Declaration.\nDIN DKE Spec 99100 chapter reference: 6.3.2" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#BatteryCarbonFootprint", + "samm-c:unit": { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#kilogramperkilowatthour" + }, + "samm:dataType": { + "@id": "xsd:double" + }, + "samm:description": { + "@language": "en", + "@value": "The battery carbon footprint is an aggregation of the carbon footprint of the individual lifecycle stages" + }, + "@type": "samm-c:Measurement" + }, + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#kilogramperkilowatthour", + "samm:symbol": "CO2e/kWh", + "samm:commonCode": "kg CO2e/kWh", + "@type": "samm:Unit" + }, + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#CarbonFootprintValue", + "samm-c:unit": { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#kilogramperkilowatthour" + }, + "samm:dataType": { + "@id": "xsd:double" + }, + "@type": "samm-c:Measurement" + }, + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#lifecycleStage", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#LifecycleStage" + }, + "samm:description": { + "@language": "en", + "@value": "The description of the life cycle stage " + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#LifecycleStage", + "samm-c:values": { + "@list": [ + "RawMaterialExtraction", + "MainProduction", + "Distribution", + "Recycling" + ] + }, + "samm:dataType": { + "@id": "xsd:string" + }, + "@type": "samm-c:Enumeration" + }, + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#CarbonFootprintForBatteries", + "samm:events": { + "@list": [] + }, + "samm:operations": { + "@list": [] + }, + "samm:properties": { + "@list": [ + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#batteryCarbonFootprint" + }, + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#carbonFootprintPerLifecycleStage" + }, + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#carbonFootprintPerformanceClass" + }, + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#carbonFootprintStudy" + }, + { + "@id": "_:b10" + } + ] + }, + "samm:see": { + "@id": "https://eur-lex.europa.eu/legal-content/EN/TXT/PDF/?uri=CELEX:32023R1542" + }, + "samm:description": { + "@language": "en", + "@value": "The battery passport must contain carbon footprint per functional unit of the battery as declared in the battery carbon footprint declaration in accordance with the entry into force of the implementing acts on the format of declaration. Reference: REGULATION (EU) 2023/1542 aka EU Battery Regulation" + }, + "@type": "samm:Aspect" + }, + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#carbonFootprint", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#CarbonFootprintValue" + }, + "samm:description": { + "@language": "en", + "@value": "Carbon footprint of the individual lifecycle stage" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#CarbonFootprintPerformanceClass", + "samm:dataType": { + "@id": "xsd:string" + }, + "samm:description": { + "@language": "en", + "@value": "EV, industrial and LMT batteries shall bear a conspicuous, clearly legible and indelible label indicating the carbon footprint of the battery and the carbon footprint performance class that the relevant battery model per manufacturing plant corresponds to. The carbon footprint performance class shall be accessible via the battery passport. A meaningful number of classes of performance will be developed (?) with category A being the best class with the lowest carbon footprint life cycle impact." + }, + "@type": "samm-c:Code" + }, + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#carbonFootprintPerformanceClass", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#CarbonFootprintPerformanceClass" + }, + "samm:description": { + "@language": "en", + "@value": "The carbon footprint performance class that the relevant battery model per manufacturing plant corresponds to.\n\nDIN DKE Spec 99100 chapter reference: 6.3.7" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#absoluteCarbonFootprint", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#AbsoluteCarbonFootprint" + }, + "samm:description": { + "@language": "en", + "@value": "As a non-mandatory data attribute, the battery passport should include the battery carbon footprint in absolute terms.\n\nThe absolute battery carbon footprint should be calculated as kilograms of carbon dioxide equivalent, without reference to the functional unit as prescribed by the battery regulation.\n\nDIN DKE Spec 99100 chapter reference: 6.3.10" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#AbsoluteCarbonFootprint", + "samm-c:unit": { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#kilogramm" + }, + "samm:dataType": { + "@id": "xsd:double" + }, + "@type": "samm-c:Measurement" + }, + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#CarbonFootprints", + "samm:dataType": { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#CarbonFootprintPerLifecycleStageEntity" + }, + "samm:description": { + "@language": "en", + "@value": "CarbainFootprints per lifecycle stage" + }, + "@type": "samm-c:List" + }, + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#kilogramm", + "samm:symbol": "kg", + "samm:referenceUnit": { + "@id": "unit:kilogram" + }, + "samm:preferredName": { + "@language": "en", + "@value": "Kilogramm Co2 Equivalent" + }, + "@type": "samm:Unit" + }, + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#carbonFootprintStudy", + "samm:characteristic": { + "@id": "samm-c:ResourcePath" + }, + "samm:description": { + "@language": "en", + "@value": "A web link to get access to a public version of the study supporting the carbon footprint values.\n\nDIN DKE Spec 99100 chapter reference: 6.3.8" + }, + "@type": "samm:Property" + }, + { + "@id": "_:b10", + "samm:optional": { + "@value": "true", + "@type": "xsd:boolean" + }, + "samm:property": { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#absoluteCarbonFootprint" + } + }, + { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#carbonFootprintPerLifecycleStage", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#CarbonFootprints" + }, + "samm:description": { + "@language": "en", + "@value": "The carbon footprint of the battery as share of total Battery Carbon Footprint, differentiated per life cycle stages raw material extraction, battery production, distribution and recycling.\n\nDIN DKE Spec 99100 chapter reference: \n6.3.3: Raw material extraction\n6.3.4: Main production\n6.3.5: Distrinution\n6.3.6: EoL/Recycling" + }, + "@type": "samm:Property" + } + ], + "@context": { + "samm-e": "urn:samm:org.eclipse.esmf.samm:entity:2.1.0#", + "unit": "urn:samm:org.eclipse.esmf.samm:unit:2.1.0#", + "samm-c": "urn:samm:org.eclipse.esmf.samm:characteristic:2.1.0#", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "samm": "urn:samm:org.eclipse.esmf.samm:meta-model:2.1.0#", + "@vocab": "urn:samm:io.BatteryPass.CarbonFootprint:1.2.0#" + } +} diff --git a/tests/fixtures/samples/batterypass_BatteryPassDataModel_Circularity-ld.json b/tests/fixtures/samples/batterypass_BatteryPassDataModel_Circularity-ld.json new file mode 100644 index 0000000..606e06d --- /dev/null +++ b/tests/fixtures/samples/batterypass_BatteryPassDataModel_Circularity-ld.json @@ -0,0 +1,714 @@ +{ + "@graph": [ + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#EndOfLifeInformation", + "samm:dataType": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#EndOfLifeInformationEntity" + }, + "@type": "samm-c:SingleEntity" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#EndOfLifeInformationEntity", + "samm:properties": { + "@list": [ + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#wastePrevention" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#separateCollection" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#informationOnCollection" + } + ] + }, + "@type": "samm:Entity" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#SparePartSourcesList", + "samm:dataType": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#SparePartSupplierEntity" + }, + "@type": "samm-c:List" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#SparePartSupplierEntity", + "samm:properties": { + "@list": [ + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#nameOfSupplier" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#addressOfSupplier" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#emailAddressOfSupplier" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#supplierWebAddress" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#components" + } + ] + }, + "samm:description": { + "@language": "en", + "@value": "The part numbers for components should be provided together with the postal address, e-mail address and web address of the sources for spare parts." + }, + "@type": "samm:Entity" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#ValidEmailAddress", + "samm:value": "^[\\w.-]+@[\\w.-]+\\.[A-Za-z]{2,}$", + "@type": "samm-c:RegularExpressionConstraint" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#PreConsumerShare", + "samm-c:unit": { + "@id": "unit:percent" + }, + "samm:dataType": { + "@id": "xsd:float" + }, + "@type": "samm-c:Measurement" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#informationOnCollection", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#ResourcePath" + }, + "samm:description": { + "@language": "en", + "@value": "Prevention and management of waste batteries: Point (c) of Article 60(1): information on the separate collection, the take back, the collection points and preparing for re-use, preparing for repurposing, and recycling operations available for waste batteries" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#documentURL", + "samm:characteristic": { + "@id": "samm-c:ResourcePath" + }, + "samm:description": { + "@language": "en", + "@value": "Link to document" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#documentType", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#Documenttype" + }, + "samm:description": { + "@language": "en", + "@value": "Describes type for document e.g. Dismantling manual" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#Documenttype", + "samm-c:values": { + "@list": [ + "BillOfMaterial", + "Model3D", + "DismantlingManual", + "RemovalManual", + "OtherManual", + "Drawing" + ] + }, + "samm:dataType": { + "@id": "xsd:string" + }, + "@type": "samm-c:Enumeration" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#ConsumerShareRange", + "samm-c:upperBoundDefinition": { + "@id": "samm-c:AT_MOST" + }, + "samm-c:lowerBoundDefinition": { + "@id": "samm-c:AT_LEAST" + }, + "samm-c:maxValue": { + "@value": "100", + "@type": "xsd:float" + }, + "samm-c:minValue": { + "@value": "0", + "@type": "xsd:float" + }, + "@type": "samm-c:RangeConstraint" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#supplierWebAddress", + "samm:characteristic": { + "@id": "samm-c:ResourcePath" + }, + "samm:description": { + "@language": "en", + "@value": "Web address of supplier for spare parts." + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#partName", + "samm:exampleValue": "Cell", + "samm:characteristic": { + "@id": "samm-c:Text" + }, + "samm:preferredName": { + "@language": "en", + "@value": "PartName" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#mimeType", + "samm:characteristic": { + "@id": "samm-c:MimeType" + }, + "samm:description": { + "@language": "en", + "@value": "Defines internet media typ to determin how to interpret the documentURL" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#safetyMeasures", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#SafetyMeasures" + }, + "samm:description": { + "@language": "en", + "@value": "Safety measures and instructions should also take past negative and extreme events as well as the separate data attributes ?battery status? and ?battery composition/chemistry? into account.\n\nDIN DKE Spec 99100 chapter reference: 6.6.1.5" + }, + "samm:preferredName": { + "@language": "en", + "@value": "SafetyMeasures" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#SafetyMeasures", + "samm:dataType": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#SafetyMeasuresEntity" + }, + "@type": "samm-c:SingleEntity" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#ComponentEntity", + "samm:properties": { + "@list": [ + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#partName" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#partNumber" + } + ] + }, + "@type": "samm:Entity" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#AddressOfSupplier", + "samm:dataType": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#PostalAddress" + }, + "samm:see": { + "@id": "https://schema.org/address" + }, + "@type": "samm-c:SingleEntity" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#PostalAddress", + "samm:properties": { + "@list": [ + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#addressCountry" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#postalCode" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#streetAddress" + } + ] + }, + "samm:see": { + "@id": "https://schema.org/PostalAddress" + }, + "@type": "samm:Entity" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#postConsumerShare", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#PostConsumerShareTrait" + }, + "samm:description": { + "@language": "en", + "@value": "Recycled material share from post-consumer waste (end-of-life scrap) of the active material." + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#PostConsumerShareTrait", + "samm-c:constraint": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#ConsumerShareRange" + }, + "samm-c:baseCharacteristic": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#PostConsumerShare" + }, + "@type": "samm-c:Trait" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#DismantlingandRemovalDocumentation", + "samm:properties": { + "@list": [ + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#documentType" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#mimeType" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#documentURL" + } + ] + }, + "samm:description": { + "@language": "en", + "@value": "Dismantling and Removal information, including at least:- Exploded diagrams of the battery system/pack showing the location of battery cells- Disassembly sequences- Type and number of fastening techniques to be unlocked- Tools required for disassembly- Warnings if risk of damaging parts exists- Amount of cells used and layoutEUBR: Annex XIII (2c)" + }, + "@type": "samm:Entity" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#endOfLifeInformation", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#EndOfLifeInformation" + }, + "samm:description": { + "@language": "en", + "@value": "Producer or producer responsibility organisations shall make information available to distributors and end-users on: the role of end-users in contributing to waste prevention, including by information on good practices and recommendations concerning the use of batteries aiming at extending their use phase and the possibilities of re-use, preparing for re-use, preparing for repurpose, repurposing and remanufacturing.\n\nDIN DKE Spec 99100 chapter reference: 6.6.3.2 - 6.6.3.4" + }, + "samm:preferredName": { + "@language": "en", + "@value": "EndOfLifeInformation" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#wastePrevention", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#ResourcePath" + }, + "samm:description": { + "@language": "en", + "@value": "Prevention and management of waste batteries: Point (a) of Article 60(1): Information on the role of end-users in contributing to waste prevention, including by information on good practices and recommendations concerning the use of batteries aiming at extending their use phase and the possibilities of re-use, preparing for re-use, preparing for repurpose, repurposing and remanufacturing" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#dismantlingAndRemovalInformation", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#DocumentationList" + }, + "samm:description": { + "@language": "en", + "@value": "Dismantling and Removal information, including at least:- Exploded diagrams of the battery system/pack showing the location of battery cells- Disassembly sequences- Type and number of fastening techniques to be unlocked- Tools required for disassembly- Warnings if risk of damaging parts exists- Amount of cells used and layout. BR Annex XIII (2c)\n\nDIN DKE Spec 99100 chapter reference: 6.6.1.2" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#preConsumerShare", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#PreConsumerShareTrait" + }, + "samm:description": { + "@language": "en", + "@value": "Recycled material share from pre-consumer waste (manufacturing waste, excluding run-around scrap) of the active material." + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#ComponentList", + "samm:dataType": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#ComponentEntity" + }, + "samm:description": { + "@language": "en", + "@value": "List of components available at supplier" + }, + "@type": "samm-c:List" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#PreConsumerShareTrait", + "samm-c:constraint": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#ConsumerShareRange" + }, + "samm-c:baseCharacteristic": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#PreConsumerShare" + }, + "@type": "samm-c:Trait" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#SafetyMeasuresEntity", + "samm:properties": { + "@list": [ + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#safetyInstructions" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#extinguishingAgent" + } + ] + }, + "samm:description": { + "@language": "en", + "@value": "The safety measures should be provided via the instruction manual as URL linking to PDF." + }, + "@type": "samm:Entity" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#extinguishingAgent", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#ExtinguishingAgentsList" + }, + "samm:description": { + "@language": "en", + "@value": "Usable extinguishing agents refering to classes of extinguishers (A, B, C, D, K).EUBR: Annex XIII (1a) ? Annex VI Part A (9)" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#PartNumber", + "samm:dataType": { + "@id": "xsd:string" + }, + "@type": "samm-c:Code" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#RecycledMaterial", + "samm-c:values": { + "@list": [ + "Cobalt", + "Nickel", + "Lithium", + "Lead", + "Cobalt", + "Nickel", + "Lithium", + "Lead" + ] + }, + "samm:dataType": { + "@id": "xsd:string" + }, + "@type": "samm-c:Enumeration" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#streetAddress", + "samm:exampleValue": "Street 1", + "samm:characteristic": { + "@id": "samm-c:Text" + }, + "samm:see": { + "@id": "https://schema.org/streetAddress" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#ResourcePath", + "samm:dataType": { + "@id": "xsd:anyURI" + }, + "samm:description": { + "@language": "en", + "@value": "The path of a resource." + }, + "samm:preferredName": { + "@language": "en", + "@value": "Resource Path" + }, + "@type": "samm:Characteristic" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#nameOfSupplier", + "samm:characteristic": { + "@id": "samm-c:Text" + }, + "samm:description": { + "@language": "en", + "@value": "Name of Supplier" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#safetyInstructions", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#ResourcePath" + }, + "samm:description": { + "@language": "en", + "@value": "- Safety measures. - Necessary safety instructions to handle waste batteries, including in relation to the risks associated with, and the handling of, batteries containing lithium." + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#renewableContent", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#RenewableContent" + }, + "samm:description": { + "@language": "en", + "@value": "Share of renewable material content. A renewable material is a material made of natural resources that can be replenished. \n\nDIN DKE Spec 99100 chapter reference: 6.6.2.11" + }, + "samm:preferredName": { + "@language": "en", + "@value": "RenewableContent" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#recycledContent", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#RecycledContentList" + }, + "samm:description": { + "@language": "en", + "@value": "Share of material recovered from waste present in active materials for each battery model per year and per manufacturing plant.\n\nDIN DKE Spec 99100 chapter reference: 6.6.2.3 - 6.6.2.10" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#RecycledContentList", + "samm:dataType": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#RecycledContentEntity" + }, + "@type": "samm-c:List" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#addressOfSupplier", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#AddressOfSupplier" + }, + "samm:description": { + "@language": "en", + "@value": "Postal address of supplier for spare parts." + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#RenewableContent", + "samm-c:unit": { + "@id": "unit:percent" + }, + "samm:dataType": { + "@id": "xsd:float" + }, + "@type": "samm-c:Measurement" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#components", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#ComponentList" + }, + "samm:description": { + "@language": "en", + "@value": "Components available at supplier" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#recycledMaterial", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#RecycledMaterial" + }, + "samm:description": { + "@language": "en", + "@value": "Name of recycled material" + }, + "samm:preferredName": { + "@language": "en", + "@value": "RecycledMaterial" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#RecycledContentEntity", + "samm:properties": { + "@list": [ + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#preConsumerShare" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#recycledMaterial" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#postConsumerShare" + } + ] + }, + "samm:description": { + "@language": "en", + "@value": "A battery passport must include recycled content information.\n\nThe content information must include the percentage share of nickel that is present in active materials and that has been recovered from battery manufacturing waste, for each battery model per year and per manufacturing plant." + }, + "@type": "samm:Entity" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#PostConsumerShare", + "samm-c:unit": { + "@id": "unit:percent" + }, + "samm:dataType": { + "@id": "xsd:float" + }, + "@type": "samm-c:Measurement" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#emailAddressOfSupplier", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#EmailAddressOfSupplierTrait" + }, + "samm:description": { + "@language": "en", + "@value": "E-mail address of supplier for spare parts." + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#EmailAddressOfSupplierTrait", + "samm-c:constraint": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#ValidEmailAddress" + }, + "samm-c:baseCharacteristic": { + "@id": "samm-c:Text" + }, + "@type": "samm-c:Trait" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#separateCollection", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#ResourcePath" + }, + "samm:description": { + "@language": "en", + "@value": "Prevention and management of waste batteries: Point (b) of Article 60(1): Information on the role of end-users in contributing to the separate collection of waste batteries in accordance with their obligations under Article 51 so as to allow their treatment" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#postalCode", + "samm:exampleValue": "DE-10719", + "samm:characteristic": { + "@id": "samm-c:Text" + }, + "samm:see": { + "@id": "https://schema.org/postalCode" + }, + "samm:preferredName": { + "@language": "en", + "@value": "PostalCode" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#addressCountry", + "samm:exampleValue": "Germany", + "samm:characteristic": { + "@id": "samm-c:Text" + }, + "samm:see": { + "@id": "https://schema.org/addressCountry" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#DocumentationList", + "samm:dataType": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#DismantlingandRemovalDocumentation" + }, + "samm:description": { + "@language": "en", + "@value": "A collection of required documentation to support EoL actions" + }, + "@type": "samm-c:List" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#ExtinguishingAgentsList", + "samm:dataType": { + "@id": "xsd:string" + }, + "@type": "samm-c:List" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#Circularity", + "samm:events": { + "@list": [] + }, + "samm:operations": { + "@list": [] + }, + "samm:properties": { + "@list": [ + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#dismantlingAndRemovalInformation" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#sparePartSources" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#recycledContent" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#safetyMeasures" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#endOfLifeInformation" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#renewableContent" + } + ] + }, + "samm:description": { + "@language": "en", + "@value": "Dismantling information (including at least: exploded diagrams of the battery system/pack showing the location of battery cells; disassembly sequences; type and number of fastening techniques to be unlocked; tools required for disassembly; warnings if risk of damaging parts exists; amount of cells used and layout); part numbers for components and contact details of sources for replacement spares; safety measures (Annex XIII (2b-d)); usable extinguishing agent (Annex VI, Part A(9)). 2024 Circulor (for and on behalf of the Battery Pass Consortium). This work is licensed under a Creative Commons License Attribution-NonCommercial 4.0 International (CC BY-NC 4.0). Readers may reproduce material for their own publications, as long as it is not sold commercially and is given appropriate attribution." + }, + "@type": "samm:Aspect" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#sparePartSources", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#SparePartSourcesList" + }, + "samm:description": { + "@language": "en", + "@value": "Contact details of sources for replacement spares. Postal address, including name and brand names, postal code and place, street and number, country, telephone, if any. BR Annex XIII (2b)\n\nDIN DKE Spec 99100 chapter reference: 6.6.1.3" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#partNumber", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.Circularity:1.2.0#PartNumber" + }, + "samm:description": { + "@language": "en", + "@value": "Part Number of Component" + }, + "@type": "samm:Property" + } + ], + "@context": { + "samm-e": "urn:samm:org.eclipse.esmf.samm:entity:2.1.0#", + "unit": "urn:samm:org.eclipse.esmf.samm:unit:2.1.0#", + "samm-c": "urn:samm:org.eclipse.esmf.samm:characteristic:2.1.0#", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "samm": "urn:samm:org.eclipse.esmf.samm:meta-model:2.1.0#", + "@vocab": "urn:samm:io.BatteryPass.Circularity:1.2.0#" + } +} diff --git a/tests/fixtures/samples/batterypass_BatteryPassDataModel_GeneralProductInformation-ld.json b/tests/fixtures/samples/batterypass_BatteryPassDataModel_GeneralProductInformation-ld.json new file mode 100644 index 0000000..fb7dc6e --- /dev/null +++ b/tests/fixtures/samples/batterypass_BatteryPassDataModel_GeneralProductInformation-ld.json @@ -0,0 +1,497 @@ +{ + "@graph": [ + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#BatteryMassMeasurable", + "samm-c:unit": { + "@id": "unit:kilogram" + }, + "samm:dataType": { + "@id": "xsd:float" + }, + "samm:description": { + "@language": "en", + "@value": "Weight of the battery\nEUBR: Annex XIII (1a) ? Annex VI Part A (5)" + }, + "@type": "samm-c:Measurement" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#warrentyPeriod", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#WarrentyPeriod" + }, + "samm:description": { + "@language": "en", + "@value": "The battery passport must include information about the period for which the commercial warranty applies.\n\nDIN DKE Spec chapter reference: 6.1.3.4" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#ManufacturerInformation", + "samm:dataType": { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#ContactInformationEntity" + }, + "@type": "samm-c:SingleEntity" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#ContactInformationEntity", + "samm:properties": { + "@list": [ + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#contactName" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#postalAddress" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#identifier" + } + ] + }, + "@type": "samm:Entity" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#ProductPassportIdentifierTrait", + "samm-c:constraint": { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#CodeConstraint" + }, + "samm-c:baseCharacteristic": { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#ProductPassportIdentifierCode" + }, + "@type": "samm-c:Trait" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#CodeConstraint", + "samm:value": "^urn:[a-z0-9]+:[a-z0-9]+$", + "samm:description": { + "@language": "en", + "@value": "Code constraint for URN" + }, + "@type": "samm-c:RegularExpressionConstraint" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#ProductPassportIdentifierCode", + "samm:dataType": { + "@id": "xsd:string" + }, + "samm:description": { + "@language": "en", + "@value": "A unique identifier is defined as \"a unique string of characters for the identification of batteries that also enables a web link to the battery passport\" (Art. 3(66)), to be attributed by the economic operator placing the battery on the market (Art. 77(3)). The unique identifier shall comply with the standard (?ISO/IEC?) 15459:2015 or equivalent (Art. 77(3)). A QR code shall provide access to the battery passport and be linked to the unique identifier (Art. 77(3)). Batteries shall ?bear a model identification and batch or serial number, or product number or another element allowing their identification? (Art. 38(6)). \n\nBattery Regulation Reference: Art. 77(3); Art. 3(66); Art. 38(6)" + }, + "samm:preferredName": { + "@language": "en", + "@value": "ProductPassportIdentifierCode" + }, + "@type": "samm-c:Code" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#BatteryCategoryEnum", + "samm-c:values": { + "@list": [ + "lmt", + "ev", + "industrial", + "stationary" + ] + }, + "samm:dataType": { + "@id": "xsd:string" + }, + "@type": "samm-c:Enumeration" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#ProductIdentifierCode", + "samm:dataType": { + "@id": "xsd:string" + }, + "samm:description": { + "@language": "en", + "@value": "A unique identifier is defined as \"a unique string of characters for the identification of batteries that also enables a web link to the battery passport\" (Art. 3(66)), to be attributed by the economic operator placing the battery on the market (Art. 77(3)). The unique identifier shall comply with the standard (?ISO/IEC?) 15459:2015 or equivalent (Art. 77(3)). A QR code shall provide access to the battery passport and be linked to the unique identifier (Art. 77(3)). Batteries shall ?bear a model identification and batch or serial number, or product number or another element allowing their identification? (Art. 38(6)). \n\nBattery Regulation Reference: Art. 77(3); Art. 3(66); Art. 38(6)" + }, + "samm:preferredName": { + "@language": "en", + "@value": "ProductIdentifierCode" + }, + "@type": "samm-c:Code" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#addressCountry", + "samm:exampleValue": "Germany", + "samm:characteristic": { + "@id": "samm-c:Text" + }, + "samm:see": { + "@id": "https://schema.org/addressCountry" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#manufacturerInformation", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#ManufacturerInformation" + }, + "samm:description": { + "@language": "en", + "@value": "Unambiguous identification of the manufacturer of the battery, suggested via a unique operator identifier (as \"unique string of characters for the identification of actors involved in the value chain of products\", ESPR Art. 2(32)). \n\nDIN DKE Spec chapter reference: 6.1.2.4" + }, + "samm:preferredName": { + "@language": "en", + "@value": "ManufacturerIdentification" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#batteryPassportIdentifier", + "samm:exampleValue": "urn:bmwk:123456687678", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#ProductPassportIdentifierTrait" + }, + "samm:description": { + "@language": "en", + "@value": "Unique identifier allowing for the unambiguous identification of each individual battery and hence each corresponding battery passport (exploration of a potential additional battery passport identifier (not requried per Battery Regulation) ongoing)." + }, + "samm:preferredName": { + "@language": "en", + "@value": "BatteryPassportIdentifier" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#manufacturingPlace", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#ManufacturingPlace" + }, + "samm:see": { + "@id": "https://schema.org/PostalAddress" + }, + "samm:description": { + "@language": "en", + "@value": "Unambiguous identification of the manufacturing facility (e.g. country, city, street, building (if needed)), suggested via a unique facility identifier (as \"unique string of characters for the identification of locations or buildings involved in the value chain of a product or used by actors involved in the value chain of a product\", ESPR Art. 2(33)).\n\nDIN DKE Spec chapter reference: 6.1.3.1" + }, + "samm:preferredName": { + "@language": "en", + "@value": "ManufacturingPlace" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#ManufacturingPlace", + "samm:dataType": { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#PostalAddressEntity" + }, + "samm:see": { + "@id": "https://schema.org/PostalAddress" + }, + "@type": "samm-c:SingleEntity" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#PostalAddressEntity", + "samm:properties": { + "@list": [ + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#addressCountry" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#postalCode" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#streetAddress" + } + ] + }, + "samm:see": { + "@id": "https://schema.org/PostalAddress" + }, + "@type": "samm:Entity" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#productIdentifier", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#ProductIdentifierCode" + }, + "samm:description": { + "@language": "en", + "@value": "Unique identifier allowing for the unambiguous identification of each individual battery and hence each corresponding battery passport (exploration of a potential additional battery passport identifier (not requried per Battery Regulation) ongoing).\nDIN DKE Spec chapter reference: 6.1.2.2" + }, + "samm:preferredName": { + "@language": "en", + "@value": "ProductIdentifier" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#puttingIntoService", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#PuttingIntoServiceDateTime" + }, + "samm:description": { + "@language": "en", + "@value": "Where appropriate, the battery passport must include information on the date of putting the battery into service. BR Annex VI Part A (1); Art. 3(33); Art. 38(7); ESPR Art. 2(32)\n\nDIN DKE Spec chapter reference: 6.1.3.3" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#PuttingIntoServiceDateTime", + "samm:dataType": { + "@id": "xsd:dateTime" + }, + "@type": "samm:Characteristic" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#operatorInformation", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#OperatorInformation" + }, + "samm:description": { + "@language": "en", + "@value": "State the name, trade name or mark, postal address, web ad-dress, e-mail address. Suggested reporting via a unique operator identifier (see requirements of unique battery identifier).\n\nDIN DKE Spec chapter reference: 6.1.2.3" + }, + "samm:preferredName": { + "@language": "en", + "@value": "OperatorInformation" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#batteryCategory", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#BatteryCategoryEnum" + }, + "samm:description": { + "@language": "en", + "@value": "Categories relevant for the battery passport: LMT battery, ?electric vehicle battery, stationary or other industrial battery >2kWh.\n\nDIN DKE Spec chapter reference: 6.1.3.5" + }, + "samm:preferredName": { + "@language": "en", + "@value": "BatteryCategory" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#identifier", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#Identifier" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#postalCode", + "samm:exampleValue": "10719", + "samm:characteristic": { + "@id": "samm-c:Text" + }, + "samm:see": { + "@id": "https://schema.org/postalCode" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#WarrentyPeriod", + "samm-c:unit": { + "@id": "unit:month" + }, + "samm:dataType": { + "@id": "xsd:gMonth" + }, + "@type": "samm-c:Measurement" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#batteryMass", + "samm:exampleValue": { + "@value": "699", + "@type": "xsd:float" + }, + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#BatteryMassMeasurable" + }, + "samm:description": { + "@language": "en", + "@value": "Mass of the entire battery in kilograms. Voluntary: if the battery is defined on pack or module level: also weight of the modules and/or cells.\n\nDIN DKE Spec chapter reference: 6.1.3.6" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#Identifier", + "samm:dataType": { + "@id": "xsd:string" + }, + "samm:description": { + "@language": "en", + "@value": "Not demanded by the EU Battery Regulation" + }, + "samm:preferredName": { + "@language": "en", + "@value": "EconomicOperatorCode" + }, + "@type": "samm-c:Code" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#BatteryStatusEnumeration", + "samm-c:values": { + "@list": [ + "Original", + "Repurposed", + "Reused", + "Remanufactured", + "Waste" + ] + }, + "samm:dataType": { + "@id": "xsd:string" + }, + "samm:description": { + "@language": "en", + "@value": "Lifecycle status of the battery. Status defined from a list, with the options suggested as follows: 'original', 'repurposed', 'reused', 'remanufactured', 'waste'\n\nEUBR: Annex XIII (4c)" + }, + "@type": "samm-c:Enumeration" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#postalAddress", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#PostalAddress" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#PostalAddress", + "samm:dataType": { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#PostalAddressEntity" + }, + "@type": "samm-c:SingleEntity" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#GeneralProductInformation", + "samm:events": { + "@list": [] + }, + "samm:operations": { + "@list": [] + }, + "samm:properties": { + "@list": [ + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#productIdentifier" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#batteryPassportIdentifier" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#batteryCategory" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#manufacturerInformation" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#manufacturingDate" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#batteryStatus" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#batteryMass" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#manufacturingPlace" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#operatorInformation" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#puttingIntoService" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#warrentyPeriod" + } + ] + }, + "samm:description": { + "@language": "en", + "@value": "Mandatory data: Product identification; manufacturer?s identification; manufacturing place; manufacturing date; battery category; battery weight; battery status (Annex VI, Part A and Annex XIII)\nCopyright ? 2023 Circulor (for and on behalf of the Battery Pass Consortium). This work is li-censed under a Creative Commons License Attribution-NonCommercial 4.0 International (CC BY-NC 4.0). Readers may reproduce material for their own publications, as long as it is not sold com-mercially and is given appropriate attribution." + }, + "@type": "samm:Aspect" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#contactName", + "samm:characteristic": { + "@id": "samm-c:Text" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#ManufacturingDateTimeStamp", + "samm:dataType": { + "@id": "xsd:dateTimeStamp" + }, + "samm:description": { + "@language": "en", + "@value": "Manufacturing date (month and year)\nRegulation Reference: Annex XIII (1a) ? Annex VI Part A (4); Annex VII Part B (1)" + }, + "samm:preferredName": { + "@language": "en", + "@value": "ManufacturingDate" + }, + "@type": "samm:Characteristic" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#streetAddress", + "samm:exampleValue": "Hindenburgstr. 10", + "samm:characteristic": { + "@id": "samm-c:Text" + }, + "samm:see": { + "@id": "https://schema.org/streetAddress" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#batteryStatus", + "samm:exampleValue": "Original", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#BatteryStatusEnumeration" + }, + "samm:description": { + "@language": "en", + "@value": "Lifecycle status of the battery. Status defined from a list, with the options suggested as follows: 'original', 'repurposed', 'reused', 'remanufactured', 'waste'.\n\nDIN DKE Spec chapter reference: 6.1.3.7" + }, + "samm:preferredName": { + "@language": "en", + "@value": "BatteryStatus" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#OperatorInformation", + "samm:dataType": { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#ContactInformationEntity" + }, + "@type": "samm-c:SingleEntity" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#manufacturingDate", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#ManufacturingDateTimeStamp" + }, + "samm:description": { + "@language": "en", + "@value": "The manufacturing date should not only relate to the battery model, but to the battery item.\n\nThe date code should comply with DIN ISO 8601 1:2020 12 and ISO 8601 2:2019.\n\nDIN DKE Spec chapter reference: 6.1.3.2" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#Characteristic4", + "samm:dataType": { + "@id": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#ContactInformationEntity" + }, + "@type": "samm:Characteristic" + } + ], + "@context": { + "samm-e": "urn:samm:org.eclipse.esmf.samm:entity:2.1.0#", + "unit": "urn:samm:org.eclipse.esmf.samm:unit:2.1.0#", + "samm-c": "urn:samm:org.eclipse.esmf.samm:characteristic:2.1.0#", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "samm": "urn:samm:org.eclipse.esmf.samm:meta-model:2.1.0#", + "@vocab": "urn:samm:io.BatteryPass.GeneralProductInformation:1.0.0#" + } +} diff --git a/tests/fixtures/samples/batterypass_BatteryPassDataModel_MaterialComposition-ld.json b/tests/fixtures/samples/batterypass_BatteryPassDataModel_MaterialComposition-ld.json new file mode 100644 index 0000000..5209dad --- /dev/null +++ b/tests/fixtures/samples/batterypass_BatteryPassDataModel_MaterialComposition-ld.json @@ -0,0 +1,536 @@ +{ + "@graph": [ + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#isCriticalRawMaterial", + "samm:exampleValue": { + "@value": "true", + "@type": "xsd:boolean" + }, + "samm:characteristic": { + "@id": "samm-c:Boolean" + }, + "samm:description": { + "@language": "en", + "@value": "The battery passport must contain information on the critical raw materials present in the battery.\n\nThe information on the critical raw materials must also be provided on the battery label.\nPer Annex VI, Part A(10), critical raw materials must be reported if present in the battery in a concentration of more than 0,1 % weight by weight. " + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#clearName", + "samm:exampleValue": "Lithium nickel manganese cobalt oxides", + "samm:characteristic": { + "@id": "samm-c:Text" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#hazardousSubstanceLocation", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#HubstanceSubstanceLocationEntity" + }, + "samm:description": { + "@language": "en", + "@value": "Hazardous substances (No. 19-23): Location on a (sub-)component-level of all hazardous substances (as ?any substance that poses a threat to human health and the environment?). Suggested via a unique identifier or nomenclature." + }, + "samm:preferredName": { + "@language": "en", + "@value": "Location" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#HubstanceSubstanceLocationEntity", + "samm:dataType": { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#BatteryLocationEntity" + }, + "samm:description": { + "@language": "en", + "@value": "\"The impact of substances, in particular, hazardous substances, contained in batteries on the environment and on human health or safety of persons, including impact due to inappropriate discarding of waste batteries such as littering or discarding as unsorted municipal waste?." + }, + "samm:preferredName": { + "@language": "en", + "@value": "Location" + }, + "@type": "samm-c:SingleEntity" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#HazardousSubstanceClassChrateristicEnum", + "samm-c:values": { + "@list": [ + "AcuteToxicity", + "SkinCorrosionOrIrritation", + "EyeDamageOrIrritation" + ] + }, + "samm:dataType": { + "@id": "xsd:string" + }, + "@type": "samm-c:Enumeration" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#MaterialIdentifierTrait", + "samm-c:constraint": { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#CASNumberConstraint" + }, + "samm-c:baseCharacteristic": { + "@id": "samm-c:Text" + }, + "@type": "samm-c:Trait" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#CASNumberConstraint", + "samm:value": "^\\d{2,7}-\\d{2}-\\d{1}$", + "@type": "samm-c:RegularExpressionConstraint" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#batteryMaterials", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#BatteryMaterialList" + }, + "samm:description": { + "@language": "en", + "@value": "\"Component materials used\" (No. 17.a-c): Naming the materials (as a composition of substances) in cathode, anode, electrolyte according to public standards, including specification of the corresponding component (i.e., cathode, anode, or electrolyte). We suggest a reporting threshold of 0.1 % weight by weight.\n\nDIN DKE Spec 99100 chapter reference: 6.5.3-6.5.4" + }, + "samm:preferredName": { + "@language": "en", + "@value": "BatteryMaterials" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#ImpactList", + "samm-c:elementCharacteristic": { + "@id": "samm-c:Text" + }, + "samm:dataType": { + "@id": "xsd:string" + }, + "@type": "samm-c:List" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#componentName", + "samm:exampleValue": "Anode", + "samm:characteristic": { + "@id": "samm-c:Text" + }, + "samm:preferredName": { + "@language": "en", + "@value": "Name" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#hazardousSubstanceIdentifier", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#MaterialIdentifierTrait" + }, + "samm:see": { + "@id": "https://www.cas.org/cas-data/cas-registry" + }, + "samm:description": { + "@language": "en", + "@value": "CAS identifier of hazardous substance" + }, + "samm:preferredName": { + "@language": "en", + "@value": "Identifier" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#batteryMaterialIdentifier", + "samm:exampleValue": "7439-93-2 ", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#MaterialIdentifierTrait" + }, + "samm:description": { + "@language": "en", + "@value": "CAS Number " + }, + "samm:preferredName": { + "@language": "en", + "@value": "Identifier" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#BatteryMaterialList", + "samm:dataType": { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#BatteryMaterialEntity" + }, + "samm:description": { + "@language": "en", + "@value": "Detailed composition, including materials used in the cathode, anode, and electrolyte\n\nEUBR: Annex XIII (2a)" + }, + "samm:preferredName": { + "@language": "en", + "@value": "Battery Material List" + }, + "@type": "samm-c:List" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#hazardousSubstanceConcentration", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#HazardousSubstanceConcentrationCharacteristic" + }, + "samm:description": { + "@language": "en", + "@value": "Concentration of hazardous substance" + }, + "samm:preferredName": { + "@language": "en", + "@value": "Concentration" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#HazardousSubstanceConcentrationCharacteristic", + "samm-c:unit": { + "@id": "unit:percent" + }, + "samm:dataType": { + "@id": "xsd:double" + }, + "@type": "samm-c:Measurement" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#hazardousSubstanceImpact", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#ImpactList" + }, + "samm:description": { + "@language": "en", + "@value": "Impact statements based on, e.g., REACH or GHS for all hazard classes applicable to substances in the battery." + }, + "samm:preferredName": { + "@language": "en", + "@value": "Impact" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#hazardousSubstanceName", + "samm:characteristic": { + "@id": "samm-c:Text" + }, + "samm:description": { + "@language": "en", + "@value": "Clear name of hazardous substance" + }, + "samm:preferredName": { + "@language": "en", + "@value": "Name" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#BatteryLocationEntity", + "samm:properties": { + "@list": [ + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#componentName" + }, + { + "@id": "_:b11" + } + ] + }, + "samm:description": { + "@language": "en", + "@value": "Battery component that includes the material" + }, + "samm:preferredName": { + "@language": "en", + "@value": "BatteryLocation " + }, + "@type": "samm:Entity" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#shortName", + "samm:exampleValue": "NMC", + "samm:characteristic": { + "@id": "samm-c:Text" + }, + "@type": "samm:Property" + }, + { + "@id": "_:b11", + "samm:optional": { + "@value": "true", + "@type": "xsd:boolean" + }, + "samm:property": { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#componentId" + } + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#Weight", + "samm-c:unit": { + "@id": "unit:gram" + }, + "samm:dataType": { + "@id": "xsd:float" + }, + "@type": "samm-c:Measurement" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#BatteryMaterialEntity", + "samm:properties": { + "@list": [ + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#batteryMaterialLocation" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#batteryMaterialIdentifier" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#batteryMaterialName" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#batteryMaterialMass" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#isCriticalRawMaterial" + } + ] + }, + "samm:preferredName": { + "@language": "en", + "@value": "Material" + }, + "@type": "samm:Entity" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#batteryMaterialLocation", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#BatteryMaterialLocation" + }, + "samm:description": { + "@language": "en", + "@value": "Battery component that relates to the material" + }, + "samm:preferredName": { + "@language": "en", + "@value": "Location" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#hazardousSubstanceClass", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#HazardousSubstanceClassChrateristicEnum" + }, + "samm:description": { + "@language": "en", + "@value": "Battery Regulation narrows reporting to substances falling under defined hazard classes and categories of the CLP regulation." + }, + "samm:preferredName": { + "@language": "en", + "@value": "Class" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#batteryMaterialMass", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#Weight" + }, + "samm:description": { + "@language": "en", + "@value": "Weight of component material" + }, + "samm:preferredName": { + "@language": "en", + "@value": "Weight" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#batteryChemistry", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#BatteryChemistryEntityList" + }, + "samm:description": { + "@language": "en", + "@value": "Composition of a product in general terms by specifying the cathode and anode active material as well as electrolyte.\n\nDIN DKE Spec 99100 chapter reference: 6.5.2" + }, + "samm:preferredName": { + "@language": "en", + "@value": "ProductChemistry" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#BatteryChemistryEntityList", + "samm:dataType": { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#BatteryChemistryEntity" + }, + "samm:description": { + "@language": "en", + "@value": "Detailed composition, including materials used in the cathode, anode, and electrolyte.\nAll common cells have two electrodes and an electrolyte. The specific combination of materials used to make these components is called \"chemistry.\" A cell's chemistry largely determines its properties, while most variations within it are caused by additives, purification, and design elements.\n\nEUBR: Annex XIII (1b) ? Annex VI Part A (7)" + }, + "samm:preferredName": { + "@language": "en", + "@value": "BatteryChemistryEntity" + }, + "@type": "samm-c:SingleEntity" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#BatteryMaterialLocation", + "samm:dataType": { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#BatteryLocationEntity" + }, + "samm:preferredName": { + "@language": "en", + "@value": "Location" + }, + "@type": "samm-c:SingleEntity" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#componentId", + "samm:characteristic": { + "@id": "samm-c:Text" + }, + "samm:preferredName": { + "@language": "en", + "@value": "SubstanceId" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#hazardousSubstances", + "samm:characteristic": { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#HazardousSubstancesList" + }, + "samm:description": { + "@language": "en", + "@value": "\"Hazardous substances\" (No 20.a-e): Name (agreed substance nomenclature, e.g. IUPAC or chemical name) all hazardous substance (as ?any substance that poses a threat to human health and the environment?). Suggested above 0.1 % weight by weight within each (sub-)component.\n\nDIN DKE Spec 99100 chapter reference: 6.5.4 - 6.5.6" + }, + "samm:preferredName": { + "@language": "en", + "@value": "HazardousSubstances" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#HazardousSubstancesList", + "samm:dataType": { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#HazardousSubstanceEntity" + }, + "samm:description": { + "@language": "en", + "@value": "Hazardous substances contained in the battery other than mercury, cadmium or lead. Substance as a chemical element and its compounds in the natural state or the result of a manufacturing process (ECHA). Battery Regulation narrows reporting to substances falling under defined hazard classes and categories of the CLP regulation.\n\nEUBR: Annex XIII (1b) ? Annex VI Part A (8)" + }, + "samm:preferredName": { + "@language": "en", + "@value": "HazardousSubstances" + }, + "@type": "samm-c:List" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#BatteryChemistryEntity", + "samm:properties": { + "@list": [ + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#shortName" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#clearName" + } + ] + }, + "samm:preferredName": { + "@language": "en", + "@value": "Chemistry" + }, + "@type": "samm:Entity" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#batteryMaterialName", + "samm:exampleValue": "Lithium", + "samm:characteristic": { + "@id": "samm-c:Text" + }, + "samm:description": { + "@language": "en", + "@value": "Clear name of Material" + }, + "samm:preferredName": { + "@language": "en", + "@value": "Name" + }, + "@type": "samm:Property" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#MaterialComposition", + "samm:events": { + "@list": [] + }, + "samm:operations": { + "@list": [] + }, + "samm:properties": { + "@list": [ + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#batteryChemistry" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#batteryMaterials" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#hazardousSubstances" + } + ] + }, + "samm:description": { + "@language": "en", + "@value": "Mandatory data: Battery chemistry; critical raw materials; materials used in the cathode, anode, and \nelectrolyte; hazardous substances; impact of substances on the environment and on human health or \nsafety\n\nCopyright ? 2024 Circulor (for and on behalf of the Battery Pass Consortium). This work is li-censed under a Creative Commons License Attribution-NonCommercial 4.0 International (CC BY-NC 4.0). Readers may reproduce material for their own publications, as long as it is not sold com-mercially and is given appropriate attribution." + }, + "samm:preferredName": { + "@language": "en", + "@value": "MaterialComposition" + }, + "@type": "samm:Aspect" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#HazardousSubstanceEntity", + "samm:properties": { + "@list": [ + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#hazardousSubstanceClass" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#hazardousSubstanceName" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#hazardousSubstanceConcentration" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#hazardousSubstanceImpact" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#hazardousSubstanceLocation" + }, + { + "@id": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#hazardousSubstanceIdentifier" + } + ] + }, + "samm:description": { + "@language": "en", + "@value": "Hazardous substances (No. 19-23): Name (agreed substance nomenclature, e.g. IUPAC or chemical name) all hazardous substance (as ?any substance that poses a threat to human health and the environment?). Suggested above 0.1 % weight by weight within each (sub-)component." + }, + "@type": "samm:Entity" + } + ], + "@context": { + "samm-e": "urn:samm:org.eclipse.esmf.samm:entity:2.1.0#", + "unit": "urn:samm:org.eclipse.esmf.samm:unit:2.1.0#", + "samm-c": "urn:samm:org.eclipse.esmf.samm:characteristic:2.1.0#", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "samm": "urn:samm:org.eclipse.esmf.samm:meta-model:2.1.0#", + "@vocab": "urn:samm:io.BatteryPass.MaterialComposition:1.2.0#" + } +} diff --git a/tests/fixtures/samples/eclipse-tractusx_sldt-semantic-models_BatteryPass.json b/tests/fixtures/samples/eclipse-tractusx_sldt-semantic-models_BatteryPass.json new file mode 100644 index 0000000..10a875b --- /dev/null +++ b/tests/fixtures/samples/eclipse-tractusx_sldt-semantic-models_BatteryPass.json @@ -0,0 +1,643 @@ +{ + "characteristics": { + "physicalDimension": { + "length": { + "value": 20.0, + "unit": "unit:millimetre" + }, + "width": { + "value": 20.0, + "unit": "unit:millimetre" + }, + "weight": { + "value": 20.0, + "unit": "unit:gram" + }, + "height": { + "value": 20.0, + "unit": "unit:millimetre" + } + }, + "warranty": { + "lifeValue": 36, + "lifeUnit": "unit:day" + } + }, + "metadata": { + "backupReference": "https://dummy.link", + "registrationIdentifier": "https://dummy.link/ID8283746239078", + "economicOperatorId": "BPNL0123456789ZZ", + "lastModification": "2000-01-01", + "predecessor": "urn:uuid:00000000-0000-0000-0000-000000000000", + "issueDate": "2000-01-01", + "version": "1.0.0", + "passportIdentifier": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "status": "draft", + "expirationDate": "2030-01-01" + }, + "commercial": { + "placedOnMarket": "2000-01-01", + "purpose": [ + "automotive" + ] + }, + "identification": { + "chemistry": "Nickel Cobalt Manganese (NCM)", + "idDmc": "34567890", + "identification": { + "batch": [ + { + "value": "BID12345678", + "key": "batchId" + } + ], + "codes": [ + { + "value": "8703 24 10 00", + "key": "TARIC" + } + ], + "type": { + "manufacturerPartId": "123-0.740-3434-A", + "nameAtManufacturer": "Mirror left" + }, + "classification": [ + { + "classificationStandard": "GIN 20510-21513", + "classificationID": "1004712", + "classificationDescription": "Generic standard for classification of parts in the automotive industry." + } + ], + "serial": [ + { + "value": "SN12345678", + "key": "partInstanceId" + } + ], + "dataCarrier": { + "carrierType": "QR", + "carrierLayout": "upper-left side" + } + }, + "category": "SLI" + }, + "performance": { + "rated": { + "roundTripEfficiency": { + "depthOfDischarge": 90.5, + "temperature": 20.0, + "50PercentLife": 89.0, + "initial": 96.0 + }, + "selfDischargingRate": 0.25, + "performanceDocument": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "testReport": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "temperature": { + "lower": -18.0, + "upper": 60.0 + }, + "lifetime": { + "report": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "cycleLifeTesting": { + "temperature": 20.0, + "depthOfDischarge": 90.5, + "appliedDischargeRate": 4.0, + "cycles": 1500, + "appliedChargeRate": 3.0 + }, + "expectedYears": 8 + }, + "power": { + "at20SoC": 35000.0, + "temperature": 20.0, + "value": 40000.0, + "at80SoC": 39000.0 + }, + "resistance": { + "temperature": 20.0, + "cell": 0.025, + "pack": 0.55, + "module": 0.2 + }, + "voltage": { + "temperature": 20.0, + "min": 2.5, + "nominal": 3.7, + "max": 4.2 + }, + "energy": { + "temperature": 20.0, + "value": 0.5 + }, + "capacity": { + "temperature": 20.0, + "value": 4.0, + "thresholdExhaustion": 80.0 + } + }, + "dynamic": { + "selfDischargingRate": 0.25, + "roundTripEfficiency": { + "remaining": { + "value": 50.0, + "time": "2023-12-07T10:39:13.576+01:00" + }, + "fade": { + "value": 50.0, + "time": "2023-12-07T10:39:13.576+01:00" + } + }, + "operatingEnvironment": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "stateOfCharge": { + "value": 50.0, + "time": "2023-12-07T10:39:13.576+01:00" + }, + "performanceDocument": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "fullCycles": { + "value": 1500, + "time": "2023-12-07T10:39:13.576+01:00" + }, + "power": { + "remaining": { + "value": 40000.0, + "time": "2023-12-07T10:39:13.576+01:00" + }, + "fade": { + "value": 50.0, + "time": "2023-12-07T10:39:13.576+01:00" + } + }, + "negativeEvents": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "resistance": { + "increase": { + "cell": { + "value": 50.0, + "time": "2023-12-07T10:39:13.576+01:00" + }, + "pack": { + "value": 50.0, + "time": "2023-12-07T10:39:13.576+01:00" + }, + "module": { + "value": 50.0, + "time": "2023-12-07T10:39:13.576+01:00" + } + }, + "remaining": { + "cell": { + "value": 0.3, + "time": "2023-12-07T10:39:13.576+01:00" + }, + "pack": { + "value": 0.3, + "time": "2023-12-07T10:39:13.576+01:00" + }, + "module": { + "value": 0.3, + "time": "2023-12-07T10:39:13.576+01:00" + } + } + }, + "capacity": { + "fade": { + "value": 50.0, + "time": "2023-12-07T10:39:13.576+01:00" + }, + "throughput": { + "value": 4.0, + "time": "2023-12-07T10:39:13.576+01:00" + }, + "capacity": { + "value": 4.0, + "time": "2023-12-07T10:39:13.576+01:00" + } + }, + "energy": { + "remaining": { + "value": 0.5, + "time": "2023-12-07T10:39:13.576+01:00" + }, + "soce": { + "value": 50.0, + "time": "2023-12-07T10:39:13.576+01:00" + }, + "throughput": { + "value": 0.5, + "time": "2023-12-07T10:39:13.576+01:00" + } + } + } + }, + "sources": [ + { + "header": "Example Document XYZ", + "category": "Product Specifications", + "type": "URL", + "content": "https://dummy.link" + } + ], + "materials": { + "hazardous": { + "cadmium": { + "concentration": 5.3, + "location": "Housing", + "critical": true, + "impactOfSubstances": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "materialUnit": "unit:partPerMillion", + "documentation": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ] + }, + "other": [ + { + "critical": true, + "impactOfSubstances": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "documentation": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "concentration": 5.3, + "materialIdentification": [ + { + "type": "CAS", + "name": "phenolphthalein", + "id": "201-004-7" + } + ], + "location": "Housing", + "materialUnit": "unit:partPerMillion" + } + ], + "mercury": { + "concentration": 5.3, + "location": "Housing", + "critical": true, + "impactOfSubstances": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "materialUnit": "unit:partPerMillion", + "documentation": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ] + }, + "lead": { + "recycled": 12.5, + "critical": true, + "impactOfSubstances": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "documentation": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "location": "Housing", + "concentration": 5.3, + "materialUnit": "unit:partPerMillion" + } + }, + "active": { + "nickel": { + "location": "Housing", + "recycled": 12.5, + "critical": true, + "documentation": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ] + }, + "lithium": { + "location": "Housing", + "recycled": 12.5, + "critical": true, + "documentation": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ] + }, + "cobalt": { + "location": "Housing", + "recycled": 12.5, + "critical": true, + "documentation": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ] + }, + "other": [ + { + "location": "Housing", + "materialIdentification": [ + { + "type": "CAS", + "name": "phenolphthalein", + "id": "201-004-7" + } + ], + "recycled": 12.5, + "critical": true, + "documentation": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ] + } + ], + "lead": { + "recycled": 12.5, + "critical": true, + "impactOfSubstances": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "documentation": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "location": "Housing", + "concentration": 5.3, + "materialUnit": "unit:partPerMillion" + } + }, + "composition": [ + { + "unit": "unit:partPerMillion", + "recycled": 12.5, + "critical": true, + "renewable": 23.5, + "documentation": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "concentration": 5.3, + "location": "Housing", + "id": [ + { + "type": "CAS", + "name": "phenolphthalein", + "id": "201-004-7" + } + ] + } + ] + }, + "safety": { + "usableExtinguishAgent": [ + { + "fireClass": "A, B", + "document": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "media": "Dry Powder" + } + ], + "safeDischarging": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "meaningOfLabels": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "dismantling": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "removalFromAppliance": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "safetyMeasures": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ] + }, + "handling": { + "applicable": true, + "content": { + "producer": [ + { + "id": "BPNL0123456789ZZ" + } + ], + "sparePart": [ + { + "manufacturerPartId": "123-0.740-3434-A", + "nameAtManufacturer": "Mirror left" + } + ] + } + }, + "conformity": { + "declarationOfConformityId": "0978234-34567890-01", + "thirdPartyAssurance": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "resultOfTestReport": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "declarationOfConformity": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "dueDiligencePolicy": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ] + }, + "operation": { + "intoServiceDate": "9494-37-15", + "manufacturer": { + "facility": [ + { + "facility": "BPNA1234567890AA" + } + ], + "manufacturingDate": "2000-01-31", + "manufacturer": "BPNLG7OCVQYDXMzJ" + } + }, + "sustainability": { + "documents": { + "separateCollection": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "sustainabilityReport": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "euTaxonomyDisclosureStatement": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "wastePrevention": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ] + }, + "carbonFootprint": [ + { + "lifecycle": "main product production", + "rulebook": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ], + "unit": "kg CO2 / kWh", + "performanceClass": "A", + "manufacturingPlant": [ + { + "facility": "BPNA1234567890AA" + } + ], + "type": "Climate Change Total", + "value": 12.678, + "declaration": [ + { + "contentType": "URL", + "header": "Example Document XYZ", + "content": "https://dummy.link" + } + ] + } + ], + "status": "original" + } +} diff --git a/tests/fixtures/samples/nfc-forum_org_long-dpp-example.json b/tests/fixtures/samples/nfc-forum_org_long-dpp-example.json new file mode 100644 index 0000000..3a5e447 --- /dev/null +++ b/tests/fixtures/samples/nfc-forum_org_long-dpp-example.json @@ -0,0 +1,43 @@ +{ + "productID": "12345-67890", + "productName": "Eco-Friendly Water Bottle", + "manufacturer": { + "name": "Green Products Ltd.", + "address": "123 Green Way, Sustainability City, EC1 2AB, Country" + }, + "productionDate": "2023-05-20", + "expiryDate": "2026-05-20", + "materials": [ + { + "materialID": "M-001", + "materialName": "Recycled PET", + "percentage": 80 + }, + { + "materialID": "M-002", + "materialName": "Stainless Steel", + "percentage": 20 + } + ], + "environmentalImpact": { + "carbonFootprint": "2.5 kg CO2", + "waterUsage": "10 liters", + "recyclability": "95%" + }, + "compliance": [ + { + "standard": "ISO 14001", + "certificationDate": "2023-06-15" + }, + { + "standard": "ISO 45001", + "certificationDate": "2023-07-01" + } + ], + "endOfLifeInstructions": { + "recycling": "Place in plastic recycling bin", + "disposal": "Dispose of at designated recycling center", + "reuse": "Refill and reuse as water bottle" + }, + "digitalPassportLink": "https/nfc-forum.org/ndpp/12345-67890.json" +} diff --git a/tests/fixtures/samples/opensource_unicc_org_untp-digital-facility-record-v0.3.9.json b/tests/fixtures/samples/opensource_unicc_org_untp-digital-facility-record-v0.3.9.json new file mode 100644 index 0000000..2b47756 --- /dev/null +++ b/tests/fixtures/samples/opensource_unicc_org_untp-digital-facility-record-v0.3.9.json @@ -0,0 +1,462 @@ +{ + "type": [ + "DigitalFacilityRecord", + "VerifiableCredential" + ], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/dfr/0.3.9" + ], + "id": "https://example-company.com/credentials/2a423366-a0d6-4855-ba65-2e0c926d09b0", + "issuer": { + "type": [ + "CredentialIssuer" + ], + "id": "did:web:identifiers.example-company.com:12345", + "name": "Example Company Pty Ltd", + "otherIdentifiers": [ + { + "type": [ + "Entity" + ], + "id": "https://id.gs1.org/01/09520123456788/21/12345", + "name": "EV battery 300Ah.", + "registeredId": "90664869327", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + } + }, + { + "type": [ + "Entity" + ], + "id": "https://id.gs1.org/01/09520123456788/21/12345", + "name": "EV battery 300Ah.", + "registeredId": "90664869327", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + } + } + ] + }, + "validFrom": 2024, + "validUntil": 2034, + "credentialSubject": { + "type": [ + "Facility", + "Entity" + ], + "id": "https://id.gs1.org/gln/0614141123452", + "registeredId": "614141123452", + "description": "LiFePO4 Battery plant number 7", + "name": "Example facility 7", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + }, + "countryOfOperation": "AU", + "processCategories": [ + { + "type": [ + "Classification" + ], + "id": "https://unstats.un.org/unsd/classifications/Econ/cpc/46410", + "code": "46410", + "name": "Primary cells and primary batteries", + "schemeID": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + { + "type": [ + "Classification" + ], + "id": "https://unstats.un.org/unsd/classifications/Econ/cpc/46410", + "code": "46410", + "name": "Primary cells and primary batteries", + "schemeID": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + } + ], + "operatedByParty": { + "type": [ + "Entity" + ], + "id": "https://id.gs1.org/01/09520123456788/21/12345", + "name": "EV battery 300Ah.", + "registeredId": "90664869327", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + } + }, + "otherIdentifiers": [ + { + "type": [ + "Entity" + ], + "id": "https://id.gs1.org/01/09520123456788/21/12345", + "name": "EV battery 300Ah.", + "registeredId": "90664869327", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + } + }, + { + "type": [ + "Entity" + ], + "id": "https://id.gs1.org/01/09520123456788/21/12345", + "name": "EV battery 300Ah.", + "registeredId": "90664869327", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + } + } + ], + "address": { + "streetAddress": "level 11, 15 London Circuit", + "postalCode": "2601", + "addressLocality": "Acton", + "addressRegion": "ACT", + "addressCountry": "AU" + }, + "locationInformation": { + "geoLocation": { + "type": "Point", + "coordinates": { + "data": [ + 3.141579, + 3.141579 + ] + } + }, + "geoBoundary": { + "type": "Polygon", + "coordinates": [ + { + "data": [ + { + "data": [ + 3.141579, + 3.141579 + ] + }, + { + "data": [ + 3.141579, + 3.141579 + ] + } + ] + }, + { + "data": [ + { + "data": [ + 3.141579, + 3.141579 + ] + }, + { + "data": [ + 3.141579, + 3.141579 + ] + } + ] + } + ] + } + }, + "conformityDeclarations": [ + { + "type": [ + "Declaration" + ], + "id": "https://jargon.sh", + "referenceStandard": { + "type": [ + "Standard" + ], + "id": "https://www.globalbattery.org/media/publications/gba-rulebook-v2.0-master.pdf", + "name": "GBA Battery Passport Greenhouse Gas Rulebook - V.2.0", + "issuingParty": { + "type": [ + "Entity" + ], + "id": "https://id.gs1.org/01/09520123456788/21/12345", + "name": "EV battery 300Ah.", + "registeredId": "90664869327", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + } + }, + "issueDate": 2023 + }, + "referenceRegulation": { + "type": [ + "Regulation" + ], + "id": "https://www.legislation.gov.au/F2008L02309/latest/versions", + "name": "NNational Greenhouse and Energy Reporting (Measurement) Determination", + "jurisdictionCountry": "Enumeration Value", + "administeredBy": { + "type": [ + "Entity" + ], + "id": "https://id.gs1.org/01/09520123456788/21/12345", + "name": "EV battery 300Ah.", + "registeredId": "90664869327", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + } + }, + "effectiveDate": 2024 + }, + "assessmentCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://www.globalbattery.org/media/publications/gba-rulebook-v2.0-master.pdf#BatteryAssembly", + "name": "GBA Battery rule book v2.0 battery assembly guidelines.", + "thresholdValues": [ + { + "metricName": "GHG emissions intensity", + "metricValue": { + "value": 10, + "unit": "KGM" + }, + "accuracy": 0.05 + }, + { + "metricName": "GHG emissions intensity", + "metricValue": { + "value": 10, + "unit": "KGM" + }, + "accuracy": 0.05 + } + ] + }, + { + "type": [ + "Criterion" + ], + "id": "https://www.globalbattery.org/media/publications/gba-rulebook-v2.0-master.pdf#BatteryAssembly", + "name": "GBA Battery rule book v2.0 battery assembly guidelines.", + "thresholdValues": [ + { + "metricName": "GHG emissions intensity", + "metricValue": { + "value": 10, + "unit": "KGM" + }, + "accuracy": 0.05 + }, + { + "metricName": "GHG emissions intensity", + "metricValue": { + "value": 10, + "unit": "KGM" + }, + "accuracy": 0.05 + } + ] + } + ], + "declaredValues": [ + { + "metricName": "GHG emissions intensity", + "metricValue": { + "value": 10, + "unit": "KGM" + }, + "accuracy": 0.05 + }, + { + "metricName": "GHG emissions intensity", + "metricValue": { + "value": 10, + "unit": "KGM" + }, + "accuracy": 0.05 + } + ], + "compliance": true, + "conformityTopic": "environment.energy", + "conformityEvidence": { + "linkURL": "https://files.example-certifier.com/1234567.json", + "linkName": "GBA rule book conformity certificate", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc", + "hashDigest": "6239119", + "hashMethod": "SHA-256", + "encryptionMethod": "AES" + } + }, + { + "type": [ + "Declaration" + ], + "id": "https://jargon.sh", + "referenceStandard": { + "type": [ + "Standard" + ], + "id": "https://www.globalbattery.org/media/publications/gba-rulebook-v2.0-master.pdf", + "name": "GBA Battery Passport Greenhouse Gas Rulebook - V.2.0", + "issuingParty": { + "type": [ + "Entity" + ], + "id": "https://id.gs1.org/01/09520123456788/21/12345", + "name": "EV battery 300Ah.", + "registeredId": "90664869327", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + } + }, + "issueDate": 2023 + }, + "referenceRegulation": { + "type": [ + "Regulation" + ], + "id": "https://www.legislation.gov.au/F2008L02309/latest/versions", + "name": "NNational Greenhouse and Energy Reporting (Measurement) Determination", + "jurisdictionCountry": "Enumeration Value", + "administeredBy": { + "type": [ + "Entity" + ], + "id": "https://id.gs1.org/01/09520123456788/21/12345", + "name": "EV battery 300Ah.", + "registeredId": "90664869327", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + } + }, + "effectiveDate": 2024 + }, + "assessmentCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://www.globalbattery.org/media/publications/gba-rulebook-v2.0-master.pdf#BatteryAssembly", + "name": "GBA Battery rule book v2.0 battery assembly guidelines.", + "thresholdValues": [ + { + "metricName": "GHG emissions intensity", + "metricValue": { + "value": 10, + "unit": "KGM" + }, + "accuracy": 0.05 + }, + { + "metricName": "GHG emissions intensity", + "metricValue": { + "value": 10, + "unit": "KGM" + }, + "accuracy": 0.05 + } + ] + }, + { + "type": [ + "Criterion" + ], + "id": "https://www.globalbattery.org/media/publications/gba-rulebook-v2.0-master.pdf#BatteryAssembly", + "name": "GBA Battery rule book v2.0 battery assembly guidelines.", + "thresholdValues": [ + { + "metricName": "GHG emissions intensity", + "metricValue": { + "value": 10, + "unit": "KGM" + }, + "accuracy": 0.05 + }, + { + "metricName": "GHG emissions intensity", + "metricValue": { + "value": 10, + "unit": "KGM" + }, + "accuracy": 0.05 + } + ] + } + ], + "declaredValues": [ + { + "metricName": "GHG emissions intensity", + "metricValue": { + "value": 10, + "unit": "KGM" + }, + "accuracy": 0.05 + }, + { + "metricName": "GHG emissions intensity", + "metricValue": { + "value": 10, + "unit": "KGM" + }, + "accuracy": 0.05 + } + ], + "compliance": true, + "conformityTopic": "environment.energy", + "conformityEvidence": { + "linkURL": "https://files.example-certifier.com/1234567.json", + "linkName": "GBA rule book conformity certificate", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc", + "hashDigest": "6239119", + "hashMethod": "SHA-256", + "encryptionMethod": "AES" + } + } + ] + } +} diff --git a/tests/fixtures/samples/opensource_unicc_org_untp-digital-product-passport-v0.3.10.json b/tests/fixtures/samples/opensource_unicc_org_untp-digital-product-passport-v0.3.10.json new file mode 100644 index 0000000..405df56 --- /dev/null +++ b/tests/fixtures/samples/opensource_unicc_org_untp-digital-product-passport-v0.3.10.json @@ -0,0 +1,389 @@ +{ + "type": [ + "DigitalProductPassport", + "VerifiableCredential" + ], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.3.10/" + ], + "id": "https://example-company.com/credentials/2a423366-a0d6-4855-ba65-2e0c926d09b0", + "issuer": { + "type": [ + "CredentialIssuer" + ], + "id": "did:web:identifiers.example-company.com:12345", + "name": "Example Company Pty Ltd", + "otherIdentifiers": [ + { + "type": [ + "Entity" + ], + "id": "https://business.gov.au/ABN/View?abn=1234567890", + "name": "Sample Company Pty Ltd", + "registeredId": "1234567890", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://business.gov.au/ABN/", + "name": "Australian Business Number" + } + } + ] + }, + "validFrom": "2024-03-15T12:00:00", + "validUntil": "2034-03-15T12:00:00", + "credentialSubject": { + "type": [ + "Product", + "Entity" + ], + "id": "https://id.gs1.org/01/09520123456788/21/12345", + "name": "EV battery 300Ah.", + "registeredId": "90664869327", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + }, + "serialNumber": "12345678", + "batchNumber": "6789", + "productImage": { + "linkURL": "https://files.example-company.com/123456789.jpg", + "linkName": "EV battery 300Ah", + "linkType": "https://www.gs1.org/voc/relatedImage" + }, + "description": "400Ah 24v LiFePO4 battery", + "productCategory": [ + { + "type": [ + "Classification" + ], + "id": "https://unstats.un.org/unsd/classifications/Econ/cpc/46410", + "code": "46410", + "name": "Primary cells and primary batteries", + "schemeID": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + } + ], + "furtherInformation": [ + { + "linkURL": "https://files.example-company.com/products/90664869327/index.html", + "linkName": "Product Information page", + "linkType": "https://www.gs1.org/voc/sustainabilityInfo" + } + ], + "producedByParty": { + "type": [ + "Entity" + ], + "id": "https://business.gov.au/ABN/View?abn=1234567890", + "name": "Sample Company Pty Ltd", + "registeredId": "1234567890", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://business.gov.au/ABN/", + "name": "Australian Business Number" + } + }, + "producedAtFacility": { + "type": [ + "Entity" + ], + "id": "https://maps.app.goo.gl/QBF7Xy4S9QjHJrzb7", + "name": "Sample EV battery manufacturing site", + "registeredId": "QBF7Xy4S9QjHJrzb7", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "hhttps://maps.app.goo.gl", + "name": "Google Pin" + } + }, + "dimensions": { + "weight": { + "value": 20, + "unit": "KGM" + }, + "length": { + "value": 1, + "unit": "MTR" + }, + "width": { + "value": 0.5, + "unit": "MTR" + }, + "height": { + "value": 0.3, + "unit": "MTR" + }, + "volume": { + "value": 0.15, + "unit": "MTQ" + } + }, + "productionDate": "2024-04-25", + "countryOfProduction": "AU", + "materialsProvenance": [ + { + "name": "Lithium Spodumene", + "originCountry": "AU", + "materialType": { + "type": [ + "Classification" + ], + "id": "https://unstats.un.org/unsd/classifications/Econ/cpc/14290", + "code": "46410", + "name": "Other non-ferrous metal ores and concentrates (other than uranium or thorium ores and concentrates)", + "schemeID": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.2, + "recycledAmount": 0.5, + "hazardous": true, + "materialSafetyInformation": { + "linkURL": "https://sampleLithiumCompany.com/msds/1234567.json", + "linkName": "Lithium safe handling instructions", + "linkType": "https://www.gs1.org/voc/safetyInfo" + } + }, + { + "name": "Copper Concentrate", + "originCountry": "CA", + "materialType": { + "type": [ + "Classification" + ], + "id": "https://unstats.un.org/unsd/classifications/Econ/cpc/14210", + "code": "14210", + "name": "Copper, ores and concentrates", + "schemeID": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.2, + "recycledAmount": 0.5, + "hazardous": false + } + ], + "conformityDeclarations": [ + { + "type": [ + "Declaration" + ], + "id": "https://files.example-company.com/declarations/90664869327/", + "referenceStandard": { + "type": [ + "Standard" + ], + "id": "https://www.globalbattery.org/media/publications/gba-rulebook-v2.0-master.pdf", + "name": "GBA Battery Passport Greenhouse Gas Rulebook - V.2.0", + "issuingParty": { + "type": [ + "Entity" + ], + "id": "https://kbopub.economie.fgov.be/kbopub/toonondernemingps.html?ondernemingsnummer=786222414", + "name": "Global Battery Alliance", + "registeredId": "786222414", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://kbopub.economie.fgov.be/", + "name": "Belgian business register" + } + }, + "issueDate": "2023-12-05" + }, + "referenceRegulation": { + "type": [ + "Regulation" + ], + "id": "https://www.legislation.gov.au/F2008L02309/latest/versions", + "name": "National Greenhouse and Energy Reporting (Measurement) Determination", + "jurisdictionCountry": "Enumeration Value", + "administeredBy": { + "type": [ + "Entity" + ], + "id": "https://abr.business.gov.au/ABN/View?abn=72321984210", + "name": "Clean Energy Regulator", + "registeredId": "72321984210", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://abr.business.gov.au/ABN/", + "name": "Australian Business Number" + } + }, + "effectiveDate": "2024-03-20" + }, + "assessmentCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://www.globalbattery.org/media/publications/gba-rulebook-v2.0-master.pdf#BatteryAssembly", + "name": "GBA Battery rule book v2.0 battery assembly guidelines.", + "thresholdValues": [ + { + "metricName": "Industry Average emissions intensity", + "metricValue": { + "value": 1.8, + "unit": "NIL" + } + } + ] + } + ], + "declaredValues": [ + { + "metricName": "GHG emissions intensity", + "metricValue": { + "value": 1.5, + "unit": "NIL" + }, + "accuracy": 0.05 + }, + { + "metricName": "GHG emissions footprint", + "metricValue": { + "value": 15, + "unit": "KGM" + }, + "accuracy": 0.05 + } + ], + "compliance": true, + "conformityTopic": "environment.emissions", + "conformityEvidence": { + "linkURL": "https://files.example-certifier.com/1234567.json", + "linkName": "GBA rule book conformity certificate", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc", + "hashDigest": "6239119", + "hashMethod": "SHA-256", + "encryptionMethod": "AES" + } + }, + { + "type": [ + "Declaration" + ], + "id": "https://files.example-company.com/declarations/906648677543/", + "referenceRegulation": { + "type": [ + "Regulation" + ], + "id": "https://www.legislation.gov.au/C2009A00028/2021-09-11/text", + "name": "Fair work act 2009", + "jurisdictionCountry": "AU", + "administeredBy": { + "type": [ + "Entity" + ], + "id": "https://abr.business.gov.au/ABN/View?abn=96584957427", + "name": "Department of Employment and Workplace Relations", + "registeredId": "96584957427", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://business.gov.au/ABN/", + "name": "Australian Business Number" + } + }, + "effectiveDate": "2024-03-20" + }, + "assessmentCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://www.legislation.gov.au/C2009A00028/2021-09-11/text", + "name": "National minimum wage orders", + "thresholdValues": [ + { + "metricName": "Minimum wage", + "metricValue": { + "value": 25, + "unit": "AUD" + } + } + ] + } + ], + "compliance": true, + "conformityTopic": "social.labour" + } + ], + "circularityScorecard": { + "recyclingInformation": { + "linkURL": "https://files.example-company.com/products/123456789/recycling.pdf", + "linkName": "Recycling instructions", + "linkType": "https://www.gs1.org/voc/recyclingAndRepairInfo" + }, + "repairInformation": { + "linkURL": "https://files.example-company.com/products/123456789/repair.pdf", + "linkName": "Repair instructions", + "linkType": "https://www.gs1.org/voc/recyclingAndRepairInfo" + }, + "recyclableContent": 0.5, + "recyecledContent": 0.3, + "utilityFactor": 1.2, + "materialCircularityIndicator": 0.67 + }, + "emissionsScorecard": { + "carbonFootprint": 1.8, + "declaredUnit": "KGM", + "operationalScope": "CradleToGate", + "primarySourcedRatio": 0.3, + "reportingStandard": { + "type": [ + "Standard" + ], + "id": "https://www.globalbattery.org/media/publications/gba-rulebook-v2.0-master.pdf", + "name": "GBA Battery Passport Greenhouse Gas Rulebook - V.2.0", + "issuingParty": { + "type": [ + "Entity" + ], + "id": "https://kbopub.economie.fgov.be/kbopub/toonondernemingps.html?ondernemingsnummer=786222414", + "name": "Global Battery Alliance", + "registeredId": "786222414", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://kbopub.economie.fgov.be/", + "name": "Belgian business register" + } + }, + "issueDate": "2023-12-05" + } + }, + "traceabilityInformation": [ + { + "linkURL": "https://files.sampleCompany.com/events/123456789.json", + "linkName": "Battery Assembly Event", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dte", + "hashDigest": "50af99a26f4af48c9f4ad8cf9d2f5018780ab4bb1167f0e94884ec228f1ba832", + "hashMethod": "SHA-256", + "encryptionMethod": "AES" + }, + { + "linkURL": "https://files.sampleCompany.com/events/123454321.json", + "linkName": "Battery Packaging Event", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dte", + "hashDigest": "50af99a26f4af48c9f4ad8cf9d2f5018780ab4bb1167f0e94884ec228f1ba832", + "hashMethod": "SHA-256", + "encryptionMethod": "AES" + } + ] + } +} diff --git a/tests/fixtures/samples/schemas_testing_breathable-t-shirt.json b/tests/fixtures/samples/schemas_testing_breathable-t-shirt.json new file mode 100644 index 0000000..7272e54 --- /dev/null +++ b/tests/fixtures/samples/schemas_testing_breathable-t-shirt.json @@ -0,0 +1,26 @@ +{ + "@context": { + "@version": 1.1, + "id": "@id", + "type": "@type", + "shirt": "https://spherity.github.io/schemas/testing/breathable-t-shirt.json#", + "schema": "https://schema.org/", + "BreathableTShirt": "shirt:BreathableTShirt", + "name": { + "@id": "shirt:name", + "@type": "schema:text" + }, + "material": { + "@id": "shirt:material", + "@type": "schema:text" + }, + "availablePrintTypes": { + "@id": "shirt:availablePrintTypes", + "@type": "schema:text" + }, + "designedBy": { + "@id": "shirt:designedBy", + "@type": "schema:text" + } + } +} diff --git a/tests/fixtures/samples/test_uncefact_org_DigitalIdentityAnchor-instance-0.6.1.json b/tests/fixtures/samples/test_uncefact_org_DigitalIdentityAnchor-instance-0.6.1.json new file mode 100644 index 0000000..b60afcd --- /dev/null +++ b/tests/fixtures/samples/test_uncefact_org_DigitalIdentityAnchor-instance-0.6.1.json @@ -0,0 +1,66 @@ +{ + "type": [ + "DigitalIdentityAnchor", + "VerifiableCredential" + ], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dia/0.6.0/" + ], + "id": "https://example-company.com/credentials/2a423366-a0d6-4855-ba65-2e0c926d09b0", + "issuer": { + "type": [ + "CredentialIssuer" + ], + "id": "did:web:identifiers.example-company.com:12345", + "name": "Example Company Pty Ltd", + "issuerAlsoKnownAs": [ + { + "id": "https://abr.business.gov.au/ABN/View?abn=90664869327", + "name": "Sample Company Pty Ltd.", + "registeredId": "90664869327", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + } + }, + { + "id": "https://abr.business.gov.au/ABN/View?abn=90664869327", + "name": "Sample Company Pty Ltd.", + "registeredId": "90664869327", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + } + } + ] + }, + "validFrom": "2024-03-15T12:00:00Z", + "validUntil": "2034-03-15T12:00:00Z", + "credentialSubject": { + "type": [ + "RegisteredIdentity" + ], + "id": "did:web:samplecompany.com/123456789", + "name": "Sample business Ltd", + "registeredId": "123456789", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + }, + "registerType": "Business", + "registrationScopeList": [ + "https://jargon.sh", + "https://jargon.sh" + ] + } +} diff --git a/tests/fixtures/samples/test_uncefact_org_untp-dpp-instance-0.6.0.json b/tests/fixtures/samples/test_uncefact_org_untp-dpp-instance-0.6.0.json new file mode 100644 index 0000000..b4cfcab --- /dev/null +++ b/tests/fixtures/samples/test_uncefact_org_untp-dpp-instance-0.6.0.json @@ -0,0 +1,561 @@ +{ + "type": [ + "DigitalProductPassport", + "VerifiableCredential" + ], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.0/" + ], + "id": "https://example-company.com/credentials/2a423366-a0d6-4855-ba65-2e0c926d09b0", + "issuer": { + "type": [ + "CredentialIssuer" + ], + "id": "did:web:identifiers.example-company.com:12345", + "name": "Example Company Pty Ltd", + "issuerAlsoKnownAs": [ + { + "id": "https://abr.business.gov.au/ABN/View?abn=90664869327", + "name": "Sample Company Pty Ltd.", + "registeredId": "90664869327", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + } + }, + { + "id": "https://abr.business.gov.au/ABN/View?abn=90664869327", + "name": "Sample Company Pty Ltd.", + "registeredId": "90664869327", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + } + } + ] + }, + "validFrom": "2024-03-15T12:00:00Z", + "validUntil": "2034-03-15T12:00:00Z", + "credentialSubject": { + "type": [ + "ProductPassport" + ], + "id": "example:product/1234", + "product": { + "type": [ + "Product" + ], + "id": "https://id.gs1.org/01/09520123456788/21/12345", + "name": "EV battery 300Ah.", + "registeredId": "09520123456788.21.12345", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + }, + "batchNumber": "6789", + "productImage": { + "linkURL": "https://files.example-certifier.com/1234567.json", + "linkName": "GBA rule book conformity certificate", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc" + }, + "description": "400Ah 24v LiFePO4 battery", + "productCategory": [ + { + "type": [ + "Classification" + ], + "id": "https://unstats.un.org/unsd/classifications/Econ/cpc/46410", + "code": "46410", + "name": "Primary cells and primary batteries", + "schemeID": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + { + "type": [ + "Classification" + ], + "id": "https://unstats.un.org/unsd/classifications/Econ/cpc/46410", + "code": "46410", + "name": "Primary cells and primary batteries", + "schemeID": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + } + ], + "furtherInformation": [ + { + "linkURL": "https://files.example-certifier.com/1234567.json", + "linkName": "GBA rule book conformity certificate", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc" + }, + { + "linkURL": "https://files.example-certifier.com/1234567.json", + "linkName": "GBA rule book conformity certificate", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc" + } + ], + "producedByParty": { + "id": "https://abr.business.gov.au/ABN/View?abn=90664869327", + "name": "Sample Company Pty Ltd.", + "registeredId": "90664869327", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + } + }, + "producedAtFacility": { + "id": "https://sample-facility-register.com/1234567", + "name": "Greenacres battery factory", + "registeredId": "1234567", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + } + }, + "productionDate": "2024-04-25", + "countryOfProduction": "AU", + "serialNumber": "12345678", + "dimensions": { + "weight": { + "value": 10, + "unit": "KGM" + }, + "length": { + "value": 10, + "unit": "KGM" + }, + "width": { + "value": 10, + "unit": "KGM" + }, + "height": { + "value": 10, + "unit": "KGM" + }, + "volume": { + "value": 10, + "unit": "KGM" + } + } + }, + "granularityLevel": "batch", + "conformityClaim": [ + { + "type": [ + "Claim", + "Declaration" + ], + "id": "https://products.example-company.com/09520123456788/declarations/12345", + "description": "A standardised disclosure of the battery's greenhouse gas emissions intensity, calculated in accordance with the Global Battery Alliance Battery Passport Greenhouse Gas Rulebook V.2.0.", + "referenceStandard": { + "type": [ + "Standard" + ], + "id": "https://www.globalbattery.org/media/publications/gba-rulebook-v2.0-master.pdf", + "name": "GBA Battery Passport Greenhouse Gas Rulebook - V.2.0", + "issuingParty": { + "id": "https://abr.business.gov.au/ABN/View?abn=90664869327", + "name": "Sample Company Pty Ltd.", + "registeredId": "90664869327", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + } + }, + "issueDate": "2023-12-05" + }, + "referenceRegulation": { + "type": [ + "Regulation" + ], + "id": "https://www.legislation.gov.au/F2008L02309/latest/versions", + "name": "NNational Greenhouse and Energy Reporting (Measurement) Determination", + "jurisdictionCountry": "AU", + "administeredBy": { + "id": "https://abr.business.gov.au/ABN/View?abn=90664869327", + "name": "Sample Company Pty Ltd.", + "registeredId": "90664869327", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + } + }, + "effectiveDate": "2024-03-20" + }, + "assessmentCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://www.globalbattery.org/media/publications/gba-rulebook-v2.0-master.pdf#BatteryAssembly", + "name": "GBA Battery rule book v2.0 battery assembly guidelines.", + "description": "Battery is designed for easy disassembly and recycling at end-of-life.", + "conformityTopic": "environment.energy", + "status": "proposed", + "subCriterion": [], + "thresholdValue": { + "metricName": "GHG emissions intensity", + "metricValue": { + "value": 10, + "unit": "KGM" + }, + "score": "BB", + "accuracy": 0.05 + }, + "performanceLevel": "\"Category 3 recyclable with 73% recyclability\"", + "tags": "The quick brown fox jumps over the lazy dog." + }, + { + "type": [ + "Criterion" + ], + "id": "https://www.globalbattery.org/media/publications/gba-rulebook-v2.0-master.pdf#BatteryAssembly", + "name": "GBA Battery rule book v2.0 battery assembly guidelines.", + "description": "Battery is designed for easy disassembly and recycling at end-of-life.", + "conformityTopic": "environment.waste", + "status": "proposed", + "subCriterion": [], + "thresholdValue": { + "metricName": "GHG emissions intensity", + "metricValue": { + "value": 10, + "unit": "KGM" + }, + "score": "BB", + "accuracy": 0.05 + }, + "performanceLevel": "\"Category 3 recyclable with 73% recyclability\"", + "tags": "The quick brown fox jumps over the lazy dog." + } + ], + "assessmentDate": "2024-03-15", + "declaredValue": [ + { + "metricName": "GHG emissions intensity", + "metricValue": { + "value": 10, + "unit": "KGM" + }, + "score": "BB", + "accuracy": 0.05 + }, + { + "metricName": "GHG emissions intensity", + "metricValue": { + "value": 10, + "unit": "KGM" + }, + "score": "BB", + "accuracy": 0.05 + } + ], + "conformance": true, + "conformityTopic": "environment.emissions", + "conformityEvidence": { + "linkURL": "https://files.example-certifier.com/1234567.json", + "linkName": "GBA rule book conformity certificate", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc", + "hashDigest": "6239119dda5bd4c8a6ffb832fe16feaa5c27b7dba154d24c53d4470a2c69adc2", + "hashMethod": "SHA-256", + "encryptionMethod": "AES" + } + }, + { + "type": [ + "Claim", + "Declaration" + ], + "id": "https://products.example-company.com/09520123456788/declarations/12345", + "description": "A standardised disclosure of the battery's greenhouse gas emissions intensity, calculated in accordance with the Global Battery Alliance Battery Passport Greenhouse Gas Rulebook V.2.0.", + "referenceStandard": { + "type": [ + "Standard" + ], + "id": "https://www.globalbattery.org/media/publications/gba-rulebook-v2.0-master.pdf", + "name": "GBA Battery Passport Greenhouse Gas Rulebook - V.2.0", + "issuingParty": { + "id": "https://abr.business.gov.au/ABN/View?abn=90664869327", + "name": "Sample Company Pty Ltd.", + "registeredId": "90664869327", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + } + }, + "issueDate": "2023-12-05" + }, + "referenceRegulation": { + "type": [ + "Regulation" + ], + "id": "https://www.legislation.gov.au/F2008L02309/latest/versions", + "name": "NNational Greenhouse and Energy Reporting (Measurement) Determination", + "jurisdictionCountry": "AU", + "administeredBy": { + "id": "https://abr.business.gov.au/ABN/View?abn=90664869327", + "name": "Sample Company Pty Ltd.", + "registeredId": "90664869327", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + } + }, + "effectiveDate": "2024-03-20" + }, + "assessmentCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://www.globalbattery.org/media/publications/gba-rulebook-v2.0-master.pdf#BatteryAssembly", + "name": "GBA Battery rule book v2.0 battery assembly guidelines.", + "description": "Battery is designed for easy disassembly and recycling at end-of-life.", + "conformityTopic": "circularity.content", + "status": "proposed", + "subCriterion": [], + "thresholdValue": { + "metricName": "GHG emissions intensity", + "metricValue": { + "value": 10, + "unit": "KGM" + }, + "score": "BB", + "accuracy": 0.05 + }, + "performanceLevel": "\"Category 3 recyclable with 73% recyclability\"", + "tags": "The quick brown fox jumps over the lazy dog." + }, + { + "type": [ + "Criterion" + ], + "id": "https://www.globalbattery.org/media/publications/gba-rulebook-v2.0-master.pdf#BatteryAssembly", + "name": "GBA Battery rule book v2.0 battery assembly guidelines.", + "description": "Battery is designed for easy disassembly and recycling at end-of-life.", + "conformityTopic": "social.rights", + "status": "proposed", + "subCriterion": [], + "thresholdValue": { + "metricName": "GHG emissions intensity", + "metricValue": { + "value": 10, + "unit": "KGM" + }, + "score": "BB", + "accuracy": 0.05 + }, + "performanceLevel": "\"Category 3 recyclable with 73% recyclability\"", + "tags": "The quick brown fox jumps over the lazy dog." + } + ], + "assessmentDate": "2024-03-15", + "declaredValue": [ + { + "metricName": "GHG emissions intensity", + "metricValue": { + "value": 10, + "unit": "KGM" + }, + "score": "BB", + "accuracy": 0.05 + }, + { + "metricName": "GHG emissions intensity", + "metricValue": { + "value": 10, + "unit": "KGM" + }, + "score": "BB", + "accuracy": 0.05 + } + ], + "conformance": true, + "conformityTopic": "environment.emissions", + "conformityEvidence": { + "linkURL": "https://files.example-certifier.com/1234567.json", + "linkName": "GBA rule book conformity certificate", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc", + "hashDigest": "6239119dda5bd4c8a6ffb832fe16feaa5c27b7dba154d24c53d4470a2c69adc2", + "hashMethod": "SHA-256", + "encryptionMethod": "AES" + } + } + ], + "emissionsScorecard": { + "carbonFootprint": 1.8, + "declaredUnit": "KGM", + "operationalScope": "CradleToGate", + "primarySourcedRatio": 0.3, + "reportingStandard": { + "type": [ + "Standard" + ], + "id": "https://www.globalbattery.org/media/publications/gba-rulebook-v2.0-master.pdf", + "name": "GBA Battery Passport Greenhouse Gas Rulebook - V.2.0", + "issuingParty": { + "id": "https://abr.business.gov.au/ABN/View?abn=90664869327", + "name": "Sample Company Pty Ltd.", + "registeredId": "90664869327", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.gs1.org/01/", + "name": "Global Trade Identification Number (GTIN)" + } + }, + "issueDate": "2023-12-05" + } + }, + "traceabilityInformation": [ + { + "valueChainProcess": "Spinning", + "verifiedRatio": 0.5, + "traceabilityEvent": [ + { + "linkURL": "https://files.example-certifier.com/1234567.json", + "linkName": "GBA rule book conformity certificate", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc", + "hashDigest": "6239119dda5bd4c8a6ffb832fe16feaa5c27b7dba154d24c53d4470a2c69adc2", + "hashMethod": "SHA-256", + "encryptionMethod": "AES" + }, + { + "linkURL": "https://files.example-certifier.com/1234567.json", + "linkName": "GBA rule book conformity certificate", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc", + "hashDigest": "6239119dda5bd4c8a6ffb832fe16feaa5c27b7dba154d24c53d4470a2c69adc2", + "hashMethod": "SHA-256", + "encryptionMethod": "AES" + } + ] + }, + { + "valueChainProcess": "Spinning", + "verifiedRatio": 0.5, + "traceabilityEvent": [ + { + "linkURL": "https://files.example-certifier.com/1234567.json", + "linkName": "GBA rule book conformity certificate", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc", + "hashDigest": "6239119dda5bd4c8a6ffb832fe16feaa5c27b7dba154d24c53d4470a2c69adc2", + "hashMethod": "SHA-256", + "encryptionMethod": "AES" + }, + { + "linkURL": "https://files.example-certifier.com/1234567.json", + "linkName": "GBA rule book conformity certificate", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc", + "hashDigest": "6239119dda5bd4c8a6ffb832fe16feaa5c27b7dba154d24c53d4470a2c69adc2", + "hashMethod": "SHA-256", + "encryptionMethod": "AES" + } + ] + } + ], + "circularityScorecard": { + "recyclingInformation": { + "linkURL": "https://files.example-certifier.com/1234567.json", + "linkName": "GBA rule book conformity certificate", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc" + }, + "repairInformation": { + "linkURL": "https://files.example-certifier.com/1234567.json", + "linkName": "GBA rule book conformity certificate", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc" + }, + "recyclableContent": 0.5, + "recycledContent": 0.3, + "utilityFactor": 1.2, + "materialCircularityIndicator": 0.67 + }, + "dueDiligenceDeclaration": { + "linkURL": "https://files.example-certifier.com/1234567.json", + "linkName": "GBA rule book conformity certificate", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc" + }, + "materialsProvenance": [ + { + "name": "Lithium Spodumene", + "originCountry": "AU", + "materialType": { + "type": [ + "Classification" + ], + "id": "https://unstats.un.org/unsd/classifications/Econ/cpc/46410", + "code": "46410", + "name": "Primary cells and primary batteries", + "schemeID": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.2, + "mass": { + "value": 10, + "unit": "KGM" + }, + "recycledMassFraction": 0.5, + "hazardous": false, + "symbol": "undefined", + "materialSafetyInformation": { + "linkURL": "https://files.example-certifier.com/1234567.json", + "linkName": "GBA rule book conformity certificate", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc" + } + }, + { + "name": "Lithium Spodumene", + "originCountry": "AU", + "materialType": { + "type": [ + "Classification" + ], + "id": "https://unstats.un.org/unsd/classifications/Econ/cpc/46410", + "code": "46410", + "name": "Primary cells and primary batteries", + "schemeID": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.2, + "mass": { + "value": 10, + "unit": "KGM" + }, + "recycledMassFraction": 0.5, + "hazardous": false, + "symbol": "undefined", + "materialSafetyInformation": { + "linkURL": "https://files.example-certifier.com/1234567.json", + "linkName": "GBA rule book conformity certificate", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc" + } + } + ] + } +} diff --git a/tests/fixtures/samples/untp-verifiable-credentials_s3_amazonaws_com_bc075c5f-2304-4b3f-bb24-46d9fa9a8e60.json b/tests/fixtures/samples/untp-verifiable-credentials_s3_amazonaws_com_bc075c5f-2304-4b3f-bb24-46d9fa9a8e60.json new file mode 100644 index 0000000..25caf25 --- /dev/null +++ b/tests/fixtures/samples/untp-verifiable-credentials_s3_amazonaws_com_bc075c5f-2304-4b3f-bb24-46d9fa9a8e60.json @@ -0,0 +1,8 @@ +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.0/" + ], + "type": "EnvelopedVerifiableCredential", + "id": "data:application/vc+jwt,eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDp3ZWI6dW5jZWZhY3QuZ2l0aHViLmlvOnByb2plY3QtdmNraXQ6dGVzdC1hbmQtZGV2ZWxvcG1lbnQjN2FmMTM2YThlZmExMWE0ZGYyZTkwMTBiOTcyYmRiOTJhMDAxMzcyNGI1MGU1ZWZhNDU0MDdhMmRkZWExODRlNi1Kc29uV2ViS2V5LWtleS0wIiwiY3R5IjoidmMiLCJ0eXAiOiJ2Yytqd3QifQ.eyJAY29udGV4dCI6WyJodHRwczovL3d3dy53My5vcmcvbnMvY3JlZGVudGlhbHMvdjIiLCJodHRwczovL3Rlc3QudW5jZWZhY3Qub3JnL3ZvY2FidWxhcnkvdW50cC9kcHAvMC42LjAvIl0sInR5cGUiOlsiRGlnaXRhbFByb2R1Y3RQYXNzcG9ydCIsIlZlcmlmaWFibGVDcmVkZW50aWFsIl0sImlzc3VlciI6eyJ0eXBlIjpbIkNyZWRlbnRpYWxJc3N1ZXIiXSwiaWQiOiJkaWQ6d2ViOnVuY2VmYWN0LmdpdGh1Yi5pbzpwcm9qZWN0LXZja2l0OnRlc3QtYW5kLWRldmVsb3BtZW50IiwibmFtZSI6IkVjb0NoYXJnZSBCYXR0ZXJ5IFN5c3RlbXMgUHR5IEx0ZCJ9LCJjcmVkZW50aWFsU3ViamVjdCI6eyJ0eXBlIjpbIlByb2R1Y3RQYXNzcG9ydCJdLCJwcm9kdWN0Ijp7InR5cGUiOlsiUHJvZHVjdCJdLCJpZCI6Imh0dHBzOi8vaWQuZ3MxLm9yZy8wMS8wOTUyMDEyMzQ1Njc4OC8yMS8wMDAxIiwibmFtZSI6IkVjb0NoYXJnZSBFViBCYXR0ZXJ5IFBhY2siLCJyZWdpc3RlcmVkSWQiOiIwOTUyMDEyMzQ1Njc4OCIsInNlcmlhbE51bWJlciI6IjAwMDEiLCJiYXRjaE51bWJlciI6IiIsImlkU2NoZW1lIjp7InR5cGUiOlsiSWRlbnRpZmllclNjaGVtZSJdLCJpZCI6Imh0dHBzOi8vaWQuZ3MxLm9yZy8wMSIsIm5hbWUiOiJHbG9iYWwgVHJhZGUgSXRlbSBOdW1iZXIgKEdUSU4pIn0sInByb2R1Y3RJbWFnZSI6eyJsaW5rVVJMIjoiaHR0cHM6Ly9jLmFuaW1hYXBwLmNvbS9iM3ZmMk0yMC9pbWcvcHAtaGVhZGVyQDJ4LnBuZyIsImxpbmtOYW1lIjoiRVYgQmF0dGVyeSAzMDBBaCBJbWFnZSJ9LCJkZXNjcmlwdGlvbiI6IkhpZ2gtcGVyZm9ybWFuY2UgYXV0b21vdGl2ZS1ncmFkZSBsaXRoaXVtLWlvbiBiYXR0ZXJ5IHBhY2sgZGVzaWduZWQgZm9yIGVsZWN0cmljIHZlaGljbGVzLiBNYW51ZmFjdHVyZWQgd2l0aCByZXNwb25zaWJseSBzb3VyY2VkIG1hdGVyaWFscyBhbmQgYSA5NSUgcmVjeWNsYWJpbGl0eSByYXRlLCBpdCByZWR1Y2VzIGxpZmVjeWNsZSBlbWlzc2lvbnMgdGhyb3VnaCBlY28tZnJpZW5kbHkgcHJvZHVjdGlvbiBhbmQgdmVyaWZpZWQgcmVjeWNsaW5nIHByb2dyYW1zLiIsInByb2R1Y3RDYXRlZ29yeSI6W3sidHlwZSI6WyJDbGFzc2lmaWNhdGlvbiJdLCJpZCI6Imh0dHBzOi8vdW5zdGF0cy51bi5vcmcvdW5zZC9jbGFzc2lmaWNhdGlvbnMvRWNvbi9jcGMvNDY0MTAiLCJjb2RlIjoiNDY0MTAiLCJuYW1lIjoiUHJpbWFyeSBjZWxscyBhbmQgcHJpbWFyeSBiYXR0ZXJpZXMiLCJzY2hlbWVJRCI6Imh0dHBzOi8vdW5zdGF0cy51bi5vcmcvdW5zZC9jbGFzc2lmaWNhdGlvbnMvRWNvbi9jcGMiLCJzY2hlbWVOYW1lIjoiVU4gQ2VudHJhbCBQcm9kdWN0IENsYXNzaWZpY2F0aW9uIChDUEMpIn1dLCJwcm9kdWNlZEJ5UGFydHkiOnsiaWQiOiJodHRwczovL2lkci51bnRwLnNob3d0aGV0aGluZy5jb20vYXBpLzEuMC4wL2F0by9hYm4vOTA2NjQ4NjkzMjc_bGlua1R5cGU9YXRvOnJlZ2lzdHJ5RW50cnkiLCJuYW1lIjoiRWNvQ2hhcmdlIEJhdHRlcnkgU3lzdGVtcyBQdHkgTHRkIiwicmVnaXN0ZXJlZElkIjoiOTA2NjQ4NjkzMjcifSwicHJvZHVjZWRBdEZhY2lsaXR5Ijp7ImlkIjoiaHR0cHM6Ly9pZHIudW50cC5zaG93dGhldGhpbmcuY29tL2FwaS8xLjAuMC9nczEvZ2xuLzEzMjEyMDIyOTA2NDg_bGlua1R5cGU9Z3MxOmxvY2F0aW9uSW5mbyIsIm5hbWUiOiJFY29DaGFyZ2UgU3lkbmV5IE1hbnVmYWN0dXJpbmcgUGxhbnQiLCJyZWdpc3RlcmVkSWQiOiIxMzIxMjAyMjkwNjQ4In0sImRpbWVuc2lvbnMiOnsid2VpZ2h0Ijp7InZhbHVlIjo0ODAsInVuaXQiOiJLR00ifSwibGVuZ3RoIjp7InZhbHVlIjoyODAwLCJ1bml0IjoiTU1UIn0sIndpZHRoIjp7InZhbHVlIjoxNjAwLCJ1bml0IjoiTU1UIn0sImhlaWdodCI6eyJ2YWx1ZSI6MjAwLCJ1bml0IjoiTU1UIn0sInZvbHVtZSI6eyJ2YWx1ZSI6MjUwLCJ1bml0IjoiRE1RIn19LCJwcm9kdWN0aW9uRGF0ZSI6IjIwMjQtMDMtMTUiLCJjb3VudHJ5T2ZQcm9kdWN0aW9uIjoiQVUifSwiZ3JhbnVsYXJpdHlMZXZlbCI6Iml0ZW0iLCJkdWVEaWxpZ2VuY2VEZWNsYXJhdGlvbiI6eyJsaW5rVVJMIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9kdWUtZGlsaWdlbmNlLzEyMzQ1NjciLCJsaW5rTmFtZSI6IkR1ZSBEaWxpZ2VuY2UgRGVjbGFyYXRpb24ifSwibWF0ZXJpYWxzUHJvdmVuYW5jZSI6W3sibmFtZSI6IkxpdGhpdW0iLCJvcmlnaW5Db3VudHJ5IjoiQVUiLCJtYXNzRnJhY3Rpb24iOjAuMywibWFzcyI6eyJ2YWx1ZSI6NzUsInVuaXQiOiJLR00ifSwiaGF6YXJkb3VzIjp0cnVlfSx7Im5hbWUiOiJOaWNrZWwiLCJvcmlnaW5Db3VudHJ5IjoiQVUiLCJtYXNzRnJhY3Rpb24iOjAuMywibWFzcyI6eyJ2YWx1ZSI6NzUsInVuaXQiOiJLR00ifSwiaGF6YXJkb3VzIjp0cnVlfSx7Im5hbWUiOiJDb2JhbHQiLCJvcmlnaW5Db3VudHJ5IjoiQVUiLCJtYXNzRnJhY3Rpb24iOjAuMiwibWFzcyI6eyJ2YWx1ZSI6NTAsInVuaXQiOiJLR00ifSwiaGF6YXJkb3VzIjp0cnVlfSx7Im5hbWUiOiJPdGhlciBNYXRlcmlhbHMiLCJvcmlnaW5Db3VudHJ5IjoiQVUiLCJtYXNzRnJhY3Rpb24iOjAuMiwibWFzcyI6eyJ2YWx1ZSI6NTAsInVuaXQiOiJLR00ifSwiaGF6YXJkb3VzIjpmYWxzZX1dLCJjb25mb3JtaXR5Q2xhaW0iOlt7InR5cGUiOlsiQ2xhaW0iLCJEZWNsYXJhdGlvbiJdLCJpZCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYXR0ZXN0YXRpb25zLzIwMjQtMDAxIiwiYXNzZXNzbWVudERhdGUiOiIyMDI0LTAzLTE1IiwiY29uZm9ybWFuY2UiOnRydWUsImNvbmZvcm1pdHlUb3BpYyI6InNvY2lhbC5zYWZldHkiLCJjb25mb3JtaXR5RXZpZGVuY2UiOnsibGlua1VSTCI6Imh0dHBzOi8vaWRyLnVudHAuc2hvd3RoZXRoaW5nLmNvbS9hcGkvMS4wLjAvZ3MxL2d0aW4vMDk1MjAxMjM0NTY3ODgvMjEvMDAwMT9saW5rVHlwZT1nczE6Y2VydGlmaWNhdGlvbkluZm8iLCJsaW5rTmFtZSI6IkNvbmZvcm1pdHkgRXZpZGVuY2UifSwicmVmZXJlbmNlU3RhbmRhcmQiOnsidHlwZSI6WyJTdGFuZGFyZCJdLCJpZCI6Imh0dHBzOi8vd3d3Lmlzby5vcmcvc3RhbmRhcmQvNzE0MDcuaHRtbCIsIm5hbWUiOiJJU08gMTI0MDUtNDoyMDE4IC0gVGVzdCBzcGVjaWZpY2F0aW9uIGZvciBsaXRoaXVtLWlvbiB0cmFjdGlvbiBiYXR0ZXJ5IHBhY2tzIGFuZCBzeXN0ZW1zIiwiaXNzdWluZ1BhcnR5Ijp7ImlkIjoiaHR0cHM6Ly93d3cuaXNvLm9yZyIsIm5hbWUiOiJJbnRlcm5hdGlvbmFsIE9yZ2FuaXphdGlvbiBmb3IgU3RhbmRhcmRpemF0aW9uIn0sImlzc3VlRGF0ZSI6IjIwMTgtMDItMTUifSwiZGVjbGFyZWRWYWx1ZSI6W3sibWV0cmljTmFtZSI6IlNob3J0IENpcmN1aXQgVGVzdCBUZW1wZXJhdHVyZSBSaXNlIiwibWV0cmljVmFsdWUiOnsidmFsdWUiOjAsInVuaXQiOiJDRUwifSwiYWNjdXJhY3kiOjAuMDEsInNjb3JlIjoiQSJ9LHsibWV0cmljTmFtZSI6Ik1heGltdW0gT3BlcmF0aW5nIFRlbXBlcmF0dXJlIiwibWV0cmljVmFsdWUiOnsidmFsdWUiOjU1LCJ1bml0IjoiQ0VMIn0sImFjY3VyYWN5IjowLjAxLCJzY29yZSI6IkEifV19XSwiY2lyY3VsYXJpdHlTY29yZWNhcmQiOnsicmVjeWNsYWJsZUNvbnRlbnQiOjAuOTUsInJlY3ljbGVkQ29udGVudCI6MC4zLCJ1dGlsaXR5RmFjdG9yIjoxLjIsIm1hdGVyaWFsQ2lyY3VsYXJpdHlJbmRpY2F0b3IiOjAuODUsInJlY3ljbGluZ0luZm9ybWF0aW9uIjp7ImxpbmtVUkwiOiJodHRwczovL2V4YW1wbGUuY29tL3JlY3ljbGluZy9ldjMwMCIsImxpbmtOYW1lIjoiQmF0dGVyeSBSZWN5Y2xpbmcgR3VpZGVsaW5lcyJ9LCJyZXBhaXJJbmZvcm1hdGlvbiI6eyJsaW5rVVJMIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZXBhaXIvZXYzMDAiLCJsaW5rTmFtZSI6IlJlcGFpciBJbnN0cnVjdGlvbnMifX0sImVtaXNzaW9uc1Njb3JlY2FyZCI6eyJjYXJib25Gb290cHJpbnQiOjI1LCJkZWNsYXJlZFVuaXQiOiJLR00iLCJvcGVyYXRpb25hbFNjb3BlIjoiQ3JhZGxlVG9HYXRlIiwicHJpbWFyeVNvdXJjZWRSYXRpbyI6MC45NSwicmVwb3J0aW5nU3RhbmRhcmQiOnsidHlwZSI6WyJTdGFuZGFyZCJdLCJpZCI6Imh0dHBzOi8vZ2hncHJvdG9jb2wub3JnL3Byb2R1Y3Qtc3RhbmRhcmQiLCJpc3N1ZURhdGUiOiIyMDExLTExLTE1IiwibmFtZSI6IkdIRyBQcm90b2NvbCBQcm9kdWN0IExpZmUgQ3ljbGUgQWNjb3VudGluZyBhbmQgUmVwb3J0aW5nIFN0YW5kYXJkIiwiaXNzdWluZ1BhcnR5Ijp7ImlkIjoiaHR0cHM6Ly9naGdwcm90b2NvbC5vcmciLCJuYW1lIjoiR3JlZW5ob3VzZSBHYXMgUHJvdG9jb2wifX19LCJ0cmFjZWFiaWxpdHlJbmZvcm1hdGlvbiI6W3sidmFsdWVDaGFpblByb2Nlc3MiOiJNYW51ZmFjdHVyaW5nIiwidmVyaWZpZWRSYXRpbyI6MC41LCJ0cmFjZWFiaWxpdHlFdmVudCI6W3sibGlua1VSTCI6Imh0dHBzOi8vaWRyLnVudHAuc2hvd3RoZXRoaW5nLmNvbS9hcGkvMS4wLjAvZ3MxLzAxLzA5NTIwMTIzNDU2Nzg4LzIxLzAwMDE_bGlua1R5cGU9Z3MxOnRyYWNlYWJpbGl0eSIsImxpbmtOYW1lIjoiVHJhbnNmb3JtYXRpb24gRXZlbnQiLCJsaW5rVHlwZSI6Imh0dHBzOi8vdGVzdC51bmNlZmFjdC5vcmcvdm9jYWJ1bGFyeS9saW5rVHlwZXMvZHRlIn1dfV19LCJ2YWxpZEZyb20iOiIyMDI1LTA2LTE3VDIyOjIxOjI1Ljg5OFoiLCJjcmVkZW50aWFsU3RhdHVzIjp7ImlkIjoiaHR0cHM6Ly92Y2tpdC51bnRwLnNob3d0aGV0aGluZy5jb20vY3JlZGVudGlhbHMvc3RhdHVzL2JpdHN0cmluZy1zdGF0dXMtbGlzdC8zIzEzNSIsInR5cGUiOiJCaXRzdHJpbmdTdGF0dXNMaXN0RW50cnkiLCJzdGF0dXNQdXJwb3NlIjoicmV2b2NhdGlvbiIsInN0YXR1c0xpc3RJbmRleCI6MTM1LCJzdGF0dXNMaXN0Q3JlZGVudGlhbCI6Imh0dHBzOi8vdmNraXQudW50cC5zaG93dGhldGhpbmcuY29tL2NyZWRlbnRpYWxzL3N0YXR1cy9iaXRzdHJpbmctc3RhdHVzLWxpc3QvMyJ9LCJpZCI6InVybjp1dWlkOmI3NTMwNDBjLTE2MDItNDViOC1iZjU5LTE1YWIzMzFhMmM3YSIsInJlbmRlck1ldGhvZCI6W3sidHlwZSI6IldlYlJlbmRlcmluZ1RlbXBsYXRlMjAyMiIsInRlbXBsYXRlIjoiPCFET0NUWVBFIGh0bWw-PGh0bWwgbGFuZz1cImVuXCI-IDxoZWFkPiA8bWV0YSBjaGFyc2V0PVwiVVRGLThcIiAvPiA8bWV0YSBuYW1lPVwidmlld3BvcnRcIiBjb250ZW50PVwid2lkdGg9ZGV2aWNlLXdpZHRoLCBpbml0aWFsLXNjYWxlPTEuMFwiIC8-IDxsaW5rIGhyZWY9XCJodHRwczovL2ZvbnRzLmdvb2dsZWFwaXMuY29tL2NzczI_ZmFtaWx5PUxhdG86aXRhbCx3Z2h0QDAsMTAwOzAsMzAwOzAsNDAwOzAsNzAwOzAsOTAwOzEsMTAwOzEsMzAwOzEsNDAwOzEsNzAwOzEsOTAwJmRpc3BsYXk9c3dhcFwiIHJlbD1cInN0eWxlc2hlZXRcIiAvPiA8dGl0bGU-RGlnaXRhbCBQcm9kdWN0IFBhc3Nwb3J0PC90aXRsZT4gPHN0eWxlPiA6cm9vdCB7IC8qIEJyYW5kIENvbG9ycyAqLyAtLWNvbG9yLXByaW1hcnk6IHJnYmEoMzEsIDkwLCAxNDksIDEpOyAvKiBDb2xvciBmb3IgcGFzc3BvcnQgYm94IGl0ZW0gdGV4dCwgZW1pc3Npb24gc2NvcmVjYXJkIHVuaXQsIGNvbmZvcm1pdHkgZGV0YWlscywgYW5kIGhpc3RvcnkgdmFsdWUgY2hhaW4gcHJvY2VzcyB0ZXh0OyBEZWZhdWx0OiByZ2JhKDMxLCA5MCwgMTQ5LCAxKSAqLyAvKiBOZXV0cmFscyAqLyAtLWNvbG9yLXdoaXRlOiByZ2JhKDI1NSwgMjU1LCAyNTUsIDEpOyAvKiBCYWNrZ3JvdW5kIGNvbG9yIGZvciBtYWluIGNvbnRhaW5lciwgY29uZm9ybWl0eSBjYXJkcywgYW5kIGlzc3VpbmcgZGV0YWlscyBzZWN0aW9uOyBEZWZhdWx0OiByZ2JhKDI1NSwgMjU1LCAyNTUsIDEpICovIC0tY29sb3ItYmxhY2s6IHJnYmEoMCwgMCwgMCwgMSk7IC8qIFRleHQgY29sb3IgZm9yIHNlY3Rpb24gZGVzY3JpcHRpb25zLCBpbmZvcm1hdGlvbiB0ZXh0LCBjb21wb3NpdGlvbiB0aXRsZSwgY29tcG9zaXRpb24gcGVyY2VudCwgY29tcG9zaXRpb24gdGFnIGl0ZW0sIGhpc3RvcnkgaXRlbSBzcGFuLCBhbmQgdHJhY2VhYmlsaXR5IGNhcmQgdGV4dDsgRGVmYXVsdDogcmdiYSgwLCAwLCAwLCAxKSAqLyAtLWNvbG9yLWdyYXktNzAwOiByZ2JhKDM1LCA0NiwgNjEsIDEpOyAvKiBUZXh0IGNvbG9yIGZvciBsaW5rcywgc2VjdGlvbiB0aXRsZXMsIHRhYmxlIGl0ZW0gdmFsdWVzLCBkZWNsYXJlZCB2YWx1ZSB0ZXh0LCBhbmQgaGVhZGVyIGJhdGNoIGl0ZW0gbGlua3M7IERlZmF1bHQ6IHJnYmEoMzUsIDQ2LCA2MSwgMSkgKi8gLS1jb2xvci1ncmF5LTYwMDogcmdiYSg4NSwgOTYsIDExMCwgMSk7IC8qIFRleHQgY29sb3IgZm9yIHRhYmxlIGl0ZW0gc3BhbnMsIGNvbmZvcm1pdHkgbGFiZWxzLCBzY29yZSBuYW1lLCBwYXNzcG9ydCBhbm5vdGF0aW9uLCBjb25mb3JtaXR5IGluZm8sIGRlY2xhcmVkIHZhbHVlIHNwYW4sIGNvdW50cnkgY29kZSwgZm9vdGVyIHRleHQsIGFuZCBoZWFkZXIgaW1hZ2UgYmFja2dyb3VuZDsgRGVmYXVsdDogcmdiYSg4NSwgOTYsIDExMCwgMSkgKi8gLS1jb2xvci1ncmF5LTQwMDogcmdiYSgyMTIsIDIxNCwgMjE2LCAxKTsgLyogQm9yZGVyIGNvbG9yIGZvciB0YWJsZSBpdGVtcywgY29uZm9ybWl0eSBjYXJkcywgY29tcG9zaXRpb24gYm94IGl0ZW1zLCBoaXN0b3J5IGl0ZW1zLCBhbmQgcGFzc3BvcnQgYm94OyBEZWZhdWx0OiByZ2JhKDIxMiwgMjE0LCAyMTYsIDEpICovIC0tY29sb3ItZ3JheS0xMDA6IHJnYmEoMjQ3LCAyNTAsIDI1MywgMSk7IC8qIEJhY2tncm91bmQgY29sb3IgZm9yIGZvb3RlciwgdmVyaWZpZWQgcmF0aW8sIGNvbXBvc2l0aW9uIHRhZyBpdGVtLCBoZWFkZXIgYmF0Y2gsIGFuZCBvbmUgcGFzc3BvcnQgYm94IGl0ZW07IERlZmF1bHQ6IHJnYmEoMjQ3LCAyNTAsIDI1MywgMSkgKi8gLyogU2VtYW50aWMgKEZ1bmN0aW9uYWwpIENvbG9ycyAqLyAtLWNvbG9yLWxpbmstdW5kZXJsaW5lLWRhcms6IHJnYmEoNzksIDE0OSwgMjIxLCAxKTsgLyogVW5kZXJsaW5lIGNvbG9yIGZvciBibHVlLWJvdHRvbS1saW5lLXRoaWNrIGxpbmtzOyBEZWZhdWx0OiByZ2JhKDc5LCAxNDksIDIyMSwgMSkgKi8gLS1jb2xvci1hY2NlbnQtc3VjY2VzczogcmdiYSgxODQsIDIzNiwgMTgyLCAxKTsgLyogQmFja2dyb3VuZCBjb2xvciBmb3IgZ3JlZW4gY29uZm9ybWFuY2UgYmFkZ2U7IERlZmF1bHQ6IHJnYmEoMTg0LCAyMzYsIDE4MiwgMSkgKi8gLS1jb2xvci1hY2NlbnQtZXJyb3I6IHJnYmEoMjU1LCAxODgsIDE4MywgMSk7IC8qIEJhY2tncm91bmQgY29sb3IgZm9yIHJlZCBjb25mb3JtYW5jZSBiYWRnZTsgRGVmYXVsdDogcmdiYSgyNTUsIDE4OCwgMTgzLCAxKSAqLyAtLWNvbG9yLWljb246IHJnYmEoMzEsIDkwLCAxNDksIDEpOyAvKiBGaWxsIGFuZCBzdHJva2UgY29sb3IgZm9yIGFsbCBTVkcgaWNvbnM7IERlZmF1bHQ6IHJnYmEoMzEsIDkwLCAxNDksIDEpICovIC8qIEZvbnQgVmFyaWFibGVzICovIC0tZm9udC1mYW1pbHk6IFwiTGF0b1wiLCBzYW5zLXNlcmlmOyAvKiBGb250IGZhbWlseSBmb3IgYWxsIHRleHQ7IERlZmF1bHQ6IExhdG8sIHNhbnMtc2VyaWYgKi8gLyogRm9udCBXZWlnaHQgVmFyaWFibGVzICovIC0tZm9udC13ZWlnaHQtbGlnaHQ6IDMwMDsgLyogRm9udCB3ZWlnaHQgZm9yIGluZm9ybWF0aW9uIHRleHQ7IERlZmF1bHQ6IDMwMCAqLyAtLWZvbnQtd2VpZ2h0LXJlZ3VsYXI6IDQwMDsgLyogRm9udCB3ZWlnaHQgZm9yIHNlY3Rpb24gZGVzY3JpcHRpb25zLCBjb25mb3JtaXR5IGxhYmVscywgdGFibGUgaXRlbSBzcGFucywgZGVjbGFyZWQgdmFsdWUgdGV4dCwgcGFzc3BvcnQgYW5ub3RhdGlvbiwgY29uZm9ybWl0eSBpbmZvLCBzY29yZSBuYW1lLCBjb21wb3NpdGlvbiB0YWcgaXRlbSwgY291bnRyeSBjb2RlLCBmb290ZXIgdGV4dCwgaGlzdG9yeSBpdGVtIHNwYW4sIHRyYWNlYWJpbGl0eSBjYXJkIHRleHQsIGFuZCBoZWFkZXIgaW1hZ2UgdG9wLWxlZnQgdGV4dDsgRGVmYXVsdDogNDAwICovIC0tZm9udC13ZWlnaHQtbWVkaXVtOiA1MDA7IC8qIEZvbnQgd2VpZ2h0IGZvciBsaW5rcywgdGFibGUgaXRlbSBwYXJhZ3JhcGhzLCBjb21wb3NpdGlvbiBwZXJjZW50LCBhbmQgaGVhZGVyIGltYWdlIHRvcC1sZWZ0IHRleHQ7IERlZmF1bHQ6IDUwMCAqLyAtLWZvbnQtd2VpZ2h0LWJvbGQ6IDYwMDsgLyogRm9udCB3ZWlnaHQgZm9yIHNlY3Rpb24gdGl0bGVzLCBjb21wb3NpdGlvbiB0aXRsZSwgYW5kIGNvbmZvcm1hbmNlIGJhZGdlIHRleHQ7IERlZmF1bHQ6IDYwMCAqLyAtLWZvbnQtd2VpZ2h0LWJsYWNrOiA5MDA7IC8qIEZvbnQgd2VpZ2h0IGZvciBoZWFkZXIgaW1hZ2UgYm90dG9tIGxlZnQgaDEsIHBhc3Nwb3J0IGJveCBpdGVtIGgzLCBhbmQgZW1pc3Npb24gc2NvcmUgdW5pdDsgRGVmYXVsdDogOTAwICovIC8qIE90aGVyIFZhcmlhYmxlcyAqLyAtLWltYWdlLXNyYzogdXJsKFwie3tjcmVkZW50aWFsU3ViamVjdC5wcm9kdWN0LnByb2R1Y3RJbWFnZS5saW5rVVJMfX1cIik7IC8qIEJhY2tncm91bmQgaW1hZ2UgZm9yIGhlYWRlciBpbWFnZTsgRGVmYXVsdDogdXJsKFwie3tjcmVkZW50aWFsU3ViamVjdC5wcm9kdWN0LnByb2R1Y3RJbWFnZS5saW5rVVJMfX1cIikgKi8gfSAqIHsgbWFyZ2luOiAwOyBwYWRkaW5nOiAwOyBib3gtc2l6aW5nOiBib3JkZXItYm94OyB9IGJvZHkgeyBmb250LWZhbWlseTogdmFyKC0tZm9udC1mYW1pbHkpOyBjb2xvcjogdmFyKC0tY29sb3ItZ3JheS02MDApOyBmb250LXdlaWdodDogdmFyKC0tZm9udC13ZWlnaHQtcmVndWxhcik7IH0gLmNvbnRhaW5lciB7IG1pbi13aWR0aDogMTUwcHg7IHdpZHRoOiAxMDAlOyBtYXJnaW46IDAgYXV0bzsgZGlzcGxheTogZmxleDsgZmxleC1kaXJlY3Rpb246IGNvbHVtbjsgZ2FwOiAzMnB4OyB3b3JkLWJyZWFrOiBicmVhay13b3JkOyBiYWNrZ3JvdW5kLWNvbG9yOiB2YXIoLS1jb2xvci13aGl0ZSk7IH0gc2VjdGlvbiwgaGVhZGVyLCBmb290ZXIgeyBwYWRkaW5nOiAwIDE2cHg7IH0gLyogTmV1dHJhbGlzZSBkZWZhdWx0IG1hcmdpbnMgb24gaGVhZGVyIGVsZW1lbnRzIHdpdGhpbiBzZWN0aW9ucyAqLyBzZWN0aW9uIGhlYWRlciB7IG1hcmdpbjogMDsgfSAuaGVhZGVyLWltYWdlIHsgYmFja2dyb3VuZC1jb2xvcjogdmFyKC0tY29sb3ItZ3JheS02MDApOyBiYWNrZ3JvdW5kLWltYWdlOiB2YXIoLS1pbWFnZS1zcmMsIG5vbmUpLCBsaW5lYXItZ3JhZGllbnQoIDI0OC4zNmRlZywgcmdiYSgwLCAwLCAwLCAwLjE4KSA3LjYlLCByZ2JhKDAsIDAsIDAsIDAuNikgNzAuNTIlICk7IGJhY2tncm91bmQtc2l6ZTogY292ZXI7IGJhY2tncm91bmQtcG9zaXRpb246IGNlbnRlcjsgYmFja2dyb3VuZC1yZXBlYXQ6IG5vLXJlcGVhdDsgaGVpZ2h0OiAyMzJweDsgcG9zaXRpb246IHJlbGF0aXZlOyB9IC5oZWFkZXItaW1hZ2UtdG9wLWxlZnQgeyBwb3NpdGlvbjogYWJzb2x1dGU7IHRvcDogMjVweDsgbGVmdDogMTVweDsgZm9udC13ZWlnaHQ6IHZhcigtLWZvbnQtd2VpZ2h0LW1lZGl1bSk7IGZvbnQtc2l6ZTogMTZweDsgbGluZS1oZWlnaHQ6IDIycHg7IGNvbG9yOiB2YXIoLS1jb2xvci13aGl0ZSk7IH0gLmhlYWRlci1pbWFnZS1ib3R0b20tbGVmdCB7IHBvc2l0aW9uOiBhYnNvbHV0ZTsgYm90dG9tOiAxOHB4OyBsZWZ0OiAxNXB4OyBjb2xvcjogdmFyKC0tY29sb3Itd2hpdGUpOyB9IC5oZWFkZXItaW1hZ2UtYm90dG9tLWxlZnQgaDEgeyBmb250LXNpemU6IDMwcHg7IGZvbnQtd2VpZ2h0OiB2YXIoLS1mb250LXdlaWdodC1ibGFjayk7IGxpbmUtaGVpZ2h0OiAzMi41cHg7IH0gLmhlYWRlci1iYXRjaCB7IHBhZGRpbmc6IDEycHggMTZweDsgYmFja2dyb3VuZC1jb2xvcjogdmFyKC0tY29sb3ItZ3JheS0xMDApOyBkaXNwbGF5OiBmbGV4OyBmbGV4LXdyYXA6IHdyYXA7IGp1c3RpZnktY29udGVudDogc3BhY2UtYmV0d2VlbjsgZ2FwOiAxMnB4OyB9IC5oZWFkZXItYmF0Y2gtaXRlbSB7IGRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogNnB4OyBmbGV4LWdyb3c6IDE7IG1pbi13aWR0aDogMDsgfSAuaGVhZGVyLWJhdGNoLWl0ZW0gYSB7IGNvbG9yOiB2YXIoLS1jb2xvci1ncmF5LTcwMCk7IGZvbnQtc2l6ZTogMTRweDsgZm9udC13ZWlnaHQ6IHZhcigtLWZvbnQtd2VpZ2h0LW1lZGl1bSk7IH0gLmhlYWRlci1iYXRjaC1pdGVtIHN2ZyB7IGZpbGw6IHZhcigtLWNvbG9yLWljb24pOyBzdHJva2U6IHZhcigtLWNvbG9yLWljb24pOyB9IC8qIEdlbmVyYWwgU2VjdGlvbiBTdHlsZXMgKi8gLnNlY3Rpb24tdGl0bGUgeyBmb250LXNpemU6IDE4cHg7IGZvbnQtd2VpZ2h0OiB2YXIoLS1mb250LXdlaWdodC1ib2xkKTsgbGluZS1oZWlnaHQ6IDE5LjYycHg7IGNvbG9yOiB2YXIoLS1jb2xvci1ncmF5LTcwMCk7IH0gLnNlY3Rpb24tZGVzY3JpcHRpb24geyBtYXJnaW4tdG9wOiAxMnB4OyBmb250LXNpemU6IDE2cHg7IGxpbmUtaGVpZ2h0OiAxOC44OHB4OyBjb2xvcjogdmFyKC0tY29sb3ItYmxhY2spOyBmb250LXdlaWdodDogdmFyKC0tZm9udC13ZWlnaHQtcmVndWxhcik7IH0gLyogVGFibGUgU3R5bGVzICovIC50YWJsZSB7IGRpc3BsYXk6IGZsZXg7IGZsZXgtZGlyZWN0aW9uOiBjb2x1bW47IGdhcDogMTBweDsgfSAudGFibGUtaXRlbSB7IGRpc3BsYXk6IGdyaWQ7IGdyaWQtdGVtcGxhdGUtY29sdW1uczogMWZyIDJmcjsgY29sdW1uLWdhcDogMTZweDsgYWxpZ24taXRlbXM6IGNlbnRlcjsgcGFkZGluZy1ib3R0b206IDEwcHg7IGJvcmRlci1ib3R0b206IDFweCBzb2xpZCB2YXIoLS1jb2xvci1ncmF5LTQwMCk7IH0gLnRhYmxlLWl0ZW0gc3BhbiB7IGZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IHZhcigtLWZvbnQtd2VpZ2h0LXJlZ3VsYXIpOyBjb2xvcjogdmFyKC0tY29sb3ItZ3JheS02MDApOyB9IC50YWJsZS1pdGVtIHAsIC50YWJsZS1pdGVtIGEgeyBmb250LXNpemU6IDE2cHg7IGZvbnQtd2VpZ2h0OiB2YXIoLS1mb250LXdlaWdodC1tZWRpdW0pOyBjb2xvcjogdmFyKC0tY29sb3ItZ3JheS03MDApOyB9IC5pdGVtLXZhbHVlIHsgZGlzcGxheTogZmxleDsgZmxleC1kaXJlY3Rpb246IGNvbHVtbjsgZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogdmFyKC0tZm9udC13ZWlnaHQtbWVkaXVtKTsgY29sb3I6IHZhcigtLWNvbG9yLWdyYXktNjAwKTsgfSAuaXRlbS12YWx1ZSBzcGFuIHsgY29sb3I6IHZhcigtLWNvbG9yLWdyYXktNzAwKTsgfSAuaW5mb3JtYXRpb24tdGV4dCB7IGZvbnQtc2l6ZTogMTlweDsgcGFkZGluZy1ib3R0b206IDhweDsgZm9udC13ZWlnaHQ6IHZhcigtLWZvbnQtd2VpZ2h0LWxpZ2h0KTsgY29sb3I6IHZhcigtLWNvbG9yLWJsYWNrKTsgbGluZS1oZWlnaHQ6IDIyLjQycHg7IH0gLmluZm9ybWF0aW9uLXNob3ctbW9yZSB7IGRpc3BsYXk6IGZsZXg7IGZsZXgtZGlyZWN0aW9uOiBjb2x1bW47IGdhcDogMTBweDsgZm9udC1zaXplOiAxNHB4OyBmb250LXdlaWdodDogdmFyKC0tZm9udC13ZWlnaHQtbWVkaXVtKTsgfSAvKiBQcm9kdWN0aW9uIFNlY3Rpb24gKi8gLnByb2R1Y3Rpb24geyBkaXNwbGF5OiBmbGV4OyBmbGV4LWRpcmVjdGlvbjogY29sdW1uOyBnYXA6IDEycHg7IH0gLyogUGFzc3BvcnQgU2VjdGlvbiAqLyAucGFzc3BvcnQgeyBkaXNwbGF5OiBmbGV4OyBmbGV4LWRpcmVjdGlvbjogY29sdW1uOyBnYXA6IDI0cHg7IH0gLnBhc3Nwb3J0LWJveCB7IGRpc3BsYXk6IGdyaWQ7IGdyaWQtdGVtcGxhdGUtY29sdW1uczogMWZyIDFmcjsgYm9yZGVyOiAxcHggc29saWQgdmFyKC0tY29sb3ItZ3JheS00MDApOyBib3JkZXItcmFkaXVzOiA1cHg7IH0gLnBhc3Nwb3J0LWJveC1pdGVtIHsgZGlzcGxheTogZmxleDsgZmxleC1kaXJlY3Rpb246IGNvbHVtbjsganVzdGlmeS1jb250ZW50OiBjZW50ZXI7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IHBhZGRpbmc6IDEycHggMTZweDsgYm9yZGVyOiAxcHggc29saWQgdmFyKC0tY29sb3ItZ3JheS00MDApOyBtaW4taGVpZ2h0OiA5NHB4OyBjb2xvcjogdmFyKC0tY29sb3ItcHJpbWFyeSk7IH0gLnBhc3Nwb3J0LWJveC1pdGVtIGgzIHsgZm9udC1zaXplOiA0MHB4OyBmb250LXdlaWdodDogdmFyKC0tZm9udC13ZWlnaHQtYmxhY2spOyBsaW5lLWhlaWdodDogNDMuMzNweDsgbGV0dGVyLXNwYWNpbmc6IDJweDsgfSAucGFzc3BvcnQtYm94LWl0ZW0gcCB7IG1hcmdpbi10b3A6IDhweDsgZm9udC1zaXplOiAxNXB4OyBmb250LXdlaWdodDogdmFyKC0tZm9udC13ZWlnaHQtYm9sZCk7IH0gLnBhc3Nwb3J0LWJveC1pdGVtOm50aC1jaGlsZCg0KSB7IGJhY2tncm91bmQtY29sb3I6IHZhcigtLWNvbG9yLWdyYXktMTAwKTsgfSAucGFzc3BvcnQtYW5ub3RhdGlvbiB7IGZvbnQtc2l6ZTogMTRweDsgZm9udC13ZWlnaHQ6IHZhcigtLWZvbnQtd2VpZ2h0LXJlZ3VsYXIpOyBsaW5lLWhlaWdodDogMTUuMjZweDsgY29sb3I6IHZhcigtLWNvbG9yLWdyYXktNjAwKTsgfSAudHJhY2VhYmlsaXR5LWNhcmRzIHsgZGlzcGxheTogZmxleDsgZmxleC1kaXJlY3Rpb246IGNvbHVtbjsgZ2FwOiAxMnB4OyB9IC50cmFjZWFiaWxpdHktY2FyZCB7IGRpc3BsYXk6IGdyaWQ7IGdyaWQtdGVtcGxhdGUtY29sdW1uczogM2ZyIDFmcjsgYWxpZ24taXRlbXM6IGNlbnRlcjsgdGV4dC1kZWNvcmF0aW9uOiBub25lOyB9IC50cmFjZWFiaWxpdHktY2FyZC10ZXh0IHsgZGlzcGxheTogZmxleDsgYWxpZ24taXRlbXM6IGNlbnRlcjsgZ2FwOiA4cHg7IGZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IHZhcigtLWZvbnQtd2VpZ2h0LXJlZ3VsYXIpOyBjb2xvcjogdmFyKC0tY29sb3ItYmxhY2spOyB9IC50cmFjZWFiaWxpdHktY2FyZC10ZXh0IHN2ZyB7IGZpbGw6IHZhcigtLWNvbG9yLWljb24pOyBzdHJva2U6IHZhcigtLWNvbG9yLWljb24pOyB9IC50cmFjZWFiaWxpdHktY2FyZC12aWV3LWRldGFpbHMgeyBkaXNwbGF5OiBmbGV4OyBqdXN0aWZ5LWNvbnRlbnQ6IGZsZXgtZW5kOyBnYXA6IDhweDsgfSAvKiBFbWlzc2lvbiBTY29yZWNhcmQgKi8gLmVtaXNzaW9uLXNjb3JlLWNhcmQgeyBkaXNwbGF5OiBmbGV4OyBmbGV4LWRpcmVjdGlvbjogY29sdW1uOyBnYXA6IDEycHg7IH0gLnNjb3JlIHsgZGlzcGxheTogZmxleDsgZmxleC1kaXJlY3Rpb246IGNvbHVtbjsgZ2FwOiA2cHg7IH0gLnNjb3JlLXVuaXQgeyBmb250LXNpemU6IDQwcHg7IGZvbnQtd2VpZ2h0OiB2YXIoLS1mb250LXdlaWdodC1ibGFjayk7IGxpbmUtaGVpZ2h0OiA0My4zM3B4OyBjb2xvcjogdmFyKC0tY29sb3ItcHJpbWFyeSk7IGxldHRlci1zcGFjaW5nOiAycHg7IH0gLnNjb3JlLW5hbWUgeyBmb250LXNpemU6IDE2cHg7IGZvbnQtd2VpZ2h0OiB2YXIoLS1mb250LXdlaWdodC1yZWd1bGFyKTsgY29sb3I6IHZhcigtLWNvbG9yLWdyYXktNjAwKTsgfSAvKiBEZWNsYXJhdGlvbnMgKi8gLmRlY2xhcmF0aW9ucyB7IGRpc3BsYXk6IGZsZXg7IGZsZXgtZGlyZWN0aW9uOiBjb2x1bW47IGdhcDogMTJweDsgfSAuY2FyZHMtY29uZm9ybWl0aWVzIHsgZGlzcGxheTogZmxleDsgZmxleC1kaXJlY3Rpb246IGNvbHVtbjsgZ2FwOiA4cHg7IH0gLmNhcmRzLWNvbmZvcm1pdHkgeyBkaXNwbGF5OiBmbGV4OyBmbGV4LWRpcmVjdGlvbjogY29sdW1uOyBnYXA6IDhweDsgcGFkZGluZzogMTZweDsgYmFja2dyb3VuZC1jb2xvcjogdmFyKC0tY29sb3Itd2hpdGUpOyBib3JkZXI6IDFweCBzb2xpZCB2YXIoLS1jb2xvci1ncmF5LTQwMCk7IGJvcmRlci1yYWRpdXM6IDRweDsgfSAuY2FyZHMtY29uZm9ybWl0eSBoZWFkZXIgeyBtYXJnaW46IDA7IH0gLmNvbmZvcm1hbmNlLWhlYWRlciB7IGRpc3BsYXk6IGZsZXg7IGp1c3RpZnktY29udGVudDogc3BhY2UtYmV0d2VlbjsgYWxpZ24taXRlbXM6IGNlbnRlcjsgZmxleC13cmFwOiB3cmFwOyBnYXA6IDVweDsgfSAuY29uZm9ybWFuY2Utc3RhdHVzIHsgZGlzcGxheTogZmxleDsgYWxpZ24taXRlbXM6IGNlbnRlcjsgZ2FwOiA0cHg7IH0gLmNvbmZvcm1hbmNlLWxhYmVsIHsgZm9udC1zaXplOiAxNHB4OyBmb250LXdlaWdodDogdmFyKC0tZm9udC13ZWlnaHQtcmVndWxhcik7IGNvbG9yOiB2YXIoLS1jb2xvci1ncmF5LTYwMCk7IH0gLnRhZ3MtVkMtYmFkZ2UtcmVkLCAudGFncy1WQy1iYWRnZS1ncmVlbiB7IHBhZGRpbmc6IDRweCA4cHg7IGJvcmRlci1yYWRpdXM6IDhweDsgZm9udC1zaXplOiAxNHB4OyBmb250LXdlaWdodDogdmFyKC0tZm9udC13ZWlnaHQtYm9sZCk7IH0gLnRhZ3MtVkMtYmFkZ2UtcmVkIHsgYmFja2dyb3VuZC1jb2xvcjogdmFyKC0tY29sb3ItYWNjZW50LWVycm9yKTsgY29sb3I6IHZhcigtLWNvbG9yLWdyYXktNjAwKTsgfSAudGFncy1WQy1iYWRnZS1ncmVlbiB7IGJhY2tncm91bmQtY29sb3I6IHZhcigtLWNvbG9yLWFjY2VudC1zdWNjZXNzKTsgY29sb3I6IHZhcigtLWNvbG9yLWdyYXktNjAwKTsgfSAuY29uZm9ybWl0eS1kZXRhaWxzIHsgZm9udC1zaXplOiAxOHB4OyBmb250LXdlaWdodDogdmFyKC0tZm9udC13ZWlnaHQtcmVndWxhcik7IGNvbG9yOiB2YXIoLS1jb2xvci1wcmltYXJ5KTsgfSAuY29uZm9ybWl0eS1pbmZvIHsgZGlzcGxheTogZmxleDsgZmxleC1kaXJlY3Rpb246IGNvbHVtbjsgZ2FwOiA4cHg7IH0gLmNvbmZvcm1pdHktaW5mbyBwIHsgZm9udC1zaXplOiAxNHB4OyBmb250LXdlaWdodDogdmFyKC0tZm9udC13ZWlnaHQtcmVndWxhcik7IGNvbG9yOiB2YXIoLS1jb2xvci1ncmF5LTYwMCk7IH0gLmRlY2xhcmVkLXZhbHVlcyB7IGRpc3BsYXk6IGZsZXg7IGZsZXgtZGlyZWN0aW9uOiBjb2x1bW47IGdhcDogNHB4OyB9IC5kZWNsYXJlZC12YWx1ZSBwIHsgZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogdmFyKC0tZm9udC13ZWlnaHQtcmVndWxhcik7IGNvbG9yOiB2YXIoLS1jb2xvci1ncmF5LTcwMCk7IH0gLmRlY2xhcmVkLXZhbHVlIHNwYW4geyBmb250LXNpemU6IDE0cHg7IGNvbG9yOiB2YXIoLS1jb2xvci1ncmF5LTYwMCk7IH0gLyogQ29tcG9zaXRpb24gKi8gLmNvbXBvc2l0aW9uLWJveCB7IGRpc3BsYXk6IGZsZXg7IGZsZXgtZGlyZWN0aW9uOiBjb2x1bW47IGdhcDogOHB4OyBtYXJnaW4tdG9wOiAxMnB4OyB9IC5jb21wb3NpdGlvbi1ib3gtaXRlbSB7IGRpc3BsYXk6IGdyaWQ7IGdyaWQtdGVtcGxhdGUtY29sdW1uczogMWZyIGF1dG87IGJvcmRlcjogMXB4IHNvbGlkIHZhcigtLWNvbG9yLWdyYXktNDAwKTsgYm9yZGVyLXJhZGl1czogNHB4OyBwYWRkaW5nOiAxNnB4OyB9IC5jb21wb3NpdGlvbi1maXJzdC1jb2x1bW4geyBkaXNwbGF5OiBncmlkOyBncmlkLXRlbXBsYXRlLWNvbHVtbnM6IDQwcHggMWZyOyBnYXA6IDEycHg7IH0gLmNvbXBvc2l0aW9uLXBlcmNlbnQgeyBmb250LXNpemU6IDE2cHg7IGZvbnQtd2VpZ2h0OiB2YXIoLS1mb250LXdlaWdodC1tZWRpdW0pOyBjb2xvcjogdmFyKC0tY29sb3ItYmxhY2spOyB9IC5jb21wb3NpdGlvbi10aXRsZSB7IGZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IHZhcigtLWZvbnQtd2VpZ2h0LWJvbGQpOyBjb2xvcjogdmFyKC0tY29sb3ItYmxhY2spOyB9IC5jb21wb3NpdGlvbi10YWcgeyBkaXNwbGF5OiBmbGV4OyBnYXA6IDRweDsgfSAuY29tcG9zaXRpb24tdGFnLWl0ZW0geyBmb250LXNpemU6IDE0cHg7IGZvbnQtd2VpZ2h0OiB2YXIoLS1mb250LXdlaWdodC1yZWd1bGFyKTsgY29sb3I6IHZhcigtLWNvbG9yLWJsYWNrKTsgYmFja2dyb3VuZC1jb2xvcjogdmFyKC0tY29sb3ItZ3JheS0xMDApOyBwYWRkaW5nOiAycHggNHB4OyB9IC5jb3VudHJ5LWNvZGUgeyBmb250LXNpemU6IDE0cHg7IGZvbnQtd2VpZ2h0OiB2YXIoLS1mb250LXdlaWdodC1yZWd1bGFyKTsgY29sb3I6IHZhcigtLWNvbG9yLWdyYXktNjAwKTsgfSAvKiBIaXN0b3J5ICovIC5oaXN0b3J5IHsgZGlzcGxheTogZmxleDsgZmxleC1kaXJlY3Rpb246IGNvbHVtbjsgZ2FwOiAxMnB4OyB9IC5oaXN0b3J5LXZhbHVlLWNoYWluIHsgZGlzcGxheTogZmxleDsgZmxleC1kaXJlY3Rpb246IGNvbHVtbjsgZ2FwOiA0cHg7IH0gLmhpc3RvcnktdmFsdWUtY2hhaW4gcCB7IGZvbnQtc2l6ZTogMThweDsgZm9udC13ZWlnaHQ6IHZhcigtLWZvbnQtd2VpZ2h0LXJlZ3VsYXIpOyBjb2xvcjogdmFyKC0tY29sb3ItcHJpbWFyeSk7IH0gLnZlcmlmaWVkLXJhdGlvIHsgYmFja2dyb3VuZC1jb2xvcjogdmFyKC0tY29sb3ItZ3JheS0xMDApOyBwYWRkaW5nOiAycHggNHB4OyB3aWR0aDogZml0LWNvbnRlbnQ7IH0gLmhpc3RvcnktaXRlbSB7IGRpc3BsYXk6IGdyaWQ7IGdyaWQtdGVtcGxhdGUtY29sdW1uczogMWZyIGF1dG87IHBhZGRpbmc6IDEwcHggMDsgYm9yZGVyLWJvdHRvbTogMXB4IHNvbGlkIHZhcigtLWNvbG9yLWdyYXktNDAwKTsgfSAuaGlzdG9yeS1pdGVtIHNwYW4geyBmb250LXNpemU6IDE2cHg7IGZvbnQtd2VpZ2h0OiB2YXIoLS1mb250LXdlaWdodC1yZWd1bGFyKTsgY29sb3I6IHZhcigtLWNvbG9yLWJsYWNrKTsgfSAuaGlzdG9yeS1pdGVtIGEgeyBmb250LXNpemU6IDE2cHg7IGZvbnQtd2VpZ2h0OiB2YXIoLS1mb250LXdlaWdodC1tZWRpdW0pOyBjb2xvcjogdmFyKC0tY29sb3ItZ3JheS03MDApOyB9IC8qIElzc3VlZCBCeSAqLyAuaXNzdWVkLWJ5IHsgZGlzcGxheTogZmxleDsgZmxleC1kaXJlY3Rpb246IGNvbHVtbjsgZ2FwOiAxMnB4OyB9IC8qIEZvb3RlciAqLyBmb290ZXIgeyBwYWRkaW5nOiAxNnB4IDE2cHggMzJweDsgYmFja2dyb3VuZC1jb2xvcjogdmFyKC0tY29sb3ItZ3JheS0xMDApOyB9IGZvb3RlciBwIHsgZm9udC1zaXplOiAxNHB4OyBmb250LXdlaWdodDogdmFyKC0tZm9udC13ZWlnaHQtcmVndWxhcik7IGNvbG9yOiB2YXIoLS1jb2xvci1ncmF5LTYwMCk7IH0gLyogTGlua3MgKi8gLmJsdWUtYm90dG9tLWxpbmUtdGhpY2sgeyB0ZXh0LWRlY29yYXRpb246IHVuZGVybGluZTsgdGV4dC1kZWNvcmF0aW9uLXRoaWNrbmVzczogMnB4OyB0ZXh0LWRlY29yYXRpb24tY29sb3I6IHZhcigtLWNvbG9yLWxpbmstdW5kZXJsaW5lLWRhcmspOyB0ZXh0LXVuZGVybGluZS1vZmZzZXQ6IDNweDsgY29sb3I6IHZhcigtLWNvbG9yLWdyYXktNzAwKTsgfSAuYmx1ZS1ib3R0b20tbGluZS10aGljay5kaXNhYmxlZCB7IHBvaW50ZXItZXZlbnRzOiBub25lOyBjdXJzb3I6IG5vdC1hbGxvd2VkOyB0ZXh0LWRlY29yYXRpb246IG5vbmU7IH0gLmJsdWUtYm90dG9tLWxpbmUtdGhpY2suZGlzYWJsZWQ6Zm9jdXMgeyBvdXRsaW5lOiBub25lOyB9IC5ncmF5LWJvdHRvbS1saW5lIHsgYm9yZGVyLWJvdHRvbTogMXB4IHNvbGlkIHZhcigtLWNvbG9yLWdyYXktNjAwKTsgdGV4dC1kZWNvcmF0aW9uOiBub25lOyBjb2xvcjogdmFyKC0tY29sb3ItZ3JheS02MDApOyB9IC8qIERlc2t0b3AgQWRqdXN0bWVudHMgKi8gQG1lZGlhIChtaW4td2lkdGg6IDEyMDBweCkgeyAuY29udGFpbmVyIHsgbWF4LXdpZHRoOiAxMjAwcHg7IH0gfSA8L3N0eWxlPiA8L2hlYWQ-IDxib2R5PiA8ZGl2IGNsYXNzPVwiY29udGFpbmVyXCI-IDxoZWFkZXIgY2xhc3M9XCJoZWFkZXJcIj4gPGRpdiBjbGFzcz1cImhlYWRlci1pbWFnZVwiPiA8cCBjbGFzcz1cImhlYWRlci1pbWFnZS10b3AtbGVmdFwiPlBST0RVQ1QgUEFTU1BPUlQ8L3A-IDxkaXYgY2xhc3M9XCJoZWFkZXItaW1hZ2UtYm90dG9tLWxlZnRcIj4gPGgxPnt7Y3JlZGVudGlhbFN1YmplY3QucHJvZHVjdC5uYW1lfX08L2gxPiA8L2Rpdj4gPC9kaXY-IDxkaXYgY2xhc3M9XCJoZWFkZXItYmF0Y2hcIj4ge3sjaWYgY3JlZGVudGlhbFN1YmplY3QucHJvZHVjdC5yZWdpc3RlcmVkSWR9fSA8ZGl2IGNsYXNzPVwiaGVhZGVyLWJhdGNoLWl0ZW1cIj4gPHN2ZyB3aWR0aD1cIjE0XCIgaGVpZ2h0PVwiMTRcIiB2aWV3Qm94PVwiMCAwIDE0IDE0XCIgZmlsbD1cIm5vbmVcIiB4bWxucz1cImh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnXCIgPiA8cGF0aCBkPVwiTTIuNDUgMy41QzIuMTcxNTIgMy41IDEuOTA0NDUgMy4zODkzOCAxLjcwNzU0IDMuMTkyNDZDMS41MTA2MiAyLjk5NTU1IDEuNCAyLjcyODQ4IDEuNCAyLjQ1QzEuNCAyLjE3MTUyIDEuNTEwNjIgMS45MDQ0NSAxLjcwNzU0IDEuNzA3NTRDMS45MDQ0NSAxLjUxMDYyIDIuMTcxNTIgMS40IDIuNDUgMS40QzIuNzI4NDggMS40IDIuOTk1NTUgMS41MTA2MiAzLjE5MjQ2IDEuNzA3NTRDMy4zODkzOCAxLjkwNDQ1IDMuNSAyLjE3MTUyIDMuNSAyLjQ1QzMuNSAyLjcyODQ4IDMuMzg5MzggMi45OTU1NSAzLjE5MjQ2IDMuMTkyNDZDMi45OTU1NSAzLjM4OTM4IDIuNzI4NDggMy41IDIuNDUgMy41Wk0xMy41ODcgNi43MDZMNy4yODcgMC40MDZDNy4wMzUgMC4xNTQgNi42ODUgMCA2LjMgMEgxLjRDMC42MjMgMCAwIDAuNjIzIDAgMS40VjYuM0MwIDYuNjg1IDAuMTU0IDcuMDM1IDAuNDEzIDcuMjg3TDYuNzA2IDEzLjU4N0M2Ljk2NSAxMy44MzkgNy4zMTUgMTQgNy43IDE0QzguMDg1IDE0IDguNDM1IDEzLjgzOSA4LjY4NyAxMy41ODdMMTMuNTg3IDguNjg3QzEzLjg0NiA4LjQzNSAxNCA4LjA4NSAxNCA3LjdDMTQgNy4zMDggMTMuODM5IDYuOTU4IDEzLjU4NyA2LjcwNlpcIiBmaWxsPVwidmFyKC0tY29sb3ItaWNvbilcIiBzdHJva2U9XCJ2YXIoLS1jb2xvci1pY29uKVwiIC8-IDwvc3ZnPiA8YSBocmVmPVwie3tjcmVkZW50aWFsU3ViamVjdC5wcm9kdWN0LmlkfX1cIiBjbGFzcz1cImJsdWUtYm90dG9tLWxpbmUtdGhpY2tcIiB0YXJnZXQ9XCJfYmxhbmtcIiA-SUQ6IHt7Y3JlZGVudGlhbFN1YmplY3QucHJvZHVjdC5yZWdpc3RlcmVkSWR9fTwvYSA-IDwvZGl2PiB7ey9pZn19IHt7I2lmIGNyZWRlbnRpYWxTdWJqZWN0LnByb2R1Y3QuYmF0Y2hOdW1iZXJ9fSA8ZGl2IGNsYXNzPVwiaGVhZGVyLWJhdGNoLWl0ZW1cIj4gPHN2ZyB3aWR0aD1cIjE0XCIgaGVpZ2h0PVwiMTRcIiB2aWV3Qm94PVwiMCAwIDE0IDE0XCIgZmlsbD1cIm5vbmVcIiB4bWxucz1cImh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnXCIgPiA8cGF0aCBkPVwiTTIuNDUgMy41QzIuMTcxNTIgMy41IDEuOTA0NDUgMy4zODkzOCAxLjcwNzU0IDMuMTkyNDZDMS41MTA2MiAyLjk5NTU1IDEuNCAyLjcyODQ4IDEuNCAyLjQ1QzEuNCAyLjE3MTUyIDEuNTEwNjIgMS45MDQ0NSAxLjcwNzU0IDEuNzA3NTRDMS45MDQ0NSAxLjUxMDYyIDIuMTcxNTIgMS40IDIuNDUgMS40QzIuNzI4NDggMS40IDIuOTk1NTUgMS41MTA2MiAzLjE5MjQ2IDEuNzA3NTRDMy4zODkzOCAxLjkwNDQ1IDMuNSAyLjE3MTUyIDMuNSAyLjQ1QzMuNSAyLjcyODQ4IDMuMzg5MzggMi45OTU1NSAzLjE5MjQ2IDMuMTkyNDZDMi45OTU1NSAzLjM4OTM4IDIuNzI4NDggMy41IDIuNDUgMy41Wk0xMy41ODcgNi43MDZMNy4yODcgMC40MDZDNy4wMzUgMC4xNTQgNi42ODUgMCA2LjMgMEgxLjRDMC42MjMgMCAwIDAuNjIzIDAgMS40VjYuM0MwIDYuNjg1IDAuMTU0IDcuMDM1IDAuNDEzIDcuMjg3TDYuNzA2IDEzLjU4N0M2Ljk2NSAxMy44MzkgNy4zMTUgMTQgNy43IDE0QzguMDg1IDE0IDguNDM1IDEzLjgzOSA4LjY4NyAxMy41ODdMMTMuNTg3IDguNjg3QzEzLjg0NiA4LjQzNSAxNCA4LjA4NSAxNCA3LjdDMTQgNy4zMDggMTMuODM5IDYuOTU4IDEzLjU4NyA2LjcwNlpcIiBmaWxsPVwidmFyKC0tY29sb3ItaWNvbilcIiBzdHJva2U9XCJ2YXIoLS1jb2xvci1pY29uKVwiIC8-IDwvc3ZnPiA8YT5CYXRjaDoge3tjcmVkZW50aWFsU3ViamVjdC5wcm9kdWN0LmJhdGNoTnVtYmVyfX08L2E-IDwvZGl2PiB7ey9pZn19IHt7I2lmIGNyZWRlbnRpYWxTdWJqZWN0LnByb2R1Y3Quc2VyaWFsTnVtYmVyfX0gPGRpdiBjbGFzcz1cImhlYWRlci1iYXRjaC1pdGVtXCI-IDxzdmcgd2lkdGg9XCIxNFwiIGhlaWdodD1cIjE0XCIgdmlld0JveD1cIjAgMCAxNCAxNFwiIGZpbGw9XCJub25lXCIgeG1sbnM9XCJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2Z1wiID4gPHBhdGggZD1cIk0yLjQ1IDMuNUMyLjE3MTUyIDMuNSAxLjkwNDQ1IDMuMzg5MzggMS43MDc1NCAzLjE5MjQ2QzEuNTEwNjIgMi45OTU1NSAxLjQgMi43Mjg0OCAxLjQgMi40NUMxLjQgMi4xNzE1MiAxLjUxMDYyIDEuOTA0NDUgMS43MDc1NCAxLjcwNzU0QzEuOTA0NDUgMS41MTA2MiAyLjE3MTUyIDEuNCAyLjQ1IDEuNEMyLjcyODQ4IDEuNCAyLjk5NTU1IDEuNTEwNjIgMy4xOTI0NiAxLjcwNzU0QzMuMzg5MzggMS45MDQ0NSAzLjUgMi4xNzE1MiAzLjUgMi40NUMzLjUgMi43Mjg0OCAzLjM4OTM4IDIuOTk1NTUgMy4xOTI0NiAzLjE5MjQ2QzIuOTk1NTUgMy4zODkzOCAyLjcyODQ4IDMuNSAyLjQ1IDMuNVpNMTMuNTg3IDYuNzA2TDcuMjg3IDAuNDA2QzcuMDM1IDAuMTU0IDYuNjg1IDAgNi4zIDBIMS40QzAuNjIzIDAgMCAwLjYyMyAwIDEuNFY2LjNDMCA2LjY4NSAwLjE1NCA3LjAzNSAwLjQxMyA3LjI4N0w2LjcwNiAxMy41ODdDNi45NjUgMTMuODM5IDcuMzE1IDE0IDcuNyAxNEM4LjA4NSAxNCA4LjQzNSAxMy44MzkgOC42ODcgMTMuNTg3TDEzLjU4NyA4LjY4N0MxMy44NDYgOC40MzUgMTQgOC4wODUgMTQgNy43QzE0IDcuMzA4IDEzLjgzOSA2Ljk1OCAxMy41ODcgNi43MDZaXCIgZmlsbD1cInZhcigtLWNvbG9yLWljb24pXCIgc3Ryb2tlPVwidmFyKC0tY29sb3ItaWNvbilcIiAvPiA8L3N2Zz4gPGE-U2VyaWFsOiB7e2NyZWRlbnRpYWxTdWJqZWN0LnByb2R1Y3Quc2VyaWFsTnVtYmVyfX08L2E-IDwvZGl2PiB7ey9pZn19IDwvZGl2PiA8L2hlYWRlcj4gPHNlY3Rpb24-IHt7I2lmIGNyZWRlbnRpYWxTdWJqZWN0LnByb2R1Y3QuZGVzY3JpcHRpb259fSA8ZGl2IGNsYXNzPVwiaW5mb3JtYXRpb24tdGV4dFwiPiB7e2NyZWRlbnRpYWxTdWJqZWN0LnByb2R1Y3QuZGVzY3JpcHRpb259fSA8L2Rpdj4ge3svaWZ9fSB7eyNpZiBjcmVkZW50aWFsU3ViamVjdC5wcm9kdWN0LmZ1cnRoZXJJbmZvcm1hdGlvbn19IDxkaXYgY2xhc3M9XCJpbmZvcm1hdGlvbi1zaG93LW1vcmVcIj4ge3sjZWFjaCBjcmVkZW50aWFsU3ViamVjdC5wcm9kdWN0LmZ1cnRoZXJJbmZvcm1hdGlvbn19IHt7I2lmIGxpbmtVUkx9fSB7eyNpZiBsaW5rTmFtZX19IDxhIGhyZWY9XCJ7e2xpbmtVUkx9fVwiIGNsYXNzPVwiYmx1ZS1ib3R0b20tbGluZS10aGlja1wiIHRhcmdldD1cIl9ibGFua1wiPiB7e2xpbmtOYW1lfX0gPC9hPiB7ey9pZn19IHt7L2lmfX0ge3svZWFjaH19IDwvZGl2PiB7ey9pZn19IDwvc2VjdGlvbj4ge3sjaWYgY3JlZGVudGlhbFN1YmplY3QucHJvZHVjdC5jaGFyYWN0ZXJpc3RpY3N9fSA8c2VjdGlvbiBjbGFzcz1cInByb2R1Y3Rpb25cIj4gPGRpdiBjbGFzcz1cInNlY3Rpb24tdGl0bGVcIj5DaGFyYWN0ZXJpc3RpY3M8L2Rpdj4gPGRpdiBjbGFzcz1cInRhYmxlXCI-IHt7I2VhY2ggY3JlZGVudGlhbFN1YmplY3QucHJvZHVjdC5jaGFyYWN0ZXJpc3RpY3N9fSA8ZGl2IGNsYXNzPVwidGFibGUtaXRlbVwiPiA8c3Bhbj57e0BrZXl9fTwvc3Bhbj4gPHAgY2xhc3M9XCJpdGVtLXZhbHVlXCI-e3t0aGlzfX08L3A-IDwvZGl2PiB7ey9lYWNofX0gPC9kaXY-IDwvc2VjdGlvbj4ge3svaWZ9fSA8c2VjdGlvbiBjbGFzcz1cInByb2R1Y3Rpb25cIj4gPGRpdiBjbGFzcz1cInNlY3Rpb24tdGl0bGVcIj5Qcm9kdWN0aW9uPC9kaXY-IDxkaXYgY2xhc3M9XCJ0YWJsZVwiPiB7eyNpZiBjcmVkZW50aWFsU3ViamVjdC5wcm9kdWN0LnByb2R1Y3RDYXRlZ29yeX19IDxkaXYgY2xhc3M9XCJ0YWJsZS1pdGVtXCI-IDxzcGFuPlByb2R1Y3QgY2F0ZWdvcnk8L3NwYW4-IDxwIGNsYXNzPVwiaXRlbS12YWx1ZVwiPiB7eyNlYWNoIGNyZWRlbnRpYWxTdWJqZWN0LnByb2R1Y3QucHJvZHVjdENhdGVnb3J5fX17e25hbWV9fXt7I3VubGVzcyBAbGFzdH19LCB7ey91bmxlc3N9fXt7L2VhY2h9fSA8L3A-IDwvZGl2PiB7ey9pZn19IHt7I2lmIGNyZWRlbnRpYWxTdWJqZWN0LnByb2R1Y3QucHJvZHVjZWRCeVBhcnR5fX0gPGRpdiBjbGFzcz1cInRhYmxlLWl0ZW1cIj4gPHNwYW4-UHJvZHVjZWQgYnk8L3NwYW4-IDxhIGhyZWY9XCJ7e2NyZWRlbnRpYWxTdWJqZWN0LnByb2R1Y3QucHJvZHVjZWRCeVBhcnR5LmlkfX1cIiBjbGFzcz1cImJsdWUtYm90dG9tLWxpbmUtdGhpY2tcIiB0YXJnZXQ9XCJfYmxhbmtcIiA-e3tjcmVkZW50aWFsU3ViamVjdC5wcm9kdWN0LnByb2R1Y2VkQnlQYXJ0eS5uYW1lfX08L2EgPiA8L2Rpdj4ge3svaWZ9fSB7eyNpZiBjcmVkZW50aWFsU3ViamVjdC5wcm9kdWN0LnByb2R1Y2VkQXRGYWNpbGl0eX19IHt7I3dpdGggY3JlZGVudGlhbFN1YmplY3QucHJvZHVjdC5wcm9kdWNlZEF0RmFjaWxpdHl9fSA8ZGl2IGNsYXNzPVwidGFibGUtaXRlbVwiPiA8c3Bhbj5Qcm9kdWNlZCBhdDwvc3Bhbj4gPHAgY2xhc3M9XCJpdGVtLXZhbHVlXCI-IDxhIGhyZWY9XCJ7e2lkfX1cIiBjbGFzcz1cImJsdWUtYm90dG9tLWxpbmUtdGhpY2tcIiB0YXJnZXQ9XCJfYmxhbmtcIj57e25hbWV9fTwvYT4gPC9wPiA8L2Rpdj4gPCEtLSBUT0RPOiBBZGQgbG9jYXRpb25JbmZvcm1hdGlvbiBhbmQgYWRkcmVzcyBiYWNrIHRvIHRoZSBEUFAgZGF0YSBtb2RlbCAtLT4ge3sjaWYgYWRkcmVzc319IDxkaXYgY2xhc3M9XCJ0YWJsZS1pdGVtXCI-IDxzcGFuPkxvY2F0aW9uPC9zcGFuPiA8cCBjbGFzcz1cIml0ZW0tdmFsdWVcIj4gPGEgaHJlZj1cInt7bG9jYXRpb25JbmZvcm1hdGlvbi5wbHVzQ29kZX19XCIgY2xhc3M9XCJibHVlLWJvdHRvbS1saW5lLXRoaWNrIHt7I3VubGVzcyBsb2NhdGlvbkluZm9ybWF0aW9uLnBsdXNDb2RlfX1kaXNhYmxlZHt7L3VubGVzc319XCIge3sjdW5sZXNzIGxvY2F0aW9uSW5mb3JtYXRpb24ucGx1c0NvZGV9fWFyaWEtZGlzYWJsZWQ9XCJ0cnVlXCIgdGFiaW5kZXg9XCItMVwie3svdW5sZXNzfX0gYXJpYS1sYWJlbD1cIlZpZXcgbG9jYXRpb24gb24gbWFwXCIgdGFyZ2V0PVwiX2JsYW5rXCIgPiB7eyNpZiBhZGRyZXNzLnN0cmVldEFkZHJlc3N9fXt7YWRkcmVzcy5zdHJlZXRBZGRyZXNzfX17ey9pZn19IHt7I2lmIGFkZHJlc3MuYWRkcmVzc0xvY2FsaXR5fX17e2FkZHJlc3MuYWRkcmVzc0xvY2FsaXR5fX17ey9pZn19IHt7I2lmIGFkZHJlc3MuYWRkcmVzc1JlZ2lvbn19e3thZGRyZXNzLmFkZHJlc3NSZWdpb259fXt7L2lmfX0ge3sjaWYgYWRkcmVzcy5wb3N0YWxDb2RlfX17e2FkZHJlc3MucG9zdGFsQ29kZX19e3svaWZ9fSA8L2E-IDwvcD4gPC9kaXY-IHt7L2lmfX0ge3svd2l0aH19IHt7L2lmfX0ge3sjaWYgY3JlZGVudGlhbFN1YmplY3QucHJvZHVjdC5wcm9kdWN0aW9uRGF0ZX19IDxkaXYgY2xhc3M9XCJ0YWJsZS1pdGVtXCI-IDxzcGFuPkRhdGUgcHJvZHVjZWQ8L3NwYW4-IDxwIGNsYXNzPVwiaXRlbS12YWx1ZVwiPnt7Y3JlZGVudGlhbFN1YmplY3QucHJvZHVjdC5wcm9kdWN0aW9uRGF0ZX19PC9wPiA8L2Rpdj4ge3svaWZ9fSB7eyNpZiBjcmVkZW50aWFsU3ViamVjdC5wcm9kdWN0LmNvdW50cnlPZlByb2R1Y3Rpb259fSA8ZGl2IGNsYXNzPVwidGFibGUtaXRlbVwiPiA8c3Bhbj5Db3VudHJ5PC9zcGFuPiA8cCBjbGFzcz1cIml0ZW0tdmFsdWVcIj57e2NyZWRlbnRpYWxTdWJqZWN0LnByb2R1Y3QuY291bnRyeU9mUHJvZHVjdGlvbn19PC9wPiA8L2Rpdj4ge3svaWZ9fSB7eyNpZiBjcmVkZW50aWFsU3ViamVjdC5wcm9kdWN0LmRpbWVuc2lvbnN9fSA8ZGl2IGNsYXNzPVwidGFibGUtaXRlbVwiPiA8c3Bhbj5EaW1lbnNpb25zPC9zcGFuPiA8cCBjbGFzcz1cIml0ZW0tdmFsdWVcIj4ge3sjaWYgY3JlZGVudGlhbFN1YmplY3QucHJvZHVjdC5kaW1lbnNpb25zLndlaWdodH19IDxzcGFuPldlaWdodDoge3tjcmVkZW50aWFsU3ViamVjdC5wcm9kdWN0LmRpbWVuc2lvbnMud2VpZ2h0LnZhbHVlfX17e2NyZWRlbnRpYWxTdWJqZWN0LnByb2R1Y3QuZGltZW5zaW9ucy53ZWlnaHQudW5pdH19PC9zcGFuPiB7ey9pZn19IHt7I2lmIGNyZWRlbnRpYWxTdWJqZWN0LnByb2R1Y3QuZGltZW5zaW9ucy5sZW5ndGh9fSA8c3Bhbj5MZW5ndGg6IHt7Y3JlZGVudGlhbFN1YmplY3QucHJvZHVjdC5kaW1lbnNpb25zLmxlbmd0aC52YWx1ZX19e3tjcmVkZW50aWFsU3ViamVjdC5wcm9kdWN0LmRpbWVuc2lvbnMubGVuZ3RoLnVuaXR9fTwvc3Bhbj4ge3svaWZ9fSB7eyNpZiBjcmVkZW50aWFsU3ViamVjdC5wcm9kdWN0LmRpbWVuc2lvbnMud2lkdGh9fSA8c3Bhbj5XaWR0aDoge3tjcmVkZW50aWFsU3ViamVjdC5wcm9kdWN0LmRpbWVuc2lvbnMud2lkdGgudmFsdWV9fXt7Y3JlZGVudGlhbFN1YmplY3QucHJvZHVjdC5kaW1lbnNpb25zLndpZHRoLnVuaXR9fTwvc3Bhbj4ge3svaWZ9fSB7eyNpZiBjcmVkZW50aWFsU3ViamVjdC5wcm9kdWN0LmRpbWVuc2lvbnMuaGVpZ2h0fX0gPHNwYW4-SGVpZ2h0OiB7e2NyZWRlbnRpYWxTdWJqZWN0LnByb2R1Y3QuZGltZW5zaW9ucy5oZWlnaHQudmFsdWV9fXt7Y3JlZGVudGlhbFN1YmplY3QucHJvZHVjdC5kaW1lbnNpb25zLmhlaWdodC51bml0fX08L3NwYW4-IHt7L2lmfX0ge3sjaWYgY3JlZGVudGlhbFN1YmplY3QucHJvZHVjdC5kaW1lbnNpb25zLnZvbHVtZX19IDxzcGFuPlZvbHVtZToge3tjcmVkZW50aWFsU3ViamVjdC5wcm9kdWN0LmRpbWVuc2lvbnMudm9sdW1lLnZhbHVlfX17e2NyZWRlbnRpYWxTdWJqZWN0LnByb2R1Y3QuZGltZW5zaW9ucy52b2x1bWUudW5pdH19PC9zcGFuPiB7ey9pZn19IDwvcD4gPC9kaXY-IHt7L2lmfX0gPC9kaXY-IDwvc2VjdGlvbj4ge3sjaWYgY3JlZGVudGlhbFN1YmplY3QuY2lyY3VsYXJpdHlTY29yZWNhcmR9fSA8c2VjdGlvbiBjbGFzcz1cInBhc3Nwb3J0XCI-IDxkaXY-IDxoMyBjbGFzcz1cInNlY3Rpb24tdGl0bGVcIj5DaXJjdWxhcml0eSBTY29yZWNhcmQ8L2gzPiA8cCBjbGFzcz1cInNlY3Rpb24tZGVzY3JpcHRpb25cIj4gVGhlIGNpcmN1bGFyaXR5IFNjb3JlY2FyZCBwcm92aWRlcyBhIHNpbXBsZSBoaWdoIGxldmVsIHN1bW1hcnkgb2YgY2lyY3VsYXJpdHkgcGVyZm9ybWFuY2Ugb2YgdGhlIHByb2R1Y3QuIDwvcD4gPC9kaXY-IDxkaXYgY2xhc3M9XCJwYXNzcG9ydC1ib3hcIj4ge3sjaWYgY3JlZGVudGlhbFN1YmplY3QuY2lyY3VsYXJpdHlTY29yZWNhcmQucmVjeWNsYWJsZUNvbnRlbnR9fSA8ZGl2IGNsYXNzPVwicGFzc3BvcnQtYm94LWl0ZW1cIj4gPGgzPnt7Y3JlZGVudGlhbFN1YmplY3QuY2lyY3VsYXJpdHlTY29yZWNhcmQucmVjeWNsYWJsZUNvbnRlbnR9fSU8L2gzPiA8cD5SZWN5Y2xhYmxlIGNvbnRlbnQ8L3A-IDwvZGl2PiB7ey9pZn19IHt7I2lmIGNyZWRlbnRpYWxTdWJqZWN0LmNpcmN1bGFyaXR5U2NvcmVjYXJkLnJlY3ljbGVkQ29udGVudH19IDxkaXYgY2xhc3M9XCJwYXNzcG9ydC1ib3gtaXRlbVwiPiA8aDM-e3tjcmVkZW50aWFsU3ViamVjdC5jaXJjdWxhcml0eVNjb3JlY2FyZC5yZWN5Y2xlZENvbnRlbnR9fSU8L2gzPiA8cD5SZWN5Y2xlZCBjb250ZW50PC9wPiA8L2Rpdj4ge3svaWZ9fSB7eyNpZiBjcmVkZW50aWFsU3ViamVjdC5jaXJjdWxhcml0eVNjb3JlY2FyZC51dGlsaXR5RmFjdG9yfX0gPGRpdiBjbGFzcz1cInBhc3Nwb3J0LWJveC1pdGVtXCI-IDxoMz57e2NyZWRlbnRpYWxTdWJqZWN0LmNpcmN1bGFyaXR5U2NvcmVjYXJkLnV0aWxpdHlGYWN0b3J9fTwvaDM-IDxwPlV0aWxpdHkgZmFjdG9yPC9wPiA8L2Rpdj4ge3svaWZ9fSB7eyNpZiBjcmVkZW50aWFsU3ViamVjdC5jaXJjdWxhcml0eVNjb3JlY2FyZC5tYXRlcmlhbENpcmN1bGFyaXR5SW5kaWNhdG9yfX0gPGRpdiBjbGFzcz1cInBhc3Nwb3J0LWJveC1pdGVtXCI-IDxoMz57e2NyZWRlbnRpYWxTdWJqZWN0LmNpcmN1bGFyaXR5U2NvcmVjYXJkLm1hdGVyaWFsQ2lyY3VsYXJpdHlJbmRpY2F0b3J9fTwvaDM-IDxwPk1hdGVyaWFsIGNpcmN1bGFyaXR5KjwvcD4gPC9kaXY-IHt7L2lmfX0gPC9kaXY-IHt7I2lmIGNyZWRlbnRpYWxTdWJqZWN0LmNpcmN1bGFyaXR5U2NvcmVjYXJkLm1hdGVyaWFsQ2lyY3VsYXJpdHlJbmRpY2F0b3J9fSA8ZGl2IGNsYXNzPVwicGFzc3BvcnQtYW5ub3RhdGlvblwiPiA8cD4qVGhlIE1hdGVyaWFsIENpcmN1bGFyaXR5IEluZGljYXRvciBwcm92aWRlcyBhbiBvdmVyYWxsIGNpcmN1bGFyaXR5IHNjb3JlIHdoaWNoIGlzIGEgZnVuY3Rpb24gb2YgYWxsIHRocmVlIG9mIHRoZSBlYXJsaWVyIG1lYXN1cmVzLjwvcD4gPC9kaXY-IHt7L2lmfX0gPGRpdiBjbGFzcz1cInRyYWNlYWJpbGl0eS1jYXJkc1wiPiB7eyNpZiBjcmVkZW50aWFsU3ViamVjdC5jaXJjdWxhcml0eVNjb3JlY2FyZC5yZWN5Y2xpbmdJbmZvcm1hdGlvbi5saW5rVVJMfX0gPGEgaHJlZj1cInt7Y3JlZGVudGlhbFN1YmplY3QuY2lyY3VsYXJpdHlTY29yZWNhcmQucmVjeWNsaW5nSW5mb3JtYXRpb24ubGlua1VSTH19XCIgY2xhc3M9XCJ0cmFjZWFiaWxpdHktY2FyZFwiIHRhcmdldD1cIl9ibGFua1wiPiA8ZGl2IGNsYXNzPVwidHJhY2VhYmlsaXR5LWNhcmQtdGV4dFwiPiA8c3ZnIHdpZHRoPVwiMjRcIiBoZWlnaHQ9XCIyNFwiIHZpZXdCb3g9XCIwIDAgMjQgMjRcIiBmaWxsPVwibm9uZVwiIHhtbG5zPVwiaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmdcIiA-IDxwYXRoIGQ9XCJNMjEuODIgMTUuNDJMMTkuMzIgMTkuNzVDMTguODMgMjAuNjEgMTcuOTIgMjEuMDYgMTcgMjFIMTVWMjNMMTIuNSAxOC41TDE1IDE0VjE2SDE3LjgyTDE1LjYgMTIuMTVMMTkuOTMgOS42NUwyMS43MyAxMi43N0MyMi4yNSAxMy41NCAyMi4zMiAxNC41NyAyMS44MiAxNS40MlpNOS4yMTAwMyAzLjA2SDE0LjIxQzE1LjE5IDMuMDYgMTYuMDQgMy42MyAxNi40NSA0LjQ1TDE3LjQ1IDYuMTlMMTkuMTggNS4xOUwxNi41NCA5LjZMMTEuMzkgOS42OUwxMy4xMiA4LjY5TDExLjcxIDYuMjRMOS41MDAwMyAxMC4wOUw1LjE2MDAzIDcuNTlMNi45NjAwMyA0LjQ3QzcuMzcwMDMgMy42NCA4LjIyMDAzIDMuMDYgOS4yMTAwMyAzLjA2Wk01LjA1MDAzIDE5Ljc2TDIuNTUwMDMgMTUuNDNDMi4wNjAwMyAxNC41OCAyLjEzMDAzIDEzLjU2IDIuNjQwMDMgMTIuNzlMMy42NDAwMyAxMS4wNkwxLjkxMDAzIDEwLjA2TDcuMDUwMDMgMTAuMTRMOS43MDAwMyAxNC41Nkw3Ljk3MDAzIDEzLjU2TDYuNTYwMDMgMTZIMTFWMjFINy40MDAwM0M2LjkzMTU0IDIxLjAzMzkgNi40NjI5MyAyMC45MzU3IDYuMDQ3NSAyMC43MTY1QzUuNjMyMDYgMjAuNDk3MyA1LjI4NjQ4IDIwLjE2NTkgNS4wNTAwMyAxOS43NlpcIiBmaWxsPVwidmFyKC0tY29sb3ItaWNvbilcIiBzdHJva2U9XCJ2YXIoLS1jb2xvci1pY29uKVwiIC8-IDwvc3ZnPiA8cD5SZWN5Y2xpbmcgaW5zdHJ1Y3Rpb25zPC9wPiA8L2Rpdj4gPGRpdiBjbGFzcz1cInRyYWNlYWJpbGl0eS1jYXJkLXZpZXctZGV0YWlsc1wiPiA8c3ZnIHdpZHRoPVwiOVwiIGhlaWdodD1cIjE2XCIgdmlld0JveD1cIjAgMCA5IDE2XCIgZmlsbD1cIm5vbmVcIiB4bWxucz1cImh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnXCIgPiA8cGF0aCBkPVwiTTEgMUw4IDhMMSAxNVwiIHN0cm9rZT1cInZhcigtLWNvbG9yLWljb24pXCIgc3Ryb2tlLXdpZHRoPVwiMlwiIHN0cm9rZS1saW5lY2FwPVwicm91bmRcIiBzdHJva2UtbGluZWpvaW49XCJyb3VuZFwiIC8-IDwvc3ZnPiA8L2Rpdj4gPC9hPiB7ey9pZn19IHt7I2lmIGNyZWRlbnRpYWxTdWJqZWN0LmNpcmN1bGFyaXR5U2NvcmVjYXJkLnJlcGFpckluZm9ybWF0aW9uLmxpbmtVUkx9fSA8YSBocmVmPVwie3tjcmVkZW50aWFsU3ViamVjdC5jaXJjdWxhcml0eVNjb3JlY2FyZC5yZXBhaXJJbmZvcm1hdGlvbi5saW5rVVJMfX1cIiBjbGFzcz1cInRyYWNlYWJpbGl0eS1jYXJkXCIgdGFyZ2V0PVwiX2JsYW5rXCI-IDxkaXYgY2xhc3M9XCJ0cmFjZWFiaWxpdHktY2FyZC10ZXh0XCI-IDxzdmcgd2lkdGg9XCIyNFwiIGhlaWdodD1cIjI0XCIgdmlld0JveD1cIjAgMCAyNCAyNFwiIGZpbGw9XCJub25lXCIgeG1sbnM9XCJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2Z1wiID4gPHBhdGggZD1cIk0xOC44NSAyMS45NzVDMTguNzE2NyAyMS45NzUgMTguNTkxNyAyMS45NTQzIDE4LjQ3NSAyMS45MTNDMTguMzU4MyAyMS44NzE3IDE4LjI1IDIxLjgwMDcgMTguMTUgMjEuN0wxMy4wNSAxNi42QzEyLjk1IDE2LjUgMTIuODc5IDE2LjM5MTcgMTIuODM3IDE2LjI3NUMxMi43OTUgMTYuMTU4MyAxMi43NzQzIDE2LjAzMzMgMTIuNzc1IDE1LjlDMTIuNzc1NyAxNS43NjY3IDEyLjc5NjcgMTUuNjQxNyAxMi44MzggMTUuNTI1QzEyLjg3OTMgMTUuNDA4MyAxMi45NSAxNS4zIDEzLjA1IDE1LjJMMTUuMTc1IDEzLjA3NUMxNS4yNzUgMTIuOTc1IDE1LjM4MzMgMTIuOTA0MyAxNS41IDEyLjg2M0MxNS42MTY3IDEyLjgyMTcgMTUuNzQxNyAxMi44MDA3IDE1Ljg3NSAxMi44QzE2LjAwODMgMTIuNzk5MyAxNi4xMzMzIDEyLjgyMDMgMTYuMjUgMTIuODYzQzE2LjM2NjcgMTIuOTA1NyAxNi40NzUgMTIuOTc2MyAxNi41NzUgMTMuMDc1TDIxLjY3NSAxOC4xNzVDMjEuNzc1IDE4LjI3NSAyMS44NDYgMTguMzgzMyAyMS44ODggMTguNUMyMS45MyAxOC42MTY3IDIxLjk1MDcgMTguNzQxNyAyMS45NSAxOC44NzVDMjEuOTQ5MyAxOS4wMDgzIDIxLjkyODcgMTkuMTMzMyAyMS44ODggMTkuMjVDMjEuODQ3MyAxOS4zNjY3IDIxLjc3NjMgMTkuNDc1IDIxLjY3NSAxOS41NzVMMTkuNTUgMjEuN0MxOS40NSAyMS44IDE5LjM0MTcgMjEuODcxIDE5LjIyNSAyMS45MTNDMTkuMTA4MyAyMS45NTUgMTguOTgzMyAyMS45NzU3IDE4Ljg1IDIxLjk3NVpNMTguODUgMTkuNkwxOS41NzUgMTguODc1TDE1LjkgMTUuMkwxNS4xNzUgMTUuOTI1TDE4Ljg1IDE5LjZaTTUuMTI1IDIyQzQuOTkxNjcgMjIgNC44NjI2NyAyMS45NzUgNC43MzggMjEuOTI1QzQuNjEzMzMgMjEuODc1IDQuNTAwNjcgMjEuOCA0LjQgMjEuN0wyLjMgMTkuNkMyLjIgMTkuNSAyLjEyNSAxOS4zODczIDIuMDc1IDE5LjI2MkMyLjAyNSAxOS4xMzY3IDIgMTkuMDA4IDIgMTguODc2QzIgMTguNzQ0IDIuMDI1IDE4LjYxOSAyLjA3NSAxOC41MDFDMi4xMjUgMTguMzgzIDIuMiAxOC4yNzQ3IDIuMyAxOC4xNzZMNy42IDEyLjg3Nkg5LjcyNUwxMC41NzUgMTIuMDI2TDYuNDUgNy45SDUuMDI1TDIgNC44NzVMNC44MjUgMi4wNUw3Ljg1IDUuMDc1VjYuNUwxMS45NzUgMTAuNjI1TDE0Ljg3NSA3LjcyNUwxMy44IDYuNjVMMTUuMiA1LjI1SDEyLjM3NUwxMS42NzUgNC41NUwxNS4yMjUgMUwxNS45MjUgMS43VjQuNTI1TDE3LjMyNSAzLjEyNUwyMC44NzUgNi42NzVDMjEuMTU4MyA2Ljk1ODMzIDIxLjM3NSA3LjI3OTMzIDIxLjUyNSA3LjYzOEMyMS42NzUgNy45OTY2NyAyMS43NSA4LjM3NTY3IDIxLjc1IDguNzc1QzIxLjc1IDkuMTc0MzMgMjEuNjc1IDkuNTU3NjcgMjEuNTI1IDkuOTI1QzIxLjM3NSAxMC4yOTIzIDIxLjE1ODMgMTAuNjE3MyAyMC44NzUgMTAuOUwxOC43NSA4Ljc3NUwxNy4zNSAxMC4xNzVMMTYuMyA5LjEyNUwxMS4xMjUgMTQuM1YxNi40TDUuODI1IDIxLjdDNS43MjUgMjEuOCA1LjYxNjY3IDIxLjg3NSA1LjUgMjEuOTI1QzUuMzgzMzMgMjEuOTc1IDUuMjU4MzMgMjIgNS4xMjUgMjJaTTUuMTI1IDE5LjZMOS4zNzUgMTUuMzVWMTQuNjI1SDguNjVMNC40IDE4Ljg3NUw1LjEyNSAxOS42Wk01LjEyNSAxOS42TDQuNCAxOC44NzVMNC43NzUgMTkuMjI1TDUuMTI1IDE5LjZaXCIgZmlsbD1cInZhcigtLWNvbG9yLWljb24pXCIgc3Ryb2tlPVwidmFyKC0tY29sb3ItaWNvbilcIiAvPiA8L3N2Zz4gPHA-UmVwYWlyIGluc3RydWN0aW9uczwvcD4gPC9kaXY-IDxkaXYgY2xhc3M9XCJ0cmFjZWFiaWxpdHktY2FyZC12aWV3LWRldGFpbHNcIj4gPHN2ZyB3aWR0aD1cIjlcIiBoZWlnaHQ9XCIxNlwiIHZpZXdCb3g9XCIwIDAgOSAxNlwiIGZpbGw9XCJub25lXCIgeG1sbnM9XCJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2Z1wiID4gPHBhdGggZD1cIk0xIDFMOCA4TDEgMTVcIiBzdHJva2U9XCJ2YXIoLS1jb2xvci1pY29uKVwiIHN0cm9rZS13aWR0aD1cIjJcIiBzdHJva2UtbGluZWNhcD1cInJvdW5kXCIgc3Ryb2tlLWxpbmVqb2luPVwicm91bmRcIiAvPiA8L3N2Zz4gPC9kaXY-IDwvYT4ge3svaWZ9fSA8L2Rpdj4gPC9zZWN0aW9uPiB7ey9pZn19IHt7I2lmIGNyZWRlbnRpYWxTdWJqZWN0LmVtaXNzaW9uc1Njb3JlY2FyZH19IDxzZWN0aW9uIGNsYXNzPVwiZW1pc3Npb24tc2NvcmUtY2FyZFwiPiA8ZGl2PiA8aDMgY2xhc3M9XCJzZWN0aW9uLXRpdGxlXCI-RW1pc3Npb25zIFNjb3JlY2FyZDwvaDM-IDxwIGNsYXNzPVwic2VjdGlvbi1kZXNjcmlwdGlvblwiPiBUaGUgRW1pc3Npb25zIFNjb3JlY2FyZCBnaXZlcyBhIGNsZWFyIHNuYXBzaG90IG9mIHRoZSBwcm9kdWN0J3MgZ3JlZW5ob3VzZSBnYXMgKEdIRykgZW1pc3Npb25zIHBlcmZvcm1hbmNlLCBwcm92aWRpbmcgYSBzaW5nbGUgaW5kaWNhdG9yIHRvIGFzc2VzcyBpdHMgb3ZlcmFsbCBlbnZpcm9ubWVudGFsIGltcGFjdC4gPC9wPiA8L2Rpdj4gPGRpdiBjbGFzcz1cInNjb3JlXCI-IDxwIGNsYXNzPVwic2NvcmUtdW5pdFwiPiB7e2NyZWRlbnRpYWxTdWJqZWN0LmVtaXNzaW9uc1Njb3JlY2FyZC5jYXJib25Gb290cHJpbnR9fXt7Y3JlZGVudGlhbFN1YmplY3QuZW1pc3Npb25zU2NvcmVjYXJkLmRlY2xhcmVkVW5pdH19IDwvcD4gPHAgY2xhc3M9XCJzY29yZS1uYW1lXCI-Q28yRXE8L3A-IDwvZGl2PiA8ZGl2IGNsYXNzPVwidGFibGVcIj4gPGRpdiBjbGFzcz1cInRhYmxlLWl0ZW1cIj4gPHNwYW4-U2NvcGUgaW5jbHVkZXM8L3NwYW4-IDxwIGNsYXNzPVwiaXRlbS12YWx1ZVwiPnt7Y3JlZGVudGlhbFN1YmplY3QuZW1pc3Npb25zU2NvcmVjYXJkLm9wZXJhdGlvbmFsU2NvcGV9fTwvcD4gPC9kaXY-IDxkaXYgY2xhc3M9XCJ0YWJsZS1pdGVtXCI-IDxzcGFuPlByaW1hcnkgc291cmNlZCByYXRpbyo8L3NwYW4-IDxwIGNsYXNzPVwiaXRlbS12YWx1ZVwiPnt7Y3JlZGVudGlhbFN1YmplY3QuZW1pc3Npb25zU2NvcmVjYXJkLnByaW1hcnlTb3VyY2VkUmF0aW99fSUgcHJpbWFyeSBzb3VyY2VzPC9wPiA8L2Rpdj4ge3sjaWYgY3JlZGVudGlhbFN1YmplY3QuZW1pc3Npb25zU2NvcmVjYXJkLnJlcG9ydGluZ1N0YW5kYXJkfX0ge3sjaWYgY3JlZGVudGlhbFN1YmplY3QuZW1pc3Npb25zU2NvcmVjYXJkLnJlcG9ydGluZ1N0YW5kYXJkLm5hbWV9fSA8ZGl2IGNsYXNzPVwidGFibGUtaXRlbVwiPiA8c3Bhbj5SZXBvcnRpbmcgc3RhbmRhcmQ8L3NwYW4-IHt7I2lmIGNyZWRlbnRpYWxTdWJqZWN0LmVtaXNzaW9uc1Njb3JlY2FyZC5yZXBvcnRpbmdTdGFuZGFyZC5pZH19IDxhIGhyZWY9XCJ7e2NyZWRlbnRpYWxTdWJqZWN0LmVtaXNzaW9uc1Njb3JlY2FyZC5yZXBvcnRpbmdTdGFuZGFyZC5pZH19XCIgY2xhc3M9XCJibHVlLWJvdHRvbS1saW5lLXRoaWNrXCIgdGFyZ2V0PVwiX2JsYW5rXCI-IHt7Y3JlZGVudGlhbFN1YmplY3QuZW1pc3Npb25zU2NvcmVjYXJkLnJlcG9ydGluZ1N0YW5kYXJkLm5hbWV9fSA8L2E-IHt7ZWxzZX19IDxwIGNsYXNzPVwiaXRlbS12YWx1ZVwiPiB7e2NyZWRlbnRpYWxTdWJqZWN0LmVtaXNzaW9uc1Njb3JlY2FyZC5yZXBvcnRpbmdTdGFuZGFyZC5uYW1lfX0gPC9wPiB7ey9pZn19IDwvZGl2PiB7ey9pZn19IHt7I2lmIGNyZWRlbnRpYWxTdWJqZWN0LmVtaXNzaW9uc1Njb3JlY2FyZC5yZXBvcnRpbmdTdGFuZGFyZC5pc3N1ZURhdGV9fSA8ZGl2IGNsYXNzPVwidGFibGUtaXRlbVwiPiA8c3Bhbj5Jc3N1ZSBkYXRlPC9zcGFuPiA8cCBjbGFzcz1cIml0ZW0tdmFsdWVcIj57e2NyZWRlbnRpYWxTdWJqZWN0LmVtaXNzaW9uc1Njb3JlY2FyZC5yZXBvcnRpbmdTdGFuZGFyZC5pc3N1ZURhdGV9fTwvcD4gPC9kaXY-IHt7L2lmfX0ge3svaWZ9fSA8L2Rpdj4gPGRpdiBjbGFzcz1cInBhc3Nwb3J0LWFubm90YXRpb25cIj4gPHA-KlRoZSBQcmltYXJ5IFNvdXJjZWQgUmF0aW8gc2hvd3MgdGhlIHBlcmNlbnRhZ2Ugb2Ygc2NvcGUgMyBlbWlzc2lvbnMgZGF0YSB0aGF0IGlzIGRpcmVjdGx5IGNvbGxlY3RlZCBmcm9tIGFjdHVhbCBzb3VyY2VzLCByYXRoZXIgdGhhbiBiZWluZyBiYXNlZCBvbiBlc3RpbWF0ZXMuPC9wPiA8L2Rpdj4gPC9zZWN0aW9uPiB7ey9pZn19IHt7I2lmIGNyZWRlbnRpYWxTdWJqZWN0LmNvbmZvcm1pdHlDbGFpbX19IDxzZWN0aW9uIGNsYXNzPVwiZGVjbGFyYXRpb25zXCI-IDxkaXY-IDxoMyBjbGFzcz1cInNlY3Rpb24tdGl0bGVcIj5EZWNsYXJhdGlvbnM8L2gzPiA8L2Rpdj4gPGRpdiBjbGFzcz1cImNhcmRzLWNvbmZvcm1pdGllc1wiPiB7eyNlYWNoIGNyZWRlbnRpYWxTdWJqZWN0LmNvbmZvcm1pdHlDbGFpbX19IDxhcnRpY2xlIGNsYXNzPVwiY2FyZHMtY29uZm9ybWl0eVwiPiA8ZGl2IGNsYXNzPVwiY29uZm9ybWFuY2UtaGVhZGVyXCI-IDxkaXYgY2xhc3M9XCJjb25mb3JtYW5jZS1zdGF0dXNcIj4gPHNwYW4gY2xhc3M9XCJjb25mb3JtYW5jZS1sYWJlbFwiPkNvbmZvcm1hbmNlOjwvc3Bhbj4gPGRpdiBjbGFzcz1cInt7I2lmIGNvbmZvcm1hbmNlfX10YWdzLVZDLWJhZGdlLWdyZWVue3tlbHNlfX10YWdzLVZDLWJhZGdlLXJlZHt7L2lmfX1cIj4ge3sjaWYgY29uZm9ybWFuY2V9fVllc3t7ZWxzZX19Tm97ey9pZn19IDwvZGl2PiA8L2Rpdj4ge3sjaWYgYXNzZXNzbWVudERhdGV9fSA8c3BhbiBjbGFzcz1cImNvbmZvcm1hbmNlLWxhYmVsXCI-QXNzZXNzZWQ6IHt7YXNzZXNzbWVudERhdGV9fTwvc3Bhbj4ge3svaWZ9fSA8L2Rpdj4ge3sjaWYgY29uZm9ybWl0eUV2aWRlbmNlLmxpbmtOYW1lfX0gPGRpdiBjbGFzcz1cImNvbmZvcm1pdHktZGV0YWlsc1wiPnt7Y29uZm9ybWl0eUV2aWRlbmNlLmxpbmtOYW1lfX08L2Rpdj4ge3svaWZ9fSA8ZGl2IGNsYXNzPVwiY29uZm9ybWl0eS1pbmZvXCI-IHt7I2lmIHJlZmVyZW5jZVJlZ3VsYXRpb259fSA8cD4ge3sjaWYgcmVmZXJlbmNlUmVndWxhdGlvbi5uYW1lfX0ge3tyZWZlcmVuY2VSZWd1bGF0aW9uLm5hbWV9fSB7eyNpZiByZWZlcmVuY2VSZWd1bGF0aW9uLmp1cmlzZGljdGlvbkNvdW50cnl9fSBhZG1pbmlzdGVyZWQgaW4ge3tyZWZlcmVuY2VSZWd1bGF0aW9uLmp1cmlzZGljdGlvbkNvdW50cnl9fSB7ey9pZn19IHt7I2lmIHJlZmVyZW5jZVJlZ3VsYXRpb24uYWRtaW5pc3RlcmVkQnl9fSBhZG1pbmlzdGVyZWQgYnkgPGEgaHJlZj1cInt7cmVmZXJlbmNlUmVndWxhdGlvbi5hZG1pbmlzdGVyZWRCeS5pZH19XCIgY2xhc3M9XCJncmF5LWJvdHRvbS1saW5lXCIgdGFyZ2V0PVwiX2JsYW5rXCIgPiB7e3JlZmVyZW5jZVJlZ3VsYXRpb24uYWRtaW5pc3RlcmVkQnkubmFtZX19IDwvYT4ge3svaWZ9fSB7e2Vsc2UgaWYgcmVmZXJlbmNlUmVndWxhdGlvbi5qdXJpc2RpY3Rpb25Db3VudHJ5fX0gQWRtaW5pc3RlcmVkIGluIHt7cmVmZXJlbmNlUmVndWxhdGlvbi5qdXJpc2RpY3Rpb25Db3VudHJ5fX0ge3sjaWYgcmVmZXJlbmNlUmVndWxhdGlvbi5hZG1pbmlzdGVyZWRCeX19IGJ5IDxhIGhyZWY9XCJ7e3JlZmVyZW5jZVJlZ3VsYXRpb24uYWRtaW5pc3RlcmVkQnkuaWR9fVwiIGNsYXNzPVwiZ3JheS1ib3R0b20tbGluZVwiIHRhcmdldD1cIl9ibGFua1wiID4ge3tyZWZlcmVuY2VSZWd1bGF0aW9uLmFkbWluaXN0ZXJlZEJ5Lm5hbWV9fSA8L2E-IHt7L2lmfX0ge3tlbHNlIGlmIHJlZmVyZW5jZVJlZ3VsYXRpb24uYWRtaW5pc3RlcmVkQnl9fSBBZG1pbmlzdGVyZWQgYnkgPGEgaHJlZj1cInt7cmVmZXJlbmNlUmVndWxhdGlvbi5hZG1pbmlzdGVyZWRCeS5pZH19XCIgY2xhc3M9XCJncmF5LWJvdHRvbS1saW5lXCIgdGFyZ2V0PVwiX2JsYW5rXCIgPiB7e3JlZmVyZW5jZVJlZ3VsYXRpb24uYWRtaW5pc3RlcmVkQnkubmFtZX19IDwvYT4ge3svaWZ9fSA8L3A-IHt7L2lmfX0ge3sjaWYgcmVmZXJlbmNlU3RhbmRhcmR9fSB7eyNpZiByZWZlcmVuY2VTdGFuZGFyZC5uYW1lfX0gPHA-IHt7cmVmZXJlbmNlU3RhbmRhcmQubmFtZX19IHt7I2lmIHJlZmVyZW5jZVN0YW5kYXJkLmlzc3VpbmdQYXJ0eX19IGlzc3VlZCBieSA8YSBocmVmPVwie3tyZWZlcmVuY2VTdGFuZGFyZC5pc3N1aW5nUGFydHkuaWR9fVwiIGNsYXNzPVwiZ3JheS1ib3R0b20tbGluZVwiIHRhcmdldD1cIl9ibGFua1wiID4ge3tyZWZlcmVuY2VTdGFuZGFyZC5pc3N1aW5nUGFydHkubmFtZX19IDwvYT4ge3svaWZ9fSA8L3A-IHt7L2lmfX0ge3svaWZ9fSA8L2Rpdj4ge3sjaWYgZGVjbGFyZWRWYWx1ZX19IDxkaXYgY2xhc3M9XCJkZWNsYXJlZC12YWx1ZXNcIj4ge3sjZWFjaCBkZWNsYXJlZFZhbHVlfX0gPGRpdiBjbGFzcz1cImRlY2xhcmVkLXZhbHVlXCI-IDxwPnt7bWV0cmljTmFtZX19IGlzIHt7bWV0cmljVmFsdWUudmFsdWV9fXt7bWV0cmljVmFsdWUudW5pdH19PC9wPiB7eyNpZiBzY29yZX19IDxzcGFuPiBTY29yZToge3tzY29yZX19e3sjaWYgYWNjdXJhY3l9fSB8IEFjY3VyYWN5OiB7e2FjY3VyYWN5fX17ey9pZn19IDwvc3Bhbj4ge3tlbHNlIGlmIGFjY3VyYWN5fX0gPHNwYW4-IEFjY3VyYWN5OiB7e2FjY3VyYWN5fX0gPC9zcGFuPiB7ey9pZn19IDwvZGl2PiB7ey9lYWNofX0gPC9kaXY-IHt7L2lmfX0ge3sjaWYgY29uZm9ybWl0eUV2aWRlbmNlLmxpbmtVUkx9fSA8YSBocmVmPVwie3tjb25mb3JtaXR5RXZpZGVuY2UubGlua1VSTH19XCIgY2xhc3M9XCJ0cmFjZWFiaWxpdHktY2FyZFwiIHRhcmdldD1cIl9ibGFua1wiPiA8ZGl2IGNsYXNzPVwidHJhY2VhYmlsaXR5LWNhcmQtdGV4dFwiPiA8c3ZnIHdpZHRoPVwiMjRcIiBoZWlnaHQ9XCIyNFwiIHZpZXdCb3g9XCIwIDAgMjQgMjRcIiBmaWxsPVwibm9uZVwiIHhtbG5zPVwiaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmdcIiA-IDxwYXRoIGQ9XCJNNSAyMUM0LjQ1IDIxIDMuOTc5MzMgMjAuODA0MyAzLjU4OCAyMC40MTNDMy4xOTY2NyAyMC4wMjE3IDMuMDAwNjcgMTkuNTUwNyAzIDE5VjVDMyA0LjQ1IDMuMTk2IDMuOTc5MzMgMy41ODggMy41ODhDMy45OCAzLjE5NjY3IDQuNDUwNjcgMy4wMDA2NyA1IDNIMTlDMTkuNTUgMyAyMC4wMjEgMy4xOTYgMjAuNDEzIDMuNTg4QzIwLjgwNSAzLjk4IDIxLjAwMDcgNC40NTA2NyAyMSA1VjE5QzIxIDE5LjU1IDIwLjgwNDMgMjAuMDIxIDIwLjQxMyAyMC40MTNDMjAuMDIxNyAyMC44MDUgMTkuNTUwNyAyMS4wMDA3IDE5IDIxSDVaTTUgNVYxOUgxOVY1SDE3VjEyTDE0LjUgMTAuNUwxMiAxMlY1SDVaXCIgZmlsbD1cInZhcigtLWNvbG9yLWljb24pXCIgPjwvcGF0aD4gPC9zdmc-IDxwPkV2aWRlbmNlPC9wPiA8L2Rpdj4gPGRpdiBjbGFzcz1cInRyYWNlYWJpbGl0eS1jYXJkLXZpZXctZGV0YWlsc1wiPiA8c3ZnIHdpZHRoPVwiOVwiIGhlaWdodD1cIjE2XCIgdmlld0JveD1cIjAgMCA5IDE2XCIgZmlsbD1cIm5vbmVcIiB4bWxucz1cImh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnXCIgPiA8cGF0aCBkPVwiTTEgMUw4IDhMMSAxNVwiIHN0cm9rZT1cInZhcigtLWNvbG9yLWljb24pXCIgc3Ryb2tlLXdpZHRoPVwiMlwiIHN0cm9rZS1saW5lY2FwPVwicm91bmRcIiBzdHJva2UtbGluZWpvaW49XCJyb3VuZFwiIC8-IDwvc3ZnPiA8L2Rpdj4gPC9hPiB7ey9pZn19IDwvYXJ0aWNsZT4ge3svZWFjaH19IDwvZGl2PiA8L3NlY3Rpb24-IHt7L2lmfX0ge3sjaWYgY3JlZGVudGlhbFN1YmplY3QubWF0ZXJpYWxzUHJvdmVuYW5jZX19IHt7I2lmIGNyZWRlbnRpYWxTdWJqZWN0Lm1hdGVyaWFsc1Byb3ZlbmFuY2UuMC5tYXNzRnJhY3Rpb259fSB7eyNpZiBjcmVkZW50aWFsU3ViamVjdC5tYXRlcmlhbHNQcm92ZW5hbmNlLjAubmFtZX19IDxzZWN0aW9uIGNsYXNzPVwiY29tcG9zaXRpb25cIj4gPGRpdj4gPGgzIGNsYXNzPVwic2VjdGlvbi10aXRsZVwiPlByb2R1Y3QgQ29tcG9zaXRpb248L2gzPiA8cCBjbGFzcz1cInNlY3Rpb24tZGVzY3JpcHRpb25cIj4gQSBjb21wbGV0ZSBsaXN0IG9mIG1hdGVyaWFscyB0aGF0IG1ha2UgdXAgdGhlIGNvbXBvc2l0aW9uIG9mIHRoaXMgcHJvZHVjdC4gPC9wPiA8L2Rpdj4gPGRpdiBjbGFzcz1cImNvbXBvc2l0aW9uLWJveFwiPiB7eyNlYWNoIGNyZWRlbnRpYWxTdWJqZWN0Lm1hdGVyaWFsc1Byb3ZlbmFuY2V9fSA8YXJ0aWNsZSBjbGFzcz1cImNvbXBvc2l0aW9uLWJveC1pdGVtXCI-IDxkaXYgY2xhc3M9XCJjb21wb3NpdGlvbi1maXJzdC1jb2x1bW5cIj4gPHAgY2xhc3M9XCJjb21wb3NpdGlvbi1wZXJjZW50XCI-e3ttYXNzRnJhY3Rpb259fSU8L3A-IDxkaXY-IDxwIGNsYXNzPVwiY29tcG9zaXRpb24tdGl0bGVcIj4ge3sjaWYgbWFzc319e3ttYXNzLnZhbHVlfX17e21hc3MudW5pdH19IHt7L2lmfX17e25hbWV9fSA8L3A-IDxkaXYgY2xhc3M9XCJjb21wb3NpdGlvbi10YWdcIj4ge3sjaWYgcmVjeWNsZWRNYXNzRnJhY3Rpb259fSA8cCBjbGFzcz1cImNvbXBvc2l0aW9uLXRhZy1pdGVtXCI-UmVjeWNsZWQge3tyZWN5Y2xlZE1hc3NGcmFjdGlvbn19JTwvcD4ge3svaWZ9fSA8IS0tIFRPRE86IElmIGhhemFyZG91cyBpcyBub3QgcHJlc2VudCBpdCB3aWxsIGRpc3BsYXkgdGhlIHRhZyBcIkhhemFyZCBOb1wiIHdoaWNoIG1heSBub3QgYmUgdHJ1ZS4gLS0-IDxwIGNsYXNzPVwiY29tcG9zaXRpb24tdGFnLWl0ZW1cIj5IYXphcmQge3sjaWYgaGF6YXJkb3VzfX1ZZXN7e2Vsc2V9fU5ve3svaWZ9fTwvcD4gPC9kaXY-IHt7I2lmIG1hdGVyaWFsU2FmZXR5SW5mb3JtYXRpb24ubGlua1VSTH19IDxhIGhyZWY9XCJ7e21hdGVyaWFsU2FmZXR5SW5mb3JtYXRpb24ubGlua1VSTH19XCIgY2xhc3M9XCJibHVlLWJvdHRvbS1saW5lLXRoaWNrXCIgdGFyZ2V0PVwiX2JsYW5rXCI-e3ttYXRlcmlhbFNhZmV0eUluZm9ybWF0aW9uLmxpbmtOYW1lfX08L2E-IHt7L2lmfX0gPC9kaXY-IDwvZGl2PiB7eyNpZiBvcmlnaW5Db3VudHJ5fX0gPGRpdiBjbGFzcz1cImNvdW50cnktY29kZVwiPnt7b3JpZ2luQ291bnRyeX19PC9kaXY-IHt7L2lmfX0gPC9hcnRpY2xlPiB7ey9lYWNofX0gPC9kaXY-IDwvc2VjdGlvbj4ge3svaWZ9fSB7ey9pZn19IHt7L2lmfX0ge3sjaWYgY3JlZGVudGlhbFN1YmplY3QudHJhY2VhYmlsaXR5SW5mb3JtYXRpb259fSA8c2VjdGlvbiBjbGFzcz1cImhpc3RvcnlcIj4gPGRpdj4gPGgzIGNsYXNzPVwic2VjdGlvbi10aXRsZVwiPkhpc3Rvcnk8L2gzPiA8L2Rpdj4ge3sjaWYgY3JlZGVudGlhbFN1YmplY3QuZHVlRGlsaWdlbmNlRGVjbGFyYXRpb24ubGlua1VSTH19IDxhIGhyZWY9XCJ7e2NyZWRlbnRpYWxTdWJqZWN0LmR1ZURpbGlnZW5jZURlY2xhcmF0aW9uLmxpbmtVUkx9fVwiIGNsYXNzPVwidHJhY2VhYmlsaXR5LWNhcmRcIiB0YXJnZXQ9XCJfYmxhbmtcIj4gPGRpdiBjbGFzcz1cInRyYWNlYWJpbGl0eS1jYXJkLXRleHRcIj4gPHN2ZyB3aWR0aD1cIjI0XCIgaGVpZ2h0PVwiMjRcIiB2aWV3Qm94PVwiMCAwIDI0IDI0XCIgZmlsbD1cIm5vbmVcIiB4bWxucz1cImh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnXCIgPiA8cGF0aCBkPVwiTTUgMjFDNC40NSAyMSAzLjk3OTMzIDIwLjgwNDMgMy41ODggMjAuNDEzQzMuMTk2NjcgMjAuMDIxNyAzLjAwMDY3IDE5LjU1MDcgMyAxOVY1QzMgNC40NSAzLjE5NiAzLjk3OTMzIDMuNTg4IDMuNTg4QzMuOTggMy4xOTY2NyA0LjQ1MDY3IDMuMDAwNjcgNSAzSDE5QzE5LjU1IDMgMjAuMDIxIDMuMTk2IDIwLjQxMyAzLjU4OEMyMC44MDUgMy45OCAyMS4wMDA3IDQuNDUwNjcgMjEgNVYxOUMyMSAxOS41NSAyMC44MDQzIDIwLjAyMSAyMC40MTMgMjAuNDEzQzIwLjAyMTcgMjAuODA1IDE5LjU1MDcgMjEuMDAwNyAxOSAyMUg1Wk01IDVWMTlIMTlWNUgxN1YxMkwxNC41IDEwLjVMMTIgMTJWNUg1WlwiIGZpbGw9XCJ2YXIoLS1jb2xvci1pY29uKVwiID48L3BhdGg-IDwvc3ZnPiA8cD5TdXBwbHkgY2hhaW4gZHVlIGRpbGlnZW5jZSByZXBvcnQ8L3A-IDwvZGl2PiA8ZGl2IGNsYXNzPVwidHJhY2VhYmlsaXR5LWNhcmQtdmlldy1kZXRhaWxzXCI-IDxzdmcgd2lkdGg9XCI5XCIgaGVpZ2h0PVwiMTZcIiB2aWV3Qm94PVwiMCAwIDkgMTZcIiBmaWxsPVwibm9uZVwiIHhtbG5zPVwiaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmdcIiA-IDxwYXRoIGQ9XCJNMSAxTDggOEwxIDE1XCIgc3Ryb2tlPVwidmFyKC0tY29sb3ItaWNvbilcIiBzdHJva2Utd2lkdGg9XCIyXCIgc3Ryb2tlLWxpbmVjYXA9XCJyb3VuZFwiIHN0cm9rZS1saW5lam9pbj1cInJvdW5kXCIgLz4gPC9zdmc-IDwvZGl2PiA8L2E-IHt7L2lmfX0ge3sjZWFjaCBjcmVkZW50aWFsU3ViamVjdC50cmFjZWFiaWxpdHlJbmZvcm1hdGlvbn19IDxkaXYgY2xhc3M9XCJoaXN0b3J5LXZhbHVlLWNoYWluXCI-IHt7I2lmIHZhbHVlQ2hhaW5Qcm9jZXNzfX0gPHA-e3t2YWx1ZUNoYWluUHJvY2Vzc319PC9wPiB7eyNpZiB2ZXJpZmllZFJhdGlvfX0gPGRpdiBjbGFzcz1cInZlcmlmaWVkLXJhdGlvXCI-IDxwPlZlcmlmaWVkIHJhdGlvIHt7dmVyaWZpZWRSYXRpb319PC9wPiA8L2Rpdj4ge3svaWZ9fSB7ey9pZn19IDwvZGl2PiB7eyNpZiB0cmFjZWFiaWxpdHlFdmVudH19IDxkaXY-IHt7I2VhY2ggdHJhY2VhYmlsaXR5RXZlbnR9fSB7eyNpZiBsaW5rTmFtZX19IHt7I2lmIGxpbmtVUkx9fSA8ZGl2IGNsYXNzPVwiaGlzdG9yeS1pdGVtXCI-IDxzcGFuPnt7bGlua05hbWV9fTwvc3Bhbj4gPGEgaHJlZj1cInt7bGlua1VSTH19XCIgY2xhc3M9XCJibHVlLWJvdHRvbS1saW5lLXRoaWNrXCIgdGFyZ2V0PVwiX2JsYW5rXCI-VmlldzwvYT4gPC9kaXY-IHt7L2lmfX0ge3svaWZ9fSB7ey9lYWNofX0gPC9kaXY-IHt7L2lmfX0ge3svZWFjaH19IDwvc2VjdGlvbj4ge3svaWZ9fSA8c2VjdGlvbiBjbGFzcz1cImlzc3VlZC1ieVwiPiA8ZGl2PiA8aDMgY2xhc3M9XCJzZWN0aW9uLXRpdGxlXCI-UGFzc3BvcnQgSXNzdWVkIEJ5PC9oMz4gPC9kaXY-IDxkaXYgY2xhc3M9XCJ0YWJsZVwiPiA8ZGl2IGNsYXNzPVwidGFibGUtaXRlbVwiPiA8c3Bhbj5PcmdhbmlzYXRpb248L3NwYW4-IDxwIGNsYXNzPVwiaXRlbS12YWx1ZVwiPnt7aXNzdWVyLm5hbWV9fTwvcD4gPC9kaXY-IDxkaXYgY2xhc3M9XCJ0YWJsZS1pdGVtXCI-IDxzcGFuPlJlZ2lzdGVyZWQgSUQ8L3NwYW4-IDxhIGhyZWY9XCJ7e2lzc3Vlci5pZH19XCIgY2xhc3M9XCJibHVlLWJvdHRvbS1saW5lLXRoaWNrXCIgdGFyZ2V0PVwiX2JsYW5rXCI-e3tpc3N1ZXIuaWR9fTwvYT4gPC9kaXY-IHt7I2lmIHZhbGlkRnJvbX19IDxkaXYgY2xhc3M9XCJ0YWJsZS1pdGVtXCI-IDxzcGFuPlZhbGlkIGZyb208L3NwYW4-IDxwIGNsYXNzPVwiaXRlbS12YWx1ZVwiPnt7dmFsaWRGcm9tfX08L3A-IDwvZGl2PiB7ey9pZn19IHt7I2lmIHZhbGlkVW50aWx9fSA8ZGl2IGNsYXNzPVwidGFibGUtaXRlbVwiPiA8c3Bhbj5WYWxpZCB0bzwvc3Bhbj4gPHAgY2xhc3M9XCJpdGVtLXZhbHVlXCI-e3t2YWxpZFVudGlsfX08L3A-IDwvZGl2PiB7ey9pZn19IDwvZGl2PiA8L3NlY3Rpb24-IDxmb290ZXI-IDxwPiBUaGlzIERpZ2l0YWwgUHJvZHVjdCBQYXNzcG9ydCAoRFBQKSBpcyBhIGRpZ2l0YWwgcmVjb3JkIG9mIHRoZSBwcm9kdWN0J3Mgc3VzdGFpbmFiaWxpdHkgYW5kIGVudmlyb25tZW50YWwgcGVyZm9ybWFuY2UsIGVuc3VyaW5nIHRyYW5zcGFyZW5jeSBhbmQgYWNjb3VudGFiaWxpdHkgaW4gbGluZSB3aXRoIFVOVFAgc3RhbmRhcmRzLiBGb3IgbW9yZSBpbmZvcm1hdGlvbiB2aXNpdCA8YSBocmVmPVwiaHR0cHM6Ly91bmNlZmFjdC5naXRodWIuaW8vc3BlYy11bnRwL1wiIGNsYXNzPVwiZ3JheS1ib3R0b20tbGluZVwiIHRhcmdldD1cIl9ibGFua1wiPnVuY2VmYWN0LmdpdGh1Yi5pby9zcGVjLXVudHAvPC9hPi4gPC9wPiA8L2Zvb3Rlcj4gPC9kaXY-IDwvYm9keT48L2h0bWw-In1dfQ.RQduJiUVKinv0ytXB9LtumEphVb5lOiWS0lOgHgWBpXr7D8NDSHFOZ78pllrbVRC8M1oI6sx6tvRetcME4MYCA" +} diff --git a/tests/fixtures/samples_report.baseline.md b/tests/fixtures/samples_report.baseline.md new file mode 100644 index 0000000..3c4a8c1 --- /dev/null +++ b/tests/fixtures/samples_report.baseline.md @@ -0,0 +1,255 @@ +# DPP Sample Evaluation Report + +## Summary + +- **Total URLs**: 16 +- **Successfully fetched**: 13 +- **Failed**: 3 + +## By Recommendation + +### EXCELLENT (2) + +- `opensource_unicc_org_untp-digital-product-passport-v0.3.10.json`: Verifiable Credential with DPP structure + - URL: https://opensource.unicc.org/11dot2/spec-untp/-/raw/main/website/samples/untp-digital-product-passport-v0.3.10.json +- `test_uncefact_org_untp-dpp-instance-0.6.0.json`: Verifiable Credential with DPP structure + - URL: https://test.uncefact.org/vocabulary/untp/dpp/untp-dpp-instance-0.6.0.json + +### GOOD (4) + +- `test_uncefact_org_DigitalIdentityAnchor-instance-0.6.1.json`: Verifiable Credential structure + - URL: https://test.uncefact.org/vocabulary/untp/dia/DigitalIdentityAnchor-instance-0.6.1.json +- `opensource_unicc_org_untp-digital-facility-record-v0.3.9.json`: Verifiable Credential structure + - URL: https://opensource.unicc.org/phila/spec-untp/-/raw/main/website/samples/untp-digital-facility-record-v0.3.9.json +- `BatteryPassDataModel_BatteryPass_GeneralProductInformation-payload.json`: Battery Pass data + - URL: https://batterypass.github.io/BatteryPassDataModel/BatteryPass/io.BatteryPass.GeneralProductInformation/1.2.0/gen/GeneralProductInformation-payload.json +- `batterypass_BatteryPassDataModel_GeneralProductInformation-ld.json`: Battery Pass data + - URL: https://raw.githubusercontent.com/batterypass/BatteryPassDataModel/refs/heads/main/BatteryPass/io.BatteryPass.GeneralProductInformation/1.2.0/gen/GeneralProductInformation-ld.json + +### MODERATE (4) + +- `eclipse-tractusx_sldt-semantic-models_BatteryPass.json`: DPP-like structure without VC wrapper + - URL: https://raw.githubusercontent.com/eclipse-tractusx/sldt-semantic-models/main/io.catenax.battery.battery_pass/6.0.0/gen/BatteryPass.json +- `batterypass_BatteryPassDataModel_Circularity-ld.json`: DPP-like structure without VC wrapper + - URL: https://raw.githubusercontent.com/batterypass/BatteryPassDataModel/refs/heads/main/BatteryPass/io.BatteryPass.Circularity/1.2.0/gen/Circularity-ld.json +- `batterypass_BatteryPassDataModel_MaterialComposition-ld.json`: DPP-like structure without VC wrapper + - URL: https://raw.githubusercontent.com/batterypass/BatteryPassDataModel/refs/heads/main/BatteryPass/io.BatteryPass.MaterialComposition/1.2.0/gen/MaterialComposition-ld.json +- `nfc-forum_org_long-dpp-example.json`: DPP-like structure without VC wrapper + - URL: https://nfc-forum.org/ndpp/long-dpp-example.json + +### MAYBE (3) + +- `untp-verifiable-credentials_s3_amazonaws_com_bc075c5f-2304-4b3f-bb24-46d9fa9a8e60.json`: JSON-LD but structure unclear + - URL: https://untp-verifiable-credentials.s3.amazonaws.com/bc075c5f-2304-4b3f-bb24-46d9fa9a8e60.json +- `schemas_testing_breathable-t-shirt.json`: JSON-LD but structure unclear + - URL: https://spherity.github.io/schemas/testing/breathable-t-shirt.json +- `batterypass_BatteryPassDataModel_CarbonFootprintForBatteries-ld.json`: JSON-LD but structure unclear + - URL: https://raw.githubusercontent.com/batterypass/BatteryPassDataModel/refs/heads/main/BatteryPass/io.BatteryPass.CarbonFootprint/1.2.0/gen/CarbonFootprintForBatteries-ld.json + +### FAILED (3) + +- `zenodo_org_untp-dpp-instance-0.5.0-computer.json`: Invalid JSON: Expecting value: line 2 column 1 (char 1) + - URL: https://zenodo.org/records/15279026/preview/untp-dpp-instance-0.5.0-computer.json.txt +- `BatteryPassDataModel_BatteryPass_CarbonFootprintForBatteries-payload.json`: Invalid JSON: Expecting value: line 1 column 1 (char 0) + - URL: https://batterypass.github.io/BatteryPassDataModel/BatteryPass/io.BatteryPass.CarbonFootprint/1.2.0/gen/CarbonFootprintForBatteries-payload.json +- `BatteryPassDataModel_BatteryPass_MaterialComposition-payload.json`: Invalid JSON: Expecting value: line 1 column 1 (char 0) + - URL: https://batterypass.github.io/BatteryPassDataModel/BatteryPass/io.BatteryPass.MaterialComposition/1.2.0/gen/MaterialComposition-payload.json + +## Detailed Evaluation + +### untp-verifiable-credentials_s3_amazonaws_com_bc075c5f-2304-4b3f-bb24-46d9fa9a8e60.json + +- **URL**: https://untp-verifiable-credentials.s3.amazonaws.com/bc075c5f-2304-4b3f-bb24-46d9fa9a8e60.json +- **Hash**: 7fdae740e64218ab +- **Recommendation**: maybe +- **Is JSON-LD**: True +- **Is VC**: False +- **Is DPP-like**: False +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: EnvelopedVerifiableCredential +- **Top keys**: @context, type, id +- **Notes**: JSON-LD but structure unclear + +### schemas_testing_breathable-t-shirt.json + +- **URL**: https://spherity.github.io/schemas/testing/breathable-t-shirt.json +- **Hash**: f5132472ac04920b +- **Recommendation**: maybe +- **Is JSON-LD**: True +- **Is VC**: False +- **Is DPP-like**: False +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: None +- **Top keys**: @context +- **Notes**: JSON-LD but structure unclear + +### eclipse-tractusx_sldt-semantic-models_BatteryPass.json + +- **URL**: https://raw.githubusercontent.com/eclipse-tractusx/sldt-semantic-models/main/io.catenax.battery.battery_pass/6.0.0/gen/BatteryPass.json +- **Hash**: 50afbb8d50f3be29 +- **Recommendation**: moderate +- **Is JSON-LD**: False +- **Is VC**: False +- **Is DPP-like**: True +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: None +- **Top keys**: characteristics, metadata, commercial, identification, performance, sources, materials, safety, handling, conformity +- **Notes**: DPP-like structure without VC wrapper + +### zenodo_org_untp-dpp-instance-0.5.0-computer.json + +- **URL**: https://zenodo.org/records/15279026/preview/untp-dpp-instance-0.5.0-computer.json.txt +- **Error**: Invalid JSON: Expecting value: line 2 column 1 (char 1) + +### test_uncefact_org_DigitalIdentityAnchor-instance-0.6.1.json + +- **URL**: https://test.uncefact.org/vocabulary/untp/dia/DigitalIdentityAnchor-instance-0.6.1.json +- **Hash**: 6784faa60f59cb76 +- **Recommendation**: good +- **Is JSON-LD**: True +- **Is VC**: True +- **Is DPP-like**: False +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: ['DigitalIdentityAnchor', 'VerifiableCredential'] +- **Top keys**: type, @context, id, issuer, validFrom, validUntil, credentialSubject +- **Notes**: Verifiable Credential structure + +### opensource_unicc_org_untp-digital-facility-record-v0.3.9.json + +- **URL**: https://opensource.unicc.org/phila/spec-untp/-/raw/main/website/samples/untp-digital-facility-record-v0.3.9.json +- **Hash**: 5a6025ab1335864f +- **Recommendation**: good +- **Is JSON-LD**: True +- **Is VC**: True +- **Is DPP-like**: False +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: ['DigitalFacilityRecord', 'VerifiableCredential'] +- **Top keys**: type, @context, id, issuer, validFrom, validUntil, credentialSubject +- **Notes**: Verifiable Credential structure + +### opensource_unicc_org_untp-digital-product-passport-v0.3.10.json + +- **URL**: https://opensource.unicc.org/11dot2/spec-untp/-/raw/main/website/samples/untp-digital-product-passport-v0.3.10.json +- **Hash**: 5b112fea72fc74b6 +- **Recommendation**: excellent +- **Is JSON-LD**: True +- **Is VC**: True +- **Is DPP-like**: True +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: ['DigitalProductPassport', 'VerifiableCredential'] +- **Top keys**: type, @context, id, issuer, validFrom, validUntil, credentialSubject +- **Notes**: Verifiable Credential with DPP structure + +### test_uncefact_org_untp-dpp-instance-0.6.0.json + +- **URL**: https://test.uncefact.org/vocabulary/untp/dpp/untp-dpp-instance-0.6.0.json +- **Hash**: dceb94862b90bce6 +- **Recommendation**: excellent +- **Is JSON-LD**: True +- **Is VC**: True +- **Is DPP-like**: True +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: ['DigitalProductPassport', 'VerifiableCredential'] +- **Top keys**: type, @context, id, issuer, validFrom, validUntil, credentialSubject +- **Notes**: Verifiable Credential with DPP structure + +### BatteryPassDataModel_BatteryPass_GeneralProductInformation-payload.json + +- **URL**: https://batterypass.github.io/BatteryPassDataModel/BatteryPass/io.BatteryPass.GeneralProductInformation/1.2.0/gen/GeneralProductInformation-payload.json +- **Hash**: d9d8393364648ed9 +- **Recommendation**: good +- **Is JSON-LD**: False +- **Is VC**: False +- **Is DPP-like**: True +- **Is Battery Pass**: True +- **Is Schema**: False +- **Type**: None +- **Top keys**: batteryCategory, operatorInformation, productIdentifier, batteryStatus, puttingIntoService, batteryMass, manufacturingDate, batteryPassportIdentifier, warrentyPeriod, manufacturerInformation +- **Notes**: Battery Pass data + +### BatteryPassDataModel_BatteryPass_CarbonFootprintForBatteries-payload.json + +- **URL**: https://batterypass.github.io/BatteryPassDataModel/BatteryPass/io.BatteryPass.CarbonFootprint/1.2.0/gen/CarbonFootprintForBatteries-payload.json +- **Error**: Invalid JSON: Expecting value: line 1 column 1 (char 0) + +### batterypass_BatteryPassDataModel_GeneralProductInformation-ld.json + +- **URL**: https://raw.githubusercontent.com/batterypass/BatteryPassDataModel/refs/heads/main/BatteryPass/io.BatteryPass.GeneralProductInformation/1.2.0/gen/GeneralProductInformation-ld.json +- **Hash**: df821e9ad855ca75 +- **Recommendation**: good +- **Is JSON-LD**: True +- **Is VC**: False +- **Is DPP-like**: True +- **Is Battery Pass**: True +- **Is Schema**: False +- **Type**: None +- **Top keys**: @graph, @context +- **Notes**: Battery Pass data + +### batterypass_BatteryPassDataModel_CarbonFootprintForBatteries-ld.json + +- **URL**: https://raw.githubusercontent.com/batterypass/BatteryPassDataModel/refs/heads/main/BatteryPass/io.BatteryPass.CarbonFootprint/1.2.0/gen/CarbonFootprintForBatteries-ld.json +- **Hash**: ebcb4870f6fd59e2 +- **Recommendation**: maybe +- **Is JSON-LD**: True +- **Is VC**: False +- **Is DPP-like**: False +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: None +- **Top keys**: @graph, @context +- **Notes**: JSON-LD but structure unclear + +### batterypass_BatteryPassDataModel_Circularity-ld.json + +- **URL**: https://raw.githubusercontent.com/batterypass/BatteryPassDataModel/refs/heads/main/BatteryPass/io.BatteryPass.Circularity/1.2.0/gen/Circularity-ld.json +- **Hash**: bcb5d1e4c3e1822b +- **Recommendation**: moderate +- **Is JSON-LD**: True +- **Is VC**: False +- **Is DPP-like**: True +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: None +- **Top keys**: @graph, @context +- **Notes**: DPP-like structure without VC wrapper + +### batterypass_BatteryPassDataModel_MaterialComposition-ld.json + +- **URL**: https://raw.githubusercontent.com/batterypass/BatteryPassDataModel/refs/heads/main/BatteryPass/io.BatteryPass.MaterialComposition/1.2.0/gen/MaterialComposition-ld.json +- **Hash**: e0692ea9b1f7a837 +- **Recommendation**: moderate +- **Is JSON-LD**: True +- **Is VC**: False +- **Is DPP-like**: True +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: None +- **Top keys**: @graph, @context +- **Notes**: DPP-like structure without VC wrapper + +### nfc-forum_org_long-dpp-example.json + +- **URL**: https://nfc-forum.org/ndpp/long-dpp-example.json +- **Hash**: 57c0c2ed05527cd0 +- **Recommendation**: moderate +- **Is JSON-LD**: False +- **Is VC**: False +- **Is DPP-like**: True +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: None +- **Top keys**: productID, productName, manufacturer, productionDate, expiryDate, materials, environmentalImpact, compliance, endOfLifeInstructions, digitalPassportLink +- **Notes**: DPP-like structure without VC wrapper + +### BatteryPassDataModel_BatteryPass_MaterialComposition-payload.json + +- **URL**: https://batterypass.github.io/BatteryPassDataModel/BatteryPass/io.BatteryPass.MaterialComposition/1.2.0/gen/MaterialComposition-payload.json +- **Error**: Invalid JSON: Expecting value: line 1 column 1 (char 0) diff --git a/tests/fixtures/samples_report.md b/tests/fixtures/samples_report.md new file mode 100644 index 0000000..3c4a8c1 --- /dev/null +++ b/tests/fixtures/samples_report.md @@ -0,0 +1,255 @@ +# DPP Sample Evaluation Report + +## Summary + +- **Total URLs**: 16 +- **Successfully fetched**: 13 +- **Failed**: 3 + +## By Recommendation + +### EXCELLENT (2) + +- `opensource_unicc_org_untp-digital-product-passport-v0.3.10.json`: Verifiable Credential with DPP structure + - URL: https://opensource.unicc.org/11dot2/spec-untp/-/raw/main/website/samples/untp-digital-product-passport-v0.3.10.json +- `test_uncefact_org_untp-dpp-instance-0.6.0.json`: Verifiable Credential with DPP structure + - URL: https://test.uncefact.org/vocabulary/untp/dpp/untp-dpp-instance-0.6.0.json + +### GOOD (4) + +- `test_uncefact_org_DigitalIdentityAnchor-instance-0.6.1.json`: Verifiable Credential structure + - URL: https://test.uncefact.org/vocabulary/untp/dia/DigitalIdentityAnchor-instance-0.6.1.json +- `opensource_unicc_org_untp-digital-facility-record-v0.3.9.json`: Verifiable Credential structure + - URL: https://opensource.unicc.org/phila/spec-untp/-/raw/main/website/samples/untp-digital-facility-record-v0.3.9.json +- `BatteryPassDataModel_BatteryPass_GeneralProductInformation-payload.json`: Battery Pass data + - URL: https://batterypass.github.io/BatteryPassDataModel/BatteryPass/io.BatteryPass.GeneralProductInformation/1.2.0/gen/GeneralProductInformation-payload.json +- `batterypass_BatteryPassDataModel_GeneralProductInformation-ld.json`: Battery Pass data + - URL: https://raw.githubusercontent.com/batterypass/BatteryPassDataModel/refs/heads/main/BatteryPass/io.BatteryPass.GeneralProductInformation/1.2.0/gen/GeneralProductInformation-ld.json + +### MODERATE (4) + +- `eclipse-tractusx_sldt-semantic-models_BatteryPass.json`: DPP-like structure without VC wrapper + - URL: https://raw.githubusercontent.com/eclipse-tractusx/sldt-semantic-models/main/io.catenax.battery.battery_pass/6.0.0/gen/BatteryPass.json +- `batterypass_BatteryPassDataModel_Circularity-ld.json`: DPP-like structure without VC wrapper + - URL: https://raw.githubusercontent.com/batterypass/BatteryPassDataModel/refs/heads/main/BatteryPass/io.BatteryPass.Circularity/1.2.0/gen/Circularity-ld.json +- `batterypass_BatteryPassDataModel_MaterialComposition-ld.json`: DPP-like structure without VC wrapper + - URL: https://raw.githubusercontent.com/batterypass/BatteryPassDataModel/refs/heads/main/BatteryPass/io.BatteryPass.MaterialComposition/1.2.0/gen/MaterialComposition-ld.json +- `nfc-forum_org_long-dpp-example.json`: DPP-like structure without VC wrapper + - URL: https://nfc-forum.org/ndpp/long-dpp-example.json + +### MAYBE (3) + +- `untp-verifiable-credentials_s3_amazonaws_com_bc075c5f-2304-4b3f-bb24-46d9fa9a8e60.json`: JSON-LD but structure unclear + - URL: https://untp-verifiable-credentials.s3.amazonaws.com/bc075c5f-2304-4b3f-bb24-46d9fa9a8e60.json +- `schemas_testing_breathable-t-shirt.json`: JSON-LD but structure unclear + - URL: https://spherity.github.io/schemas/testing/breathable-t-shirt.json +- `batterypass_BatteryPassDataModel_CarbonFootprintForBatteries-ld.json`: JSON-LD but structure unclear + - URL: https://raw.githubusercontent.com/batterypass/BatteryPassDataModel/refs/heads/main/BatteryPass/io.BatteryPass.CarbonFootprint/1.2.0/gen/CarbonFootprintForBatteries-ld.json + +### FAILED (3) + +- `zenodo_org_untp-dpp-instance-0.5.0-computer.json`: Invalid JSON: Expecting value: line 2 column 1 (char 1) + - URL: https://zenodo.org/records/15279026/preview/untp-dpp-instance-0.5.0-computer.json.txt +- `BatteryPassDataModel_BatteryPass_CarbonFootprintForBatteries-payload.json`: Invalid JSON: Expecting value: line 1 column 1 (char 0) + - URL: https://batterypass.github.io/BatteryPassDataModel/BatteryPass/io.BatteryPass.CarbonFootprint/1.2.0/gen/CarbonFootprintForBatteries-payload.json +- `BatteryPassDataModel_BatteryPass_MaterialComposition-payload.json`: Invalid JSON: Expecting value: line 1 column 1 (char 0) + - URL: https://batterypass.github.io/BatteryPassDataModel/BatteryPass/io.BatteryPass.MaterialComposition/1.2.0/gen/MaterialComposition-payload.json + +## Detailed Evaluation + +### untp-verifiable-credentials_s3_amazonaws_com_bc075c5f-2304-4b3f-bb24-46d9fa9a8e60.json + +- **URL**: https://untp-verifiable-credentials.s3.amazonaws.com/bc075c5f-2304-4b3f-bb24-46d9fa9a8e60.json +- **Hash**: 7fdae740e64218ab +- **Recommendation**: maybe +- **Is JSON-LD**: True +- **Is VC**: False +- **Is DPP-like**: False +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: EnvelopedVerifiableCredential +- **Top keys**: @context, type, id +- **Notes**: JSON-LD but structure unclear + +### schemas_testing_breathable-t-shirt.json + +- **URL**: https://spherity.github.io/schemas/testing/breathable-t-shirt.json +- **Hash**: f5132472ac04920b +- **Recommendation**: maybe +- **Is JSON-LD**: True +- **Is VC**: False +- **Is DPP-like**: False +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: None +- **Top keys**: @context +- **Notes**: JSON-LD but structure unclear + +### eclipse-tractusx_sldt-semantic-models_BatteryPass.json + +- **URL**: https://raw.githubusercontent.com/eclipse-tractusx/sldt-semantic-models/main/io.catenax.battery.battery_pass/6.0.0/gen/BatteryPass.json +- **Hash**: 50afbb8d50f3be29 +- **Recommendation**: moderate +- **Is JSON-LD**: False +- **Is VC**: False +- **Is DPP-like**: True +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: None +- **Top keys**: characteristics, metadata, commercial, identification, performance, sources, materials, safety, handling, conformity +- **Notes**: DPP-like structure without VC wrapper + +### zenodo_org_untp-dpp-instance-0.5.0-computer.json + +- **URL**: https://zenodo.org/records/15279026/preview/untp-dpp-instance-0.5.0-computer.json.txt +- **Error**: Invalid JSON: Expecting value: line 2 column 1 (char 1) + +### test_uncefact_org_DigitalIdentityAnchor-instance-0.6.1.json + +- **URL**: https://test.uncefact.org/vocabulary/untp/dia/DigitalIdentityAnchor-instance-0.6.1.json +- **Hash**: 6784faa60f59cb76 +- **Recommendation**: good +- **Is JSON-LD**: True +- **Is VC**: True +- **Is DPP-like**: False +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: ['DigitalIdentityAnchor', 'VerifiableCredential'] +- **Top keys**: type, @context, id, issuer, validFrom, validUntil, credentialSubject +- **Notes**: Verifiable Credential structure + +### opensource_unicc_org_untp-digital-facility-record-v0.3.9.json + +- **URL**: https://opensource.unicc.org/phila/spec-untp/-/raw/main/website/samples/untp-digital-facility-record-v0.3.9.json +- **Hash**: 5a6025ab1335864f +- **Recommendation**: good +- **Is JSON-LD**: True +- **Is VC**: True +- **Is DPP-like**: False +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: ['DigitalFacilityRecord', 'VerifiableCredential'] +- **Top keys**: type, @context, id, issuer, validFrom, validUntil, credentialSubject +- **Notes**: Verifiable Credential structure + +### opensource_unicc_org_untp-digital-product-passport-v0.3.10.json + +- **URL**: https://opensource.unicc.org/11dot2/spec-untp/-/raw/main/website/samples/untp-digital-product-passport-v0.3.10.json +- **Hash**: 5b112fea72fc74b6 +- **Recommendation**: excellent +- **Is JSON-LD**: True +- **Is VC**: True +- **Is DPP-like**: True +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: ['DigitalProductPassport', 'VerifiableCredential'] +- **Top keys**: type, @context, id, issuer, validFrom, validUntil, credentialSubject +- **Notes**: Verifiable Credential with DPP structure + +### test_uncefact_org_untp-dpp-instance-0.6.0.json + +- **URL**: https://test.uncefact.org/vocabulary/untp/dpp/untp-dpp-instance-0.6.0.json +- **Hash**: dceb94862b90bce6 +- **Recommendation**: excellent +- **Is JSON-LD**: True +- **Is VC**: True +- **Is DPP-like**: True +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: ['DigitalProductPassport', 'VerifiableCredential'] +- **Top keys**: type, @context, id, issuer, validFrom, validUntil, credentialSubject +- **Notes**: Verifiable Credential with DPP structure + +### BatteryPassDataModel_BatteryPass_GeneralProductInformation-payload.json + +- **URL**: https://batterypass.github.io/BatteryPassDataModel/BatteryPass/io.BatteryPass.GeneralProductInformation/1.2.0/gen/GeneralProductInformation-payload.json +- **Hash**: d9d8393364648ed9 +- **Recommendation**: good +- **Is JSON-LD**: False +- **Is VC**: False +- **Is DPP-like**: True +- **Is Battery Pass**: True +- **Is Schema**: False +- **Type**: None +- **Top keys**: batteryCategory, operatorInformation, productIdentifier, batteryStatus, puttingIntoService, batteryMass, manufacturingDate, batteryPassportIdentifier, warrentyPeriod, manufacturerInformation +- **Notes**: Battery Pass data + +### BatteryPassDataModel_BatteryPass_CarbonFootprintForBatteries-payload.json + +- **URL**: https://batterypass.github.io/BatteryPassDataModel/BatteryPass/io.BatteryPass.CarbonFootprint/1.2.0/gen/CarbonFootprintForBatteries-payload.json +- **Error**: Invalid JSON: Expecting value: line 1 column 1 (char 0) + +### batterypass_BatteryPassDataModel_GeneralProductInformation-ld.json + +- **URL**: https://raw.githubusercontent.com/batterypass/BatteryPassDataModel/refs/heads/main/BatteryPass/io.BatteryPass.GeneralProductInformation/1.2.0/gen/GeneralProductInformation-ld.json +- **Hash**: df821e9ad855ca75 +- **Recommendation**: good +- **Is JSON-LD**: True +- **Is VC**: False +- **Is DPP-like**: True +- **Is Battery Pass**: True +- **Is Schema**: False +- **Type**: None +- **Top keys**: @graph, @context +- **Notes**: Battery Pass data + +### batterypass_BatteryPassDataModel_CarbonFootprintForBatteries-ld.json + +- **URL**: https://raw.githubusercontent.com/batterypass/BatteryPassDataModel/refs/heads/main/BatteryPass/io.BatteryPass.CarbonFootprint/1.2.0/gen/CarbonFootprintForBatteries-ld.json +- **Hash**: ebcb4870f6fd59e2 +- **Recommendation**: maybe +- **Is JSON-LD**: True +- **Is VC**: False +- **Is DPP-like**: False +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: None +- **Top keys**: @graph, @context +- **Notes**: JSON-LD but structure unclear + +### batterypass_BatteryPassDataModel_Circularity-ld.json + +- **URL**: https://raw.githubusercontent.com/batterypass/BatteryPassDataModel/refs/heads/main/BatteryPass/io.BatteryPass.Circularity/1.2.0/gen/Circularity-ld.json +- **Hash**: bcb5d1e4c3e1822b +- **Recommendation**: moderate +- **Is JSON-LD**: True +- **Is VC**: False +- **Is DPP-like**: True +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: None +- **Top keys**: @graph, @context +- **Notes**: DPP-like structure without VC wrapper + +### batterypass_BatteryPassDataModel_MaterialComposition-ld.json + +- **URL**: https://raw.githubusercontent.com/batterypass/BatteryPassDataModel/refs/heads/main/BatteryPass/io.BatteryPass.MaterialComposition/1.2.0/gen/MaterialComposition-ld.json +- **Hash**: e0692ea9b1f7a837 +- **Recommendation**: moderate +- **Is JSON-LD**: True +- **Is VC**: False +- **Is DPP-like**: True +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: None +- **Top keys**: @graph, @context +- **Notes**: DPP-like structure without VC wrapper + +### nfc-forum_org_long-dpp-example.json + +- **URL**: https://nfc-forum.org/ndpp/long-dpp-example.json +- **Hash**: 57c0c2ed05527cd0 +- **Recommendation**: moderate +- **Is JSON-LD**: False +- **Is VC**: False +- **Is DPP-like**: True +- **Is Battery Pass**: False +- **Is Schema**: False +- **Type**: None +- **Top keys**: productID, productName, manufacturer, productionDate, expiryDate, materials, environmentalImpact, compliance, endOfLifeInstructions, digitalPassportLink +- **Notes**: DPP-like structure without VC wrapper + +### BatteryPassDataModel_BatteryPass_MaterialComposition-payload.json + +- **URL**: https://batterypass.github.io/BatteryPassDataModel/BatteryPass/io.BatteryPass.MaterialComposition/1.2.0/gen/MaterialComposition-payload.json +- **Error**: Invalid JSON: Expecting value: line 1 column 1 (char 0) diff --git a/tests/fixtures/upstream/SOURCES.md b/tests/fixtures/upstream/SOURCES.md new file mode 100644 index 0000000..6dfe22b --- /dev/null +++ b/tests/fixtures/upstream/SOURCES.md @@ -0,0 +1,96 @@ + + +# Upstream UNTP artefacts (vendored) + +This directory holds **read-only, byte-for-byte copies** of UN/CEFACT UNTP artefacts pulled from the upstream GitLab repository. They drive the `tests/fixtures/upstream/` validation matrix and are the source of truth that the bundled `src/dppvalidator/{schemas,vocabularies}/data/` files derive from. Do not edit them — re-vendor when the upstream tag changes. + +Each version directory pins the exact upstream commit SHA so that re-pulling against a moved tag is detectable as a hash mismatch. + +| Directory | Vendored from | Purpose | +| -------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| [`v0.7.0/`](v0.7.0/) | tag `v0.7.0` | First release supported under the `0.4.0` migration plan ([docs/plans/UNTP_0.7.0_MIGRATION.md](../../../docs/plans/UNTP_0.7.0_MIGRATION.md)) | + +______________________________________________________________________ + +## v0.7.0 — UNTP DPP `0.7.0` + +**Upstream:** `https://opensource.unicc.org/un/unece/uncefact/spec-untp.git` +**Tag:** `v0.7.0` +**Pinned commit SHA:** `707cd5267deddede24bb74e453a758561972a109` +**Tag created:** `2026-05-04T10:34:14+00:00` +**Pulled:** `2026-05-07` +**Raw URL prefix:** `https://opensource.unicc.org/un/unece/uncefact/spec-untp/-/raw/707cd5267deddede24bb74e453a758561972a109/artefacts` +**Production mirror prefix:** `https://untp.unece.org/artefacts` + +The "Raw URL prefix" is the SHA-pinned source we vendored from (immutable; safe to diff against). The "Production mirror prefix" is the human-friendly hosting at `untp.unece.org` — verified bit-identical to the SHA-pinned source on 2026-05-08 (every artefact's SHA-256 matched). Use the production mirror for documentation links; use the SHA-pinned URL for integrity checks. + +### Files + +| Local path | Upstream path | Bytes | SHA-256 | +| ------------------------------------------------------------- | --------------------------------------------------------------------------- | ------: | ------------------------------------------------------------------ | +| `v0.7.0/schema/DigitalProductPassport.json` | `artefacts/schema/v0.7.0/dpp/DigitalProductPassport.json` | 50 362 | `42c51943ab23547d5287899fd12b214b19b006c28d105a70ff390f8551b12653` | +| `v0.7.0/schema/Product.json` | `artefacts/schema/v0.7.0/dpp/Product.json` | 38 990 | `fde2e1f11b0bbebd8fc209675c0575f1ff8359a9b52e5557f01d41c11f9ef23f` | +| `v0.7.0/contexts/untp-context.jsonld` | `artefacts/contexts/v0.7.0/untp-context.jsonld` | 105 396 | `fbd4824e30d3cfc5cba949e1efe19b4c9ebaee056abe7aaf1c6b139a7bf91b0c` | +| `v0.7.0/samples/DigitalProductPassport_instance.json` | `artefacts/samples/v0.7.0/dpp/DigitalProductPassport_instance.json` | 8 749 | `4c8df24357651169a90242b3f779842573104ed5f755d8fbe817f3129e8f0f91` | +| `v0.7.0/samples/DigitalProductPassport_battery_instance.json` | `artefacts/samples/v0.7.0/dpp/DigitalProductPassport_battery_instance.json` | 23 268 | `462264fcc6a4dc5ebcdc69cfbe238f76d2efa75534b71d5e1195d33139c7e599` | +| `v0.7.0/samples/DigitalProductPassport_cathode_instance.json` | `artefacts/samples/v0.7.0/dpp/DigitalProductPassport_cathode_instance.json` | 9 628 | `65841b5f60aa0b11e8b5c19656525023c2d62be8e40a3757c08e36426c8c79f4` | +| `v0.7.0/vocabularies/untp-ontology.jsonld` | `artefacts/vocabularies/untp-core/untp-ontology.jsonld` | 147 724 | `752060cc15c6c77bfcea8b170f173239a705e9da389314c1cb2dacc8a69d93bc` | +| `v0.7.0/vocabularies/untp-metrics.jsonld` | `artefacts/vocabularies/untp-metrics/untp-metrics.jsonld` | 53 765 | `77900ce1138be124976d138750bea24bacb6c8ba327672fe8598b85db99a0a36` | +| `v0.7.0/vocabularies/untp-topics.jsonld` | `artefacts/vocabularies/untp-topics/untp-topics.jsonld` | 61 045 | `49affcb265bdf2a7a92d1b171c49a27543bfb4915bcbd11dd6e571252a57bb12` | + +### Quick-look facts + +- **DPP schema** — required: `[@context, id, issuer, validFrom, name, credentialSubject]`; 22 `$defs`; `credentialSubject` is `Product` (no `ProductPassport` envelope). +- **Context** — single unified `@context` covering DPP/DCC/DFR/DIA/DTE; 36 top-level term keys; `untp` prefix is `https://vocabulary.uncefact.org/untp/`; JSON-LD `@version: 1.1`. +- **Samples** — all three samples validate cleanly against the bundled DPP schema (verified at vendor time). + +### Verifying integrity + +The snippet below extracts the `(local-path, sha256)` pairs from the table above and re-checks them with `shasum`. It is robust to Markdown table reformatting because it pattern-matches on the literal backticks around the path and the 64-char hex SHA, not on byte positions: + +```bash +python3 - <<'PY' | shasum -a 256 -c +import re, pathlib +src = pathlib.Path("tests/fixtures/upstream/SOURCES.md").read_text() +# Match a path-in-backticks (`v0.X.Y/...`) followed within the same row by +# a 64-char hex SHA-256 in backticks. `.*?` is non-greedy so adjacent rows +# don't bleed into each other. +pat = r"`(v0\.\d+\.\d+/[^`]+)`.*?`([0-9a-f]{64})`" +for path, sha in re.findall(pat, src): + print(f"{sha} tests/fixtures/upstream/{path}") +PY +``` + +Or, equivalently, re-pull and diff: + +```bash +sha=707cd5267deddede24bb74e453a758561972a109 +base=https://opensource.unicc.org/un/unece/uncefact/spec-untp/-/raw/$sha/artefacts +diff <(curl -sL $base/schema/v0.7.0/dpp/DigitalProductPassport.json) tests/fixtures/upstream/v0.7.0/schema/DigitalProductPassport.json +``` + +### Production mirror cross-check + +The production mirror at `untp.unece.org` is verified bit-identical to the SHA-pinned `opensource.unicc.org` source on each re-vendor pull. To re-run the cross-check against the current bundled bytes: + +```bash +prod=https://untp.unece.org/artefacts +diff <(curl -sL $prod/schema/v0.7.0/dpp/DigitalProductPassport.json) tests/fixtures/upstream/v0.7.0/schema/DigitalProductPassport.json +diff <(curl -sL $prod/schema/v0.7.0/dpp/Product.json) tests/fixtures/upstream/v0.7.0/schema/Product.json +diff <(curl -sL $prod/samples/v0.7.0/dpp/DigitalProductPassport_instance.json) tests/fixtures/upstream/v0.7.0/samples/DigitalProductPassport_instance.json +diff <(curl -sL $prod/samples/v0.7.0/dpp/DigitalProductPassport_battery_instance.json) tests/fixtures/upstream/v0.7.0/samples/DigitalProductPassport_battery_instance.json +diff <(curl -sL $prod/samples/v0.7.0/dpp/DigitalProductPassport_cathode_instance.json) tests/fixtures/upstream/v0.7.0/samples/DigitalProductPassport_cathode_instance.json +``` + +Each `diff` should produce no output. A non-empty diff means upstream republished the artefact at the production mirror without re-tagging — open an issue with UN/CEFACT and re-pin the registry against the new bytes. + +### Re-vendoring (when a new upstream tag lands) + +1. Resolve the new tag's commit SHA (`curl -sL "https://opensource.unicc.org/api/v4/projects/62/repository/tags/" | jq -r .commit.id`). +1. Run the fetch helper (or use the `/untp-bump ` Claude Code slash command, which scripts steps 2–4 of [docs/plans/UNTP_0.7.0_MIGRATION.md](../../../docs/plans/UNTP_0.7.0_MIGRATION.md) §7.2). +1. Re-compute SHA-256s and append a new section to this file. +1. Open a tracking PR linking back to the upstream tag. + +### License + +The UNTP specification artefacts are published by UN/CEFACT under [GPL-3.0-or-later](https://www.gnu.org/licenses/gpl-3.0.html) per the upstream repository. They are vendored here for **test fixture use only** under that licence — they are not redistributed inside the `dppvalidator` Python wheel. The vendored copies are read-only; modifications to the upstream content must happen upstream. diff --git a/tests/fixtures/upstream/v0.7.0/contexts/untp-context.jsonld b/tests/fixtures/upstream/v0.7.0/contexts/untp-context.jsonld new file mode 100644 index 0000000..bb353a3 --- /dev/null +++ b/tests/fixtures/upstream/v0.7.0/contexts/untp-context.jsonld @@ -0,0 +1,3493 @@ +{ + "@context": { + "untp": "https://vocabulary.uncefact.org/untp/", + "schema": "https://schema.org/", + "renderMethodPrefix": "https://w3id.org/vc/render-method#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "@protected": true, + "@version": 1.1, + "type": "@type", + "id": "@id", + "issuingSoftware": { + "@id": "untp:issuingSoftware", + "@type": "@id", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/IssuingSoftware#", + "name": { + "@id": "schema:name" + }, + "version": { + "@id": "untp:version", + "@type": "xsd:string" + }, + "vendor": { + "@id": "untp:vendor", + "@type": "@id", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/SoftwareVendor#", + "name": { + "@id": "schema:name" + } + } + } + } + }, + "DigitalProductPassport": { + "@protected": true, + "@id": "untp:DigitalProductPassport", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/DigitalProductPassport#" + } + }, + "DigitalConformityCredential": { + "@protected": true, + "@id": "untp:DigitalConformityCredential", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/DigitalConformityCredential#" + } + }, + "DigitalFacilityRecord": { + "@protected": true, + "@id": "untp:DigitalFacilityRecord", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/DigitalFacilityRecord#" + } + }, + "DigitalIdentityAnchor": { + "@protected": true, + "@id": "untp:DigitalIdentityAnchor", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/DigitalIdentityAnchor#" + } + }, + "DigitalTraceabilityEvent": { + "@protected": true, + "@id": "untp:DigitalTraceabilityEvent", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/DigitalTraceabilityEvent#" + } + }, + "RenderTemplate2024": { + "@protected": true, + "@id": "untp:RenderTemplate2024", + "@context": { + "@protected": true, + "mediaQuery": { + "@id": "untp:mediaQuery", + "@type": "xsd:string" + }, + "template": { + "@id": "untp:template", + "@type": "xsd:string" + }, + "url": { + "@id": "untp:url", + "@type": "xsd:anyURI" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + } + } + }, + "IdentifierScheme": { + "@protected": true, + "@id": "untp:IdentifierScheme", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + } + } + }, + "Party": { + "@protected": true, + "@id": "untp:Party", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/Party#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "registeredId": { + "@id": "untp:registeredId", + "@type": "xsd:string" + }, + "idScheme": { + "@id": "untp:idScheme", + "@type": "@id", + "@context": { + "@protected": true, + "id": { + "@id": "untp:id", + "@type": "xsd:anyURI" + }, + "name": { + "@id": "schema:name" + } + } + }, + "registrationCountry": { + "@protected": true, + "@id": "untp:registrationCountry", + "@context": { + "@protected": true, + "countryCode": { + "@id": "untp:countryCode", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "countryName": { + "@id": "untp:countryName", + "@type": "xsd:string" + } + } + }, + "partyAddress": { + "@protected": true, + "@id": "untp:partyAddress", + "@context": { + "@protected": true, + "streetAddress": { + "@id": "schema:streetAddress", + "@type": "xsd:string" + }, + "postalCode": { + "@id": "schema:postalCode", + "@type": "xsd:string" + }, + "addressLocality": { + "@id": "schema:addressLocality", + "@type": "xsd:string" + }, + "addressRegion": { + "@id": "schema:addressRegion", + "@type": "xsd:string" + }, + "addressCountry": { + "@protected": true, + "@id": "untp:addressCountry", + "@context": { + "@protected": true, + "countryCode": { + "@id": "untp:countryCode", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "countryName": { + "@id": "untp:countryName", + "@type": "xsd:string" + } + } + } + } + }, + "organisationWebsite": { + "@id": "untp:organisationWebsite", + "@type": "xsd:anyURI" + }, + "industryCategory": { + "@protected": true, + "@id": "untp:industryCategory", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "partyAlsoKnownAs": { + "@id": "untp:partyAlsoKnownAs", + "@type": "@id" + } + } + }, + "CredentialIssuer": { + "@protected": true, + "@id": "untp:CredentialIssuer", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/CredentialIssuer#", + "name": { + "@id": "schema:name" + }, + "issuerAlsoKnownAs": { + "@id": "untp:issuerAlsoKnownAs", + "@type": "@id" + } + } + }, + "Entity": { + "@protected": false, + "@id": "untp:Entity", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/Entity#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + } + } + }, + "ConformityTopic": { + "@protected": true, + "@id": "untp:ConformityTopic", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + } + } + }, + "PerformanceMetric": { + "@protected": true, + "@id": "untp:PerformanceMetric", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "improvementDirection": { + "@id": "untp:improvementDirection", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ImprovementIndicator#" + } + }, + "aggregationMethod": { + "@id": "untp:aggregationMethod", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/AggregationType#" + } + }, + "allowedUnit": { + "@id": "untp:allowedUnit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "Criterion": { + "@protected": true, + "@id": "untp:Criterion", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/Criterion#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "version": { + "@id": "untp:version", + "@type": "xsd:string" + }, + "status": { + "@id": "untp:status", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/CriterionStatus#" + } + }, + "documentation": { + "@id": "untp:documentation", + "@type": "xsd:anyURI" + }, + "conformityTopic": { + "@id": "untp:conformityTopic", + "@type": "@id", + "@container": "@set" + }, + "tag": { + "@id": "untp:tag", + "@type": "xsd:string" + }, + "requiredPerformance": { + "@protected": true, + "@id": "untp:requiredPerformance", + "@context": { + "@protected": true, + "metric": { + "@id": "untp:metric", + "@type": "@id" + }, + "measure": { + "@protected": true, + "@id": "untp:measure", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "score": { + "@protected": true, + "@id": "untp:score", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "rank": { + "@id": "untp:rank", + "@type": "xsd:integer" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + } + } + } + } + } + } + }, + "Regulation": { + "@protected": true, + "@id": "untp:Regulation", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/Regulation#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "jurisdictionCountry": { + "@protected": true, + "@id": "untp:jurisdictionCountry", + "@context": { + "@protected": true, + "countryCode": { + "@id": "untp:countryCode", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "countryName": { + "@id": "untp:countryName", + "@type": "xsd:string" + } + } + }, + "administeredBy": { + "@id": "untp:administeredBy", + "@type": "@id" + }, + "effectiveDate": { + "@id": "untp:effectiveDate", + "@type": "xsd:date" + } + } + }, + "Standard": { + "@protected": true, + "@id": "untp:Standard", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/Standard#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "issuingParty": { + "@id": "untp:issuingParty", + "@type": "@id" + }, + "issueDate": { + "@id": "untp:issueDate", + "@type": "xsd:date" + } + } + }, + "Claim": { + "@protected": true, + "@id": "untp:Claim", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/Claim#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "referenceCriteria": { + "@id": "untp:referenceCriteria", + "@type": "@id" + }, + "referenceRegulation": { + "@id": "untp:referenceRegulation", + "@type": "@id" + }, + "referenceStandard": { + "@id": "untp:referenceStandard", + "@type": "@id" + }, + "claimDate": { + "@id": "untp:claimDate", + "@type": "xsd:date" + }, + "applicablePeriod": { + "@protected": true, + "@id": "untp:applicablePeriod", + "@context": { + "@protected": true, + "startDate": { + "@id": "untp:startDate", + "@type": "xsd:date" + }, + "endDate": { + "@id": "untp:endDate", + "@type": "xsd:date" + }, + "periodInformation": { + "@id": "untp:periodInformation", + "@type": "xsd:string" + } + } + }, + "claimedPerformance": { + "@protected": true, + "@id": "untp:claimedPerformance", + "@context": { + "@protected": true, + "metric": { + "@id": "untp:metric", + "@type": "@id" + }, + "measure": { + "@protected": true, + "@id": "untp:measure", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "score": { + "@protected": true, + "@id": "untp:score", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "rank": { + "@id": "untp:rank", + "@type": "xsd:integer" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + } + } + } + } + }, + "evidence": { + "@protected": true, + "@id": "untp:evidence", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "conformityTopic": { + "@id": "untp:conformityTopic", + "@type": "@id", + "@container": "@set" + } + } + }, + "Facility": { + "@protected": true, + "@id": "untp:Facility", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/Facility#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "registeredId": { + "@id": "untp:registeredId", + "@type": "xsd:string" + }, + "idScheme": { + "@id": "untp:idScheme", + "@type": "@id", + "@context": { + "@protected": true, + "id": { + "@id": "untp:id", + "@type": "xsd:anyURI" + }, + "name": { + "@id": "schema:name" + } + } + }, + "countryOfOperation": { + "@protected": true, + "@id": "untp:countryOfOperation", + "@context": { + "@protected": true, + "countryCode": { + "@id": "untp:countryCode", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "countryName": { + "@id": "untp:countryName", + "@type": "xsd:string" + } + } + }, + "processCategory": { + "@protected": true, + "@id": "untp:processCategory", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "relatedParty": { + "@protected": true, + "@id": "untp:relatedParty", + "@context": { + "@protected": true, + "role": { + "@id": "untp:role", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/PartyRole#" + } + }, + "party": { + "@id": "untp:party", + "@type": "@id" + } + } + }, + "relatedDocument": { + "@protected": true, + "@id": "untp:relatedDocument", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "facilityAlsoKnownAs": { + "@id": "untp:facilityAlsoKnownAs", + "@type": "@id" + }, + "locationInformation": { + "@protected": true, + "@id": "untp:locationInformation", + "@context": { + "@protected": true, + "plusCode": { + "@id": "untp:plusCode", + "@type": "xsd:anyURI" + }, + "geoLocation": { + "@protected": true, + "@id": "untp:geoLocation", + "@context": { + "@protected": true, + "latitude": { + "@id": "untp:latitude", + "@type": "xsd:double" + }, + "longitude": { + "@id": "untp:longitude", + "@type": "xsd:double" + } + } + }, + "geoBoundary": { + "@protected": true, + "@id": "untp:geoBoundary", + "@context": { + "@protected": true, + "latitude": { + "@id": "untp:latitude", + "@type": "xsd:double" + }, + "longitude": { + "@id": "untp:longitude", + "@type": "xsd:double" + } + } + } + } + }, + "address": { + "@protected": true, + "@id": "untp:address", + "@context": { + "@protected": true, + "streetAddress": { + "@id": "schema:streetAddress", + "@type": "xsd:string" + }, + "postalCode": { + "@id": "schema:postalCode", + "@type": "xsd:string" + }, + "addressLocality": { + "@id": "schema:addressLocality", + "@type": "xsd:string" + }, + "addressRegion": { + "@id": "schema:addressRegion", + "@type": "xsd:string" + }, + "addressCountry": { + "@protected": true, + "@id": "untp:addressCountry", + "@context": { + "@protected": true, + "countryCode": { + "@id": "untp:countryCode", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "countryName": { + "@id": "untp:countryName", + "@type": "xsd:string" + } + } + } + } + }, + "materialUsage": { + "@protected": true, + "@id": "untp:materialUsage", + "@context": { + "@protected": true, + "applicablePeriod": { + "@protected": true, + "@id": "untp:applicablePeriod", + "@context": { + "@protected": true, + "startDate": { + "@id": "untp:startDate", + "@type": "xsd:date" + }, + "endDate": { + "@id": "untp:endDate", + "@type": "xsd:date" + }, + "periodInformation": { + "@id": "untp:periodInformation", + "@type": "xsd:string" + } + } + }, + "materialConsumed": { + "@protected": true, + "@id": "untp:materialConsumed", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "originCountry": { + "@protected": true, + "@id": "untp:originCountry", + "@context": { + "@protected": true, + "countryCode": { + "@id": "untp:countryCode", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "countryName": { + "@id": "untp:countryName", + "@type": "xsd:string" + } + } + }, + "materialType": { + "@protected": true, + "@id": "untp:materialType", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "massFraction": { + "@id": "untp:massFraction", + "@type": "xsd:double" + }, + "mass": { + "@protected": true, + "@id": "untp:mass", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "recycledMassFraction": { + "@id": "untp:recycledMassFraction", + "@type": "xsd:double" + }, + "hazardous": { + "@id": "untp:hazardous", + "@type": "xsd:boolean" + }, + "symbol": { + "@protected": true, + "@id": "untp:symbol", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "imageData": { + "@id": "untp:imageData", + "@type": "xsd:string" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + } + } + }, + "materialSafetyInformation": { + "@protected": true, + "@id": "untp:materialSafetyInformation", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + } + } + } + } + }, + "performanceClaim": { + "@id": "untp:performanceClaim", + "@type": "@id" + } + } + }, + "ConformityScheme": { + "@protected": true, + "@id": "untp:ConformityScheme", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ConformityScheme#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "owner": { + "@id": "untp:owner", + "@type": "@id" + }, + "endorsementLevel": { + "@id": "untp:endorsementLevel", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/SchemeEndorsementLevel#" + } + }, + "endorsement": { + "@protected": true, + "@id": "untp:endorsement", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "trustmark": { + "@protected": true, + "@id": "untp:trustmark", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "imageData": { + "@id": "untp:imageData", + "@type": "xsd:string" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + } + } + }, + "issuingAuthority": { + "@id": "untp:issuingAuthority", + "@type": "@id" + }, + "endorsementEvidence": { + "@protected": true, + "@id": "untp:endorsementEvidence", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + } + } + }, + "schemeScoringFramework": { + "@protected": true, + "@id": "untp:schemeScoringFramework", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "score": { + "@protected": true, + "@id": "untp:score", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "rank": { + "@id": "untp:rank", + "@type": "xsd:integer" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + } + } + } + } + }, + "documentation": { + "@id": "untp:documentation", + "@type": "xsd:anyURI" + }, + "licenseType": { + "@id": "untp:licenseType", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/LicenseType#" + } + }, + "establishedDate": { + "@id": "untp:establishedDate", + "@type": "xsd:date" + }, + "geographicScope": { + "@protected": true, + "@id": "untp:geographicScope", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "industryScope": { + "@protected": true, + "@id": "untp:industryScope", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "trustmark": { + "@protected": true, + "@id": "untp:trustmark", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "imageData": { + "@id": "untp:imageData", + "@type": "xsd:string" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + } + } + }, + "conformsTo": { + "@protected": true, + "@id": "untp:conformsTo", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "includedProfile": { + "@id": "untp:includedProfile", + "@type": "@id" + } + } + }, + "ConformityProfile": { + "@protected": true, + "@id": "untp:ConformityProfile", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ConformityProfile#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "version": { + "@id": "untp:version", + "@type": "xsd:string" + }, + "validFrom": { + "@id": "untp:validFrom", + "@type": "xsd:date" + }, + "status": { + "@id": "untp:status", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/CriterionStatus#" + } + }, + "subjectType": { + "@id": "untp:subjectType", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/AssessmentSubjectType#" + } + }, + "standardAlignment": { + "@protected": true, + "@id": "untp:standardAlignment", + "@context": { + "@protected": true, + "standard": { + "@id": "untp:standard", + "@type": "@id" + }, + "alignmentLevel": { + "@id": "untp:alignmentLevel", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/SchemeAlignmentLevel#" + } + } + } + }, + "regulatoryAlignment": { + "@protected": true, + "@id": "untp:regulatoryAlignment", + "@context": { + "@protected": true, + "regulation": { + "@id": "untp:regulation", + "@type": "@id" + }, + "alignmentLevel": { + "@id": "untp:alignmentLevel", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/SchemeAlignmentLevel#" + } + } + } + }, + "criterionScoringFramework": { + "@protected": true, + "@id": "untp:criterionScoringFramework", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "score": { + "@protected": true, + "@id": "untp:score", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "rank": { + "@id": "untp:rank", + "@type": "xsd:integer" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + } + } + } + } + }, + "documentation": { + "@id": "untp:documentation", + "@type": "xsd:anyURI" + }, + "criterion": { + "@id": "untp:criterion", + "@type": "@id" + }, + "scope": { + "@protected": true, + "@id": "untp:scope", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "scheme": { + "@id": "untp:scheme", + "@type": "@id" + } + } + }, + "Product": { + "@protected": true, + "@id": "untp:Product", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/Product#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "idScheme": { + "@id": "untp:idScheme", + "@type": "@id", + "@context": { + "@protected": true, + "id": { + "@id": "untp:id", + "@type": "xsd:anyURI" + }, + "name": { + "@id": "schema:name" + } + } + }, + "modelNumber": { + "@id": "untp:modelNumber", + "@type": "xsd:string" + }, + "batchNumber": { + "@id": "untp:batchNumber", + "@type": "xsd:string" + }, + "itemNumber": { + "@id": "untp:itemNumber", + "@type": "xsd:string" + }, + "idGranularity": { + "@id": "untp:idGranularity", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ProductIDGranularity#" + } + }, + "characteristics": { + "@id": "untp:characteristics", + "@context": { + "@vocab": "https://vocabulary.uncefact.org/untp/Characteristics#" + } + }, + "productImage": { + "@protected": true, + "@id": "untp:productImage", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "productCategory": { + "@protected": true, + "@id": "untp:productCategory", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "relatedDocument": { + "@protected": true, + "@id": "untp:relatedDocument", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "relatedParty": { + "@protected": true, + "@id": "untp:relatedParty", + "@context": { + "@protected": true, + "role": { + "@id": "untp:role", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/PartyRole#" + } + }, + "party": { + "@id": "untp:party", + "@type": "@id" + } + } + }, + "producedAtFacility": { + "@id": "untp:producedAtFacility", + "@type": "@id" + }, + "productionDate": { + "@id": "untp:productionDate", + "@type": "xsd:date" + }, + "expiryDate": { + "@id": "untp:expiryDate", + "@type": "xsd:date" + }, + "countryOfProduction": { + "@protected": true, + "@id": "untp:countryOfProduction", + "@context": { + "@protected": true, + "countryCode": { + "@id": "untp:countryCode", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "countryName": { + "@id": "untp:countryName", + "@type": "xsd:string" + } + } + }, + "dimensions": { + "@protected": true, + "@id": "untp:dimensions", + "@context": { + "@protected": true, + "weight": { + "@protected": true, + "@id": "untp:weight", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "length": { + "@protected": true, + "@id": "untp:length", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "width": { + "@protected": true, + "@id": "untp:width", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "height": { + "@protected": true, + "@id": "untp:height", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "volume": { + "@protected": true, + "@id": "untp:volume", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + } + } + }, + "materialProvenance": { + "@protected": true, + "@id": "untp:materialProvenance", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "originCountry": { + "@protected": true, + "@id": "untp:originCountry", + "@context": { + "@protected": true, + "countryCode": { + "@id": "untp:countryCode", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "countryName": { + "@id": "untp:countryName", + "@type": "xsd:string" + } + } + }, + "materialType": { + "@protected": true, + "@id": "untp:materialType", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "massFraction": { + "@id": "untp:massFraction", + "@type": "xsd:double" + }, + "mass": { + "@protected": true, + "@id": "untp:mass", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "recycledMassFraction": { + "@id": "untp:recycledMassFraction", + "@type": "xsd:double" + }, + "hazardous": { + "@id": "untp:hazardous", + "@type": "xsd:boolean" + }, + "symbol": { + "@protected": true, + "@id": "untp:symbol", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "imageData": { + "@id": "untp:imageData", + "@type": "xsd:string" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + } + } + }, + "materialSafetyInformation": { + "@protected": true, + "@id": "untp:materialSafetyInformation", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + } + } + }, + "packaging": { + "@protected": true, + "@id": "untp:packaging", + "@context": { + "@protected": true, + "description": { + "@id": "schema:description" + }, + "dimensions": { + "@protected": true, + "@id": "untp:dimensions", + "@context": { + "@protected": true, + "weight": { + "@protected": true, + "@id": "untp:weight", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "length": { + "@protected": true, + "@id": "untp:length", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "width": { + "@protected": true, + "@id": "untp:width", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "height": { + "@protected": true, + "@id": "untp:height", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "volume": { + "@protected": true, + "@id": "untp:volume", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + } + } + }, + "materialUsed": { + "@protected": true, + "@id": "untp:materialUsed", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "originCountry": { + "@protected": true, + "@id": "untp:originCountry", + "@context": { + "@protected": true, + "countryCode": { + "@id": "untp:countryCode", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/CountryId#" + } + }, + "countryName": { + "@id": "untp:countryName", + "@type": "xsd:string" + } + } + }, + "materialType": { + "@protected": true, + "@id": "untp:materialType", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "massFraction": { + "@id": "untp:massFraction", + "@type": "xsd:double" + }, + "mass": { + "@protected": true, + "@id": "untp:mass", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "recycledMassFraction": { + "@id": "untp:recycledMassFraction", + "@type": "xsd:double" + }, + "hazardous": { + "@id": "untp:hazardous", + "@type": "xsd:boolean" + }, + "symbol": { + "@protected": true, + "@id": "untp:symbol", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "imageData": { + "@id": "untp:imageData", + "@type": "xsd:string" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + } + } + }, + "materialSafetyInformation": { + "@protected": true, + "@id": "untp:materialSafetyInformation", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + } + } + }, + "packageLabel": { + "@protected": true, + "@id": "untp:packageLabel", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "imageData": { + "@id": "untp:imageData", + "@type": "xsd:string" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + } + } + }, + "performanceClaim": { + "@id": "untp:performanceClaim", + "@type": "@id" + } + } + }, + "productLabel": { + "@protected": true, + "@id": "untp:productLabel", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "imageData": { + "@id": "untp:imageData", + "@type": "xsd:string" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + } + } + }, + "performanceClaim": { + "@id": "untp:performanceClaim", + "@type": "@id" + } + } + }, + "ConformityAssessment": { + "@protected": true, + "@id": "untp:ConformityAssessment", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ConformityAssessment#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "assessmentCriteria": { + "@id": "untp:assessmentCriteria", + "@type": "@id" + }, + "assessmentDate": { + "@id": "untp:assessmentDate", + "@type": "xsd:date" + }, + "assessedPerformance": { + "@protected": true, + "@id": "untp:assessedPerformance", + "@context": { + "@protected": true, + "metric": { + "@id": "untp:metric", + "@type": "@id" + }, + "measure": { + "@protected": true, + "@id": "untp:measure", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "score": { + "@protected": true, + "@id": "untp:score", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "rank": { + "@id": "untp:rank", + "@type": "xsd:integer" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + } + } + } + } + }, + "assessedProduct": { + "@protected": true, + "@id": "untp:assessedProduct", + "@context": { + "@protected": true, + "product": { + "@id": "untp:product", + "@type": "@id" + }, + "idVerifiedByCAB": { + "@id": "untp:idVerifiedByCAB", + "@type": "xsd:boolean" + } + } + }, + "assessedFacility": { + "@protected": true, + "@id": "untp:assessedFacility", + "@context": { + "@protected": true, + "facility": { + "@id": "untp:facility", + "@type": "@id" + }, + "idVerifiedByCAB": { + "@id": "untp:idVerifiedByCAB", + "@type": "xsd:boolean" + } + } + }, + "assessedOrganisation": { + "@id": "untp:assessedOrganisation", + "@type": "@id" + }, + "referenceStandard": { + "@id": "untp:referenceStandard", + "@type": "@id" + }, + "referenceRegulation": { + "@id": "untp:referenceRegulation", + "@type": "@id" + }, + "specifiedCondition": { + "@id": "untp:specifiedCondition", + "@type": "xsd:string" + }, + "evidence": { + "@protected": true, + "@id": "untp:evidence", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "conformityTopic": { + "@id": "untp:conformityTopic", + "@type": "@id", + "@container": "@set" + }, + "conformance": { + "@id": "untp:conformance", + "@type": "xsd:boolean" + } + } + }, + "ConformityAttestation": { + "@protected": true, + "@id": "untp:ConformityAttestation", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ConformityAttestation#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "assessorLevel": { + "@id": "untp:assessorLevel", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/AssessorLevel#" + } + }, + "assessmentLevel": { + "@id": "untp:assessmentLevel", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/AssessmentLevel#" + } + }, + "attestationType": { + "@id": "untp:attestationType", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/AttestationType#" + } + }, + "issuedToParty": { + "@id": "untp:issuedToParty", + "@type": "@id" + }, + "authorisation": { + "@protected": true, + "@id": "untp:authorisation", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "trustmark": { + "@protected": true, + "@id": "untp:trustmark", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "imageData": { + "@id": "untp:imageData", + "@type": "xsd:string" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + } + } + }, + "issuingAuthority": { + "@id": "untp:issuingAuthority", + "@type": "@id" + }, + "endorsementEvidence": { + "@protected": true, + "@id": "untp:endorsementEvidence", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + } + } + }, + "referenceScheme": { + "@id": "untp:referenceScheme", + "@type": "@id" + }, + "referenceProfile": { + "@id": "untp:referenceProfile", + "@type": "@id" + }, + "profileScore": { + "@protected": true, + "@id": "untp:profileScore", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "rank": { + "@id": "untp:rank", + "@type": "xsd:integer" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + } + } + }, + "conformityCertificate": { + "@protected": true, + "@id": "untp:conformityCertificate", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "auditableEvidence": { + "@protected": true, + "@id": "untp:auditableEvidence", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "trustmark": { + "@protected": true, + "@id": "untp:trustmark", + "@context": { + "@protected": true, + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "imageData": { + "@id": "untp:imageData", + "@type": "xsd:string" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + } + } + }, + "conformityAssessment": { + "@id": "untp:conformityAssessment", + "@type": "@id" + } + } + }, + "LifecycleEvent": { + "@protected": true, + "@id": "untp:LifecycleEvent", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/LifecycleEvent#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "eventDate": { + "@id": "untp:eventDate", + "@type": "xsd:datetime" + }, + "sensorData": { + "@protected": true, + "@id": "untp:sensorData", + "@context": { + "@protected": true, + "metric": { + "@id": "untp:metric", + "@type": "@id" + }, + "measure": { + "@protected": true, + "@id": "untp:measure", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "rawData": { + "@protected": true, + "@id": "untp:rawData", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "sensor": { + "@id": "untp:sensor", + "@type": "@id" + }, + "geoLocation": { + "@protected": true, + "@id": "untp:geoLocation", + "@context": { + "@protected": true, + "latitude": { + "@id": "untp:latitude", + "@type": "xsd:double" + }, + "longitude": { + "@id": "untp:longitude", + "@type": "xsd:double" + } + } + } + } + }, + "relatedDocument": { + "@protected": true, + "@id": "untp:relatedDocument", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "activityType": { + "@protected": true, + "@id": "untp:activityType", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "relatedParty": { + "@protected": true, + "@id": "untp:relatedParty", + "@context": { + "@protected": true, + "role": { + "@id": "untp:role", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/PartyRole#" + } + }, + "party": { + "@id": "untp:party", + "@type": "@id" + } + } + } + } + }, + "MakeEvent": { + "@protected": true, + "@id": "untp:MakeEvent", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/MakeEvent#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "eventDate": { + "@id": "untp:eventDate", + "@type": "xsd:datetime" + }, + "sensorData": { + "@protected": true, + "@id": "untp:sensorData", + "@context": { + "@protected": true, + "metric": { + "@id": "untp:metric", + "@type": "@id" + }, + "measure": { + "@protected": true, + "@id": "untp:measure", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "rawData": { + "@protected": true, + "@id": "untp:rawData", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "sensor": { + "@id": "untp:sensor", + "@type": "@id" + }, + "geoLocation": { + "@protected": true, + "@id": "untp:geoLocation", + "@context": { + "@protected": true, + "latitude": { + "@id": "untp:latitude", + "@type": "xsd:double" + }, + "longitude": { + "@id": "untp:longitude", + "@type": "xsd:double" + } + } + } + } + }, + "relatedDocument": { + "@protected": true, + "@id": "untp:relatedDocument", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "activityType": { + "@protected": true, + "@id": "untp:activityType", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "relatedParty": { + "@protected": true, + "@id": "untp:relatedParty", + "@context": { + "@protected": true, + "role": { + "@id": "untp:role", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/PartyRole#" + } + }, + "party": { + "@id": "untp:party", + "@type": "@id" + } + } + }, + "inputProduct": { + "@protected": true, + "@id": "untp:inputProduct", + "@context": { + "@protected": true, + "product": { + "@id": "untp:product", + "@type": "@id" + }, + "quantity": { + "@protected": true, + "@id": "untp:quantity", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "disposition": { + "@id": "untp:disposition", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ProductStatus#" + } + } + } + }, + "outputProduct": { + "@protected": true, + "@id": "untp:outputProduct", + "@context": { + "@protected": true, + "product": { + "@id": "untp:product", + "@type": "@id" + }, + "quantity": { + "@protected": true, + "@id": "untp:quantity", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "disposition": { + "@id": "untp:disposition", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ProductStatus#" + } + } + } + }, + "madeAtFacility": { + "@id": "untp:madeAtFacility", + "@type": "@id" + } + } + }, + "MoveEvent": { + "@protected": true, + "@id": "untp:MoveEvent", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/MoveEvent#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "eventDate": { + "@id": "untp:eventDate", + "@type": "xsd:datetime" + }, + "sensorData": { + "@protected": true, + "@id": "untp:sensorData", + "@context": { + "@protected": true, + "metric": { + "@id": "untp:metric", + "@type": "@id" + }, + "measure": { + "@protected": true, + "@id": "untp:measure", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "rawData": { + "@protected": true, + "@id": "untp:rawData", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "sensor": { + "@id": "untp:sensor", + "@type": "@id" + }, + "geoLocation": { + "@protected": true, + "@id": "untp:geoLocation", + "@context": { + "@protected": true, + "latitude": { + "@id": "untp:latitude", + "@type": "xsd:double" + }, + "longitude": { + "@id": "untp:longitude", + "@type": "xsd:double" + } + } + } + } + }, + "relatedDocument": { + "@protected": true, + "@id": "untp:relatedDocument", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "activityType": { + "@protected": true, + "@id": "untp:activityType", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "relatedParty": { + "@protected": true, + "@id": "untp:relatedParty", + "@context": { + "@protected": true, + "role": { + "@id": "untp:role", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/PartyRole#" + } + }, + "party": { + "@id": "untp:party", + "@type": "@id" + } + } + }, + "movedProduct": { + "@protected": true, + "@id": "untp:movedProduct", + "@context": { + "@protected": true, + "product": { + "@id": "untp:product", + "@type": "@id" + }, + "quantity": { + "@protected": true, + "@id": "untp:quantity", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "disposition": { + "@id": "untp:disposition", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ProductStatus#" + } + } + } + }, + "fromFacility": { + "@id": "untp:fromFacility", + "@type": "@id" + }, + "toFacility": { + "@id": "untp:toFacility", + "@type": "@id" + }, + "consignmentId": { + "@id": "untp:consignmentId", + "@type": "xsd:anyURI" + } + } + }, + "ModifyEvent": { + "@protected": true, + "@id": "untp:ModifyEvent", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ModifyEvent#", + "name": { + "@id": "schema:name" + }, + "description": { + "@id": "schema:description" + }, + "eventDate": { + "@id": "untp:eventDate", + "@type": "xsd:datetime" + }, + "sensorData": { + "@protected": true, + "@id": "untp:sensorData", + "@context": { + "@protected": true, + "metric": { + "@id": "untp:metric", + "@type": "@id" + }, + "measure": { + "@protected": true, + "@id": "untp:measure", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "rawData": { + "@protected": true, + "@id": "untp:rawData", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "sensor": { + "@id": "untp:sensor", + "@type": "@id" + }, + "geoLocation": { + "@protected": true, + "@id": "untp:geoLocation", + "@context": { + "@protected": true, + "latitude": { + "@id": "untp:latitude", + "@type": "xsd:double" + }, + "longitude": { + "@id": "untp:longitude", + "@type": "xsd:double" + } + } + } + } + }, + "relatedDocument": { + "@protected": true, + "@id": "untp:relatedDocument", + "@context": { + "@protected": true, + "linkURL": { + "@id": "untp:linkURL", + "@type": "xsd:anyURI" + }, + "linkName": { + "@id": "untp:linkName", + "@type": "xsd:string" + }, + "digestMultibase": { + "@id": "https://w3id.org/security#digestMultibase", + "@type": "https://w3id.org/security#multibase" + }, + "mediaType": { + "@id": "https://schema.org/encodingFormat" + }, + "linkType": { + "@id": "untp:linkType", + "@type": "xsd:string" + } + } + }, + "activityType": { + "@protected": true, + "@id": "untp:activityType", + "@context": { + "@protected": true, + "code": { + "@id": "untp:code", + "@type": "xsd:string" + }, + "name": { + "@id": "schema:name" + }, + "definition": { + "@id": "untp:definition", + "@type": "xsd:string" + }, + "schemeId": { + "@id": "untp:schemeId", + "@type": "xsd:anyURI" + }, + "schemeName": { + "@id": "untp:schemeName", + "@type": "xsd:string" + } + } + }, + "relatedParty": { + "@protected": true, + "@id": "untp:relatedParty", + "@context": { + "@protected": true, + "role": { + "@id": "untp:role", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/PartyRole#" + } + }, + "party": { + "@id": "untp:party", + "@type": "@id" + } + } + }, + "modifiedProduct": { + "@protected": true, + "@id": "untp:modifiedProduct", + "@context": { + "@protected": true, + "product": { + "@id": "untp:product", + "@type": "@id" + }, + "quantity": { + "@protected": true, + "@id": "untp:quantity", + "@context": { + "@protected": true, + "value": { + "@id": "untp:value", + "@type": "xsd:double" + }, + "upperTolerance": { + "@id": "untp:upperTolerance", + "@type": "xsd:double" + }, + "lowerTolerance": { + "@id": "untp:lowerTolerance", + "@type": "xsd:double" + }, + "unit": { + "@id": "untp:unit", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/UnitMeasureCode#" + } + } + } + }, + "disposition": { + "@id": "untp:disposition", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/ProductStatus#" + } + } + } + }, + "modifiedAtFacility": { + "@id": "untp:modifiedAtFacility", + "@type": "@id" + } + } + }, + "RegisteredIdentity": { + "@protected": true, + "@id": "untp:RegisteredIdentity", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/RegisteredIdentity#", + "registeredName": { + "@id": "untp:registeredName", + "@type": "xsd:string" + }, + "registeredId": { + "@id": "untp:registeredId", + "@type": "xsd:string" + }, + "registeredDate": { + "@id": "untp:registeredDate", + "@type": "xsd:date" + }, + "publicInformation": { + "@id": "untp:publicInformation", + "@type": "xsd:anyURI" + }, + "idScheme": { + "@id": "untp:idScheme", + "@type": "@id", + "@context": { + "@protected": true, + "id": { + "@id": "untp:id", + "@type": "xsd:anyURI" + }, + "name": { + "@id": "schema:name" + } + } + }, + "registrar": { + "@id": "untp:registrar", + "@type": "@id" + }, + "registerType": { + "@id": "untp:registerType", + "@type": "@vocab", + "@context": { + "@protected": true, + "@vocab": "https://vocabulary.uncefact.org/untp/RegistryType#" + } + }, + "registrationScope": { + "@id": "untp:registrationScope", + "@type": "xsd:anyURI" + } + } + } + } +} diff --git a/tests/fixtures/upstream/v0.7.0/samples/DigitalProductPassport_battery_instance.json b/tests/fixtures/upstream/v0.7.0/samples/DigitalProductPassport_battery_instance.json new file mode 100644 index 0000000..5a39115 --- /dev/null +++ b/tests/fixtures/upstream/v0.7.0/samples/DigitalProductPassport_battery_instance.json @@ -0,0 +1,720 @@ +{ + "type": [ + "DigitalProductPassport", + "VerifiableCredential" + ], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/" + ], + "id": "https://credentials.sample-battery.example.com/dpp/bat-75kwh-2025", + "issuer": { + "type": [ + "CredentialIssuer" + ], + "id": "did:web:sample-battery.example.com", + "name": "Sample Battery Mfg GmbH", + "issuerAlsoKnownAs": [ + { + "type": [ + "Party" + ], + "id": "https://sample-register.example.com/companies/BAT-001", + "name": "Sample Battery Mfg GmbH", + "registeredId": "BAT-001", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://sample-register.example.com", + "name": "Sample Commercial Register" + } + } + ] + }, + "validFrom": "2025-03-01T00:00:00Z", + "validUntil": "2035-03-01T00:00:00Z", + "name": "Digital Product Passport — 75 kWh Li-ion Battery Pack", + "issuingSoftware": { + "id": "https://sample-software-vendor.example.com/.well-known/untp/software/passport-builder/2026.04.1", + "name": "Sample Passport Builder", + "version": "2026.04.1", + "vendor": { + "id": "did:web:sample-software-vendor.example.com", + "name": "Sample Software Vendor Inc" + } + }, + "credentialSubject": { + "type": [ + "Product" + ], + "id": "https://id.sample-battery.example.com/product/bat-75kwh-2025", + "name": "75 kWh Li-ion Battery Pack", + "description": "75 kWh NMC 811 lithium-ion battery pack for electric vehicle applications. Assembled at the Sample Battery Factory in Salzgitter, Germany. Energy density 166 Wh/kg, designed for 1500+ charge cycles.", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.sample-battery.example.com", + "name": "Sample Product Identifier Scheme" + }, + "modelNumber": "BAT-NMC811-75", + "batchNumber": "2025-SZG-0342", + "itemNumber": "BAT-75-2025-00471", + "idGranularity": "item", + "productCategory": [ + { + "code": "46410", + "name": "Primary cells and primary batteries", + "definition": "Primary cells and primary batteries and parts thereof.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + } + ], + "characteristics": { + "batteryChemistry": "NMC 811 (LiNi0.8Mn0.1Co0.1O2)", + "batteryCategory": "EV", + "ratedCapacity": { + "value": 150, + "unit": "Ah" + }, + "certifiedUsableEnergy": { + "value": 75, + "unit": "kWh" + }, + "nominalVoltage": { + "value": 400, + "unit": "V" + }, + "minimumVoltage": { + "value": 280, + "unit": "V" + }, + "maximumVoltage": { + "value": 450, + "unit": "V" + }, + "originalPowerCapability": { + "value": 250000, + "unit": "W" + }, + "maximumPermittedPower": { + "value": 270000, + "unit": "W" + }, + "initialInternalResistance": { + "cell": { + "value": 0.8, + "unit": "mOhm" + }, + "pack": { + "value": 45, + "unit": "mOhm" + } + }, + "expectedLifetimeYears": 15, + "expectedLifetimeCycles": 1500, + "capacityThresholdForExhaustion": 80, + "temperatureRangeIdleState": { + "lower": -20, + "upper": 50, + "unit": "CEL" + }, + "initialSelfDischargeRate": { + "value": 2, + "unit": "%/month" + }, + "initialRoundTripEnergyEfficiency": 95, + "extinguishingAgent": "Class D dry powder or CO2", + "warrantyPeriodMonths": 96 + }, + "relatedDocument": [ + { + "linkURL": "https://credentials.sample-vap-cab.example.com/dcc/battery-003", + "linkName": "RBA VAP Certification — Sample Battery Factory", + "mediaType": "application/ld+json", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc" + }, + { + "linkURL": "https://docs.sample-battery.example.com/dismantling/BAT-NMC811-75-manual.pdf", + "linkName": "Dismantling and Disassembly Manual", + "mediaType": "application/pdf", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dismantlingInfo" + }, + { + "linkURL": "https://docs.sample-battery.example.com/due-diligence/2025-report.pdf", + "linkName": "Supply Chain Due Diligence Report 2025", + "mediaType": "application/pdf", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dueDiligenceReport" + }, + { + "linkURL": "https://docs.sample-battery.example.com/carbon-footprint/BAT-NMC811-75-study.pdf", + "linkName": "Carbon Footprint Study — 75 kWh Battery Pack", + "mediaType": "application/pdf", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/carbonFootprintStudy" + }, + { + "linkURL": "https://docs.sample-battery.example.com/spare-parts/BAT-NMC811-75.html", + "linkName": "Spare Parts and Service Information", + "mediaType": "text/html", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/sparePartsInfo" + }, + { + "linkURL": "https://docs.sample-battery.example.com/safety/BAT-NMC811-75-measures.pdf", + "linkName": "Safety Measures", + "mediaType": "application/pdf", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/safetyMeasures" + }, + { + "linkURL": "https://docs.sample-battery.example.com/end-of-life/battery-collection-guidance.pdf", + "linkName": "Battery Collection and End-of-Life Treatment Guidance", + "mediaType": "application/pdf", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/endOfLifeInfo" + }, + { + "linkURL": "https://docs.sample-battery.example.com/conformity/eu-doc-BAT-NMC811-75.pdf", + "linkName": "EU Declaration of Conformity", + "mediaType": "application/pdf", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/euDeclarationOfConformity" + } + ], + "relatedParty": [ + { + "role": "manufacturer", + "party": { + "type": [ + "Party" + ], + "id": "did:web:sample-battery.example.com", + "name": "Sample Battery Mfg GmbH", + "registeredId": "BAT-001", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://sample-register.example.com", + "name": "Sample Commercial Register" + }, + "registrationCountry": { + "countryCode": "DE", + "countryName": "Germany" + } + } + } + ], + "producedAtFacility": { + "type": [ + "Facility" + ], + "id": "https://facility-register.example.com/fac-003", + "name": "Sample Battery Factory", + "registeredId": "fac-003", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://facility-register.example.com", + "name": "UNTP Sample Facility Register" + } + }, + "productionDate": "2025-03-01", + "countryOfProduction": { + "countryCode": "DE", + "countryName": "Germany" + }, + "dimensions": { + "weight": { + "value": 450, + "unit": "KGM" + }, + "length": { + "value": 2100, + "unit": "MMT" + }, + "width": { + "value": 1500, + "unit": "MMT" + }, + "height": { + "value": 150, + "unit": "MMT" + } + }, + "materialProvenance": [ + { + "name": "Copper cathode", + "originCountry": { + "countryCode": "JP", + "countryName": "Japan" + }, + "materialType": { + "code": "41521", + "name": "Unwrought copper", + "definition": "Copper, unrefined; copper anodes for electrolytic refining; refined copper and copper alloys, unwrought.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.08, + "mass": { + "value": 36, + "unit": "KGM" + }, + "recycledMassFraction": 0.12, + "hazardous": false + }, + { + "name": "Cobalt sulphate", + "originCountry": { + "countryCode": "CD", + "countryName": "Congo (Democratic Republic of the)" + }, + "materialType": { + "code": "14210", + "name": "Cobalt ores and concentrates", + "definition": "Cobalt ores and concentrates.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.05, + "mass": { + "value": 22.5, + "unit": "KGM" + }, + "recycledMassFraction": 0.16, + "hazardous": false + }, + { + "name": "Lithium carbonate", + "originCountry": { + "countryCode": "CL", + "countryName": "Chile" + }, + "materialType": { + "code": "14290", + "name": "Other non-ferrous metal ores and concentrates", + "definition": "Lithium, beryllium, and other non-ferrous metal ores and concentrates.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.12, + "mass": { + "value": 54, + "unit": "KGM" + }, + "recycledMassFraction": 0.06, + "hazardous": false + }, + { + "name": "Nickel sulphate", + "originCountry": { + "countryCode": "ID", + "countryName": "Indonesia" + }, + "materialType": { + "code": "14230", + "name": "Nickel ores and concentrates", + "definition": "Nickel ores and concentrates.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.35, + "mass": { + "value": 157.5, + "unit": "KGM" + }, + "recycledMassFraction": 0.08, + "hazardous": false + }, + { + "name": "Graphite (anode material)", + "originCountry": { + "countryCode": "MZ", + "countryName": "Mozambique" + }, + "materialType": { + "code": "15310", + "name": "Natural graphite", + "definition": "Natural graphite.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.18, + "mass": { + "value": 81, + "unit": "KGM" + }, + "recycledMassFraction": 0, + "hazardous": false + }, + { + "name": "Other components (electrolyte, separator, casing, BMS)", + "originCountry": { + "countryCode": "DE", + "countryName": "Germany" + }, + "materialType": { + "code": "46410", + "name": "Primary cells and primary batteries", + "definition": "Primary cells and primary batteries and parts thereof.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.22, + "mass": { + "value": 99, + "unit": "KGM" + }, + "recycledMassFraction": 0.25, + "hazardous": false + } + ], + "packaging": { + "description": "Reinforced steel transit crate with foam inserts", + "dimensions": { + "weight": { + "value": 35, + "unit": "KGM" + }, + "length": { + "value": 2300, + "unit": "MMT" + }, + "width": { + "value": 1700, + "unit": "MMT" + }, + "height": { + "value": 350, + "unit": "MMT" + } + }, + "materialUsed": [ + { + "name": "Steel crate", + "originCountry": { + "countryCode": "DE", + "countryName": "Germany" + }, + "materialType": { + "code": "41211", + "name": "Flat-rolled products of iron or non-alloy steel", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.85, + "recycledMassFraction": 0.7, + "hazardous": false + } + ] + }, + "productLabel": [ + { + "name": "CE Marking", + "description": "EU conformity marking for the battery pack", + "imageData": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "mediaType": "image/png" + }, + { + "name": "Separate Collection Symbol", + "description": "Crossed-out wheeled bin indicating separate collection requirement per EU Battery Regulation Article 13", + "imageData": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "mediaType": "image/png" + }, + { + "name": "Carbon Footprint Performance Class", + "description": "Battery carbon footprint class label (Class B) per EU Battery Regulation Article 7", + "imageData": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "mediaType": "image/png" + } + ], + "performanceClaim": [ + { + "type": [ + "Claim" + ], + "id": "https://sample-battery.example.com/claims/battery-carbon-2025", + "name": "Battery Carbon Footprint", + "description": "Cradle-to-gate carbon footprint of the 75 kWh battery pack per kWh of capacity, covering all lifecycle stages as required by EU Battery Regulation Article 7.", + "referenceRegulation": [ + { + "type": [ + "Regulation" + ], + "id": "https://eur-lex.europa.eu/eli/reg/2023/1542/oj", + "name": "EU Battery Regulation (EU) 2023/1542" + } + ], + "referenceCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://sample-scheme.responsiblebusiness.org/criteria/ghg-reporting/v8", + "name": "GHG Emissions Reporting (RBA Code of Conduct Section C.1)" + } + ], + "claimDate": "2025-03-01", + "claimedPerformance": [ + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/product-carbon-footprint", + "name": "Product Carbon Footprint" + }, + "measure": { + "value": 61, + "unit": "KGM" + }, + "score": { + "code": "B", + "rank": 2, + "definition": "Carbon footprint performance class B per EU Battery Regulation" + } + } + ], + "evidence": [ + { + "linkURL": "https://credentials.sample-cab.example.com/dcc/carbon-verification-bat-75kwh", + "linkName": "Carbon Footprint Verification — Sample CAB", + "mediaType": "application/ld+json", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc" + } + ], + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/greenhouse-gas-emissions", + "name": "Greenhouse Gas Emissions" + } + ] + }, + { + "type": [ + "Claim" + ], + "id": "https://sample-battery.example.com/claims/battery-recycled-2025", + "name": "Recycled Content — Battery Pack", + "description": "Recycled content percentages for critical raw materials (cobalt, lithium, nickel, lead) as required by EU Battery Regulation Article 8.", + "referenceRegulation": [ + { + "type": [ + "Regulation" + ], + "id": "https://eur-lex.europa.eu/eli/reg/2023/1542/oj", + "name": "EU Battery Regulation (EU) 2023/1542" + } + ], + "referenceCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://sample-scheme.responsiblebusiness.org/criteria/recycled-content/v8", + "name": "Recycled Content Requirements (RBA Code of Conduct Section C.5)" + } + ], + "claimDate": "2025-03-01", + "claimedPerformance": [ + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/recycled-content-percentage", + "name": "Recycled Content Percentage" + }, + "measure": { + "value": 16, + "unit": "P1" + } + }, + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/recycled-content-percentage", + "name": "Cobalt Recycled Content" + }, + "measure": { + "value": 16, + "unit": "P1" + } + }, + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/recycled-content-percentage", + "name": "Lithium Recycled Content" + }, + "measure": { + "value": 6, + "unit": "P1" + } + }, + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/recycled-content-percentage", + "name": "Nickel Recycled Content" + }, + "measure": { + "value": 8, + "unit": "P1" + } + } + ], + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/recycled-material-integration", + "name": "Recycled Material Integration" + } + ] + }, + { + "type": [ + "Claim" + ], + "id": "https://sample-battery.example.com/claims/battery-efficiency-2025", + "name": "Round Trip Energy Efficiency", + "description": "Initial round trip energy efficiency and projected efficiency at 50% of cycle-life.", + "referenceRegulation": [ + { + "type": [ + "Regulation" + ], + "id": "https://eur-lex.europa.eu/eli/reg/2023/1542/oj", + "name": "EU Battery Regulation (EU) 2023/1542" + } + ], + "referenceCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://sample-scheme.responsiblebusiness.org/criteria/energy-efficiency/v8", + "name": "Energy Efficiency (RBA Code of Conduct Section C.3)" + } + ], + "claimDate": "2025-03-01", + "claimedPerformance": [ + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/energy-intensity", + "name": "Initial Round Trip Energy Efficiency" + }, + "measure": { + "value": 95, + "unit": "P1" + } + }, + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/energy-intensity", + "name": "Round Trip Energy Efficiency at 50% Cycle-life" + }, + "measure": { + "value": 90, + "unit": "P1" + } + } + ], + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/energy-optimization", + "name": "Energy Optimization" + } + ] + }, + { + "type": [ + "Claim" + ], + "id": "https://sample-battery.example.com/claims/battery-due-diligence-2025", + "name": "Ethical Material Sourcing", + "description": "Compliance with supply chain due diligence obligations under EU Battery Regulation Articles 48-52 and OECD Due Diligence Guidance for Responsible Supply Chains of Minerals.", + "referenceRegulation": [ + { + "type": [ + "Regulation" + ], + "id": "https://eur-lex.europa.eu/eli/reg/2023/1542/oj", + "name": "EU Battery Regulation (EU) 2023/1542" + } + ], + "referenceStandard": [ + { + "type": [ + "Standard" + ], + "id": "https://www.oecd.org/corporate/mne/mining.htm", + "name": "OECD Due Diligence Guidance for Responsible Supply Chains of Minerals" + } + ], + "referenceCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://sample-scheme.responsiblebusiness.org/criteria/responsible-minerals/v8", + "name": "Responsible Minerals Sourcing (RBA Code of Conduct Section C.7)" + } + ], + "claimDate": "2025-03-01", + "claimedPerformance": [ + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/supplier-due-diligence-coverage", + "name": "Supplier Due Diligence Coverage" + }, + "score": { + "code": "compliant", + "rank": 1, + "definition": "Fully compliant with due diligence obligations" + } + } + ], + "evidence": [ + { + "linkURL": "https://credentials.sample-cab.example.com/dcc/due-diligence-bat-2025", + "linkName": "Third-party Due Diligence Assurance — Sample CAB", + "mediaType": "application/ld+json", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc" + } + ], + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/ethical-material-sourcing", + "name": "Ethical Material Sourcing" + } + ] + } + ] + } +} diff --git a/tests/fixtures/upstream/v0.7.0/samples/DigitalProductPassport_cathode_instance.json b/tests/fixtures/upstream/v0.7.0/samples/DigitalProductPassport_cathode_instance.json new file mode 100644 index 0000000..2e87963 --- /dev/null +++ b/tests/fixtures/upstream/v0.7.0/samples/DigitalProductPassport_cathode_instance.json @@ -0,0 +1,284 @@ +{ + "type": [ + "DigitalProductPassport", + "VerifiableCredential" + ], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/" + ], + "id": "https://credentials.sample-refinery.example.com/dpp/cu-cathode-2025", + "issuer": { + "type": [ + "CredentialIssuer" + ], + "id": "did:web:sample-refinery.example.com", + "name": "Sample Copper Refinery Co. Ltd", + "issuerAlsoKnownAs": [ + { + "type": [ + "Party" + ], + "id": "https://www.sample-register.example.com/henkorireki-johoto.html?selHouzinNo=REF-001", + "name": "Sample Copper Refinery Co. Ltd", + "registeredId": "REF-001", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://www.sample-register.example.com", + "name": "Japan Corporate Number (Houjin Bangou)" + } + } + ] + }, + "validFrom": "2025-03-01T00:00:00Z", + "validUntil": "2026-03-01T00:00:00Z", + "name": "Digital Product Passport — LME Grade A Copper Cathode", + "issuingSoftware": { + "id": "https://sample-software-vendor.example.com/.well-known/untp/software/passport-builder/2026.04.1", + "name": "Sample Passport Builder", + "version": "2026.04.1", + "vendor": { + "id": "did:web:sample-software-vendor.example.com", + "name": "Sample Software Vendor Inc" + } + }, + "credentialSubject": { + "type": [ + "Product" + ], + "id": "https://id.sample-refinery.example.com/product/cu-cathode-2025", + "name": "LME Grade A Copper Cathode", + "description": "LME Grade A copper cathode (Cu 99.99%) produced by electrolytic refining at Sample Copper Refinery. Each cathode weighs approximately 125 kg and meets London Metal Exchange delivery specifications.", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.sample-refinery.example.com", + "name": "Sample Product Identifier Scheme" + }, + "modelNumber": "SR-CU-CATH-9999", + "batchNumber": "2025-Q1-0812", + "idGranularity": "model", + "productCategory": [ + { + "code": "41521", + "name": "Unwrought copper", + "definition": "Copper, unrefined; copper anodes for electrolytic refining; refined copper and copper alloys, unwrought.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + } + ], + "relatedDocument": [ + { + "linkURL": "https://credentials.sample-cab.example.com/dcc/smelter-002", + "linkName": "Coppermark Certification — Sample Copper Refinery", + "mediaType": "application/ld+json", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc" + } + ], + "relatedParty": [ + { + "role": "manufacturer", + "party": { + "type": [ + "Party" + ], + "id": "did:web:sample-refinery.example.com", + "name": "Sample Copper Refinery Co. Ltd", + "registeredId": "REF-001", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://www.sample-register.example.com", + "name": "Japan Corporate Number (Houjin Bangou)" + }, + "registrationCountry": { + "countryCode": "JP", + "countryName": "Japan" + } + } + } + ], + "producedAtFacility": { + "type": [ + "Facility" + ], + "id": "https://facility-register.example.com/fac-002", + "name": "Sample Copper Refinery", + "registeredId": "fac-002", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://facility-register.example.com", + "name": "UNTP Sample Facility Register" + } + }, + "productionDate": "2025-03-01", + "countryOfProduction": { + "countryCode": "JP", + "countryName": "Japan" + }, + "dimensions": { + "weight": { + "value": 125, + "unit": "KGM" + } + }, + "materialProvenance": [ + { + "name": "Copper concentrate", + "originCountry": { + "countryCode": "ZM", + "countryName": "Zambia" + }, + "materialType": { + "code": "14110", + "name": "Copper ores and concentrates", + "definition": "Copper ores and concentrates obtained from mining operations.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.88, + "mass": { + "value": 110, + "unit": "KGM" + }, + "recycledMassFraction": 0, + "hazardous": false + }, + { + "name": "Recycled copper scrap", + "originCountry": { + "countryCode": "JP", + "countryName": "Japan" + }, + "materialType": { + "code": "41521", + "name": "Unwrought copper", + "definition": "Copper, unrefined; copper anodes for electrolytic refining; refined copper and copper alloys, unwrought.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.12, + "mass": { + "value": 15, + "unit": "KGM" + }, + "recycledMassFraction": 1, + "hazardous": false + } + ], + "performanceClaim": [ + { + "type": [ + "Claim" + ], + "id": "https://sample-refinery.example.com/claims/product-carbon-2025", + "name": "Product Carbon Footprint — Copper Cathode", + "description": "Cradle-to-gate carbon footprint of copper cathode per tonne produced at Sample smelter.", + "referenceCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://sample-scheme.coppermark.org/criteria/ghg-management/v3", + "name": "GHG Emissions Management (Coppermark RRA Criterion 26)", + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/greenhouse-gas-emissions", + "name": "Greenhouse Gas Emissions", + "definition": "Assessment of direct and indirect greenhouse gas emissions across scopes 1, 2, and 3, including measurement, reporting, and reduction targets aligned with climate science." + } + ] + } + ], + "claimDate": "2025-03-01", + "claimedPerformance": [ + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/product-carbon-footprint", + "name": "Product Carbon Footprint" + }, + "measure": { + "value": 3.8, + "unit": "KGM" + } + } + ], + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/greenhouse-gas-emissions", + "name": "Greenhouse Gas Emissions", + "definition": "Assessment of direct and indirect greenhouse gas emissions across scopes 1, 2, and 3, including measurement, reporting, and reduction targets aligned with climate science." + } + ] + }, + { + "type": [ + "Claim" + ], + "id": "https://sample-refinery.example.com/claims/product-recycled-2025", + "name": "Recycled Content — Copper Cathode", + "description": "Percentage of recycled copper content in cathode output at Sample smelter.", + "referenceCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://sample-scheme.coppermark.org/criteria/recycled-feedstock/v3", + "name": "Recycled Feedstock Management (Coppermark RRA Criterion 31)", + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/recycled-material-integration", + "name": "Recycled Material Integration", + "definition": "Incorporation of recycled and reclaimed materials into products and processes, promoting circular material flows and reducing demand for virgin resources." + } + ] + } + ], + "claimDate": "2025-03-01", + "claimedPerformance": [ + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/recycled-content-percentage", + "name": "Recycled Content Percentage" + }, + "measure": { + "value": 12, + "unit": "P1" + } + } + ], + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/recycled-material-integration", + "name": "Recycled Material Integration", + "definition": "Incorporation of recycled and reclaimed materials into products and processes, promoting circular material flows and reducing demand for virgin resources." + } + ] + } + ] + } +} diff --git a/tests/fixtures/upstream/v0.7.0/samples/DigitalProductPassport_instance.json b/tests/fixtures/upstream/v0.7.0/samples/DigitalProductPassport_instance.json new file mode 100644 index 0000000..b8f686c --- /dev/null +++ b/tests/fixtures/upstream/v0.7.0/samples/DigitalProductPassport_instance.json @@ -0,0 +1,263 @@ +{ + "type": [ + "DigitalProductPassport", + "VerifiableCredential" + ], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/" + ], + "id": "https://credentials.sample-mine.example.com/dpp/cu-conc-2025", + "issuer": { + "type": [ + "CredentialIssuer" + ], + "id": "did:web:sample-mine.example.com", + "name": "Sample Copper Mine Pty Ltd", + "issuerAlsoKnownAs": [ + { + "type": [ + "Party" + ], + "id": "https://sample-register.example.com/companies/MINE-001", + "name": "Sample Copper Mine Pty Ltd", + "registeredId": "MINE-001", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://sample-register.example.com", + "name": "Patents and Companies Registration Agency (Zambia)" + } + } + ] + }, + "validFrom": "2025-03-01T00:00:00Z", + "validUntil": "2026-03-01T00:00:00Z", + "name": "Digital Product Passport — Copper Concentrate (Cu 30%)", + "issuingSoftware": { + "id": "https://sample-software-vendor.example.com/.well-known/untp/software/passport-builder/2026.04.1", + "name": "Sample Passport Builder", + "version": "2026.04.1", + "vendor": { + "id": "did:web:sample-software-vendor.example.com", + "name": "Sample Software Vendor Inc" + } + }, + "credentialSubject": { + "type": [ + "Product" + ], + "id": "https://id.sample-mine.example.com/product/cu-conc-2025", + "name": "Copper Concentrate (Cu 30%)", + "description": "Copper sulphide flotation concentrate with approximately 30% copper content, produced at Sample Copper Mine. Suitable for smelting to produce refined copper cathode.", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.sample-mine.example.com", + "name": "Sample Product Identifier Scheme" + }, + "modelNumber": "SM-CU-CONC-30", + "batchNumber": "2025-Q1-4501", + "idGranularity": "model", + "productCategory": [ + { + "code": "14110", + "name": "Copper ores and concentrates", + "definition": "Copper ores and concentrates obtained from mining operations.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + } + ], + "relatedDocument": [ + { + "linkURL": "https://credentials.sample-cab.example.com/dcc/mine-001", + "linkName": "Coppermark Certification — Sample Copper Mine", + "mediaType": "application/ld+json", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc" + } + ], + "relatedParty": [ + { + "role": "manufacturer", + "party": { + "type": [ + "Party" + ], + "id": "did:web:sample-mine.example.com", + "name": "Sample Copper Mine Pty Ltd", + "registeredId": "MINE-001", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://sample-register.example.com", + "name": "Patents and Companies Registration Agency (Zambia)" + }, + "registrationCountry": { + "countryCode": "ZM", + "countryName": "Zambia" + } + } + } + ], + "producedAtFacility": { + "type": [ + "Facility" + ], + "id": "https://facility-register.example.com/fac-001", + "name": "Sample Copper Mine", + "registeredId": "fac-001", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://facility-register.example.com", + "name": "UNTP Sample Facility Register" + } + }, + "productionDate": "2025-03-01", + "countryOfProduction": { + "countryCode": "ZM", + "countryName": "Zambia" + }, + "dimensions": { + "weight": { + "value": 1000, + "unit": "KGM" + } + }, + "materialProvenance": [ + { + "name": "Copper ore", + "originCountry": { + "countryCode": "ZM", + "countryName": "Zambia" + }, + "materialType": { + "code": "14110", + "name": "Copper ores and concentrates", + "definition": "Copper ores and concentrates obtained from mining operations.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.3, + "mass": { + "value": 300, + "unit": "KGM" + }, + "recycledMassFraction": 0, + "hazardous": false + } + ], + "performanceClaim": [ + { + "type": [ + "Claim" + ], + "id": "https://sample-mine.example.com/claims/product-carbon-2025", + "name": "Product Carbon Footprint — Copper Concentrate", + "description": "Cradle-to-gate carbon footprint of copper concentrate per tonne produced at Sample mine.", + "referenceCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://sample-scheme.coppermark.org/criteria/ghg-management/v3", + "name": "GHG Emissions Management (Coppermark RRA Criterion 26)", + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/greenhouse-gas-emissions", + "name": "Greenhouse Gas Emissions", + "definition": "Assessment of direct and indirect greenhouse gas emissions across scopes 1, 2, and 3, including measurement, reporting, and reduction targets aligned with climate science." + } + ] + } + ], + "claimDate": "2025-03-01", + "claimedPerformance": [ + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/product-carbon-footprint", + "name": "Product Carbon Footprint" + }, + "measure": { + "value": 2.1, + "unit": "KGM" + } + } + ], + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/greenhouse-gas-emissions", + "name": "Greenhouse Gas Emissions", + "definition": "Assessment of direct and indirect greenhouse gas emissions across scopes 1, 2, and 3, including measurement, reporting, and reduction targets aligned with climate science." + } + ] + }, + { + "type": [ + "Claim" + ], + "id": "https://sample-mine.example.com/claims/product-water-2025", + "name": "Water Intensity — Copper Concentrate", + "description": "Water consumption per tonne of copper concentrate produced at Sample mine.", + "referenceCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://sample-scheme.coppermark.org/criteria/water-stewardship/v3", + "name": "Water Stewardship (Coppermark RRA Criterion 27)", + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/water-conservation", + "name": "Water Conservation", + "definition": "Efficient and responsible management of water resources, including reduction of water consumption, recycling, and protection of water quality in operations and supply chains." + } + ] + } + ], + "claimDate": "2025-03-01", + "claimedPerformance": [ + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/water-intensity", + "name": "Water Intensity" + }, + "measure": { + "value": 15, + "unit": "MTQ" + } + } + ], + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/water-conservation", + "name": "Water Conservation", + "definition": "Efficient and responsible management of water resources, including reduction of water consumption, recycling, and protection of water quality in operations and supply chains." + } + ] + } + ] + } +} diff --git a/tests/fixtures/upstream/v0.7.0/schema/DigitalProductPassport.json b/tests/fixtures/upstream/v0.7.0/schema/DigitalProductPassport.json new file mode 100644 index 0000000..dcdf7db --- /dev/null +++ b/tests/fixtures/upstream/v0.7.0/schema/DigitalProductPassport.json @@ -0,0 +1,1455 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "DigitalProductPassport", + "VerifiableCredential" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "DigitalProductPassport", + "minContains": 1 + } + }, + { + "contains": { + "const": "VerifiableCredential", + "minContains": 1 + } + } + ] + }, + "@context": { + "example": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/" + ], + "type": "array", + "items": { + "type": "string" + }, + "description": "A list of JSON-LD context URIs that define the semantic meaning of properties within the credential. ", + "readOnly": true, + "prefixItems": [ + { + "const": "https://www.w3.org/ns/credentials/v2", + "type": "string" + }, + { + "const": "https://vocabulary.uncefact.org/untp/0.7.0/context/", + "type": "string" + } + ], + "default": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/" + ], + "minItems": 2, + "uniqueItems": true + }, + "id": { + "example": "https://example-company.com/credentials/2a423366-a0d6-4855-ba65-2e0c926d09b0", + "type": "string", + "format": "uri", + "description": "A unique identifier (URI) assigned to this verifiable credential." + }, + "issuer": { + "$ref": "#/$defs/CredentialIssuer", + "description": "The organisation that is the issuer of this VC. Note that the \"id\" property MUST be a W3C DID. Other identifiers such as tax registration numbers can be listed in the \"otherIdentifiers\" property." + }, + "validFrom": { + "example": "2024-03-15T12:00:00Z", + "type": "string", + "format": "date-time", + "description": "The date and time from which the credential is valid." + }, + "validUntil": { + "example": "2034-03-15T12:00:00Z", + "type": "string", + "format": "date-time", + "description": "The expiry date (if applicable) of this verifiable credential." + }, + "name": { + "example": "Some name", + "type": "string", + "description": "Name of this verifiable credential instance (eg the title of a digital product passport, facility record, lifecycle event, or conformity credential)" + }, + "credentialStatus": { + "$ref": "#/$defs/BitstringStatusListEntry", + "description": "A W3C VCDM2.0 compliant object containing credential status information." + }, + "renderMethod": { + "type": "array", + "items": { + "$ref": "#/$defs/RenderTemplate2024" + }, + "description": "Human rendering information for this credential. An array of render methods (eg RenderTemplate2024) that may be used to display the credential." + }, + "credentialSubject": { + "$ref": "#/$defs/Product", + "description": "The product that is the subject of this digital product passport." + }, + "issuingSoftware": { + "$ref": "#/$defs/IssuingSoftware", + "description": "Optional metadata identifying the software product (and its vendor) that issued this credential. Useful for vendor traceability and conformity testing. Issuers MAY omit this property." + } + }, + "description": "A digital Product Passport (DPP) credential.", + "required": [ + "@context", + "id", + "issuer", + "validFrom", + "name", + "credentialSubject" + ], + "$defs": { + "CredentialIssuer": { + "type": "object", + "additionalProperties": true, + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "CredentialIssuer" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "CredentialIssuer", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "did:web:identifiers.example-company.com:12345", + "type": "string", + "format": "uri", + "description": "The W3C DID of the issuer - should be a did:web or did:webvh" + }, + "name": { + "example": "Example Company Pty Ltd", + "type": "string", + "description": "The name of the issuer person or organisation" + }, + "issuerAlsoKnownAs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Party" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Party", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://sample-business-register.gov/123456789", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this party. Typically represented as a URI identifierScheme/Identifier URI" + }, + "name": { + "example": "Sample Company Ltd", + "type": "string", + "description": "Legal registered name of this party." + }, + "registeredId": { + "example": 90664869327, + "type": "string", + "description": "The registration number (alphanumeric) of the Party within the register. Unique within the register." + } + }, + "required": [ + "id", + "name" + ] + }, + "description": "An optional list of other registered identifiers for this credential issuer " + } + }, + "description": "The issuer party (person or organisation) of a verifiable credential.", + "required": [ + "id", + "name" + ] + }, + "BitstringStatusListEntry": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "BitstringStatusListEntry" + ], + "example": "BitstringStatusListEntry", + "description": "The type of status list - must be set to \"The type property MUST be BitstringStatusListEntry.\"" + }, + "id": { + "example": "https://example-cab.com/credentials/status/3#94567\"", + "type": "string", + "format": "uri", + "description": "optional identifier of this status list entry." + }, + "statusPurpose": { + "type": "string", + "enum": [ + "refresh", + "revocation", + "suspension", + "message" + ], + "example": "refresh", + "description": "Status purpose drawn from a standard list but extensible as per w3c bitstring status list specification." + }, + "statusListIndex": { + "minimum": 0, + "example": 94567, + "type": "integer", + "description": "\tThe statusListIndex property MUST be an arbitrary size integer greater than or equal to 0, expressed as a string in base 10. The value identifies the position of the status of the verifiable credential." + }, + "statusListCredential": { + "example": "https://example-cab.com/credentials/status/4", + "type": "string", + "format": "uri", + "description": "The statusListCredential property MUST be a URL to a verifiable credential. When the URL is dereferenced, the resulting verifiable credential MUST have type property that includes the BitstringStatusListCredential value." + } + }, + "description": "A privacy-preserving, space-efficient, and high-performance mechanism for publishing status information such as suspension or revocation of Verifiable Credentials through use of bitstrings. See https://www.w3.org/TR/vc-bitstring-status-list/ for full details.", + "required": [ + "type", + "statusPurpose", + "statusListIndex", + "statusListCredential" + ] + }, + "RenderTemplate2024": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "RenderTemplate2024" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "RenderTemplate2024", + "minContains": 1 + } + } + ] + }, + "name": { + "type": "string", + "description": "Human facing display name for selection" + }, + "mediaQuery": { + "type": "string", + "description": "Media query as defined in https://www.w3.org/TR/mediaqueries-4/" + }, + "template": { + "type": "string", + "description": "An inline template field for use cases where remote retrieval of a render method is suboptimal" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL for remotely hosted template" + }, + "mediaType": { + "type": "string", + "description": "media type of the rendered output (eg text/html)" + }, + "digestMultibase": { + "type": "string", + "description": "Used for resource integrity and/or validation of the inline `template`" + } + }, + "description": "A single template format focused render method where the content/media type decision becomes secondary (and is expressed separately).See https://github.com/w3c-ccg/vc-render-method/issues/9" + }, + "IssuingSoftware": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "example": "https://yourdomain.com/.well-known/untp/software/yourproduct/2026.04.1", + "type": "string", + "format": "uri", + "description": "A resolvable identifier for the specific version of the software product that issued this credential." + }, + "name": { + "example": "Your Product Name", + "type": "string", + "description": "The name of the software product that issued this credential." + }, + "version": { + "example": "2026.04.1", + "type": "string", + "description": "The version of the software product that issued this credential." + }, + "vendor": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "example": "did:web:yourdomain.com", + "type": "string", + "format": "uri", + "description": "The decentralised identifier (DID) or other resolvable identifier of the software vendor." + }, + "name": { + "example": "Your Vendor Name", + "type": "string", + "description": "The name of the software vendor." + } + }, + "required": [ + "id", + "name" + ], + "description": "The vendor of the software product that issued this credential." + } + }, + "required": [ + "id", + "name", + "version", + "vendor" + ], + "description": "Optional metadata identifying the software product (and its vendor) that issued the parent credential. When present, all listed sub-properties MUST be populated; when absent, the credential is still valid (verifiers MUST treat the property as optional)." + }, + "Product": { + "type": "object", + "additionalProperties": true, + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Product" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Product", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "did:web:manufacturer.com:product:123456789", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this product. Typically represented as a URI identifierScheme/Identifier URI or, if self-issued, as a did." + }, + "name": { + "example": "600 Ah Lithium Battery", + "type": "string", + "description": "The product name as known to the market." + }, + "description": { + "type": "string", + "description": "Description of the product." + }, + "idScheme": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "IdentifierScheme" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "IdentifierScheme", + "minContains": 1 + } + } + ] + }, + "id": { + "type": "string", + "format": "uri", + "description": "The URI of this identifier scheme" + }, + "name": { + "example": "Global Identifier Scheme Name", + "type": "string", + "description": "The name of the identifier scheme. " + } + }, + "required": [ + "id", + "name" + ], + "description": "The identifier scheme for this product. Eg a GS1 GTIN or an AU Livestock NLIS, or similar. If self issued then use the party ID of the issuer. " + }, + "modelNumber": { + "type": "string", + "description": "Where available, the model number (for manufactured products) or material identification (for bulk materials)" + }, + "batchNumber": { + "example": 6789, + "type": "string", + "description": "Identifier of the specific production batch of the product. Unique within the product class." + }, + "itemNumber": { + "example": 12345678, + "type": "string", + "description": "A number or code representing a specific serialised item of the product. Unique within product class." + }, + "idGranularity": { + "type": "string", + "enum": [ + "model", + "batch", + "item" + ], + "example": "model", + "description": "The identification granularity for this product (item, batch, model)" + }, + "productImage": { + "$ref": "#/$defs/Link", + "description": "Reference information (location, type, name) of an image of the product." + }, + "characteristics": { + "$ref": "#/$defs/Characteristics", + "description": "A set of industry specific product information. " + }, + "productCategory": { + "type": "array", + "items": { + "$ref": "#/$defs/Classification" + }, + "description": "A code representing the product's class, typically using the UN CPC (United Nations Central Product Classification) https://unstats.un.org/unsd/classifications/Econ/cpc" + }, + "relatedDocument": { + "type": "array", + "items": { + "$ref": "#/$defs/Link" + }, + "description": "A list of links to documents providing additional product information. Documents that support a conformity claim (e.g. permits or certificates) SHOULD be referenced as claim evidence rather than here." + }, + "relatedParty": { + "type": "array", + "items": { + "$ref": "#/$defs/PartyRole" + }, + "description": "A list of parties with a defined relationship to this product" + }, + "producedAtFacility": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Facility" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Facility", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://sample-location-register.com/987654321", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this facility. Typically represented as a URI identifierScheme/Identifier URI" + }, + "name": { + "example": "Sample Factory A", + "type": "string", + "description": "Name of this facility as defined the location register." + }, + "registeredId": { + "example": 1234567, + "type": "string", + "description": "The registration number (alphanumeric) of the facility within the identifier scheme. Unique within the register." + } + }, + "required": [ + "id", + "name" + ], + "description": "The Facility where the product batch was produced / manufactured." + }, + "productionDate": { + "example": "2024-04-25", + "type": "string", + "format": "date", + "description": "The ISO 8601 date on which the product batch or individual serialised item was manufactured." + }, + "expiryDate": { + "example": "2027-04-25", + "type": "string", + "format": "date", + "description": "The date at which this product is no longer fit for use. Typically used for a food product use-by date but may also represent the usable life of any product." + }, + "countryOfProduction": { + "$ref": "#/$defs/Country", + "description": "The country in which this item was produced / manufactured.using ISO-3166 code and name." + }, + "dimensions": { + "$ref": "#/$defs/Dimension", + "description": "The physical dimensions of the product. Not every dimension is relevant to every products. For example bulk materials may have weight and volume but not length, width, or height.\"weight\":{\"value\":10, \"unit\":\"KGM\"}" + }, + "materialProvenance": { + "type": "array", + "items": { + "$ref": "#/$defs/Material" + }, + "description": "A list of materials provenance objects providing details on the origin and mass fraction of materials of the product or batch." + }, + "packaging": { + "$ref": "#/$defs/Package", + "description": "The packaging for this product." + }, + "productLabel": { + "type": "array", + "items": { + "$ref": "#/$defs/Image" + }, + "description": "An array of labels that may appear on the product such as certification marks or regulatory labels." + }, + "performanceClaim": { + "type": "array", + "items": { + "$ref": "#/$defs/Claim" + }, + "description": "A list of performance claims (eg emissions intensity) for this product." + } + }, + "description": "The ProductInformation class encapsulates detailed information regarding a specific product, including its identification details, manufacturer, and other pertinent details.", + "required": [ + "id", + "name", + "idScheme", + "idGranularity", + "productCategory", + "producedAtFacility", + "countryOfProduction" + ] + }, + "Link": { + "type": "object", + "additionalProperties": false, + "properties": { + "linkURL": { + "example": "https://files.example-certifier.com/1234567.json", + "type": "string", + "format": "uri", + "description": "The URL of the target resource. " + }, + "linkName": { + "type": "string", + "description": "Display name for this link." + }, + "digestMultibase": { + "example": "abc123-example-digest-invalid", + "type": "string", + "description": "An optional multi-base encoded digest to ensure the content of the link has not changed. See https://www.w3.org/TR/vc-data-integrity/#resource-integrity for more information." + }, + "mediaType": { + "example": "application/ld+json", + "type": "string", + "description": "The media type of the target resource." + }, + "linkType": { + "example": "https://test.uncefact.org/vocabulary/linkTypes/dcc", + "type": "string", + "description": "The type of the target resource - drawn from a controlled vocabulary " + } + }, + "description": "A structure to provide a URL link plus metadata associated with the link.", + "required": [ + "linkURL", + "linkName" + ] + }, + "Characteristics": { + "type": "object", + "additionalProperties": true, + "properties": {}, + "description": "A declaration of conformance with one or more criteria from a specific standard or regulation. " + }, + "Classification": { + "type": "object", + "additionalProperties": false, + "properties": { + "code": { + "example": 46410, + "type": "string", + "description": "classification code within the scheme" + }, + "name": { + "example": "Primary cells and primary batteries", + "type": "string", + "description": "Name of the classification represented by the code" + }, + "definition": { + "type": "string", + "description": "A rich definition of this classification code." + }, + "schemeId": { + "example": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "type": "string", + "format": "uri", + "description": "Classification scheme ID" + }, + "schemeName": { + "example": "UN Central Product Classification (CPC)", + "type": "string", + "description": "The name of the classification scheme" + } + }, + "description": "A classification scheme and code / name representing a category value for a product, entity, or facility.", + "required": [ + "code", + "name", + "schemeId", + "schemeName" + ] + }, + "PartyRole": { + "type": "object", + "additionalProperties": false, + "properties": { + "role": { + "type": "string", + "enum": [ + "owner", + "producer", + "manufacturer", + "processor", + "remanufacturer", + "recycler", + "operator", + "serviceProvider", + "inspector", + "certifier", + "logisticsProvider", + "carrier", + "consignor", + "consignee", + "importer", + "exporter", + "distributor", + "retailer", + "brandOwner", + "regulator" + ], + "example": "owner", + "description": "The role played by the party in this relationship" + }, + "party": { + "$ref": "#/$defs/Party", + "description": "The party that has the specified role." + } + }, + "description": "A party with a defined relationship to the referencing entity", + "required": [ + "role", + "party" + ] + }, + "Party": { + "type": "object", + "additionalProperties": true, + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Party" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Party", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://sample-business-register.gov/123456789", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this party. Typically represented as a URI identifierScheme/Identifier URI" + }, + "name": { + "example": "Sample Company Ltd", + "type": "string", + "description": "Legal registered name of this party." + }, + "description": { + "type": "string", + "description": "Description of the party including function and other names." + }, + "registeredId": { + "example": 90664869327, + "type": "string", + "description": "The registration number (alphanumeric) of the Party within the register. Unique within the register." + }, + "idScheme": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "IdentifierScheme" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "IdentifierScheme", + "minContains": 1 + } + } + ] + }, + "id": { + "type": "string", + "format": "uri", + "description": "The URI of this identifier scheme" + }, + "name": { + "example": "Global Identifier Scheme Name", + "type": "string", + "description": "The name of the identifier scheme. " + } + }, + "required": [ + "id", + "name" + ], + "description": "The identifier scheme of the party. Typically a national business register or a global scheme such as GLEIF. " + }, + "registrationCountry": { + "$ref": "#/$defs/Country", + "description": "the country in which this organisation is registered - using ISO-3166 code and name." + }, + "partyAddress": { + "$ref": "#/$defs/Address", + "description": "The address of the party" + }, + "organisationWebsite": { + "example": "https://example-company.com", + "type": "string", + "format": "uri", + "description": "Website for this organisation" + }, + "industryCategory": { + "type": "array", + "items": { + "$ref": "#/$defs/Classification" + }, + "description": "The industry categories for this organisation. Recommend use of UNCPC as the category scheme. for example - unstats.un.org/isic/1030" + }, + "partyAlsoKnownAs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Party" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Party", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://sample-business-register.gov/123456789", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this party. Typically represented as a URI identifierScheme/Identifier URI" + }, + "name": { + "example": "Sample Company Ltd", + "type": "string", + "description": "Legal registered name of this party." + }, + "registeredId": { + "example": 90664869327, + "type": "string", + "description": "The registration number (alphanumeric) of the Party within the register. Unique within the register." + } + }, + "required": [ + "id", + "name" + ] + }, + "description": "An optional list of other registered identifiers for this organisation. For example DUNS, GLN, LEI, etc" + } + }, + "description": "An organisation. May be a supply chain actor, a certifier, a government agency.", + "required": [ + "id", + "name" + ] + }, + "Country": { + "type": "object", + "additionalProperties": false, + "properties": { + "countryCode": { + "type": "string", + "x-external-enumeration": "https://vocabulary.uncefact.org/CountryId#", + "description": "ISO 3166 country code\n\n This is an enumerated value, but the list of valid values are too big, or change too often to include here. You can access the list of allowable values at this URL: https://vocabulary.uncefact.org/CountryId#\n " + }, + "countryName": { + "type": "string", + "description": "Country Name as defined in ISO 3166" + } + }, + "description": "Country Code and Name from ISO 3166", + "required": [ + "countryCode" + ] + }, + "Address": { + "type": "object", + "additionalProperties": false, + "properties": { + "streetAddress": { + "example": "level 11, 15 London Circuit", + "type": "string", + "description": "the street address as an unstructured string." + }, + "postalCode": { + "example": 2601, + "type": "string", + "description": "The postal code or zip code for this address." + }, + "addressLocality": { + "example": "Acton", + "type": "string", + "description": "The city, suburb or township name." + }, + "addressRegion": { + "example": "ACT", + "type": "string", + "description": "The state or territory or province" + }, + "addressCountry": { + "$ref": "#/$defs/Country", + "description": "The address country as an ISO-3166 two letter country code and name." + } + }, + "description": "A postal address.", + "required": [ + "streetAddress", + "postalCode", + "addressLocality", + "addressRegion", + "addressCountry" + ] + }, + "Dimension": { + "type": "object", + "additionalProperties": true, + "properties": { + "weight": { + "$ref": "#/$defs/Measure", + "description": "the weight of the product. EG {\"value\":10, \"unit\":\"KGM\"}" + }, + "length": { + "$ref": "#/$defs/Measure", + "description": "The length of the product or packaging eg {\"value\":840, \"unit\":\"MMT\"}" + }, + "width": { + "$ref": "#/$defs/Measure", + "description": "The width of the product or packaging. eg {\"value\":150, \"unit\":\"MMT\"}" + }, + "height": { + "$ref": "#/$defs/Measure", + "description": "The height of the product or packaging. eg {\"value\":220, \"unit\":\"MMT\"}" + }, + "volume": { + "$ref": "#/$defs/Measure", + "description": "The displacement volume of the product. eg {\"value\":7.5, \"unit\":\"LTR\"}" + } + }, + "description": "Overall (length, width, height) dimensions and weight/volume of an item." + }, + "Measure": { + "type": "object", + "additionalProperties": false, + "properties": { + "value": { + "example": 10, + "type": "number", + "description": "The numeric value of the measure" + }, + "upperTolerance": { + "type": "number", + "description": "The upper tolerance associated with this measure expressed in the same units as the measure. For example value=10, upperTolerance=0.1, unit=KGM would mean that this measure is 10kg + 0.1kg" + }, + "lowerTolerance": { + "type": "number", + "description": "The lower tolerance associated with this measure expressed in the same units as the measure. For example value=10, lowerTolerance=0.1, unit=KGM would mean that this measure is 10kg - 0.1kg" + }, + "unit": { + "type": "string", + "x-external-enumeration": "https://vocabulary.uncefact.org/UnitMeasureCode#", + "description": "Unit of measure drawn from the UNECE Rec20 measure code list.\n\n This is an enumerated value, but the list of valid values are too big, or change too often to include here. You can access the list of allowable values at this URL: https://vocabulary.uncefact.org/UnitMeasureCode#\n " + } + }, + "description": "The measure class defines a numeric measured value (eg 10) and a coded unit of measure (eg KG). There is an optional upper and lower tolerance which can be used to specify uncertainty in the measure. ", + "required": [ + "value", + "unit" + ] + }, + "Material": { + "type": "object", + "additionalProperties": true, + "properties": { + "name": { + "example": "Lithium Spodumene", + "type": "string", + "description": "Name of this material (eg \"Egyptian Cotton\")" + }, + "originCountry": { + "$ref": "#/$defs/Country", + "description": "A ISO 3166-1 code representing the country of origin of the component or ingredient." + }, + "materialType": { + "$ref": "#/$defs/Classification", + "description": "The type of this material - as a value drawn from a controlled vocabulary eg from UN Framework Classification for Resources (UNFC)." + }, + "massFraction": { + "maximum": 1, + "minimum": 0, + "example": 0.2, + "type": "number", + "description": "The mass fraction as a decimal of the product (or facility reporting period) represented by this material. " + }, + "mass": { + "$ref": "#/$defs/Measure", + "description": "The mass of the material component." + }, + "recycledMassFraction": { + "maximum": 1, + "minimum": 0, + "example": 0.5, + "type": "number", + "description": "Mass fraction of this material that is recycled (eg 50% recycled Lithium)" + }, + "hazardous": { + "type": "boolean", + "description": "Indicates whether this material is hazardous. If true then the materialSafetyInformation property must be present" + }, + "symbol": { + "$ref": "#/$defs/Image", + "description": "Based 64 encoded binary used to represent a visual symbol for a given material. " + }, + "materialSafetyInformation": { + "$ref": "#/$defs/Link", + "description": "Reference to further information about safe handling of this hazardous material (for example a link to a material safety data sheet)" + } + }, + "description": "The material class encapsulates details about the origin or source of raw materials in a product, including the country of origin and the mass fraction.", + "required": [ + "name", + "originCountry", + "materialType", + "massFraction" + ] + }, + "Image": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "example": "certification trust mark", + "type": "string", + "description": "the display name for this image" + }, + "description": { + "type": "string", + "description": "The detailed description / supporting information for this image." + }, + "imageData": { + "type": "string", + "format": "byte", + "description": "The image data encoded as a base64 string." + }, + "mediaType": { + "type": "string", + "x-external-enumeration": "https://mimetype.io/", + "description": "The media type of this image (eg image/png)\n\n This is an enumerated value, but the list of valid values are too big, or change too often to include here. You can access the list of allowable values at this URL: https://mimetype.io/\n " + } + }, + "description": "A binary image encoded as base64 text and embedded into the data. Use this for small images like certification trust marks or regulated labels. Large images should be external links.", + "required": [ + "name", + "imageData", + "mediaType" + ] + }, + "Package": { + "type": "object", + "additionalProperties": true, + "properties": { + "description": { + "type": "string", + "description": "Description of the packaging." + }, + "dimensions": { + "$ref": "#/$defs/Dimension", + "description": "dimensions of the packaging" + }, + "materialUsed": { + "type": "array", + "items": { + "$ref": "#/$defs/Material" + }, + "description": "materials used for the packaging." + }, + "packageLabel": { + "type": "array", + "items": { + "$ref": "#/$defs/Image" + }, + "description": "An array of package labels that may appear on the packaging together with their meaning. Use for small images that represent certification marks or regulatory requirements. Large images should be linked as evidence to claims." + }, + "performanceClaim": { + "type": "array", + "items": { + "$ref": "#/$defs/Claim" + }, + "description": "conformity claims made about the packaging." + } + }, + "description": "Details of product packaging", + "required": [ + "description", + "dimensions", + "materialUsed" + ] + }, + "Claim": { + "type": "object", + "additionalProperties": true, + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Claim" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Claim", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://sample-company.com/claim/e78dab5d-b6f6-4bc4-a458-7feb039f6cb3", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this claim. Typically represented as a URI companyURL/claimID URI or a UUID" + }, + "name": { + "example": "Sample company Forced Labour claim", + "type": "string", + "description": "Name of this claim - typically similar or the same as the referenced criterion name." + }, + "description": { + "type": "string", + "description": "Description of this conformity claim" + }, + "referenceCriteria": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Criterion" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Criterion", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://vocabulary.sample-scheme.org/criterion/lb/v1.0", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this conformity criterion. Typically represented as a URI SchemeOwner/CriterionID URI" + }, + "name": { + "example": "Forced labour assessment criterion", + "type": "string", + "description": "Name of this criterion as defined by the scheme owner." + } + }, + "required": [ + "id", + "name" + ] + }, + "description": "The criterion against which the claim is made." + }, + "referenceRegulation": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Regulation" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Regulation", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://regulations.country.gov/ABC-12345", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this standard. Typically represented as a URI government/regulation URI" + }, + "name": { + "example": "Due Diligence Directove", + "type": "string", + "description": "Name of this regulation as defined by the regulator." + } + }, + "required": [ + "id", + "name" + ] + }, + "description": "List of references to regulation to which conformity is claimed claimed for this product" + }, + "referenceStandard": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Standard" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Standard", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://sample-standards.org/A1234", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this standard. Typically represented as a URI issuer/standard URI" + }, + "name": { + "example": "Labour rights standard", + "type": "string", + "description": "Name for this standard" + } + }, + "required": [ + "id", + "name" + ] + }, + "description": "List of references to standards to which conformity is claimed claimed for this product" + }, + "claimDate": { + "type": "string", + "format": "date", + "description": "That date on which the claimed performance is applicable." + }, + "applicablePeriod": { + "$ref": "#/$defs/Period", + "description": "The applicable reporting period for this facility record." + }, + "claimedPerformance": { + "type": "array", + "items": { + "$ref": "#/$defs/Performance" + }, + "description": "The claimed performance level " + }, + "evidence": { + "type": "array", + "items": { + "$ref": "#/$defs/Link" + }, + "description": "A URI pointing to the evidence supporting the claim. SHOULD be a URL to a UNTP Digital Conformity Credential (DCC)" + }, + "conformityTopic": { + "type": "array", + "items": { + "$ref": "#/$defs/ConformityTopic" + }, + "description": "The conformity topic category for this assessment" + } + }, + "description": "A performance claim about a product, facility, or organisation that is made against a well defined criterion.", + "required": [ + "id", + "name", + "referenceCriteria", + "claimDate", + "claimedPerformance", + "conformityTopic" + ] + }, + "Period": { + "type": "object", + "additionalProperties": false, + "properties": { + "startDate": { + "type": "string", + "format": "date", + "description": "The period start date" + }, + "endDate": { + "type": "string", + "format": "date", + "description": "The period end date" + }, + "periodInformation": { + "type": "string", + "description": "Additional information relevant to this reporting period" + } + }, + "description": "A period of time, typically a month, quarter or a year, which defines the context boundary for reported facts.", + "required": [ + "startDate", + "endDate" + ] + }, + "Performance": { + "type": "object", + "additionalProperties": false, + "properties": { + "metric": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "PerformanceMetric" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "PerformanceMetric", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://authority.gov/schemeABC/123456789", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this reporting metric. " + }, + "name": { + "example": "emissions intensity", + "type": "string", + "description": "A human readable name for this metric (for example \"water usage per Kg of material\")" + } + }, + "required": [ + "id", + "name" + ], + "description": "The metric (eg material emissions intensity CO2e/Kg or percentage of young workers) that is measured." + }, + "measure": { + "$ref": "#/$defs/Measure", + "description": "The measured performance value" + }, + "score": { + "$ref": "#/$defs/Score", + "description": "A performance score (eg \"AA\") drawn from a scoring framework defined by the scheme or criterion." + } + }, + "description": "A claimed, assessed, or required performance level defined either by a scoring system or a numeric measure. When a numeric measure is provided, the metric classifying the measurement is required. When only a score is provided, the scoring framework is discoverable via the conformity scheme or criterion.", + "dependentRequired": { + "measure": [ + "metric" + ] + } + }, + "Score": { + "type": "object", + "additionalProperties": false, + "properties": { + "code": { + "type": "string", + "description": "The coded value for this score (eg \"AAA\")" + }, + "rank": { + "type": "integer", + "description": "The ranking of this score within the scoring framework - using an integer where \"1\" is the highest rank." + }, + "definition": { + "type": "string", + "description": "A description of the meaning of this score." + } + }, + "description": "A single score within a scoring framework. ", + "required": [ + "code" + ] + }, + "ConformityTopic": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "ConformityTopic" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "ConformityTopic", + "minContains": 1 + } + } + ] + }, + "id": { + "type": "string", + "format": "uri", + "description": "The unique identifier for this conformity topic" + }, + "name": { + "example": "forced-labour", + "type": "string", + "description": "The human readable name for this conformity topic." + }, + "definition": { + "type": "string", + "description": "The rich definition of this conformity topic." + } + }, + "description": "The UNTP standard classification scheme for conformity topic. see http://vocabulary.uncefact.org/ConformityTopic", + "required": [ + "id", + "name" + ] + } + } +} diff --git a/tests/fixtures/upstream/v0.7.0/schema/Product.json b/tests/fixtures/upstream/v0.7.0/schema/Product.json new file mode 100644 index 0000000..ab0cb99 --- /dev/null +++ b/tests/fixtures/upstream/v0.7.0/schema/Product.json @@ -0,0 +1,1121 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Product" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Product", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "did:web:manufacturer.com:product:123456789", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this product. Typically represented as a URI identifierScheme/Identifier URI or, if self-issued, as a did." + }, + "name": { + "example": "600 Ah Lithium Battery", + "type": "string", + "description": "The product name as known to the market." + }, + "description": { + "type": "string", + "description": "Description of the product." + }, + "idScheme": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "IdentifierScheme" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "IdentifierScheme", + "minContains": 1 + } + } + ] + }, + "id": { + "type": "string", + "format": "uri", + "description": "The URI of this identifier scheme" + }, + "name": { + "example": "Global Identifier Scheme Name", + "type": "string", + "description": "The name of the identifier scheme. " + } + }, + "required": [ + "id", + "name" + ], + "description": "The identifier scheme for this product. Eg a GS1 GTIN or an AU Livestock NLIS, or similar. If self issued then use the party ID of the issuer. " + }, + "modelNumber": { + "type": "string", + "description": "Where available, the model number (for manufactured products) or material identification (for bulk materials)" + }, + "batchNumber": { + "example": 6789, + "type": "string", + "description": "Identifier of the specific production batch of the product. Unique within the product class." + }, + "itemNumber": { + "example": 12345678, + "type": "string", + "description": "A number or code representing a specific serialised item of the product. Unique within product class." + }, + "idGranularity": { + "type": "string", + "enum": [ + "model", + "batch", + "item" + ], + "example": "model", + "description": "The identification granularity for this product (item, batch, model)" + }, + "productImage": { + "$ref": "#/$defs/Link", + "description": "Reference information (location, type, name) of an image of the product." + }, + "characteristics": { + "$ref": "#/$defs/Characteristics", + "description": "A set of industry specific product information. " + }, + "productCategory": { + "type": "array", + "items": { + "$ref": "#/$defs/Classification" + }, + "description": "A code representing the product's class, typically using the UN CPC (United Nations Central Product Classification) https://unstats.un.org/unsd/classifications/Econ/cpc" + }, + "relatedDocument": { + "type": "array", + "items": { + "$ref": "#/$defs/Link" + }, + "description": "A list of links to documents providing additional product information. Documents that support a conformity claim (e.g. permits or certificates) SHOULD be referenced as claim evidence rather than here." + }, + "relatedParty": { + "type": "array", + "items": { + "$ref": "#/$defs/PartyRole" + }, + "description": "A list of parties with a defined relationship to this product" + }, + "producedAtFacility": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Facility" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Facility", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://sample-location-register.com/987654321", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this facility. Typically represented as a URI identifierScheme/Identifier URI" + }, + "name": { + "example": "Sample Factory A", + "type": "string", + "description": "Name of this facility as defined the location register." + }, + "registeredId": { + "example": 1234567, + "type": "string", + "description": "The registration number (alphanumeric) of the facility within the identifier scheme. Unique within the register." + } + }, + "required": [ + "id", + "name" + ], + "description": "The Facility where the product batch was produced / manufactured." + }, + "productionDate": { + "example": "2024-04-25", + "type": "string", + "format": "date", + "description": "The ISO 8601 date on which the product batch or individual serialised item was manufactured." + }, + "expiryDate": { + "example": "2027-04-25", + "type": "string", + "format": "date", + "description": "The date at which this product is no longer fit for use. Typically used for a food product use-by date but may also represent the usable life of any product." + }, + "countryOfProduction": { + "$ref": "#/$defs/Country", + "description": "The country in which this item was produced / manufactured.using ISO-3166 code and name." + }, + "dimensions": { + "$ref": "#/$defs/Dimension", + "description": "The physical dimensions of the product. Not every dimension is relevant to every products. For example bulk materials may have weight and volume but not length, width, or height.\"weight\":{\"value\":10, \"unit\":\"KGM\"}" + }, + "materialProvenance": { + "type": "array", + "items": { + "$ref": "#/$defs/Material" + }, + "description": "A list of materials provenance objects providing details on the origin and mass fraction of materials of the product or batch." + }, + "packaging": { + "$ref": "#/$defs/Package", + "description": "The packaging for this product." + }, + "productLabel": { + "type": "array", + "items": { + "$ref": "#/$defs/Image" + }, + "description": "An array of labels that may appear on the product such as certification marks or regulatory labels." + }, + "performanceClaim": { + "type": "array", + "items": { + "$ref": "#/$defs/Claim" + }, + "description": "A list of performance claims (eg emissions intensity) for this product." + } + }, + "description": "The ProductInformation class encapsulates detailed information regarding a specific product, including its identification details, manufacturer, and other pertinent details.", + "required": [ + "id", + "name", + "idScheme", + "idGranularity", + "productCategory", + "producedAtFacility", + "countryOfProduction" + ], + "$defs": { + "Link": { + "type": "object", + "additionalProperties": false, + "properties": { + "linkURL": { + "example": "https://files.example-certifier.com/1234567.json", + "type": "string", + "format": "uri", + "description": "The URL of the target resource. " + }, + "linkName": { + "type": "string", + "description": "Display name for this link." + }, + "digestMultibase": { + "example": "abc123-example-digest-invalid", + "type": "string", + "description": "An optional multi-base encoded digest to ensure the content of the link has not changed. See https://www.w3.org/TR/vc-data-integrity/#resource-integrity for more information." + }, + "mediaType": { + "example": "application/ld+json", + "type": "string", + "description": "The media type of the target resource." + }, + "linkType": { + "example": "https://test.uncefact.org/vocabulary/linkTypes/dcc", + "type": "string", + "description": "The type of the target resource - drawn from a controlled vocabulary " + } + }, + "description": "A structure to provide a URL link plus metadata associated with the link.", + "required": [ + "linkURL", + "linkName" + ] + }, + "Characteristics": { + "type": "object", + "additionalProperties": true, + "properties": { + "@context": { + "description": "Optional JSON-LD scoped context used to declare vocabulary prefixes for extension properties. Most commonly a single-entry object that binds a prefix to an industry or vendor namespace (for example { \"battery\": \"https://example-industry.org/battery/v1/\" }), allowing extension keys in this characteristics object to be written as \"battery:batteryChemistry\" and expanded to IRIs in that namespace. When omitted, extension keys default to the UNTP characteristics namespace defined in the UNTP JSON-LD context.", + "oneOf": [ + { + "type": "object" + }, + { + "type": "array", + "items": { + "type": [ + "string", + "object" + ] + } + } + ] + } + }, + "description": "A set of quantitative or qualitative characteristics describing a product. This is a deliberate extension point: implementers MAY add arbitrary properties beyond those defined here (for example industry-specific attributes such as batteryChemistry, recycledContentPercentage, or gradeClass). Extension keys are preserved in the expanded JSON-LD graph — unknown keys default to the UNTP characteristics namespace (https://vocabulary.uncefact.org/untp/Characteristics/) so they remain addressable for SPARQL / SHACL consumers. Implementers who maintain their own published vocabulary SHOULD declare a scoped @context inside the characteristics object to redirect extension keys into their namespace. Example:\n\n \"characteristics\": {\n \"@context\": { \"battery\": \"https://example-industry.org/battery/v1/\" },\n \"type\": [\"Characteristics\"],\n \"battery:batteryChemistry\": \"NMC 811\",\n \"battery:batteryCategory\": \"EV\"\n }" + }, + "Classification": { + "type": "object", + "additionalProperties": false, + "properties": { + "code": { + "example": 46410, + "type": "string", + "description": "classification code within the scheme" + }, + "name": { + "example": "Primary cells and primary batteries", + "type": "string", + "description": "Name of the classification represented by the code" + }, + "definition": { + "type": "string", + "description": "A rich definition of this classification code." + }, + "schemeId": { + "example": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "type": "string", + "format": "uri", + "description": "Classification scheme ID" + }, + "schemeName": { + "example": "UN Central Product Classification (CPC)", + "type": "string", + "description": "The name of the classification scheme" + } + }, + "description": "A classification scheme and code / name representing a category value for a product, entity, or facility.", + "required": [ + "code", + "name", + "schemeId", + "schemeName" + ] + }, + "PartyRole": { + "type": "object", + "additionalProperties": false, + "properties": { + "role": { + "type": "string", + "enum": [ + "owner", + "producer", + "manufacturer", + "processor", + "remanufacturer", + "recycler", + "operator", + "serviceProvider", + "inspector", + "certifier", + "logisticsProvider", + "carrier", + "consignor", + "consignee", + "importer", + "exporter", + "distributor", + "retailer", + "brandOwner", + "regulator" + ], + "example": "owner", + "description": "The role played by the party in this relationship" + }, + "party": { + "$ref": "#/$defs/Party", + "description": "The party that has the specified role." + } + }, + "description": "A party with a defined relationship to the referencing entity", + "required": [ + "role", + "party" + ] + }, + "Party": { + "type": "object", + "additionalProperties": true, + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Party" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Party", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://sample-business-register.gov/123456789", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this party. Typically represented as a URI identifierScheme/Identifier URI" + }, + "name": { + "example": "Sample Company Ltd", + "type": "string", + "description": "Legal registered name of this party." + }, + "description": { + "type": "string", + "description": "Description of the party including function and other names." + }, + "registeredId": { + "example": 90664869327, + "type": "string", + "description": "The registration number (alphanumeric) of the Party within the register. Unique within the register." + }, + "idScheme": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "IdentifierScheme" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "IdentifierScheme", + "minContains": 1 + } + } + ] + }, + "id": { + "type": "string", + "format": "uri", + "description": "The URI of this identifier scheme" + }, + "name": { + "example": "Global Identifier Scheme Name", + "type": "string", + "description": "The name of the identifier scheme. " + } + }, + "required": [ + "id", + "name" + ], + "description": "The identifier scheme of the party. Typically a national business register or a global scheme such as GLEIF. " + }, + "registrationCountry": { + "$ref": "#/$defs/Country", + "description": "the country in which this organisation is registered - using ISO-3166 code and name." + }, + "partyAddress": { + "$ref": "#/$defs/Address", + "description": "The address of the party" + }, + "organisationWebsite": { + "example": "https://example-company.com", + "type": "string", + "format": "uri", + "description": "Website for this organisation" + }, + "industryCategory": { + "type": "array", + "items": { + "$ref": "#/$defs/Classification" + }, + "description": "The industry categories for this organisation. Recommend use of UNCPC as the category scheme. for example - unstats.un.org/isic/1030" + }, + "partyAlsoKnownAs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Party" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Party", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://sample-business-register.gov/123456789", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this party. Typically represented as a URI identifierScheme/Identifier URI" + }, + "name": { + "example": "Sample Company Ltd", + "type": "string", + "description": "Legal registered name of this party." + }, + "registeredId": { + "example": 90664869327, + "type": "string", + "description": "The registration number (alphanumeric) of the Party within the register. Unique within the register." + } + }, + "required": [ + "id", + "name" + ] + }, + "description": "An optional list of other registered identifiers for this organisation. For example DUNS, GLN, LEI, etc" + } + }, + "description": "An organisation. May be a supply chain actor, a certifier, a government agency.", + "required": [ + "id", + "name" + ] + }, + "Country": { + "type": "object", + "additionalProperties": false, + "properties": { + "countryCode": { + "type": "string", + "x-external-enumeration": "https://vocabulary.uncefact.org/CountryId#", + "description": "ISO 3166 country code\n\n This is an enumerated value, but the list of valid values are too big, or change too often to include here. You can access the list of allowable values at this URL: https://vocabulary.uncefact.org/CountryId#\n " + }, + "countryName": { + "type": "string", + "description": "Country Name as defined in ISO 3166" + } + }, + "description": "Country Code and Name from ISO 3166", + "required": [ + "countryCode" + ] + }, + "Address": { + "type": "object", + "additionalProperties": false, + "properties": { + "streetAddress": { + "example": "level 11, 15 London Circuit", + "type": "string", + "description": "the street address as an unstructured string." + }, + "postalCode": { + "example": 2601, + "type": "string", + "description": "The postal code or zip code for this address." + }, + "addressLocality": { + "example": "Acton", + "type": "string", + "description": "The city, suburb or township name." + }, + "addressRegion": { + "example": "ACT", + "type": "string", + "description": "The state or territory or province" + }, + "addressCountry": { + "$ref": "#/$defs/Country", + "description": "The address country as an ISO-3166 two letter country code and name." + } + }, + "description": "A postal address.", + "required": [ + "streetAddress", + "postalCode", + "addressLocality", + "addressRegion", + "addressCountry" + ] + }, + "Dimension": { + "type": "object", + "additionalProperties": true, + "properties": { + "weight": { + "$ref": "#/$defs/Measure", + "description": "the weight of the product. EG {\"value\":10, \"unit\":\"KGM\"}" + }, + "length": { + "$ref": "#/$defs/Measure", + "description": "The length of the product or packaging eg {\"value\":840, \"unit\":\"MMT\"}" + }, + "width": { + "$ref": "#/$defs/Measure", + "description": "The width of the product or packaging. eg {\"value\":150, \"unit\":\"MMT\"}" + }, + "height": { + "$ref": "#/$defs/Measure", + "description": "The height of the product or packaging. eg {\"value\":220, \"unit\":\"MMT\"}" + }, + "volume": { + "$ref": "#/$defs/Measure", + "description": "The displacement volume of the product. eg {\"value\":7.5, \"unit\":\"LTR\"}" + } + }, + "description": "Overall (length, width, height) dimensions and weight/volume of an item." + }, + "Measure": { + "type": "object", + "additionalProperties": false, + "properties": { + "value": { + "example": 10, + "type": "number", + "description": "The numeric value of the measure" + }, + "upperTolerance": { + "type": "number", + "description": "The upper tolerance associated with this measure expressed in the same units as the measure. For example value=10, upperTolerance=0.1, unit=KGM would mean that this measure is 10kg + 0.1kg" + }, + "lowerTolerance": { + "type": "number", + "description": "The lower tolerance associated with this measure expressed in the same units as the measure. For example value=10, lowerTolerance=0.1, unit=KGM would mean that this measure is 10kg - 0.1kg" + }, + "unit": { + "type": "string", + "x-external-enumeration": "https://vocabulary.uncefact.org/UnitMeasureCode#", + "description": "Unit of measure drawn from the UNECE Rec20 measure code list.\n\n This is an enumerated value, but the list of valid values are too big, or change too often to include here. You can access the list of allowable values at this URL: https://vocabulary.uncefact.org/UnitMeasureCode#\n " + } + }, + "description": "The measure class defines a numeric measured value (eg 10) and a coded unit of measure (eg KG). There is an optional upper and lower tolerance which can be used to specify uncertainty in the measure. ", + "required": [ + "value", + "unit" + ] + }, + "Material": { + "type": "object", + "additionalProperties": true, + "properties": { + "name": { + "example": "Lithium Spodumene", + "type": "string", + "description": "Name of this material (eg \"Egyptian Cotton\")" + }, + "originCountry": { + "$ref": "#/$defs/Country", + "description": "A ISO 3166-1 code representing the country of origin of the component or ingredient." + }, + "materialType": { + "$ref": "#/$defs/Classification", + "description": "The type of this material - as a value drawn from a controlled vocabulary eg from UN Framework Classification for Resources (UNFC)." + }, + "massFraction": { + "maximum": 1, + "minimum": 0, + "example": 0.2, + "type": "number", + "description": "The mass fraction as a decimal of the product (or facility reporting period) represented by this material. " + }, + "mass": { + "$ref": "#/$defs/Measure", + "description": "The mass of the material component." + }, + "recycledMassFraction": { + "maximum": 1, + "minimum": 0, + "example": 0.5, + "type": "number", + "description": "Mass fraction of this material that is recycled (eg 50% recycled Lithium)" + }, + "hazardous": { + "type": "boolean", + "description": "Indicates whether this material is hazardous. If true then the materialSafetyInformation property must be present" + }, + "symbol": { + "$ref": "#/$defs/Image", + "description": "Based 64 encoded binary used to represent a visual symbol for a given material. " + }, + "materialSafetyInformation": { + "$ref": "#/$defs/Link", + "description": "Reference to further information about safe handling of this hazardous material (for example a link to a material safety data sheet)" + } + }, + "description": "The material class encapsulates details about the origin or source of raw materials in a product, including the country of origin and the mass fraction.", + "required": [ + "name", + "originCountry", + "materialType", + "massFraction" + ] + }, + "Image": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "example": "certification trust mark", + "type": "string", + "description": "the display name for this image" + }, + "description": { + "type": "string", + "description": "The detailed description / supporting information for this image." + }, + "imageData": { + "type": "string", + "format": "byte", + "description": "The image data encoded as a base64 string." + }, + "mediaType": { + "type": "string", + "x-external-enumeration": "https://mimetype.io/", + "description": "The media type of this image (eg image/png)\n\n This is an enumerated value, but the list of valid values are too big, or change too often to include here. You can access the list of allowable values at this URL: https://mimetype.io/\n " + } + }, + "description": "A binary image encoded as base64 text and embedded into the data. Use this for small images like certification trust marks or regulated labels. Large images should be external links.", + "required": [ + "name", + "imageData", + "mediaType" + ] + }, + "Package": { + "type": "object", + "additionalProperties": true, + "properties": { + "description": { + "type": "string", + "description": "Description of the packaging." + }, + "dimensions": { + "$ref": "#/$defs/Dimension", + "description": "dimensions of the packaging" + }, + "materialUsed": { + "type": "array", + "items": { + "$ref": "#/$defs/Material" + }, + "description": "materials used for the packaging." + }, + "packageLabel": { + "type": "array", + "items": { + "$ref": "#/$defs/Image" + }, + "description": "An array of package labels that may appear on the packaging together with their meaning. Use for small images that represent certification marks or regulatory requirements. Large images should be linked as evidence to claims." + }, + "performanceClaim": { + "type": "array", + "items": { + "$ref": "#/$defs/Claim" + }, + "description": "conformity claims made about the packaging." + } + }, + "description": "Details of product packaging", + "required": [ + "description", + "dimensions", + "materialUsed" + ] + }, + "Claim": { + "type": "object", + "additionalProperties": true, + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Claim" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Claim", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://sample-company.com/claim/e78dab5d-b6f6-4bc4-a458-7feb039f6cb3", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this claim. Typically represented as a URI companyURL/claimID URI or a UUID" + }, + "name": { + "example": "Sample company Forced Labour claim", + "type": "string", + "description": "Name of this claim - typically similar or the same as the referenced criterion name." + }, + "description": { + "type": "string", + "description": "Description of this conformity claim" + }, + "referenceCriteria": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Criterion" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Criterion", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://vocabulary.sample-scheme.org/criterion/lb/v1.0", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this conformity criterion. Typically represented as a URI SchemeOwner/CriterionID URI" + }, + "name": { + "example": "Forced labour assessment criterion", + "type": "string", + "description": "Name of this criterion as defined by the scheme owner." + } + }, + "required": [ + "id", + "name" + ] + }, + "description": "The criterion against which the claim is made." + }, + "referenceRegulation": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Regulation" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Regulation", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://regulations.country.gov/ABC-12345", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this standard. Typically represented as a URI government/regulation URI" + }, + "name": { + "example": "Due Diligence Directove", + "type": "string", + "description": "Name of this regulation as defined by the regulator." + } + }, + "required": [ + "id", + "name" + ] + }, + "description": "List of references to regulation to which conformity is claimed claimed for this product" + }, + "referenceStandard": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "Standard" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "Standard", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://sample-standards.org/A1234", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this standard. Typically represented as a URI issuer/standard URI" + }, + "name": { + "example": "Labour rights standard", + "type": "string", + "description": "Name for this standard" + } + }, + "required": [ + "id", + "name" + ] + }, + "description": "List of references to standards to which conformity is claimed claimed for this product" + }, + "claimDate": { + "type": "string", + "format": "date", + "description": "That date on which the claimed performance is applicable." + }, + "applicablePeriod": { + "$ref": "#/$defs/Period", + "description": "The applicable reporting period for this facility record." + }, + "claimedPerformance": { + "type": "array", + "items": { + "$ref": "#/$defs/Performance" + }, + "description": "The claimed performance level " + }, + "evidence": { + "type": "array", + "items": { + "$ref": "#/$defs/Link" + }, + "description": "A URI pointing to the evidence supporting the claim. SHOULD be a URL to a UNTP Digital Conformity Credential (DCC)" + }, + "conformityTopic": { + "type": "array", + "items": { + "$ref": "#/$defs/ConformityTopic" + }, + "description": "The conformity topic category for this assessment" + } + }, + "description": "A performance claim about a product, facility, or organisation that is made against a well defined criterion.", + "required": [ + "id", + "name", + "referenceCriteria", + "claimDate", + "claimedPerformance", + "conformityTopic" + ] + }, + "Period": { + "type": "object", + "additionalProperties": false, + "properties": { + "startDate": { + "type": "string", + "format": "date", + "description": "The period start date" + }, + "endDate": { + "type": "string", + "format": "date", + "description": "The period end date" + }, + "periodInformation": { + "type": "string", + "description": "Additional information relevant to this reporting period" + } + }, + "description": "A period of time, typically a month, quarter or a year, which defines the context boundary for reported facts.", + "required": [ + "startDate", + "endDate" + ] + }, + "Performance": { + "type": "object", + "additionalProperties": false, + "properties": { + "metric": { + "type": "object", + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "PerformanceMetric" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "PerformanceMetric", + "minContains": 1 + } + } + ] + }, + "id": { + "example": "https://authority.gov/schemeABC/123456789", + "type": "string", + "format": "uri", + "description": "Globally unique identifier of this reporting metric. " + }, + "name": { + "example": "emissions intensity", + "type": "string", + "description": "A human readable name for this metric (for example \"water usage per Kg of material\")" + } + }, + "required": [ + "id", + "name" + ], + "description": "The metric (eg material emissions intensity CO2e/Kg or percentage of young workers) that is measured." + }, + "measure": { + "$ref": "#/$defs/Measure", + "description": "The measured performance value" + }, + "score": { + "$ref": "#/$defs/Score", + "description": "A performance score (eg \"AA\") drawn from a scoring framework defined by the scheme or criterion." + } + }, + "description": "A claimed, assessed, or required performance level defined either by a scoring system or a numeric measure. When a numeric measure is provided, the metric classifying the measurement is required. When only a score is provided, the scoring framework is discoverable via the conformity scheme or criterion.", + "dependentRequired": { + "measure": [ + "metric" + ] + } + }, + "Score": { + "type": "object", + "additionalProperties": false, + "properties": { + "code": { + "type": "string", + "description": "The coded value for this score (eg \"AAA\")" + }, + "rank": { + "type": "integer", + "description": "The ranking of this score within the scoring framework - using an integer where \"1\" is the highest rank." + }, + "definition": { + "type": "string", + "description": "A description of the meaning of this score." + } + }, + "description": "A single score within a scoring framework. ", + "required": [ + "code" + ] + }, + "ConformityTopic": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "array", + "readOnly": true, + "default": [ + "ConformityTopic" + ], + "items": { + "type": "string" + }, + "allOf": [ + { + "contains": { + "const": "ConformityTopic", + "minContains": 1 + } + } + ] + }, + "id": { + "type": "string", + "format": "uri", + "description": "The unique identifier for this conformity topic" + }, + "name": { + "example": "forced-labour", + "type": "string", + "description": "The human readable name for this conformity topic." + }, + "definition": { + "type": "string", + "description": "The rich definition of this conformity topic." + } + }, + "description": "The UNTP standard classification scheme for conformity topic. see http://vocabulary.uncefact.org/ConformityTopic", + "required": [ + "id", + "name" + ] + } + } +} diff --git a/tests/fixtures/upstream/v0.7.0/vocabularies/untp-metrics.jsonld b/tests/fixtures/upstream/v0.7.0/vocabularies/untp-metrics.jsonld new file mode 100644 index 0000000..59dbd80 --- /dev/null +++ b/tests/fixtures/upstream/v0.7.0/vocabularies/untp-metrics.jsonld @@ -0,0 +1,1146 @@ +{ + "@context": { + "skos": "http://www.w3.org/2004/02/skos/core#", + "dcterms": "http://purl.org/dc/terms/", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "owl": "http://www.w3.org/2002/07/owl#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "untp": "https://vocabulary.uncefact.org/untp/", + "metrics": "https://vocabulary.uncefact.org/performance-metrics/", + "rec20": "https://vocabulary.uncefact.org/rec20/", + "prefLabel": { "@id": "skos:prefLabel", "@language": "en" }, + "definition": { "@id": "skos:definition", "@language": "en" }, + "notation": "skos:notation", + "scopeNote": { "@id": "skos:scopeNote", "@language": "en" }, + "broader": { "@id": "skos:broader", "@type": "@id" }, + "narrower": { "@id": "skos:narrower", "@type": "@id", "@container": "@set" }, + "topConceptOf": { "@id": "skos:topConceptOf", "@type": "@id" }, + "hasTopConcept": { "@id": "skos:hasTopConcept", "@type": "@id", "@container": "@set" }, + "inScheme": { "@id": "skos:inScheme", "@type": "@id" }, + "closeMatch": { "@id": "skos:closeMatch", "@type": "@id", "@container": "@set" }, + "allowedUnit": "untp:allowedUnit", + "aggregationMethod": "untp:aggregationMethod", + "improvementDirection": "untp:improvementDirection" + }, + "@graph": [ + { + "@id": "https://vocabulary.uncefact.org/performance-metrics/", + "@type": "skos:ConceptScheme", + "dcterms:title": { "@value": "UNTP Performance Metrics Vocabulary", "@language": "en" }, + "dcterms:description": { "@value": "A hierarchical vocabulary of standardised performance metrics for tagging fine-grained product and facility-level sustainability claims. Enables automatic roll-up to enterprise-level disclosures aligned with IFRS S1/S2, GRI, ESRS, and EU Battery Regulation. Counterpart to the UNTP Conformity Topic Classification — topics classify what is being assessed, metrics define what is measured.", "@language": "en" }, + "dcterms:creator": "United Nations Economic Commission for Europe (UNECE)", + "dcterms:license": "https://creativecommons.org/licenses/by/4.0/", + "owl:versionInfo": "0.1.0-working", + "dcterms:issued": "2026-03-13", + "dcterms:modified": "2026-03-13", + "hasTopConcept": [ + "metrics:greenhouse-gas-emissions", + "metrics:energy", + "metrics:water", + "metrics:waste-and-circularity", + "metrics:biodiversity-and-land-use", + "metrics:pollution", + "metrics:workforce", + "metrics:governance", + "metrics:product-safety-and-quality", + "metrics:food-safety-and-quality" + ] + }, + + { + "@id": "metrics:greenhouse-gas-emissions", + "@type": "skos:Concept", + "prefLabel": "Greenhouse Gas Emissions", + "definition": "Metrics for measuring, reporting, and reducing greenhouse gas emissions across all scopes, including absolute values, intensities, and reduction progress.", + "notation": "01", + "topConceptOf": "https://vocabulary.uncefact.org/performance-metrics/", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IFRS S2 paras 29–36; ESRS E1; GRI 305; GHG Protocol Corporate Standard.", + "narrower": [ + "metrics:scope-1-ghg-emissions", + "metrics:scope-2-ghg-emissions", + "metrics:scope-3-upstream-emissions", + "metrics:scope-3-downstream-emissions", + "metrics:total-ghg-emissions", + "metrics:ghg-emissions-intensity", + "metrics:product-carbon-footprint", + "metrics:biogenic-emissions", + "metrics:ghg-reduction-progress" + ] + }, + { + "@id": "metrics:scope-1-ghg-emissions", + "@type": "skos:Concept", + "prefLabel": "Scope 1 GHG Emissions", + "definition": "Absolute GHG emissions from sources owned or controlled by the reporting entity, in tonnes CO2 equivalent.", + "notation": "01.01", + "broader": "metrics:greenhouse-gas-emissions", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IFRS S2 para 29(a); ESRS E1-6; GRI 305-1; GHG Protocol Scope 1.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:scope-2-ghg-emissions", + "@type": "skos:Concept", + "prefLabel": "Scope 2 GHG Emissions", + "definition": "Indirect GHG emissions from purchased electricity, steam, heating, and cooling consumed by the reporting entity, in tonnes CO2 equivalent.", + "notation": "01.02", + "broader": "metrics:greenhouse-gas-emissions", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IFRS S2 para 29(a); ESRS E1-6; GRI 305-2; GHG Protocol Scope 2.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:scope-3-upstream-emissions", + "@type": "skos:Concept", + "prefLabel": "Scope 3 Upstream Emissions", + "definition": "Indirect GHG emissions occurring in the upstream value chain including purchased goods, transportation, and business travel, in tonnes CO2 equivalent.", + "notation": "01.03", + "broader": "metrics:greenhouse-gas-emissions", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IFRS S2 para 29(a); ESRS E1-6; GRI 305-3; GHG Protocol Scope 3 categories 1–8.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:scope-3-downstream-emissions", + "@type": "skos:Concept", + "prefLabel": "Scope 3 Downstream Emissions", + "definition": "Indirect GHG emissions occurring in the downstream value chain including product use, end-of-life treatment, and distribution, in tonnes CO2 equivalent.", + "notation": "01.04", + "broader": "metrics:greenhouse-gas-emissions", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IFRS S2 para 29(a); ESRS E1-6; GRI 305-3; GHG Protocol Scope 3 categories 9–15.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:total-ghg-emissions", + "@type": "skos:Concept", + "prefLabel": "Total GHG Emissions", + "definition": "Sum of Scope 1, Scope 2, and Scope 3 greenhouse gas emissions, in tonnes CO2 equivalent.", + "notation": "01.05", + "broader": "metrics:greenhouse-gas-emissions", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IFRS S2 para 29; ESRS E1-6; GRI 305.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:ghg-emissions-intensity", + "@type": "skos:Concept", + "prefLabel": "GHG Emissions Intensity", + "definition": "Greenhouse gas emissions per unit of economic output or physical activity, expressed as kg CO2e per unit of measure.", + "notation": "01.06", + "broader": "metrics:greenhouse-gas-emissions", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IFRS S2 para 29(b); ESRS E1-6; GRI 305-4.", + "allowedUnit": "KGM", + "aggregationMethod": "weighted-average", + "improvementDirection": "lower" + }, + { + "@id": "metrics:product-carbon-footprint", + "@type": "skos:Concept", + "prefLabel": "Product Carbon Footprint", + "definition": "Total lifecycle greenhouse gas emissions attributable to a single product unit, from raw material extraction through end-of-life, in kg CO2 equivalent.", + "notation": "01.07", + "broader": "metrics:greenhouse-gas-emissions", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ISO 14067; EU PEF method; EU Battery Regulation Art. 7.", + "allowedUnit": "KGM", + "aggregationMethod": "weighted-average", + "improvementDirection": "lower" + }, + { + "@id": "metrics:biogenic-emissions", + "@type": "skos:Concept", + "prefLabel": "Biogenic Emissions", + "definition": "CO2 emissions from the combustion or biodegradation of biomass, reported separately from fossil-fuel emissions, in tonnes CO2.", + "notation": "01.08", + "broader": "metrics:greenhouse-gas-emissions", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "GHG Protocol Land Sector and Removals Guidance; GRI 305-1.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:ghg-reduction-progress", + "@type": "skos:Concept", + "prefLabel": "GHG Reduction Target Progress", + "definition": "Percentage of committed GHG reduction target achieved, measured against a declared baseline year.", + "notation": "01.09", + "broader": "metrics:greenhouse-gas-emissions", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IFRS S2 para 33; ESRS E1-4; SBTi Target Validation Protocol.", + "allowedUnit": "P1", + "aggregationMethod": "latest", + "improvementDirection": "higher" + }, + + { + "@id": "metrics:energy", + "@type": "skos:Concept", + "prefLabel": "Energy", + "definition": "Metrics for measuring energy consumption, renewable energy share, and energy efficiency across operations and supply chains.", + "notation": "02", + "topConceptOf": "https://vocabulary.uncefact.org/performance-metrics/", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IFRS S2 para 29; ESRS E1; GRI 302; EU Energy Efficiency Directive.", + "narrower": [ + "metrics:total-energy-consumption", + "metrics:renewable-energy-percentage", + "metrics:energy-intensity", + "metrics:onsite-renewable-generation", + "metrics:non-renewable-energy-consumption" + ] + }, + { + "@id": "metrics:total-energy-consumption", + "@type": "skos:Concept", + "prefLabel": "Total Energy Consumption", + "definition": "Total energy consumed from all sources including fuel, electricity, heating, cooling, and steam, in megawatt hours.", + "notation": "02.01", + "broader": "metrics:energy", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E1-5; GRI 302-1; ISO 50001.", + "allowedUnit": "MWH", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:renewable-energy-percentage", + "@type": "skos:Concept", + "prefLabel": "Renewable Energy Percentage", + "definition": "Share of total energy consumption sourced from renewable sources such as solar, wind, hydro, and geothermal.", + "notation": "02.02", + "broader": "metrics:energy", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E1-5; GRI 302-1; RE100 reporting.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:energy-intensity", + "@type": "skos:Concept", + "prefLabel": "Energy Intensity", + "definition": "Energy consumed per unit of economic output or physical activity, expressed as MWh per unit of measure.", + "notation": "02.03", + "broader": "metrics:energy", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E1-5; GRI 302-3.", + "allowedUnit": "MWH", + "aggregationMethod": "weighted-average", + "improvementDirection": "lower" + }, + { + "@id": "metrics:onsite-renewable-generation", + "@type": "skos:Concept", + "prefLabel": "On-site Renewable Generation", + "definition": "Total renewable energy generated on-site from owned or controlled installations, in megawatt hours.", + "notation": "02.04", + "broader": "metrics:energy", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "GRI 302-1; RE100 reporting methodology.", + "allowedUnit": "MWH", + "aggregationMethod": "sum", + "improvementDirection": "higher" + }, + { + "@id": "metrics:non-renewable-energy-consumption", + "@type": "skos:Concept", + "prefLabel": "Non-Renewable Energy Consumption", + "definition": "Energy consumed from non-renewable sources including fossil fuels and nuclear, in megawatt hours.", + "notation": "02.05", + "broader": "metrics:energy", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E1-5; GRI 302-1.", + "allowedUnit": "MWH", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + + { + "@id": "metrics:water", + "@type": "skos:Concept", + "prefLabel": "Water", + "definition": "Metrics for measuring water withdrawal, consumption, discharge, recycling, and usage intensity across operations.", + "notation": "03", + "topConceptOf": "https://vocabulary.uncefact.org/performance-metrics/", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E3; GRI 303; CEO Water Mandate; Alliance for Water Stewardship.", + "narrower": [ + "metrics:total-water-withdrawal", + "metrics:water-consumption", + "metrics:water-recycling-rate", + "metrics:water-discharge", + "metrics:water-intensity", + "metrics:water-stress-area-withdrawal" + ] + }, + { + "@id": "metrics:total-water-withdrawal", + "@type": "skos:Concept", + "prefLabel": "Total Water Withdrawal", + "definition": "Total volume of water drawn from surface, ground, sea, produced, or third-party sources, in cubic metres.", + "notation": "03.01", + "broader": "metrics:water", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E3-4; GRI 303-3.", + "allowedUnit": "MTQ", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:water-consumption", + "@type": "skos:Concept", + "prefLabel": "Water Consumption", + "definition": "Volume of water withdrawn that is not returned to the original source, representing net water removed from the environment, in cubic metres.", + "notation": "03.02", + "broader": "metrics:water", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E3-4; GRI 303-5.", + "allowedUnit": "MTQ", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:water-recycling-rate", + "@type": "skos:Concept", + "prefLabel": "Water Recycling Rate", + "definition": "Percentage of total water use that is recycled or reused within operations.", + "notation": "03.03", + "broader": "metrics:water", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "GRI 303-3; CEO Water Mandate.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:water-discharge", + "@type": "skos:Concept", + "prefLabel": "Water Discharge", + "definition": "Total volume of effluent water discharged to surface water, groundwater, or third-party treatment, in cubic metres.", + "notation": "03.04", + "broader": "metrics:water", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E3-4; GRI 303-4.", + "allowedUnit": "MTQ", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:water-intensity", + "@type": "skos:Concept", + "prefLabel": "Water Intensity", + "definition": "Water consumed per unit of economic output or physical activity, expressed as litres per unit of measure.", + "notation": "03.05", + "broader": "metrics:water", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E3-4; GRI 303-5.", + "allowedUnit": "LTR", + "aggregationMethod": "weighted-average", + "improvementDirection": "lower" + }, + { + "@id": "metrics:water-stress-area-withdrawal", + "@type": "skos:Concept", + "prefLabel": "Water Stress Area Withdrawal", + "definition": "Volume of water withdrawn from areas classified as high or extremely-high baseline water stress, in cubic metres.", + "notation": "03.06", + "broader": "metrics:water", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E3-4; GRI 303-3; WRI Aqueduct water stress classifications.", + "allowedUnit": "MTQ", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + + { + "@id": "metrics:waste-and-circularity", + "@type": "skos:Concept", + "prefLabel": "Waste and Circularity", + "definition": "Metrics for measuring waste generation, diversion, recycled content, recyclability, and circular economy performance.", + "notation": "04", + "topConceptOf": "https://vocabulary.uncefact.org/performance-metrics/", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E5; GRI 306; EU ESPR Art. 5–8; EU Waste Framework Directive.", + "narrower": [ + "metrics:total-waste-generated", + "metrics:hazardous-waste-generated", + "metrics:waste-diversion-rate", + "metrics:recycled-content-percentage", + "metrics:recyclability-rate", + "metrics:material-recovery-rate", + "metrics:waste-to-landfill", + "metrics:product-durability-index", + "metrics:reuse-remanufacturing-rate" + ] + }, + { + "@id": "metrics:total-waste-generated", + "@type": "skos:Concept", + "prefLabel": "Total Waste Generated", + "definition": "Total weight of hazardous and non-hazardous waste generated by operations, in tonnes.", + "notation": "04.01", + "broader": "metrics:waste-and-circularity", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E5-5; GRI 306-3.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:hazardous-waste-generated", + "@type": "skos:Concept", + "prefLabel": "Hazardous Waste Generated", + "definition": "Total weight of waste classified as hazardous under applicable regulations, in tonnes.", + "notation": "04.02", + "broader": "metrics:waste-and-circularity", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E5-5; GRI 306-3; Basel Convention.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:waste-diversion-rate", + "@type": "skos:Concept", + "prefLabel": "Waste Diversion Rate", + "definition": "Percentage of total waste diverted from landfill and incineration through recycling, composting, or other recovery methods.", + "notation": "04.03", + "broader": "metrics:waste-and-circularity", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E5-5; GRI 306-4.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:recycled-content-percentage", + "@type": "skos:Concept", + "prefLabel": "Recycled Content Percentage", + "definition": "Share of pre-consumer and post-consumer recycled material in the total weight of a product or material input.", + "notation": "04.04", + "broader": "metrics:waste-and-circularity", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EU ESPR Art. 8; EU Battery Regulation Art. 8; ISO 14021; GRI 301-2.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:recyclability-rate", + "@type": "skos:Concept", + "prefLabel": "Recyclability Rate", + "definition": "Percentage of product weight that is technically recyclable at end of life under available infrastructure.", + "notation": "04.05", + "broader": "metrics:waste-and-circularity", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EU ESPR Art. 6; ISO 14021.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:material-recovery-rate", + "@type": "skos:Concept", + "prefLabel": "Material Recovery Rate", + "definition": "Percentage of end-of-life product mass actually recovered through recycling, remanufacturing, or refurbishment processes.", + "notation": "04.06", + "broader": "metrics:waste-and-circularity", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EU ESPR Art. 6; EU Waste Framework Directive Art. 11; GRI 306-4.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:waste-to-landfill", + "@type": "skos:Concept", + "prefLabel": "Waste to Landfill", + "definition": "Total weight of waste disposed via landfill, in tonnes.", + "notation": "04.07", + "broader": "metrics:waste-and-circularity", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E5-5; GRI 306-5.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:product-durability-index", + "@type": "skos:Concept", + "prefLabel": "Product Durability Index", + "definition": "Expected useful life of a product under normal conditions of use, expressed in years or cycles as applicable.", + "notation": "04.08", + "broader": "metrics:waste-and-circularity", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EU ESPR Art. 5 – Durability requirements; ESRS E5.", + "allowedUnit": "ANN", + "aggregationMethod": "average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:reuse-remanufacturing-rate", + "@type": "skos:Concept", + "prefLabel": "Reuse and Remanufacturing Rate", + "definition": "Percentage of product units or components returned to service through reuse, refurbishment, or remanufacturing.", + "notation": "04.09", + "broader": "metrics:waste-and-circularity", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EU ESPR Art. 6; EU Waste Framework Directive.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + + { + "@id": "metrics:biodiversity-and-land-use", + "@type": "skos:Concept", + "prefLabel": "Biodiversity and Land Use", + "definition": "Metrics for measuring deforestation-free sourcing, land-use change, biodiversity impact, and protection of sensitive areas.", + "notation": "05", + "topConceptOf": "https://vocabulary.uncefact.org/performance-metrics/", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E4; GRI 304; TNFD; EU Deforestation Regulation; Kunming-Montreal Global Biodiversity Framework.", + "narrower": [ + "metrics:deforestation-free-sourcing", + "metrics:land-use-change", + "metrics:biodiversity-impact-score", + "metrics:protected-area-impact" + ] + }, + { + "@id": "metrics:deforestation-free-sourcing", + "@type": "skos:Concept", + "prefLabel": "Deforestation-Free Sourcing", + "definition": "Percentage of raw material inputs verified as sourced without associated deforestation or forest degradation after a declared cut-off date.", + "notation": "05.01", + "broader": "metrics:biodiversity-and-land-use", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EU Deforestation Regulation (EUDR); ESRS E4; GRI 304.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:land-use-change", + "@type": "skos:Concept", + "prefLabel": "Land Use Change", + "definition": "Area of natural ecosystems converted to managed land for production or extraction activities, in hectares.", + "notation": "05.02", + "broader": "metrics:biodiversity-and-land-use", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E4; GRI 304-1; GHG Protocol Land Sector Guidance.", + "allowedUnit": "HAR", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:biodiversity-impact-score", + "@type": "skos:Concept", + "prefLabel": "Biodiversity Impact Score", + "definition": "Composite index quantifying the impact of operations on species diversity and ecosystem integrity, using a recognised assessment framework (e.g., STAR, BII).", + "notation": "05.03", + "broader": "metrics:biodiversity-and-land-use", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "TNFD LEAP approach; ESRS E4; SBTN biodiversity targets.", + "allowedUnit": "C62", + "aggregationMethod": "average", + "improvementDirection": "lower" + }, + { + "@id": "metrics:protected-area-impact", + "@type": "skos:Concept", + "prefLabel": "Protected Area Impact", + "definition": "Area of operations, sourcing, or infrastructure footprint located within or adjacent to legally protected or high-biodiversity-value areas, in hectares.", + "notation": "05.04", + "broader": "metrics:biodiversity-and-land-use", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E4; GRI 304-1; IUCN Protected Area categories.", + "allowedUnit": "HAR", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + + { + "@id": "metrics:pollution", + "@type": "skos:Concept", + "prefLabel": "Pollution", + "definition": "Metrics for measuring air pollutant emissions, hazardous substance releases, and chemical safety performance beyond GHG emissions.", + "notation": "06", + "topConceptOf": "https://vocabulary.uncefact.org/performance-metrics/", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E2; GRI 305 (non-GHG); EU Industrial Emissions Directive; Stockholm Convention; Montreal Protocol.", + "narrower": [ + "metrics:sox-emissions", + "metrics:nox-emissions", + "metrics:voc-emissions", + "metrics:particulate-matter-emissions", + "metrics:substances-of-concern", + "metrics:ozone-depleting-emissions" + ] + }, + { + "@id": "metrics:sox-emissions", + "@type": "skos:Concept", + "prefLabel": "SOx Emissions", + "definition": "Total mass of sulphur oxides released to air from stationary and mobile sources, in tonnes.", + "notation": "06.01", + "broader": "metrics:pollution", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E2-4; GRI 305-7; EU Industrial Emissions Directive.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:nox-emissions", + "@type": "skos:Concept", + "prefLabel": "NOx Emissions", + "definition": "Total mass of nitrogen oxides released to air from combustion and industrial processes, in tonnes.", + "notation": "06.02", + "broader": "metrics:pollution", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E2-4; GRI 305-7; EU Industrial Emissions Directive.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:voc-emissions", + "@type": "skos:Concept", + "prefLabel": "VOC Emissions", + "definition": "Total mass of volatile organic compounds released to air from solvents, coatings, and industrial processes, in tonnes.", + "notation": "06.03", + "broader": "metrics:pollution", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E2-4; GRI 305-7; EU Solvents Directive.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:particulate-matter-emissions", + "@type": "skos:Concept", + "prefLabel": "Particulate Matter Emissions", + "definition": "Total mass of fine particulate matter (PM2.5 and PM10) released to air from operations, in tonnes.", + "notation": "06.04", + "broader": "metrics:pollution", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS E2-4; GRI 305-7; WHO Air Quality Guidelines.", + "allowedUnit": "TNE", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:substances-of-concern", + "@type": "skos:Concept", + "prefLabel": "Substances of Concern", + "definition": "Total mass of substances of concern or substances of very high concern (SVHC) present in products or released during production, in kilograms.", + "notation": "06.05", + "broader": "metrics:pollution", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EU ESPR Annex I; REACH SVHC candidate list; ESRS E2.", + "allowedUnit": "KGM", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:ozone-depleting-emissions", + "@type": "skos:Concept", + "prefLabel": "Ozone-Depleting Substance Emissions", + "definition": "Total mass of ozone-depleting substances released, measured in kg CFC-11 equivalent.", + "notation": "06.06", + "broader": "metrics:pollution", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "GRI 305-6; Montreal Protocol; ESRS E2.", + "allowedUnit": "KGM", + "aggregationMethod": "sum", + "improvementDirection": "lower" + }, + + { + "@id": "metrics:workforce", + "@type": "skos:Concept", + "prefLabel": "Workforce", + "definition": "Metrics for measuring labour practices, workplace safety, diversity, equity, and human rights performance across operations and supply chains.", + "notation": "07", + "topConceptOf": "https://vocabulary.uncefact.org/performance-metrics/", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1, S2; GRI 401–409; IFRS S1; ILO Core Conventions; UN Guiding Principles on Business and Human Rights.", + "narrower": [ + "metrics:living-wage-coverage", + "metrics:lost-time-injury-rate", + "metrics:gender-pay-gap", + "metrics:women-in-management", + "metrics:training-hours-per-employee", + "metrics:collective-bargaining-coverage", + "metrics:employee-turnover-rate", + "metrics:child-labor-incidents", + "metrics:forced-labor-incidents", + "metrics:workforce-diversity-ratio" + ] + }, + { + "@id": "metrics:living-wage-coverage", + "@type": "skos:Concept", + "prefLabel": "Living Wage Coverage", + "definition": "Percentage of workers (including contractor and supply-chain workers in scope) receiving at least a verified living wage.", + "notation": "07.01", + "broader": "metrics:workforce", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1-10; GRI 202-1; Global Living Wage Coalition methodology.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:lost-time-injury-rate", + "@type": "skos:Concept", + "prefLabel": "Lost Time Injury Frequency Rate", + "definition": "Number of lost-time injuries per one million hours worked, measuring workplace safety performance.", + "notation": "07.02", + "broader": "metrics:workforce", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1-14; GRI 403-9; ISO 45001.", + "allowedUnit": "C62", + "aggregationMethod": "average", + "improvementDirection": "lower" + }, + { + "@id": "metrics:gender-pay-gap", + "@type": "skos:Concept", + "prefLabel": "Gender Pay Gap", + "definition": "Difference in average compensation between male and female employees as a percentage of male average compensation.", + "notation": "07.03", + "broader": "metrics:workforce", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1-16; GRI 405-2; EU Pay Transparency Directive.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "lower" + }, + { + "@id": "metrics:women-in-management", + "@type": "skos:Concept", + "prefLabel": "Women in Management", + "definition": "Percentage of management and leadership positions held by women.", + "notation": "07.04", + "broader": "metrics:workforce", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1-9; GRI 405-1.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:training-hours-per-employee", + "@type": "skos:Concept", + "prefLabel": "Training Hours per Employee", + "definition": "Average number of hours of training and professional development provided per employee per year.", + "notation": "07.05", + "broader": "metrics:workforce", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1-13; GRI 404-1.", + "allowedUnit": "HUR", + "aggregationMethod": "average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:collective-bargaining-coverage", + "@type": "skos:Concept", + "prefLabel": "Collective Bargaining Coverage", + "definition": "Percentage of employees covered by collective bargaining agreements.", + "notation": "07.06", + "broader": "metrics:workforce", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1-8; GRI 407-1; ILO Convention 98.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:employee-turnover-rate", + "@type": "skos:Concept", + "prefLabel": "Employee Turnover Rate", + "definition": "Percentage of employees who leave the organisation voluntarily or involuntarily during the reporting period.", + "notation": "07.07", + "broader": "metrics:workforce", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1-6; GRI 401-1.", + "allowedUnit": "P1", + "aggregationMethod": "average", + "improvementDirection": "lower" + }, + { + "@id": "metrics:child-labor-incidents", + "@type": "skos:Concept", + "prefLabel": "Child Labor Incidents", + "definition": "Number of confirmed incidents of child labor identified in own operations and supply chain during the reporting period.", + "notation": "07.08", + "broader": "metrics:workforce", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1, S2; GRI 408-1; ILO Conventions 138, 182.", + "allowedUnit": "C62", + "aggregationMethod": "count", + "improvementDirection": "lower" + }, + { + "@id": "metrics:forced-labor-incidents", + "@type": "skos:Concept", + "prefLabel": "Forced Labor Incidents", + "definition": "Number of confirmed incidents of forced, bonded, or compulsory labor identified in own operations and supply chain during the reporting period.", + "notation": "07.09", + "broader": "metrics:workforce", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1, S2; GRI 409-1; ILO Conventions 29, 105.", + "allowedUnit": "C62", + "aggregationMethod": "count", + "improvementDirection": "lower" + }, + { + "@id": "metrics:workforce-diversity-ratio", + "@type": "skos:Concept", + "prefLabel": "Workforce Diversity Ratio", + "definition": "Representation of under-represented groups in the workforce as a percentage of total headcount, covering gender, ethnicity, disability, and other protected characteristics.", + "notation": "07.10", + "broader": "metrics:workforce", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1-9; GRI 405-1.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + + { + "@id": "metrics:governance", + "@type": "skos:Concept", + "prefLabel": "Governance", + "definition": "Metrics for measuring anti-corruption practices, supply chain due diligence, ESG disclosure quality, and grievance mechanism effectiveness.", + "notation": "08", + "topConceptOf": "https://vocabulary.uncefact.org/performance-metrics/", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS G1; GRI 205, 308, 414; IFRS S1; OECD Guidelines Chapter VII.", + "narrower": [ + "metrics:anti-corruption-training-coverage", + "metrics:supplier-due-diligence-coverage", + "metrics:esg-disclosure-score", + "metrics:grievance-response-rate" + ] + }, + { + "@id": "metrics:anti-corruption-training-coverage", + "@type": "skos:Concept", + "prefLabel": "Anti-Corruption Training Coverage", + "definition": "Percentage of employees and governance body members who have received anti-corruption training during the reporting period.", + "notation": "08.01", + "broader": "metrics:governance", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS G1-4; GRI 205-2; OECD Anti-Bribery Convention.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:supplier-due-diligence-coverage", + "@type": "skos:Concept", + "prefLabel": "Supplier Due Diligence Coverage", + "definition": "Percentage of significant suppliers assessed against environmental and social due diligence criteria during the reporting period.", + "notation": "08.02", + "broader": "metrics:governance", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS G1-5; GRI 308-1, 414-1; EU CSDDD.", + "allowedUnit": "P1", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:esg-disclosure-score", + "@type": "skos:Concept", + "prefLabel": "ESG Disclosure Score", + "definition": "Composite score measuring the completeness, accuracy, and timeliness of environmental, social, and governance public disclosures.", + "notation": "08.03", + "broader": "metrics:governance", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IFRS S1; ESRS 1; CDP Disclosure Scoring Methodology.", + "allowedUnit": "P1", + "aggregationMethod": "latest", + "improvementDirection": "higher" + }, + { + "@id": "metrics:grievance-response-rate", + "@type": "skos:Concept", + "prefLabel": "Grievance Response Rate", + "definition": "Percentage of grievances received through formal mechanisms that were acknowledged and addressed within the defined response timeframe.", + "notation": "08.04", + "broader": "metrics:governance", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ESRS S1-17, S2-11; GRI 2-25, 2-26; UN Guiding Principles Principle 31.", + "allowedUnit": "P1", + "aggregationMethod": "average", + "improvementDirection": "higher" + }, + + { + "@id": "metrics:product-safety-and-quality", + "@type": "skos:Concept", + "prefLabel": "Product Safety and Quality", + "definition": "Metrics for measuring physical, mechanical, thermal, electrical, chemical, and fire safety properties of products and materials against applicable safety standards and performance requirements.", + "notation": "09", + "topConceptOf": "https://vocabulary.uncefact.org/performance-metrics/", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EU General Product Safety Regulation (EU) 2023/988; ICC International Building Code; ISO/IEC product safety standards; EU Construction Products Regulation.", + "narrower": [ + "metrics:mechanical-strength", + "metrics:impact-resistance", + "metrics:thermal-performance", + "metrics:fire-resistance-rating", + "metrics:electrical-safety-rating", + "metrics:flammability-rating", + "metrics:chemical-substance-concentration", + "metrics:noise-emission-level" + ] + }, + { + "@id": "metrics:mechanical-strength", + "@type": "skos:Concept", + "prefLabel": "Mechanical Strength", + "definition": "Tensile, compressive, or flexural strength of a material or product under specified test conditions, in megapascals.", + "notation": "09.01", + "broader": "metrics:product-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ISO 527 (tensile, plastics); ISO 6892 (tensile, metals); ASTM C39 (compressive, concrete); ICC IBC structural requirements.", + "allowedUnit": "MPA", + "aggregationMethod": "minimum", + "improvementDirection": "higher" + }, + { + "@id": "metrics:impact-resistance", + "@type": "skos:Concept", + "prefLabel": "Impact Resistance", + "definition": "Energy absorbed by a material or product before fracture under impact loading, in joules.", + "notation": "09.02", + "broader": "metrics:product-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ISO 179 (Charpy impact, plastics); ISO 148 (Charpy impact, metals); IEC 62262 (IK rating, equipment enclosures).", + "allowedUnit": "JOU", + "aggregationMethod": "minimum", + "improvementDirection": "higher" + }, + { + "@id": "metrics:thermal-performance", + "@type": "skos:Concept", + "prefLabel": "Thermal Performance", + "definition": "Thermal resistance (R-value) or thermal conductivity of a material or assembly, indicating its ability to insulate against heat transfer.", + "notation": "09.03", + "broader": "metrics:product-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ICC International Energy Conservation Code (IECC); ISO 22007; ASTM C518; EU Energy Performance of Buildings Directive.", + "allowedUnit": "C62", + "aggregationMethod": "weighted-average", + "improvementDirection": "higher" + }, + { + "@id": "metrics:fire-resistance-rating", + "@type": "skos:Concept", + "prefLabel": "Fire Resistance Rating", + "definition": "Duration a material or assembly maintains structural integrity, insulation, and limits heat transfer under standard fire exposure conditions, in minutes.", + "notation": "09.04", + "broader": "metrics:product-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "ICC IBC Chapter 7; ASTM E119; ISO 834; EU Construction Products Regulation (EN 13501).", + "allowedUnit": "MIN", + "aggregationMethod": "minimum", + "improvementDirection": "higher" + }, + { + "@id": "metrics:electrical-safety-rating", + "@type": "skos:Concept", + "prefLabel": "Electrical Safety Rating", + "definition": "Composite test result or classification for electrical insulation, shock protection, and fault tolerance under applicable safety standards.", + "notation": "09.05", + "broader": "metrics:product-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "IEC 60335 (household appliances); IEC 60601 (medical devices); IEC 62368 (AV/IT equipment); UL product safety standards.", + "allowedUnit": "C62", + "aggregationMethod": "minimum", + "improvementDirection": "higher" + }, + { + "@id": "metrics:flammability-rating", + "@type": "skos:Concept", + "prefLabel": "Flammability Rating", + "definition": "Classification of a material's reaction to fire, covering ignitability, flame spread, heat release, and smoke generation under standard test conditions.", + "notation": "09.06", + "broader": "metrics:product-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EN 13501 (EU Euroclasses); UL 94 (plastics); ASTM E84 (surface burning); EU GPSR flammability requirements.", + "allowedUnit": "C62", + "aggregationMethod": "minimum", + "improvementDirection": "higher" + }, + { + "@id": "metrics:chemical-substance-concentration", + "@type": "skos:Concept", + "prefLabel": "Chemical Substance Concentration", + "definition": "Concentration of a specified regulated or restricted substance present in a product, in milligrams per kilogram.", + "notation": "09.07", + "broader": "metrics:product-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EU REACH (SVHCs); EU RoHS Directive; EU ESPR Annex I; EU GPSR chemical safety requirements.", + "allowedUnit": "MK", + "aggregationMethod": "maximum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:noise-emission-level", + "@type": "skos:Concept", + "prefLabel": "Noise Emission Level", + "definition": "Sound power or sound pressure level emitted by a product during normal operation, in decibels.", + "notation": "09.08", + "broader": "metrics:product-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "EU Outdoor Noise Directive 2000/14/EC; ISO 3744; IEC 60704 (household appliances); EU Energy Labelling Regulation.", + "allowedUnit": "C62", + "aggregationMethod": "maximum", + "improvementDirection": "lower" + }, + + { + "@id": "metrics:food-safety-and-quality", + "@type": "skos:Concept", + "prefLabel": "Food Safety and Quality", + "definition": "Metrics for measuring microbiological safety, chemical contaminant levels, pesticide and veterinary drug residues, food additive levels, nutritional content, and allergen presence in food products.", + "notation": "10", + "topConceptOf": "https://vocabulary.uncefact.org/performance-metrics/", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "Codex Alimentarius (FAO/WHO); EU General Food Law Regulation (EC) 178/2002; EU food safety regulations; ISO 22000.", + "narrower": [ + "metrics:microbiological-count", + "metrics:chemical-contaminant-level", + "metrics:pesticide-residue-level", + "metrics:veterinary-drug-residue-level", + "metrics:food-additive-level", + "metrics:nutritional-content", + "metrics:allergen-presence", + "metrics:shelf-life-duration" + ] + }, + { + "@id": "metrics:microbiological-count", + "@type": "skos:Concept", + "prefLabel": "Microbiological Count", + "definition": "Colony-forming units of a specified microorganism per unit of food, measuring microbiological safety and hygiene performance.", + "notation": "10.01", + "broader": "metrics:food-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "Codex CAC/GL 21; EU Regulation (EC) 2073/2005 on microbiological criteria for foodstuffs; ISO 4833.", + "allowedUnit": "C62", + "aggregationMethod": "maximum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:chemical-contaminant-level", + "@type": "skos:Concept", + "prefLabel": "Chemical Contaminant Level", + "definition": "Concentration of a specified chemical contaminant (heavy metals, mycotoxins, dioxins, etc.) in food, in milligrams per kilogram.", + "notation": "10.02", + "broader": "metrics:food-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "Codex CXS 193 (General Standard for Contaminants and Toxins); EU Regulation (EC) 1881/2006.", + "allowedUnit": "MK", + "aggregationMethod": "maximum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:pesticide-residue-level", + "@type": "skos:Concept", + "prefLabel": "Pesticide Residue Level", + "definition": "Concentration of a specified pesticide residue in food, measured against the applicable maximum residue limit, in milligrams per kilogram.", + "notation": "10.03", + "broader": "metrics:food-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "Codex Maximum Residue Limits for Pesticides (CX/MRL); EU Regulation (EC) 396/2005.", + "allowedUnit": "MK", + "aggregationMethod": "maximum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:veterinary-drug-residue-level", + "@type": "skos:Concept", + "prefLabel": "Veterinary Drug Residue Level", + "definition": "Concentration of a specified veterinary drug residue in animal-derived food, measured against the applicable maximum residue limit, in micrograms per kilogram.", + "notation": "10.04", + "broader": "metrics:food-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "Codex Maximum Residue Limits for Veterinary Drugs (CX/MRL); EU Regulation (EU) 37/2010.", + "allowedUnit": "MK", + "aggregationMethod": "maximum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:food-additive-level", + "@type": "skos:Concept", + "prefLabel": "Food Additive Level", + "definition": "Concentration of a specified food additive in the final product, measured against the applicable maximum permitted level, in milligrams per kilogram.", + "notation": "10.05", + "broader": "metrics:food-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "Codex General Standard for Food Additives (CXS 192); EU Regulation (EC) 1333/2008.", + "allowedUnit": "MK", + "aggregationMethod": "maximum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:nutritional-content", + "@type": "skos:Concept", + "prefLabel": "Nutritional Content", + "definition": "Amount of a specified nutrient (energy, protein, fat, carbohydrate, sugar, sodium, fibre, vitamins, minerals) per standard serving or per 100 grams of food.", + "notation": "10.06", + "broader": "metrics:food-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "Codex CXS 1-1985 (General Standard for Labelling); Codex CXG 2-1985 (Nutrition Labelling Guidelines); EU Regulation (EU) 1169/2011.", + "allowedUnit": "GRM", + "aggregationMethod": "average", + "improvementDirection": "context-dependent" + }, + { + "@id": "metrics:allergen-presence", + "@type": "skos:Concept", + "prefLabel": "Allergen Presence", + "definition": "Declared presence or measured concentration of a specified allergen in a food product, supporting consumer safety and regulatory labelling requirements.", + "notation": "10.07", + "broader": "metrics:food-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "Codex CXS 1-1985 (allergen labelling); EU Regulation (EU) 1169/2011 Annex II; Codex CXA 4-1989 (allergen classification).", + "allowedUnit": "MK", + "aggregationMethod": "maximum", + "improvementDirection": "lower" + }, + { + "@id": "metrics:shelf-life-duration", + "@type": "skos:Concept", + "prefLabel": "Shelf Life Duration", + "definition": "Expected period during which a food product maintains safety and quality under stated storage conditions, in days.", + "notation": "10.08", + "broader": "metrics:food-safety-and-quality", + "inScheme": "https://vocabulary.uncefact.org/performance-metrics/", + "scopeNote": "Codex General Principles of Food Hygiene (CXC 1-1969); EU Regulation (EU) 1169/2011 (date marking); ISO 22000.", + "allowedUnit": "DAY", + "aggregationMethod": "minimum", + "improvementDirection": "higher" + } + ] +} diff --git a/tests/fixtures/upstream/v0.7.0/vocabularies/untp-ontology.jsonld b/tests/fixtures/upstream/v0.7.0/vocabularies/untp-ontology.jsonld new file mode 100644 index 0000000..d0054f6 --- /dev/null +++ b/tests/fixtures/upstream/v0.7.0/vocabularies/untp-ontology.jsonld @@ -0,0 +1,5046 @@ +{ + "@context": { + "untp": "https://vocabulary.uncefact.org/untp/", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "owl": "http://www.w3.org/2002/07/owl#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "schema": "https://schema.org/", + "dcterms": "http://purl.org/dc/terms/", + "vann": "http://purl.org/vocab/vann/", + "foaf": "http://xmlns.com/foaf/0.1/" + }, + "@graph": [ + { + "@id": "https://vocabulary.uncefact.org/untp/", + "@type": "owl:Ontology", + "vann:preferredNamespacePrefix": "untp", + "vann:preferredNamespaceUri": "https://vocabulary.uncefact.org/untp/", + "dcterms:title": "UNTP Core Vocabulary", + "dcterms:description": "Core classes and properties for the UNTP data model (JSON-LD/RDF).", + "owl:versionInfo": "working" + }, + { + "@id": "untp:credentialSubjectType", + "@type": "rdf:Property", + "rdfs:comment": "The expected type of the credentialSubject for this credential class. Used to connect UNTP credential types to the UNTP domain classes that populate the W3C VCDM credentialSubject property, without redefining the W3C property itself.", + "rdfs:label": "credentialSubjectType", + "schema:domainIncludes": [ + { + "@id": "untp:DigitalProductPassport" + }, + { + "@id": "untp:DigitalFacilityRecord" + }, + { + "@id": "untp:DigitalConformityCredential" + }, + { + "@id": "untp:DigitalTraceabilityEvent" + }, + { + "@id": "untp:DigitalIdentityAnchor" + } + ], + "schema:rangeIncludes": { + "@id": "rdfs:Class" + } + }, + { + "@id": "untp:extendsModel", + "@type": "rdf:Property", + "rdfs:comment": "Indicates that this UNTP class reuses and extends a class defined in an external vocabulary (e.g. W3C VCDM, schema.org). The external class defines the envelope or base properties; UNTP defines only the extensions. This annotation enables human-readable renderings to display or link to the inherited properties without redefining them.", + "rdfs:label": "extendsModel", + "schema:domainIncludes": [ + { + "@id": "untp:VerifiableCredential" + }, + { + "@id": "untp:Address" + } + ], + "schema:rangeIncludes": { + "@id": "rdfs:Class" + } + }, + { + "@id": "untp:DigitalProductPassport", + "@type": "rdfs:Class", + "rdfs:comment": "A digital Product Passport (DPP) credential.", + "rdfs:label": "DigitalProductPassport", + "rdfs:subClassOf": "untp:VerifiableCredential", + "untp:credentialSubjectType": { + "@id": "untp:Product" + } + }, + { + "@id": "untp:VerifiableCredential", + "@type": "rdfs:Class", + "rdfs:comment": "A verifiable credential is a digital and verifiable version of everyday credentials such as certificates and licenses. It conforms to the W3C Verifiable Credentials Data Model v2.0 (VCDM).", + "rdfs:label": "VerifiableCredential", + "untp:extendsModel": { + "@id": "https://www.w3.org/2018/credentials#VerifiableCredential" + } + }, + { + "@id": "untp:DigitalFacilityRecord", + "@type": "rdfs:Class", + "rdfs:comment": "A digital Facility Record (DFR) credential.", + "rdfs:label": "DigitalFacilityRecord", + "rdfs:subClassOf": "untp:VerifiableCredential", + "untp:credentialSubjectType": { + "@id": "untp:Facility" + } + }, + { + "@id": "untp:CredentialIssuer", + "@type": "rdfs:Class", + "rdfs:comment": "The issuer party (person or organisation) of a verifiable credential.", + "rdfs:label": "CredentialIssuer" + }, + { + "@id": "untp:IssuingSoftware", + "@type": "rdfs:Class", + "rdfs:comment": "Optional metadata identifying the software product (and its vendor) that issued the parent credential. Used for vendor traceability and conformity testing.", + "rdfs:label": "IssuingSoftware" + }, + { + "@id": "untp:SoftwareVendor", + "@type": "rdfs:Class", + "rdfs:comment": "The vendor of a software product that issued a UNTP credential.", + "rdfs:label": "SoftwareVendor" + }, + { + "@id": "untp:Party", + "@type": "rdfs:Class", + "rdfs:comment": "An organisation. May be a supply chain actor, a certifier, a government agency.", + "rdfs:label": "Party" + }, + { + "@id": "untp:Entity", + "@type": "rdfs:Class", + "rdfs:comment": "A uniquely identified entity", + "rdfs:label": "Entity" + }, + { + "@id": "untp:IdentifierScheme", + "@type": "rdfs:Class", + "rdfs:comment": "An identifier registration scheme for products, facilities, or organisations. Typically operated by a state, national or global authority.", + "rdfs:label": "IdentifierScheme" + }, + { + "@id": "untp:Country", + "@type": "rdfs:Class", + "rdfs:comment": "Country Code and Name from ISO 3166", + "rdfs:label": "Country" + }, + { + "@id": "untp:Address", + "@type": "rdfs:Class", + "rdfs:comment": "A postal address. Reuses streetAddress, postalCode, addressLocality, and addressRegion from schema.org PostalAddress. Extends with addressCountry (an ISO-3166 country code/name structure).", + "rdfs:label": "Address", + "untp:extendsModel": { + "@id": "schema:PostalAddress" + } + }, + { + "@id": "untp:Classification", + "@type": "rdfs:Class", + "rdfs:comment": "A classification scheme and code / name representing a category value for a product, entity, or facility.", + "rdfs:label": "Classification" + }, + { + "@id": "untp:BitstringStatusListEntry", + "@type": "rdfs:Class", + "rdfs:comment": "A privacy-preserving, space-efficient, and high-performance mechanism for publishing status information such as suspension or revocation of Verifiable Credentials through use of bitstrings. See https://www.w3.org/TR/vc-bitstring-status-list/ for full details.", + "rdfs:label": "BitstringStatusListEntry" + }, + { + "@id": "untp:RenderTemplate2024", + "@type": "rdfs:Class", + "rdfs:comment": "A single template format focused render method where the content/media type decision becomes secondary (and is expressed separately).See https://github.com/w3c-ccg/vc-render-method/issues/9", + "rdfs:label": "RenderTemplate2024" + }, + { + "@id": "untp:Facility", + "@type": "rdfs:Class", + "rdfs:comment": "The physical site (eg farm or factory) where the product or materials was produced.", + "rdfs:label": "Facility" + }, + { + "@id": "untp:PartyRole", + "@type": "rdfs:Class", + "rdfs:comment": "A party with a defined relationship to the referencing entity", + "rdfs:label": "PartyRole" + }, + { + "@id": "untp:Link", + "@type": "rdfs:Class", + "rdfs:comment": "A structure to provide a URL link plus metadata associated with the link.", + "rdfs:label": "Link" + }, + { + "@id": "untp:Location", + "@type": "rdfs:Class", + "rdfs:comment": "Location information including address and geo-location of points, areas, and boundaries. At least one of plusCode, geoLocation, or geoBoundary are required.", + "rdfs:label": "Location" + }, + { + "@id": "untp:Coordinate", + "@type": "rdfs:Class", + "rdfs:comment": "A geographic point defined by latitude and longitude using the WGS84 geodetic coordinate reference system (EPSG:4326). Latitude and longitude are expressed in decimal degrees as floating-point numbers. Coordinates follow the conventional order (latitude, longitude) and represent a point on the Earth’s surface.", + "rdfs:label": "Coordinate" + }, + { + "@id": "untp:MaterialUsage", + "@type": "rdfs:Class", + "rdfs:comment": "A material usage record defining the consumption of materials for a given period, typically at an operating facility. Used to specify volumetric consumption and country of origin without specifying specific suppliers.", + "rdfs:label": "MaterialUsage" + }, + { + "@id": "untp:Period", + "@type": "rdfs:Class", + "rdfs:comment": "A period of time, typically a month, quarter or a year, which defines the context boundary for reported facts.", + "rdfs:label": "Period" + }, + { + "@id": "untp:Material", + "@type": "rdfs:Class", + "rdfs:comment": "The material class encapsulates details about the origin or source of raw materials in a product, including the country of origin and the mass fraction.", + "rdfs:label": "Material" + }, + { + "@id": "untp:Measure", + "@type": "rdfs:Class", + "rdfs:comment": "The measure class defines a numeric measured value (eg 10) and a coded unit of measure (eg KG). There is an optional upper and lower tolerance which can be used to specify uncertainty in the measure. ", + "rdfs:label": "Measure" + }, + { + "@id": "untp:Image", + "@type": "rdfs:Class", + "rdfs:comment": "A binary image encoded as base64 text and embedded into the data. Use this for small images like certification trust marks or regulated labels. Large images should be external links.", + "rdfs:label": "Image" + }, + { + "@id": "untp:Claim", + "@type": "rdfs:Class", + "rdfs:comment": "A performance claim about a product, facility, or organisation that is made against a well defined criterion.", + "rdfs:label": "Claim" + }, + { + "@id": "untp:Criterion", + "@type": "rdfs:Class", + "rdfs:comment": "A specific rule or criterion within a standard or regulation. eg a carbon intensity calculation rule within an emissions standard.", + "rdfs:label": "Criterion" + }, + { + "@id": "untp:ConformityTopic", + "@type": "rdfs:Class", + "rdfs:comment": "The UNTP standard classification scheme for conformity topic. see http://vocabulary.uncefact.org/ConformityTopic", + "rdfs:label": "ConformityTopic" + }, + { + "@id": "untp:Performance", + "@type": "rdfs:Class", + "rdfs:comment": "A claimed, assessed, or required performance level defined either by a scoring system or a numeric measure.", + "rdfs:label": "Performance" + }, + { + "@id": "untp:PerformanceMetric", + "@type": "rdfs:Class", + "rdfs:comment": "A standardised data point for performance reporting (eg product carbon footprint)", + "rdfs:label": "PerformanceMetric" + }, + { + "@id": "untp:Score", + "@type": "rdfs:Class", + "rdfs:comment": "A single score within a scoring framework. ", + "rdfs:label": "Score" + }, + { + "@id": "untp:Regulation", + "@type": "rdfs:Class", + "rdfs:comment": "A regulation (eg EU deforestation regulation) that defines the criteria for assessment.", + "rdfs:label": "Regulation" + }, + { + "@id": "untp:Standard", + "@type": "rdfs:Class", + "rdfs:comment": "A standard (eg ISO 14000) that specifies the criteria for conformance.", + "rdfs:label": "Standard" + }, + { + "@id": "untp:DigitalConformityCredential", + "@type": "rdfs:Class", + "rdfs:comment": "A Digital Conformity Credential (DCC) credential.", + "rdfs:label": "DigitalConformityCredential", + "rdfs:subClassOf": "untp:VerifiableCredential", + "untp:credentialSubjectType": { + "@id": "untp:ConformityAttestation" + } + }, + { + "@id": "untp:ConformityAttestation", + "@type": "rdfs:Class", + "rdfs:comment": "A conformity attestation issued by a competent body that defines one or more assessments (eg carbon intensity) about a product (eg battery) against a specification (eg LCA method) defined in a standard or regulation.", + "rdfs:label": "ConformityAttestation" + }, + { + "@id": "untp:Endorsement", + "@type": "rdfs:Class", + "rdfs:comment": "The authority under which a conformity claim is issued. For example a national accreditation authority may authorise a test lab to issue test certificates about a product against a standard. ", + "rdfs:label": "Endorsement" + }, + { + "@id": "untp:ConformityScheme", + "@type": "rdfs:Class", + "rdfs:comment": "A formal governance scheme under which an attestation is issued (eg ACRS structural steel certification) ", + "rdfs:label": "ConformityScheme" + }, + { + "@id": "untp:ScoringFramework", + "@type": "rdfs:Class", + "rdfs:comment": "A scoring framework used for performance level assessments against a criteria or scheme. For example forced labour performance might score A to D depending on the percentage of workforce subject to recruitment fees.", + "rdfs:label": "ScoringFramework" + }, + { + "@id": "untp:ConformityProfile", + "@type": "rdfs:Class", + "rdfs:comment": "A versioned conformity profile, managed under a scheme, which includes a specific list of versioned criteria. A conformity profile represents the precise scope of a conformity attestation. ", + "rdfs:label": "ConformityProfile" + }, + { + "@id": "untp:StandardAlignment", + "@type": "rdfs:Class", + "rdfs:comment": "A voluntary standard and an alignment level (exceeds, meets, partial).", + "rdfs:label": "StandardAlignment" + }, + { + "@id": "untp:RegulatoryAlignment", + "@type": "rdfs:Class", + "rdfs:comment": "A national regulation or international treaty and an alignment level (exceeds, meets, partial).", + "rdfs:label": "RegulatoryAlignment" + }, + { + "@id": "untp:ConformityAssessment", + "@type": "rdfs:Class", + "rdfs:comment": "A specific assessment about the product or facility against a specific specification. Eg the carbon intensity of a given product or batch.", + "rdfs:label": "ConformityAssessment" + }, + { + "@id": "untp:ProductVerification", + "@type": "rdfs:Class", + "rdfs:comment": "The product which is the subject of this conformity assessment", + "rdfs:label": "ProductVerification" + }, + { + "@id": "untp:Product", + "@type": "rdfs:Class", + "rdfs:comment": "The ProductInformation class encapsulates detailed information regarding a specific product, including its identification details, manufacturer, and other pertinent details.", + "rdfs:label": "Product" + }, + { + "@id": "untp:Characteristics", + "@type": "rdfs:Class", + "rdfs:comment": "A declaration of conformance with one or more criteria from a specific standard or regulation. ", + "rdfs:label": "Characteristics" + }, + { + "@id": "untp:Dimension", + "@type": "rdfs:Class", + "rdfs:comment": "Overall (length, width, height) dimensions and weight/volume of an item.", + "rdfs:label": "Dimension" + }, + { + "@id": "untp:Package", + "@type": "rdfs:Class", + "rdfs:comment": "Details of product packaging", + "rdfs:label": "Package" + }, + { + "@id": "untp:FacilityVerification", + "@type": "rdfs:Class", + "rdfs:comment": "The facility which is the subject of this conformity assessment", + "rdfs:label": "FacilityVerification" + }, + { + "@id": "untp:DigitalTraceabilityEvent", + "@type": "rdfs:Class", + "rdfs:comment": "A Digital Traceability Event (DTE) credential.", + "rdfs:label": "DigitalTraceabilityEvent", + "rdfs:subClassOf": "untp:VerifiableCredential", + "untp:credentialSubjectType": { + "@id": "untp:LifecycleEvent" + } + }, + { + "@id": "untp:LifecycleEvent", + "@type": "rdfs:Class", + "rdfs:comment": "This abstract event structure provides a common language to describe product lifecycle events such as shipments, inspections, manufacturing processes, etc.", + "rdfs:label": "LifecycleEvent" + }, + { + "@id": "untp:MakeEvent", + "@type": "rdfs:Class", + "rdfs:comment": "Transformation (manufacture/ production) of input products to output products at a given facility.", + "rdfs:label": "MakeEvent", + "rdfs:subClassOf": "untp:LifecycleEvent" + }, + { + "@id": "untp:SensorData", + "@type": "rdfs:Class", + "rdfs:comment": "A sensor data recording associated with this event", + "rdfs:label": "SensorData" + }, + { + "@id": "untp:EventProduct", + "@type": "rdfs:Class", + "rdfs:comment": "A quantity of products or materials involved in a lifecycle event.", + "rdfs:label": "EventProduct" + }, + { + "@id": "untp:MoveEvent", + "@type": "rdfs:Class", + "rdfs:comment": "Transfer (shipment) of products from one facility to another.", + "rdfs:label": "MoveEvent", + "rdfs:subClassOf": "untp:LifecycleEvent" + }, + { + "@id": "untp:ModifyEvent", + "@type": "rdfs:Class", + "rdfs:comment": "Intervention (eg repair) on a product without changing it's identity at a given facility.", + "rdfs:label": "ModifyEvent", + "rdfs:subClassOf": "untp:LifecycleEvent" + }, + { + "@id": "untp:DigitalIdentityAnchor", + "@type": "rdfs:Class", + "rdfs:comment": "The Digital Identity Anchor (DIA) is a very simple credential that is issued by a trusted authority and asserts an equivalence between a member identity as known to the authority (eg a VAT number) and one or more decentralised identifiers (DIDs) held by the member.", + "rdfs:label": "DigitalIdentityAnchor", + "rdfs:subClassOf": "untp:VerifiableCredential", + "untp:credentialSubjectType": { + "@id": "untp:RegisteredIdentity" + } + }, + { + "@id": "untp:RegisteredIdentity", + "@type": "rdfs:Class", + "rdfs:comment": "The identity anchor is a mapping between a registry member identity and one or more decentralised identifiers owned by the member. It may also list a set of membership scopes.", + "rdfs:label": "RegisteredIdentity" + }, + { + "@id": "untp:id", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:CredentialIssuer" + }, + { + "@id": "untp:Party" + }, + { + "@id": "untp:Entity" + }, + { + "@id": "untp:IdentifierScheme" + }, + { + "@id": "untp:BitstringStatusListEntry" + }, + { + "@id": "untp:Facility" + }, + { + "@id": "untp:Claim" + }, + { + "@id": "untp:Criterion" + }, + { + "@id": "untp:ConformityTopic" + }, + { + "@id": "untp:PerformanceMetric" + }, + { + "@id": "untp:Regulation" + }, + { + "@id": "untp:Standard" + }, + { + "@id": "untp:ConformityAttestation" + }, + { + "@id": "untp:ConformityScheme" + }, + { + "@id": "untp:ConformityProfile" + }, + { + "@id": "untp:ConformityAssessment" + }, + { + "@id": "untp:Product" + }, + { + "@id": "untp:LifecycleEvent" + }, + { + "@id": "untp:MakeEvent" + }, + { + "@id": "untp:MoveEvent" + }, + { + "@id": "untp:ModifyEvent" + }, + { + "@id": "untp:RegisteredIdentity" + } + ], + "rdfs:comment": [ + "The W3C DID of the issuer - should be a did:web or did:webvh", + "Globally unique identifier of this party. Typically represented as a URI identifierScheme/Identifier URI", + "The globally unique identifier of this entity. ", + "The URI of this identifier scheme", + "optional identifier of this status list entry.", + "Globally unique identifier of this facility. Typically represented as a URI identifierScheme/Identifier URI", + "Globally unique identifier of this claim. Typically represented as a URI companyURL/claimID URI or a UUID", + "Globally unique identifier of this conformity criterion. Typically represented as a URI SchemeOwner/CriterionID URI", + "The unique identifier for this conformity topic", + "Globally unique identifier of this reporting metric. ", + "Globally unique identifier of this standard. Typically represented as a URI government/regulation URI", + "Globally unique identifier of this standard. Typically represented as a URI issuer/standard URI", + "Globally unique identifier of this attestation. Typically represented as a URI AssessmentBody/CertificateID URI or a UUID", + "Globally unique identifier of this conformity scheme. Typically represented as a URI SchemeOwner/SchemeName URI", + "Globally unique identifier of this context specific conformity profile. Typically represented as a URI SchemeOwner/profileID URI", + "Globally unique identifier of this assessment. Typically represented as a URI AssessmentBody/Assessment URI or a UUID", + "Globally unique identifier of this product. Typically represented as a URI identifierScheme/Identifier URI or, if self-issued, as a did.", + "Globally unique ID for this lifecycle event. Should be a URI. Can be a UUID.", + "Globally unique ID for this lifecycle event. Should be a URI. Can be a UUID.", + "Globally unique ID for this lifecycle event. Should be a URI. Can be a UUID.", + "Globally unique ID for this lifecycle event. Should be a URI. Can be a UUID.", + "The DID that is controlled by the registered member and is linked to the registeredID through this Identity Anchor credential" + ], + "rdfs:label": "id" + }, + { + "@id": "untp:name", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:CredentialIssuer" + }, + { + "@id": "untp:Party" + }, + { + "@id": "untp:Entity" + }, + { + "@id": "untp:IdentifierScheme" + }, + { + "@id": "untp:Classification" + }, + { + "@id": "untp:RenderTemplate2024" + }, + { + "@id": "untp:Facility" + }, + { + "@id": "untp:Material" + }, + { + "@id": "untp:Image" + }, + { + "@id": "untp:Claim" + }, + { + "@id": "untp:Criterion" + }, + { + "@id": "untp:ConformityTopic" + }, + { + "@id": "untp:PerformanceMetric" + }, + { + "@id": "untp:Regulation" + }, + { + "@id": "untp:Standard" + }, + { + "@id": "untp:ConformityAttestation" + }, + { + "@id": "untp:Endorsement" + }, + { + "@id": "untp:ConformityScheme" + }, + { + "@id": "untp:ScoringFramework" + }, + { + "@id": "untp:ConformityProfile" + }, + { + "@id": "untp:ConformityAssessment" + }, + { + "@id": "untp:Product" + }, + { + "@id": "untp:LifecycleEvent" + }, + { + "@id": "untp:MakeEvent" + }, + { + "@id": "untp:MoveEvent" + }, + { + "@id": "untp:ModifyEvent" + } + ], + "rdfs:comment": [ + "The name of the issuer person or organisation", + "Legal registered name of this party.", + "The name of this entity.", + "The name of the identifier scheme. ", + "Name of the classification represented by the code", + "Human facing display name for selection", + "Name of this facility as defined the location register.", + "Name of this material (eg \"Egyptian Cotton\")", + "the display name for this image", + "Name of this claim - typically similar or the same as the referenced criterion name.", + "Name of this criterion as defined by the scheme owner.", + "The human readable name for this conformity topic.", + "A human readable name for this metric (for example \"water usage per Kg of material\")", + "Name of this regulation as defined by the regulator.", + "Name for this standard", + "Name of this attestation - typically the title of the certificate.", + "The name of the accreditation.", + "Name of this scheme as defined by the scheme owner.", + "A name for this scoring framework. Must be unique within a scheme.", + "Name of this conformity profile as defined by the scheme owner.", + "Name of this assessment - typically similar or the same as the referenced criterion name.", + "The product name as known to the market.", + "The name for this lifecycle event ", + "The name for this lifecycle event ", + "The name for this lifecycle event ", + "The name for this lifecycle event " + ], + "rdfs:label": "name" + }, + { + "@id": "untp:issuerAlsoKnownAs", + "schema:rangeIncludes": { + "@id": "untp:Party" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:CredentialIssuer" + } + ], + "rdfs:comment": [ + "An optional list of other registered identifiers for this credential issuer " + ], + "rdfs:label": "issuerAlsoKnownAs" + }, + { + "@id": "untp:issuingSoftware", + "schema:rangeIncludes": { + "@id": "untp:IssuingSoftware" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:DigitalProductPassport" + }, + { + "@id": "untp:DigitalConformityCredential" + }, + { + "@id": "untp:DigitalFacilityRecord" + }, + { + "@id": "untp:DigitalIdentityAnchor" + }, + { + "@id": "untp:DigitalTraceabilityEvent" + } + ], + "rdfs:comment": [ + "Optional metadata identifying the software product (and its vendor) that issued this credential." + ], + "rdfs:label": "issuingSoftware" + }, + { + "@id": "untp:vendor", + "schema:rangeIncludes": { + "@id": "untp:SoftwareVendor" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:IssuingSoftware" + } + ], + "rdfs:comment": [ + "The vendor of the software product that issued the parent credential." + ], + "rdfs:label": "vendor" + }, + { + "@id": "untp:description", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Party" + }, + { + "@id": "untp:Entity" + }, + { + "@id": "untp:Facility" + }, + { + "@id": "untp:Image" + }, + { + "@id": "untp:Claim" + }, + { + "@id": "untp:Criterion" + }, + { + "@id": "untp:Regulation" + }, + { + "@id": "untp:Standard" + }, + { + "@id": "untp:ConformityAttestation" + }, + { + "@id": "untp:ConformityScheme" + }, + { + "@id": "untp:ScoringFramework" + }, + { + "@id": "untp:ConformityProfile" + }, + { + "@id": "untp:ConformityAssessment" + }, + { + "@id": "untp:Product" + }, + { + "@id": "untp:Package" + }, + { + "@id": "untp:LifecycleEvent" + }, + { + "@id": "untp:MakeEvent" + }, + { + "@id": "untp:MoveEvent" + }, + { + "@id": "untp:ModifyEvent" + }, + { + "@id": "untp:PerformanceMetric" + } + ], + "rdfs:comment": [ + "Description of the party including function and other names.", + "A rich descrition of this identified entity. ", + "Description of the facility including function and other names.", + "The detailed description / supporting information for this image.", + "Description of this conformity claim", + "Description of this criterion", + "Description of this regulation.", + "Description of this standard.", + "Description of this attestation.", + "Description of this conformity scheme", + "A full text description of the criterion that clearly specifies how compliance is achieved and measured. ", + "The description of this versioned and context specific conformity profile.", + "Description of this conformity assessment ", + "Description of the product.", + "Description of the packaging.", + "The description of this lifecycle event.", + "The description of this lifecycle event.", + "The description of this lifecycle event.", + "The description of this lifecycle event.", + "A rich description of this reporting metric." + ], + "rdfs:label": "description" + }, + { + "@id": "untp:registeredId", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Party" + }, + { + "@id": "untp:Facility" + }, + { + "@id": "untp:RegisteredIdentity" + } + ], + "rdfs:comment": [ + "The registration number (alphanumeric) of the Party within the register. Unique within the register.", + "The registration number (alphanumeric) of the facility within the identifier scheme. Unique within the register.", + "The registration number (alphanumeric) of the entity within the register. Unique within the register." + ], + "rdfs:label": "registeredId" + }, + { + "@id": "untp:idScheme", + "schema:rangeIncludes": { + "@id": "untp:IdentifierScheme" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Party" + }, + { + "@id": "untp:Facility" + }, + { + "@id": "untp:Product" + }, + { + "@id": "untp:RegisteredIdentity" + } + ], + "rdfs:comment": [ + "The identifier scheme of the party. Typically a national business register or a global scheme such as GLEIF. ", + "The ID scheme of the facility. eg a GS1 GLN or a National land registry scheme. If self issued then use the party ID of the facility owner. ", + "The identifier scheme for this product. Eg a GS1 GTIN or an AU Livestock NLIS, or similar. If self issued then use the party ID of the issuer. ", + "The identifier scheme for this registered entity ID." + ], + "rdfs:label": "idScheme" + }, + { + "@id": "untp:registrationCountry", + "schema:rangeIncludes": { + "@id": "untp:Country" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Party" + } + ], + "rdfs:comment": [ + "the country in which this organisation is registered - using ISO-3166 code and name." + ], + "rdfs:label": "registrationCountry" + }, + { + "@id": "untp:partyAddress", + "schema:rangeIncludes": { + "@id": "untp:Address" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Party" + } + ], + "rdfs:comment": [ + "The address of the party" + ], + "rdfs:label": "partyAddress" + }, + { + "@id": "untp:organisationWebsite", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Party" + } + ], + "rdfs:comment": [ + "Website for this organisation" + ], + "rdfs:label": "organisationWebsite" + }, + { + "@id": "untp:industryCategory", + "schema:rangeIncludes": { + "@id": "untp:Classification" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Party" + } + ], + "rdfs:comment": [ + "The industry categories for this organisation. Recommend use of UNCPC as the category scheme. for example - unstats.un.org/isic/1030" + ], + "rdfs:label": "industryCategory" + }, + { + "@id": "untp:partyAlsoKnownAs", + "schema:rangeIncludes": { + "@id": "untp:Party" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Party" + } + ], + "rdfs:comment": [ + "An optional list of other registered identifiers for this organisation. For example DUNS, GLN, LEI, etc" + ], + "rdfs:label": "partyAlsoKnownAs" + }, + { + "@id": "untp:countryCode", + "schema:rangeIncludes": { + "@id": "untp:CountryCode" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Country" + } + ], + "rdfs:comment": [ + "ISO 3166 country code" + ], + "rdfs:label": "countryCode" + }, + { + "@id": "untp:countryName", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Country" + } + ], + "rdfs:comment": [ + "Country Name as defined in ISO 3166" + ], + "rdfs:label": "countryName" + }, + { + "@id": "untp:addressCountry", + "schema:rangeIncludes": { + "@id": "untp:Country" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Address" + } + ], + "rdfs:comment": [ + "The address country as an ISO-3166 two letter country code and name." + ], + "rdfs:label": "addressCountry" + }, + { + "@id": "untp:code", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Classification" + }, + { + "@id": "untp:Score" + } + ], + "rdfs:comment": [ + "classification code within the scheme", + "The coded value for this score (eg \"AAA\")" + ], + "rdfs:label": "code" + }, + { + "@id": "untp:definition", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Classification" + }, + { + "@id": "untp:ConformityTopic" + }, + { + "@id": "untp:Score" + } + ], + "rdfs:comment": [ + "A rich definition of this classification code.", + "The rich definition of this conformity topic.", + "A description of the meaning of this score." + ], + "rdfs:label": "definition" + }, + { + "@id": "untp:schemeId", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Classification" + } + ], + "rdfs:comment": [ + "Classification scheme ID" + ], + "rdfs:label": "schemeId" + }, + { + "@id": "untp:schemeName", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Classification" + } + ], + "rdfs:comment": [ + "The name of the classification scheme" + ], + "rdfs:label": "schemeName" + }, + { + "@id": "untp:type", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:BitstringStatusListEntry" + } + ], + "rdfs:comment": [ + "The type of status list - must be set to \"The type property MUST be BitstringStatusListEntry.\"" + ], + "rdfs:label": "type" + }, + { + "@id": "untp:statusPurpose", + "schema:rangeIncludes": { + "@id": "untp:CredentialStatus" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:BitstringStatusListEntry" + } + ], + "rdfs:comment": [ + "Status purpose drawn from a standard list but extensible as per w3c bitstring status list specification." + ], + "rdfs:label": "statusPurpose" + }, + { + "@id": "untp:statusListIndex", + "schema:rangeIncludes": { + "@id": "xsd:integer" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:BitstringStatusListEntry" + } + ], + "rdfs:comment": [ + "\tThe statusListIndex property MUST be an arbitrary size integer greater than or equal to 0, expressed as a string in base 10. The value identifies the position of the status of the verifiable credential." + ], + "rdfs:label": "statusListIndex" + }, + { + "@id": "untp:statusListCredential", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:BitstringStatusListEntry" + } + ], + "rdfs:comment": [ + "The statusListCredential property MUST be a URL to a verifiable credential. When the URL is dereferenced, the resulting verifiable credential MUST have type property that includes the BitstringStatusListCredential value." + ], + "rdfs:label": "statusListCredential" + }, + { + "@id": "untp:mediaQuery", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RenderTemplate2024" + } + ], + "rdfs:comment": [ + "Media query as defined in https://www.w3.org/TR/mediaqueries-4/" + ], + "rdfs:label": "mediaQuery" + }, + { + "@id": "untp:template", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RenderTemplate2024" + } + ], + "rdfs:comment": [ + "An inline template field for use cases where remote retrieval of a render method is suboptimal" + ], + "rdfs:label": "template" + }, + { + "@id": "untp:url", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RenderTemplate2024" + } + ], + "rdfs:comment": [ + "URL for remotely hosted template" + ], + "rdfs:label": "url" + }, + { + "@id": "untp:mediaType", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RenderTemplate2024" + }, + { + "@id": "untp:Link" + }, + { + "@id": "untp:Image" + } + ], + "rdfs:comment": [ + "media type of the rendered output (eg text/html)", + "The media type of the target resource.", + "The media type of this image (eg image/png)" + ], + "rdfs:label": "mediaType" + }, + { + "@id": "untp:digestMultibase", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RenderTemplate2024" + }, + { + "@id": "untp:Link" + } + ], + "rdfs:comment": [ + "Used for resource integrity and/or validation of the inline `template`", + "An optional multi-base encoded digest to ensure the content of the link has not changed. See https://www.w3.org/TR/vc-data-integrity/#resource-integrity for more information." + ], + "rdfs:label": "digestMultibase" + }, + { + "@id": "untp:countryOfOperation", + "schema:rangeIncludes": { + "@id": "untp:Country" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Facility" + } + ], + "rdfs:comment": [ + "The country in which this facility is operating.using ISO-3166 code and name." + ], + "rdfs:label": "countryOfOperation" + }, + { + "@id": "untp:processCategory", + "schema:rangeIncludes": { + "@id": "untp:Classification" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Facility" + } + ], + "rdfs:comment": [ + "The industrial or production processes performed by this facility. Example unstats.un.org/isic/1030." + ], + "rdfs:label": "processCategory" + }, + { + "@id": "untp:relatedParty", + "schema:rangeIncludes": { + "@id": "untp:PartyRole" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Facility" + }, + { + "@id": "untp:Product" + }, + { + "@id": "untp:LifecycleEvent" + }, + { + "@id": "untp:MakeEvent" + }, + { + "@id": "untp:MoveEvent" + }, + { + "@id": "untp:ModifyEvent" + } + ], + "rdfs:comment": [ + "A list of parties with a specified role relationship to this facility ", + "A list of parties with a defined relationship to this product", + "Any related parties and their roles involved in this event (eg the carrier for a shipment event)", + "Any related parties and their roles involved in this event (eg the carrier for a shipment event)", + "Any related parties and their roles involved in this event (eg the carrier for a shipment event)", + "Any related parties and their roles involved in this event (eg the carrier for a shipment event)" + ], + "rdfs:label": "relatedParty" + }, + { + "@id": "untp:relatedDocument", + "schema:rangeIncludes": { + "@id": "untp:Link" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Facility" + }, + { + "@id": "untp:Product" + }, + { + "@id": "untp:LifecycleEvent" + }, + { + "@id": "untp:MakeEvent" + }, + { + "@id": "untp:MoveEvent" + }, + { + "@id": "untp:ModifyEvent" + } + ], + "rdfs:comment": [ + "A list of links to documents providing additional facility information. Documents that support a conformity claim (e.g. permits or certificates) SHOULD be referenced as claim evidence rather than here.", + "A list of links to documents providing additional product information. Documents that support a conformity claim (e.g. permits or certificates) SHOULD be referenced as claim evidence rather than here.", + "A list of links to documentary evidence that supports this event. ", + "A list of links to documentary evidence that supports this event. ", + "A list of links to documentary evidence that supports this event. ", + "A list of links to documentary evidence that supports this event. " + ], + "rdfs:label": "relatedDocument" + }, + { + "@id": "untp:facilityAlsoKnownAs", + "schema:rangeIncludes": { + "@id": "untp:Facility" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Facility" + } + ], + "rdfs:comment": [ + "An optional list of other registered identifiers for this facility - eg GLNs or other schemes." + ], + "rdfs:label": "facilityAlsoKnownAs" + }, + { + "@id": "untp:locationInformation", + "schema:rangeIncludes": { + "@id": "untp:Location" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Facility" + } + ], + "rdfs:comment": [ + "Geo-location information for this facility as a resolvable geographic area (a Plus Code), and/or a geo-located point (latitude / longitude), and/or a defined boundary (GeoJSON Polygon)." + ], + "rdfs:label": "locationInformation" + }, + { + "@id": "untp:address", + "schema:rangeIncludes": { + "@id": "untp:Address" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Facility" + } + ], + "rdfs:comment": [ + "The Postal address of the location." + ], + "rdfs:label": "address" + }, + { + "@id": "untp:materialUsage", + "schema:rangeIncludes": { + "@id": "untp:MaterialUsage" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Facility" + } + ], + "rdfs:comment": [ + "The type and provenance of materials consumed by the facility during the reporting period. " + ], + "rdfs:label": "materialUsage" + }, + { + "@id": "untp:performanceClaim", + "schema:rangeIncludes": { + "@id": "untp:Claim" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Facility" + }, + { + "@id": "untp:Product" + }, + { + "@id": "untp:Package" + } + ], + "rdfs:comment": [ + "A list of performance claims (eg deforestation status) for this facility.", + "A list of performance claims (eg emissions intensity) for this product.", + "conformity claims made about the packaging." + ], + "rdfs:label": "performanceClaim" + }, + { + "@id": "untp:role", + "schema:rangeIncludes": { + "@id": "untp:PartyRole" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:PartyRole" + } + ], + "rdfs:comment": [ + "The role played by the party in this relationship" + ], + "rdfs:label": "role" + }, + { + "@id": "untp:party", + "schema:rangeIncludes": { + "@id": "untp:Party" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:PartyRole" + } + ], + "rdfs:comment": [ + "The party that has the specified role." + ], + "rdfs:label": "party" + }, + { + "@id": "untp:linkURL", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Link" + } + ], + "rdfs:comment": [ + "The URL of the target resource. " + ], + "rdfs:label": "linkURL" + }, + { + "@id": "untp:linkName", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Link" + } + ], + "rdfs:comment": [ + "Display name for this link." + ], + "rdfs:label": "linkName" + }, + { + "@id": "untp:linkType", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Link" + } + ], + "rdfs:comment": [ + "The type of the target resource - drawn from a controlled vocabulary " + ], + "rdfs:label": "linkType" + }, + { + "@id": "untp:plusCode", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Location" + } + ], + "rdfs:comment": [ + "An open location code (https://maps.google.com/pluscodes/) representing this geographic location or region. Open location codes can represent any sized area from a point to a large region and are easily resolved to a visual map location. " + ], + "rdfs:label": "plusCode" + }, + { + "@id": "untp:geoLocation", + "schema:rangeIncludes": { + "@id": "untp:Coordinate" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Location" + }, + { + "@id": "untp:SensorData" + } + ], + "rdfs:comment": [ + "The latitude and longitude coordinates that best represent the specified location. ", + "The geolocation of this sensor data recording event." + ], + "rdfs:label": "geoLocation" + }, + { + "@id": "untp:geoBoundary", + "schema:rangeIncludes": { + "@id": "untp:Coordinate" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Location" + } + ], + "rdfs:comment": [ + "The list of ordered coordinates that define a closed area polygon as a location boundary. The first and last coordinates in the array must match - thereby defining a closed boundary." + ], + "rdfs:label": "geoBoundary" + }, + { + "@id": "untp:latitude", + "schema:rangeIncludes": { + "@id": "xsd:double" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Coordinate" + } + ], + "rdfs:comment": [ + "latitude: Angular distance north or south of the equator, expressed in decimal degrees.Valid range: −90.0 to +90.0." + ], + "rdfs:label": "latitude" + }, + { + "@id": "untp:longitude", + "schema:rangeIncludes": { + "@id": "xsd:double" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Coordinate" + } + ], + "rdfs:comment": [ + "longitude: Angular distance east or west of the Prime Meridian, expressed in decimal degrees.Valid range: −180.0 to +180.0." + ], + "rdfs:label": "longitude" + }, + { + "@id": "untp:applicablePeriod", + "schema:rangeIncludes": { + "@id": "untp:Period" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:MaterialUsage" + }, + { + "@id": "untp:Claim" + } + ], + "rdfs:comment": [ + "The period over which this material consumption is reported", + "The applicable reporting period for this facility record." + ], + "rdfs:label": "applicablePeriod" + }, + { + "@id": "untp:materialConsumed", + "schema:rangeIncludes": { + "@id": "untp:Material" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:MaterialUsage" + } + ], + "rdfs:comment": [ + "An list of materials consumed during the usage period. " + ], + "rdfs:label": "materialConsumed" + }, + { + "@id": "untp:startDate", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Period" + } + ], + "rdfs:comment": [ + "The period start date" + ], + "rdfs:label": "startDate" + }, + { + "@id": "untp:endDate", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Period" + } + ], + "rdfs:comment": [ + "The period end date" + ], + "rdfs:label": "endDate" + }, + { + "@id": "untp:periodInformation", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Period" + } + ], + "rdfs:comment": [ + "Additional information relevant to this reporting period" + ], + "rdfs:label": "periodInformation" + }, + { + "@id": "untp:originCountry", + "schema:rangeIncludes": { + "@id": "untp:Country" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Material" + } + ], + "rdfs:comment": [ + "A ISO 3166-1 code representing the country of origin of the component or ingredient." + ], + "rdfs:label": "originCountry" + }, + { + "@id": "untp:materialType", + "schema:rangeIncludes": { + "@id": "untp:Classification" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Material" + } + ], + "rdfs:comment": [ + "The type of this material - as a value drawn from a controlled vocabulary eg from UN Framework Classification for Resources (UNFC)." + ], + "rdfs:label": "materialType" + }, + { + "@id": "untp:massFraction", + "schema:rangeIncludes": { + "@id": "xsd:double" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Material" + } + ], + "rdfs:comment": [ + "The mass fraction as a decimal of the product (or facility reporting period) represented by this material. " + ], + "rdfs:label": "massFraction" + }, + { + "@id": "untp:mass", + "schema:rangeIncludes": { + "@id": "untp:Measure" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Material" + } + ], + "rdfs:comment": [ + "The mass of the material component." + ], + "rdfs:label": "mass" + }, + { + "@id": "untp:recycledMassFraction", + "schema:rangeIncludes": { + "@id": "xsd:double" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Material" + } + ], + "rdfs:comment": [ + "Mass fraction of this material that is recycled (eg 50% recycled Lithium)" + ], + "rdfs:label": "recycledMassFraction" + }, + { + "@id": "untp:hazardous", + "schema:rangeIncludes": { + "@id": "xsd:boolean" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Material" + } + ], + "rdfs:comment": [ + "Indicates whether this material is hazardous. If true then the materialSafetyInformation property must be present" + ], + "rdfs:label": "hazardous" + }, + { + "@id": "untp:symbol", + "schema:rangeIncludes": { + "@id": "untp:Image" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Material" + } + ], + "rdfs:comment": [ + "Based 64 encoded binary used to represent a visual symbol for a given material. " + ], + "rdfs:label": "symbol" + }, + { + "@id": "untp:materialSafetyInformation", + "schema:rangeIncludes": { + "@id": "untp:Link" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Material" + } + ], + "rdfs:comment": [ + "Reference to further information about safe handling of this hazardous material (for example a link to a material safety data sheet)" + ], + "rdfs:label": "materialSafetyInformation" + }, + { + "@id": "untp:value", + "schema:rangeIncludes": { + "@id": "xsd:double" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Measure" + } + ], + "rdfs:comment": [ + "The numeric value of the measure" + ], + "rdfs:label": "value" + }, + { + "@id": "untp:upperTolerance", + "schema:rangeIncludes": { + "@id": "xsd:double" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Measure" + } + ], + "rdfs:comment": [ + "The upper tolerance associated with this measure expressed in the same units as the measure. For example value=10, upperTolerance=0.1, unit=KGM would mean that this measure is 10kg + 0.1kg" + ], + "rdfs:label": "upperTolerance" + }, + { + "@id": "untp:lowerTolerance", + "schema:rangeIncludes": { + "@id": "xsd:double" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Measure" + } + ], + "rdfs:comment": [ + "The lower tolerance associated with this measure expressed in the same units as the measure. For example value=10, lowerTolerance=0.1, unit=KGM would mean that this measure is 10kg - 0.1kg" + ], + "rdfs:label": "lowerTolerance" + }, + { + "@id": "untp:unit", + "schema:rangeIncludes": { + "@id": "untp:UnitOfMeasure" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Measure" + } + ], + "rdfs:comment": [ + "Unit of measure drawn from the UNECE Rec20 measure code list." + ], + "rdfs:label": "unit" + }, + { + "@id": "untp:imageData", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Image" + } + ], + "rdfs:comment": [ + "The image data encoded as a base64 string." + ], + "rdfs:label": "imageData" + }, + { + "@id": "untp:referenceCriteria", + "schema:rangeIncludes": { + "@id": "untp:Criterion" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Claim" + } + ], + "rdfs:comment": [ + "The criterion against which the claim is made." + ], + "rdfs:label": "referenceCriteria" + }, + { + "@id": "untp:referenceRegulation", + "schema:rangeIncludes": { + "@id": "untp:Regulation" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Claim" + }, + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "List of references to regulation to which conformity is claimed claimed for this product", + "The reference to the regulation that defines the assessment criteria" + ], + "rdfs:label": "referenceRegulation" + }, + { + "@id": "untp:referenceStandard", + "schema:rangeIncludes": { + "@id": "untp:Standard" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Claim" + }, + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "List of references to standards to which conformity is claimed claimed for this product", + "The reference to the standard that defines the specification / criteria" + ], + "rdfs:label": "referenceStandard" + }, + { + "@id": "untp:claimDate", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Claim" + } + ], + "rdfs:comment": [ + "That date on which the claimed performance is applicable." + ], + "rdfs:label": "claimDate" + }, + { + "@id": "untp:claimedPerformance", + "schema:rangeIncludes": { + "@id": "untp:Performance" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Claim" + } + ], + "rdfs:comment": [ + "The claimed performance level " + ], + "rdfs:label": "claimedPerformance" + }, + { + "@id": "untp:evidence", + "schema:rangeIncludes": { + "@id": "untp:Link" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Claim" + }, + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "A URI pointing to the evidence supporting the claim. SHOULD be a URL to a UNTP Digital Conformity Credential (DCC)", + "Evidence to support this specific assessment." + ], + "rdfs:label": "evidence" + }, + { + "@id": "untp:conformityTopic", + "schema:rangeIncludes": { + "@id": "untp:ConformityTopic" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Claim" + }, + { + "@id": "untp:Criterion" + }, + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "The conformity topic category for this assessment", + "A global UN/CEFACT standard conformity topic code. ", + "The UNTP conformity topic used to categorise this assessment. Should match the topic defined by the scheme criterion." + ], + "rdfs:label": "conformityTopic" + }, + { + "@id": "untp:version", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Criterion" + }, + { + "@id": "untp:ConformityProfile" + }, + { + "@id": "untp:IssuingSoftware" + } + ], + "rdfs:comment": [ + "The major.minor version of the criterion. Minor versions represent changes that would not invalidate an assessment made under a previous version.", + "Version of this scheme following SemVer best practice (major.minor.patch). " + ], + "rdfs:label": "version" + }, + { + "@id": "untp:status", + "schema:rangeIncludes": { + "@id": "untp:CriterionStatus" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Criterion" + }, + { + "@id": "untp:ConformityProfile" + } + ], + "rdfs:comment": [ + "The lifecycle status of this criterion. ", + "The status of this conformity profile (draft, active, deprecated)" + ], + "rdfs:label": "status" + }, + { + "@id": "untp:documentation", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Criterion" + }, + { + "@id": "untp:ConformityScheme" + }, + { + "@id": "untp:ConformityProfile" + } + ], + "rdfs:comment": [ + "A web page carrying detailed information about this criterion.", + "A web page providing full documentation of this scheme.", + "A web page that describes this entity in detail." + ], + "rdfs:label": "documentation" + }, + { + "@id": "untp:requiredPerformance", + "schema:rangeIncludes": { + "@id": "untp:Performance" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Criterion" + } + ], + "rdfs:comment": [ + "The required performance level as one or more score and/or a metric that represents compliance defined by the criteria" + ], + "rdfs:label": "requiredPerformance" + }, + { + "@id": "untp:tag", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Criterion" + } + ], + "rdfs:comment": [ + "A set of tags that can be used by the scheme owner to be able to filter or group criterion in a large vocabulary for specific use cases." + ], + "rdfs:label": "tag" + }, + { + "@id": "untp:metric", + "schema:rangeIncludes": { + "@id": "untp:PerformanceMetric" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Performance" + }, + { + "@id": "untp:SensorData" + } + ], + "rdfs:comment": [ + "The metric (eg material emissions intensity CO2e/Kg or percentage of young workers) that is measured.", + "The type of measurement recorded in this sensor data event." + ], + "rdfs:label": "metric" + }, + { + "@id": "untp:improvementDirection", + "schema:rangeIncludes": { + "@id": "untp:ImprovementIndicator" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:PerformanceMetric" + } + ], + "rdfs:comment": [ + "Indicator of whether conforming performance is greater than or less than the defined threshold." + ], + "rdfs:label": "improvementDirection" + }, + { + "@id": "untp:aggregationMethod", + "schema:rangeIncludes": { + "@id": "untp:AggregationType" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:PerformanceMetric" + } + ], + "rdfs:comment": [ + "Indicates how to aggregate multiple values to report a single performance metric." + ], + "rdfs:label": "aggregationMethod" + }, + { + "@id": "untp:measure", + "schema:rangeIncludes": { + "@id": "untp:Measure" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Performance" + }, + { + "@id": "untp:SensorData" + } + ], + "rdfs:comment": [ + "The measured performance value", + "The value measured by this sensor measurement event." + ], + "rdfs:label": "measure" + }, + { + "@id": "untp:score", + "schema:rangeIncludes": { + "@id": "untp:Score" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Performance" + }, + { + "@id": "untp:ScoringFramework" + } + ], + "rdfs:comment": [ + "A performance score (eg \"AA\") drawn from a scoring framework defined by the scheme or criterion.", + "A list of scores and ranks associated with this scoring framework." + ], + "rdfs:label": "score" + }, + { + "@id": "untp:allowedUnit", + "schema:rangeIncludes": { + "@id": "untp:UnitOfMeasure" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:PerformanceMetric" + } + ], + "rdfs:comment": [ + "The allowed units for value reporting against this metric (eg cubic meters)" + ], + "rdfs:label": "allowedUnit" + }, + { + "@id": "untp:rank", + "schema:rangeIncludes": { + "@id": "xsd:integer" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Score" + } + ], + "rdfs:comment": [ + "The ranking of this score within the scoring framework - using an integer where \"1\" is the highest rank." + ], + "rdfs:label": "rank" + }, + { + "@id": "untp:jurisdictionCountry", + "schema:rangeIncludes": { + "@id": "untp:Country" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Regulation" + } + ], + "rdfs:comment": [ + "The legal jurisdiction (country) under which the regulation is issued." + ], + "rdfs:label": "jurisdictionCountry" + }, + { + "@id": "untp:administeredBy", + "schema:rangeIncludes": { + "@id": "untp:Party" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Regulation" + } + ], + "rdfs:comment": [ + "the issuing body of the regulation. For example Australian Government Department of Climate Change, Energy, the Environment and Water" + ], + "rdfs:label": "administeredBy" + }, + { + "@id": "untp:effectiveDate", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Regulation" + } + ], + "rdfs:comment": [ + "the date at which the regulation came into effect." + ], + "rdfs:label": "effectiveDate" + }, + { + "@id": "untp:issuingParty", + "schema:rangeIncludes": { + "@id": "untp:Party" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Standard" + } + ], + "rdfs:comment": [ + "The party that issued the standard " + ], + "rdfs:label": "issuingParty" + }, + { + "@id": "untp:issueDate", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Standard" + } + ], + "rdfs:comment": [ + "The date when the standard was issued." + ], + "rdfs:label": "issueDate" + }, + { + "@id": "untp:assessorLevel", + "schema:rangeIncludes": { + "@id": "untp:AssessorLevel" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "Assurance code pertaining to assessor (relation to the object under assessment)" + ], + "rdfs:label": "assessorLevel" + }, + { + "@id": "untp:assessmentLevel", + "schema:rangeIncludes": { + "@id": "untp:AssessmentLevel" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "Assurance pertaining to assessment (any authority or support for the assessment process)" + ], + "rdfs:label": "assessmentLevel" + }, + { + "@id": "untp:attestationType", + "schema:rangeIncludes": { + "@id": "untp:AttestationType" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "The type of criterion (optional or mandatory)." + ], + "rdfs:label": "attestationType" + }, + { + "@id": "untp:issuedToParty", + "schema:rangeIncludes": { + "@id": "untp:Party" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "The party to whom the conformity attestation was issued." + ], + "rdfs:label": "issuedToParty" + }, + { + "@id": "untp:authorisation", + "schema:rangeIncludes": { + "@id": "untp:Endorsement" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "The authority under which a conformity claim is issued. For example a national accreditation authority may authorise a test lab to issue test certificates about a product against a standard. " + ], + "rdfs:label": "authorisation" + }, + { + "@id": "untp:referenceScheme", + "schema:rangeIncludes": { + "@id": "untp:ConformityScheme" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "The conformity scheme under which this attestation is made." + ], + "rdfs:label": "referenceScheme" + }, + { + "@id": "untp:referenceProfile", + "schema:rangeIncludes": { + "@id": "untp:ConformityProfile" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "The specific versioned conformity profile (comprising a set of versioned criteria) against which this conformity attestation is made." + ], + "rdfs:label": "referenceProfile" + }, + { + "@id": "untp:profileScore", + "schema:rangeIncludes": { + "@id": "untp:Score" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "The overall performance against a scheme level performance measurement framework for the referenced profile or scheme." + ], + "rdfs:label": "profileScore" + }, + { + "@id": "untp:conformityCertificate", + "schema:rangeIncludes": { + "@id": "untp:Link" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "A reference to the human / printable version of this conformity attestation - typically represented as a PDF document. The document may have more details than are represented in the digital attestation." + ], + "rdfs:label": "conformityCertificate" + }, + { + "@id": "untp:auditableEvidence", + "schema:rangeIncludes": { + "@id": "untp:Link" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "Auditable evidence supporting this assessment such as raw measurements, supporting documents. This is usually private data and would normally be encrypted." + ], + "rdfs:label": "auditableEvidence" + }, + { + "@id": "untp:trustmark", + "schema:rangeIncludes": { + "@id": "untp:Image" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + }, + { + "@id": "untp:Endorsement" + }, + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "A trust mark as a small binary image encoded as base64 with a description. Maye be displayed on the conformity credential rendering.", + "The trust mark image awarded by the AB to the CAB to indicate accreditation.", + "The trust mark or seal used by this conformity scheme." + ], + "rdfs:label": "trustmark" + }, + { + "@id": "untp:conformityAssessment", + "schema:rangeIncludes": { + "@id": "untp:ConformityAssessment" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAttestation" + } + ], + "rdfs:comment": [ + "A list of individual assessment made under this attestation. " + ], + "rdfs:label": "conformityAssessment" + }, + { + "@id": "untp:issuingAuthority", + "schema:rangeIncludes": { + "@id": "untp:Party" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Endorsement" + } + ], + "rdfs:comment": [ + "The competent authority that issued the accreditation." + ], + "rdfs:label": "issuingAuthority" + }, + { + "@id": "untp:endorsementEvidence", + "schema:rangeIncludes": { + "@id": "untp:Link" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Endorsement" + } + ], + "rdfs:comment": [ + "The evidence that supports the authority under which the attestation is issued - for an example an accreditation certificate." + ], + "rdfs:label": "endorsementEvidence" + }, + { + "@id": "untp:owner", + "schema:rangeIncludes": { + "@id": "untp:Party" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "The party that is the owner / maintainer of this conformity scheme." + ], + "rdfs:label": "owner" + }, + { + "@id": "untp:endorsementLevel", + "schema:rangeIncludes": { + "@id": "untp:SchemeEndorsementLevel" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "The scheme assurance type." + ], + "rdfs:label": "endorsementLevel" + }, + { + "@id": "untp:endorsement", + "schema:rangeIncludes": { + "@id": "untp:Endorsement" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "The endorsement provided to the scheme by an external authority such as a regulator, an accreditaiton authority, or a benchmarking scheme." + ], + "rdfs:label": "endorsement" + }, + { + "@id": "untp:schemeScoringFramework", + "schema:rangeIncludes": { + "@id": "untp:ScoringFramework" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "The scheme level overall scoring framework that represents the achievement levels (AA, A, B etc) that maybe be awarded to the subject of an independent assessment under the scheme." + ], + "rdfs:label": "schemeScoringFramework" + }, + { + "@id": "untp:licenseType", + "schema:rangeIncludes": { + "@id": "untp:LicenseType" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "Descriptive name and URL link to the license conditions associated with this scheme." + ], + "rdfs:label": "licenseType" + }, + { + "@id": "untp:establishedDate", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "The date when this scheme was first established. " + ], + "rdfs:label": "establishedDate" + }, + { + "@id": "untp:geographicScope", + "schema:rangeIncludes": { + "@id": "untp:Classification" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "The geographic scope of this scheme as a list of ISO-3166 countries, regions, or code=001, name=Worldwide to indicate global coverage." + ], + "rdfs:label": "geographicScope" + }, + { + "@id": "untp:industryScope", + "schema:rangeIncludes": { + "@id": "untp:Classification" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "A list of UN ISIC code & name indicating the industry scope for this scheme. " + ], + "rdfs:label": "industryScope" + }, + { + "@id": "untp:conformsTo", + "schema:rangeIncludes": { + "@id": "untp:Link" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "The name and URI of the vocabulary standard (eg UNTP CVC) that the machine readable version of this sceme conforms to." + ], + "rdfs:label": "conformsTo" + }, + { + "@id": "untp:includedProfile", + "schema:rangeIncludes": { + "@id": "untp:ConformityProfile" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityScheme" + } + ], + "rdfs:comment": [ + "The list of versioned conformity profiles included in this scheme" + ], + "rdfs:label": "includedProfile" + }, + { + "@id": "untp:validFrom", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityProfile" + } + ], + "rdfs:comment": [ + "The data from which this scheme version is valid." + ], + "rdfs:label": "validFrom" + }, + { + "@id": "untp:subjectType", + "schema:rangeIncludes": { + "@id": "untp:AssessmentSubjectType" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityProfile" + } + ], + "rdfs:comment": [ + "The type of the subject of assessments made under this conformity profile (eg product, facility, organisation)" + ], + "rdfs:label": "subjectType" + }, + { + "@id": "untp:standardAlignment", + "schema:rangeIncludes": { + "@id": "untp:StandardAlignment" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityProfile" + } + ], + "rdfs:comment": [ + "A list of voluntary standards referenced by this conformity profile and against which some level of compliance can be inferred for subjects that pass an assessment. " + ], + "rdfs:label": "standardAlignment" + }, + { + "@id": "untp:regulatoryAlignment", + "schema:rangeIncludes": { + "@id": "untp:RegulatoryAlignment" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityProfile" + } + ], + "rdfs:comment": [ + "A list of regulations or legally binding conventions referenced by this conformity profile and against which some level of compliance can be inferred for subjects that pass an assessment. " + ], + "rdfs:label": "regulatoryAlignment" + }, + { + "@id": "untp:criterionScoringFramework", + "schema:rangeIncludes": { + "@id": "untp:ScoringFramework" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityProfile" + } + ], + "rdfs:comment": [ + "A list of named scoring frameworks that are applied by criterion within this profile. " + ], + "rdfs:label": "criterionScoringFramework" + }, + { + "@id": "untp:criterion", + "schema:rangeIncludes": { + "@id": "untp:Criterion" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityProfile" + } + ], + "rdfs:comment": [ + "A list of criterion that are included in this conformity profile." + ], + "rdfs:label": "criterion" + }, + { + "@id": "untp:scope", + "schema:rangeIncludes": { + "@id": "untp:Classification" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityProfile" + } + ], + "rdfs:comment": [ + "A set of classification codes that may be used to categorize the applicability of this criteria - for example industry sector, jurisdiction or commodity type - based on a formal vocabulary." + ], + "rdfs:label": "scope" + }, + { + "@id": "untp:scheme", + "schema:rangeIncludes": { + "@id": "untp:ConformityScheme" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityProfile" + } + ], + "rdfs:comment": [ + "The conformity scheme under which this versioned profile is maintained." + ], + "rdfs:label": "scheme" + }, + { + "@id": "untp:standard", + "schema:rangeIncludes": { + "@id": "untp:Standard" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:StandardAlignment" + } + ], + "rdfs:comment": [ + "The standard against which this alignment assessment is made." + ], + "rdfs:label": "standard" + }, + { + "@id": "untp:alignmentLevel", + "schema:rangeIncludes": { + "@id": "untp:SchemeAlignmentLevel" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:StandardAlignment" + }, + { + "@id": "untp:RegulatoryAlignment" + } + ], + "rdfs:comment": [ + "A level of alignment with the referenced standard (exceeds, meets, partial,..)", + "A level of alignment with the referenced standard (exceeds, meets, partial,..)" + ], + "rdfs:label": "alignmentLevel" + }, + { + "@id": "untp:regulation", + "schema:rangeIncludes": { + "@id": "untp:Regulation" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RegulatoryAlignment" + } + ], + "rdfs:comment": [ + "The regulation against which this alignment assessment is made." + ], + "rdfs:label": "regulation" + }, + { + "@id": "untp:assessmentCriteria", + "schema:rangeIncludes": { + "@id": "untp:Criterion" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "The specification against which the assessment is made." + ], + "rdfs:label": "assessmentCriteria" + }, + { + "@id": "untp:assessmentDate", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "The date on which this assessment was made. " + ], + "rdfs:label": "assessmentDate" + }, + { + "@id": "untp:assessedPerformance", + "schema:rangeIncludes": { + "@id": "untp:Performance" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "The assessed performance against criteria." + ], + "rdfs:label": "assessedPerformance" + }, + { + "@id": "untp:assessedProduct", + "schema:rangeIncludes": { + "@id": "untp:ProductVerification" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "The product which is the subject of this assessment." + ], + "rdfs:label": "assessedProduct" + }, + { + "@id": "untp:assessedFacility", + "schema:rangeIncludes": { + "@id": "untp:FacilityVerification" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "The facility which is the subject of this assessment." + ], + "rdfs:label": "assessedFacility" + }, + { + "@id": "untp:assessedOrganisation", + "schema:rangeIncludes": { + "@id": "untp:Party" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "An organisation that is the subject of this assessment." + ], + "rdfs:label": "assessedOrganisation" + }, + { + "@id": "untp:specifiedCondition", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "A list of specific conditions that constrain this conformity assessment. For example a specific jurisdiction, material type, or test method." + ], + "rdfs:label": "specifiedCondition" + }, + { + "@id": "untp:conformance", + "schema:rangeIncludes": { + "@id": "xsd:boolean" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ConformityAssessment" + } + ], + "rdfs:comment": [ + "An indicator (true / false) whether the outcome of this assessment is conformant to the requirements defined by the standard or criterion." + ], + "rdfs:label": "conformance" + }, + { + "@id": "untp:product", + "schema:rangeIncludes": { + "@id": "untp:Product" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ProductVerification" + }, + { + "@id": "untp:EventProduct" + } + ], + "rdfs:comment": [ + "The product, serial or batch that is the subject of this assessment", + "The product item / model / batch subject to this lifecycle event." + ], + "rdfs:label": "product" + }, + { + "@id": "untp:idVerifiedByCAB", + "schema:rangeIncludes": { + "@id": "xsd:boolean" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ProductVerification" + }, + { + "@id": "untp:FacilityVerification" + } + ], + "rdfs:comment": [ + "Indicates whether the conformity assessment body has verified the identity product that is the subject of the assessment.", + "Indicates whether the conformity assessment body has verified the identity of the facility which is the subject of the assessment." + ], + "rdfs:label": "idVerifiedByCAB" + }, + { + "@id": "untp:modelNumber", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "Where available, the model number (for manufactured products) or material identification (for bulk materials)" + ], + "rdfs:label": "modelNumber" + }, + { + "@id": "untp:batchNumber", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "Identifier of the specific production batch of the product. Unique within the product class." + ], + "rdfs:label": "batchNumber" + }, + { + "@id": "untp:itemNumber", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "A number or code representing a specific serialised item of the product. Unique within product class." + ], + "rdfs:label": "itemNumber" + }, + { + "@id": "untp:idGranularity", + "schema:rangeIncludes": { + "@id": "untp:ProductIDGranularity" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "The identification granularity for this product (item, batch, model)" + ], + "rdfs:label": "idGranularity" + }, + { + "@id": "untp:productImage", + "schema:rangeIncludes": { + "@id": "untp:Link" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "Reference information (location, type, name) of an image of the product." + ], + "rdfs:label": "productImage" + }, + { + "@id": "untp:characteristics", + "schema:rangeIncludes": { + "@id": "untp:Characteristics" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "A set of industry specific product information. " + ], + "rdfs:label": "characteristics" + }, + { + "@id": "untp:productCategory", + "schema:rangeIncludes": { + "@id": "untp:Classification" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "A code representing the product's class, typically using the UN CPC (United Nations Central Product Classification) https://unstats.un.org/unsd/classifications/Econ/cpc" + ], + "rdfs:label": "productCategory" + }, + { + "@id": "untp:producedAtFacility", + "schema:rangeIncludes": { + "@id": "untp:Facility" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "The Facility where the product batch was produced / manufactured." + ], + "rdfs:label": "producedAtFacility" + }, + { + "@id": "untp:productionDate", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "The ISO 8601 date on which the product batch or individual serialised item was manufactured." + ], + "rdfs:label": "productionDate" + }, + { + "@id": "untp:expiryDate", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "The date at which this product is no longer fit for use. Typically used for a food product use-by date but may also represent the usable life of any product." + ], + "rdfs:label": "expiryDate" + }, + { + "@id": "untp:countryOfProduction", + "schema:rangeIncludes": { + "@id": "untp:Country" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "The country in which this item was produced / manufactured.using ISO-3166 code and name." + ], + "rdfs:label": "countryOfProduction" + }, + { + "@id": "untp:dimensions", + "schema:rangeIncludes": { + "@id": "untp:Dimension" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + }, + { + "@id": "untp:Package" + } + ], + "rdfs:comment": [ + "The physical dimensions of the product. Not every dimension is relevant to every products. For example bulk materials may have weight and volume but not length, width, or height.\"weight\":{\"value\":10, \"unit\":\"KGM\"}", + "dimensions of the packaging" + ], + "rdfs:label": "dimensions" + }, + { + "@id": "untp:materialProvenance", + "schema:rangeIncludes": { + "@id": "untp:Material" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "A list of materials provenance objects providing details on the origin and mass fraction of materials of the product or batch." + ], + "rdfs:label": "materialProvenance" + }, + { + "@id": "untp:packaging", + "schema:rangeIncludes": { + "@id": "untp:Package" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "The packaging for this product." + ], + "rdfs:label": "packaging" + }, + { + "@id": "untp:productLabel", + "schema:rangeIncludes": { + "@id": "untp:Image" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Product" + } + ], + "rdfs:comment": [ + "An array of labels that may appear on the product such as certification marks or regulatory labels." + ], + "rdfs:label": "productLabel" + }, + { + "@id": "untp:weight", + "schema:rangeIncludes": { + "@id": "untp:Measure" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Dimension" + } + ], + "rdfs:comment": [ + "the weight of the product. EG {\"value\":10, \"unit\":\"KGM\"}" + ], + "rdfs:label": "weight" + }, + { + "@id": "untp:length", + "schema:rangeIncludes": { + "@id": "untp:Measure" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Dimension" + } + ], + "rdfs:comment": [ + "The length of the product or packaging eg {\"value\":840, \"unit\":\"MMT\"}" + ], + "rdfs:label": "length" + }, + { + "@id": "untp:width", + "schema:rangeIncludes": { + "@id": "untp:Measure" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Dimension" + } + ], + "rdfs:comment": [ + "The width of the product or packaging. eg {\"value\":150, \"unit\":\"MMT\"}" + ], + "rdfs:label": "width" + }, + { + "@id": "untp:height", + "schema:rangeIncludes": { + "@id": "untp:Measure" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Dimension" + } + ], + "rdfs:comment": [ + "The height of the product or packaging. eg {\"value\":220, \"unit\":\"MMT\"}" + ], + "rdfs:label": "height" + }, + { + "@id": "untp:volume", + "schema:rangeIncludes": { + "@id": "untp:Measure" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Dimension" + } + ], + "rdfs:comment": [ + "The displacement volume of the product. eg {\"value\":7.5, \"unit\":\"LTR\"}" + ], + "rdfs:label": "volume" + }, + { + "@id": "untp:materialUsed", + "schema:rangeIncludes": { + "@id": "untp:Material" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Package" + } + ], + "rdfs:comment": [ + "materials used for the packaging." + ], + "rdfs:label": "materialUsed" + }, + { + "@id": "untp:packageLabel", + "schema:rangeIncludes": { + "@id": "untp:Image" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:Package" + } + ], + "rdfs:comment": [ + "An array of package labels that may appear on the packaging together with their meaning. Use for small images that represent certification marks or regulatory requirements. Large images should be linked as evidence to claims." + ], + "rdfs:label": "packageLabel" + }, + { + "@id": "untp:facility", + "schema:rangeIncludes": { + "@id": "untp:Facility" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:FacilityVerification" + } + ], + "rdfs:comment": [ + "The facility which is the subject of this assessment" + ], + "rdfs:label": "facility" + }, + { + "@id": "untp:eventDate", + "schema:rangeIncludes": { + "@id": "xsd:datetime" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:LifecycleEvent" + }, + { + "@id": "untp:MakeEvent" + }, + { + "@id": "untp:MoveEvent" + }, + { + "@id": "untp:ModifyEvent" + } + ], + "rdfs:comment": [ + "The date and time at which this lifecycle event occurs. use 00:00 for time if only a date is required.", + "The date and time at which this lifecycle event occurs. use 00:00 for time if only a date is required.", + "The date and time at which this lifecycle event occurs. use 00:00 for time if only a date is required.", + "The date and time at which this lifecycle event occurs. use 00:00 for time if only a date is required." + ], + "rdfs:label": "eventDate" + }, + { + "@id": "untp:sensorData", + "schema:rangeIncludes": { + "@id": "untp:SensorData" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:LifecycleEvent" + }, + { + "@id": "untp:MakeEvent" + }, + { + "@id": "untp:MoveEvent" + }, + { + "@id": "untp:ModifyEvent" + } + ], + "rdfs:comment": [ + "A sensor data set associated with this lifecycle event.", + "A sensor data set associated with this lifecycle event.", + "A sensor data set associated with this lifecycle event.", + "A sensor data set associated with this lifecycle event." + ], + "rdfs:label": "sensorData" + }, + { + "@id": "untp:activityType", + "schema:rangeIncludes": { + "@id": "untp:Classification" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:LifecycleEvent" + }, + { + "@id": "untp:MakeEvent" + }, + { + "@id": "untp:MoveEvent" + }, + { + "@id": "untp:ModifyEvent" + } + ], + "rdfs:comment": [ + "The business activity that this event represents (eg shipping, repair, etc) using a standard classification scheme - eg https://ref.gs1.org/cbv/BizStep. This may be replaced with industry specific vocabularies (ginning, spinning, weaving, dyeing, etc in textiles)", + "The business activity that this event represents (eg shipping, repair, etc) using a standard classification scheme - eg https://ref.gs1.org/cbv/BizStep. This may be replaced with industry specific vocabularies (ginning, spinning, weaving, dyeing, etc in textiles)", + "The business activity that this event represents (eg shipping, repair, etc) using a standard classification scheme - eg https://ref.gs1.org/cbv/BizStep. This may be replaced with industry specific vocabularies (ginning, spinning, weaving, dyeing, etc in textiles)", + "The business activity that this event represents (eg shipping, repair, etc) using a standard classification scheme - eg https://ref.gs1.org/cbv/BizStep. This may be replaced with industry specific vocabularies (ginning, spinning, weaving, dyeing, etc in textiles)" + ], + "rdfs:label": "activityType" + }, + { + "@id": "untp:inputProduct", + "schema:rangeIncludes": { + "@id": "untp:EventProduct" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:MakeEvent" + } + ], + "rdfs:comment": [ + "An array of input products and quantities for this production or manufacturing process" + ], + "rdfs:label": "inputProduct" + }, + { + "@id": "untp:outputProduct", + "schema:rangeIncludes": { + "@id": "untp:EventProduct" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:MakeEvent" + } + ], + "rdfs:comment": [ + "An array of output products and quantities for this produciton or manufacturing process" + ], + "rdfs:label": "outputProduct" + }, + { + "@id": "untp:madeAtFacility", + "schema:rangeIncludes": { + "@id": "untp:Facility" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:MakeEvent" + } + ], + "rdfs:comment": [ + "The facility at which this production / manufacturing event happens." + ], + "rdfs:label": "madeAtFacility" + }, + { + "@id": "untp:rawData", + "schema:rangeIncludes": { + "@id": "untp:Link" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:SensorData" + } + ], + "rdfs:comment": [ + "Link to raw data file associated with this sensor reading (eg an image)." + ], + "rdfs:label": "rawData" + }, + { + "@id": "untp:sensor", + "schema:rangeIncludes": { + "@id": "untp:Product" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:SensorData" + } + ], + "rdfs:comment": [ + "The sensor device used for this sensor measurement" + ], + "rdfs:label": "sensor" + }, + { + "@id": "untp:quantity", + "schema:rangeIncludes": { + "@id": "untp:Measure" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:EventProduct" + } + ], + "rdfs:comment": [ + "The quantity of product subject to this lifecycle event. Not needed for serialised items." + ], + "rdfs:label": "quantity" + }, + { + "@id": "untp:disposition", + "schema:rangeIncludes": { + "@id": "untp:ProductStatus" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:EventProduct" + } + ], + "rdfs:comment": [ + "The status of the product after the event has happened." + ], + "rdfs:label": "disposition" + }, + { + "@id": "untp:movedProduct", + "schema:rangeIncludes": { + "@id": "untp:EventProduct" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:MoveEvent" + } + ], + "rdfs:comment": [ + "An array of products and quantities for this movement / shipment process" + ], + "rdfs:label": "movedProduct" + }, + { + "@id": "untp:fromFacility", + "schema:rangeIncludes": { + "@id": "untp:Facility" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:MoveEvent" + } + ], + "rdfs:comment": [ + "The source facility for this movement / shipment of products" + ], + "rdfs:label": "fromFacility" + }, + { + "@id": "untp:toFacility", + "schema:rangeIncludes": { + "@id": "untp:Facility" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:MoveEvent" + } + ], + "rdfs:comment": [ + "The destination facility for this movement / shipment of products" + ], + "rdfs:label": "toFacility" + }, + { + "@id": "untp:consignmentId", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:MoveEvent" + } + ], + "rdfs:comment": [ + "The consignment ID related to this movement of products. Ideally this is a resolvable URL but if not available then use a URN notation such as urn:carrier:waybillNumber." + ], + "rdfs:label": "consignmentId" + }, + { + "@id": "untp:modifiedProduct", + "schema:rangeIncludes": { + "@id": "untp:EventProduct" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ModifyEvent" + } + ], + "rdfs:comment": [ + "An array of products and quantities for this intervention (repair, inspection, etc)" + ], + "rdfs:label": "modifiedProduct" + }, + { + "@id": "untp:modifiedAtFacility", + "schema:rangeIncludes": { + "@id": "untp:Facility" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:ModifyEvent" + } + ], + "rdfs:comment": [ + "The facility at which this intervention event happens." + ], + "rdfs:label": "modifiedAtFacility" + }, + { + "@id": "untp:registeredName", + "schema:rangeIncludes": { + "@id": "xsd:string" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RegisteredIdentity" + } + ], + "rdfs:comment": [ + "The registered name of the entity within the identifier scheme. Examples: product - EV battery 300Ah, Party - Sample Company Pty Ltd, Facility - Green Acres battery factory " + ], + "rdfs:label": "registeredName" + }, + { + "@id": "untp:registeredDate", + "schema:rangeIncludes": { + "@id": "xsd:date" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RegisteredIdentity" + } + ], + "rdfs:comment": [ + "The date on which this identity was first registered with the registrar." + ], + "rdfs:label": "registeredDate" + }, + { + "@id": "untp:publicInformation", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RegisteredIdentity" + } + ], + "rdfs:comment": [ + "A link to further information about the registered entity on the authoritative registrar site." + ], + "rdfs:label": "publicInformation" + }, + { + "@id": "untp:registrar", + "schema:rangeIncludes": { + "@id": "untp:Party" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RegisteredIdentity" + } + ], + "rdfs:comment": [ + "The registrar party that operates the register." + ], + "rdfs:label": "registrar" + }, + { + "@id": "untp:registerType", + "schema:rangeIncludes": { + "@id": "untp:RegistryType" + }, + "@type": [ + "rdf:Property", + "owl:ObjectProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RegisteredIdentity" + } + ], + "rdfs:comment": [ + "The thematic purpose of the register - organisations, facilities, products, trademarks, etc" + ], + "rdfs:label": "registerType" + }, + { + "@id": "untp:registrationScope", + "schema:rangeIncludes": { + "@id": "xsd:anyURI" + }, + "@type": [ + "rdf:Property", + "owl:DatatypeProperty" + ], + "schema:domainIncludes": [ + { + "@id": "untp:RegisteredIdentity" + } + ], + "rdfs:comment": [ + "List of URIs that represent the roles or scopes of membership. For example [\"https://abr.business.gov.au/Help/EntityTypeDescription?Id=19\"]" + ], + "rdfs:label": "registrationScope" + }, + { + "@id": "untp:AssessmentLevel", + "@type": "rdfs:Class", + "rdfs:label": "AssessmentLevel", + "rdfs:comment": "Type of authority endorsement of the assessment process" + }, + { + "@id": "untp:AssessmentLevel#authority-benchmark", + "@type": "untp:AssessmentLevel", + "rdf:value": "authority-benchmark", + "rdfs:label": "Authority-derived assurance: Recognition by approved benchmarking organisation", + "rdfs:comment": "Benchmarking of scheme by an organization approved to UNIDO benchmarking\nprinciples and process. UNIDO Global Best Practice Framework for Organisations Performing Benchmarking Activities for Certification-related Conformity Assessment Schemes 2026" + }, + { + "@id": "untp:AssessmentLevel#authority-mandate", + "@type": "untp:AssessmentLevel", + "rdf:value": "authority-mandate", + "rdfs:label": "Authority-derived assurance: Recognition by government mandate", + "rdfs:comment": "Government mandate for conformity assessment activity. Ownership or mandate provided by national government or intergovernmental entity." + }, + { + "@id": "untp:AssessmentLevel#authority-globalmra", + "@type": "untp:AssessmentLevel", + "rdf:value": "authority-globalmra", + "rdfs:label": "Authority-derived assurance:Global accreditation mutual recognition arrangement", + "rdfs:comment": "Accreditation of CAB under global mutual recognition arrangement by a body peer-evaluated\nto ISO/IEC 17011. Scheme evaluation is a prerequisite for accreditation of CABs by bodies that are signatories to the Global Accreditation Cooperation Incorporated Mutual Recognition Arrangement." + }, + { + "@id": "untp:AssessmentLevel#authority-peer", + "@type": "untp:AssessmentLevel", + "rdf:value": "authority-peer", + "rdfs:label": "Authority-derived assurance: Recognition by a governmental peer assessment authority", + "rdfs:comment": "Peer assessment process managed by government. Ownership or mandate provided by national government or intergovernmental entity." + }, + { + "@id": "untp:AssessmentLevel#authority-extended-mra", + "@type": "untp:AssessmentLevel", + "rdf:value": "authority-extended-mra", + "rdfs:label": "Authority- derived assurance: Peer assessment body recognition for accredited CAB", + "rdfs:comment": "Independent peer assessment for accredited CAB. This pathway applies to CABs accredited under the Mutual Recognition Arrangement of the Global Accreditation Cooperation Incorporated. Schemes used by CABs may be owned by the peer assessment body but the CAB itself shall not be owned by or otherwise related to the peer assessment body." + }, + { + "@id": "untp:AssessmentLevel#scheme-self", + "@type": "untp:AssessmentLevel", + "rdf:value": "scheme-self", + "rdfs:label": "Scheme-derived assurance: Self-declaration by registered scheme", + "rdfs:comment": "Scheme owner directly conducting conformity assessment activities. The linked scheme self-declaration can be used to assist in judging credibility of the scheme." + }, + { + "@id": "untp:AssessmentLevel#scheme-cab", + "@type": "untp:AssessmentLevel", + "rdf:value": "scheme-cab", + "rdfs:label": "Scheme-derived assurance: Recognition of CAB by registered scheme", + "rdfs:comment": "Scheme owner recognition of other parties assessing against the scheme standards. The linked scheme self-declaration can be used to assist in judging credibility of the scheme. Users of conformity credentials issued by a CAB recognised under a scheme may refer to the linked scheme self-declaration for details of the CAB-approval process used by the scheme owner" + }, + { + "@id": "untp:AssessmentLevel#no-endorsement", + "@type": "untp:AssessmentLevel", + "rdf:value": "no-endorsement", + "rdfs:label": "No endorsement.", + "rdfs:comment": "conformity assessment claiming no external authority or else unspecified" + }, + { + "@id": "untp:AssessmentSubjectType", + "@type": "rdfs:Class", + "rdfs:label": "AssessmentSubjectType", + "rdfs:comment": "The type of entity being assessed." + }, + { + "@id": "untp:AssessmentSubjectType#product", + "@type": "untp:AssessmentSubjectType", + "rdf:value": "product", + "rdfs:label": "Product", + "rdfs:comment": "The conformity profile targets products — assessing characteristics, composition, performance, or safety of manufactured goods." + }, + { + "@id": "untp:AssessmentSubjectType#facility", + "@type": "untp:AssessmentSubjectType", + "rdf:value": "facility", + "rdfs:label": "Facility", + "rdfs:comment": "The conformity profile targets facilities — assessing the operational practices, environmental performance, or working conditions at a specific site." + }, + { + "@id": "untp:AssessmentSubjectType#organisation", + "@type": "untp:AssessmentSubjectType", + "rdf:value": "organisation", + "rdfs:label": "Organisation", + "rdfs:comment": "The conformity profile targets organisations — assessing entity-level governance, policies, management systems, or corporate sustainability performance." + }, + { + "@id": "untp:AssessorLevel", + "@type": "rdfs:Class", + "rdfs:label": "AssessorLevel", + "rdfs:comment": "Code that describes the level of independent assurance of the specific assessment" + }, + { + "@id": "untp:AssessorLevel#self", + "@type": "untp:AssessorLevel", + "rdf:value": "self", + "rdfs:label": "Self assessed", + "rdfs:comment": " self-assessment" + }, + { + "@id": "untp:AssessorLevel#commercial", + "@type": "untp:AssessorLevel", + "rdf:value": "commercial", + "rdfs:label": "Commercial assessment", + "rdfs:comment": " conformity assessment by related body or under commercial contract" + }, + { + "@id": "untp:AssessorLevel#buyer", + "@type": "untp:AssessorLevel", + "rdf:value": "buyer", + "rdfs:label": "Buyer assessment", + "rdfs:comment": " conformity assessment by potential purchaser" + }, + { + "@id": "untp:AssessorLevel#membership", + "@type": "untp:AssessorLevel", + "rdf:value": "membership", + "rdfs:label": "Industry body assessment", + "rdfs:comment": " conformity assessment by industry representative body or membership body" + }, + { + "@id": "untp:AssessorLevel#unspecified", + "@type": "untp:AssessorLevel", + "rdf:value": "unspecified", + "rdfs:label": "No independent assessment", + "rdfs:comment": " conformity assessment by party with unspecified relationship " + }, + { + "@id": "untp:AssessorLevel#3rdParty", + "@type": "untp:AssessorLevel", + "rdf:value": "3rdParty", + "rdfs:label": "Independent third party assessment", + "rdfs:comment": " 3rd party (independent) conformity assessment" + }, + { + "@id": "untp:AssessorLevel#hybrid", + "@type": "untp:AssessorLevel", + "rdf:value": "hybrid", + "rdfs:label": "Input from self-declaring parties", + "rdfs:comment": "2nd or 3rd party conformity assessment that is dependent on the accuracy of information provided by self-declaring parties" + }, + { + "@id": "untp:AttestationType", + "@type": "rdfs:Class", + "rdfs:label": "AttestationType", + "rdfs:comment": "A code for the type of the attestation credential" + }, + { + "@id": "untp:AttestationType#certification", + "@type": "untp:AttestationType", + "rdf:value": "certification", + "rdfs:label": "certification", + "rdfs:comment": "A formal third party certification of conformity" + }, + { + "@id": "untp:AttestationType#declaration", + "@type": "untp:AttestationType", + "rdf:value": "declaration", + "rdfs:label": "declaration", + "rdfs:comment": "A self assessed declaration of conformity" + }, + { + "@id": "untp:AttestationType#inspection", + "@type": "untp:AttestationType", + "rdf:value": "inspection", + "rdfs:label": "inspection", + "rdfs:comment": "An Inspection report " + }, + { + "@id": "untp:AttestationType#testing", + "@type": "untp:AttestationType", + "rdf:value": "testing", + "rdfs:label": "testing", + "rdfs:comment": "A test report" + }, + { + "@id": "untp:AttestationType#verification", + "@type": "untp:AttestationType", + "rdf:value": "verification", + "rdfs:label": "verification", + "rdfs:comment": "A verification report" + }, + { + "@id": "untp:AttestationType#validation", + "@type": "untp:AttestationType", + "rdf:value": "validation", + "rdfs:label": "validation", + "rdfs:comment": "A validation report" + }, + { + "@id": "untp:AttestationType#calibration", + "@type": "untp:AttestationType", + "rdf:value": "calibration", + "rdfs:label": "calibration", + "rdfs:comment": "An equipment calibration report" + }, + { + "@id": "untp:CountryCode", + "@type": "rdfs:Class", + "rdfs:label": "CountryCode", + "rdfs:comment": "ISO 2 letter country code" + }, + { + "@id": "untp:CredentialStatus", + "@type": "rdfs:Class", + "rdfs:label": "CredentialStatus", + "rdfs:comment": "The status purpose of a credential status entry within a W3C Verifiable Credential, indicating the type of status check that can be performed (e.g. revocation, suspension, refresh, or message)." + }, + { + "@id": "untp:CredentialStatus#refresh", + "@type": "untp:CredentialStatus", + "rdf:value": "refresh", + "rdfs:label": "refresh", + "rdfs:comment": "Used to signal that an updated verifiable credential is available via the credential's refresh service feature. This status does not invalidate the verifiable credential and is not reversible." + }, + { + "@id": "untp:CredentialStatus#revocation", + "@type": "untp:CredentialStatus", + "rdf:value": "revocation", + "rdfs:label": "revocation", + "rdfs:comment": "Used to cancel the validity of a verifiable credential. This status is not reversible." + }, + { + "@id": "untp:CredentialStatus#suspension", + "@type": "untp:CredentialStatus", + "rdf:value": "suspension", + "rdfs:label": "suspension", + "rdfs:comment": "Used to temporarily prevent the acceptance of a verifiable credential. This status is reversible." + }, + { + "@id": "untp:CredentialStatus#message", + "@type": "untp:CredentialStatus", + "rdf:value": "message", + "rdfs:label": "message", + "rdfs:comment": "Used to indicate a ussuer specified flexible status message associated with a verifiable credential. The status message descriptions MUST be defined in credentialSubject.statusMessages. credentialSubject.statusSize MUST be specified when this statusPurpose value is used." + }, + { + "@id": "untp:CriterionStatus", + "@type": "rdfs:Class", + "rdfs:label": "CriterionStatus", + "rdfs:comment": "The status of the conformity profile or criterion" + }, + { + "@id": "untp:CriterionStatus#proposed", + "@type": "untp:CriterionStatus", + "rdf:value": "proposed", + "rdfs:label": "Proposed", + "rdfs:comment": "The criterion is proposed" + }, + { + "@id": "untp:CriterionStatus#active", + "@type": "untp:CriterionStatus", + "rdf:value": "active", + "rdfs:label": "Active", + "rdfs:comment": "The criterion is in active use." + }, + { + "@id": "untp:CriterionStatus#deprecated", + "@type": "untp:CriterionStatus", + "rdf:value": "deprecated", + "rdfs:label": "Deprecated", + "rdfs:comment": "The criterion is deprecated." + }, + { + "@id": "untp:LicenseType", + "@type": "rdfs:Class", + "rdfs:label": "LicenseType", + "rdfs:comment": "The license type of the published vocabulary" + }, + { + "@id": "untp:LicenseType#proprietary-Code", + "@type": "untp:LicenseType", + "rdf:value": "proprietary-Code", + "rdfs:label": "Proprietary", + "rdfs:comment": "Commercial software, internal docs. Restrictiveness - Very high" + }, + { + "@id": "untp:LicenseType#proprietary-Document", + "@type": "untp:LicenseType", + "rdf:value": "proprietary-Document", + "rdfs:label": "Documentation licenses", + "rdfs:comment": "Manuals, standards. Restrictiveness - Medium" + }, + { + "@id": "untp:LicenseType#permissive-OpenSource", + "@type": "untp:LicenseType", + "rdf:value": "permissive-OpenSource", + "rdfs:label": "Permissive open source", + "rdfs:comment": "Libraries, frameworks. Restrictiveness - Low" + }, + { + "@id": "untp:LicenseType#copyleft", + "@type": "untp:LicenseType", + "rdf:value": "copyleft", + "rdfs:label": "Copyleft", + "rdfs:comment": "Platforms, infrastructure. Restrictiveness - Medium–high" + }, + { + "@id": "untp:LicenseType#creative-Commons", + "@type": "untp:LicenseType", + "rdf:value": "creative-Commons", + "rdfs:label": "Creative Commons", + "rdfs:comment": "Media, publications. Restrictiveness - Variable" + }, + { + "@id": "untp:LicenseType#source-Available", + "@type": "untp:LicenseType", + "rdf:value": "source-Available", + "rdfs:label": "Source-available", + "rdfs:comment": "Commercial SaaS vendors. Restrictiveness - Medium–high" + }, + { + "@id": "untp:LicenseType#public", + "@type": "untp:LicenseType", + "rdf:value": "public", + "rdfs:label": "Public domain", + "rdfs:comment": "Data, examples. Restrictiveness - None" + }, + { + "@id": "untp:MimeType", + "@type": "rdfs:Class", + "rdfs:label": "MimeType", + "rdfs:comment": "IANA multipart media encoding type " + }, + { + "@id": "untp:PartyRole", + "@type": "rdfs:Class", + "rdfs:label": "PartyRole", + "rdfs:comment": "The role for this facility - party or product - party relationship" + }, + { + "@id": "untp:PartyRole#owner", + "@type": "untp:PartyRole", + "rdf:value": "owner", + "rdfs:label": "Party that owns the product or asset" + }, + { + "@id": "untp:PartyRole#producer", + "@type": "untp:PartyRole", + "rdf:value": "producer", + "rdfs:label": "Party that extracts, grows, or produces raw materials" + }, + { + "@id": "untp:PartyRole#manufacturer", + "@type": "untp:PartyRole", + "rdf:value": "manufacturer", + "rdfs:label": "Party that manufactures or assembles the product" + }, + { + "@id": "untp:PartyRole#processor", + "@type": "untp:PartyRole", + "rdf:value": "processor", + "rdfs:label": "Party that processes or transforms materials" + }, + { + "@id": "untp:PartyRole#remanufacturer", + "@type": "untp:PartyRole", + "rdf:value": "remanufacturer", + "rdfs:label": "Party that remanufactures or refurbishes products" + }, + { + "@id": "untp:PartyRole#recycler", + "@type": "untp:PartyRole", + "rdf:value": "recycler", + "rdfs:label": "Party that recovers materials from products" + }, + { + "@id": "untp:PartyRole#operator", + "@type": "untp:PartyRole", + "rdf:value": "operator", + "rdfs:label": "Party operating a facility or process" + }, + { + "@id": "untp:PartyRole#serviceProvider", + "@type": "untp:PartyRole", + "rdf:value": "serviceProvider", + "rdfs:label": "Party providing maintenance or servicing" + }, + { + "@id": "untp:PartyRole#inspector", + "@type": "untp:PartyRole", + "rdf:value": "inspector", + "rdfs:label": "Party performing inspection or testing" + }, + { + "@id": "untp:PartyRole#certifier", + "@type": "untp:PartyRole", + "rdf:value": "certifier", + "rdfs:label": "Party issuing certification or conformity assessment" + }, + { + "@id": "untp:PartyRole#logisticsProvider", + "@type": "untp:PartyRole", + "rdf:value": "logisticsProvider", + "rdfs:label": "Party responsible for logistics operations" + }, + { + "@id": "untp:PartyRole#carrier", + "@type": "untp:PartyRole", + "rdf:value": "carrier", + "rdfs:label": "Party physically transporting the goods" + }, + { + "@id": "untp:PartyRole#consignor", + "@type": "untp:PartyRole", + "rdf:value": "consignor", + "rdfs:label": "Party sending the goods" + }, + { + "@id": "untp:PartyRole#consignee", + "@type": "untp:PartyRole", + "rdf:value": "consignee", + "rdfs:label": "Party receiving the goods" + }, + { + "@id": "untp:PartyRole#importer", + "@type": "untp:PartyRole", + "rdf:value": "importer", + "rdfs:label": "Party importing the goods into a jurisdiction" + }, + { + "@id": "untp:PartyRole#exporter", + "@type": "untp:PartyRole", + "rdf:value": "exporter", + "rdfs:label": "Party exporting the goods from a jurisdiction" + }, + { + "@id": "untp:PartyRole#distributor", + "@type": "untp:PartyRole", + "rdf:value": "distributor", + "rdfs:label": "Party distributing goods in the supply chain" + }, + { + "@id": "untp:PartyRole#retailer", + "@type": "untp:PartyRole", + "rdf:value": "retailer", + "rdfs:label": "Party selling goods to end users" + }, + { + "@id": "untp:PartyRole#brandOwner", + "@type": "untp:PartyRole", + "rdf:value": "brandOwner", + "rdfs:label": "Party responsible for the brand or product specification" + }, + { + "@id": "untp:PartyRole#regulator", + "@type": "untp:PartyRole", + "rdf:value": "regulator", + "rdfs:label": "Authority responsible for regulatory oversight" + }, + { + "@id": "untp:ImprovementIndicator", + "@type": "rdfs:Class", + "rdfs:label": "ImprovementIndicator", + "rdfs:comment": "Indicator of whether conforming performance is greater than or less than the defined threshold." + }, + { + "@id": "untp:ImprovementIndicator#higher", + "@type": "untp:ImprovementIndicator", + "rdf:value": "higher", + "rdfs:label": "higher", + "rdfs:comment": "Performance improves with a higher measured value" + }, + { + "@id": "untp:ImprovementIndicator#lower", + "@type": "untp:ImprovementIndicator", + "rdf:value": "lower", + "rdfs:label": "lower", + "rdfs:comment": "Performance improves with a lower measured value" + }, + { + "@id": "untp:AggregationType", + "@type": "rdfs:Class", + "rdfs:label": "AggregationType", + "rdfs:comment": "Indicates how to aggregate multiple values to report a single performance metric." + }, + { + "@id": "untp:AggregationType#sum", + "@type": "untp:AggregationType", + "rdf:value": "sum", + "rdfs:label": "sum", + "rdfs:comment": "Values add up (e.g. total GHG emissions across all facilities = sum of each facility's emissions)" + }, + { + "@id": "untp:AggregationType#weighted-average", + "@type": "untp:AggregationType", + "rdf:value": "weighted-average", + "rdfs:label": "weighted-average", + "rdfs:comment": "Values must be averaged weighted by volume/output (e.g. emissions intensity per kg across suppliers)" + }, + { + "@id": "untp:AggregationType#latest", + "@type": "untp:AggregationType", + "rdf:value": "latest", + "rdfs:label": "latest", + "rdfs:comment": "Only the most recent value is meaningful (e.g. a biodiversity assessment score where only the current state matters)" + }, + { + "@id": "untp:ProductIDGranularity", + "@type": "rdfs:Class", + "rdfs:label": "ProductIDGranularity", + "rdfs:comment": "Product identification granularity" + }, + { + "@id": "untp:ProductIDGranularity#model", + "@type": "untp:ProductIDGranularity", + "rdf:value": "model", + "rdfs:label": "product model level ID", + "rdfs:comment": "" + }, + { + "@id": "untp:ProductIDGranularity#batch", + "@type": "untp:ProductIDGranularity", + "rdf:value": "batch", + "rdfs:label": "product manufactured batch level ID", + "rdfs:comment": "" + }, + { + "@id": "untp:ProductIDGranularity#item", + "@type": "untp:ProductIDGranularity", + "rdf:value": "item", + "rdfs:label": "serialised item level ID", + "rdfs:comment": "" + }, + { + "@id": "untp:ProductStatus", + "@type": "rdfs:Class", + "rdfs:label": "ProductStatus", + "rdfs:comment": "The lifecycle status of a product, describing its current state from initial production through to eventual disposal or recycling. Used as the value of the disposition property on EventProduct in traceability events." + }, + { + "@id": "untp:ProductStatus#new", + "@type": "untp:ProductStatus", + "rdf:value": "new", + "rdfs:label": "New", + "rdfs:comment": "Product has been newly manufactured or produced and has not yet entered service. Equivalent to GS1 CBV Disp-active." + }, + { + "@id": "untp:ProductStatus#inTransit", + "@type": "untp:ProductStatus", + "rdf:value": "inTransit", + "rdfs:label": "In Transit", + "rdfs:comment": "Product has been shipped and is in transit between facilities. Equivalent to GS1 CBV Disp-in_transit." + }, + { + "@id": "untp:ProductStatus#active", + "@type": "untp:ProductStatus", + "rdf:value": "active", + "rdfs:label": "Active", + "rdfs:comment": "Product is in active service or use by the end customer or a downstream manufacturer. Equivalent to GS1 CBV Disp-retail_sold." + }, + { + "@id": "untp:ProductStatus#repaired", + "@type": "untp:ProductStatus", + "rdf:value": "repaired", + "rdfs:label": "Repaired", + "rdfs:comment": "Product has been repaired or refurbished to restore functionality and returned to service. Equivalent to GS1 CBV Disp-available (after a repairing step)." + }, + { + "@id": "untp:ProductStatus#recalled", + "@type": "untp:ProductStatus", + "rdf:value": "recalled", + "rdfs:label": "Recalled", + "rdfs:comment": "Product has been withdrawn from the market or service due to a safety, quality, or compliance issue. Equivalent to GS1 CBV Disp-recalled." + }, + { + "@id": "untp:ProductStatus#expired", + "@type": "untp:ProductStatus", + "rdf:value": "expired", + "rdfs:label": "Expired", + "rdfs:comment": "Product has passed its use-by, certification, or regulatory expiration date. Equivalent to GS1 CBV Disp-expired." + }, + { + "@id": "untp:ProductStatus#consumed", + "@type": "untp:ProductStatus", + "rdf:value": "consumed", + "rdfs:label": "Consumed", + "rdfs:comment": "Product has been consumed as an input to a manufacturing process and no longer exists as a separate item. No direct GS1 CBV equivalent." + }, + { + "@id": "untp:ProductStatus#recycled", + "@type": "untp:ProductStatus", + "rdf:value": "recycled", + "rdfs:label": "Recycled", + "rdfs:comment": "Product has been processed to recover constituent materials for reuse in new products. No direct GS1 CBV equivalent." + }, + { + "@id": "untp:ProductStatus#disposed", + "@type": "untp:ProductStatus", + "rdf:value": "disposed", + "rdfs:label": "Disposed", + "rdfs:comment": "Product has reached end of life and has been disposed of or destroyed without material recovery. Equivalent to GS1 CBV Disp-disposed and Disp-destroyed." + }, + { + "@id": "untp:RegistryType", + "@type": "rdfs:Class", + "rdfs:label": "RegistryType", + "rdfs:comment": "A registry category code." + }, + { + "@id": "untp:RegistryType#product", + "@type": "untp:RegistryType", + "rdf:value": "product", + "rdfs:label": "Product", + "rdfs:comment": "A register of products or product classes, such as a national product catalogue or a GS1 GTIN registry." + }, + { + "@id": "untp:RegistryType#facility", + "@type": "untp:RegistryType", + "rdf:value": "facility", + "rdfs:label": "Facility", + "rdfs:comment": "A register of facilities or sites, such as a mining cadastre, environmental permit register, or industrial facility directory." + }, + { + "@id": "untp:RegistryType#business", + "@type": "untp:RegistryType", + "rdf:value": "business", + "rdfs:label": "Business", + "rdfs:comment": "A register of business entities or legal persons, such as a national company register, VAT register, or LEI registry." + }, + { + "@id": "untp:RegistryType#trademark", + "@type": "untp:RegistryType", + "rdf:value": "trademark", + "rdfs:label": "Trademark", + "rdfs:comment": "A register of trademarks, certification marks, or other intellectual property identifiers maintained by a national or international IP office." + }, + { + "@id": "untp:RegistryType#land", + "@type": "untp:RegistryType", + "rdf:value": "land", + "rdfs:label": "Land", + "rdfs:comment": "A register of land titles, parcels, or cadastral boundaries, such as a national land registry or territorial cadastre." + }, + { + "@id": "untp:RegistryType#accreditation", + "@type": "untp:RegistryType", + "rdf:value": "accreditation", + "rdfs:label": "Accreditation", + "rdfs:comment": "A register of accredited conformity assessment bodies, maintained by a national or regional accreditation authority." + }, + { + "@id": "untp:SchemeAlignmentLevel", + "@type": "rdfs:Class", + "rdfs:label": "SchemeAlignmentLevel", + "rdfs:comment": "Alignment level of a scheme profile or criterion against a reference standard or regulation" + }, + { + "@id": "untp:SchemeAlignmentLevel#meets", + "@type": "untp:SchemeAlignmentLevel", + "rdf:value": "meets", + "rdfs:label": "Meets", + "rdfs:comment": "The scheme profile or criterion fully satisfies the requirements of the referenced standard or regulation." + }, + { + "@id": "untp:SchemeAlignmentLevel#exceeds", + "@type": "untp:SchemeAlignmentLevel", + "rdf:value": "exceeds", + "rdfs:label": "Exceeds", + "rdfs:comment": "The scheme profile or criterion goes beyond the requirements of the referenced standard or regulation, imposing stricter thresholds or broader scope." + }, + { + "@id": "untp:SchemeAlignmentLevel#partial", + "@type": "untp:SchemeAlignmentLevel", + "rdf:value": "partial", + "rdfs:label": "Partially meets", + "rdfs:comment": "The scheme profile or criterion addresses some but not all requirements of the referenced standard or regulation." + }, + { + "@id": "untp:SchemeEndorsementLevel", + "@type": "rdfs:Class", + "rdfs:label": "SchemeEndorsementLevel", + "rdfs:comment": "The level of endorsement or recognition that a conformity scheme has received from authoritative bodies, indicating the degree of independent assurance over the scheme's credibility and rigour." + }, + { + "@id": "untp:SchemeEndorsementLevel#endorsed_self", + "@type": "untp:SchemeEndorsementLevel", + "rdf:value": "endorsed_self", + "rdfs:label": "Self-declaration by scheme owner", + "rdfs:comment": "Scheme owner self-declaration using the UNTP scheme declaration template" + }, + { + "@id": "untp:SchemeEndorsementLevel#endorsed_mandate", + "@type": "untp:SchemeEndorsementLevel", + "rdf:value": "endorsed_mandate", + "rdfs:label": "Government owned or mandated scheme", + "rdfs:comment": "Ownership of scheme or mandate for adoption of scheme by national government or intergovernmental entity." + }, + { + "@id": "untp:SchemeEndorsementLevel#endorsed_accreditation", + "@type": "untp:SchemeEndorsementLevel", + "rdf:value": "endorsed_accreditation", + "rdfs:label": "Accreditation authority endorsement of scheme suitability", + "rdfs:comment": "Scheme evaluated for suitability by the Global Accreditation Cooperation Incorporated, or by an accreditation body member of the Global Mutual Recognition Arrangement for such scope, or by a Regional Accreditation Cooperation member." + }, + { + "@id": "untp:SchemeEndorsementLevel#endorsed_benchmarked", + "@type": "untp:SchemeEndorsementLevel", + "rdf:value": "endorsed_benchmarked", + "rdfs:label": "Scheme recognition by a benchmarking organisation approved to UNIDO principles and process", + "rdfs:comment": "Benchmarking of scheme by an organization approved to UNIDO benchmarking principles and process. UNIDO Global Best Practice Framework for Organisations Performing Benchmarking Activities for Certification-related Conformity Assessment Schemes 2026" + }, + { + "@id": "untp:UnitOfMeasure", + "@type": "rdfs:Class", + "rdfs:label": "UnitOfMeasure", + "rdfs:comment": "UNECE Recommendation 20 Unit of Measure codelist" + } + ] +} diff --git a/tests/fixtures/upstream/v0.7.0/vocabularies/untp-topics.jsonld b/tests/fixtures/upstream/v0.7.0/vocabularies/untp-topics.jsonld new file mode 100644 index 0000000..4d3326b --- /dev/null +++ b/tests/fixtures/upstream/v0.7.0/vocabularies/untp-topics.jsonld @@ -0,0 +1,1281 @@ +{ + "@context": { + "skos": "http://www.w3.org/2004/02/skos/core#", + "dcterms": "http://purl.org/dc/terms/", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "owl": "http://www.w3.org/2002/07/owl#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "sdg": "http://metadata.un.org/sdg/", + "topics": "https://vocabulary.uncefact.org/conformity-topics/", + "prefLabel": { "@id": "skos:prefLabel", "@language": "en" }, + "definition": { "@id": "skos:definition", "@language": "en" }, + "notation": "skos:notation", + "scopeNote": { "@id": "skos:scopeNote", "@language": "en" }, + "broader": { "@id": "skos:broader", "@type": "@id" }, + "narrower": { "@id": "skos:narrower", "@type": "@id", "@container": "@set" }, + "topConceptOf": { "@id": "skos:topConceptOf", "@type": "@id" }, + "hasTopConcept": { "@id": "skos:hasTopConcept", "@type": "@id", "@container": "@set" }, + "inScheme": { "@id": "skos:inScheme", "@type": "@id" }, + "relatedMatch": { "@id": "skos:relatedMatch", "@type": "@id", "@container": "@set" } + }, + "@graph": [ + { + "@id": "https://vocabulary.uncefact.org/conformity-topics/", + "@type": "skos:ConceptScheme", + "dcterms:title": { "@value": "UNTP Conformity Topic Classification", "@language": "en" }, + "dcterms:description": { "@value": "A hierarchical classification scheme for conformity topics used to categorise conformity criteria published by scheme owners. Encompasses sustainability (environmental, social, governance), product integrity, trade compliance, technical conformity, and information security domains. Designed as a common reference taxonomy for interoperable conformity assessments across regulatory frameworks and voluntary standards.", "@language": "en" }, + "dcterms:creator": "United Nations Economic Commission for Europe (UNECE)", + "dcterms:license": "https://creativecommons.org/licenses/by/4.0/", + "owl:versionInfo": "0.2.0-working", + "dcterms:issued": "2025-01-01", + "dcterms:modified": "2026-03-13", + "hasTopConcept": [ + "topics:ecological-resilience", + "topics:human-equity-and-welfare", + "topics:ethical-governance", + "topics:product-integrity", + "topics:circular-value-chains", + "topics:economic-sustainability", + "topics:health-and-safety", + "topics:systemic-sustainability", + "topics:trade-and-market-access", + "topics:technical-conformity", + "topics:information-security" + ] + }, + + { + "@id": "topics:ecological-resilience", + "@type": "skos:Concept", + "prefLabel": "Ecological Resilience", + "definition": "Environmental protection, resource conservation, and climate resilience. Covers emissions reduction, energy transition, water stewardship, waste prevention, biodiversity, and circular design.", + "notation": "01", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "UN SDGs 6, 7, 12, 13, 14, 15; OECD Guidelines Chapter VI: Environment; EU ESPR Art. 5-8 and Annex I.", + "relatedMatch": ["sdg:6", "sdg:7", "sdg:12", "sdg:13", "sdg:14", "sdg:15"], + "narrower": [ + "topics:greenhouse-gas-emissions", + "topics:renewable-energy-use", + "topics:water-conservation", + "topics:waste-minimization", + "topics:ecosystem-preservation", + "topics:forest-conservation", + "topics:recycled-material-integration", + "topics:sustainable-product-design", + "topics:chemical-safety", + "topics:air-quality-management" + ] + }, + { + "@id": "topics:greenhouse-gas-emissions", + "@type": "skos:Concept", + "prefLabel": "Greenhouse Gas Emissions", + "definition": "Measuring, reporting, and reducing greenhouse gas emissions (CO2, methane, N2O, F-gases) across production, transport, and supply chain activities.", + "notation": "01.01", + "broader": "topics:ecological-resilience", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:13"], + "scopeNote": "EU ESPR Art. 5 - Environmental Sustainability; UNTP environment.emissions." + }, + { + "@id": "topics:renewable-energy-use", + "@type": "skos:Concept", + "prefLabel": "Renewable Energy Use", + "definition": "Transition to sustainable energy sources including solar, wind, hydro, and other renewables in production and operations.", + "notation": "01.02", + "broader": "topics:ecological-resilience", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:7"], + "scopeNote": "EU ESPR Art. 7 - Energy Efficiency; UNTP environment.energy." + }, + { + "@id": "topics:water-conservation", + "@type": "skos:Concept", + "prefLabel": "Water Conservation", + "definition": "Sustainable water management including efficient use, pollution prevention, and watershed protection throughout operations and supply chains.", + "notation": "01.03", + "broader": "topics:ecological-resilience", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:6"], + "scopeNote": "EU ESPR Annex I - Water Use; UNTP environment.water." + }, + { + "@id": "topics:waste-minimization", + "@type": "skos:Concept", + "prefLabel": "Waste Minimization", + "definition": "Reducing waste generation through prevention, reuse, and improved production processes across the product lifecycle.", + "notation": "01.04", + "broader": "topics:ecological-resilience", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 6 - Waste Prevention; UNTP environment.waste." + }, + { + "@id": "topics:ecosystem-preservation", + "@type": "skos:Concept", + "prefLabel": "Ecosystem Preservation", + "definition": "Protecting biodiversity, natural habitats, and ecosystem services from degradation caused by production and extraction activities.", + "notation": "01.05", + "broader": "topics:ecological-resilience", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:15"], + "scopeNote": "EU ESPR Annex I - Biodiversity Impact; UNTP environment.biodiversity." + }, + { + "@id": "topics:forest-conservation", + "@type": "skos:Concept", + "prefLabel": "Forest Conservation", + "definition": "Preventing deforestation and promoting sustainable forestry practices in raw material sourcing and land use.", + "notation": "01.06", + "broader": "topics:ecological-resilience", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:15"], + "scopeNote": "EU ESPR Art. 5 - Resource Use; UNTP environment.deforestation." + }, + { + "@id": "topics:recycled-material-integration", + "@type": "skos:Concept", + "prefLabel": "Recycled Material Integration", + "definition": "Incorporation of secondary and recycled materials into production processes, reducing dependence on virgin resources.", + "notation": "01.07", + "broader": "topics:ecological-resilience", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 8 - Recycled Content; UNTP circularity.content." + }, + { + "@id": "topics:sustainable-product-design", + "@type": "skos:Concept", + "prefLabel": "Sustainable Product Design", + "definition": "Designing products for durability, repairability, recyclability, and minimal environmental impact throughout their lifecycle.", + "notation": "01.08", + "broader": "topics:ecological-resilience", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 5 - Durability and Recyclability; UNTP circularity.design." + }, + { + "@id": "topics:chemical-safety", + "@type": "skos:Concept", + "prefLabel": "Chemical Safety", + "definition": "Restriction and responsible management of hazardous substances in materials, products, and production processes.", + "notation": "01.09", + "broader": "topics:ecological-resilience", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Annex I - Substance Restrictions." + }, + { + "@id": "topics:air-quality-management", + "@type": "skos:Concept", + "prefLabel": "Air Quality Management", + "definition": "Controlling and reducing non-GHG air pollutant emissions including SOx, NOx, VOCs, particulates, and ozone-depleting substances from operations and production processes.", + "notation": "01.10", + "broader": "topics:ecological-resilience", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3", "sdg:13"], + "scopeNote": "EU ESPR Annex I - Air Emissions; WHO Air Quality Guidelines; Montreal Protocol (ozone-depleting substances)." + }, + + { + "@id": "topics:human-equity-and-welfare", + "@type": "skos:Concept", + "prefLabel": "Human Equity and Welfare", + "definition": "Protection of human rights, promotion of fair labor practices, and support for community wellbeing across operations and supply chains.", + "notation": "02", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "UN SDGs 1, 3, 4, 5, 8, 10; OECD Guidelines Chapter IV: Human Rights and Chapter V: Employment and Industrial Relations; EU ESPR Art. 10.", + "relatedMatch": ["sdg:1", "sdg:3", "sdg:4", "sdg:5", "sdg:8", "sdg:10"], + "narrower": [ + "topics:rights-and-equality", + "topics:decent-work-conditions", + "topics:workplace-safety", + "topics:community-empowerment", + "topics:worker-representation", + "topics:forced-labor-elimination", + "topics:youth-protection", + "topics:gender-equity" + ] + }, + { + "@id": "topics:rights-and-equality", + "@type": "skos:Concept", + "prefLabel": "Rights and Equality", + "definition": "Ensuring non-discrimination and equal treatment regardless of race, gender, religion, disability, or other protected characteristics.", + "notation": "02.01", + "broader": "topics:human-equity-and-welfare", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:10"], + "scopeNote": "EU ESPR Art. 10 - Social Sustainability; UNTP social.rights." + }, + { + "@id": "topics:decent-work-conditions", + "@type": "skos:Concept", + "prefLabel": "Decent Work Conditions", + "definition": "Provision of fair wages, reasonable working hours, and dignified employment conditions throughout the supply chain.", + "notation": "02.02", + "broader": "topics:human-equity-and-welfare", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:8"], + "scopeNote": "EU ESPR Annex I - Supply Chain Due Diligence; UNTP social.labour." + }, + { + "@id": "topics:workplace-safety", + "@type": "skos:Concept", + "prefLabel": "Workplace Safety", + "definition": "Protecting worker health and safety through hazard prevention, protective equipment, and safe working environments.", + "notation": "02.03", + "broader": "topics:human-equity-and-welfare", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3"], + "scopeNote": "EU ESPR Art. 10 - Social Impact; UNTP social.safety." + }, + { + "@id": "topics:community-empowerment", + "@type": "skos:Concept", + "prefLabel": "Community Empowerment", + "definition": "Supporting local community development, livelihoods, and participation in decisions that affect their wellbeing.", + "notation": "02.04", + "broader": "topics:human-equity-and-welfare", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:1"], + "scopeNote": "EU ESPR Art. 10 - Stakeholder Engagement; UNTP social.community." + }, + { + "@id": "topics:worker-representation", + "@type": "skos:Concept", + "prefLabel": "Worker Representation", + "definition": "Respecting freedom of association, collective bargaining rights, and worker participation in workplace governance.", + "notation": "02.05", + "broader": "topics:human-equity-and-welfare", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:8"], + "scopeNote": "EU ESPR Annex I - Labor Rights." + }, + { + "@id": "topics:forced-labor-elimination", + "@type": "skos:Concept", + "prefLabel": "Forced Labor Elimination", + "definition": "Preventing all forms of forced, bonded, or compulsory labor including debt bondage and human trafficking in supply chains.", + "notation": "02.06", + "broader": "topics:human-equity-and-welfare", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:8"], + "scopeNote": "EU ESPR Art. 10 - Human Rights Due Diligence." + }, + { + "@id": "topics:youth-protection", + "@type": "skos:Concept", + "prefLabel": "Youth Protection", + "definition": "Safeguarding young workers from hazardous conditions and eliminating child labor in all forms across supply chains.", + "notation": "02.07", + "broader": "topics:human-equity-and-welfare", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:8"], + "scopeNote": "EU ESPR Annex I - Child Labor Ban." + }, + { + "@id": "topics:gender-equity", + "@type": "skos:Concept", + "prefLabel": "Gender Equity", + "definition": "Promoting gender diversity, equal opportunity, and elimination of gender-based discrimination in employment and business practices.", + "notation": "02.08", + "broader": "topics:human-equity-and-welfare", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:5"], + "scopeNote": "EU ESPR Art. 10 - Social Sustainability." + }, + + { + "@id": "topics:ethical-governance", + "@type": "skos:Concept", + "prefLabel": "Ethical Governance", + "definition": "Promoting organizational integrity, accountability, and transparent practices in business operations and decision-making.", + "notation": "03", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "UN SDG 16; OECD Guidelines Chapter II: General Policies and Chapter VII: Combating Bribery; EU ESPR Art. 11-12.", + "relatedMatch": ["sdg:16"], + "narrower": [ + "topics:anti-corruption-measures", + "topics:open-reporting", + "topics:legal-compliance", + "topics:responsible-procurement", + "topics:stakeholder-inclusion", + "topics:data-privacy", + "topics:ip-protection", + "topics:competitive-fairness" + ] + }, + { + "@id": "topics:anti-corruption-measures", + "@type": "skos:Concept", + "prefLabel": "Anti-Corruption Measures", + "definition": "Preventing bribery, extortion, and corrupt practices through policies, controls, and organizational culture.", + "notation": "03.01", + "broader": "topics:ethical-governance", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 11 - Governance Requirements; UNTP governance.ethics." + }, + { + "@id": "topics:open-reporting", + "@type": "skos:Concept", + "prefLabel": "Open Reporting", + "definition": "Transparent disclosure of environmental, social, and governance performance to stakeholders and the public.", + "notation": "03.02", + "broader": "topics:ethical-governance", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 12 - Information Requirements; UNTP governance.transparency." + }, + { + "@id": "topics:legal-compliance", + "@type": "skos:Concept", + "prefLabel": "Legal Compliance", + "definition": "Adherence to applicable laws, regulations, and legal obligations in all jurisdictions of operation.", + "notation": "03.03", + "broader": "topics:ethical-governance", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 4 - Compliance Obligations; UNTP governance.compliance." + }, + { + "@id": "topics:responsible-procurement", + "@type": "skos:Concept", + "prefLabel": "Responsible Procurement", + "definition": "Ethical sourcing and purchasing practices that consider environmental, social, and governance factors in supplier selection.", + "notation": "03.04", + "broader": "topics:ethical-governance", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 10 - Supply Chain Responsibility." + }, + { + "@id": "topics:stakeholder-inclusion", + "@type": "skos:Concept", + "prefLabel": "Stakeholder Inclusion", + "definition": "Meaningful engagement with affected parties including workers, communities, and civil society in governance processes.", + "notation": "03.05", + "broader": "topics:ethical-governance", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 10 - Stakeholder Dialogue." + }, + { + "@id": "topics:data-privacy", + "@type": "skos:Concept", + "prefLabel": "Data Privacy", + "definition": "Protection of personal information and responsible data handling in compliance with privacy regulations and ethical standards.", + "notation": "03.06", + "broader": "topics:ethical-governance", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 12 - Digital Product Passport." + }, + { + "@id": "topics:ip-protection", + "@type": "skos:Concept", + "prefLabel": "Intellectual Property Protection", + "definition": "Respecting intellectual property rights including patents, trademarks, copyrights, and trade secrets.", + "notation": "03.07", + "broader": "topics:ethical-governance", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 11 - Governance Standards." + }, + { + "@id": "topics:competitive-fairness", + "@type": "skos:Concept", + "prefLabel": "Competitive Fairness", + "definition": "Ensuring fair market practices, preventing anti-competitive behavior, and maintaining a level playing field.", + "notation": "03.08", + "broader": "topics:ethical-governance", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 11 - Governance." + }, + + { + "@id": "topics:product-integrity", + "@type": "skos:Concept", + "prefLabel": "Product Integrity", + "definition": "Ensuring products are safe, reliable, and meet quality and sustainability standards throughout their lifecycle.", + "notation": "04", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "UN SDGs 9, 12; OECD Guidelines Chapter VIII: Consumer Interests; EU ESPR Art. 4-7 and Annex I.", + "relatedMatch": ["sdg:9", "sdg:12"], + "narrower": [ + "topics:product-safety-standards", + "topics:quality-performance", + "topics:substance-control", + "topics:product-longevity", + "topics:standards-adherence", + "topics:supply-chain-traceability", + "topics:consumer-information", + "topics:end-of-life-management" + ] + }, + { + "@id": "topics:product-safety-standards", + "@type": "skos:Concept", + "prefLabel": "Product Safety Standards", + "definition": "Ensuring consumer safety through compliance with product safety requirements, testing, and hazard prevention.", + "notation": "04.01", + "broader": "topics:product-integrity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 5 - Safety Requirements; UNTP social.safety." + }, + { + "@id": "topics:quality-performance", + "@type": "skos:Concept", + "prefLabel": "Quality Performance", + "definition": "Meeting defined performance specifications, functional requirements, and quality benchmarks for products and services.", + "notation": "04.02", + "broader": "topics:product-integrity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "EU ESPR Art. 5 - Performance Standards." + }, + { + "@id": "topics:substance-control", + "@type": "skos:Concept", + "prefLabel": "Substance Control", + "definition": "Banning or restricting harmful materials and substances of concern in product composition and manufacturing.", + "notation": "04.03", + "broader": "topics:product-integrity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Annex I - Substance Restrictions." + }, + { + "@id": "topics:product-longevity", + "@type": "skos:Concept", + "prefLabel": "Product Longevity", + "definition": "Enhancing product durability, repairability, and lifespan to reduce premature obsolescence and waste.", + "notation": "04.04", + "broader": "topics:product-integrity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 5 - Durability; UNTP circularity.design." + }, + { + "@id": "topics:standards-adherence", + "@type": "skos:Concept", + "prefLabel": "Standards Adherence", + "definition": "Compliance with applicable product certifications, industry standards, and regulatory requirements.", + "notation": "04.05", + "broader": "topics:product-integrity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "EU ESPR Art. 4 - Ecodesign Requirements." + }, + { + "@id": "topics:supply-chain-traceability", + "@type": "skos:Concept", + "prefLabel": "Supply Chain Traceability", + "definition": "Tracking product origins, components, and transformations throughout the supply chain to enable transparency and accountability.", + "notation": "04.06", + "broader": "topics:product-integrity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 12 - Digital Product Passport; UNTP governance.transparency." + }, + { + "@id": "topics:consumer-information", + "@type": "skos:Concept", + "prefLabel": "Consumer Information", + "definition": "Providing clear, accurate, and accessible product labeling and information to enable informed consumer choices.", + "notation": "04.07", + "broader": "topics:product-integrity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 7 - Information Obligations." + }, + { + "@id": "topics:end-of-life-management", + "@type": "skos:Concept", + "prefLabel": "End-of-Life Management", + "definition": "Effective collection, recycling, and disposal processes for products at end of useful life, minimizing environmental impact.", + "notation": "04.08", + "broader": "topics:product-integrity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 6 - End-of-Life Requirements." + }, + + { + "@id": "topics:circular-value-chains", + "@type": "skos:Concept", + "prefLabel": "Circular Value Chains", + "definition": "Advancing sustainability, circularity, and responsible practices throughout supply and production networks.", + "notation": "05", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "UN SDGs 8, 12, 17; OECD Guidelines Chapter II: General Policies and Chapter VI: Environment; EU ESPR Art. 8, 10.", + "relatedMatch": ["sdg:8", "sdg:12", "sdg:17"], + "narrower": [ + "topics:ethical-material-sourcing", + "topics:supplier-sustainability", + "topics:resource-circularity", + "topics:energy-optimization", + "topics:supply-chain-labor-rights", + "topics:origin-tracking", + "topics:supplier-development", + "topics:supply-chain-risk-reduction" + ] + }, + { + "@id": "topics:ethical-material-sourcing", + "@type": "skos:Concept", + "prefLabel": "Ethical Material Sourcing", + "definition": "Procuring raw materials through sustainable and responsible practices, avoiding conflict minerals and environmentally destructive extraction.", + "notation": "05.01", + "broader": "topics:circular-value-chains", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 10 - Supply Chain Due Diligence; UNTP governance.transparency." + }, + { + "@id": "topics:supplier-sustainability", + "@type": "skos:Concept", + "prefLabel": "Supplier Sustainability", + "definition": "Ensuring suppliers meet environmental, social, and governance requirements through assessment, monitoring, and collaboration.", + "notation": "05.02", + "broader": "topics:circular-value-chains", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:17"], + "scopeNote": "EU ESPR Art. 10 - Supply Chain Responsibility." + }, + { + "@id": "topics:resource-circularity", + "@type": "skos:Concept", + "prefLabel": "Resource Circularity", + "definition": "Promoting reuse, remanufacturing, and recycling of materials to create closed-loop resource flows.", + "notation": "05.03", + "broader": "topics:circular-value-chains", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 8 - Recycled Content; UNTP circularity.content." + }, + { + "@id": "topics:energy-optimization", + "@type": "skos:Concept", + "prefLabel": "Energy Optimization", + "definition": "Improving energy efficiency across supply chain operations including manufacturing, logistics, and warehousing.", + "notation": "05.04", + "broader": "topics:circular-value-chains", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:7"], + "scopeNote": "EU ESPR Art. 7 - Energy Efficiency." + }, + { + "@id": "topics:supply-chain-labor-rights", + "@type": "skos:Concept", + "prefLabel": "Supply Chain Labor Rights", + "definition": "Ensuring fair treatment of workers throughout the supply chain including subcontractors and informal workers.", + "notation": "05.05", + "broader": "topics:circular-value-chains", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:8"], + "scopeNote": "EU ESPR Annex I - Labor Standards." + }, + { + "@id": "topics:origin-tracking", + "@type": "skos:Concept", + "prefLabel": "Origin Tracking", + "definition": "Transparent documentation and verification of material and product origins throughout the supply chain.", + "notation": "05.06", + "broader": "topics:circular-value-chains", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 12 - Digital Product Passport." + }, + { + "@id": "topics:supplier-development", + "@type": "skos:Concept", + "prefLabel": "Supplier Development", + "definition": "Building supplier capacity and capability to meet sustainability requirements through training, support, and partnership.", + "notation": "05.07", + "broader": "topics:circular-value-chains", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:17"], + "scopeNote": "EU ESPR Art. 10 - Supply Chain Support." + }, + { + "@id": "topics:supply-chain-risk-reduction", + "@type": "skos:Concept", + "prefLabel": "Supply Chain Risk Reduction", + "definition": "Identifying, assessing, and mitigating environmental, social, and operational vulnerabilities in supply networks.", + "notation": "05.08", + "broader": "topics:circular-value-chains", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 10 - Risk Management." + }, + + { + "@id": "topics:economic-sustainability", + "@type": "skos:Concept", + "prefLabel": "Economic Sustainability", + "definition": "Balancing profitability with sustainable economic practices that create shared value for businesses and communities.", + "notation": "06", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "UN SDGs 8, 9; OECD Guidelines Chapter II: General Policies; EU ESPR Art. 5, 7, 10, 11.", + "relatedMatch": ["sdg:8", "sdg:9"], + "narrower": [ + "topics:business-resilience", + "topics:sustainable-investment", + "topics:green-innovation", + "topics:employment-opportunities", + "topics:regional-economic-growth", + "topics:resource-efficiency", + "topics:economic-risk-management", + "topics:supply-network-strength" + ] + }, + { + "@id": "topics:business-resilience", + "@type": "skos:Concept", + "prefLabel": "Business Resilience", + "definition": "Building long-term profitability and organizational resilience through sustainable business models and practices.", + "notation": "06.01", + "broader": "topics:economic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:8"], + "scopeNote": "EU ESPR Art. 11 - Governance for Sustainability." + }, + { + "@id": "topics:sustainable-investment", + "@type": "skos:Concept", + "prefLabel": "Sustainable Investment", + "definition": "Directing capital toward green initiatives, sustainable technologies, and projects with positive environmental and social outcomes.", + "notation": "06.02", + "broader": "topics:economic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "EU ESPR Art. 5 - Resource Efficiency." + }, + { + "@id": "topics:green-innovation", + "@type": "skos:Concept", + "prefLabel": "Green Innovation", + "definition": "Developing sustainable technologies, processes, and business models that reduce environmental impact while creating economic value.", + "notation": "06.03", + "broader": "topics:economic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "EU ESPR Art. 5 - Innovation Requirements." + }, + { + "@id": "topics:employment-opportunities", + "@type": "skos:Concept", + "prefLabel": "Employment Opportunities", + "definition": "Creating decent jobs and fostering inclusive economic participation through sustainable business growth.", + "notation": "06.04", + "broader": "topics:economic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:8"], + "scopeNote": "EU ESPR Art. 10 - Social Impact." + }, + { + "@id": "topics:regional-economic-growth", + "@type": "skos:Concept", + "prefLabel": "Regional Economic Growth", + "definition": "Supporting local economic development and equitable distribution of economic benefits in communities of operation.", + "notation": "06.05", + "broader": "topics:economic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:8"], + "scopeNote": "EU ESPR Art. 10 - Community Benefits; UNTP social.community." + }, + { + "@id": "topics:resource-efficiency", + "@type": "skos:Concept", + "prefLabel": "Resource Efficiency", + "definition": "Optimizing resource utilization to reduce costs and environmental impact while maintaining productivity.", + "notation": "06.06", + "broader": "topics:economic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:8"], + "scopeNote": "EU ESPR Art. 7 - Efficiency Standards." + }, + { + "@id": "topics:economic-risk-management", + "@type": "skos:Concept", + "prefLabel": "Economic Risk Management", + "definition": "Assessing and managing financial risks arising from environmental, social, and governance factors.", + "notation": "06.07", + "broader": "topics:economic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:8"], + "scopeNote": "EU ESPR Art. 11 - Governance." + }, + { + "@id": "topics:supply-network-strength", + "@type": "skos:Concept", + "prefLabel": "Supply Network Strength", + "definition": "Enhancing the stability, diversity, and resilience of value chain networks against disruption.", + "notation": "06.08", + "broader": "topics:economic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "EU ESPR Art. 10 - Supply Chain Resilience." + }, + + { + "@id": "topics:health-and-safety", + "@type": "skos:Concept", + "prefLabel": "Health and Safety Assurance", + "definition": "Prioritizing the health and safety of workers and communities through hazard prevention, preparedness, and wellbeing support.", + "notation": "07", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "UN SDG 3; OECD Guidelines Chapter V: Employment and Industrial Relations; EU ESPR Art. 5, 10 and Annex I.", + "relatedMatch": ["sdg:3"], + "narrower": [ + "topics:workplace-hazard-control", + "topics:emergency-readiness", + "topics:exposure-management", + "topics:living-conditions", + "topics:healthcare-access", + "topics:wellbeing-support", + "topics:nutrition-standards", + "topics:ergonomic-design" + ] + }, + { + "@id": "topics:workplace-hazard-control", + "@type": "skos:Concept", + "prefLabel": "Workplace Hazard Control", + "definition": "Systematic identification, assessment, and mitigation of workplace hazards to reduce risk of injury and illness, including incident reporting, investigation, and corrective action.", + "notation": "07.01", + "broader": "topics:health-and-safety", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3"], + "scopeNote": "EU ESPR Art. 10 - Social Sustainability; UNTP social.safety." + }, + { + "@id": "topics:emergency-readiness", + "@type": "skos:Concept", + "prefLabel": "Emergency Readiness", + "definition": "Preparedness planning, training, and response capabilities for workplace emergencies including fire, chemical spills, and natural disasters.", + "notation": "07.02", + "broader": "topics:health-and-safety", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3"], + "scopeNote": "EU ESPR Annex I - Safety Measures." + }, + { + "@id": "topics:exposure-management", + "@type": "skos:Concept", + "prefLabel": "Exposure Management", + "definition": "Controlling worker exposure to harmful chemical, biological, and physical agents through monitoring and protective measures.", + "notation": "07.03", + "broader": "topics:health-and-safety", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3"], + "scopeNote": "EU ESPR Annex I - Substance Safety." + }, + { + "@id": "topics:living-conditions", + "@type": "skos:Concept", + "prefLabel": "Living Conditions", + "definition": "Ensuring safe, sanitary, and dignified accommodation for workers where employer-provided housing is applicable.", + "notation": "07.04", + "broader": "topics:health-and-safety", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3"], + "scopeNote": "EU ESPR Art. 10 - Worker Welfare." + }, + { + "@id": "topics:healthcare-access", + "@type": "skos:Concept", + "prefLabel": "Healthcare Access", + "definition": "Providing access to medical support, occupational health services, and health insurance for workers.", + "notation": "07.05", + "broader": "topics:health-and-safety", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3"], + "scopeNote": "EU ESPR Art. 10 - Health Provisions." + }, + { + "@id": "topics:wellbeing-support", + "@type": "skos:Concept", + "prefLabel": "Wellbeing Support", + "definition": "Addressing worker mental health, stress management, and overall wellbeing through support programs and workplace culture.", + "notation": "07.06", + "broader": "topics:health-and-safety", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3"], + "scopeNote": "EU ESPR Art. 10 - Social Impact." + }, + { + "@id": "topics:nutrition-standards", + "@type": "skos:Concept", + "prefLabel": "Nutrition Standards", + "definition": "Ensuring safe, adequate, and nutritious food provisions for workers where employer-provided meals are applicable.", + "notation": "07.07", + "broader": "topics:health-and-safety", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3"], + "scopeNote": "EU ESPR Art. 10 - Worker Welfare." + }, + { + "@id": "topics:ergonomic-design", + "@type": "skos:Concept", + "prefLabel": "Ergonomic Design", + "definition": "Designing safe physical work environments that minimize musculoskeletal strain and support worker comfort and productivity.", + "notation": "07.08", + "broader": "topics:health-and-safety", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3"], + "scopeNote": "EU ESPR Art. 5 - Product Safety." + }, + + { + "@id": "topics:systemic-sustainability", + "@type": "skos:Concept", + "prefLabel": "Systemic Sustainability", + "definition": "Establishing management frameworks, policies, and processes for systematic improvement of environmental, social, and governance outcomes.", + "notation": "08", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "UN SDGs 12, 16; OECD Guidelines Chapter II: General Policies; EU ESPR Art. 4, 5, 10-12.", + "relatedMatch": ["sdg:12", "sdg:16"], + "narrower": [ + "topics:sustainability-policies", + "topics:risk-identification", + "topics:outcome-tracking", + "topics:capacity-building", + "topics:process-enhancement", + "topics:feedback-channels", + "topics:compliance-verification", + "topics:transparent-communication" + ] + }, + { + "@id": "topics:sustainability-policies", + "@type": "skos:Concept", + "prefLabel": "Sustainability Policies", + "definition": "Formal organizational commitments, policies, and targets for environmental, social, and governance performance.", + "notation": "08.01", + "broader": "topics:systemic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 11 - Governance Framework." + }, + { + "@id": "topics:risk-identification", + "@type": "skos:Concept", + "prefLabel": "Risk Identification", + "definition": "Systematic assessment and prioritization of environmental, social, and governance risks across operations and supply chains.", + "notation": "08.02", + "broader": "topics:systemic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 10 - Due Diligence." + }, + { + "@id": "topics:outcome-tracking", + "@type": "skos:Concept", + "prefLabel": "Outcome Tracking", + "definition": "Monitoring, measuring, and reporting on sustainability performance against defined targets and indicators.", + "notation": "08.03", + "broader": "topics:systemic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 12 - Reporting Requirements." + }, + { + "@id": "topics:capacity-building", + "@type": "skos:Concept", + "prefLabel": "Capacity Building", + "definition": "Training and developing stakeholder knowledge and skills to implement and maintain sustainability practices.", + "notation": "08.04", + "broader": "topics:systemic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 10 - Stakeholder Support." + }, + { + "@id": "topics:process-enhancement", + "@type": "skos:Concept", + "prefLabel": "Process Enhancement", + "definition": "Continuous improvement of operational processes to achieve better sustainability outcomes over time.", + "notation": "08.05", + "broader": "topics:systemic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:12"], + "scopeNote": "EU ESPR Art. 5 - Performance Improvement." + }, + { + "@id": "topics:feedback-channels", + "@type": "skos:Concept", + "prefLabel": "Feedback Channels", + "definition": "Accessible grievance mechanisms, whistleblower protections, and feedback systems for workers, communities, and stakeholders to raise concerns without fear of retaliation.", + "notation": "08.06", + "broader": "topics:systemic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 10 - Stakeholder Engagement." + }, + { + "@id": "topics:compliance-verification", + "@type": "skos:Concept", + "prefLabel": "Compliance Verification", + "definition": "Independent audits, inspections, and verification processes to confirm adherence to sustainability standards and regulations.", + "notation": "08.07", + "broader": "topics:systemic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 4 - Compliance Monitoring." + }, + { + "@id": "topics:transparent-communication", + "@type": "skos:Concept", + "prefLabel": "Transparent Communication", + "definition": "Public disclosure and reporting of sustainability policies, performance, and progress to stakeholders.", + "notation": "08.08", + "broader": "topics:systemic-sustainability", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "EU ESPR Art. 12 - Information Disclosure; UNTP governance.transparency." + }, + + { + "@id": "topics:trade-and-market-access", + "@type": "skos:Concept", + "prefLabel": "Trade and Market Access", + "definition": "Adherence to trade regulations, customs requirements, market access rules, and cross-border compliance frameworks.", + "notation": "09", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "WTO TBT and SPS Agreements; UNECE Trade Facilitation Recommendations; WCO Harmonized System.", + "relatedMatch": ["sdg:17"], + "narrower": [ + "topics:import-export-controls", + "topics:customs-classification", + "topics:rules-of-origin", + "topics:sanctions-compliance", + "topics:market-authorization", + "topics:trade-documentation", + "topics:tariff-and-duty-compliance", + "topics:mutual-recognition" + ] + }, + { + "@id": "topics:import-export-controls", + "@type": "skos:Concept", + "prefLabel": "Import and Export Controls", + "definition": "Compliance with cross-border trade restrictions, licensing requirements, and controlled goods regulations.", + "notation": "09.01", + "broader": "topics:trade-and-market-access", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:17"], + "scopeNote": "WTO Trade Facilitation Agreement; national export control regimes." + }, + { + "@id": "topics:customs-classification", + "@type": "skos:Concept", + "prefLabel": "Customs Classification", + "definition": "Accurate tariff classification and customs valuation of goods in accordance with the Harmonized System and national schedules.", + "notation": "09.02", + "broader": "topics:trade-and-market-access", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:17"], + "scopeNote": "WCO Harmonized System Convention." + }, + { + "@id": "topics:rules-of-origin", + "@type": "skos:Concept", + "prefLabel": "Rules of Origin", + "definition": "Verification of product origin to determine eligibility for preferential tariff treatment under trade agreements.", + "notation": "09.03", + "broader": "topics:trade-and-market-access", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:17"], + "scopeNote": "WTO Agreement on Rules of Origin; WCO Revised Kyoto Convention." + }, + { + "@id": "topics:sanctions-compliance", + "@type": "skos:Concept", + "prefLabel": "Sanctions Compliance", + "definition": "Adherence to international trade sanctions, embargoes, and restricted party screening requirements.", + "notation": "09.04", + "broader": "topics:trade-and-market-access", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "UN Security Council sanctions; national sanctions regimes." + }, + { + "@id": "topics:market-authorization", + "@type": "skos:Concept", + "prefLabel": "Market Authorization", + "definition": "Meeting regulatory requirements for market entry including product registration, type approval, and pre-market conformity assessment.", + "notation": "09.05", + "broader": "topics:trade-and-market-access", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:17"], + "scopeNote": "WTO TBT Agreement Art. 5 - Conformity Assessment Procedures." + }, + { + "@id": "topics:trade-documentation", + "@type": "skos:Concept", + "prefLabel": "Trade Documentation", + "definition": "Accuracy, completeness, and digital exchange of trade and customs documentation including certificates, invoices, and declarations.", + "notation": "09.06", + "broader": "topics:trade-and-market-access", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:17"], + "scopeNote": "UNECE Trade Facilitation Recommendations; UN/CEFACT standards." + }, + { + "@id": "topics:tariff-and-duty-compliance", + "@type": "skos:Concept", + "prefLabel": "Tariff and Duty Compliance", + "definition": "Correct assessment, declaration, and payment of applicable customs duties, taxes, and fees.", + "notation": "09.07", + "broader": "topics:trade-and-market-access", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:17"], + "scopeNote": "WCO Revised Kyoto Convention; national customs legislation." + }, + { + "@id": "topics:mutual-recognition", + "@type": "skos:Concept", + "prefLabel": "Mutual Recognition", + "definition": "Acceptance of conformity assessment results, certifications, and test reports across jurisdictions through mutual recognition agreements.", + "notation": "09.08", + "broader": "topics:trade-and-market-access", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:17"], + "scopeNote": "WTO TBT Agreement Art. 6; ILAC and IAF mutual recognition arrangements." + }, + + { + "@id": "topics:technical-conformity", + "@type": "skos:Concept", + "prefLabel": "Technical Conformity", + "definition": "Adherence to technical regulations, voluntary standards, and conformity assessment procedures that ensure product and process fitness for purpose.", + "notation": "10", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "WTO TBT Agreement; ISO/IEC 17000 series conformity assessment standards; Codex Alimentarius.", + "relatedMatch": ["sdg:9"], + "narrower": [ + "topics:technical-regulations", + "topics:voluntary-standards", + "topics:metrology-and-measurement", + "topics:testing-and-certification", + "topics:sanitary-and-phytosanitary", + "topics:interoperability-standards", + "topics:accessibility-requirements", + "topics:performance-specifications" + ] + }, + { + "@id": "topics:technical-regulations", + "@type": "skos:Concept", + "prefLabel": "Technical Regulations", + "definition": "Compliance with mandatory government-imposed technical requirements for products, processes, and production methods.", + "notation": "10.01", + "broader": "topics:technical-conformity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "WTO TBT Agreement Art. 2 - Technical Regulations." + }, + { + "@id": "topics:voluntary-standards", + "@type": "skos:Concept", + "prefLabel": "Voluntary Standards", + "definition": "Adherence to consensus-based standards developed by recognized standards bodies for products, services, and management systems.", + "notation": "10.02", + "broader": "topics:technical-conformity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "WTO TBT Agreement Art. 4 - Standards; ISO, IEC, ITU standards." + }, + { + "@id": "topics:metrology-and-measurement", + "@type": "skos:Concept", + "prefLabel": "Metrology and Measurement", + "definition": "Accuracy and traceability of measurements and calibrations to national and international measurement standards.", + "notation": "10.03", + "broader": "topics:technical-conformity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "BIPM International System of Units; OIML Recommendations." + }, + { + "@id": "topics:testing-and-certification", + "@type": "skos:Concept", + "prefLabel": "Testing and Certification", + "definition": "Third-party conformity assessment including laboratory testing, product certification, and inspection by accredited bodies.", + "notation": "10.04", + "broader": "topics:technical-conformity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "ISO/IEC 17065 Product Certification; ISO/IEC 17025 Testing Laboratories." + }, + { + "@id": "topics:sanitary-and-phytosanitary", + "@type": "skos:Concept", + "prefLabel": "Sanitary and Phytosanitary Measures", + "definition": "Compliance with food safety, animal health, and plant health standards designed to protect human, animal, and plant life.", + "notation": "10.05", + "broader": "topics:technical-conformity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:3"], + "scopeNote": "WTO SPS Agreement; Codex Alimentarius; OIE; IPPC." + }, + { + "@id": "topics:interoperability-standards", + "@type": "skos:Concept", + "prefLabel": "Interoperability Standards", + "definition": "Conformity with standards ensuring compatibility, data exchange, and seamless interaction between systems, components, and services.", + "notation": "10.06", + "broader": "topics:technical-conformity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "ISO/IEC JTC 1 information technology standards; W3C web standards." + }, + { + "@id": "topics:accessibility-requirements", + "@type": "skos:Concept", + "prefLabel": "Accessibility Requirements", + "definition": "Compliance with inclusive design and accessibility standards ensuring products and services are usable by people with diverse abilities.", + "notation": "10.07", + "broader": "topics:technical-conformity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:10"], + "scopeNote": "ISO 21542 Accessibility; WCAG 2.1; EN 301 549." + }, + { + "@id": "topics:performance-specifications", + "@type": "skos:Concept", + "prefLabel": "Performance Specifications", + "definition": "Meeting defined functional, reliability, and performance benchmarks established by regulations, standards, or contractual requirements.", + "notation": "10.08", + "broader": "topics:technical-conformity", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "Industry-specific performance standards and testing protocols." + }, + + { + "@id": "topics:information-security", + "@type": "skos:Concept", + "prefLabel": "Information Security and Digital Trust", + "definition": "Protection of data, digital systems, and information assets, and the establishment of trust frameworks for digital interactions.", + "notation": "11", + "topConceptOf": "https://vocabulary.uncefact.org/conformity-topics/", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "scopeNote": "ISO/IEC 27001 Information Security; GDPR; eIDAS; NIST Cybersecurity Framework.", + "relatedMatch": ["sdg:9", "sdg:16"], + "narrower": [ + "topics:data-protection-and-privacy", + "topics:cybersecurity-controls", + "topics:digital-identity-and-trust", + "topics:access-management", + "topics:incident-response", + "topics:system-integrity", + "topics:encryption-and-data-security", + "topics:audit-and-accountability" + ] + }, + { + "@id": "topics:data-protection-and-privacy", + "@type": "skos:Concept", + "prefLabel": "Data Protection and Privacy", + "definition": "Safeguarding personal and sensitive data in compliance with privacy regulations, consent requirements, and ethical data handling principles.", + "notation": "11.01", + "broader": "topics:information-security", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "GDPR; ISO/IEC 27701 Privacy Information Management." + }, + { + "@id": "topics:cybersecurity-controls", + "@type": "skos:Concept", + "prefLabel": "Cybersecurity Controls", + "definition": "Implementation of technical and organizational security measures to protect digital infrastructure from threats and vulnerabilities.", + "notation": "11.02", + "broader": "topics:information-security", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "ISO/IEC 27001; NIST Cybersecurity Framework; IEC 62443." + }, + { + "@id": "topics:digital-identity-and-trust", + "@type": "skos:Concept", + "prefLabel": "Digital Identity and Trust", + "definition": "Verification and assurance of digital identities, credentials, and trust relationships in electronic transactions and communications.", + "notation": "11.03", + "broader": "topics:information-security", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "eIDAS Regulation; W3C Verifiable Credentials; UNTP Digital Identity Anchor." + }, + { + "@id": "topics:access-management", + "@type": "skos:Concept", + "prefLabel": "Access Management", + "definition": "Controls for authentication, authorization, and system access ensuring only authorized parties can access resources and data.", + "notation": "11.04", + "broader": "topics:information-security", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "ISO/IEC 27001 Annex A - Access Control." + }, + { + "@id": "topics:incident-response", + "@type": "skos:Concept", + "prefLabel": "Incident Response and Recovery", + "definition": "Preparedness planning, detection, response procedures, and recovery capabilities for security breaches and system failures.", + "notation": "11.05", + "broader": "topics:information-security", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "ISO/IEC 27035 Incident Management; NIST SP 800-61." + }, + { + "@id": "topics:system-integrity", + "@type": "skos:Concept", + "prefLabel": "System Integrity and Availability", + "definition": "Ensuring reliability, uptime, and integrity of digital systems through resilient architecture and continuity planning.", + "notation": "11.06", + "broader": "topics:information-security", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "ISO 22301 Business Continuity; ISO/IEC 27001 Availability Controls." + }, + { + "@id": "topics:encryption-and-data-security", + "@type": "skos:Concept", + "prefLabel": "Encryption and Data Security", + "definition": "Protection of data confidentiality and integrity in transit and at rest through cryptographic controls and key management.", + "notation": "11.07", + "broader": "topics:information-security", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:9"], + "scopeNote": "ISO/IEC 19790 Cryptographic Modules; NIST FIPS 140." + }, + { + "@id": "topics:audit-and-accountability", + "@type": "skos:Concept", + "prefLabel": "Audit Trail and Accountability", + "definition": "Logging, monitoring, and accountability mechanisms for digital activities to support compliance verification and forensic analysis.", + "notation": "11.08", + "broader": "topics:information-security", + "inScheme": "https://vocabulary.uncefact.org/conformity-topics/", + "relatedMatch": ["sdg:16"], + "scopeNote": "ISO/IEC 27001 Annex A - Logging and Monitoring." + } + ] +} diff --git a/tests/fixtures/valid/minimal_dpp.json b/tests/fixtures/valid/minimal_dpp.json index 3baf43f..11c4ff8 100644 --- a/tests/fixtures/valid/minimal_dpp.json +++ b/tests/fixtures/valid/minimal_dpp.json @@ -11,5 +11,17 @@ "issuer": { "id": "https://example.com/issuers/001", "name": "Example Company Ltd" + }, + "validFrom": "2024-01-01T00:00:00Z", + "validUntil": "2034-01-01T00:00:00Z", + "credentialSubject": { + "id": "https://example.com/subject/001", + "type": [ + "ProductPassport" + ], + "product": { + "id": "https://example.com/products/001", + "name": "Example Product" + } } } diff --git a/tests/fixtures/valid/untp-dpp-battery-instance-0.7.0.json b/tests/fixtures/valid/untp-dpp-battery-instance-0.7.0.json new file mode 100644 index 0000000..5a39115 --- /dev/null +++ b/tests/fixtures/valid/untp-dpp-battery-instance-0.7.0.json @@ -0,0 +1,720 @@ +{ + "type": [ + "DigitalProductPassport", + "VerifiableCredential" + ], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/" + ], + "id": "https://credentials.sample-battery.example.com/dpp/bat-75kwh-2025", + "issuer": { + "type": [ + "CredentialIssuer" + ], + "id": "did:web:sample-battery.example.com", + "name": "Sample Battery Mfg GmbH", + "issuerAlsoKnownAs": [ + { + "type": [ + "Party" + ], + "id": "https://sample-register.example.com/companies/BAT-001", + "name": "Sample Battery Mfg GmbH", + "registeredId": "BAT-001", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://sample-register.example.com", + "name": "Sample Commercial Register" + } + } + ] + }, + "validFrom": "2025-03-01T00:00:00Z", + "validUntil": "2035-03-01T00:00:00Z", + "name": "Digital Product Passport — 75 kWh Li-ion Battery Pack", + "issuingSoftware": { + "id": "https://sample-software-vendor.example.com/.well-known/untp/software/passport-builder/2026.04.1", + "name": "Sample Passport Builder", + "version": "2026.04.1", + "vendor": { + "id": "did:web:sample-software-vendor.example.com", + "name": "Sample Software Vendor Inc" + } + }, + "credentialSubject": { + "type": [ + "Product" + ], + "id": "https://id.sample-battery.example.com/product/bat-75kwh-2025", + "name": "75 kWh Li-ion Battery Pack", + "description": "75 kWh NMC 811 lithium-ion battery pack for electric vehicle applications. Assembled at the Sample Battery Factory in Salzgitter, Germany. Energy density 166 Wh/kg, designed for 1500+ charge cycles.", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.sample-battery.example.com", + "name": "Sample Product Identifier Scheme" + }, + "modelNumber": "BAT-NMC811-75", + "batchNumber": "2025-SZG-0342", + "itemNumber": "BAT-75-2025-00471", + "idGranularity": "item", + "productCategory": [ + { + "code": "46410", + "name": "Primary cells and primary batteries", + "definition": "Primary cells and primary batteries and parts thereof.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + } + ], + "characteristics": { + "batteryChemistry": "NMC 811 (LiNi0.8Mn0.1Co0.1O2)", + "batteryCategory": "EV", + "ratedCapacity": { + "value": 150, + "unit": "Ah" + }, + "certifiedUsableEnergy": { + "value": 75, + "unit": "kWh" + }, + "nominalVoltage": { + "value": 400, + "unit": "V" + }, + "minimumVoltage": { + "value": 280, + "unit": "V" + }, + "maximumVoltage": { + "value": 450, + "unit": "V" + }, + "originalPowerCapability": { + "value": 250000, + "unit": "W" + }, + "maximumPermittedPower": { + "value": 270000, + "unit": "W" + }, + "initialInternalResistance": { + "cell": { + "value": 0.8, + "unit": "mOhm" + }, + "pack": { + "value": 45, + "unit": "mOhm" + } + }, + "expectedLifetimeYears": 15, + "expectedLifetimeCycles": 1500, + "capacityThresholdForExhaustion": 80, + "temperatureRangeIdleState": { + "lower": -20, + "upper": 50, + "unit": "CEL" + }, + "initialSelfDischargeRate": { + "value": 2, + "unit": "%/month" + }, + "initialRoundTripEnergyEfficiency": 95, + "extinguishingAgent": "Class D dry powder or CO2", + "warrantyPeriodMonths": 96 + }, + "relatedDocument": [ + { + "linkURL": "https://credentials.sample-vap-cab.example.com/dcc/battery-003", + "linkName": "RBA VAP Certification — Sample Battery Factory", + "mediaType": "application/ld+json", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc" + }, + { + "linkURL": "https://docs.sample-battery.example.com/dismantling/BAT-NMC811-75-manual.pdf", + "linkName": "Dismantling and Disassembly Manual", + "mediaType": "application/pdf", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dismantlingInfo" + }, + { + "linkURL": "https://docs.sample-battery.example.com/due-diligence/2025-report.pdf", + "linkName": "Supply Chain Due Diligence Report 2025", + "mediaType": "application/pdf", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dueDiligenceReport" + }, + { + "linkURL": "https://docs.sample-battery.example.com/carbon-footprint/BAT-NMC811-75-study.pdf", + "linkName": "Carbon Footprint Study — 75 kWh Battery Pack", + "mediaType": "application/pdf", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/carbonFootprintStudy" + }, + { + "linkURL": "https://docs.sample-battery.example.com/spare-parts/BAT-NMC811-75.html", + "linkName": "Spare Parts and Service Information", + "mediaType": "text/html", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/sparePartsInfo" + }, + { + "linkURL": "https://docs.sample-battery.example.com/safety/BAT-NMC811-75-measures.pdf", + "linkName": "Safety Measures", + "mediaType": "application/pdf", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/safetyMeasures" + }, + { + "linkURL": "https://docs.sample-battery.example.com/end-of-life/battery-collection-guidance.pdf", + "linkName": "Battery Collection and End-of-Life Treatment Guidance", + "mediaType": "application/pdf", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/endOfLifeInfo" + }, + { + "linkURL": "https://docs.sample-battery.example.com/conformity/eu-doc-BAT-NMC811-75.pdf", + "linkName": "EU Declaration of Conformity", + "mediaType": "application/pdf", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/euDeclarationOfConformity" + } + ], + "relatedParty": [ + { + "role": "manufacturer", + "party": { + "type": [ + "Party" + ], + "id": "did:web:sample-battery.example.com", + "name": "Sample Battery Mfg GmbH", + "registeredId": "BAT-001", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://sample-register.example.com", + "name": "Sample Commercial Register" + }, + "registrationCountry": { + "countryCode": "DE", + "countryName": "Germany" + } + } + } + ], + "producedAtFacility": { + "type": [ + "Facility" + ], + "id": "https://facility-register.example.com/fac-003", + "name": "Sample Battery Factory", + "registeredId": "fac-003", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://facility-register.example.com", + "name": "UNTP Sample Facility Register" + } + }, + "productionDate": "2025-03-01", + "countryOfProduction": { + "countryCode": "DE", + "countryName": "Germany" + }, + "dimensions": { + "weight": { + "value": 450, + "unit": "KGM" + }, + "length": { + "value": 2100, + "unit": "MMT" + }, + "width": { + "value": 1500, + "unit": "MMT" + }, + "height": { + "value": 150, + "unit": "MMT" + } + }, + "materialProvenance": [ + { + "name": "Copper cathode", + "originCountry": { + "countryCode": "JP", + "countryName": "Japan" + }, + "materialType": { + "code": "41521", + "name": "Unwrought copper", + "definition": "Copper, unrefined; copper anodes for electrolytic refining; refined copper and copper alloys, unwrought.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.08, + "mass": { + "value": 36, + "unit": "KGM" + }, + "recycledMassFraction": 0.12, + "hazardous": false + }, + { + "name": "Cobalt sulphate", + "originCountry": { + "countryCode": "CD", + "countryName": "Congo (Democratic Republic of the)" + }, + "materialType": { + "code": "14210", + "name": "Cobalt ores and concentrates", + "definition": "Cobalt ores and concentrates.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.05, + "mass": { + "value": 22.5, + "unit": "KGM" + }, + "recycledMassFraction": 0.16, + "hazardous": false + }, + { + "name": "Lithium carbonate", + "originCountry": { + "countryCode": "CL", + "countryName": "Chile" + }, + "materialType": { + "code": "14290", + "name": "Other non-ferrous metal ores and concentrates", + "definition": "Lithium, beryllium, and other non-ferrous metal ores and concentrates.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.12, + "mass": { + "value": 54, + "unit": "KGM" + }, + "recycledMassFraction": 0.06, + "hazardous": false + }, + { + "name": "Nickel sulphate", + "originCountry": { + "countryCode": "ID", + "countryName": "Indonesia" + }, + "materialType": { + "code": "14230", + "name": "Nickel ores and concentrates", + "definition": "Nickel ores and concentrates.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.35, + "mass": { + "value": 157.5, + "unit": "KGM" + }, + "recycledMassFraction": 0.08, + "hazardous": false + }, + { + "name": "Graphite (anode material)", + "originCountry": { + "countryCode": "MZ", + "countryName": "Mozambique" + }, + "materialType": { + "code": "15310", + "name": "Natural graphite", + "definition": "Natural graphite.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.18, + "mass": { + "value": 81, + "unit": "KGM" + }, + "recycledMassFraction": 0, + "hazardous": false + }, + { + "name": "Other components (electrolyte, separator, casing, BMS)", + "originCountry": { + "countryCode": "DE", + "countryName": "Germany" + }, + "materialType": { + "code": "46410", + "name": "Primary cells and primary batteries", + "definition": "Primary cells and primary batteries and parts thereof.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.22, + "mass": { + "value": 99, + "unit": "KGM" + }, + "recycledMassFraction": 0.25, + "hazardous": false + } + ], + "packaging": { + "description": "Reinforced steel transit crate with foam inserts", + "dimensions": { + "weight": { + "value": 35, + "unit": "KGM" + }, + "length": { + "value": 2300, + "unit": "MMT" + }, + "width": { + "value": 1700, + "unit": "MMT" + }, + "height": { + "value": 350, + "unit": "MMT" + } + }, + "materialUsed": [ + { + "name": "Steel crate", + "originCountry": { + "countryCode": "DE", + "countryName": "Germany" + }, + "materialType": { + "code": "41211", + "name": "Flat-rolled products of iron or non-alloy steel", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.85, + "recycledMassFraction": 0.7, + "hazardous": false + } + ] + }, + "productLabel": [ + { + "name": "CE Marking", + "description": "EU conformity marking for the battery pack", + "imageData": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "mediaType": "image/png" + }, + { + "name": "Separate Collection Symbol", + "description": "Crossed-out wheeled bin indicating separate collection requirement per EU Battery Regulation Article 13", + "imageData": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "mediaType": "image/png" + }, + { + "name": "Carbon Footprint Performance Class", + "description": "Battery carbon footprint class label (Class B) per EU Battery Regulation Article 7", + "imageData": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "mediaType": "image/png" + } + ], + "performanceClaim": [ + { + "type": [ + "Claim" + ], + "id": "https://sample-battery.example.com/claims/battery-carbon-2025", + "name": "Battery Carbon Footprint", + "description": "Cradle-to-gate carbon footprint of the 75 kWh battery pack per kWh of capacity, covering all lifecycle stages as required by EU Battery Regulation Article 7.", + "referenceRegulation": [ + { + "type": [ + "Regulation" + ], + "id": "https://eur-lex.europa.eu/eli/reg/2023/1542/oj", + "name": "EU Battery Regulation (EU) 2023/1542" + } + ], + "referenceCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://sample-scheme.responsiblebusiness.org/criteria/ghg-reporting/v8", + "name": "GHG Emissions Reporting (RBA Code of Conduct Section C.1)" + } + ], + "claimDate": "2025-03-01", + "claimedPerformance": [ + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/product-carbon-footprint", + "name": "Product Carbon Footprint" + }, + "measure": { + "value": 61, + "unit": "KGM" + }, + "score": { + "code": "B", + "rank": 2, + "definition": "Carbon footprint performance class B per EU Battery Regulation" + } + } + ], + "evidence": [ + { + "linkURL": "https://credentials.sample-cab.example.com/dcc/carbon-verification-bat-75kwh", + "linkName": "Carbon Footprint Verification — Sample CAB", + "mediaType": "application/ld+json", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc" + } + ], + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/greenhouse-gas-emissions", + "name": "Greenhouse Gas Emissions" + } + ] + }, + { + "type": [ + "Claim" + ], + "id": "https://sample-battery.example.com/claims/battery-recycled-2025", + "name": "Recycled Content — Battery Pack", + "description": "Recycled content percentages for critical raw materials (cobalt, lithium, nickel, lead) as required by EU Battery Regulation Article 8.", + "referenceRegulation": [ + { + "type": [ + "Regulation" + ], + "id": "https://eur-lex.europa.eu/eli/reg/2023/1542/oj", + "name": "EU Battery Regulation (EU) 2023/1542" + } + ], + "referenceCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://sample-scheme.responsiblebusiness.org/criteria/recycled-content/v8", + "name": "Recycled Content Requirements (RBA Code of Conduct Section C.5)" + } + ], + "claimDate": "2025-03-01", + "claimedPerformance": [ + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/recycled-content-percentage", + "name": "Recycled Content Percentage" + }, + "measure": { + "value": 16, + "unit": "P1" + } + }, + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/recycled-content-percentage", + "name": "Cobalt Recycled Content" + }, + "measure": { + "value": 16, + "unit": "P1" + } + }, + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/recycled-content-percentage", + "name": "Lithium Recycled Content" + }, + "measure": { + "value": 6, + "unit": "P1" + } + }, + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/recycled-content-percentage", + "name": "Nickel Recycled Content" + }, + "measure": { + "value": 8, + "unit": "P1" + } + } + ], + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/recycled-material-integration", + "name": "Recycled Material Integration" + } + ] + }, + { + "type": [ + "Claim" + ], + "id": "https://sample-battery.example.com/claims/battery-efficiency-2025", + "name": "Round Trip Energy Efficiency", + "description": "Initial round trip energy efficiency and projected efficiency at 50% of cycle-life.", + "referenceRegulation": [ + { + "type": [ + "Regulation" + ], + "id": "https://eur-lex.europa.eu/eli/reg/2023/1542/oj", + "name": "EU Battery Regulation (EU) 2023/1542" + } + ], + "referenceCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://sample-scheme.responsiblebusiness.org/criteria/energy-efficiency/v8", + "name": "Energy Efficiency (RBA Code of Conduct Section C.3)" + } + ], + "claimDate": "2025-03-01", + "claimedPerformance": [ + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/energy-intensity", + "name": "Initial Round Trip Energy Efficiency" + }, + "measure": { + "value": 95, + "unit": "P1" + } + }, + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/energy-intensity", + "name": "Round Trip Energy Efficiency at 50% Cycle-life" + }, + "measure": { + "value": 90, + "unit": "P1" + } + } + ], + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/energy-optimization", + "name": "Energy Optimization" + } + ] + }, + { + "type": [ + "Claim" + ], + "id": "https://sample-battery.example.com/claims/battery-due-diligence-2025", + "name": "Ethical Material Sourcing", + "description": "Compliance with supply chain due diligence obligations under EU Battery Regulation Articles 48-52 and OECD Due Diligence Guidance for Responsible Supply Chains of Minerals.", + "referenceRegulation": [ + { + "type": [ + "Regulation" + ], + "id": "https://eur-lex.europa.eu/eli/reg/2023/1542/oj", + "name": "EU Battery Regulation (EU) 2023/1542" + } + ], + "referenceStandard": [ + { + "type": [ + "Standard" + ], + "id": "https://www.oecd.org/corporate/mne/mining.htm", + "name": "OECD Due Diligence Guidance for Responsible Supply Chains of Minerals" + } + ], + "referenceCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://sample-scheme.responsiblebusiness.org/criteria/responsible-minerals/v8", + "name": "Responsible Minerals Sourcing (RBA Code of Conduct Section C.7)" + } + ], + "claimDate": "2025-03-01", + "claimedPerformance": [ + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/supplier-due-diligence-coverage", + "name": "Supplier Due Diligence Coverage" + }, + "score": { + "code": "compliant", + "rank": 1, + "definition": "Fully compliant with due diligence obligations" + } + } + ], + "evidence": [ + { + "linkURL": "https://credentials.sample-cab.example.com/dcc/due-diligence-bat-2025", + "linkName": "Third-party Due Diligence Assurance — Sample CAB", + "mediaType": "application/ld+json", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc" + } + ], + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/ethical-material-sourcing", + "name": "Ethical Material Sourcing" + } + ] + } + ] + } +} diff --git a/tests/fixtures/valid/untp-dpp-cathode-instance-0.7.0.json b/tests/fixtures/valid/untp-dpp-cathode-instance-0.7.0.json new file mode 100644 index 0000000..2e87963 --- /dev/null +++ b/tests/fixtures/valid/untp-dpp-cathode-instance-0.7.0.json @@ -0,0 +1,284 @@ +{ + "type": [ + "DigitalProductPassport", + "VerifiableCredential" + ], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/" + ], + "id": "https://credentials.sample-refinery.example.com/dpp/cu-cathode-2025", + "issuer": { + "type": [ + "CredentialIssuer" + ], + "id": "did:web:sample-refinery.example.com", + "name": "Sample Copper Refinery Co. Ltd", + "issuerAlsoKnownAs": [ + { + "type": [ + "Party" + ], + "id": "https://www.sample-register.example.com/henkorireki-johoto.html?selHouzinNo=REF-001", + "name": "Sample Copper Refinery Co. Ltd", + "registeredId": "REF-001", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://www.sample-register.example.com", + "name": "Japan Corporate Number (Houjin Bangou)" + } + } + ] + }, + "validFrom": "2025-03-01T00:00:00Z", + "validUntil": "2026-03-01T00:00:00Z", + "name": "Digital Product Passport — LME Grade A Copper Cathode", + "issuingSoftware": { + "id": "https://sample-software-vendor.example.com/.well-known/untp/software/passport-builder/2026.04.1", + "name": "Sample Passport Builder", + "version": "2026.04.1", + "vendor": { + "id": "did:web:sample-software-vendor.example.com", + "name": "Sample Software Vendor Inc" + } + }, + "credentialSubject": { + "type": [ + "Product" + ], + "id": "https://id.sample-refinery.example.com/product/cu-cathode-2025", + "name": "LME Grade A Copper Cathode", + "description": "LME Grade A copper cathode (Cu 99.99%) produced by electrolytic refining at Sample Copper Refinery. Each cathode weighs approximately 125 kg and meets London Metal Exchange delivery specifications.", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.sample-refinery.example.com", + "name": "Sample Product Identifier Scheme" + }, + "modelNumber": "SR-CU-CATH-9999", + "batchNumber": "2025-Q1-0812", + "idGranularity": "model", + "productCategory": [ + { + "code": "41521", + "name": "Unwrought copper", + "definition": "Copper, unrefined; copper anodes for electrolytic refining; refined copper and copper alloys, unwrought.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + } + ], + "relatedDocument": [ + { + "linkURL": "https://credentials.sample-cab.example.com/dcc/smelter-002", + "linkName": "Coppermark Certification — Sample Copper Refinery", + "mediaType": "application/ld+json", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc" + } + ], + "relatedParty": [ + { + "role": "manufacturer", + "party": { + "type": [ + "Party" + ], + "id": "did:web:sample-refinery.example.com", + "name": "Sample Copper Refinery Co. Ltd", + "registeredId": "REF-001", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://www.sample-register.example.com", + "name": "Japan Corporate Number (Houjin Bangou)" + }, + "registrationCountry": { + "countryCode": "JP", + "countryName": "Japan" + } + } + } + ], + "producedAtFacility": { + "type": [ + "Facility" + ], + "id": "https://facility-register.example.com/fac-002", + "name": "Sample Copper Refinery", + "registeredId": "fac-002", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://facility-register.example.com", + "name": "UNTP Sample Facility Register" + } + }, + "productionDate": "2025-03-01", + "countryOfProduction": { + "countryCode": "JP", + "countryName": "Japan" + }, + "dimensions": { + "weight": { + "value": 125, + "unit": "KGM" + } + }, + "materialProvenance": [ + { + "name": "Copper concentrate", + "originCountry": { + "countryCode": "ZM", + "countryName": "Zambia" + }, + "materialType": { + "code": "14110", + "name": "Copper ores and concentrates", + "definition": "Copper ores and concentrates obtained from mining operations.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.88, + "mass": { + "value": 110, + "unit": "KGM" + }, + "recycledMassFraction": 0, + "hazardous": false + }, + { + "name": "Recycled copper scrap", + "originCountry": { + "countryCode": "JP", + "countryName": "Japan" + }, + "materialType": { + "code": "41521", + "name": "Unwrought copper", + "definition": "Copper, unrefined; copper anodes for electrolytic refining; refined copper and copper alloys, unwrought.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.12, + "mass": { + "value": 15, + "unit": "KGM" + }, + "recycledMassFraction": 1, + "hazardous": false + } + ], + "performanceClaim": [ + { + "type": [ + "Claim" + ], + "id": "https://sample-refinery.example.com/claims/product-carbon-2025", + "name": "Product Carbon Footprint — Copper Cathode", + "description": "Cradle-to-gate carbon footprint of copper cathode per tonne produced at Sample smelter.", + "referenceCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://sample-scheme.coppermark.org/criteria/ghg-management/v3", + "name": "GHG Emissions Management (Coppermark RRA Criterion 26)", + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/greenhouse-gas-emissions", + "name": "Greenhouse Gas Emissions", + "definition": "Assessment of direct and indirect greenhouse gas emissions across scopes 1, 2, and 3, including measurement, reporting, and reduction targets aligned with climate science." + } + ] + } + ], + "claimDate": "2025-03-01", + "claimedPerformance": [ + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/product-carbon-footprint", + "name": "Product Carbon Footprint" + }, + "measure": { + "value": 3.8, + "unit": "KGM" + } + } + ], + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/greenhouse-gas-emissions", + "name": "Greenhouse Gas Emissions", + "definition": "Assessment of direct and indirect greenhouse gas emissions across scopes 1, 2, and 3, including measurement, reporting, and reduction targets aligned with climate science." + } + ] + }, + { + "type": [ + "Claim" + ], + "id": "https://sample-refinery.example.com/claims/product-recycled-2025", + "name": "Recycled Content — Copper Cathode", + "description": "Percentage of recycled copper content in cathode output at Sample smelter.", + "referenceCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://sample-scheme.coppermark.org/criteria/recycled-feedstock/v3", + "name": "Recycled Feedstock Management (Coppermark RRA Criterion 31)", + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/recycled-material-integration", + "name": "Recycled Material Integration", + "definition": "Incorporation of recycled and reclaimed materials into products and processes, promoting circular material flows and reducing demand for virgin resources." + } + ] + } + ], + "claimDate": "2025-03-01", + "claimedPerformance": [ + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/recycled-content-percentage", + "name": "Recycled Content Percentage" + }, + "measure": { + "value": 12, + "unit": "P1" + } + } + ], + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/recycled-material-integration", + "name": "Recycled Material Integration", + "definition": "Incorporation of recycled and reclaimed materials into products and processes, promoting circular material flows and reducing demand for virgin resources." + } + ] + } + ] + } +} diff --git a/tests/fixtures/valid/untp-dpp-instance-0.7.0.json b/tests/fixtures/valid/untp-dpp-instance-0.7.0.json new file mode 100644 index 0000000..b8f686c --- /dev/null +++ b/tests/fixtures/valid/untp-dpp-instance-0.7.0.json @@ -0,0 +1,263 @@ +{ + "type": [ + "DigitalProductPassport", + "VerifiableCredential" + ], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/" + ], + "id": "https://credentials.sample-mine.example.com/dpp/cu-conc-2025", + "issuer": { + "type": [ + "CredentialIssuer" + ], + "id": "did:web:sample-mine.example.com", + "name": "Sample Copper Mine Pty Ltd", + "issuerAlsoKnownAs": [ + { + "type": [ + "Party" + ], + "id": "https://sample-register.example.com/companies/MINE-001", + "name": "Sample Copper Mine Pty Ltd", + "registeredId": "MINE-001", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://sample-register.example.com", + "name": "Patents and Companies Registration Agency (Zambia)" + } + } + ] + }, + "validFrom": "2025-03-01T00:00:00Z", + "validUntil": "2026-03-01T00:00:00Z", + "name": "Digital Product Passport — Copper Concentrate (Cu 30%)", + "issuingSoftware": { + "id": "https://sample-software-vendor.example.com/.well-known/untp/software/passport-builder/2026.04.1", + "name": "Sample Passport Builder", + "version": "2026.04.1", + "vendor": { + "id": "did:web:sample-software-vendor.example.com", + "name": "Sample Software Vendor Inc" + } + }, + "credentialSubject": { + "type": [ + "Product" + ], + "id": "https://id.sample-mine.example.com/product/cu-conc-2025", + "name": "Copper Concentrate (Cu 30%)", + "description": "Copper sulphide flotation concentrate with approximately 30% copper content, produced at Sample Copper Mine. Suitable for smelting to produce refined copper cathode.", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://id.sample-mine.example.com", + "name": "Sample Product Identifier Scheme" + }, + "modelNumber": "SM-CU-CONC-30", + "batchNumber": "2025-Q1-4501", + "idGranularity": "model", + "productCategory": [ + { + "code": "14110", + "name": "Copper ores and concentrates", + "definition": "Copper ores and concentrates obtained from mining operations.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + } + ], + "relatedDocument": [ + { + "linkURL": "https://credentials.sample-cab.example.com/dcc/mine-001", + "linkName": "Coppermark Certification — Sample Copper Mine", + "mediaType": "application/ld+json", + "linkType": "https://test.uncefact.org/vocabulary/linkTypes/dcc" + } + ], + "relatedParty": [ + { + "role": "manufacturer", + "party": { + "type": [ + "Party" + ], + "id": "did:web:sample-mine.example.com", + "name": "Sample Copper Mine Pty Ltd", + "registeredId": "MINE-001", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://sample-register.example.com", + "name": "Patents and Companies Registration Agency (Zambia)" + }, + "registrationCountry": { + "countryCode": "ZM", + "countryName": "Zambia" + } + } + } + ], + "producedAtFacility": { + "type": [ + "Facility" + ], + "id": "https://facility-register.example.com/fac-001", + "name": "Sample Copper Mine", + "registeredId": "fac-001", + "idScheme": { + "type": [ + "IdentifierScheme" + ], + "id": "https://facility-register.example.com", + "name": "UNTP Sample Facility Register" + } + }, + "productionDate": "2025-03-01", + "countryOfProduction": { + "countryCode": "ZM", + "countryName": "Zambia" + }, + "dimensions": { + "weight": { + "value": 1000, + "unit": "KGM" + } + }, + "materialProvenance": [ + { + "name": "Copper ore", + "originCountry": { + "countryCode": "ZM", + "countryName": "Zambia" + }, + "materialType": { + "code": "14110", + "name": "Copper ores and concentrates", + "definition": "Copper ores and concentrates obtained from mining operations.", + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN Central Product Classification (CPC)" + }, + "massFraction": 0.3, + "mass": { + "value": 300, + "unit": "KGM" + }, + "recycledMassFraction": 0, + "hazardous": false + } + ], + "performanceClaim": [ + { + "type": [ + "Claim" + ], + "id": "https://sample-mine.example.com/claims/product-carbon-2025", + "name": "Product Carbon Footprint — Copper Concentrate", + "description": "Cradle-to-gate carbon footprint of copper concentrate per tonne produced at Sample mine.", + "referenceCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://sample-scheme.coppermark.org/criteria/ghg-management/v3", + "name": "GHG Emissions Management (Coppermark RRA Criterion 26)", + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/greenhouse-gas-emissions", + "name": "Greenhouse Gas Emissions", + "definition": "Assessment of direct and indirect greenhouse gas emissions across scopes 1, 2, and 3, including measurement, reporting, and reduction targets aligned with climate science." + } + ] + } + ], + "claimDate": "2025-03-01", + "claimedPerformance": [ + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/product-carbon-footprint", + "name": "Product Carbon Footprint" + }, + "measure": { + "value": 2.1, + "unit": "KGM" + } + } + ], + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/greenhouse-gas-emissions", + "name": "Greenhouse Gas Emissions", + "definition": "Assessment of direct and indirect greenhouse gas emissions across scopes 1, 2, and 3, including measurement, reporting, and reduction targets aligned with climate science." + } + ] + }, + { + "type": [ + "Claim" + ], + "id": "https://sample-mine.example.com/claims/product-water-2025", + "name": "Water Intensity — Copper Concentrate", + "description": "Water consumption per tonne of copper concentrate produced at Sample mine.", + "referenceCriteria": [ + { + "type": [ + "Criterion" + ], + "id": "https://sample-scheme.coppermark.org/criteria/water-stewardship/v3", + "name": "Water Stewardship (Coppermark RRA Criterion 27)", + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/water-conservation", + "name": "Water Conservation", + "definition": "Efficient and responsible management of water resources, including reduction of water consumption, recycling, and protection of water quality in operations and supply chains." + } + ] + } + ], + "claimDate": "2025-03-01", + "claimedPerformance": [ + { + "metric": { + "type": [ + "PerformanceMetric" + ], + "id": "https://vocabulary.uncefact.org/performance-metric/water-intensity", + "name": "Water Intensity" + }, + "measure": { + "value": 15, + "unit": "MTQ" + } + } + ], + "conformityTopic": [ + { + "type": [ + "ConformityTopic" + ], + "id": "https://vocabulary.uncefact.org/conformity-topic/water-conservation", + "name": "Water Conservation", + "definition": "Efficient and responsible management of water resources, including reduction of water consumption, recycling, and protection of water quality in operations and supply chains." + } + ] + } + ] + } +} diff --git a/tests/fuzz/test_fuzz_cirpass.py b/tests/fuzz/test_fuzz_cirpass.py new file mode 100644 index 0000000..5757627 --- /dev/null +++ b/tests/fuzz/test_fuzz_cirpass.py @@ -0,0 +1,233 @@ +"""Fuzz tests for CIRPASS-2 validation to ensure robustness. + +Tests that the validation engine and CIRPASS rules never crash +on malformed or unexpected input. +""" + +from hypothesis import given, settings +from hypothesis import strategies as st + +from dppvalidator.validators import ValidationEngine, ValidationResult + + +class TestCIRPASSRulesFuzzing: + """Fuzz tests for CIRPASS validation rules.""" + + @given( + st.fixed_dictionaries( + { + "id": st.text(min_size=0, max_size=200), + "issuer": st.one_of( + st.none(), + st.text(max_size=100), + st.fixed_dictionaries( + { + "id": st.text(max_size=100), + "name": st.text(max_size=100), + } + ), + ), + "validFrom": st.one_of( + st.none(), + st.text(max_size=50), + st.integers(), + ), + "validUntil": st.one_of( + st.none(), + st.text(max_size=50), + st.integers(), + ), + "credentialSubject": st.one_of( + st.none(), + st.text(max_size=100), + st.dictionaries( + st.text(min_size=1, max_size=20), + st.text(max_size=50), + max_size=5, + ), + ), + } + ) + ) + @settings(max_examples=200, deadline=1000) + def test_validation_engine_never_crashes_on_dpp_like_data(self, data: dict): + """Test engine handles DPP-like structures without crashing.""" + engine = ValidationEngine(layers=["model", "semantic"]) + try: + result = engine.validate(data) + assert result is not None + assert isinstance(result, ValidationResult) + except Exception: + # Validation errors are acceptable, crashes are not + pass + + @given( + st.lists( + st.fixed_dictionaries( + { + "name": st.text(max_size=50), + "massFraction": st.one_of( + st.none(), + st.floats(allow_nan=True, allow_infinity=True), + st.text(max_size=20), + st.integers(), + ), + } + ), + max_size=10, + ) + ) + @settings(max_examples=100, deadline=500) + def test_materials_provenance_fuzzing(self, materials: list): + """Test materials provenance handling with fuzzed data.""" + engine = ValidationEngine(layers=["model", "semantic"]) + data = { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "type": ["DigitalProductPassport"], + "id": "https://example.com/dpp", + "issuer": {"id": "https://example.com/issuer", "name": "Test"}, + "validFrom": "2024-01-01T00:00:00Z", + "validUntil": "2034-01-01T00:00:00Z", + "credentialSubject": { + "id": "https://example.com/subject", + "type": ["ProductPassport"], + "product": {"id": "https://example.com/product", "name": "Test"}, + "materialsProvenance": materials, + }, + } + try: + result = engine.validate(data) + assert result is not None + except Exception: + pass + + +class TestExternalLibraryFuzzing: + """Fuzz tests for external library integration.""" + + @given( + st.dictionaries( + st.text(min_size=1, max_size=30), + st.recursive( + st.none() | st.booleans() | st.integers() | st.text(max_size=50), + lambda children: st.lists(children, max_size=3) + | st.dictionaries(st.text(min_size=1, max_size=10), children, max_size=3), + max_leaves=10, + ), + max_size=10, + ) + ) + @settings(max_examples=100, deadline=1000) + def test_jsonld_context_fuzzing(self, context_like: dict): + """Test JSON-LD context handling with fuzzed data.""" + engine = ValidationEngine(layers=["model"]) + data = { + "@context": context_like, + "type": ["DigitalProductPassport"], + "id": "https://example.com/dpp", + } + try: + result = engine.validate(data) + assert result is not None + except Exception: + pass + + @given( + st.text( + min_size=0, + max_size=500, + alphabet=st.characters( + whitelist_categories=("L", "N", "P", "S", "Z"), + whitelist_characters="\n\t@:/<>{}[]\"'", + ), + ) + ) + @settings(max_examples=200, deadline=500) + def test_url_like_strings_fuzzing(self, url_like: str): + """Test URL-like string handling.""" + engine = ValidationEngine(layers=["model"]) + data = { + "id": url_like, + "issuer": {"id": url_like, "name": "Test"}, + } + try: + result = engine.validate(data) + assert result is not None + except Exception: + pass + + +class TestSHACLFuzzing: + """Fuzz tests for SHACL validation (when available).""" + + @given( + st.dictionaries( + st.sampled_from(["@context", "@type", "@id", "type", "id", "name", "value"]), + st.one_of( + st.none(), + st.text(max_size=100), + st.lists(st.text(max_size=50), max_size=5), + ), + min_size=1, + max_size=10, + ) + ) + @settings(max_examples=100, deadline=1000) + def test_shacl_validator_handles_malformed_jsonld(self, data: dict): + """Test SHACL validator handles malformed JSON-LD gracefully.""" + try: + from dppvalidator.vocabularies.rdf_loader import is_shacl_available + + if not is_shacl_available(): + return + + from dppvalidator.validators.shacl import validate_jsonld_with_official_shacl + + result = validate_jsonld_with_official_shacl(data) + assert result is not None + except ImportError: + pass + except Exception: + # Any exception is acceptable as long as it doesn't crash Python + pass + + +class TestDateTimeFuzzing: + """Fuzz tests for datetime handling in validation.""" + + @given( + valid_from=st.one_of( + st.none(), + st.text(max_size=50), + st.integers(min_value=-1000000000, max_value=1000000000), + st.floats(allow_nan=True), + ), + valid_until=st.one_of( + st.none(), + st.text(max_size=50), + st.integers(min_value=-1000000000, max_value=1000000000), + st.floats(allow_nan=True), + ), + ) + @settings(max_examples=150, deadline=500) + def test_datetime_field_fuzzing(self, valid_from, valid_until): + """Test datetime field handling with fuzzed values.""" + engine = ValidationEngine(layers=["model", "semantic"]) + data = { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "type": ["DigitalProductPassport"], + "id": "https://example.com/dpp", + "issuer": {"id": "https://example.com/issuer", "name": "Test"}, + "validFrom": valid_from, + "validUntil": valid_until, + "credentialSubject": { + "id": "https://example.com/subject", + "type": ["ProductPassport"], + "product": {"id": "https://example.com/product", "name": "Test"}, + }, + } + try: + result = engine.validate(data) + assert result is not None + except Exception: + pass diff --git a/tests/fuzz/test_fuzz_engine.py b/tests/fuzz/test_fuzz_engine.py index fa62183..6a0225f 100644 --- a/tests/fuzz/test_fuzz_engine.py +++ b/tests/fuzz/test_fuzz_engine.py @@ -16,7 +16,7 @@ class TestEngineFuzzing: """Fuzzing tests for ValidationEngine.""" @given(st.binary(min_size=0, max_size=1000)) - @settings(max_examples=500) + @settings(max_examples=500, deadline=500) def test_engine_never_crashes_on_binary(self, data: bytes): """Test engine never crashes on arbitrary binary input.""" engine = ValidationEngine(layers=["model"]) @@ -30,7 +30,7 @@ def test_engine_never_crashes_on_binary(self, data: bytes): pass @given(st.text(min_size=0, max_size=500)) - @settings(max_examples=500) + @settings(max_examples=500, deadline=500) def test_engine_never_crashes_on_text(self, text: str): """Test engine never crashes on arbitrary text input.""" engine = ValidationEngine(layers=["model"]) @@ -53,7 +53,7 @@ def test_engine_never_crashes_on_text(self, text: str): max_leaves=20, ) ) - @settings(max_examples=300) + @settings(max_examples=300, deadline=500) def test_engine_never_crashes_on_json_structure(self, data): """Test engine never crashes on arbitrary JSON-like structures.""" engine = ValidationEngine(layers=["model"]) @@ -79,7 +79,7 @@ def test_engine_never_crashes_on_json_structure(self, data): max_size=20, ) ) - @settings(max_examples=300) + @settings(max_examples=300, deadline=500) def test_engine_never_crashes_on_random_dicts(self, data: dict): """Test engine never crashes on random dictionary input.""" engine = ValidationEngine(layers=["model"]) @@ -91,7 +91,7 @@ def test_engine_never_crashes_on_random_dicts(self, data: dict): assert result.valid is False @given(st.binary(min_size=0, max_size=500)) - @settings(max_examples=200) + @settings(max_examples=200, deadline=500) def test_engine_with_all_layers_never_crashes(self, data: bytes): """Test engine with all layers enabled never crashes.""" engine = ValidationEngine(layers=["model", "semantic"]) @@ -107,7 +107,7 @@ class TestJSONParseFuzzing: """Fuzzing tests for JSON parsing paths.""" @given(st.text(min_size=0, max_size=200)) - @settings(max_examples=300) + @settings(max_examples=300, deadline=500) def test_json_parse_never_crashes(self, text: str): """Test JSON parsing never crashes on arbitrary text.""" engine = ValidationEngine(layers=["model"]) @@ -124,7 +124,7 @@ def test_json_parse_never_crashes(self, text: str): pass @given(st.from_regex(r"\{[^}]*\}", fullmatch=True)) - @settings(max_examples=200) + @settings(max_examples=200, deadline=500) def test_json_like_strings_never_crash(self, text: str): """Test JSON-like strings never crash the engine.""" engine = ValidationEngine(layers=["model"]) @@ -151,7 +151,7 @@ class TestMalformedInputFuzzing: max_size=5, ) ) - @settings(max_examples=200) + @settings(max_examples=200, deadline=500) def test_partial_valid_structure_never_crashes(self, data: dict): """Test partial DPP-like structures never crash.""" engine = ValidationEngine(layers=["model"]) @@ -172,7 +172,7 @@ def test_partial_valid_structure_never_crashes(self, data: dict): } ) ) - @settings(max_examples=200) + @settings(max_examples=200, deadline=500) def test_wrong_types_never_crash(self, data: dict): """Test wrong field types never crash the engine.""" engine = ValidationEngine(layers=["model"]) @@ -181,7 +181,7 @@ def test_wrong_types_never_crash(self, data: dict): assert isinstance(result, ValidationResult) @given(st.integers(min_value=-1000000, max_value=1000000)) - @settings(max_examples=100) + @settings(max_examples=100, deadline=500) def test_integer_input_never_crashes(self, data: int): """Test integer input never crashes.""" engine = ValidationEngine(layers=["model"]) @@ -189,7 +189,7 @@ def test_integer_input_never_crashes(self, data: int): assert result is not None @given(st.floats(allow_nan=False, allow_infinity=False)) - @settings(max_examples=100) + @settings(max_examples=100, deadline=500) def test_float_input_never_crashes(self, data: float): """Test float input never crashes.""" engine = ValidationEngine(layers=["model"]) @@ -197,7 +197,7 @@ def test_float_input_never_crashes(self, data: float): assert result is not None @given(st.lists(st.text(max_size=20), max_size=10)) - @settings(max_examples=100) + @settings(max_examples=100, deadline=500) def test_list_input_never_crashes(self, data: list): """Test list input never crashes.""" engine = ValidationEngine(layers=["model"]) diff --git a/tests/integration/test_cirpass_shacl_integration.py b/tests/integration/test_cirpass_shacl_integration.py new file mode 100644 index 0000000..176893a --- /dev/null +++ b/tests/integration/test_cirpass_shacl_integration.py @@ -0,0 +1,234 @@ +"""Integration tests for CIRPASS-2 SHACL validation. + +Tests the full SHACL validation pipeline with rdflib and pyshacl +against official CIRPASS-2 shapes. +""" + +import json +from pathlib import Path + +import pytest + +from dppvalidator.vocabularies.rdf_loader import is_rdf_available, is_shacl_available + +FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" + +pytestmark = pytest.mark.skipif( + not is_shacl_available(), + reason="SHACL validation requires pyshacl: pip install dppvalidator[rdf]", +) + + +class TestSHACLShapesLoading: + """Tests for loading and parsing SHACL shapes.""" + + def test_cirpass_shacl_shapes_load_successfully(self): + """Test that CIRPASS SHACL shapes load without errors.""" + from dppvalidator.vocabularies.rdf_loader import load_cirpass_shacl_shapes + + graph = load_cirpass_shacl_shapes() + assert graph is not None + # Should have triples + assert len(graph) > 0 + + def test_shacl_shapes_contain_node_shapes(self): + """Test SHACL shapes define NodeShape constraints.""" + from rdflib import SH + + from dppvalidator.vocabularies.rdf_loader import load_cirpass_shacl_shapes + + graph = load_cirpass_shacl_shapes() + + # Find all NodeShapes + node_shapes = list(graph.subjects(predicate=None, object=SH.NodeShape)) + # Should have at least some node shapes defined + assert len(node_shapes) >= 0 # May be 0 if shapes use different patterns + + +class TestSHACLValidationBehavior: + """Tests for SHACL validation behavior with real DPP data.""" + + @pytest.fixture + def valid_dpp(self): + """Load a valid DPP fixture.""" + fixture_path = FIXTURES_DIR / "valid" / "minimal_dpp.json" + with open(fixture_path, encoding="utf-8") as f: + return json.load(f) + + def test_shacl_validator_initializes(self): + """Test SHACLValidator can be initialized.""" + from dppvalidator.validators.shacl import SHACLValidator + + validator = SHACLValidator() + assert validator is not None + + def test_shacl_validates_valid_dpp_structure(self, valid_dpp): + """Test SHACL validation accepts valid DPP structure.""" + from dppvalidator.models import DigitalProductPassport + from dppvalidator.validators.shacl import validate_with_shacl + + # Parse the DPP data into a model + passport = DigitalProductPassport.model_validate(valid_dpp) + result = validate_with_shacl(passport) + assert result is not None + assert hasattr(result, "conforms") + + def test_shacl_produces_violations_for_incomplete_passport(self): + """Test SHACL validation produces violations for incomplete DPP.""" + from dppvalidator.models import CredentialIssuer, DigitalProductPassport + from dppvalidator.validators.shacl import validate_with_shacl + + # Minimal passport missing some CIRPASS required fields + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + ) + result = validate_with_shacl(passport) + assert result is not None + # Should have some violations for missing fields + assert hasattr(result, "violations") + + +class TestOntologyIntegration: + """Tests for EU DPP ontology integration.""" + + def test_load_all_eudpp_ontologies(self): + """Test loading all EU DPP ontologies into merged graph.""" + from dppvalidator.vocabularies.rdf_loader import load_all_eudpp_ontologies + + graph = load_all_eudpp_ontologies() + # Returns a merged Graph + assert graph is not None + assert len(graph) > 0 + + def test_ontologies_contain_triples(self): + """Test merged ontology contains triples.""" + from dppvalidator.vocabularies.rdf_loader import load_all_eudpp_ontologies + + graph = load_all_eudpp_ontologies() + # Should have substantial content + assert len(graph) > 100 + + def test_product_dpp_ontology_has_content(self): + """Test product_dpp ontology has content.""" + from dppvalidator.vocabularies.rdf_loader import load_bundled_ontology + + graph = load_bundled_ontology("product_dpp_v1.7.1.ttl") + assert graph is not None + assert len(graph) > 0 + + +class TestRDFLibIntegration: + """Tests for rdflib library integration.""" + + @pytest.mark.skipif(not is_rdf_available(), reason="rdflib not installed") + def test_rdflib_graph_creation(self): + """Test rdflib Graph can be created and used.""" + from rdflib import Graph, Literal, Namespace + + g = Graph() + ex = Namespace("https://example.com/") + + g.add((ex.subject, ex.predicate, Literal("object"))) + assert len(g) == 1 + + @pytest.mark.skipif(not is_rdf_available(), reason="rdflib not installed") + def test_rdflib_turtle_parsing(self): + """Test rdflib can parse Turtle format.""" + from rdflib import Graph + + ttl_data = """ + @prefix ex: . + ex:Product a ex:DigitalProductPassport ; + ex:name "Test Product" . + """ + g = Graph() + g.parse(data=ttl_data, format="turtle") + assert len(g) == 2 + + @pytest.mark.skipif(not is_rdf_available(), reason="rdflib not installed") + def test_rdflib_jsonld_parsing(self): + """Test rdflib can parse JSON-LD format.""" + from rdflib import Graph + + jsonld_data = { + "@context": {"ex": "https://example.com/"}, + "@id": "ex:product1", + "@type": "ex:Product", + "ex:name": "Test Product", + } + g = Graph() + g.parse(data=json.dumps(jsonld_data), format="json-ld") + assert len(g) >= 1 + + +class TestPySHACLIntegration: + """Tests for pyshacl library integration.""" + + def test_pyshacl_validate_function_available(self): + """Test pyshacl validate function is importable.""" + from pyshacl import validate + + assert callable(validate) + + def test_pyshacl_basic_validation(self): + """Test basic pyshacl validation works.""" + from pyshacl import validate + from rdflib import Graph + + # Simple data graph + data_ttl = """ + @prefix ex: . + ex:Product1 a ex:Product ; + ex:name "Test" . + """ + + # Simple shapes graph requiring name + shapes_ttl = """ + @prefix sh: . + @prefix ex: . + + ex:ProductShape a sh:NodeShape ; + sh:targetClass ex:Product ; + sh:property [ + sh:path ex:name ; + sh:minCount 1 ; + ] . + """ + + data_graph = Graph().parse(data=data_ttl, format="turtle") + shapes_graph = Graph().parse(data=shapes_ttl, format="turtle") + + conforms, _, _ = validate(data_graph, shacl_graph=shapes_graph) + assert conforms is True + + def test_pyshacl_detects_violations(self): + """Test pyshacl detects shape violations.""" + from pyshacl import validate + from rdflib import Graph + + # Data missing required property + data_ttl = """ + @prefix ex: . + ex:Product1 a ex:Product . + """ + + shapes_ttl = """ + @prefix sh: . + @prefix ex: . + + ex:ProductShape a sh:NodeShape ; + sh:targetClass ex:Product ; + sh:property [ + sh:path ex:name ; + sh:minCount 1 ; + sh:message "Product must have a name" ; + ] . + """ + + data_graph = Graph().parse(data=data_ttl, format="turtle") + shapes_graph = Graph().parse(data=shapes_ttl, format="turtle") + + conforms, results_graph, _ = validate(data_graph, shacl_graph=shapes_graph) + assert conforms is False + assert len(results_graph) > 0 diff --git a/tests/integration/test_cli_workflows.py b/tests/integration/test_cli_workflows.py index 18ad6d1..1ee1ac8 100644 --- a/tests/integration/test_cli_workflows.py +++ b/tests/integration/test_cli_workflows.py @@ -52,15 +52,23 @@ def test_validate_with_json_output(self, capsys): def test_validate_single_file_workflow(self, tmp_path): """Validating a single file through the CLI.""" data = { + "type": ["DigitalProductPassport", "VerifiableCredential"], "@context": [ "https://www.w3.org/ns/credentials/v2", "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", ], "id": "https://example.com/dpp", "issuer": {"id": "https://example.com/issuer", "name": "Test"}, + "validFrom": "2024-01-01T00:00:00Z", + "validUntil": "2034-01-01T00:00:00Z", + "credentialSubject": { + "id": "https://example.com/subject/001", + "type": ["ProductPassport"], + "product": {"id": "https://example.com/products/001", "name": "Test Product"}, + }, } file_path = tmp_path / "dpp.json" - file_path.write_text(json.dumps(data)) + file_path.write_text(json.dumps(data), encoding="utf-8") exit_code = main(["validate", str(file_path)]) @@ -79,7 +87,7 @@ def test_export_to_jsonld(self, tmp_path): assert exit_code == EXIT_VALID assert output.exists() - content = json.loads(output.read_text()) + content = json.loads(output.read_text(encoding="utf-8")) assert "@context" in content def test_export_to_json(self, tmp_path): @@ -124,7 +132,7 @@ def test_init_with_full_template(self, tmp_path): assert exit_code == EXIT_VALID dpp_file = project_dir / "data" / "sample_passport.json" - content = json.loads(dpp_file.read_text()) + content = json.loads(dpp_file.read_text(encoding="utf-8")) assert "materialsProvenance" in content.get("credentialSubject", {}) def test_init_then_validate_workflow(self, tmp_path): @@ -175,15 +183,23 @@ class TestQuietAndVerboseFlags: def test_quiet_mode_minimal_output(self, tmp_path, capsys): """Quiet mode produces minimal output.""" data = { + "type": ["DigitalProductPassport", "VerifiableCredential"], "@context": [ "https://www.w3.org/ns/credentials/v2", "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", ], "id": "https://example.com/dpp", "issuer": {"id": "https://example.com/issuer", "name": "Test"}, + "validFrom": "2024-01-01T00:00:00Z", + "validUntil": "2034-01-01T00:00:00Z", + "credentialSubject": { + "id": "https://example.com/subject/001", + "type": ["ProductPassport"], + "product": {"id": "https://example.com/products/001", "name": "Test Product"}, + }, } file_path = tmp_path / "dpp.json" - file_path.write_text(json.dumps(data)) + file_path.write_text(json.dumps(data), encoding="utf-8") main(["--quiet", "validate", str(file_path)]) captured = capsys.readouterr() @@ -194,15 +210,23 @@ def test_quiet_mode_minimal_output(self, tmp_path, capsys): def test_verbose_mode_detailed_output(self, tmp_path, capsys): """Verbose mode produces detailed output.""" data = { + "type": ["DigitalProductPassport", "VerifiableCredential"], "@context": [ "https://www.w3.org/ns/credentials/v2", "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", ], "id": "https://example.com/dpp", "issuer": {"id": "https://example.com/issuer", "name": "Test"}, + "validFrom": "2024-01-01T00:00:00Z", + "validUntil": "2034-01-01T00:00:00Z", + "credentialSubject": { + "id": "https://example.com/subject/001", + "type": ["ProductPassport"], + "product": {"id": "https://example.com/products/001", "name": "Test Product"}, + }, } file_path = tmp_path / "dpp.json" - file_path.write_text(json.dumps(data)) + file_path.write_text(json.dumps(data), encoding="utf-8") main(["--verbose", "validate", str(file_path)]) captured = capsys.readouterr() @@ -217,7 +241,7 @@ class TestErrorHandling: def test_malformed_json_graceful_error(self, tmp_path, capsys): """Malformed JSON produces helpful error message.""" file_path = tmp_path / "bad.json" - file_path.write_text("{not valid json") + file_path.write_text("{not valid json", encoding="utf-8") exit_code = main(["validate", str(file_path)]) @@ -270,13 +294,172 @@ def test_sequential_validation_workflow(self, tmp_path): # Create multiple DPP files and validate each for i in range(3): data = { + "type": ["DigitalProductPassport", "VerifiableCredential"], "@context": ctx, "id": f"https://example.com/dpp-{i}", "issuer": {"id": "https://example.com/issuer", "name": f"Issuer {i}"}, + "validFrom": "2024-01-01T00:00:00Z", + "validUntil": "2034-01-01T00:00:00Z", + "credentialSubject": { + "id": f"https://example.com/subject/{i}", + "type": ["ProductPassport"], + "product": { + "id": f"https://example.com/products/{i}", + "name": f"Product {i}", + }, + }, } file_path = tmp_path / f"dpp-{i}.json" - file_path.write_text(json.dumps(data)) + file_path.write_text(json.dumps(data), encoding="utf-8") # Validate each file individually exit_code = main(["validate", str(file_path)]) assert exit_code == EXIT_VALID + + +class TestBatchValidationWorkflows: + """Integration tests for batch validation with multiple files and glob patterns.""" + + def test_batch_validate_multiple_files(self, tmp_path): + """Validate multiple files passed as arguments.""" + ctx = [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ] + files = [] + for i in range(3): + data = { + "type": ["DigitalProductPassport", "VerifiableCredential"], + "@context": ctx, + "id": f"https://example.com/batch-{i}", + "issuer": {"id": "https://example.com/issuer", "name": "Test"}, + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "id": f"https://example.com/subject/{i}", + "type": ["ProductPassport"], + "product": {"id": f"https://example.com/p/{i}", "name": f"P{i}"}, + }, + } + file_path = tmp_path / f"batch_{i}.json" + file_path.write_text(json.dumps(data), encoding="utf-8") + files.append(str(file_path)) + + exit_code = main(["validate", *files]) + assert exit_code == EXIT_VALID + + def test_batch_validate_glob_pattern(self, tmp_path): + """Validate files matching a glob pattern.""" + ctx = [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ] + for i in range(4): + data = { + "type": ["DigitalProductPassport", "VerifiableCredential"], + "@context": ctx, + "id": f"https://example.com/glob-{i}", + "issuer": {"id": "https://example.com/issuer", "name": "Test"}, + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "id": f"https://example.com/subject/{i}", + "type": ["ProductPassport"], + "product": {"id": f"https://example.com/p/{i}", "name": f"P{i}"}, + }, + } + file_path = tmp_path / f"glob_test_{i}.json" + file_path.write_text(json.dumps(data), encoding="utf-8") + + exit_code = main(["validate", str(tmp_path / "glob_test_*.json")]) + assert exit_code == EXIT_VALID + + def test_batch_validate_json_output_format(self, tmp_path, capsys): + """Batch validation with JSON output includes summary.""" + ctx = [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ] + for i in range(2): + data = { + "type": ["DigitalProductPassport", "VerifiableCredential"], + "@context": ctx, + "id": f"https://example.com/json-{i}", + "issuer": {"id": "https://example.com/issuer", "name": "Test"}, + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "id": f"https://example.com/subject/{i}", + "type": ["ProductPassport"], + "product": {"id": f"https://example.com/p/{i}", "name": f"P{i}"}, + }, + } + file_path = tmp_path / f"json_out_{i}.json" + file_path.write_text(json.dumps(data), encoding="utf-8") + + exit_code = main(["validate", str(tmp_path / "json_out_*.json"), "-f", "json"]) + captured = capsys.readouterr() + + assert exit_code == EXIT_VALID + output = json.loads(captured.out) + assert "files" in output + assert "summary" in output + assert output["summary"]["total"] == 2 + assert output["summary"]["valid"] == 2 + assert output["summary"]["invalid"] == 0 + + def test_batch_validate_mixed_results(self, tmp_path): + """Batch validation with mix of valid and invalid files.""" + ctx = [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ] + # Valid file + valid_data = { + "type": ["DigitalProductPassport", "VerifiableCredential"], + "@context": ctx, + "id": "https://example.com/valid", + "issuer": {"id": "https://example.com/issuer", "name": "Test"}, + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "id": "https://example.com/subject/1", + "type": ["ProductPassport"], + "product": {"id": "https://example.com/p/1", "name": "P1"}, + }, + } + (tmp_path / "mix_valid.json").write_text(json.dumps(valid_data), encoding="utf-8") + + # Invalid file (missing required fields) + invalid_data = { + "id": "https://example.com/invalid", + "issuer": {"id": "https://example.com/issuer", "name": "Test"}, + } + (tmp_path / "mix_invalid.json").write_text(json.dumps(invalid_data), encoding="utf-8") + + exit_code = main(["validate", str(tmp_path / "mix_*.json")]) + assert exit_code == EXIT_INVALID + + def test_batch_validate_table_output(self, tmp_path, capsys): + """Batch validation with table output shows summary.""" + ctx = [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ] + for i in range(2): + data = { + "type": ["DigitalProductPassport", "VerifiableCredential"], + "@context": ctx, + "id": f"https://example.com/table-{i}", + "issuer": {"id": "https://example.com/issuer", "name": "Test"}, + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "id": f"https://example.com/subject/{i}", + "type": ["ProductPassport"], + "product": {"id": f"https://example.com/p/{i}", "name": f"P{i}"}, + }, + } + (tmp_path / f"table_{i}.json").write_text(json.dumps(data), encoding="utf-8") + + exit_code = main(["validate", str(tmp_path / "table_*.json"), "-f", "table"]) + captured = capsys.readouterr() + + assert exit_code == EXIT_VALID + assert "Batch Validation Results" in captured.out + assert "Total:" in captured.out diff --git a/tests/integration/test_compat_roundtrip.py b/tests/integration/test_compat_roundtrip.py new file mode 100644 index 0000000..d072af9 --- /dev/null +++ b/tests/integration/test_compat_roundtrip.py @@ -0,0 +1,183 @@ +"""Phase 4 round-trip: upgrade every 0.6.x fixture and re-validate at 0.7. + +This integration test enforces the Phase 4 exit criterion from +``docs/plans/UNTP_0.7.0_MIGRATION.md``: + +> Every 0.6.x valid fixture either upgrades and re-validates cleanly, +> or emits a documented warning. + +The test takes each enveloped 0.6.x fixture under ``tests/fixtures/valid/``, +runs it through :func:`dppvalidator.compat.upgrade_0_6_to_0_7.upgrade`, +then attempts to construct the v0.7 ``DigitalProductPassport`` model +from the result. Validation outcomes split into three buckets: + +- **CLEAN**: model construction succeeds with zero warnings — the + fixture upgrades fully. +- **WARNED**: model construction succeeds but the shim emitted at + least one ``UPG`` warning — the fixture upgrades with documented + caveats. +- **REQUIRES_MANUAL**: the shim emitted ``UPG004``-class + required-field-missing warnings or model construction fails — the + fixture cannot fully upgrade without manual data fill-in. + +The test only fails when a fixture lands in REQUIRES_MANUAL **and** +the shim emitted no warnings to explain it — i.e. the shim silently +produced an invalid result. Documented failures (warnings emitted) +are surfaced via the captured "known limitation" list and persisted +for the migration guide. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from pydantic import ValidationError + +from dppvalidator.compat.upgrade_0_6_to_0_7 import ( + UPG_CODE_REQUIRED_FIELD_MISSING, + UpgradeSeverity, + upgrade, +) +from dppvalidator.models.v0_7.envelope import DigitalProductPassport + +_FIXTURE_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "valid" + + +def _enveloped_fixtures() -> list[Path]: + """Return only enveloped 0.6.x DPP fixtures. + + Phase 5 vendored the upstream 0.7.0 samples into the same + ``valid/`` directory; those are not v0.6 inputs and must be + skipped — the shim is defined for v0.6 → v0.7 only. We detect + a v0.7 payload by its context URL (the only stable on-the-wire + marker; type arrays are version-shared). + """ + out: list[Path] = [] + for p in sorted(_FIXTURE_DIR.glob("*.json")): + try: + data = json.loads(p.read_text(encoding="utf-8")) + except json.JSONDecodeError: # pragma: no cover — fixtures should parse + continue + if not (isinstance(data, dict) and "@context" in data and "credentialSubject" in data): + continue + # Skip v0.7-shaped payloads (already in target shape). + ctx = data.get("@context") or [] + if any(isinstance(c, str) and "vocabulary.uncefact.org/untp/0.7" in c for c in ctx): + continue + out.append(p) + return out + + +def _classify(upgraded: dict[str, Any], warnings: list[Any]) -> tuple[str, str | None]: + """Categorise an upgrade outcome as CLEAN / WARNED / REQUIRES_MANUAL. + + Returns a tuple of (bucket, validation_error_message_or_None). + """ + has_required_missing = any(w.code == UPG_CODE_REQUIRED_FIELD_MISSING for w in warnings) + has_error_severity = any(w.severity == UpgradeSeverity.ERROR for w in warnings) + try: + DigitalProductPassport.model_validate(upgraded) + except ValidationError as exc: + # Any ValidationError pushes us into REQUIRES_MANUAL — caller + # uses the warning set to assess whether it was expected. + return "REQUIRES_MANUAL", str(exc) + if has_required_missing or has_error_severity: + return "REQUIRES_MANUAL", None + if warnings: + return "WARNED", None + return "CLEAN", None + + +@pytest.mark.parametrize( + "fixture_path", + _enveloped_fixtures(), + ids=lambda p: p.name, +) +def test_v06_fixture_round_trip(fixture_path: Path) -> None: + """Each enveloped 0.6.x fixture either re-validates or emits warnings. + + The shim is allowed to leave a fixture in REQUIRES_MANUAL state — + that's expected for fixtures missing v0.7-required fields like + ``Material.materialType``. What's NOT allowed is a fixture + silently failing to validate without any explanatory warning: that + indicates the shim has a transformation bug. + """ + src = json.loads(fixture_path.read_text(encoding="utf-8")) + upgraded, warnings = upgrade(src) + bucket, error_msg = _classify(upgraded, warnings) + if bucket == "REQUIRES_MANUAL": + # If we landed in REQUIRES_MANUAL and the shim emitted at least + # one warning, that's an *expected* outcome — the warning + # explains why. We still log enough to make the migration + # guide entry obvious. + assert warnings, ( + f"{fixture_path.name} failed to validate at 0.7 and the shim " + f"emitted no warnings — silent shim bug.\n" + f"Validation error: {error_msg}" + ) + + +def test_at_least_one_fixture_round_trips_cleanly() -> None: + """Smoke check that the CLEAN / WARNED path is reachable. + + A pure-acceptance integration test that's worthless unless we know + the shim *can* produce a valid output for *some* 0.6.x input. We + construct a hand-crafted fixture that has every v0.7-required + field already populated in v0.6 shape, then assert it round-trips + cleanly into a v0.7 model. + """ + src = { + "type": ["DigitalProductPassport", "VerifiableCredential"], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ], + "id": "https://example.com/credentials/clean", + "name": "Clean fixture", + "issuer": { + "type": ["CredentialIssuer"], + "id": "did:example:1", + "name": "Example", + }, + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "type": ["ProductPassport"], + "id": "https://example.com/subject/1", + "granularityLevel": "model", + "product": { + "type": ["Product"], + "id": "https://example.com/products/1", + "name": "Clean product", + "idScheme": { + "type": ["IdentifierScheme"], + "id": "https://id.example.com", + "name": "Example scheme", + }, + "productCategory": { + "type": ["Classification"], + "schemeID": "https://unstats.un.org/cpc/", + "schemeName": "UN CPC", + "code": "12345", + "name": "Example category", + }, + "producedAtFacility": { + "type": ["Facility"], + "id": "https://facilities.example.com/1", + "name": "Example facility", + }, + "countryOfProduction": "DE", + }, + }, + } + upgraded, warnings = upgrade(src, country_lookup={"DE": "Germany"}) + # The model accepts the upgraded payload. + DigitalProductPassport.model_validate(upgraded) + # Only INFO-grade events fire — no required-field warnings, no + # blocking severities. + blocking = [w for w in warnings if w.severity != UpgradeSeverity.INFO] + assert not blocking, ( + f"Expected zero blocking warnings, got: {[(w.code, w.path, w.message) for w in blocking]}" + ) diff --git a/tests/integration/test_example_plugin.py b/tests/integration/test_example_plugin.py new file mode 100644 index 0000000..06926f4 --- /dev/null +++ b/tests/integration/test_example_plugin.py @@ -0,0 +1,343 @@ +"""Phase 6 acceptance test: example plugin integration coverage. + +The exit criterion from §Phase 6 of +``docs/plans/UNTP_0.7.0_MIGRATION.md``: + +> ``pip install -e examples/dppvalidator_example_plugin && pytest +> tests/integration/test_example_plugin.py`` is green. + +This module makes the example plugin a CI-tested target so any +public-API regression in the core (renaming a model attribute, moving +a module, breaking the ``SemanticRule`` protocol) is caught +immediately. + +The plugin is treated as an editable optional dependency: if it isn't +installed when the suite runs, the tests skip with a clear pointer to +the install command rather than failing. This keeps the plugin +opt-in while still wiring it into nightly CI. +""" + +from __future__ import annotations + +import importlib +import importlib.util +from typing import Any + +import pytest + + +def _plugin_installed() -> bool: + """Return True when ``dppvalidator_example_plugin`` is importable.""" + return importlib.util.find_spec("dppvalidator_example_plugin") is not None + + +pytestmark = pytest.mark.skipif( + not _plugin_installed(), + reason=( + "example plugin not installed; run " + "`uv pip install -e examples/dppvalidator_example_plugin` first" + ), +) + + +# --------------------------------------------------------------------------- +# Fixtures: build v0.6 / v0.7 passports for the rules to chew on +# --------------------------------------------------------------------------- + + +@pytest.fixture +def v06_passport() -> Any: + """Construct a minimal v0.6 ``DigitalProductPassport`` instance. + + Uses the top-level alias ``dppvalidator.models.passport`` — + that's the import the plugin's v0.6 rule expects to see, and the + public-API stability rule in §4.1.8 of the migration plan + promises this entry point keeps working in 0.4.0. + """ + from dppvalidator.models.passport import DigitalProductPassport + + payload: dict[str, Any] = { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ], + "type": ["DigitalProductPassport", "VerifiableCredential"], + "id": "https://example.com/credentials/test-v06", + "issuer": {"id": "did:example:1", "name": "Example"}, + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "type": ["ProductPassport"], + "id": "https://example.com/subject/1", + "product": { + "type": ["Product"], + "id": "https://example.com/products/1", + "name": "v0.6 sample product", + }, + "materialsProvenance": [ + {"name": "Cotton", "originCountry": "EG", "massFraction": 0.6}, + {"name": "Polyester", "originCountry": "DE", "massFraction": 0.4}, + ], + }, + } + return DigitalProductPassport.model_validate(payload) + + +@pytest.fixture +def v07_passport() -> Any: + """Construct a minimal v0.7 ``DigitalProductPassport`` instance.""" + from dppvalidator.models.v0_7.envelope import DigitalProductPassport + + payload: dict[str, Any] = { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/", + ], + "type": ["DigitalProductPassport", "VerifiableCredential"], + "id": "https://example.com/credentials/test-v07", + "name": "Sample DPP", + "issuer": { + "type": ["CredentialIssuer"], + "id": "did:example:1", + "name": "Example", + }, + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "type": ["Product"], + "id": "https://example.com/products/1", + "name": "v0.7 sample product", + "idScheme": { + "type": ["IdentifierScheme"], + "id": "https://example.com/schemes/internal", + "name": "Internal scheme", + }, + "idGranularity": "model", + "productCategory": [ + { + "type": ["Classification"], + "schemeId": "https://unstats.un.org/cpc/", + "schemeName": "UN CPC", + "code": "12345", + "name": "Example category", + } + ], + "producedAtFacility": { + "type": ["Facility"], + "id": "https://example.com/facilities/1", + "name": "Example facility", + }, + "countryOfProduction": {"countryCode": "DE", "countryName": "Germany"}, + "relatedParty": [ + { + "role": "brandOwner", + "party": { + "type": ["Party"], + "id": "did:example:brand", + "name": "Sample Brand Inc", + }, + } + ], + }, + } + return DigitalProductPassport.model_validate(payload) + + +# --------------------------------------------------------------------------- +# Public-API stability — the imports the plugin relies on still work +# --------------------------------------------------------------------------- + + +class TestPluginPublicApi: + """The imports baked into the plugin must remain stable.""" + + def test_top_level_passport_alias_imports(self) -> None: + """``dppvalidator.models.passport.DigitalProductPassport`` resolves.""" + from dppvalidator.models.passport import DigitalProductPassport + + assert DigitalProductPassport is not None + + def test_credential_subject_dot_product_dot_name_path_works(self, v06_passport: Any) -> None: + """The plugin reads ``passport.credential_subject.product.name``. + + Phase 3 moved the v0.6 models under ``models/v0_6/`` with a + thin shim at the top level. The shim must continue to expose + the original attribute path; otherwise the example plugin + breaks at attribute-access time, not import time. + """ + assert v06_passport.credential_subject is not None + assert v06_passport.credential_subject.product is not None + assert v06_passport.credential_subject.product.name == "v0.6 sample product" + + def test_v07_credential_subject_is_product_directly(self, v07_passport: Any) -> None: + """v0.7 credentialSubject *is* the Product (no envelope).""" + cs = v07_passport.credential_subject + assert cs is not None + # The v0.7 shape must NOT carry an outer ``.product`` attribute — + # plugin authors targeting v0.7 read the Product fields directly. + assert not hasattr(cs, "product") + assert cs.name == "v0.7 sample product" + + +# --------------------------------------------------------------------------- +# Entry-point discovery +# --------------------------------------------------------------------------- + + +class TestPluginDiscovery: + """The installed plugin's entry points are discoverable.""" + + def test_validator_entry_points_register(self) -> None: + from dppvalidator.plugins.discovery import discover_validators + + names = {name for name, _ in discover_validators()} + assert "brand_name" in names + assert "brand_name_v07" in names + assert "min_materials" in names + + def test_exporter_entry_points_register(self) -> None: + from dppvalidator.plugins.discovery import discover_exporters + + names = {name for name, _ in discover_exporters()} + assert "csv" in names + + def test_validator_classes_are_instantiable(self) -> None: + """Each discovered validator class can be constructed. + + ``discover_validators`` returns the class object (not an + instance). The engine instantiates them; a constructor that + requires arguments would break the registry without surfacing + a clear error. + """ + from dppvalidator.plugins.discovery import discover_validators + + for name, cls in discover_validators(): + instance = cls() + assert instance is not None, f"failed to construct {name}" + assert hasattr(instance, "rule_id") + assert hasattr(instance, "check") + + +# --------------------------------------------------------------------------- +# v0.6 rule behaviour — keep working +# --------------------------------------------------------------------------- + + +class TestV06BrandNameRule: + """The v0.6 ``BrandNameRule`` keeps its pre-Phase-3 behaviour.""" + + def test_no_violation_when_product_has_name(self, v06_passport: Any) -> None: + from dppvalidator_example_plugin.validators import BrandNameRule + + rule = BrandNameRule() + assert rule.check(v06_passport) == [] + + def test_violation_when_product_name_missing(self, v06_passport: Any) -> None: + from dppvalidator_example_plugin.validators import BrandNameRule + + # Pydantic v2 frozen=False on this model — we reach in directly. + v06_passport.credential_subject.product.name = "" + rule = BrandNameRule() + violations = rule.check(v06_passport) + assert len(violations) == 1 + path, _ = violations[0] + assert path == "$.credentialSubject.product.name" + + +class TestV06MinMaterialsRule: + """The v0.6 ``MinMaterialsRule`` keeps its pre-Phase-3 behaviour.""" + + def test_no_violation_with_two_materials(self, v06_passport: Any) -> None: + from dppvalidator_example_plugin.validators import MinMaterialsRule + + rule = MinMaterialsRule() + assert rule.check(v06_passport) == [] + + def test_warning_with_one_material(self, v06_passport: Any) -> None: + from dppvalidator_example_plugin.validators import MinMaterialsRule + + # Trim materials down to 1 to trigger the rule. + v06_passport.credential_subject.materials_provenance = ( + v06_passport.credential_subject.materials_provenance[:1] + ) + rule = MinMaterialsRule() + violations = rule.check(v06_passport) + assert len(violations) == 1 + + +# --------------------------------------------------------------------------- +# v0.7 rule behaviour — new in Phase 6 +# --------------------------------------------------------------------------- + + +class TestV07BrandNameRule: + """The new ``BrandNameRuleV07`` ships v0.7-aware semantics.""" + + def test_rule_id_advertised(self) -> None: + from dppvalidator_example_plugin.brand_name_v07 import BrandNameRuleV07 + + rule = BrandNameRuleV07() + assert rule.rule_id == "SEM_BRAND_V07" + + def test_applies_to_versions_pins_v07(self) -> None: + from dppvalidator_example_plugin.brand_name_v07 import BrandNameRuleV07 + + assert BrandNameRuleV07.applies_to_versions == ("0.7.0",) + + def test_no_violation_when_product_has_name(self, v07_passport: Any) -> None: + from dppvalidator_example_plugin.brand_name_v07 import BrandNameRuleV07 + + rule = BrandNameRuleV07() + assert rule.check(v07_passport) == [] + + def test_no_violation_when_brand_owner_present_even_without_name( + self, v07_passport: Any + ) -> None: + from dppvalidator_example_plugin.brand_name_v07 import BrandNameRuleV07 + + # Drop the name; the brandOwner relatedParty alone should satisfy. + v07_passport.credential_subject.name = "" + rule = BrandNameRuleV07() + assert rule.check(v07_passport) == [] + + def test_violation_when_neither_name_nor_brand_owner(self, v07_passport: Any) -> None: + from dppvalidator_example_plugin.brand_name_v07 import BrandNameRuleV07 + + v07_passport.credential_subject.name = "" + v07_passport.credential_subject.related_party = [] + rule = BrandNameRuleV07() + violations = rule.check(v07_passport) + assert len(violations) == 1 + path, message = violations[0] + assert path == "$.credentialSubject" + assert "brandOwner" in message + + def test_silently_skips_v06_passports(self, v06_passport: Any) -> None: + """Handed a v0.6 passport, the v0.7 rule no-ops cleanly. + + This is the version-aware-rule pattern: rules co-exist in the + registry and self-filter on shape rather than crashing when the + wrong version flows through. + """ + from dppvalidator_example_plugin.brand_name_v07 import BrandNameRuleV07 + + rule = BrandNameRuleV07() + # Even though the v0.6 product.name is set, the v0.7 rule should + # not look at the wrapped product — it returns no violations + # because the shape didn't match. + assert rule.check(v06_passport) == [] + + +# --------------------------------------------------------------------------- +# CSV exporter — public-API smoke +# --------------------------------------------------------------------------- + + +class TestCSVExporterSmoke: + """The CSV exporter still exports a non-empty payload.""" + + def test_export_returns_string(self, v06_passport: Any) -> None: + from dppvalidator_example_plugin.exporters import CSVExporter + + exporter = CSVExporter() + out = exporter.export(v06_passport) + assert isinstance(out, str) + assert out diff --git a/tests/integration/test_real_world_samples.py b/tests/integration/test_real_world_samples.py new file mode 100644 index 0000000..772a0af --- /dev/null +++ b/tests/integration/test_real_world_samples.py @@ -0,0 +1,444 @@ +"""Integration tests using real-world DPP samples from various sources. + +Tests the validation pipeline against actual DPP instances from: +- UNTP (UN Trade Protocol) reference implementations +- Battery Pass data model examples +- NFC Forum DPP examples +- Catena-X/Tractus-X battery pass models +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pytest + +from dppvalidator.validators import ValidationEngine, ValidationResult + +if TYPE_CHECKING: + from collections.abc import Iterator + +SAMPLES_DIR = Path(__file__).parent.parent / "fixtures" / "samples" + + +def _load_sample(filename: str) -> dict[str, Any] | None: + """Load a sample file if it exists.""" + path = SAMPLES_DIR / filename + if not path.exists(): + return None + with open(path) as f: + data: dict[str, Any] = json.load(f) + return data + + +def _sample_files() -> Iterator[Path]: + """Yield all JSON sample files.""" + if not SAMPLES_DIR.exists(): + return + yield from SAMPLES_DIR.glob("*.json") + + +class TestUNTPSamples: + """Tests for UNTP (UN Trade Protocol) DPP samples. + + These are considered the reference implementation for DPP structure + and should pass validation with high confidence. + """ + + @pytest.fixture + def engine(self) -> ValidationEngine: + return ValidationEngine(schema_version="auto", layers=["model", "semantic"]) + + def test_untp_dpp_instance_0_6_0(self, engine: ValidationEngine): + """UNTP DPP v0.6.0 instance should validate successfully.""" + data = _load_sample("test_uncefact_org_untp-dpp-instance-0.6.0.json") + if data is None: + pytest.skip("Sample not available") + assert data is not None # Type narrowing + + result = engine.validate(data) + + assert isinstance(result, ValidationResult) + assert result.passport is not None, f"Failed to parse: {result.errors}" + # UNTP reference should pass with minimal errors + assert result.valid or len(result.errors) <= 2 + + def test_untp_dpp_instance_0_3_10(self, engine: ValidationEngine): + """UNTP DPP v0.3.10 instance should be parseable.""" + data = _load_sample("opensource_unicc_org_untp-digital-product-passport-v0.3.10.json") + if data is None: + pytest.skip("Sample not available") + assert data is not None # Type narrowing + + result = engine.validate(data) + + assert isinstance(result, ValidationResult) + # Older version may have structural differences + assert result.passport is not None or len(result.errors) > 0 + + def test_untp_digital_facility_record(self, engine: ValidationEngine): + """UNTP Digital Facility Record should be parseable as related credential.""" + data = _load_sample("opensource_unicc_org_untp-digital-facility-record-v0.3.9.json") + if data is None: + pytest.skip("Sample not available") + assert data is not None # Type narrowing + + result = engine.validate(data) + + # Facility records are different from DPPs but should still parse + assert isinstance(result, ValidationResult) + + def test_untp_digital_identity_anchor(self, engine: ValidationEngine): + """UNTP Digital Identity Anchor should be parseable.""" + data = _load_sample("test_uncefact_org_DigitalIdentityAnchor-instance-0.6.1.json") + if data is None: + pytest.skip("Sample not available") + assert data is not None # Type narrowing + + result = engine.validate(data) + + assert isinstance(result, ValidationResult) + # Identity anchors have different structure + assert result.passport is not None or len(result.errors) > 0 + + +class TestBatteryPassSamples: + """Tests for Battery Pass data model samples. + + These follow the EU Battery Regulation requirements and include + specific battery-related fields. + """ + + @pytest.fixture + def engine(self) -> ValidationEngine: + return ValidationEngine(schema_version="auto", layers=["model", "semantic"]) + + def test_battery_pass_general_product_info_payload(self, engine: ValidationEngine): + """Battery Pass GeneralProductInformation payload should be parseable.""" + data = _load_sample( + "BatteryPassDataModel_BatteryPass_GeneralProductInformation-payload.json" + ) + if data is None: + pytest.skip("Sample not available") + assert data is not None # Type narrowing + + result = engine.validate(data) + + assert isinstance(result, ValidationResult) + # Battery Pass has different structure, may not fully validate + # but should parse and identify key fields + if result.passport: + # Check if battery-specific fields are recognized + assert result.passport is not None + + def test_battery_pass_general_product_info_ld(self, engine: ValidationEngine): + """Battery Pass GeneralProductInformation JSON-LD should be parseable.""" + data = _load_sample("batterypass_BatteryPassDataModel_GeneralProductInformation-ld.json") + if data is None: + pytest.skip("Sample not available") + assert data is not None # Type narrowing + + result = engine.validate(data) + + assert isinstance(result, ValidationResult) + # JSON-LD with @graph structure may need special handling + if "@graph" in data: + # Verify we handle @graph structures + assert result.passport is not None or len(result.errors) > 0 + + def test_battery_pass_circularity_ld(self, engine: ValidationEngine): + """Battery Pass Circularity JSON-LD should be parseable.""" + data = _load_sample("batterypass_BatteryPassDataModel_Circularity-ld.json") + if data is None: + pytest.skip("Sample not available") + assert data is not None # Type narrowing + + result = engine.validate(data) + + assert isinstance(result, ValidationResult) + + def test_battery_pass_material_composition_ld(self, engine: ValidationEngine): + """Battery Pass MaterialComposition JSON-LD should be parseable.""" + data = _load_sample("batterypass_BatteryPassDataModel_MaterialComposition-ld.json") + if data is None: + pytest.skip("Sample not available") + assert data is not None # Type narrowing + + result = engine.validate(data) + + assert isinstance(result, ValidationResult) + + def test_battery_pass_carbon_footprint_ld(self, engine: ValidationEngine): + """Battery Pass CarbonFootprint JSON-LD should be parseable.""" + data = _load_sample("batterypass_BatteryPassDataModel_CarbonFootprintForBatteries-ld.json") + if data is None: + pytest.skip("Sample not available") + assert data is not None # Type narrowing + + result = engine.validate(data) + + assert isinstance(result, ValidationResult) + + +class TestCatenaXSamples: + """Tests for Catena-X/Tractus-X battery pass samples. + + These are industry consortium examples from automotive sector. + """ + + @pytest.fixture + def engine(self) -> ValidationEngine: + return ValidationEngine(schema_version="auto", layers=["model", "semantic"]) + + def test_tractus_x_battery_pass(self, engine: ValidationEngine): + """Tractus-X BatteryPass sample should be parseable.""" + data = _load_sample("eclipse-tractusx_sldt-semantic-models_BatteryPass.json") + if data is None: + pytest.skip("Sample not available") + assert data is not None # Type narrowing + + result = engine.validate(data) + + assert isinstance(result, ValidationResult) + # Catena-X has its own structure, validate basic parsing + if result.passport: + # Should extract some product information + assert result.passport is not None + + +class TestAlternativeDPPFormats: + """Tests for alternative DPP formats from various sources.""" + + @pytest.fixture + def engine(self) -> ValidationEngine: + return ValidationEngine(schema_version="auto", layers=["model", "semantic"]) + + def test_nfc_forum_long_dpp_example(self, engine: ValidationEngine): + """NFC Forum long DPP example should be parseable.""" + data = _load_sample("nfc-forum_org_long-dpp-example.json") + if data is None: + pytest.skip("Sample not available") + assert data is not None # Type narrowing + + result = engine.validate(data) + + assert isinstance(result, ValidationResult) + # NFC Forum example has different structure + # but contains product information + if result.passport is None and result.errors: + # Should identify what's missing + error_messages = [e.message for e in result.errors] + assert len(error_messages) > 0 + + def test_spherity_breathable_tshirt(self, engine: ValidationEngine): + """Spherity breathable t-shirt sample should be parseable.""" + data = _load_sample("schemas_testing_breathable-t-shirt.json") + if data is None: + pytest.skip("Sample not available") + assert data is not None # Type narrowing + + result = engine.validate(data) + + assert isinstance(result, ValidationResult) + + def test_enveloped_verifiable_credential(self, engine: ValidationEngine): + """Enveloped VC from S3 should be parseable.""" + data = _load_sample( + "untp-verifiable-credentials_s3_amazonaws_com_bc075c5f-2304-4b3f-bb24-46d9fa9a8e60.json" + ) + if data is None: + pytest.skip("Sample not available") + assert data is not None # Type narrowing + + result = engine.validate(data) + + assert isinstance(result, ValidationResult) + # Enveloped credentials need unwrapping + if data.get("type") == "EnvelopedVerifiableCredential": + # Should handle or report enveloped format + assert result.passport is not None or len(result.errors) > 0 + + +class TestBulkSampleValidation: + """Bulk tests across all downloaded samples.""" + + @pytest.fixture + def engine(self) -> ValidationEngine: + return ValidationEngine(schema_version="auto", layers=["model"]) + + def test_all_samples_are_valid_json(self): + """All sample files should be valid JSON.""" + for sample_path in _sample_files(): + with open(sample_path) as f: + try: + data = json.load(f) + assert isinstance(data, dict), f"{sample_path.name} is not a JSON object" + except json.JSONDecodeError as e: + pytest.fail(f"{sample_path.name} is not valid JSON: {e}") + + def test_all_samples_can_be_processed(self, engine: ValidationEngine): + """All samples should be processable without crashing.""" + for sample_path in _sample_files(): + with open(sample_path) as f: + data = json.load(f) + + # Should not raise any exceptions + result = engine.validate(data) + assert isinstance(result, ValidationResult), f"Failed on {sample_path.name}" + + @pytest.mark.parametrize( + "filename", + [ + "test_uncefact_org_untp-dpp-instance-0.6.0.json", + ], + ) + def test_excellent_samples_validate(self, engine: ValidationEngine, filename: str): + """Samples rated 'excellent' with matching schema version should pass validation.""" + data = _load_sample(filename) + if data is None: + pytest.skip(f"Sample {filename} not available") + assert data is not None # Type narrowing + + result = engine.validate(data) + + assert result.passport is not None, f"{filename}: {result.errors}" + + def test_older_untp_sample_processes_with_version_differences(self, engine: ValidationEngine): + """Older UNTP samples may have schema differences but should still process.""" + data = _load_sample("opensource_unicc_org_untp-digital-product-passport-v0.3.10.json") + if data is None: + pytest.skip("Sample not available") + assert data is not None # Type narrowing + + result = engine.validate(data) + + # Older versions may fail strict validation due to schema evolution + # but the error should be about extra/missing fields, not parsing failures + assert isinstance(result, ValidationResult) + if not result.valid: + # Errors should be model-level (schema differences), not parsing errors + assert all(e.layer == "model" for e in result.errors) + + @pytest.mark.parametrize( + "filename", + [ + "test_uncefact_org_DigitalIdentityAnchor-instance-0.6.1.json", + "opensource_unicc_org_untp-digital-facility-record-v0.3.9.json", + "BatteryPassDataModel_BatteryPass_GeneralProductInformation-payload.json", + "batterypass_BatteryPassDataModel_GeneralProductInformation-ld.json", + ], + ) + def test_good_samples_process(self, engine: ValidationEngine, filename: str): + """Samples rated 'good' should process without errors.""" + data = _load_sample(filename) + if data is None: + pytest.skip(f"Sample {filename} not available") + assert data is not None # Type narrowing + + result = engine.validate(data) + + assert isinstance(result, ValidationResult) + # Good samples may not fully validate but should parse + assert result.passport is not None or result.errors + + +class TestSampleStructureAnalysis: + """Tests that analyze the structure of real-world samples.""" + + def test_untp_samples_have_context(self): + """UNTP samples should have @context field.""" + untp_samples = [ + "test_uncefact_org_untp-dpp-instance-0.6.0.json", + "opensource_unicc_org_untp-digital-product-passport-v0.3.10.json", + ] + for filename in untp_samples: + data = _load_sample(filename) + if data is None: + continue + + assert "@context" in data, f"{filename} missing @context" + assert isinstance(data["@context"], list), f"{filename} @context should be list" + + def test_untp_samples_have_type(self): + """UNTP samples should have type field.""" + untp_samples = [ + "test_uncefact_org_untp-dpp-instance-0.6.0.json", + "opensource_unicc_org_untp-digital-product-passport-v0.3.10.json", + ] + for filename in untp_samples: + data = _load_sample(filename) + if data is None: + continue + + assert "type" in data, f"{filename} missing type" + types = data["type"] + assert "VerifiableCredential" in types, f"{filename} should be VerifiableCredential" + + def test_untp_samples_have_issuer(self): + """UNTP samples should have issuer field.""" + untp_samples = [ + "test_uncefact_org_untp-dpp-instance-0.6.0.json", + "opensource_unicc_org_untp-digital-product-passport-v0.3.10.json", + ] + for filename in untp_samples: + data = _load_sample(filename) + if data is None: + continue + + assert "issuer" in data, f"{filename} missing issuer" + issuer = data["issuer"] + assert "id" in issuer, f"{filename} issuer missing id" + + def test_untp_samples_have_credential_subject(self): + """UNTP samples should have credentialSubject field.""" + untp_samples = [ + "test_uncefact_org_untp-dpp-instance-0.6.0.json", + "opensource_unicc_org_untp-digital-product-passport-v0.3.10.json", + ] + for filename in untp_samples: + data = _load_sample(filename) + if data is None: + continue + + assert "credentialSubject" in data, f"{filename} missing credentialSubject" + + def test_battery_pass_samples_have_battery_fields(self): + """Battery Pass samples should contain battery-specific fields.""" + battery_sample = _load_sample( + "BatteryPassDataModel_BatteryPass_GeneralProductInformation-payload.json" + ) + if battery_sample is None: + pytest.skip("Battery Pass sample not available") + assert battery_sample is not None # Type narrowing + + battery_fields = ["batteryCategory", "batteryStatus", "batteryMass"] + sample_keys = list(battery_sample.keys()) + found = [f for f in battery_fields if f in sample_keys] + assert len(found) >= 2, f"Expected battery fields, found: {sample_keys}" + + +class TestValidationConsistency: + """Tests for validation consistency across similar samples.""" + + @pytest.fixture + def engine(self) -> ValidationEngine: + return ValidationEngine(schema_version="auto", layers=["model", "semantic"]) + + def test_same_version_samples_produce_consistent_results(self, engine: ValidationEngine): + """Samples from the same schema version should produce consistent validation.""" + # Test with two v0.6.x samples which should both validate + sample1 = _load_sample("test_uncefact_org_untp-dpp-instance-0.6.0.json") + sample2 = _load_sample("test_uncefact_org_DigitalIdentityAnchor-instance-0.6.1.json") + + if sample1 is None or sample2 is None: + pytest.skip("Samples not available") + assert sample1 is not None and sample2 is not None # Type narrowing + + result1 = engine.validate(sample1) + result2 = engine.validate(sample2) + + # Both are v0.6.x and should parse successfully + assert result1.passport is not None, f"v0.6.0 DPP failed: {result1.errors}" + assert result2.passport is not None, f"v0.6.1 DIA failed: {result2.errors}" diff --git a/tests/integration/test_validation_pipeline.py b/tests/integration/test_validation_pipeline.py index 09f940e..73df488 100644 --- a/tests/integration/test_validation_pipeline.py +++ b/tests/integration/test_validation_pipeline.py @@ -14,11 +14,11 @@ class TestValidFixtures: - """Integration tests validating known-good DPP fixtures.""" + """Integration tests validating known-good v0.6.x DPP fixtures.""" @pytest.fixture def engine(self) -> ValidationEngine: - """Create validation engine with all layers.""" + """Create validation engine with all layers (pinned to v0.6.1).""" return ValidationEngine(schema_version="0.6.1", layers=["model", "semantic"]) def test_minimal_dpp_passes_validation(self, engine): @@ -52,6 +52,73 @@ def test_untp_instance_passes_validation(self, engine): assert result.passport is not None or len(result.errors) > 0 +class TestValidV07Fixtures: + """Integration tests validating the vendored v0.7.0 upstream fixtures. + + Phase 5 vendored the upstream UNTP 0.7.0 sample DPPs into + ``tests/fixtures/valid/``. This class pins the contract that the + full validation pipeline (model + semantic layers) accepts each + of them — a regression here means either the v0.7 model has + drifted from the upstream schema, or a semantic rule started + rejecting a sample we previously accepted. + """ + + @pytest.fixture + def engine(self) -> ValidationEngine: + """Create validation engine with all layers (pinned to v0.7.0).""" + return ValidationEngine(schema_version="0.7.0", layers=["model", "semantic"]) + + @pytest.mark.parametrize( + "fixture_name", + [ + "untp-dpp-instance-0.7.0.json", + "untp-dpp-battery-instance-0.7.0.json", + "untp-dpp-cathode-instance-0.7.0.json", + ], + ) + def test_canonical_v07_fixture_passes_pipeline( + self, engine: ValidationEngine, fixture_name: str + ) -> None: + """Each canonical v0.7.0 sample validates cleanly through the pipeline. + + The vendored fixtures are bit-identical to the upstream samples + published at ``untp.unece.org/artefacts/samples/v0.7.0/dpp/``; + they're the highest-fidelity smoke test we have for the v0.7 + model + semantic-rule combination. + """ + fixture_path = FIXTURES_DIR / "valid" / fixture_name + if not fixture_path.exists(): + pytest.skip(f"v0.7 fixture not vendored: {fixture_name}") + + result = engine.validate_file(fixture_path) + + assert result.valid, f"{fixture_name} unexpectedly rejected:\n" + "\n".join( + f" [{e.code}] {e.path}: {e.message}" for e in result.errors + ) + assert result.passport is not None + + def test_v06_fixture_through_v07_engine_fails_with_VER001( + self, engine: ValidationEngine + ) -> None: + """Feeding a v0.6.x payload to a v0.7.0-pinned engine is fail-fast. + + Pins the VER001 contract from Phase 3.3 — the engine must not + silently coerce across versions. See + ``docs/errors/VER001.md`` for the user-facing remediation. + """ + fixture_path = FIXTURES_DIR / "valid" / "untp-dpp-instance-0.6.1.json" + if not fixture_path.exists(): + pytest.skip("v0.6 fixture not available") + + result = engine.validate_file(fixture_path) + + assert result.valid is False + assert any(e.code == "VER001" for e in result.errors), ( + "Expected VER001 (version mismatch) when v0.6.x payload " + "flows through a v0.7.0-pinned engine." + ) + + class TestInvalidFixtures: """Integration tests validating known-bad DPP fixtures.""" diff --git a/tests/integration/test_version_matrix.py b/tests/integration/test_version_matrix.py new file mode 100644 index 0000000..cb457bb --- /dev/null +++ b/tests/integration/test_version_matrix.py @@ -0,0 +1,178 @@ +"""Phase 5 acceptance test: validator-layer × UNTP-version matrix. + +This module enforces the Phase 5 exit criterion from +``docs/plans/UNTP_0.7.0_MIGRATION.md``: + +> Parametrised matrix is green; coverage report unchanged or improved. + +The matrix multiplies every supported validation layer (``schema``, +``model``, ``semantic``, ``jsonld``, ``deep``) by every supported UNTP +DPP version (currently ``0.6.1`` and ``0.7.0``) and asserts the +canonical happy-path fixture validates cleanly through each layer/version +combination. When a new UNTP version is registered, the matrix +automatically expands. + +Negative coverage — fixtures that *must* fail — lives next to this test +in ``tests/fixtures/invalid/0.7.0/`` with a parametrised "every invalid +fixture surfaces at least one error" check. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any + +import pytest + +from dppvalidator.validators import ValidationEngine + +_FIXTURE_ROOT = Path(__file__).resolve().parents[1] / "fixtures" +_VALID_DIR = _FIXTURE_ROOT / "valid" +_INVALID_07_DIR = _FIXTURE_ROOT / "invalid" / "0.7.0" + + +# --------------------------------------------------------------------------- +# Fixture discovery — keyed on the published UNTP version +# --------------------------------------------------------------------------- + + +_HAPPY_PATH_BY_VERSION: dict[str, Path] = { + "0.6.1": _VALID_DIR / "untp-dpp-instance-0.6.1.json", + "0.7.0": _VALID_DIR / "untp-dpp-instance-0.7.0.json", +} + + +def _matrix_versions() -> tuple[str, ...]: + """Read the matrix versions from the conftest registry helper.""" + from tests.conftest import all_matrix_versions + + return tuple(all_matrix_versions()) + + +def _layers_for( + version: str, # noqa: ARG001 — placeholder for future per-version filtering +) -> tuple[str, ...]: + """Layers that should execute against ``version``. + + All four sub-validators are exercised for both versions in Phase 5 + — the matrix is exhaustive on purpose. If a layer ever needs to + skip a version (e.g. an experimental layer not yet ported), this + is the place to express that. The deep validator is async and + handled separately. + """ + return ("schema", "model", "semantic", "jsonld") + + +# --------------------------------------------------------------------------- +# Happy-path matrix — sub-validator × version +# --------------------------------------------------------------------------- + + +def _layer_version_matrix() -> list[tuple[str, str, Path]]: + """Build the ``(layer, version, fixture)`` cartesian product.""" + out: list[tuple[str, str, Path]] = [] + for version in _matrix_versions(): + fixture = _HAPPY_PATH_BY_VERSION.get(version) + if fixture is None or not fixture.is_file(): + continue + for layer in _layers_for(version): + out.append((layer, version, fixture)) + return out + + +@pytest.mark.parametrize( + ("layer", "version", "fixture"), + _layer_version_matrix(), + ids=lambda val: val if isinstance(val, str) else val.name, +) +def test_layer_passes_for_canonical_fixture(layer: str, version: str, fixture: Path) -> None: + """Run a single layer against the canonical fixture for ``version``. + + Fails when the fixture is rejected — that's the matrix's purpose. + Each (layer, version) slot is a separate test ID so CI failures + point straight at the broken combination without ambiguity. + """ + engine = ValidationEngine(schema_version=version, layers=[layer]) + data = json.loads(fixture.read_text(encoding="utf-8")) + result = engine.validate(data) + assert result.valid, ( + f"{layer} layer rejected the canonical {version} fixture " + f"({fixture.name}):\n" + + "\n".join(f" [{e.code}] {e.path}: {e.message}" for e in result.errors) + ) + + +def test_full_pipeline_passes_for_each_version() -> None: + """The default-layer pipeline (schema+model+semantic) is green per version.""" + for version in _matrix_versions(): + fixture = _HAPPY_PATH_BY_VERSION.get(version) + if fixture is None or not fixture.is_file(): + pytest.skip(f"No happy-path fixture vendored for {version}") + engine = ValidationEngine(schema_version=version) + data = json.loads(fixture.read_text(encoding="utf-8")) + result = engine.validate(data) + assert result.valid, f"Full pipeline failed for {version} ({fixture.name}):\n" + "\n".join( + f" [{e.code}] {e.path}: {e.message}" for e in result.errors + ) + + +# --------------------------------------------------------------------------- +# Deep validator — async, exercised once per version +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("version", _matrix_versions()) +def test_deep_validator_runs_per_version(version: str) -> None: + """The deep validator constructs and executes for each UNTP version. + + The fixture has no external links (``href``-bearing fields are + self-referential or absent), so deep traversal has nothing to fetch + and the result must be valid. We assert the *path* compiles per + version more than that the result is empty — Phase 3b's + ``LINK_PATHS_BY_VERSION`` dispatch is what's under test. + """ + fixture = _HAPPY_PATH_BY_VERSION.get(version) + if fixture is None or not fixture.is_file(): + pytest.skip(f"No happy-path fixture vendored for {version}") + data = json.loads(fixture.read_text(encoding="utf-8")) + engine = ValidationEngine(schema_version=version) + result = asyncio.run(engine.validate_deep(data)) + # The deep validator may surface no findings — what matters is that + # the per-version dispatch table produced a runnable validator. + assert result is not None + + +# --------------------------------------------------------------------------- +# Negative matrix — every invalid v0.7 fixture surfaces an error +# --------------------------------------------------------------------------- + + +def _invalid_07_fixtures() -> list[Path]: + return sorted(_INVALID_07_DIR.glob("*.json")) + + +@pytest.mark.parametrize( + "fixture", + _invalid_07_fixtures(), + ids=lambda p: p.name, +) +def test_invalid_v07_fixture_is_rejected(fixture: Path) -> None: + """Every fixture in ``invalid/0.7.0/`` must be flagged as invalid. + + Catches regressions where a schema or model relaxation accidentally + starts accepting a payload that's documented as invalid. + """ + data: dict[str, Any] = json.loads(fixture.read_text(encoding="utf-8")) + engine = ValidationEngine(schema_version="0.7.0") + result = engine.validate(data) + assert not result.valid, ( + f"{fixture.name} unexpectedly validated cleanly — the fixture is " + "documented as invalid; either the fixture or the validator " + "regressed." + ) + assert result.errors, ( + f"{fixture.name} returned valid=False but no errors — the engine " + "should always surface the cause." + ) diff --git a/tests/property/test_property_cirpass.py b/tests/property/test_property_cirpass.py new file mode 100644 index 0000000..b2e427f --- /dev/null +++ b/tests/property/test_property_cirpass.py @@ -0,0 +1,243 @@ +"""Property-based tests for CIRPASS-2 validation using Hypothesis. + +Tests the invariants and properties of CIRPASS validation rules. +""" + +from datetime import datetime, timedelta, timezone + +from hypothesis import given, settings +from hypothesis import strategies as st + +from dppvalidator.models import CredentialIssuer, DigitalProductPassport, Product, ProductPassport +from dppvalidator.validators.rules.cirpass import ( + CIRPASSMandatoryAttributesRule, + CIRPASSValidityPeriodRule, +) + +# Strategy for generating valid URLs +url_strategy = st.from_regex( + r"https://[a-z]+\.[a-z]{2,3}/[a-z0-9\-]+", + fullmatch=True, +) + + +# Strategy for generating valid ISO dates +def datetime_strategy(min_year: int = 2020, max_year: int = 2030): + """Generate valid datetime strings.""" + return st.datetimes( + min_value=datetime(min_year, 1, 1, tzinfo=timezone.utc), + max_value=datetime(max_year, 12, 31, tzinfo=timezone.utc), + ).map(lambda dt: dt.isoformat()) + + +class TestCIRPASSMandatoryAttributesProperty: + """Property-based tests for CQ001: Mandatory ESPR attributes.""" + + @given( + issuer_name=st.text( + min_size=1, max_size=100, alphabet=st.characters(whitelist_categories=("L", "N", "P")) + ), + ) + @settings(max_examples=50) + def test_valid_passport_always_passes_mandatory_check(self, issuer_name: str): + """A complete passport should always pass mandatory attribute checks.""" + if not issuer_name.strip(): + return # Skip empty names + + rule = CIRPASSMandatoryAttributesRule() + passport = DigitalProductPassport( + id="https://example.com/dpp/test", + issuer=CredentialIssuer( + id="https://example.com/issuer", + name=issuer_name.strip(), + ), + validFrom=datetime.now(timezone.utc).isoformat(), + credentialSubject=ProductPassport( + product=Product( + id="https://example.com/product/test", + name="Test Product", + ), + ), + ) + violations = rule.check(passport) + # Complete passport should have no mandatory attribute violations + assert len([v for v in violations if "mandatory" in v[1].lower()]) == 0 + + @given( + has_valid_from=st.booleans(), + has_credential_subject=st.booleans(), + ) + @settings(max_examples=30) + def test_optional_fields_affect_violations( + self, has_valid_from: bool, has_credential_subject: bool + ): + """Optional CIRPASS fields affect violation count.""" + rule = CIRPASSMandatoryAttributesRule() + + # Build passport - issuer is always required by Pydantic + kwargs = { + "id": "https://example.com/dpp/test", + "issuer": CredentialIssuer( + id="https://example.com/issuer", + name="Test Issuer", + ), + } + + if has_valid_from: + kwargs["validFrom"] = datetime.now(timezone.utc).isoformat() + + if has_credential_subject: + kwargs["credentialSubject"] = ProductPassport( + product=Product( + id="https://example.com/product/test", + name="Test Product", + ), + ) + + passport = DigitalProductPassport(**kwargs) + violations = rule.check(passport) + + # If all CIRPASS fields present, fewer violations expected + all_present = has_valid_from and has_credential_subject + if all_present: + assert len(violations) == 0 + # Violations depend on which fields are missing + + +class TestCIRPASSValidityPeriodProperty: + """Property-based tests for CQ016: Validity period rules.""" + + @given( + days_valid=st.integers(min_value=1, max_value=3650), + ) + @settings(max_examples=50) + def test_valid_period_never_produces_date_order_violation(self, days_valid: int): + """A passport with validFrom < validUntil should not have date order violations.""" + rule = CIRPASSValidityPeriodRule() + + now = datetime.now(timezone.utc) + valid_from = now.isoformat() + valid_until = (now + timedelta(days=days_valid)).isoformat() + + passport = DigitalProductPassport( + id="https://example.com/dpp/test", + issuer=CredentialIssuer( + id="https://example.com/issuer", + name="Test Issuer", + ), + validFrom=valid_from, + validUntil=valid_until, + ) + + violations = rule.check(passport) + # Should not have date order violations + date_order_violations = [ + v for v in violations if "before" in v[1].lower() or "order" in v[1].lower() + ] + assert len(date_order_violations) == 0 + + @given( + years_until_expiry=st.integers(min_value=1, max_value=20), + ) + @settings(max_examples=30) + def test_long_validity_periods_are_accepted(self, years_until_expiry: int): + """Passports with various validity periods should be validated.""" + rule = CIRPASSValidityPeriodRule() + + now = datetime.now(timezone.utc) + valid_from = now.isoformat() + valid_until = (now + timedelta(days=365 * years_until_expiry)).isoformat() + + passport = DigitalProductPassport( + id="https://example.com/dpp/test", + issuer=CredentialIssuer( + id="https://example.com/issuer", + name="Test Issuer", + ), + validFrom=valid_from, + validUntil=valid_until, + ) + + violations = rule.check(passport) + # Valid date order should not produce date violations + date_violations = [ + v for v in violations if "date" in v[1].lower() or "before" in v[1].lower() + ] + assert len(date_violations) == 0 + + +class TestDPPModelProperty: + """Property-based tests for DPP model invariants.""" + + @given( + product_name=st.text(min_size=1, max_size=200), + issuer_name=st.text(min_size=1, max_size=100), + ) + @settings(max_examples=50) + def test_passport_roundtrip_preserves_data(self, product_name: str, issuer_name: str): + """Creating a passport and serializing/deserializing should preserve data.""" + if not product_name.strip() or not issuer_name.strip(): + return + + passport = DigitalProductPassport( + id="https://example.com/dpp/test", + issuer=CredentialIssuer( + id="https://example.com/issuer", + name=issuer_name.strip(), + ), + validFrom=datetime.now(timezone.utc).isoformat(), + credentialSubject=ProductPassport( + product=Product( + id="https://example.com/product/test", + name=product_name.strip(), + ), + ), + ) + + # Serialize to dict and back + data = passport.model_dump(by_alias=True, exclude_none=True) + restored = DigitalProductPassport.model_validate(data) + + # Core fields should match + assert restored.id == passport.id + assert restored.issuer.name == passport.issuer.name + + @given( + num_materials=st.integers(min_value=0, max_value=10), + ) + @settings(max_examples=20) + def test_passport_with_materials_validates(self, num_materials: int): + """Passports with varying numbers of materials should validate.""" + from dppvalidator.validators import ValidationEngine + + passport_data = { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ], + "type": ["DigitalProductPassport", "VerifiableCredential"], + "id": "https://example.com/dpp/test", + "issuer": {"id": "https://example.com/issuer", "name": "Test"}, + "validFrom": datetime.now(timezone.utc).isoformat(), + "validUntil": (datetime.now(timezone.utc) + timedelta(days=365)).isoformat(), + "credentialSubject": { + "id": "https://example.com/subject", + "type": ["ProductPassport"], + "product": { + "id": "https://example.com/product", + "name": "Test Product", + }, + }, + } + + if num_materials > 0: + passport_data["credentialSubject"]["materialsProvenance"] = [ + {"name": f"Material {i}", "massFraction": 1.0 / num_materials} + for i in range(num_materials) + ] + + engine = ValidationEngine(layers=["model"]) + result = engine.validate(passport_data) + + # Should successfully parse regardless of material count + assert result is not None diff --git a/tests/property/test_property_validators.py b/tests/property/test_property_validators.py index ee930cc..c357898 100644 --- a/tests/property/test_property_validators.py +++ b/tests/property/test_property_validators.py @@ -115,7 +115,7 @@ def test_engine_invalid_data_returns_errors(self, invalid_data): assert result.valid is False @given(st.binary(min_size=0, max_size=100)) - @settings(max_examples=50) + @settings(max_examples=50, deadline=500) def test_engine_never_crashes_on_binary(self, binary_data): """Test engine never crashes on arbitrary binary input.""" engine = ValidationEngine(layers=["model"]) diff --git a/tests/unit/test_cirpass_loader.py b/tests/unit/test_cirpass_loader.py new file mode 100644 index 0000000..8c75b0a --- /dev/null +++ b/tests/unit/test_cirpass_loader.py @@ -0,0 +1,305 @@ +"""Tests for CIRPASS schema loader (Phase 5).""" + +from dppvalidator.schemas.cirpass_loader import ( + CIRPASS_SCHEMA_FILE, + CIRPASS_SCHEMA_TITLE, + CIRPASS_SCHEMA_VERSION, + CIRPASSSchemaLoader, + CIRPASSSHACLLoader, + get_cirpass_schema, + get_cirpass_schema_version, +) + + +class TestCIRPASSSchemaConstants: + """Tests for CIRPASS schema constants.""" + + def test_schema_version(self): + """Test schema version constant.""" + assert CIRPASS_SCHEMA_VERSION == "1.3.0" + + def test_schema_title(self): + """Test schema title constant.""" + assert CIRPASS_SCHEMA_TITLE == "CIRPASS DPP reference structure" + + def test_schema_file(self): + """Test schema file constant.""" + assert CIRPASS_SCHEMA_FILE == "cirpass_dpp_schema.json" + + +class TestCIRPASSSchemaLoader: + """Tests for CIRPASSSchemaLoader class.""" + + def test_create_loader(self): + """Test creating a schema loader.""" + loader = CIRPASSSchemaLoader() + assert loader.SCHEMA_VERSION == "1.3.0" + assert loader._schema is None + + def test_load_schema(self): + """Test loading the CIRPASS schema.""" + loader = CIRPASSSchemaLoader() + schema = loader.load() + + assert isinstance(schema, dict) + assert "properties" in schema + assert "title" in schema + + def test_load_schema_cached(self): + """Test schema is cached after first load.""" + loader = CIRPASSSchemaLoader() + schema1 = loader.load() + schema2 = loader.load() + + assert schema1 is schema2 + + def test_schema_id(self): + """Test getting schema ID.""" + loader = CIRPASSSchemaLoader() + schema_id = loader.schema_id + + assert "CIRPASS DPP reference structure" in schema_id + + def test_schema_version_property(self): + """Test getting schema version from property.""" + loader = CIRPASSSchemaLoader() + version = loader.schema_version + + assert version == "1.3.0" + + def test_json_schema_draft(self): + """Test getting JSON Schema draft version.""" + loader = CIRPASSSchemaLoader() + draft = loader.json_schema_draft + + assert "json-schema.org" in draft + assert "2020-12" in draft + + def test_get_property_names(self): + """Test getting property names from schema.""" + loader = CIRPASSSchemaLoader() + properties = loader.get_property_names() + + assert isinstance(properties, list) + assert len(properties) > 0 + assert "uniqueDPPID" in properties + assert "appliesToProduct" in properties + + def test_get_property_schema(self): + """Test getting schema for a specific property.""" + loader = CIRPASSSchemaLoader() + prop_schema = loader.get_property_schema("uniqueDPPID") + + assert prop_schema is not None + assert "type" in prop_schema + + def test_get_property_schema_not_found(self): + """Test getting schema for unknown property.""" + loader = CIRPASSSchemaLoader() + prop_schema = loader.get_property_schema("unknownProperty") + + assert prop_schema is None + + def test_has_property_true(self): + """Test has_property returns True for existing property.""" + loader = CIRPASSSchemaLoader() + + assert loader.has_property("uniqueDPPID") + assert loader.has_property("appliesToProduct") + + def test_has_property_false(self): + """Test has_property returns False for unknown property.""" + loader = CIRPASSSchemaLoader() + + assert not loader.has_property("unknownProperty") + + def test_is_additional_properties_allowed(self): + """Test checking if additional properties are allowed.""" + loader = CIRPASSSchemaLoader() + + # CIRPASS schema has additionalProperties: false + assert not loader.is_additional_properties_allowed() + + def test_clear_cache(self): + """Test clearing the schema cache.""" + loader = CIRPASSSchemaLoader() + loader.load() # Load to populate cache + assert loader._schema is not None + + loader.clear_cache() + assert loader._schema is None + + +class TestCIRPASSSHACLLoader: + """Tests for CIRPASSSHACLLoader class.""" + + def test_create_shacl_loader(self): + """Test creating a SHACL loader.""" + loader = CIRPASSSHACLLoader() + assert loader.SHACL_FILE == "cirpass_dpp_shacl.ttl" + assert loader._shapes_text is None + + def test_load_shacl_text(self): + """Test loading SHACL shapes as text.""" + loader = CIRPASSSHACLLoader() + shapes = loader.load_text() + + assert isinstance(shapes, str) + assert len(shapes) > 0 + # SHACL files contain these prefixes + assert "@prefix" in shapes or "PREFIX" in shapes + + def test_load_shacl_cached(self): + """Test SHACL shapes are cached after first load.""" + loader = CIRPASSSHACLLoader() + shapes1 = loader.load_text() + shapes2 = loader.load_text() + + assert shapes1 is shapes2 + + def test_clear_shacl_cache(self): + """Test clearing the SHACL cache.""" + loader = CIRPASSSHACLLoader() + loader.load_text() # Load to populate cache + assert loader._shapes_text is not None + + loader.clear_cache() + assert loader._shapes_text is None + + +class TestConvenienceFunctions: + """Tests for convenience functions.""" + + def test_get_cirpass_schema(self): + """Test get_cirpass_schema function.""" + schema = get_cirpass_schema() + + assert isinstance(schema, dict) + assert "properties" in schema + assert "title" in schema + + def test_get_cirpass_schema_version(self): + """Test get_cirpass_schema_version function.""" + version = get_cirpass_schema_version() + + assert version == "1.3.0" + + +class TestSchemaIntegrity: + """Tests for schema integrity and structure.""" + + def test_schema_has_required_dpp_properties(self): + """Test schema has required DPP properties.""" + loader = CIRPASSSchemaLoader() + properties = loader.get_property_names() + + # Required DPP properties per ESPR + required_props = [ + "uniqueDPPID", + "validFrom", + "appliesToProduct", + ] + + for prop in required_props: + assert prop in properties, f"Missing required property: {prop}" + + def test_schema_applies_to_product_structure(self): + """Test appliesToProduct has expected structure.""" + loader = CIRPASSSchemaLoader() + prop_schema = loader.get_property_schema("appliesToProduct") + + assert prop_schema is not None + # appliesToProduct is an array of objects + assert prop_schema.get("type") == "array" + assert "items" in prop_schema + + def test_schema_is_valid_json_schema(self): + """Test schema is valid JSON Schema draft 2020-12.""" + loader = CIRPASSSchemaLoader() + schema = loader.load() + + # Check JSON Schema properties + assert "$schema" in schema + assert "https://json-schema.org/draft/2020-12/schema" in schema["$schema"] + + +class TestSchemaLoaderImports: + """Tests for schema loader imports from package.""" + + def test_import_from_schemas_package(self): + """Test importing from schemas package.""" + from dppvalidator.schemas import ( + CIRPASS_SCHEMA_VERSION, + CIRPASSSchemaLoader, + CIRPASSSHACLLoader, + get_cirpass_schema, + get_cirpass_schema_version, + ) + + assert CIRPASSSchemaLoader is not None + assert CIRPASSSHACLLoader is not None + assert CIRPASS_SCHEMA_VERSION == "1.3.0" + assert get_cirpass_schema is not None + assert get_cirpass_schema_version is not None + + +class TestCIRPASSSchemaLoaderErrorPaths: + """Tests for error handling in schema loader.""" + + def test_load_file_not_found_raises_runtime_error(self): + """FileNotFoundError during load raises RuntimeError.""" + from unittest.mock import MagicMock, patch + + import pytest + + loader = CIRPASSSchemaLoader() + loader._schema = None # Reset cache + + mock_data_dir = MagicMock() + mock_path = MagicMock() + mock_path.read_text.side_effect = FileNotFoundError("Schema file not found") + mock_data_dir.joinpath.return_value = mock_path + + with patch( + "dppvalidator.schemas.cirpass_loader._get_cirpass_schema_dir", + return_value=mock_data_dir, + ): + with pytest.raises(RuntimeError) as exc_info: + loader.load() + + assert "not found" in str(exc_info.value).lower() + + def test_load_invalid_json_raises_runtime_error(self): + """JSONDecodeError during load raises RuntimeError.""" + from unittest.mock import MagicMock, patch + + import pytest + + loader = CIRPASSSchemaLoader() + loader._schema = None # Reset cache + + mock_data_dir = MagicMock() + mock_path = MagicMock() + mock_path.read_text.return_value = "{ invalid json }" + mock_data_dir.joinpath.return_value = mock_path + + with patch( + "dppvalidator.schemas.cirpass_loader._get_cirpass_schema_dir", + return_value=mock_data_dir, + ): + with pytest.raises(RuntimeError) as exc_info: + loader.load() + + assert "invalid json" in str(exc_info.value).lower() + + def test_schema_version_fallback_when_no_v_in_title(self): + """schema_version falls back to default when no 'v' in title.""" + from unittest.mock import patch + + loader = CIRPASSSchemaLoader() + + # Mock load to return a schema without version in title + with patch.object(loader, "load", return_value={"title": "CIRPASS Schema"}): + version = loader.schema_version + + assert version == loader.SCHEMA_VERSION diff --git a/tests/unit/test_cirpass_rules.py b/tests/unit/test_cirpass_rules.py new file mode 100644 index 0000000..88150e5 --- /dev/null +++ b/tests/unit/test_cirpass_rules.py @@ -0,0 +1,417 @@ +"""Tests for CIRPASS-2 semantic validation rules.""" + +import pytest + +from dppvalidator.models import CredentialIssuer, DigitalProductPassport +from dppvalidator.validators.rules.cirpass import ( + CIRPASS_RULES, + CIRPASSGranularityConsistencyRule, + CIRPASSMandatoryAttributesRule, + CIRPASSOperatorIdentifierRule, + CIRPASSSubstancesOfConcernRule, + CIRPASSValidityPeriodRule, + CIRPASSWeightVolumeRule, +) + + +class TestCIRPASSRulesRegistration: + """Tests for CIRPASS rules registration.""" + + def test_cirpass_rules_list_not_empty(self): + """Test CIRPASS_RULES list is not empty.""" + assert len(CIRPASS_RULES) > 0 + + def test_cirpass_rules_count(self): + """Test CIRPASS_RULES has expected count.""" + assert len(CIRPASS_RULES) == 6 + + def test_all_rules_have_required_attributes(self): + """Test all CIRPASS rules have required attributes.""" + for rule in CIRPASS_RULES: + assert hasattr(rule, "rule_id") + assert hasattr(rule, "description") + assert hasattr(rule, "severity") + assert hasattr(rule, "suggestion") + assert hasattr(rule, "docs_url") + assert hasattr(rule, "check") + assert rule.rule_id.startswith("CQ") + + +class TestCIRPASSMandatoryAttributesRule: + """Tests for CQ001: Mandatory ESPR attributes.""" + + @pytest.fixture + def rule(self) -> CIRPASSMandatoryAttributesRule: + """Create rule instance.""" + return CIRPASSMandatoryAttributesRule() + + def test_rule_attributes(self, rule: CIRPASSMandatoryAttributesRule): + """Test rule has correct attributes.""" + assert rule.rule_id == "CQ001" + assert rule.severity == "error" + + def test_valid_passport_no_violations(self, rule: CIRPASSMandatoryAttributesRule): + """Test valid passport produces no violations.""" + from dppvalidator.models import Product, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + validFrom="2024-01-01T00:00:00Z", + credential_subject=ProductPassport( + product=Product(id="https://example.com/product", name="Test") + ), + ) + violations = rule.check(passport) + assert len(violations) == 0 + + def test_missing_valid_from(self, rule: CIRPASSMandatoryAttributesRule): + """Test missing validFrom produces violation.""" + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + ) + violations = rule.check(passport) + assert any("validFrom" in v[0] for v in violations) + + def test_missing_credential_subject(self, rule: CIRPASSMandatoryAttributesRule): + """Test missing credentialSubject produces violation.""" + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + validFrom="2024-01-01T00:00:00Z", + ) + violations = rule.check(passport) + assert any("credentialSubject" in v[0] for v in violations) + + +class TestCIRPASSSubstancesOfConcernRule: + """Tests for CQ004: Substances of concern identification.""" + + @pytest.fixture + def rule(self) -> CIRPASSSubstancesOfConcernRule: + """Create rule instance.""" + return CIRPASSSubstancesOfConcernRule() + + def test_rule_attributes(self, rule: CIRPASSSubstancesOfConcernRule): + """Test rule has correct attributes.""" + assert rule.rule_id == "CQ004" + assert rule.severity == "error" + + def test_no_materials_no_violations(self, rule: CIRPASSSubstancesOfConcernRule): + """Test no materials produces no violations.""" + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + ) + violations = rule.check(passport) + assert len(violations) == 0 + + def test_non_hazardous_material_no_violation(self, rule: CIRPASSSubstancesOfConcernRule): + """Test non-hazardous material produces no violation.""" + from dppvalidator.models import Material, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credentialSubject=ProductPassport( + materials_provenance=[ + Material(name="Safe Material", hazardous=False), + ] + ), + ) + violations = rule.check(passport) + assert len(violations) == 0 + + def test_hazardous_without_cas_produces_violation(self, rule: CIRPASSSubstancesOfConcernRule): + """Test hazardous material without CAS number produces violation.""" + from dppvalidator.models import Link, Material, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credentialSubject=ProductPassport( + materials_provenance=[ + Material( + name="Unknown Chemical", + hazardous=True, + materialSafetyInformation=Link(linkURL="https://example.com/msds"), + ), + ] + ), + ) + violations = rule.check(passport) + assert len(violations) == 1 + assert "CAS" in violations[0][1] or "EINECS" in violations[0][1] + + def test_hazardous_with_cas_code_no_violation(self, rule: CIRPASSSubstancesOfConcernRule): + """Test hazardous material with CAS code in materialType produces no violation.""" + from dppvalidator.models import Classification, Link, Material, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credentialSubject=ProductPassport( + materials_provenance=[ + Material( + name="Benzene", + hazardous=True, + material_type=Classification( + id="https://cas.org/71-43-2", + name="Benzene", + code="71-43-2", + ), + material_safety_information=Link(link_url="https://example.com/msds"), + ), + ] + ), + ) + violations = rule.check(passport) + assert len(violations) == 0 + + +class TestCIRPASSOperatorIdentifierRule: + """Tests for CQ011: Operator identifier.""" + + @pytest.fixture + def rule(self) -> CIRPASSOperatorIdentifierRule: + """Create rule instance.""" + return CIRPASSOperatorIdentifierRule() + + def test_rule_attributes(self, rule: CIRPASSOperatorIdentifierRule): + """Test rule has correct attributes.""" + assert rule.rule_id == "CQ011" + assert rule.severity == "error" + + def test_valid_issuer_no_violations(self, rule: CIRPASSOperatorIdentifierRule): + """Test valid issuer produces no violations.""" + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + ) + violations = rule.check(passport) + assert len(violations) == 0 + + def test_issuer_present_no_violations(self, rule: CIRPASSOperatorIdentifierRule): + """Test issuer with id produces no violations.""" + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + ) + violations = rule.check(passport) + assert len(violations) == 0 + + +class TestCIRPASSValidityPeriodRule: + """Tests for CQ016: Validity period.""" + + @pytest.fixture + def rule(self) -> CIRPASSValidityPeriodRule: + """Create rule instance.""" + return CIRPASSValidityPeriodRule() + + def test_rule_attributes(self, rule: CIRPASSValidityPeriodRule): + """Test rule has correct attributes.""" + assert rule.rule_id == "CQ016" + assert rule.severity == "warning" + + def test_both_dates_present_no_violations(self, rule: CIRPASSValidityPeriodRule): + """Test both dates present produces no violations.""" + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + validFrom="2024-01-01T00:00:00Z", + validUntil="2034-01-01T00:00:00Z", + ) + violations = rule.check(passport) + assert len(violations) == 0 + + def test_missing_valid_from(self, rule: CIRPASSValidityPeriodRule): + """Test missing validFrom produces violation.""" + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + validUntil="2034-01-01T00:00:00Z", + ) + violations = rule.check(passport) + assert any("validFrom" in v[0] for v in violations) + + def test_missing_valid_until(self, rule: CIRPASSValidityPeriodRule): + """Test missing validUntil produces violation.""" + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + validFrom="2024-01-01T00:00:00Z", + ) + violations = rule.check(passport) + assert any("validUntil" in v[0] for v in violations) + + +class TestCIRPASSWeightVolumeRule: + """Tests for CQ020: Weight/volume declarations.""" + + @pytest.fixture + def rule(self) -> CIRPASSWeightVolumeRule: + """Create rule instance.""" + return CIRPASSWeightVolumeRule() + + def test_rule_attributes(self, rule: CIRPASSWeightVolumeRule): + """Test rule has correct attributes.""" + assert rule.rule_id == "CQ020" + assert rule.severity == "warning" + + def test_no_product_no_violations(self, rule: CIRPASSWeightVolumeRule): + """Test no product produces no violations.""" + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + ) + violations = rule.check(passport) + assert len(violations) == 0 + + def test_product_with_dimensions_no_violations(self, rule: CIRPASSWeightVolumeRule): + """Test product with dimensions produces no violations.""" + from dppvalidator.models import Dimension, Measure, Product, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credential_subject=ProductPassport( + product=Product( + id="https://example.com/product", + name="Test", + dimensions=Dimension(weight=Measure(value=1.5, unit="KGM")), + ) + ), + ) + violations = rule.check(passport) + assert len(violations) == 0 + + def test_product_without_dimensions(self, rule: CIRPASSWeightVolumeRule): + """Test product without dimensions produces violation.""" + from dppvalidator.models import Product, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credential_subject=ProductPassport( + product=Product(id="https://example.com/product", name="Test") + ), + ) + violations = rule.check(passport) + assert len(violations) == 1 + assert "dimension" in violations[0][0].lower() + + +class TestCIRPASSGranularityConsistencyRule: + """Tests for CQ017: Granularity consistency.""" + + @pytest.fixture + def rule(self) -> CIRPASSGranularityConsistencyRule: + """Create rule instance.""" + return CIRPASSGranularityConsistencyRule() + + def test_rule_attributes(self, rule: CIRPASSGranularityConsistencyRule): + """Test rule has correct attributes.""" + assert rule.rule_id == "CQ017" + assert rule.severity == "warning" + + def test_no_granularity_no_violations(self, rule: CIRPASSGranularityConsistencyRule): + """Test no granularity level produces no violations.""" + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + ) + violations = rule.check(passport) + assert len(violations) == 0 + + def test_item_level_with_serial_no_violations(self, rule: CIRPASSGranularityConsistencyRule): + """Test item level with serial number produces no violations.""" + from dppvalidator.models import GranularityLevel, Product, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credential_subject=ProductPassport( + granularity_level=GranularityLevel.ITEM, + product=Product( + id="https://example.com/product", + name="Test", + serial_number="SN123456", + ), + ), + ) + violations = rule.check(passport) + assert len(violations) == 0 + + def test_item_level_without_serial_produces_violation( + self, rule: CIRPASSGranularityConsistencyRule + ): + """Test item level without serial number produces violation.""" + from dppvalidator.models import GranularityLevel, Product, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credential_subject=ProductPassport( + granularity_level=GranularityLevel.ITEM, + product=Product(id="https://example.com/product", name="Test"), + ), + ) + violations = rule.check(passport) + assert len(violations) == 1 + assert "serialNumber" in violations[0][0] + + def test_batch_level_with_batch_number_no_violations( + self, rule: CIRPASSGranularityConsistencyRule + ): + """Test batch level with batch number produces no violations.""" + from dppvalidator.models import GranularityLevel, Product, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credential_subject=ProductPassport( + granularity_level=GranularityLevel.BATCH, + product=Product( + id="https://example.com/product", + name="Test", + batch_number="BATCH001", + ), + ), + ) + violations = rule.check(passport) + assert len(violations) == 0 + + def test_batch_level_without_batch_number_produces_violation( + self, rule: CIRPASSGranularityConsistencyRule + ): + """Test batch level without batch number produces violation.""" + from dppvalidator.models import GranularityLevel, Product, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credential_subject=ProductPassport( + granularity_level=GranularityLevel.BATCH, + product=Product(id="https://example.com/product", name="Test"), + ), + ) + violations = rule.check(passport) + assert len(violations) == 1 + assert "batchNumber" in violations[0][0] + + def test_model_level_no_violations(self, rule: CIRPASSGranularityConsistencyRule): + """Test model level produces no violations.""" + from dppvalidator.models import GranularityLevel, Product, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credential_subject=ProductPassport( + granularity_level=GranularityLevel.MODEL, + product=Product(id="https://example.com/product", name="Test"), + ), + ) + violations = rule.check(passport) + assert len(violations) == 0 diff --git a/tests/unit/test_cirpass_vocabulary.py b/tests/unit/test_cirpass_vocabulary.py new file mode 100644 index 0000000..7f1e5d9 --- /dev/null +++ b/tests/unit/test_cirpass_vocabulary.py @@ -0,0 +1,275 @@ +"""Tests for CIRPASS-2 vocabulary module.""" + +import pytest + +from dppvalidator.vocabularies.cirpass_terms import ( + CIRPASS_CORE_TERMS, + ESPR_ANNEX_I_PARAMETERS, + CIRPASSTerm, + CIRPASSVocabulary, + ESPRAnnexIParameter, + GranularityLevel, + get_cirpass_term_count, + get_espr_parameters, + is_valid_espr_parameter, + is_valid_granularity_level, +) + + +class TestGranularityLevel: + """Tests for GranularityLevel enum.""" + + def test_granularity_levels_exist(self): + """Test all granularity levels exist.""" + assert GranularityLevel.MODEL.value == "model" + assert GranularityLevel.BATCH.value == "batch" + assert GranularityLevel.PRODUCT.value == "product" + + def test_granularity_level_from_string(self): + """Test creating granularity level from string.""" + assert GranularityLevel("model") == GranularityLevel.MODEL + assert GranularityLevel("batch") == GranularityLevel.BATCH + assert GranularityLevel("product") == GranularityLevel.PRODUCT + + def test_item_is_deprecated_alias(self): + """Test ITEM is deprecated alias for PRODUCT.""" + # ITEM now maps to 'product' for backward compatibility + assert GranularityLevel.ITEM.value == "product" + + def test_invalid_granularity_level(self): + """Test invalid granularity level raises ValueError.""" + with pytest.raises(ValueError): + GranularityLevel("invalid") + + +class TestESPRAnnexIParameter: + """Tests for ESPRAnnexIParameter enum.""" + + def test_espr_parameters_exist(self): + """Test key ESPR parameters exist.""" + assert ESPRAnnexIParameter.DURABILITY.value == "durability" + assert ESPRAnnexIParameter.CARBON_FOOTPRINT.value == "carbon_footprint" + assert ESPRAnnexIParameter.RECYCLABILITY.value == "recyclability" + + def test_espr_parameter_count(self): + """Test ESPR parameter count.""" + assert len(ESPRAnnexIParameter) >= 18 + + +class TestCIRPASSTerm: + """Tests for CIRPASSTerm dataclass.""" + + def test_term_creation(self): + """Test creating a CIRPASS term.""" + term = CIRPASSTerm( + name="TestTerm", + definition="A test term definition.", + source="ESPR", + article="Art 2(1)", + ) + assert term.name == "TestTerm" + assert term.definition == "A test term definition." + assert term.source == "ESPR" + assert term.article == "Art 2(1)" + + def test_term_without_article(self): + """Test creating a term without article reference.""" + term = CIRPASSTerm( + name="TestTerm", + definition="A test definition.", + source="CIRPASS-2", + ) + assert term.article is None + + def test_term_is_frozen(self): + """Test CIRPASSTerm is immutable.""" + term = CIRPASSTerm( + name="TestTerm", + definition="A test definition.", + source="ESPR", + ) + with pytest.raises(AttributeError): + term.name = "NewName" + + +class TestCIRPASSCoreTerms: + """Tests for CIRPASS core terms dictionary.""" + + def test_core_terms_not_empty(self): + """Test core terms dictionary is not empty.""" + assert len(CIRPASS_CORE_TERMS) > 0 + + def test_core_terms_minimum_count(self): + """Test minimum number of core terms.""" + assert len(CIRPASS_CORE_TERMS) >= 20 + + def test_product_term_exists(self): + """Test Product term exists.""" + assert "Product" in CIRPASS_CORE_TERMS + product = CIRPASS_CORE_TERMS["Product"] + assert product.source == "ESPR" + assert "Art 2(1)" in product.article + + def test_model_term_exists(self): + """Test Model term exists.""" + assert "Model" in CIRPASS_CORE_TERMS + model = CIRPASS_CORE_TERMS["Model"] + assert model.source == "SR5423" + + def test_batch_term_exists(self): + """Test Batch term exists.""" + assert "Batch" in CIRPASS_CORE_TERMS + batch = CIRPASS_CORE_TERMS["Batch"] + assert batch.source == "SR5423" + + def test_item_term_exists(self): + """Test Item term exists.""" + assert "Item" in CIRPASS_CORE_TERMS + item = CIRPASS_CORE_TERMS["Item"] + assert item.source == "SR5423" + + def test_unique_product_identifier_exists(self): + """Test UniqueProductIdentifier term exists.""" + assert "UniqueProductIdentifier" in CIRPASS_CORE_TERMS + + def test_digital_product_passport_exists(self): + """Test DigitalProductPassport term exists.""" + assert "DigitalProductPassport" in CIRPASS_CORE_TERMS + + def test_all_terms_have_required_fields(self): + """Test all terms have required fields.""" + for name, term in CIRPASS_CORE_TERMS.items(): + assert term.name == name + assert term.definition + assert term.source in {"ESPR", "SR5423", "CIRPASS-2"} + + +class TestCIRPASSVocabulary: + """Tests for CIRPASSVocabulary class.""" + + def test_get_term_exists(self): + """Test getting an existing term.""" + term = CIRPASSVocabulary.get_term("Product") + assert term is not None + assert term.name == "Product" + + def test_get_term_not_exists(self): + """Test getting a non-existent term.""" + term = CIRPASSVocabulary.get_term("NonExistentTerm") + assert term is None + + def test_is_valid_term_true(self): + """Test is_valid_term returns True for valid term.""" + assert CIRPASSVocabulary.is_valid_term("Product") is True + assert CIRPASSVocabulary.is_valid_term("Model") is True + + def test_is_valid_term_false(self): + """Test is_valid_term returns False for invalid term.""" + assert CIRPASSVocabulary.is_valid_term("InvalidTerm") is False + + def test_get_terms_by_source_espr(self): + """Test getting terms by ESPR source.""" + espr_terms = CIRPASSVocabulary.get_terms_by_source("ESPR") + assert len(espr_terms) > 0 + for term in espr_terms: + assert term.source == "ESPR" + + def test_get_terms_by_source_sr5423(self): + """Test getting terms by SR5423 source.""" + sr_terms = CIRPASSVocabulary.get_terms_by_source("SR5423") + assert len(sr_terms) > 0 + for term in sr_terms: + assert term.source == "SR5423" + + def test_get_terms_by_source_cirpass2(self): + """Test getting terms by CIRPASS-2 source.""" + cp_terms = CIRPASSVocabulary.get_terms_by_source("CIRPASS-2") + assert len(cp_terms) > 0 + for term in cp_terms: + assert term.source == "CIRPASS-2" + + def test_all_term_names(self): + """Test getting all term names.""" + names = CIRPASSVocabulary.all_term_names() + assert isinstance(names, frozenset) + assert len(names) == len(CIRPASS_CORE_TERMS) + assert "Product" in names + assert "Model" in names + + +class TestGranularityValidation: + """Tests for granularity level validation.""" + + def test_valid_granularity_levels(self): + """Test valid granularity levels.""" + assert is_valid_granularity_level("model") is True + assert is_valid_granularity_level("batch") is True + assert is_valid_granularity_level("product") is True + + def test_valid_granularity_case_insensitive(self): + """Test granularity validation is case insensitive.""" + assert is_valid_granularity_level("MODEL") is True + assert is_valid_granularity_level("Batch") is True + assert is_valid_granularity_level("PRODUCT") is True + + def test_invalid_granularity_level(self): + """Test invalid granularity level.""" + assert is_valid_granularity_level("invalid") is False + assert is_valid_granularity_level("") is False + assert is_valid_granularity_level("item") is False # 'item' is no longer valid + + +class TestESPRParameterValidation: + """Tests for ESPR parameter validation.""" + + def test_valid_espr_parameters(self): + """Test valid ESPR parameters.""" + assert is_valid_espr_parameter("durability") is True + assert is_valid_espr_parameter("carbon_footprint") is True + assert is_valid_espr_parameter("recyclability") is True + + def test_espr_parameter_normalization(self): + """Test ESPR parameter normalization.""" + assert is_valid_espr_parameter("carbon-footprint") is True + assert is_valid_espr_parameter("carbon footprint") is True + assert is_valid_espr_parameter("DURABILITY") is True + + def test_invalid_espr_parameter(self): + """Test invalid ESPR parameter.""" + assert is_valid_espr_parameter("invalid_param") is False + assert is_valid_espr_parameter("") is False + + def test_get_espr_parameters(self): + """Test getting all ESPR parameters.""" + params = get_espr_parameters() + assert isinstance(params, frozenset) + assert len(params) >= 18 + assert "durability" in params + assert "carbon_footprint" in params + + +class TestESPRAnnexIParameters: + """Tests for ESPR_ANNEX_I_PARAMETERS constant.""" + + def test_parameters_is_frozenset(self): + """Test ESPR_ANNEX_I_PARAMETERS is a frozenset.""" + assert isinstance(ESPR_ANNEX_I_PARAMETERS, frozenset) + + def test_key_parameters_present(self): + """Test key parameters are present.""" + assert "durability" in ESPR_ANNEX_I_PARAMETERS + assert "reliability" in ESPR_ANNEX_I_PARAMETERS + assert "ease_of_repair" in ESPR_ANNEX_I_PARAMETERS + assert "recyclability" in ESPR_ANNEX_I_PARAMETERS + assert "carbon_footprint" in ESPR_ANNEX_I_PARAMETERS + assert "environmental_footprint" in ESPR_ANNEX_I_PARAMETERS + + +class TestTermCount: + """Tests for term count function.""" + + def test_get_cirpass_term_count(self): + """Test getting CIRPASS term count.""" + count = get_cirpass_term_count() + assert count >= 20 + assert count == len(CIRPASS_CORE_TERMS) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 6f1b294..d09f486 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -5,6 +5,26 @@ from dppvalidator.cli.main import EXIT_ERROR, EXIT_INVALID, EXIT_VALID, create_parser, main +def _valid_dpp() -> dict: + """Return CIRPASS-compliant DPP data for CLI tests.""" + return { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ], + "type": ["DigitalProductPassport", "VerifiableCredential"], + "id": "https://example.com/dpp", + "issuer": {"id": "https://example.com/issuer", "name": "Test"}, + "validFrom": "2024-01-01T00:00:00Z", + "validUntil": "2034-01-01T00:00:00Z", + "credentialSubject": { + "id": "https://example.com/subject/001", + "type": ["ProductPassport"], + "product": {"id": "https://example.com/products/001", "name": "Test Product"}, + }, + } + + class TestCLIParser: """Tests for CLI argument parser.""" @@ -62,16 +82,8 @@ class TestValidateCommand: def test_validate_valid_file(self, tmp_path): """Test validating a valid passport file.""" - passport_data = { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", - ], - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) + file_path.write_text(json.dumps(_valid_dpp()), encoding="utf-8") result = main(["validate", str(file_path)]) assert result == EXIT_VALID @@ -80,7 +92,7 @@ def test_validate_invalid_file(self, tmp_path): """Test validating an invalid passport file.""" passport_data = {"invalid": "data"} file_path = tmp_path / "invalid.json" - file_path.write_text(json.dumps(passport_data)) + file_path.write_text(json.dumps(passport_data), encoding="utf-8") result = main(["validate", str(file_path)]) assert result == EXIT_INVALID @@ -92,16 +104,8 @@ def test_validate_nonexistent_file(self): def test_validate_with_format_json(self, tmp_path, capsys): """Test validate with JSON format output.""" - passport_data = { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", - ], - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) + file_path.write_text(json.dumps(_valid_dpp()), encoding="utf-8") result = main(["validate", str(file_path), "--format", "json"]) captured = capsys.readouterr() @@ -112,20 +116,12 @@ def test_validate_with_format_json(self, tmp_path, capsys): def test_validate_strict_mode(self, tmp_path): """Test validate with strict mode.""" - passport_data = { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", - ], - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) + file_path.write_text(json.dumps(_valid_dpp()), encoding="utf-8") result = main(["validate", str(file_path), "--strict"]) - # Should pass since minimal passport is valid - assert result in (EXIT_VALID, EXIT_INVALID) + # Should pass since CIRPASS-compliant passport is valid + assert result == EXIT_VALID class TestExportCommand: @@ -133,16 +129,8 @@ class TestExportCommand: def test_export_to_stdout(self, tmp_path, capsys): """Test export to stdout.""" - passport_data = { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", - ], - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) + file_path.write_text(json.dumps(_valid_dpp()), encoding="utf-8") result = main(["export", str(file_path)]) captured = capsys.readouterr() @@ -152,36 +140,20 @@ def test_export_to_stdout(self, tmp_path, capsys): def test_export_to_file(self, tmp_path): """Test export to file.""" - passport_data = { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", - ], - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } input_path = tmp_path / "passport.json" - input_path.write_text(json.dumps(passport_data)) + input_path.write_text(json.dumps(_valid_dpp()), encoding="utf-8") output_path = tmp_path / "output.jsonld" result = main(["export", str(input_path), "-o", str(output_path)]) assert result == EXIT_VALID assert output_path.exists() - content = json.loads(output_path.read_text()) + content = json.loads(output_path.read_text(encoding="utf-8")) assert "@context" in content def test_export_json_format(self, tmp_path, capsys): """Test export with JSON format.""" - passport_data = { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", - ], - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) + file_path.write_text(json.dumps(_valid_dpp()), encoding="utf-8") result = main(["export", str(file_path), "--format", "json"]) captured = capsys.readouterr() @@ -280,7 +252,7 @@ def test_validate_with_table_format(self, tmp_path, capsys): # noqa: ARG002 "issuer": {"id": "https://example.com/issuer", "name": "Test"}, } file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) + file_path.write_text(json.dumps(passport_data), encoding="utf-8") result = main(["validate", str(file_path), "--format", "table"]) assert result in (EXIT_VALID, EXIT_INVALID) @@ -309,6 +281,92 @@ def test_validate_stdin(self, tmp_path, monkeypatch): # noqa: ARG002 result = main(["validate", "-"]) assert result in (EXIT_VALID, EXIT_INVALID, EXIT_ERROR) + def test_validate_multiple_files(self, tmp_path): + """Test validate with multiple files.""" + # Create two valid DPP files + for i in range(2): + file_path = tmp_path / f"passport{i}.json" + file_path.write_text(json.dumps(_valid_dpp()), encoding="utf-8") + + result = main( + ["validate", str(tmp_path / "passport0.json"), str(tmp_path / "passport1.json")] + ) + assert result == EXIT_VALID + + def test_validate_glob_pattern(self, tmp_path): + """Test validate with glob pattern.""" + # Create multiple DPP files + for i in range(3): + file_path = tmp_path / f"dpp_{i}.json" + file_path.write_text(json.dumps(_valid_dpp()), encoding="utf-8") + + result = main(["validate", str(tmp_path / "dpp_*.json")]) + assert result == EXIT_VALID + + def test_validate_glob_no_match(self, tmp_path, capsys): + """Test validate with glob pattern that matches nothing.""" + result = main(["validate", str(tmp_path / "nonexistent_*.json")]) + captured = capsys.readouterr() + assert result == EXIT_ERROR + assert "No files match" in captured.err + + def test_validate_mixed_valid_invalid(self, tmp_path): + """Test validate with mix of valid and invalid files.""" + # Create one valid file + valid_file = tmp_path / "valid.json" + valid_file.write_text(json.dumps(_valid_dpp()), encoding="utf-8") + + # Create one invalid file (missing required fields) + invalid_file = tmp_path / "invalid.json" + invalid_file.write_text( + json.dumps({"id": "https://x.com", "issuer": {"id": "https://x.com", "name": "T"}}), + encoding="utf-8", + ) + + result = main(["validate", str(valid_file), str(invalid_file)]) + assert result == EXIT_INVALID + + def test_validate_batch_json_output(self, tmp_path, capsys): + """Test validate batch output in JSON format.""" + for i in range(2): + file_path = tmp_path / f"p{i}.json" + file_path.write_text(json.dumps(_valid_dpp()), encoding="utf-8") + + result = main(["validate", str(tmp_path / "p*.json"), "--format", "json"]) + captured = capsys.readouterr() + assert result == EXIT_VALID + output = json.loads(captured.out) + assert "files" in output + assert "summary" in output + assert output["summary"]["total"] == 2 + assert output["summary"]["valid"] == 2 + + def test_validate_batch_table_output(self, tmp_path, capsys): + """Test validate batch output in table format.""" + for i in range(2): + file_path = tmp_path / f"t{i}.json" + file_path.write_text(json.dumps(_valid_dpp()), encoding="utf-8") + + result = main(["validate", str(tmp_path / "t*.json"), "--format", "table"]) + captured = capsys.readouterr() + assert result == EXIT_VALID + assert "Batch Validation Results" in captured.out + assert "Total:" in captured.out + + def test_validate_glob_with_backslash_path(self, tmp_path): + """Test glob pattern with Windows-style backslashes (cross-platform).""" + # Create files in a subdirectory + subdir = tmp_path / "data" + subdir.mkdir() + for i in range(2): + file_path = subdir / f"win_{i}.json" + file_path.write_text(json.dumps(_valid_dpp()), encoding="utf-8") + + # Use backslash path (Windows-style) - should work on all platforms + pattern = str(subdir).replace("/", "\\") + "\\win_*.json" + result = main(["validate", pattern]) + assert result == EXIT_VALID + class TestExportCommandExtended: """Extended tests for export command.""" @@ -328,16 +386,8 @@ def test_export_invalid_json(self, tmp_path): def test_export_with_format_jsonld(self, tmp_path, capsys): """Test export with jsonld format.""" - passport_data = { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", - ], - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) + file_path.write_text(json.dumps(_valid_dpp()), encoding="utf-8") result = main(["export", str(file_path), "--format", "jsonld"]) captured = capsys.readouterr() @@ -355,16 +405,8 @@ def test_main_verbose_error(self): def test_main_quiet_mode(self, tmp_path): """Test quiet mode.""" - passport_data = { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", - ], - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) + file_path.write_text(json.dumps(_valid_dpp()), encoding="utf-8") result = main(["--quiet", "validate", str(file_path)]) assert result == EXIT_VALID @@ -474,16 +516,8 @@ class TestValidateCommandCoverage: def test_validate_with_format_json(self, tmp_path, capsys): """Test validate with JSON output format.""" - passport_data = { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", - ], - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) + file_path.write_text(json.dumps(_valid_dpp()), encoding="utf-8") result = main(["validate", str(file_path), "--format", "json"]) captured = capsys.readouterr() @@ -498,7 +532,7 @@ def test_validate_with_strict_mode(self, tmp_path, capsys): # noqa: ARG002 "issuer": {"id": "https://example.com/issuer", "name": "Test"}, } file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) + file_path.write_text(json.dumps(passport_data), encoding="utf-8") result = main(["validate", str(file_path), "--strict"]) assert result in (EXIT_VALID, EXIT_INVALID) @@ -536,9 +570,7 @@ def test_schema_info_valid_version(self): console = Console(file=stream) result = schema_module.run(args, console) - output = stream.getvalue() assert result == EXIT_VALID - assert "0.6.1" in output def test_schema_info_unknown_version_error(self, capsys): """Test schema info with unknown version returns error.""" @@ -595,7 +627,7 @@ def test_export_invalid_dpp_shows_errors(self, tmp_path, capsys): """Test export with invalid DPP shows validation errors.""" passport_data = {"invalid": "data", "no_issuer": True} file_path = tmp_path / "invalid.json" - file_path.write_text(json.dumps(passport_data)) + file_path.write_text(json.dumps(passport_data), encoding="utf-8") result = main(["export", str(file_path)]) captured = capsys.readouterr() @@ -604,16 +636,8 @@ def test_export_invalid_dpp_shows_errors(self, tmp_path, capsys): def test_export_compact_output(self, tmp_path, capsys): """Test export with --compact flag produces minimal formatting.""" - passport_data = { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", - ], - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) + file_path.write_text(json.dumps(_valid_dpp()), encoding="utf-8") result = main(["export", str(file_path), "--compact"]) captured = capsys.readouterr() @@ -631,7 +655,7 @@ def test_export_to_json(self, tmp_path, capsys): "issuer": {"id": "https://example.com/issuer", "name": "Test"}, } file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) + file_path.write_text(json.dumps(passport_data), encoding="utf-8") result = main(["export", str(file_path), "--format", "json"]) _captured = capsys.readouterr() @@ -645,7 +669,7 @@ def test_export_to_jsonld(self, tmp_path, capsys): # noqa: ARG002 "issuer": {"id": "https://example.com/issuer", "name": "Test"}, } file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) + file_path.write_text(json.dumps(passport_data), encoding="utf-8") result = main(["export", str(file_path), "--format", "jsonld"]) assert result in (EXIT_VALID, EXIT_ERROR) @@ -657,7 +681,7 @@ def test_export_to_file(self, tmp_path, capsys): # noqa: ARG002 "issuer": {"id": "https://example.com/issuer", "name": "Test"}, } file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) + file_path.write_text(json.dumps(passport_data), encoding="utf-8") output_path = tmp_path / "output.json" result = main(["export", str(file_path), "-o", str(output_path)]) @@ -669,16 +693,8 @@ class TestValidateCommandBehavior: def test_validate_returns_structured_json_output(self, tmp_path, capsys): """Test that --format json returns properly structured output.""" - passport_data = { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", - ], - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test Corp"}, - } file_path = tmp_path / "passport.json" - file_path.write_text(json.dumps(passport_data)) + file_path.write_text(json.dumps(_valid_dpp()), encoding="utf-8") result = main(["validate", str(file_path), "--format", "json"]) captured = capsys.readouterr() @@ -694,7 +710,7 @@ def test_validate_invalid_data_json_format(self, tmp_path, capsys): """Test invalid data returns structured errors in JSON format.""" passport_data = {"missing": "issuer"} file_path = tmp_path / "invalid.json" - file_path.write_text(json.dumps(passport_data)) + file_path.write_text(json.dumps(passport_data), encoding="utf-8") result = main(["validate", str(file_path), "--format", "json"]) captured = capsys.readouterr() @@ -711,7 +727,7 @@ def test_validate_multiple_errors_collected(self, tmp_path, capsys): "validUntil": "2024-01-01T00:00:00Z", } file_path = tmp_path / "multi_error.json" - file_path.write_text(json.dumps(passport_data)) + file_path.write_text(json.dumps(passport_data), encoding="utf-8") result = main(["validate", str(file_path), "--format", "json"]) captured = capsys.readouterr() diff --git a/tests/unit/test_cli_commands.py b/tests/unit/test_cli_commands.py index e9fba56..59fd5af 100644 --- a/tests/unit/test_cli_commands.py +++ b/tests/unit/test_cli_commands.py @@ -33,8 +33,16 @@ def test_precommit_valid_file_returns_success(self, tmp_path): "https://www.w3.org/ns/credentials/v2", "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", ], + "type": ["DigitalProductPassport", "VerifiableCredential"], "id": "https://example.com/dpp", "issuer": {"id": "https://example.com/issuer", "name": "Test"}, + "validFrom": "2024-01-01T00:00:00Z", + "validUntil": "2034-01-01T00:00:00Z", + "credentialSubject": { + "id": "https://example.com/subject/001", + "type": ["ProductPassport"], + "product": {"id": "https://example.com/products/001", "name": "Test"}, + }, } ) ) @@ -104,6 +112,41 @@ def test_precommit_fail_on_warning(self, tmp_path): result = main(["--fail-on-warning", str(dpp_file)]) assert result in (0, 1) + def test_precommit_fail_on_warning_with_warnings(self, tmp_path): + """Pre-commit with --fail-on-warning fails when warnings are present.""" + from unittest.mock import MagicMock, patch + + from dppvalidator.cli.commands.precommit import main + from dppvalidator.validators.results import ValidationError, ValidationResult + + dpp_file = tmp_path / "passport.json" + dpp_file.write_text( + json.dumps({"id": "https://example.com/dpp", "type": ["DigitalProductPassport"]}) + ) + + # Create a mock result that is valid but has warnings + mock_result = ValidationResult( + valid=True, + warnings=[ + ValidationError( + path="$.field", + message="This is a warning", + code="WARN001", + layer="semantic", + severity="warning", + ) + ], + schema_version="0.6.1", + ) + + with patch("dppvalidator.cli.commands.precommit.ValidationEngine") as mock_engine: + mock_instance = MagicMock() + mock_instance.validate_file.return_value = mock_result + mock_engine.return_value = mock_instance + + result = main(["--fail-on-warning", str(dpp_file)]) + assert result == 1 # Should fail due to warnings + def test_precommit_multiple_files(self, tmp_path): """Pre-commit validates multiple files.""" from dppvalidator.cli.commands.precommit import main @@ -658,7 +701,9 @@ def test_init_full_template(self, tmp_path): result = run(args, console) assert result == 0 - dpp_content = json.loads((project_dir / "data" / "sample_passport.json").read_text()) + dpp_content = json.loads( + (project_dir / "data" / "sample_passport.json").read_text(encoding="utf-8") + ) assert "materialsProvenance" in dpp_content.get("credentialSubject", {}) def test_init_with_config(self, tmp_path): @@ -745,7 +790,7 @@ def test_init_existing_files_skipped(self, tmp_path): result = run(args, console) assert result == 0 - assert (project_dir / ".gitignore").read_text() == "existing content" + assert (project_dir / ".gitignore").read_text(encoding="utf-8") == "existing content" def test_init_force_overwrites(self, tmp_path): """Init with --force overwrites existing files.""" @@ -769,7 +814,7 @@ def test_init_force_overwrites(self, tmp_path): result = run(args, console) assert result == 0 - assert (project_dir / ".gitignore").read_text() != "old content" + assert (project_dir / ".gitignore").read_text(encoding="utf-8") != "old content" def test_init_no_readme(self, tmp_path): """Init with --no-readme skips README creation.""" @@ -855,7 +900,7 @@ def test_init_existing_dpp_skipped(self, tmp_path): result = run(args, console) assert result == 0 - content = json.loads((data_dir / "sample_passport.json").read_text()) + content = json.loads((data_dir / "sample_passport.json").read_text(encoding="utf-8")) assert content == {"existing": True} def test_init_existing_readme_skipped(self, tmp_path): @@ -880,7 +925,7 @@ def test_init_existing_readme_skipped(self, tmp_path): result = run(args, console) assert result == 0 - assert "Existing README" in (project_dir / "README.md").read_text() + assert "Existing README" in (project_dir / "README.md").read_text(encoding="utf-8") def test_init_existing_config_skipped(self, tmp_path): """Init skips existing .dppvalidator.json.""" @@ -904,7 +949,7 @@ def test_init_existing_config_skipped(self, tmp_path): result = run(args, console) assert result == 0 - content = json.loads((project_dir / ".dppvalidator.json").read_text()) + content = json.loads((project_dir / ".dppvalidator.json").read_text(encoding="utf-8")) assert content == {"existing": True} def test_init_existing_precommit_skipped(self, tmp_path): @@ -913,7 +958,7 @@ def test_init_existing_precommit_skipped(self, tmp_path): project_dir = tmp_path / "project" project_dir.mkdir() - (project_dir / ".pre-commit-config.yaml").write_text("repos: []") + (project_dir / ".pre-commit-config.yaml").write_text("repos: []", encoding="utf-8") args = argparse.Namespace( path=str(project_dir), @@ -929,7 +974,7 @@ def test_init_existing_precommit_skipped(self, tmp_path): result = run(args, console) assert result == 0 - assert "repos: []" in (project_dir / ".pre-commit-config.yaml").read_text() + assert "repos: []" in (project_dir / ".pre-commit-config.yaml").read_text(encoding="utf-8") def test_init_all_files_skipped_message(self, tmp_path): """Init shows message when no files created.""" diff --git a/tests/unit/test_cli_init.py b/tests/unit/test_cli_init.py index a00785d..7780c63 100644 --- a/tests/unit/test_cli_init.py +++ b/tests/unit/test_cli_init.py @@ -116,7 +116,7 @@ def test_init_creates_sample_dpp(self, tmp_path: Path) -> None: dpp_file = tmp_path / "data" / "sample_passport.json" assert dpp_file.exists() - content = json.loads(dpp_file.read_text()) + content = json.loads(dpp_file.read_text(encoding="utf-8")) assert "type" in content assert "DigitalProductPassport" in content["type"] @@ -137,7 +137,7 @@ def test_init_creates_gitignore(self, tmp_path: Path) -> None: gitignore = tmp_path / ".gitignore" assert gitignore.exists() - assert ".dppvalidator/" in gitignore.read_text() + assert ".dppvalidator/" in gitignore.read_text(encoding="utf-8") def test_init_creates_readme(self, tmp_path: Path) -> None: """Init should create README.md.""" @@ -156,7 +156,7 @@ def test_init_creates_readme(self, tmp_path: Path) -> None: readme = tmp_path / "README.md" assert readme.exists() - assert "TestProject" in readme.read_text() + assert "TestProject" in readme.read_text(encoding="utf-8") def test_init_creates_config(self, tmp_path: Path) -> None: """Init should create config file when requested.""" @@ -176,7 +176,7 @@ def test_init_creates_config(self, tmp_path: Path) -> None: config_file = tmp_path / ".dppvalidator.json" assert config_file.exists() - content = json.loads(config_file.read_text()) + content = json.loads(config_file.read_text(encoding="utf-8")) assert "validation" in content def test_init_creates_precommit(self, tmp_path: Path) -> None: @@ -196,7 +196,7 @@ def test_init_creates_precommit(self, tmp_path: Path) -> None: precommit = tmp_path / ".pre-commit-config.yaml" assert precommit.exists() - assert "dppvalidator" in precommit.read_text() + assert "dppvalidator" in precommit.read_text(encoding="utf-8") def test_init_skips_existing_files(self, tmp_path: Path) -> None: """Init should skip existing files without --force.""" @@ -205,7 +205,7 @@ def test_init_skips_existing_files(self, tmp_path: Path) -> None: # Create existing file (tmp_path / "data").mkdir() existing = tmp_path / "data" / "sample_passport.json" - existing.write_text('{"existing": true}') + existing.write_text('{"existing": true}', encoding="utf-8") args = MagicMock() args.path = str(tmp_path) @@ -219,7 +219,7 @@ def test_init_skips_existing_files(self, tmp_path: Path) -> None: run(args, console) # Should not overwrite - content = json.loads(existing.read_text()) + content = json.loads(existing.read_text(encoding="utf-8")) assert content == {"existing": True} def test_init_overwrites_with_force(self, tmp_path: Path) -> None: @@ -229,7 +229,7 @@ def test_init_overwrites_with_force(self, tmp_path: Path) -> None: # Create existing file (tmp_path / "data").mkdir() existing = tmp_path / "data" / "sample_passport.json" - existing.write_text('{"existing": true}') + existing.write_text('{"existing": true}', encoding="utf-8") args = MagicMock() args.path = str(tmp_path) @@ -243,7 +243,7 @@ def test_init_overwrites_with_force(self, tmp_path: Path) -> None: run(args, console) # Should be overwritten - content = json.loads(existing.read_text()) + content = json.loads(existing.read_text(encoding="utf-8")) assert "type" in content def test_init_invalid_project_name(self, tmp_path: Path) -> None: diff --git a/tests/unit/test_cli_migrate.py b/tests/unit/test_cli_migrate.py new file mode 100644 index 0000000..ebd4536 --- /dev/null +++ b/tests/unit/test_cli_migrate.py @@ -0,0 +1,244 @@ +"""CLI tests for ``dppvalidator migrate`` and ``validate --upgrade-from``. + +Phase 4 wires the compat shim into two CLI surfaces: + +- ``dppvalidator migrate`` writes the upgraded JSON to ``-o`` / + ``--in-place`` / stdout; refuses to write when warnings fire unless + ``--accept-warnings`` is set; emits a sidecar ``.warnings.json`` file + whenever any non-info warning is recorded. +- ``dppvalidator validate --upgrade-from `` runs the shim before + validating, surfacing both upgrade warnings and validation issues in + one report. + +These tests exercise both surfaces end-to-end via the public ``main`` +entry point, so they double as integration coverage for the dispatch +table and arg-parser wiring. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from dppvalidator.cli.main import main + + +@pytest.fixture +def v06_payload(tmp_path: Path) -> Path: + """Write a minimal v0.6 DPP fixture for the CLI to read. + + The fixture intentionally carries an unconditional v0.7-only + blocking transformation (``Product.registeredId`` → UPG001 lossy) + so we can exercise the warnings-block path. To exercise the + no-warnings path, individual tests pop the offending field before + invoking the CLI. + """ + data: dict[str, Any] = { + "type": ["DigitalProductPassport", "VerifiableCredential"], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ], + "id": "https://example.com/credentials/test", + "name": "Sample DPP", # Pre-populated to avoid UPG002 from name synthesis. + "issuer": {"id": "did:example:1", "name": "Example"}, + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "type": ["ProductPassport"], + "id": "https://example.com/subject/1", + "product": { + "type": ["Product"], + "id": "https://example.com/products/1", + "name": "Sample", + "registeredId": "ABN-123", + }, + }, + } + path = tmp_path / "input.json" + path.write_text(json.dumps(data), encoding="utf-8") + return path + + +# --------------------------------------------------------------------------- +# `migrate` command +# --------------------------------------------------------------------------- + + +class TestMigrateCommand: + """Acceptance tests for ``dppvalidator migrate``.""" + + def test_writes_to_stdout_when_no_output_path( + self, v06_payload: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + # Drop the registeredId line so the payload upgrades without + # blocking warnings (registeredId fires UPG001). + data = json.loads(v06_payload.read_text()) + data["credentialSubject"]["product"].pop("registeredId", None) + v06_payload.write_text(json.dumps(data), encoding="utf-8") + exit_code = main(["migrate", str(v06_payload)]) + captured = capsys.readouterr() + assert exit_code == 0 + # Stdout should contain serialised JSON of the upgraded payload. + assert "vocabulary.uncefact.org/untp/0.7" in captured.out + + def test_writes_to_explicit_output_file(self, v06_payload: Path, tmp_path: Path) -> None: + data = json.loads(v06_payload.read_text()) + data["credentialSubject"]["product"].pop("registeredId", None) + v06_payload.write_text(json.dumps(data), encoding="utf-8") + out = tmp_path / "upgraded.json" + exit_code = main(["migrate", str(v06_payload), "-o", str(out)]) + assert exit_code == 0 + assert out.is_file() + upgraded = json.loads(out.read_text()) + assert any("vocabulary.uncefact.org/untp/0.7" in c for c in upgraded["@context"]) + + def test_in_place_overwrites_input(self, v06_payload: Path) -> None: + data = json.loads(v06_payload.read_text()) + data["credentialSubject"]["product"].pop("registeredId", None) + v06_payload.write_text(json.dumps(data), encoding="utf-8") + exit_code = main(["migrate", str(v06_payload), "--in-place"]) + assert exit_code == 0 + upgraded = json.loads(v06_payload.read_text()) + assert any("vocabulary.uncefact.org/untp/0.7" in c for c in upgraded["@context"]) + + def test_in_place_and_output_are_mutually_exclusive( + self, v06_payload: Path, tmp_path: Path + ) -> None: + out = tmp_path / "x.json" + exit_code = main( + ["migrate", str(v06_payload), "--in-place", "-o", str(out)], + ) + assert exit_code != 0 + + def test_refuses_to_write_when_warnings_fire(self, v06_payload: Path, tmp_path: Path) -> None: + # The fixture has registeredId → UPG001 (lossy/warning) — without + # --accept-warnings the command must refuse. + out = tmp_path / "upgraded.json" + exit_code = main(["migrate", str(v06_payload), "-o", str(out)]) + assert exit_code == 1 + # Sidecar must always be written when blocking warnings fire. + sidecar = out.with_suffix(out.suffix + ".warnings.json") + assert sidecar.is_file(), "sidecar warnings file must be written" + # Main output file must NOT be written. + assert not out.is_file() + + def test_accept_warnings_lets_write_proceed(self, v06_payload: Path, tmp_path: Path) -> None: + out = tmp_path / "upgraded.json" + exit_code = main( + ["migrate", str(v06_payload), "-o", str(out), "--accept-warnings"], + ) + assert exit_code == 0 + assert out.is_file() + sidecar = out.with_suffix(out.suffix + ".warnings.json") + assert sidecar.is_file() + sidecar_data = json.loads(sidecar.read_text()) + assert sidecar_data["schema_version_from"].startswith("0.6") + assert any(w["code"].startswith("UPG") for w in sidecar_data["warnings"]) + + def test_rejects_unknown_source_version(self, v06_payload: Path) -> None: + exit_code = main( + ["migrate", str(v06_payload), "--from", "0.5.0"], + ) + assert exit_code != 0 + + def test_missing_input_file_returns_error(self, tmp_path: Path) -> None: + missing = tmp_path / "nope.json" + exit_code = main(["migrate", str(missing)]) + assert exit_code != 0 + + def test_invalid_json_input_returns_error(self, tmp_path: Path) -> None: + bad = tmp_path / "bad.json" + bad.write_text("{ this is not json", encoding="utf-8") + exit_code = main(["migrate", str(bad)]) + assert exit_code != 0 + + def test_in_place_with_stdin_is_rejected( + self, + monkeypatch: pytest.MonkeyPatch, + v06_payload: Path, # noqa: ARG002 — fixture only here for parser order + ) -> None: + # ``--in-place`` requires a real file path; ``-`` (stdin) cannot + # be written back to. + import io + + monkeypatch.setattr("sys.stdin", io.StringIO("{}")) + exit_code = main(["migrate", "-", "--in-place"]) + assert exit_code != 0 + + def test_stdin_input_works( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + """Reading from stdin and writing to stdout is the pipe-friendly path.""" + import io + + payload: dict[str, Any] = { + "type": ["DigitalProductPassport", "VerifiableCredential"], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ], + "id": "https://example.com/credentials/test", + "name": "From stdin", + "issuer": {"id": "did:example:1", "name": "Example"}, + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "type": ["ProductPassport"], + "id": "https://example.com/subject/1", + "product": { + "type": ["Product"], + "id": "https://example.com/products/1", + "name": "Sample", + }, + }, + } + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(payload))) + exit_code = main(["migrate", "-"]) + captured = capsys.readouterr() + assert exit_code == 0 + assert "vocabulary.uncefact.org/untp/0.7" in captured.out + + +# --------------------------------------------------------------------------- +# `validate --upgrade-from` flag +# --------------------------------------------------------------------------- + + +class TestValidateUpgradeFrom: + """``dppvalidator validate --upgrade-from`` runs the shim then validates.""" + + def test_flag_is_accepted(self, v06_payload: Path, capsys: pytest.CaptureFixture[str]) -> None: + # We don't care about the exit code (the upgraded fixture is + # likely still invalid against the v0.7 schema due to required + # fields the shim can't synthesise) — only that the flag is + # accepted and the shim runs. + main( + [ + "validate", + str(v06_payload), + "--upgrade-from", + "0.6.1", + "--schema-version", + "0.6.1", # not the target — we're only sanity-checking flag wiring + ] + ) + captured = capsys.readouterr() + # The upgrade-warnings header should appear in the output. + assert "Upgrade warnings" in captured.out or "UPG" in captured.out + + def test_no_flag_means_no_shim( + self, v06_payload: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + main( + [ + "validate", + str(v06_payload), + "--schema-version", + "0.6.1", + ] + ) + captured = capsys.readouterr() + assert "Upgrade warnings" not in captured.out + assert "UPG" not in captured.out diff --git a/tests/unit/test_code_lists.py b/tests/unit/test_code_lists.py new file mode 100644 index 0000000..b98d807 --- /dev/null +++ b/tests/unit/test_code_lists.py @@ -0,0 +1,285 @@ +"""Unit tests for extended code list validation.""" + +from dppvalidator.vocabularies.code_lists import ( + extract_gtin_from_gs1_digital_link, + get_hs_chapter_description, + get_hs_codes, + get_material_codes, + is_textile_hs_code, + is_valid_gs1_digital_link, + is_valid_hs_code, + is_valid_material_code, + validate_gtin, +) + + +class TestMaterialCodes: + """Tests for UNECE Rec 46 material code validation.""" + + def test_valid_material_codes(self) -> None: + """Valid material codes are accepted.""" + assert is_valid_material_code("COTTON") is True + assert is_valid_material_code("POLYESTER") is True + assert is_valid_material_code("WOOL") is True + assert is_valid_material_code("SILK") is True + + def test_case_insensitive(self) -> None: + """Material codes are case-insensitive.""" + assert is_valid_material_code("cotton") is True + assert is_valid_material_code("Cotton") is True + assert is_valid_material_code("COTTON") is True + + def test_normalized_codes(self) -> None: + """Material codes with spaces/hyphens are normalized.""" + assert is_valid_material_code("RECYCLED COTTON") is True + assert is_valid_material_code("RECYCLED-COTTON") is True + assert is_valid_material_code("recycled_cotton") is True + + def test_invalid_material_codes(self) -> None: + """Invalid material codes are rejected.""" + assert is_valid_material_code("UNKNOWN_MATERIAL") is False + assert is_valid_material_code("XYZ123") is False + assert is_valid_material_code("") is False + + def test_get_material_codes_returns_frozenset(self) -> None: + """get_material_codes returns a frozenset.""" + codes = get_material_codes() + assert isinstance(codes, frozenset) + assert len(codes) > 0 + assert "COTTON" in codes + + +class TestHSCodes: + """Tests for HS (Harmonized System) code validation.""" + + def test_valid_hs_codes(self) -> None: + """Valid textile HS codes are accepted.""" + assert is_valid_hs_code("5201") is True # Cotton + assert is_valid_hs_code("5208") is True # Cotton woven + assert is_valid_hs_code("6101") is True # Apparel knitted + + def test_hs_code_with_dots(self) -> None: + """HS codes with dots are normalized.""" + assert is_valid_hs_code("52.01") is True + assert is_valid_hs_code("52.08") is True + + def test_invalid_hs_codes(self) -> None: + """Invalid HS codes are rejected.""" + assert is_valid_hs_code("9999") is False + assert is_valid_hs_code("1234") is False + assert is_valid_hs_code("") is False + + def test_is_textile_hs_code(self) -> None: + """Textile chapter detection works.""" + assert is_textile_hs_code("5201") is True # Chapter 52 + assert is_textile_hs_code("6301") is True # Chapter 63 + assert is_textile_hs_code("5001") is True # Chapter 50 + assert is_textile_hs_code("4901") is False # Chapter 49 (not textile) + assert is_textile_hs_code("6401") is False # Chapter 64 (not textile) + + def test_get_hs_codes_returns_frozenset(self) -> None: + """get_hs_codes returns a frozenset.""" + codes = get_hs_codes() + assert isinstance(codes, frozenset) + assert len(codes) > 0 + assert "5201" in codes + + def test_get_hs_chapter_description(self) -> None: + """Chapter descriptions are returned correctly.""" + assert get_hs_chapter_description("5201") == "Cotton" + assert get_hs_chapter_description("5101") == "Wool, fine or coarse animal hair" + assert get_hs_chapter_description("6101") == "Articles of apparel, knitted or crocheted" + assert get_hs_chapter_description("9901") is None + + +class TestGTINValidation: + """Tests for GS1 GTIN checksum validation.""" + + def test_valid_gtin13(self) -> None: + """Valid GTIN-13 numbers pass checksum.""" + # Example valid GTIN-13 + assert validate_gtin("5901234123457") is True + assert validate_gtin("4006381333931") is True + + def test_valid_gtin8(self) -> None: + """Valid GTIN-8 numbers pass checksum.""" + assert validate_gtin("96385074") is True + + def test_valid_gtin14(self) -> None: + """Valid GTIN-14 numbers pass checksum.""" + assert validate_gtin("10614141000415") is True + + def test_invalid_gtin_checksum(self) -> None: + """Invalid GTIN checksums are rejected.""" + # Same as valid but last digit changed + assert validate_gtin("5901234123458") is False + assert validate_gtin("4006381333932") is False + + def test_invalid_gtin_length(self) -> None: + """Invalid GTIN lengths are rejected.""" + assert validate_gtin("123") is False + assert validate_gtin("12345678901234567") is False + assert validate_gtin("") is False + + def test_gtin_with_non_digits(self) -> None: + """Non-digit characters are stripped.""" + # Valid GTIN-13 with formatting + assert validate_gtin("590-123-412345-7") is True + + +class TestGS1DigitalLink: + """Tests for GS1 Digital Link URL handling.""" + + def test_extract_gtin_from_url(self) -> None: + """GTIN is extracted from GS1 Digital Link URL.""" + url = "https://id.gs1.org/01/09506000134352" + assert extract_gtin_from_gs1_digital_link(url) == "09506000134352" + + def test_extract_gtin_with_additional_path(self) -> None: + """GTIN is extracted even with additional path segments.""" + url = "https://example.com/01/09506000134352/21/12345" + assert extract_gtin_from_gs1_digital_link(url) == "09506000134352" + + def test_extract_gtin_no_match(self) -> None: + """None is returned when no GTIN found.""" + assert extract_gtin_from_gs1_digital_link("https://example.com") is None + assert extract_gtin_from_gs1_digital_link("https://example.com/product/123") is None + + def test_is_valid_gs1_digital_link(self) -> None: + """Valid GS1 Digital Links are accepted.""" + # This requires a valid GTIN with correct checksum + # Using a made-up but checksum-valid GTIN + valid_url = "https://id.gs1.org/01/5901234123457" + assert is_valid_gs1_digital_link(valid_url) is True + + def test_invalid_gs1_digital_link(self) -> None: + """Invalid GS1 Digital Links are rejected.""" + invalid_url = "https://id.gs1.org/01/5901234123458" # Bad checksum + assert is_valid_gs1_digital_link(invalid_url) is False + + +class TestVocabularyModuleExports: + """Tests for vocabulary module exports.""" + + def test_code_lists_exported(self) -> None: + """Code list functions are exported from vocabularies.code_lists submodule.""" + from dppvalidator.vocabularies import ( + is_valid_hs_code, + is_valid_material_code, + validate_gtin, + ) + from dppvalidator.vocabularies.code_lists import ( + get_hs_codes, + get_material_codes, + is_textile_hs_code, + is_valid_gs1_digital_link, + ) + + assert callable(get_material_codes) + assert callable(get_hs_codes) + assert callable(is_valid_material_code) + assert callable(is_valid_hs_code) + assert callable(is_textile_hs_code) + assert callable(validate_gtin) + assert callable(is_valid_gs1_digital_link) + + +class TestValidationRulesExported: + """Tests for validation rules being exported.""" + + def test_voc_rules_in_all_rules(self) -> None: + """VOC003-VOC005 rules are in ALL_RULES.""" + from dppvalidator.validators.rules import ALL_RULES + + rule_ids = [rule.rule_id for rule in ALL_RULES] + assert "VOC003" in rule_ids + assert "VOC004" in rule_ids + assert "VOC005" in rule_ids + + def test_rule_classes_exported(self) -> None: + """Rule classes are exported from rules module.""" + from dppvalidator.validators.rules import ( + GTINChecksumRule, + HSCodeRule, + MaterialCodeRule, + ) + + assert MaterialCodeRule.rule_id == "VOC003" + assert HSCodeRule.rule_id == "VOC004" + assert GTINChecksumRule.rule_id == "VOC005" + + +class TestCodeListErrorHandling: + """Tests for error handling in code list loading.""" + + def test_load_code_list_file_not_found(self) -> None: + """Missing code list file returns empty frozenset.""" + from unittest.mock import MagicMock, patch + + from dppvalidator.vocabularies.code_lists import _load_code_list + + mock_data_files = MagicMock() + mock_path = MagicMock() + mock_path.read_text.side_effect = FileNotFoundError("File not found") + mock_data_files.joinpath.return_value = mock_path + + with patch( + "dppvalidator.vocabularies.code_lists._get_data_files", + return_value=mock_data_files, + ): + result = _load_code_list("nonexistent") + + assert result == frozenset() + + def test_load_code_list_invalid_json(self) -> None: + """Invalid JSON in code list file returns empty frozenset.""" + from unittest.mock import MagicMock, patch + + from dppvalidator.vocabularies.code_lists import _load_code_list + + mock_data_files = MagicMock() + mock_path = MagicMock() + mock_path.read_text.return_value = "{ invalid json }" + mock_data_files.joinpath.return_value = mock_path + + with patch( + "dppvalidator.vocabularies.code_lists._get_data_files", + return_value=mock_data_files, + ): + result = _load_code_list("invalid") + + assert result == frozenset() + + +class TestTextileHSCodeEdgeCases: + """Tests for edge cases in textile HS code validation.""" + + def test_empty_code_returns_false(self) -> None: + """Empty code returns False.""" + assert is_textile_hs_code("") is False + + def test_single_char_returns_false(self) -> None: + """Single character code returns False.""" + assert is_textile_hs_code("5") is False + + def test_non_numeric_chapter_returns_false(self) -> None: + """Non-numeric chapter code returns False.""" + assert is_textile_hs_code("AB01") is False + assert is_textile_hs_code("XX") is False + + +class TestHSChapterDescriptionEdgeCases: + """Tests for edge cases in HS chapter description.""" + + def test_empty_code_returns_none(self) -> None: + """Empty code returns None.""" + assert get_hs_chapter_description("") is None + + def test_single_char_returns_none(self) -> None: + """Single character code returns None.""" + assert get_hs_chapter_description("5") is None + + def test_unknown_chapter_returns_none(self) -> None: + """Unknown chapter returns None.""" + assert get_hs_chapter_description("9901") is None + assert get_hs_chapter_description("0100") is None diff --git a/tests/unit/test_compat_upgrade.py b/tests/unit/test_compat_upgrade.py new file mode 100644 index 0000000..dd1e97a --- /dev/null +++ b/tests/unit/test_compat_upgrade.py @@ -0,0 +1,711 @@ +"""Phase 4 acceptance tests: UNTP DPP v0.6.x → v0.7.0 compatibility shim. + +This module is the unit-level coverage for +``src/dppvalidator/compat/upgrade_0_6_to_0_7.py``. Each test pins a +single transformation step from §Phase 4 of +``docs/plans/UNTP_0.7.0_MIGRATION.md`` and asserts both the structural +rewrite and the structured warnings the shim emits. + +Tests are organised by step number so it's easy to map a failing case +back to the migration plan. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from dppvalidator.compat import ( + UPG_CODE_LOSSY, + UPG_CODE_REQUIRED_FIELD_MISSING, + UPG_CODE_SYNTHESISED, + UPG_CODE_UNMAPPED_COUNTRY, + UpgradeSeverity, + UpgradeWarning, + active_version, + is_version, + upgrade, +) + + +def _minimal_v06_payload(**overrides: Any) -> dict[str, Any]: + """Return a minimal v0.6 ProductPassport payload for use in shim tests.""" + base: dict[str, Any] = { + "type": ["DigitalProductPassport", "VerifiableCredential"], + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ], + "id": "https://example.com/credentials/x", + "issuer": {"id": "did:example:1", "name": "Example"}, + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "type": ["ProductPassport"], + "id": "https://example.com/subject/1", + "product": { + "type": ["Product"], + "id": "https://example.com/products/1", + "name": "Sample Product", + }, + }, + } + base.update(overrides) + return base + + +# --------------------------------------------------------------------------- +# active_version() / is_version() +# --------------------------------------------------------------------------- + + +class TestActiveVersionHelpers: + """Phase 4 introduces ``active_version()`` / ``is_version()``.""" + + def test_active_version_matches_default_schema_version(self) -> None: + from dppvalidator.schemas.registry import DEFAULT_SCHEMA_VERSION + + assert active_version() == DEFAULT_SCHEMA_VERSION + + def test_is_version_true_for_active(self) -> None: + assert is_version(active_version()) is True + + def test_is_version_false_for_other(self) -> None: + assert is_version("9.9.9") is False + + +# --------------------------------------------------------------------------- +# Type / contract checks +# --------------------------------------------------------------------------- + + +class TestUpgradeContract: + """``upgrade()`` is pure, doesn't mutate input, and rejects bad shapes.""" + + def test_rejects_non_dict(self) -> None: + with pytest.raises(TypeError): + upgrade("not a dict") # type: ignore[arg-type] + + def test_does_not_mutate_input(self) -> None: + src = _minimal_v06_payload() + before = json.dumps(src, sort_keys=True) + upgrade(src) + after = json.dumps(src, sort_keys=True) + assert before == after, "upgrade() must not mutate its input" + + def test_returns_tuple_of_dict_and_warning_list(self) -> None: + out, warnings = upgrade(_minimal_v06_payload()) + assert isinstance(out, dict) + assert isinstance(warnings, list) + for w in warnings: + assert isinstance(w, UpgradeWarning) + + +# --------------------------------------------------------------------------- +# Step 1 — context URL substitution +# --------------------------------------------------------------------------- + + +class TestStep1ContextRewrite: + def test_v061_context_becomes_v07_context(self) -> None: + out, _ = upgrade(_minimal_v06_payload()) + assert "https://vocabulary.uncefact.org/untp/0.7.0/context/" in out["@context"] + assert "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/" not in out["@context"] + + def test_v060_context_is_also_rewritten(self) -> None: + src = _minimal_v06_payload() + src["@context"] = [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.0/", + ] + out, _ = upgrade(src) + assert "https://vocabulary.uncefact.org/untp/0.7.0/context/" in out["@context"] + + def test_w3c_vc_context_preserved(self) -> None: + out, _ = upgrade(_minimal_v06_payload()) + assert "https://www.w3.org/ns/credentials/v2" in out["@context"] + + def test_unknown_context_entries_pass_through(self) -> None: + src = _minimal_v06_payload() + src["@context"].append("https://example.com/extension") + out, _ = upgrade(src) + assert "https://example.com/extension" in out["@context"] + + +# --------------------------------------------------------------------------- +# Step 2 — envelope required fields +# --------------------------------------------------------------------------- + + +class TestStep2EnvelopeRequiredFields: + def test_synthesises_name_from_product_name(self) -> None: + src = _minimal_v06_payload() + src.pop("name", None) + out, warnings = upgrade(src) + assert out["name"] == "Sample Product" + codes = [w.code for w in warnings if w.path == "name"] + assert UPG_CODE_SYNTHESISED in codes + + def test_warns_when_name_cannot_be_synthesised(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["product"].pop("name", None) + out, warnings = upgrade(src) + codes = [(w.code, w.severity) for w in warnings if w.path == "name"] + assert (UPG_CODE_REQUIRED_FIELD_MISSING, UpgradeSeverity.ERROR) in codes + + def test_warns_when_validFrom_missing(self) -> None: + src = _minimal_v06_payload() + src.pop("validFrom", None) + _, warnings = upgrade(src) + codes = [w.code for w in warnings if w.path == "validFrom"] + assert UPG_CODE_REQUIRED_FIELD_MISSING in codes + + +# --------------------------------------------------------------------------- +# Step 3 — drop ProductPassport envelope +# --------------------------------------------------------------------------- + + +class TestStep3FlattenEnvelope: + def test_credential_subject_is_product(self) -> None: + out, _ = upgrade(_minimal_v06_payload()) + cs = out["credentialSubject"] + assert cs["type"] == ["Product"] + assert "product" not in cs + + def test_granularity_level_renamed(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["granularityLevel"] = "batch" + out, _ = upgrade(src) + assert out["credentialSubject"]["idGranularity"] == "batch" + + def test_serial_number_renamed_to_item_number(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["product"]["serialNumber"] = "SN-42" + out, _ = upgrade(src) + cs = out["credentialSubject"] + assert cs.get("itemNumber") == "SN-42" + assert "serialNumber" not in cs + + def test_passport_id_preserved_when_product_has_none(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["product"].pop("id", None) + src["credentialSubject"]["id"] = "https://example.com/subject/keep" + out, _ = upgrade(src) + assert out["credentialSubject"]["id"] == "https://example.com/subject/keep" + + +# --------------------------------------------------------------------------- +# Step 4 — materialsProvenance → materialProvenance +# --------------------------------------------------------------------------- + + +class TestStep4MaterialsProvenance: + def test_materials_provenance_renamed_and_moved(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["materialsProvenance"] = [ + {"name": "Cotton", "originCountry": "EG", "massFraction": 0.5} + ] + out, _ = upgrade(src) + cs = out["credentialSubject"] + assert "materialsProvenance" not in cs + assert isinstance(cs["materialProvenance"], list) + assert cs["materialProvenance"][0]["name"] == "Cotton" + + +# --------------------------------------------------------------------------- +# Step 5 — dueDiligenceDeclaration → relatedDocument[] +# --------------------------------------------------------------------------- + + +class TestStep5DueDiligenceDeclaration: + def test_due_diligence_link_lands_in_related_document(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["dueDiligenceDeclaration"] = { + "linkURL": "https://example.com/dd", + "linkName": "Custom", + } + out, _ = upgrade(src) + cs = out["credentialSubject"] + assert "dueDiligenceDeclaration" not in cs + rd = cs["relatedDocument"] + assert any("dd" in entry.get("linkURL", "") for entry in rd) + + +# --------------------------------------------------------------------------- +# Step 6 — conformityClaim → performanceClaim with field renames +# --------------------------------------------------------------------------- + + +class TestStep6ConformityClaimToPerformanceClaim: + def test_conformity_claim_array_becomes_performance_claim(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["conformityClaim"] = [ + { + "id": "https://example.com/claims/1", + "description": "Sample claim", + "assessmentDate": "2024-03-15", + "conformityTopic": "environment.emissions", + "declaredValue": [ + { + "metricName": "GHG intensity", + "metricValue": {"value": 1.5, "unit": "KGM"}, + "score": "AA", + }, + ], + }, + ] + out, _ = upgrade(src) + cs = out["credentialSubject"] + assert "conformityClaim" not in cs + pc = cs["performanceClaim"] + assert len(pc) == 1 + claim = pc[0] + assert claim["claimDate"] == "2024-03-15" + assert isinstance(claim["conformityTopic"], list) + assert claim["conformityTopic"][0]["name"] == "environment.emissions" + assert isinstance(claim["claimedPerformance"], list) + perf = claim["claimedPerformance"][0] + assert perf["metric"]["name"] == "GHG intensity" + assert perf["measure"] == {"value": 1.5, "unit": "KGM"} + assert perf["score"]["code"] == "AA" + + def test_claim_with_no_name_falls_back_to_description(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["conformityClaim"] = [ + {"id": "https://example.com/claims/1", "description": "Sample"}, + ] + out, _ = upgrade(src) + assert out["credentialSubject"]["performanceClaim"][0]["name"] == "Sample" + + +# --------------------------------------------------------------------------- +# Step 7 — scorecards → Claim entries on performanceClaim +# --------------------------------------------------------------------------- + + +class TestStep7ScorecardsAsClaims: + def test_emissions_scorecard_becomes_claim(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["emissionsScorecard"] = { + "carbonFootprint": 1.8, + "primarySourcedRatio": 0.3, + } + out, _ = upgrade(src) + pc = out["credentialSubject"]["performanceClaim"] + emissions = next( + c for c in pc if any(t["name"] == "Emissions" for t in c.get("conformityTopic", [])) + ) + names = {p["metric"]["name"] for p in emissions["claimedPerformance"]} + assert "carbonFootprint" in names + assert "primarySourcedRatio" in names + + def test_circularity_scorecard_link_becomes_evidence(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["circularityScorecard"] = { + "recyclableContent": 0.5, + "recyclingInformation": { + "linkURL": "https://example.com/recycle", + "linkName": "Recycling guide", + }, + } + out, _ = upgrade(src) + pc = out["credentialSubject"]["performanceClaim"] + circ = next( + c for c in pc if any(t["name"] == "Circularity" for t in c.get("conformityTopic", [])) + ) + assert any("recycle" in (e.get("linkURL") or "") for e in circ.get("evidence", [])) + + def test_traceability_information_array_explodes(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["traceabilityInformation"] = [ + {"valueChainProcess": "Spinning", "verifiedRatio": 0.5}, + {"valueChainProcess": "Weaving", "verifiedRatio": 0.7}, + ] + out, _ = upgrade(src) + pc = out["credentialSubject"]["performanceClaim"] + trace = [ + c for c in pc if any(t["name"] == "Traceability" for t in c.get("conformityTopic", [])) + ] + assert len(trace) == 2 + + +# --------------------------------------------------------------------------- +# Step 8 — wrap scalar Standard / Regulation +# --------------------------------------------------------------------------- + + +class TestStep8ClaimReferencesAreLists: + def test_scalar_standard_wrapped_into_list(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["conformityClaim"] = [ + { + "id": "https://example.com/claims/1", + "description": "x", + "referenceStandard": {"id": "https://std.example/A", "name": "Std A"}, + }, + ] + out, _ = upgrade(src) + rs = out["credentialSubject"]["performanceClaim"][0]["referenceStandard"] + assert isinstance(rs, list) + assert rs[0]["name"] == "Std A" + + +# --------------------------------------------------------------------------- +# Step 9 — wrap scalar country codes +# --------------------------------------------------------------------------- + + +class TestStep9CountryCodes: + def test_scalar_country_wrapped_to_object(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["product"]["countryOfProduction"] = "DE" + out, _ = upgrade(src) + cop = out["credentialSubject"]["countryOfProduction"] + assert cop == {"countryCode": "DE"} or ( + isinstance(cop, dict) and cop["countryCode"] == "DE" + ) + + def test_country_lookup_populates_name(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["product"]["countryOfProduction"] = "DE" + out, _ = upgrade(src, country_lookup={"DE": "Germany"}) + assert out["credentialSubject"]["countryOfProduction"]["countryName"] == "Germany" + + def test_unknown_country_fires_upg003(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["product"]["countryOfProduction"] = "XX" + _, warnings = upgrade(src) + assert any(w.code == UPG_CODE_UNMAPPED_COUNTRY for w in warnings) + + def test_known_country_without_lookup_fires_upg002_info(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["product"]["countryOfProduction"] = "DE" + _, warnings = upgrade(src) + info_warnings = [ + w for w in warnings if w.code == UPG_CODE_SYNTHESISED and "countryName" in w.path + ] + assert info_warnings, "Expected UPG002 info warning for missing country lookup" + assert all(w.severity == UpgradeSeverity.INFO for w in info_warnings) + + def test_material_origin_country_also_wrapped(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["materialsProvenance"] = [ + { + "name": "X", + "originCountry": "DE", + "massFraction": 0.5, + "materialType": {"schemeID": "s", "schemeName": "S", "code": "c", "name": "n"}, + } + ] + out, _ = upgrade(src, country_lookup={"DE": "Germany"}) + m = out["credentialSubject"]["materialProvenance"][0] + assert m["originCountry"] == {"countryCode": "DE", "countryName": "Germany"} + + +# --------------------------------------------------------------------------- +# Step 10 — wrap Product.productCategory +# --------------------------------------------------------------------------- + + +class TestStep10WrapProductCategory: + def test_scalar_category_wrapped(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["product"]["productCategory"] = { + "schemeID": "s", + "schemeName": "S", + "code": "c", + "name": "n", + } + out, _ = upgrade(src) + pc = out["credentialSubject"]["productCategory"] + assert isinstance(pc, list) and len(pc) == 1 + + def test_existing_list_passthrough(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["product"]["productCategory"] = [ + {"schemeID": "s", "schemeName": "S", "code": "c", "name": "n"}, + ] + out, _ = upgrade(src) + assert isinstance(out["credentialSubject"]["productCategory"], list) + + +# --------------------------------------------------------------------------- +# Step 11 — producedByParty → relatedParty[] +# --------------------------------------------------------------------------- + + +class TestStep11ProducedByParty: + def test_scalar_party_becomes_party_role_with_manufacturer_role(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["product"]["producedByParty"] = { + "id": "did:example:mfr", + "name": "Manufacturer", + } + out, _ = upgrade(src) + cs = out["credentialSubject"] + assert "producedByParty" not in cs + rp = cs["relatedParty"] + assert len(rp) == 1 + assert rp[0]["role"] == "manufacturer" + assert rp[0]["party"]["name"] == "Manufacturer" + + +# --------------------------------------------------------------------------- +# Step 12 — furtherInformation → relatedDocument[] +# --------------------------------------------------------------------------- + + +class TestStep12FurtherInformation: + def test_further_information_appended_to_related_document(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["product"]["furtherInformation"] = [ + {"linkURL": "https://example.com/info1"}, + {"linkURL": "https://example.com/info2"}, + ] + out, _ = upgrade(src) + cs = out["credentialSubject"] + assert "furtherInformation" not in cs + rd = cs["relatedDocument"] + urls = [r["linkURL"] for r in rd] + assert "https://example.com/info1" in urls + assert "https://example.com/info2" in urls + + +# --------------------------------------------------------------------------- +# Step 13 — drop Product.registeredId with warning +# --------------------------------------------------------------------------- + + +class TestStep13DropRegisteredId: + def test_registered_id_dropped_and_warns(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["product"]["registeredId"] = "ABN-123" + out, warnings = upgrade(src) + assert "registeredId" not in out["credentialSubject"] + assert any(w.code == UPG_CODE_LOSSY and "registeredId" in w.path for w in warnings) + + +# --------------------------------------------------------------------------- +# Step 14 — Material.symbol → Image +# --------------------------------------------------------------------------- + + +class TestStep14MaterialSymbol: + def test_undefined_placeholder_dropped(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["materialsProvenance"] = [ + { + "name": "X", + "originCountry": "DE", + "massFraction": 0.5, + "materialType": {"schemeID": "s", "schemeName": "S", "code": "c", "name": "n"}, + "symbol": "undefined", + }, + ] + out, warnings = upgrade(src) + m = out["credentialSubject"]["materialProvenance"][0] + assert "symbol" not in m + assert any( + w.code == UPG_CODE_LOSSY and "symbol" in w.path and w.severity == UpgradeSeverity.INFO + for w in warnings + ) + + def test_real_base64_becomes_image_object(self) -> None: + # 16+ chars, valid base64 (padded) — passes _looks_like_base64. + sample_b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" + src = _minimal_v06_payload() + src["credentialSubject"]["materialsProvenance"] = [ + { + "name": "X", + "originCountry": "DE", + "massFraction": 0.5, + "materialType": {"schemeID": "s", "schemeName": "S", "code": "c", "name": "n"}, + "symbol": sample_b64, + }, + ] + out, warnings = upgrade(src) + m = out["credentialSubject"]["materialProvenance"][0] + assert isinstance(m["symbol"], dict) + assert m["symbol"]["imageData"] == sample_b64 + assert m["symbol"]["mediaType"] == "image/png" + assert m["symbol"]["name"] + assert any(w.code == UPG_CODE_SYNTHESISED and "symbol" in w.path for w in warnings) + + +# --------------------------------------------------------------------------- +# Step 15 — strip type arrays on embedded objects +# --------------------------------------------------------------------------- + + +class TestStep15StripEmbeddedTypes: + def test_dimension_type_stripped(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["product"]["dimensions"] = { + "type": ["Dimension"], + "weight": {"type": ["Measure"], "value": 1.0, "unit": "KGM"}, + } + out, _ = upgrade(src) + dims = out["credentialSubject"]["dimensions"] + assert "type" not in dims + assert "type" not in dims["weight"] + + def test_product_type_preserved(self) -> None: + out, _ = upgrade(_minimal_v06_payload()) + assert out["credentialSubject"]["type"] == ["Product"] + + +# --------------------------------------------------------------------------- +# Step 16 — schemeID → schemeId rename +# --------------------------------------------------------------------------- + + +class TestStep16SchemeIdRename: + def test_scheme_id_renamed_recursively(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["product"]["productCategory"] = [ + {"schemeID": "https://example.com/scheme", "schemeName": "S", "code": "c", "name": "n"}, + ] + out, _ = upgrade(src) + cls = out["credentialSubject"]["productCategory"][0] + assert "schemeID" not in cls + assert cls["schemeId"] == "https://example.com/scheme" + + def test_rename_inside_nested_classification(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["materialsProvenance"] = [ + { + "name": "X", + "originCountry": "DE", + "massFraction": 0.5, + "materialType": { + "schemeID": "https://example.com/scheme", + "schemeName": "S", + "code": "c", + "name": "n", + }, + }, + ] + out, _ = upgrade(src) + mt = out["credentialSubject"]["materialProvenance"][0]["materialType"] + assert "schemeID" not in mt + assert mt["schemeId"] == "https://example.com/scheme" + + +# --------------------------------------------------------------------------- +# Step 17 — Material required-field detection +# --------------------------------------------------------------------------- + + +class TestStep17MaterialRequiredFields: + def test_missing_material_type_emits_upg004(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["materialsProvenance"] = [ + {"name": "X", "originCountry": "DE", "massFraction": 0.5}, + ] + _, warnings = upgrade(src) + assert any( + w.code == UPG_CODE_REQUIRED_FIELD_MISSING and "materialType" in w.path for w in warnings + ) + + def test_missing_mass_fraction_emits_upg004(self) -> None: + src = _minimal_v06_payload() + src["credentialSubject"]["materialsProvenance"] = [ + { + "name": "X", + "originCountry": "DE", + "materialType": {"schemeID": "s", "schemeName": "S", "code": "c", "name": "n"}, + }, + ] + _, warnings = upgrade(src) + assert any( + w.code == UPG_CODE_REQUIRED_FIELD_MISSING and "massFraction" in w.path for w in warnings + ) + + +# --------------------------------------------------------------------------- +# Round-trip: every 0.6.x valid fixture upgrades and either validates +# cleanly or emits structured warnings. +# --------------------------------------------------------------------------- + + +_VALID_FIXTURE_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "valid" + + +def _enveloped_v06_fixtures() -> list[Path]: + """Return ``valid/*.json`` fixtures that carry a full VC envelope. + + Some legacy fixtures (e.g. ``product_passport_instance_0.6.1.json``) + are bare ``ProductPassport`` shapes without the W3C VC v2 envelope — + they were captured for direct ``ProductPassport`` model tests, not + for the engine's full credential pipeline. The shim correctly + no-ops on them (nothing to upgrade), but they don't fit the + envelope-oriented round-trip assertions below. + """ + out: list[Path] = [] + for path in sorted(_VALID_FIXTURE_DIR.glob("*.json")): + data = json.loads(path.read_text(encoding="utf-8")) + if not (isinstance(data, dict) and "@context" in data and "credentialSubject" in data): + continue + # Skip v0.7-shaped fixtures vendored in Phase 5 — the shim upgrades + # *from* v0.6 only, not v0.7. + ctx = data.get("@context") or [] + if any(isinstance(c, str) and "vocabulary.uncefact.org/untp/0.7" in c for c in ctx): + continue + out.append(path) + return out + + +@pytest.mark.parametrize( + "fixture_path", + _enveloped_v06_fixtures(), + ids=lambda p: p.name, +) +def test_v06_fixtures_round_trip_through_shim(fixture_path: Path) -> None: + """Every enveloped 0.6.x ``valid/*.json`` fixture upgrades without crashing. + + Per the migration plan exit criterion: "every 0.6.x valid fixture + either upgrades and re-validates cleanly, or emits a documented + warning". This test asserts the *upgrade itself* never crashes; + re-validation is covered by the model integration test below. + """ + src = json.loads(fixture_path.read_text(encoding="utf-8")) + out, warnings = upgrade(src) + assert isinstance(out, dict) + assert "@context" in out + for w in warnings: + assert w.code.startswith("UPG"), f"unexpected code: {w.code}" + assert w.path, "warning path must not be empty" + assert w.message, "warning message must not be empty" + + +@pytest.mark.parametrize( + "fixture_path", + _enveloped_v06_fixtures(), + ids=lambda p: p.name, +) +def test_v06_fixtures_upgrade_to_v07_context_url(fixture_path: Path) -> None: + """The shim always swaps the v0.6 context URL for the v0.7 one.""" + src = json.loads(fixture_path.read_text(encoding="utf-8")) + out, _ = upgrade(src) + contexts = out["@context"] + assert any("vocabulary.uncefact.org/untp/0.7" in c for c in contexts) + assert not any("test.uncefact.org/vocabulary/untp/dpp/0.6" in c for c in contexts) + + +def test_bare_product_passport_does_not_crash_shim() -> None: + """Bare ProductPassport shapes (no VC envelope) should no-op cleanly. + + The shim is defined for v0.6 ``DigitalProductPassport`` envelopes; + a bare ``ProductPassport`` lacks the envelope and shouldn't make + the shim raise. It just won't do anything substantive. + """ + bare_path = _VALID_FIXTURE_DIR / "product_passport_instance_0.6.1.json" + if not bare_path.is_file(): + pytest.skip("bare ProductPassport fixture not present") + src = json.loads(bare_path.read_text(encoding="utf-8")) + out, warnings = upgrade(src) + assert isinstance(out, dict) + assert isinstance(warnings, list) diff --git a/tests/unit/test_credential_verifier.py b/tests/unit/test_credential_verifier.py new file mode 100644 index 0000000..45d6530 --- /dev/null +++ b/tests/unit/test_credential_verifier.py @@ -0,0 +1,874 @@ +"""Unit tests for credential verification behavior.""" + +import base64 +from typing import Any +from unittest.mock import MagicMock + +from cryptography.hazmat.primitives.asymmetric import ed25519 + +from dppvalidator.verifier.did import DIDDocument, DIDResolver, VerificationMethod +from dppvalidator.verifier.verifier import ( + CredentialVerifier, + VerificationResult, + has_vc_support, + verify_credential, +) + + +class TestVerificationResultBehavior: + """Tests for VerificationResult dataclass behavior.""" + + def test_verified_requires_both_valid_and_signature(self) -> None: + """verified property requires both valid=True and signature_valid=True.""" + # Both True + result = VerificationResult(valid=True, signature_valid=True) + assert result.verified is True + + # Valid but no signature check + result = VerificationResult(valid=True, signature_valid=None) + assert result.verified is False + + # Signature valid but overall invalid + result = VerificationResult(valid=False, signature_valid=True) + assert result.verified is False + + def test_errors_and_warnings_default_empty(self) -> None: + """Errors and warnings default to empty lists.""" + result = VerificationResult(valid=True) + assert result.errors == [] + assert result.warnings == [] + + def test_can_add_errors_and_warnings(self) -> None: + """Errors and warnings can be added.""" + result = VerificationResult(valid=False) + result.errors.append("Error 1") + result.warnings.append("Warning 1") + + assert len(result.errors) == 1 + assert len(result.warnings) == 1 + + +class TestCredentialVerifierIssuerExtraction: + """Tests for issuer extraction from credentials.""" + + def test_extract_issuer_from_string(self) -> None: + """Issuer extracted from string format.""" + verifier = CredentialVerifier() + credential = {"issuer": "did:web:example.com"} + + result = verifier.verify(credential) + assert result.issuer_did == "did:web:example.com" + + def test_extract_issuer_from_object(self) -> None: + """Issuer extracted from object with id field.""" + verifier = CredentialVerifier() + credential = {"issuer": {"id": "did:key:z6Mk...", "name": "Test Issuer"}} + + result = verifier.verify(credential) + assert result.issuer_did == "did:key:z6Mk..." + + def test_missing_issuer_returns_none(self) -> None: + """Missing issuer returns None for issuer_did.""" + verifier = CredentialVerifier() + credential = {"credentialSubject": {"id": "urn:uuid:123"}} + + result = verifier.verify(credential) + assert result.issuer_did is None + + +class TestCredentialVerifierProofHandling: + """Tests for proof verification in credentials.""" + + def test_no_proof_returns_warning(self) -> None: + """Credential without proof returns warning.""" + verifier = CredentialVerifier() + credential = {"issuer": "did:web:example.com"} + + result = verifier.verify(credential) + assert "No proof found in credential" in result.warnings + assert result.valid is True + + def test_proof_with_unresolvable_did_fails(self) -> None: + """Proof with unresolvable DID returns error.""" + verifier = CredentialVerifier() + credential = { + "issuer": "did:web:nonexistent.example.com", + "proof": { + "type": "Ed25519Signature2020", + "verificationMethod": "did:web:nonexistent.example.com#key-1", + "proofValue": "z...", + }, + } + + result = verifier.verify(credential) + assert result.valid is False + assert any("Could not resolve DID" in e for e in result.errors) + + def test_proof_with_invalid_verification_method_format(self) -> None: + """Proof with invalid verification method format fails.""" + verifier = CredentialVerifier() + credential = { + "issuer": "did:web:example.com", + "proof": { + "type": "Ed25519Signature2020", + "verificationMethod": "not-a-did", + "proofValue": "z...", + }, + } + + result = verifier.verify(credential) + assert result.valid is False + assert any("Could not extract DID" in e for e in result.errors) + + def test_multiple_proofs_verified(self) -> None: + """Multiple proofs in array are all checked.""" + mock_resolver = MagicMock(spec=DIDResolver) + mock_resolver.resolve.return_value = None + + verifier = CredentialVerifier(did_resolver=mock_resolver) + credential = { + "issuer": "did:web:example.com", + "proof": [ + { + "type": "Ed25519Signature2020", + "verificationMethod": "did:web:example.com#key-1", + "proofValue": "z...", + }, + { + "type": "Ed25519Signature2020", + "verificationMethod": "did:web:example.com#key-2", + "proofValue": "z...", + }, + ], + } + + verifier.verify(credential) + # Both proofs should be attempted + assert mock_resolver.resolve.call_count == 2 + + def test_unsupported_proof_type_warning(self) -> None: + """Unsupported proof type returns warning.""" + # Create a mock DID document with verification method + vm = VerificationMethod( + id="did:key:z6Mk...#key-1", + type="Ed25519VerificationKey2020", + controller="did:key:z6Mk...", + public_key_jwk={"kty": "OKP", "crv": "Ed25519", "x": "abc"}, + ) + doc = DIDDocument( + id="did:key:z6Mk...", + verification_method=[vm], + ) + + mock_resolver = MagicMock(spec=DIDResolver) + mock_resolver.resolve.return_value = doc + + verifier = CredentialVerifier(did_resolver=mock_resolver) + credential = { + "issuer": "did:key:z6Mk...", + "proof": { + "type": "UnknownProofType2025", + "verificationMethod": "did:key:z6Mk...#key-1", + "proofValue": "zbase58signature", + }, + } + + result = verifier.verify(credential) + assert any("Unsupported proof type" in w for w in result.warnings) + + +class TestBase58Decoding: + """Tests for base58btc decoding in verifier.""" + + def test_decode_valid_base58(self) -> None: + """Valid base58btc string decodes correctly.""" + verifier = CredentialVerifier() + # "1" in base58 = 0x00 + # "2" in base58 = 0x01 + result = verifier._decode_base58btc("2") + assert result == b"\x01" + + def test_decode_base58_with_leading_ones(self) -> None: + """Leading '1' characters decode to leading zero bytes.""" + verifier = CredentialVerifier() + # "111" = three leading zeros + result = verifier._decode_base58btc("1112") + assert result is not None + assert result.startswith(b"\x00\x00\x00") + + def test_decode_invalid_base58_returns_none(self) -> None: + """Invalid base58 characters return None.""" + verifier = CredentialVerifier() + # 'O', 'I', 'l', '0' are not in base58 alphabet + result = verifier._decode_base58btc("O0Il") + assert result is None + + +class TestVerifyData: + """Tests for creating verification data.""" + + def test_create_verify_data_removes_proof(self) -> None: + """Verification data excludes proof object.""" + verifier = CredentialVerifier() + credential = { + "@context": ["https://www.w3.org/2018/credentials/v1"], + "id": "urn:uuid:123", + "type": ["VerifiableCredential"], + "proof": { + "type": "Ed25519Signature2020", + "proofValue": "zsig...", + }, + } + proof: dict[str, Any] = credential["proof"] # type: ignore[assignment] + + data = verifier._create_verify_data(credential, proof) + + assert data is not None + # URDNA2015 produces N-Quads format, not JSON + # The proof object should be excluded from the credential canonicalization + assert b"proofValue" not in data + # Data should contain the credential ID in some form + assert b"urn:uuid:123" in data or len(data) > 0 + + def test_create_verify_data_removes_proof_value(self) -> None: + """Proof options exclude proofValue.""" + verifier = CredentialVerifier() + # Use a credential with @context for proper URDNA2015 canonicalization + credential = { + "@context": ["https://www.w3.org/2018/credentials/v1"], + "id": "urn:uuid:123", + "type": ["VerifiableCredential"], + } + proof = { + "type": "Ed25519Signature2020", + "proofValue": "zsig...", + "created": "2024-01-01", + } + + data = verifier._create_verify_data(credential, proof) + + assert data is not None + # proofValue should always be excluded from verification data + assert b"proofValue" not in data + # Data should be non-empty (URDNA2015 produces N-Quads) + assert len(data) > 0 + + +class TestJWSProofVerification: + """Tests for JWS proof verification.""" + + def test_jws_proof_without_jws_returns_none(self) -> None: + """JWS proof without jws field returns None.""" + vm = VerificationMethod( + id="did:key:z6Mk...#key-1", + type="JsonWebKey2020", + controller="did:key:z6Mk...", + public_key_jwk={"kty": "OKP", "crv": "Ed25519", "x": "abc"}, + ) + + verifier = CredentialVerifier() + result = verifier._verify_jws_proof({}, {"type": "JsonWebSignature2020"}, vm) + assert result is None + + def test_jws_proof_without_jwk_returns_none(self) -> None: + """JWS proof without JWK in verification method returns None.""" + vm = VerificationMethod( + id="did:key:z6Mk...#key-1", + type="JsonWebKey2020", + controller="did:key:z6Mk...", + public_key_jwk=None, + ) + + verifier = CredentialVerifier() + result = verifier._verify_jws_proof({}, {"jws": "eyJ..."}, vm) + assert result is None + + +class TestEd25519ProofVerification: + """Tests for Ed25519 proof verification.""" + + def test_ed25519_proof_without_value_returns_none(self) -> None: + """Ed25519 proof without proofValue returns None.""" + vm = VerificationMethod( + id="did:key:z6Mk...#key-1", + type="Ed25519VerificationKey2020", + controller="did:key:z6Mk...", + public_key_jwk={"kty": "OKP", "crv": "Ed25519", "x": "abc"}, + ) + + verifier = CredentialVerifier() + result = verifier._verify_ed25519_proof({}, {"type": "Ed25519Signature2020"}, vm) + assert result is None + + def test_ed25519_proof_with_base64_signature(self) -> None: + """Ed25519 proof with base64 signature round-trips through the verifier. + + Round-trips the verifier's own canonicalisation: we ask the + verifier to produce ``verify-data`` for a (credential, proof + options) pair, sign **those** bytes with a fresh Ed25519 key, + attach the signature as the ``proofValue``, and then call the + verifier — which must recompute the same canonical bytes and + report a valid signature. + + The credential carries an ``@context`` so URDNA2015 + canonicalisation produces non-empty n-quads (the verifier's + primary path since 0.3.2). This avoids the historical flake: + signing JSON-canonical bytes while the verifier compares + against URDNA2015-canonicalised bytes. + """ + verifier = CredentialVerifier() + + # Generate the signing key. + private_key = ed25519.Ed25519PrivateKey.generate() + public_key = private_key.public_key() + + vm = VerificationMethod( + id="did:key:z6Mk...#key-1", + type="Ed25519VerificationKey2020", + controller="did:key:z6Mk...", + public_key_jwk={ + "kty": "OKP", + "crv": "Ed25519", + "x": base64.urlsafe_b64encode(public_key.public_bytes_raw()).rstrip(b"=").decode(), + }, + ) + + # A credential with @context so URDNA2015 produces non-empty + # canonical bytes — otherwise the verifier and the signer would + # operate on different message bytes. + credential = { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "id": "urn:uuid:test", + "type": ["VerifiableCredential"], + } + proof_options = { + "type": "Ed25519Signature2020", + "created": "2024-01-01T00:00:00Z", + "verificationMethod": vm.id, + "proofPurpose": "assertionMethod", + } + + # Sign exactly what the verifier will verify. + message = verifier._create_verify_data(credential, proof_options) + assert message, "verify-data must be non-empty for a credential with @context" + + signature = private_key.sign(message) + proof = {**proof_options, "proofValue": base64.b64encode(signature).decode()} + + result = verifier._verify_ed25519_proof(credential, proof, vm) + assert result is True + + +class TestModuleFunctions: + """Tests for module-level functions.""" + + def test_verify_credential_creates_verifier(self) -> None: + """verify_credential function creates CredentialVerifier.""" + credential = {"issuer": "did:web:test.com"} + result = verify_credential(credential) + + assert isinstance(result, VerificationResult) + assert result.issuer_did == "did:web:test.com" + + def test_has_vc_support_returns_true(self) -> None: + """has_vc_support returns True when cryptography is installed.""" + assert has_vc_support() is True + + +class TestDIDExtraction: + """Tests for DID extraction from verification methods.""" + + def test_extract_did_from_full_method_id(self) -> None: + """DID extracted from full verification method ID.""" + verifier = CredentialVerifier() + did = verifier._extract_did_from_method("did:web:example.com#key-1") + assert did == "did:web:example.com" + + def test_extract_did_from_method_without_fragment(self) -> None: + """DID extracted when no fragment present.""" + verifier = CredentialVerifier() + did = verifier._extract_did_from_method("did:key:z6Mk...") + assert did == "did:key:z6Mk..." + + def test_extract_did_from_non_did_returns_none(self) -> None: + """Non-DID string returns None.""" + verifier = CredentialVerifier() + did = verifier._extract_did_from_method("https://example.com/keys/1") + assert did is None + + +class TestJWTCredentialVerification: + """Tests for JWT credential verification behavior.""" + + def test_jwt_credential_without_token_returns_error(self) -> None: + """JWT credential without jwt field returns error.""" + verifier = CredentialVerifier() + credential: dict[str, Any] = { + "issuer": "did:web:example.com", + } + # Force JWT path by setting internal flag + verifier._is_jwt_credential = lambda _: True # type: ignore[method-assign] + + result = verifier._verify_jwt_credential(credential, VerificationResult(valid=True)) + + assert result.signature_valid is False + assert any("No JWT token" in e for e in result.errors) + + def test_jwt_credential_with_proof_jwt_field(self) -> None: + """JWT token extracted from proof.jwt field.""" + mock_resolver = MagicMock(spec=DIDResolver) + mock_resolver.resolve.return_value = None + + verifier = CredentialVerifier(did_resolver=mock_resolver) + credential: dict[str, Any] = { + "issuer": "did:web:example.com", + "proof": {"jwt": "eyJhbGciOiJFZERTQSJ9.eyJpc3MiOiJkaWQ6d2ViOmV4YW1wbGUuY29tIn0.sig"}, + } + + result = verifier._verify_jwt_credential(credential, VerificationResult(valid=True)) + + # Should attempt to resolve issuer DID + assert result.issuer_did == "did:web:example.com" + assert any("Failed to resolve issuer DID" in e for e in result.errors) + + def test_jwt_credential_invalid_format_returns_error(self) -> None: + """Invalid JWT format returns decode error.""" + verifier = CredentialVerifier() + credential: dict[str, Any] = { + "issuer": "did:web:example.com", + "jwt": "not-a-valid-jwt", + } + + result = verifier._verify_jwt_credential(credential, VerificationResult(valid=True)) + + assert result.signature_valid is False + assert any("Invalid JWT format" in e for e in result.errors) + + def test_jwt_credential_extracts_issuer_from_payload(self) -> None: + """Issuer extracted from JWT payload when not in credential.""" + mock_resolver = MagicMock(spec=DIDResolver) + mock_resolver.resolve.return_value = None + + verifier = CredentialVerifier(did_resolver=mock_resolver) + # JWT with iss claim in payload (base64url encoded: {"alg":"ES256"}.{"iss":"did:web:jwt-issuer.com"}) + credential: dict[str, Any] = { + "jwt": "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJkaWQ6d2ViOmp3dC1pc3N1ZXIuY29tIn0.sig", + } + + result = verifier._verify_jwt_credential(credential, VerificationResult(valid=True)) + + # Should extract issuer from JWT payload + assert result.issuer_did == "did:web:jwt-issuer.com" + + def test_jwt_credential_no_issuer_returns_error(self) -> None: + """JWT without issuer anywhere returns error.""" + verifier = CredentialVerifier() + # JWT without iss claim + credential: dict[str, Any] = { + "jwt": "eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.sig", + } + + result = verifier._verify_jwt_credential(credential, VerificationResult(valid=True)) + + assert result.signature_valid is False + assert any("Cannot extract issuer DID" in e for e in result.errors) + + def test_jwt_credential_with_kid_uses_specific_key(self) -> None: + """JWT with kid header uses specific verification method.""" + vm = VerificationMethod( + id="did:web:example.com#key-specific", + type="JsonWebKey2020", + controller="did:web:example.com", + public_key_jwk={"kty": "EC", "crv": "P-256", "x": "test", "y": "test"}, + ) + doc = DIDDocument( + id="did:web:example.com", + verification_method=[vm], + ) + + mock_resolver = MagicMock(spec=DIDResolver) + mock_resolver.resolve.return_value = doc + + verifier = CredentialVerifier(did_resolver=mock_resolver) + # JWT with kid in header + credential: dict[str, Any] = { + "issuer": "did:web:example.com", + "jwt": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImRpZDp3ZWI6ZXhhbXBsZS5jb20ja2V5LXNwZWNpZmljIn0.eyJpc3MiOiJkaWQ6d2ViOmV4YW1wbGUuY29tIn0.sig", + } + + result = verifier._verify_jwt_credential(credential, VerificationResult(valid=True)) + + # Should use the specific key from kid + assert result.verification_method == "did:web:example.com#key-specific" + + def test_jwt_credential_fallback_to_assertion_method(self) -> None: + """JWT without kid falls back to assertionMethod.""" + vm = VerificationMethod( + id="did:web:example.com#assertion-key", + type="JsonWebKey2020", + controller="did:web:example.com", + public_key_jwk={"kty": "EC", "crv": "P-256", "x": "test", "y": "test"}, + ) + doc = DIDDocument( + id="did:web:example.com", + verification_method=[vm], + assertion_method=["did:web:example.com#assertion-key"], + ) + + mock_resolver = MagicMock(spec=DIDResolver) + mock_resolver.resolve.return_value = doc + + verifier = CredentialVerifier(did_resolver=mock_resolver) + credential: dict[str, Any] = { + "issuer": "did:web:example.com", + "jwt": "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJkaWQ6d2ViOmV4YW1wbGUuY29tIn0.sig", + } + + result = verifier._verify_jwt_credential(credential, VerificationResult(valid=True)) + + # Should use assertion method key + assert result.verification_method == "did:web:example.com#assertion-key" + + def test_jwt_credential_no_suitable_key_returns_error(self) -> None: + """JWT without suitable verification method returns error.""" + vm = VerificationMethod( + id="did:web:example.com#key-no-jwk", + type="Ed25519VerificationKey2020", + controller="did:web:example.com", + public_key_jwk=None, # No JWK + ) + doc = DIDDocument( + id="did:web:example.com", + verification_method=[vm], + ) + + mock_resolver = MagicMock(spec=DIDResolver) + mock_resolver.resolve.return_value = doc + + verifier = CredentialVerifier(did_resolver=mock_resolver) + credential: dict[str, Any] = { + "issuer": "did:web:example.com", + "jwt": "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJkaWQ6d2ViOmV4YW1wbGUuY29tIn0.sig", + } + + result = verifier._verify_jwt_credential(credential, VerificationResult(valid=True)) + + assert result.signature_valid is False + assert any("No suitable verification method" in e for e in result.errors) + + def test_jwt_credential_verification_exception_handled(self) -> None: + """Exception during JWT verification is handled gracefully.""" + vm = VerificationMethod( + id="did:web:example.com#key-1", + type="JsonWebKey2020", + controller="did:web:example.com", + public_key_jwk={"kty": "EC", "crv": "P-256", "x": "invalid", "y": "invalid"}, + ) + doc = DIDDocument( + id="did:web:example.com", + verification_method=[vm], + ) + + mock_resolver = MagicMock(spec=DIDResolver) + mock_resolver.resolve.return_value = doc + + verifier = CredentialVerifier(did_resolver=mock_resolver) + credential: dict[str, Any] = { + "issuer": "did:web:example.com", + "jwt": "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJkaWQ6d2ViOmV4YW1wbGUuY29tIn0.invalidsig", + } + + result = verifier._verify_jwt_credential(credential, VerificationResult(valid=True)) + + # Should handle exception and return error + assert result.signature_valid is False + assert len(result.errors) > 0 + + +class TestVerificationMethodNotFound: + """Tests for verification method resolution edge cases.""" + + def test_verification_method_not_in_did_document(self) -> None: + """Verification method ID not found in DID document returns error.""" + vm = VerificationMethod( + id="did:web:example.com#different-key", + type="Ed25519VerificationKey2020", + controller="did:web:example.com", + public_key_jwk={"kty": "OKP", "crv": "Ed25519", "x": "abc"}, + ) + doc = DIDDocument( + id="did:web:example.com", + verification_method=[vm], + ) + + mock_resolver = MagicMock(spec=DIDResolver) + mock_resolver.resolve.return_value = doc + + verifier = CredentialVerifier(did_resolver=mock_resolver) + credential = { + "issuer": "did:web:example.com", + "proof": { + "type": "Ed25519Signature2020", + "verificationMethod": "did:web:example.com#nonexistent-key", + "proofValue": "z...", + }, + } + + result = verifier.verify(credential) + + assert result.valid is False + assert any("Verification method not found" in e for e in result.errors) + + +class TestEd25519ProofEdgeCases: + """Tests for Ed25519 proof verification edge cases.""" + + def test_ed25519_proof_with_base64_non_multibase(self) -> None: + """Ed25519 proof with standard base64 (not multibase) is decoded.""" + vm = VerificationMethod( + id="did:key:z6Mk...#key-1", + type="Ed25519VerificationKey2020", + controller="did:key:z6Mk...", + public_key_jwk={"kty": "OKP", "crv": "Ed25519", "x": "abc"}, + ) + + verifier = CredentialVerifier() + # Non-multibase (no 'z' prefix) - should decode as base64 + proof = { + "type": "Ed25519Signature2020", + "proofValue": base64.b64encode(b"signature-bytes").decode(), + } + + # This will fail signature verification but should decode the signature + result = verifier._verify_ed25519_proof({"id": "test"}, proof, vm) + + # Returns None or False (verification fails but decoding works) + assert result in (None, False) + + def test_ed25519_proof_create_verify_data_failure(self) -> None: + """Ed25519 proof handles verify data creation failure.""" + vm = VerificationMethod( + id="did:key:z6Mk...#key-1", + type="Ed25519VerificationKey2020", + controller="did:key:z6Mk...", + public_key_jwk={"kty": "OKP", "crv": "Ed25519", "x": "abc"}, + ) + + verifier = CredentialVerifier() + # Mock _create_verify_data to return None + verifier._create_verify_data = lambda _c, _p: None # type: ignore[method-assign] + + proof = { + "type": "Ed25519Signature2020", + "proofValue": "zbase58sig", + } + + result = verifier._verify_ed25519_proof({"id": "test"}, proof, vm) + assert result is None + + def test_ed25519_proof_invalid_signature_bytes(self) -> None: + """Ed25519 proof with invalid signature bytes returns None.""" + vm = VerificationMethod( + id="did:key:z6Mk...#key-1", + type="Ed25519VerificationKey2020", + controller="did:key:z6Mk...", + public_key_jwk={"kty": "OKP", "crv": "Ed25519", "x": "abc"}, + ) + + verifier = CredentialVerifier() + # Mock _decode_base58btc to return None + verifier._decode_base58btc = lambda _: None # type: ignore[method-assign] + + proof = { + "type": "Ed25519Signature2020", + "proofValue": "zInvalidBase58", + } + + result = verifier._verify_ed25519_proof({"id": "test"}, proof, vm) + assert result is None + + +class TestCreateVerifyDataFallback: + """Tests for URDNA2015 to JSON fallback in verify data creation.""" + + def test_create_verify_data_json_fallback(self) -> None: + """Verify data falls back to JSON when URDNA2015 fails.""" + verifier = CredentialVerifier() + # Credential with invalid @context that will fail URDNA2015 normalization + credential = { + "@context": "not-a-valid-url", + "id": "urn:uuid:test", + "type": ["VerifiableCredential"], + } + proof = { + "type": "Ed25519Signature2020", + "created": "2024-01-01", + "proofValue": "zsig...", # This should be excluded + } + + data = verifier._create_verify_data(credential, proof) + + assert data is not None + # Data should contain credential content + assert len(data) > 0 + # proofValue should always be excluded from verification data + assert b"zsig" not in data + + def test_create_verify_data_urdna2015_success(self) -> None: + """Verify data uses URDNA2015 when @context present.""" + verifier = CredentialVerifier() + credential = { + "@context": ["https://www.w3.org/2018/credentials/v1"], + "id": "urn:uuid:test", + "type": ["VerifiableCredential"], + } + proof = { + "type": "Ed25519Signature2020", + "created": "2024-01-01", + } + + data = verifier._create_verify_data(credential, proof) + + assert data is not None + # URDNA2015 produces N-Quads format + assert len(data) > 0 + + +class TestDataIntegrityProofSignatureFailure: + """Tests for signature verification failure handling.""" + + def test_signature_verification_returns_false_when_verify_fails(self) -> None: + """Signature verification returning False is captured in result.""" + vm = VerificationMethod( + id="did:web:example.com#key-1", + type="Ed25519VerificationKey2020", + controller="did:web:example.com", + public_key_jwk={"kty": "OKP", "crv": "Ed25519", "x": "abc"}, + ) + + verifier = CredentialVerifier() + + # Test _verify_ed25519_proof directly with invalid signature + credential = {"id": "test"} + proof = { + "type": "Ed25519Signature2020", + "proofValue": "zInvalidSignature", + } + + # This should return False or None (verification fails) + result = verifier._verify_ed25519_proof(credential, proof, vm) + + # Returns None or False when verification fails + assert result in (None, False) + + +class TestJWSProofVerificationPaths: + """Tests for JWS proof verification paths.""" + + def test_jws_proof_type_handled(self) -> None: + """JsonWebSignature2020 proof type is recognized.""" + vm = VerificationMethod( + id="did:web:example.com#key-1", + type="JsonWebKey2020", + controller="did:web:example.com", + public_key_jwk={"kty": "EC", "crv": "P-256", "x": "test", "y": "test"}, + ) + doc = DIDDocument( + id="did:web:example.com", + verification_method=[vm], + ) + + mock_resolver = MagicMock(spec=DIDResolver) + mock_resolver.resolve.return_value = doc + + verifier = CredentialVerifier(did_resolver=mock_resolver) + credential = { + "issuer": "did:web:example.com", + "proof": { + "type": "JsonWebSignature2020", + "verificationMethod": "did:web:example.com#key-1", + "jws": "eyJhbGciOiJFUzI1NiJ9..invalid", + }, + } + + result = verifier.verify(credential) + + # Should process the JWS proof type + assert result.verification_method == "did:web:example.com#key-1" + + def test_jws_proof_exception_handling(self) -> None: + """JWS proof verification handles exceptions gracefully.""" + vm = VerificationMethod( + id="did:web:example.com#key-1", + type="JsonWebKey2020", + controller="did:web:example.com", + public_key_jwk={"kty": "EC", "crv": "P-256", "x": "invalid", "y": "invalid"}, + ) + + verifier = CredentialVerifier() + + # Test _verify_jws_proof with invalid JWS + proof = {"jws": "completely-invalid-jws"} + result = verifier._verify_jws_proof({}, proof, vm) + + # Should return None or False on exception/verification failure + assert result in (None, False) + + +class TestCreateVerifyDataExceptionHandling: + """Tests for exception handling in verify data creation.""" + + def test_create_verify_data_handles_general_exception(self) -> None: + """Create verify data handles general exceptions.""" + verifier = CredentialVerifier() + + # Create a credential that will cause an exception during processing + # Use an object that can't be JSON serialized in the fallback + class NonSerializable: + pass + + credential = { + "@context": "invalid", + "bad_field": NonSerializable(), + } + proof = {"type": "test"} + + result = verifier._create_verify_data(credential, proof) + + # Should return None on exception + assert result is None + + +class TestDataIntegrityProofVerification: + """Tests for DataIntegrityProof type handling.""" + + def test_data_integrity_proof_type_handled(self) -> None: + """DataIntegrityProof type is recognized as Ed25519.""" + vm = VerificationMethod( + id="did:web:example.com#key-1", + type="Ed25519VerificationKey2020", + controller="did:web:example.com", + public_key_jwk={"kty": "OKP", "crv": "Ed25519", "x": "abc"}, + ) + doc = DIDDocument( + id="did:web:example.com", + verification_method=[vm], + ) + + mock_resolver = MagicMock(spec=DIDResolver) + mock_resolver.resolve.return_value = doc + + verifier = CredentialVerifier(did_resolver=mock_resolver) + credential = { + "issuer": "did:web:example.com", + "proof": { + "type": "DataIntegrityProof", + "verificationMethod": "did:web:example.com#key-1", + "proofValue": "zInvalidSig", + }, + } + + result = verifier.verify(credential) + + # Should process the DataIntegrityProof type + assert result.verification_method == "did:web:example.com#key-1" diff --git a/tests/unit/test_deep_extended.py b/tests/unit/test_deep_extended.py new file mode 100644 index 0000000..c27f555 --- /dev/null +++ b/tests/unit/test_deep_extended.py @@ -0,0 +1,473 @@ +"""Extended unit tests for deep validation behavior.""" + +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from dppvalidator.validators.deep import ( + DeepValidationResult, + DeepValidator, + LinkInfo, + RateLimiter, + RetryConfig, + validate_deep, +) +from dppvalidator.validators.results import ValidationResult + + +class TestDeepValidationResultProperties: + """Tests for DeepValidationResult properties.""" + + def test_all_errors_aggregates_from_all_sources(self) -> None: + """all_errors collects errors from root and all linked docs.""" + from dppvalidator.validators.results import ValidationError + + root_error = ValidationError( + path="$.root", + message="Root error", + code="ROOT001", + layer="model", + ) + link_error = ValidationError( + path="$.link", + message="Link error", + code="LINK001", + layer="model", + ) + + root_result = ValidationResult(valid=False, errors=[root_error]) + link_result = ValidationResult(valid=False, errors=[link_error]) + + result = DeepValidationResult( + root_result=root_result, + link_graph={"https://example.com/linked": link_result}, + ) + + all_errors = result.all_errors + assert len(all_errors) == 2 + assert ("root", root_error) in all_errors + assert ("https://example.com/linked", link_error) in all_errors + + def test_to_dict_serializes_all_fields(self) -> None: + """to_dict includes all required fields.""" + result = DeepValidationResult( + root_result=ValidationResult(valid=True), + visited_urls={"https://a.com", "https://b.com"}, + failed_urls={"https://fail.com": "Connection refused"}, + total_documents=3, + max_depth_reached=2, + cycle_detected=True, + elapsed_time_ms=150.5, + ) + + d = result.to_dict() + + assert d["valid"] is True + assert d["total_documents"] == 3 + assert d["max_depth_reached"] == 2 + assert d["cycle_detected"] is True + assert d["elapsed_time_ms"] == 150.5 + assert "https://fail.com" in d["failed_urls"] + assert len(d["visited_urls"]) == 2 + + +class TestLinkExtraction: + """Tests for link extraction from DPP documents.""" + + def test_extract_from_object_with_url_field(self) -> None: + """Extract URL from object with url field.""" + validator = DeepValidator(follow_links=["links"]) + data = {"links": [{"url": "https://example.com/a", "name": "A"}]} + + links = validator._extract_links(data, depth=0) + + assert len(links) == 1 + assert links[0].url == "https://example.com/a" + + def test_extract_from_object_with_href_field(self) -> None: + """Extract URL from object with href field.""" + validator = DeepValidator(follow_links=["links"]) + data = {"links": [{"href": "https://example.com/b", "label": "B"}]} + + links = validator._extract_links(data, depth=0) + + assert len(links) == 1 + assert links[0].url == "https://example.com/b" + + def test_extract_from_object_with_linkURL_field(self) -> None: + """Extract URL from object with linkURL field.""" + validator = DeepValidator(follow_links=["links"]) + data = {"links": [{"linkURL": "https://example.com/c"}]} + + links = validator._extract_links(data, depth=0) + + assert len(links) == 1 + assert links[0].url == "https://example.com/c" + + def test_skip_non_http_urls(self) -> None: + """Non-HTTP URLs are skipped.""" + validator = DeepValidator(follow_links=["links"]) + data = { + "links": [ + "https://example.com/valid", + "urn:uuid:123", + "file:///etc/passwd", + "ftp://ftp.example.com", + ] + } + + links = validator._extract_links(data, depth=0) + + assert len(links) == 1 + assert links[0].url == "https://example.com/valid" + + def test_extract_nested_array_paths(self) -> None: + """Extract URLs from arrays in nested paths.""" + validator = DeepValidator(follow_links=["data.items"]) + data = { + "data": { + "items": [ + "https://example.com/1", + "https://example.com/2", + ] + } + } + + links = validator._extract_links(data, depth=0) + + assert len(links) == 2 + + def test_path_not_found_returns_empty(self) -> None: + """Non-existent path returns no links.""" + validator = DeepValidator(follow_links=["nonexistent.path"]) + data = {"other": "data"} + + links = validator._extract_links(data, depth=0) + + assert len(links) == 0 + + def test_depth_incremented_correctly(self) -> None: + """Extracted links have depth incremented by 1.""" + validator = DeepValidator(follow_links=["links"]) + data = {"links": ["https://example.com/a"]} + + links = validator._extract_links(data, depth=2) + + assert links[0].depth == 3 + + def test_parent_url_stored(self) -> None: + """Parent URL is stored in LinkInfo.""" + validator = DeepValidator(follow_links=["links"]) + data = {"links": ["https://example.com/child"]} + + links = validator._extract_links(data, depth=0, parent_url="https://example.com/parent") + + assert links[0].parent_url == "https://example.com/parent" + + +class TestURLPathExtraction: + """Tests for _get_urls_at_path method.""" + + def test_simple_path(self) -> None: + """Extract from simple property path.""" + validator = DeepValidator() + data = {"link": "https://example.com"} + + urls = validator._get_urls_at_path(data, "link") + + assert urls == ["https://example.com"] + + def test_nested_path(self) -> None: + """Extract from nested path.""" + validator = DeepValidator() + data = {"level1": {"level2": "https://example.com"}} + + urls = validator._get_urls_at_path(data, "level1.level2") + + assert urls == ["https://example.com"] + + def test_array_path(self) -> None: + """Extract from array at path.""" + validator = DeepValidator() + data = {"items": [{"id": "https://a.com"}, {"id": "https://b.com"}]} + + urls = validator._get_urls_at_path(data, "items.id") + + assert "https://a.com" in urls + assert "https://b.com" in urls + + def test_missing_intermediate_returns_empty(self) -> None: + """Missing intermediate path returns empty.""" + validator = DeepValidator() + data = {"other": "value"} + + urls = validator._get_urls_at_path(data, "missing.path") + + assert urls == [] + + +class TestURLValidation: + """Tests for URL validation.""" + + def test_valid_https_url(self) -> None: + """HTTPS URL is valid.""" + validator = DeepValidator() + assert validator._is_valid_url("https://example.com/path") is True + + def test_valid_http_url(self) -> None: + """HTTP URL is valid.""" + validator = DeepValidator() + assert validator._is_valid_url("http://example.com:8080/path") is True + + def test_invalid_urn(self) -> None: + """URN is not valid HTTP URL.""" + validator = DeepValidator() + assert validator._is_valid_url("urn:uuid:123") is False + + def test_invalid_file_url(self) -> None: + """File URL is not valid.""" + validator = DeepValidator() + assert validator._is_valid_url("file:///etc/passwd") is False + + def test_invalid_relative_path(self) -> None: + """Relative path is not valid URL.""" + validator = DeepValidator() + assert validator._is_valid_url("/path/to/resource") is False + + def test_empty_string(self) -> None: + """Empty string is not valid URL.""" + validator = DeepValidator() + assert validator._is_valid_url("") is False + + +class TestAsyncValidation: + """Tests for async validation methods.""" + + @pytest.mark.asyncio + async def test_validate_with_root_url(self) -> None: + """Validation with root URL tracks it as visited.""" + validator = DeepValidator(max_depth=0) + data = {"id": "urn:uuid:123"} + + result = await validator.validate(data, root_url="https://example.com/root") + + assert "https://example.com/root" in result.visited_urls + + @pytest.mark.asyncio + async def test_cycle_detection(self) -> None: + """Cycles are detected and flagged.""" + mock_validator = MagicMock() + mock_validator.validate.return_value = ValidationResult(valid=True) + + deep_validator = DeepValidator( + validator_factory=lambda: mock_validator, + max_depth=1, + follow_links=["links"], + ) + + # Data with self-referencing link + data = {"links": ["https://example.com/self"]} + + # First validation + await deep_validator.validate(data) + + # When link is already visited, cycle should be detected + # Reset and run again with the URL already visited + deep_validator._visited = {"https://example.com/self"} + deep_validator._cycle_detected = False + + # Queue a link that's already visited + await deep_validator._pending.put( + LinkInfo(url="https://example.com/self", path="links", depth=1) + ) + await deep_validator._process_queue(mock_validator) + + assert deep_validator._cycle_detected is True + + @pytest.mark.asyncio + async def test_depth_limit_respected(self) -> None: + """Depth limit prevents deep traversal.""" + mock_validator = MagicMock() + mock_validator.validate.return_value = ValidationResult(valid=True) + + deep_validator = DeepValidator( + validator_factory=lambda: mock_validator, + max_depth=0, # Root only + ) + + data = {"credentialSubject": {"traceabilityEvents": ["https://example.com/event"]}} + result = await deep_validator.validate(data) + + # Should only validate root (depth 0) + assert result.total_documents == 1 + assert len(result.link_graph) == 0 + + @pytest.mark.asyncio + async def test_max_depth_tracked(self) -> None: + """Maximum depth reached is tracked.""" + validator = DeepValidator(max_depth=3) + data = {"id": "test"} + + result = await validator.validate(data) + + assert result.max_depth_reached >= 0 + + +class TestRateLimiterBehavior: + """Tests for rate limiter behavior.""" + + @pytest.mark.asyncio + async def test_multiple_acquires_respect_rate(self) -> None: + """Multiple acquires are rate limited.""" + import time + + limiter = RateLimiter(requests_per_second=1000.0) # Fast for testing + + start = time.monotonic() + await limiter.acquire() + await limiter.acquire() + elapsed = time.monotonic() - start + + # Should complete quickly but with small delay + assert elapsed < 0.1 + + +class TestRetryConfigBehavior: + """Tests for retry configuration.""" + + def test_get_delay_first_attempt(self) -> None: + """First attempt uses base delay.""" + config = RetryConfig(base_delay=1.0) + assert config.get_delay(0) == 1.0 + + def test_get_delay_respects_max(self) -> None: + """Delay capped at max_delay.""" + config = RetryConfig(base_delay=1.0, max_delay=5.0, exponential_base=10.0) + # 10^3 = 1000, but capped at 5 + assert config.get_delay(3) == 5.0 + + +class TestFetchWithRetry: + """Tests for fetch with retry logic.""" + + @pytest.mark.asyncio + async def test_fetch_success(self) -> None: + """Successful fetch returns data.""" + validator = DeepValidator(max_depth=1) + + mock_response = MagicMock() + mock_response.json.return_value = {"id": "test"} + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + + result = await validator._fetch_with_retry(mock_client, "https://example.com") + + assert result == {"id": "test"} + + @pytest.mark.asyncio + async def test_fetch_rate_limited_retries(self) -> None: + """Rate limited response triggers retry.""" + validator = DeepValidator(retry_config=RetryConfig(max_retries=2, base_delay=0.01)) + + # First call rate limited, second succeeds + mock_response_429 = MagicMock() + mock_response_429.status_code = 429 + + mock_response_ok = MagicMock() + mock_response_ok.json.return_value = {"id": "success"} + + mock_client = AsyncMock() + mock_client.get.side_effect = [ + httpx.HTTPStatusError("Rate limited", request=MagicMock(), response=mock_response_429), + mock_response_ok, + ] + + result = await validator._fetch_with_retry(mock_client, "https://example.com") + + assert result == {"id": "success"} + + @pytest.mark.asyncio + async def test_fetch_network_error_retries(self) -> None: + """Network error triggers retry.""" + validator = DeepValidator(retry_config=RetryConfig(max_retries=2, base_delay=0.01)) + + mock_response_ok = MagicMock() + mock_response_ok.json.return_value = {"id": "recovered"} + + mock_client = AsyncMock() + mock_client.get.side_effect = [ + httpx.RequestError("Network error"), + mock_response_ok, + ] + + result = await validator._fetch_with_retry(mock_client, "https://example.com") + + assert result == {"id": "recovered"} + + +class TestValidateDeepFunction: + """Tests for module-level validate_deep function.""" + + @pytest.mark.asyncio + async def test_validate_deep_returns_result(self) -> None: + """validate_deep returns DeepValidationResult.""" + data = {"id": "urn:uuid:123"} + + result = await validate_deep(data, max_depth=0) + + assert isinstance(result, DeepValidationResult) + + @pytest.mark.asyncio + async def test_validate_deep_custom_options(self) -> None: + """validate_deep accepts custom options.""" + data = {"id": "urn:uuid:123"} + + result = await validate_deep( + data, + max_depth=2, + follow_links=["custom.path"], + timeout=15.0, + auth_header={"Authorization": "Bearer token"}, + ) + + assert isinstance(result, DeepValidationResult) + + +class TestExtractURLsFromValue: + """Tests for _extract_urls_from_value method.""" + + def test_extract_from_string(self) -> None: + """Extract URL from string value.""" + validator = DeepValidator() + urls = validator._extract_urls_from_value("https://example.com") + + assert urls == ["https://example.com"] + + def test_extract_from_dict_with_id(self) -> None: + """Extract URL from dict with id field.""" + validator = DeepValidator() + urls = validator._extract_urls_from_value({"id": "https://example.com/id"}) + + assert urls == ["https://example.com/id"] + + def test_extract_from_list(self) -> None: + """Extract URLs from list.""" + validator = DeepValidator() + urls = validator._extract_urls_from_value( + [ + "https://a.com", + {"id": "https://b.com"}, + ] + ) + + assert "https://a.com" in urls + assert "https://b.com" in urls + + def test_skip_invalid_in_dict(self) -> None: + """Skip dict without URL fields.""" + validator = DeepValidator() + urls = validator._extract_urls_from_value({"name": "Not a URL"}) + + assert urls == [] diff --git a/tests/unit/test_deep_validation.py b/tests/unit/test_deep_validation.py new file mode 100644 index 0000000..5f7fe43 --- /dev/null +++ b/tests/unit/test_deep_validation.py @@ -0,0 +1,309 @@ +"""Unit tests for deep/recursive validation.""" + +import asyncio +from unittest.mock import MagicMock + +import pytest + +from dppvalidator.validators.deep import ( + DEFAULT_LINK_PATHS, + DeepValidationResult, + DeepValidator, + LinkInfo, + RateLimiter, + RetryConfig, + validate_deep, +) +from dppvalidator.validators.results import ValidationResult + + +class TestRateLimiter: + """Tests for RateLimiter.""" + + @pytest.mark.asyncio + async def test_rate_limiter_acquires(self) -> None: + """Rate limiter acquires without blocking on first call.""" + limiter = RateLimiter(requests_per_second=10.0) + await limiter.acquire() + # Should complete without blocking + + @pytest.mark.asyncio + async def test_rate_limiter_respects_interval(self) -> None: + """Rate limiter enforces interval between requests.""" + limiter = RateLimiter(requests_per_second=100.0) # 10ms interval + + # First acquire should be instant + await limiter.acquire() + + # Second acquire should wait ~10ms + await limiter.acquire() + # Test passes if no exception + + +class TestRetryConfig: + """Tests for RetryConfig.""" + + def test_exponential_backoff(self) -> None: + """Delay increases exponentially with attempts.""" + config = RetryConfig(base_delay=1.0, exponential_base=2.0, max_delay=30.0) + + assert config.get_delay(0) == 1.0 + assert config.get_delay(1) == 2.0 + assert config.get_delay(2) == 4.0 + assert config.get_delay(3) == 8.0 + + def test_max_delay_cap(self) -> None: + """Delay is capped at max_delay.""" + config = RetryConfig(base_delay=1.0, exponential_base=2.0, max_delay=10.0) + + assert config.get_delay(10) == 10.0 # Would be 1024 without cap + + +class TestLinkInfo: + """Tests for LinkInfo dataclass.""" + + def test_link_info_creation(self) -> None: + """LinkInfo stores all fields correctly.""" + link = LinkInfo( + url="https://example.com/dpp/123", + path="credentialSubject.traceabilityEvents", + depth=2, + parent_url="https://example.com/dpp/root", + ) + + assert link.url == "https://example.com/dpp/123" + assert link.path == "credentialSubject.traceabilityEvents" + assert link.depth == 2 + assert link.parent_url == "https://example.com/dpp/root" + + +class TestDeepValidationResult: + """Tests for DeepValidationResult.""" + + def test_valid_property_all_valid(self) -> None: + """valid is True when root and all linked documents are valid.""" + root_result = ValidationResult(valid=True) + link_graph = { + "https://a.com": ValidationResult(valid=True), + "https://b.com": ValidationResult(valid=True), + } + + result = DeepValidationResult( + root_result=root_result, + link_graph=link_graph, + ) + + assert result.valid is True + + def test_valid_property_root_invalid(self) -> None: + """valid is False when root document is invalid.""" + root_result = ValidationResult(valid=False) + link_graph = {"https://a.com": ValidationResult(valid=True)} + + result = DeepValidationResult( + root_result=root_result, + link_graph=link_graph, + ) + + assert result.valid is False + + def test_valid_property_linked_invalid(self) -> None: + """valid is False when any linked document is invalid.""" + root_result = ValidationResult(valid=True) + link_graph = { + "https://a.com": ValidationResult(valid=True), + "https://b.com": ValidationResult(valid=False), + } + + result = DeepValidationResult( + root_result=root_result, + link_graph=link_graph, + ) + + assert result.valid is False + + def test_to_dict(self) -> None: + """to_dict serializes correctly.""" + root_result = ValidationResult(valid=True, schema_version="0.6.1") + result = DeepValidationResult( + root_result=root_result, + visited_urls={"https://a.com"}, + total_documents=2, + max_depth_reached=1, + cycle_detected=False, + elapsed_time_ms=100.5, + ) + + d = result.to_dict() + + assert d["valid"] is True + assert d["total_documents"] == 2 + assert d["max_depth_reached"] == 1 + assert d["cycle_detected"] is False + assert d["elapsed_time_ms"] == 100.5 + assert "https://a.com" in d["visited_urls"] + + +class TestDeepValidator: + """Tests for DeepValidator.""" + + def test_default_link_paths(self) -> None: + """Default link paths are set.""" + validator = DeepValidator() + assert validator.follow_links == DEFAULT_LINK_PATHS + + def test_custom_link_paths(self) -> None: + """Custom link paths override defaults.""" + custom_paths = ["custom.path"] + validator = DeepValidator(follow_links=custom_paths) + assert validator.follow_links == custom_paths + + def test_max_depth_default(self) -> None: + """Default max depth is 3.""" + validator = DeepValidator() + assert validator.max_depth == 3 + + def test_extract_links_from_string_url(self) -> None: + """Links are extracted from string URLs.""" + validator = DeepValidator(follow_links=["links"]) + data = {"links": "https://example.com/dpp/123"} + + links = validator._extract_links(data, depth=0) + + assert len(links) == 1 + assert links[0].url == "https://example.com/dpp/123" + assert links[0].depth == 1 + + def test_extract_links_from_array(self) -> None: + """Links are extracted from arrays of URLs.""" + validator = DeepValidator(follow_links=["links"]) + data = { + "links": [ + "https://example.com/a", + "https://example.com/b", + ] + } + + links = validator._extract_links(data, depth=0) + + assert len(links) == 2 + assert {link.url for link in links} == { + "https://example.com/a", + "https://example.com/b", + } + + def test_extract_links_from_nested_path(self) -> None: + """Links are extracted from nested paths.""" + validator = DeepValidator(follow_links=["credentialSubject.events"]) + data = {"credentialSubject": {"events": ["https://example.com/event/1"]}} + + links = validator._extract_links(data, depth=0) + + assert len(links) == 1 + assert links[0].url == "https://example.com/event/1" + + def test_extract_links_from_object_with_id(self) -> None: + """Links are extracted from objects with id field.""" + validator = DeepValidator(follow_links=["links"]) + data = { + "links": [ + {"id": "https://example.com/a", "name": "Link A"}, + {"id": "https://example.com/b", "name": "Link B"}, + ] + } + + links = validator._extract_links(data, depth=0) + + assert len(links) == 2 + + def test_is_valid_url_http(self) -> None: + """HTTP URLs are valid.""" + validator = DeepValidator() + assert validator._is_valid_url("http://example.com") is True + + def test_is_valid_url_https(self) -> None: + """HTTPS URLs are valid.""" + validator = DeepValidator() + assert validator._is_valid_url("https://example.com/path") is True + + def test_is_valid_url_not_url(self) -> None: + """Non-URLs are invalid.""" + validator = DeepValidator() + assert validator._is_valid_url("not-a-url") is False + assert validator._is_valid_url("urn:uuid:123") is False + assert validator._is_valid_url("file:///etc/passwd") is False + + @pytest.mark.asyncio + async def test_validate_root_only(self) -> None: + """Validation works for root document without links.""" + mock_result = ValidationResult(valid=True, schema_version="0.6.1") + mock_validator = MagicMock() + mock_validator.validate.return_value = mock_result + + deep_validator = DeepValidator( + validator_factory=lambda: mock_validator, + max_depth=0, + ) + + data = {"credentialSubject": {"product": {"id": "urn:uuid:123"}}} + result = await deep_validator.validate(data) + + assert result.root_result.valid is True + assert result.total_documents == 1 + assert len(result.link_graph) == 0 + + @pytest.mark.asyncio + async def test_validate_respects_max_depth(self) -> None: + """Validation respects max_depth limit.""" + deep_validator = DeepValidator(max_depth=0) + + # Even with links, max_depth=0 won't follow them + data = {"credentialSubject": {"traceabilityEvents": ["https://example.com/event/1"]}} + + result = await deep_validator.validate(data) + + # Should only validate root (max_depth=0) + assert result.total_documents == 1 + + +class TestValidationEngineIntegration: + """Tests for ValidationEngine.validate_deep integration.""" + + def test_validate_deep_method_exists(self) -> None: + """ValidationEngine has validate_deep method.""" + from dppvalidator.validators.engine import ValidationEngine + + engine = ValidationEngine() + assert hasattr(engine, "validate_deep") + assert asyncio.iscoroutinefunction(engine.validate_deep) + + @pytest.mark.asyncio + async def test_validate_deep_returns_result(self) -> None: + """validate_deep returns DeepValidationResult.""" + from dppvalidator.validators.engine import ValidationEngine + + engine = ValidationEngine() + data = {"credentialSubject": {"product": {"id": "urn:uuid:123"}}} + + result = await engine.validate_deep(data, max_depth=0) + + assert isinstance(result, DeepValidationResult) + assert result.total_documents == 1 + + +class TestModuleExports: + """Tests for deep validation module exports.""" + + def test_validators_module_exports(self) -> None: + """validators module exports deep validation classes.""" + from dppvalidator.validators import ( + DeepValidator, + validate_deep, + ) + + assert callable(DeepValidator) + assert callable(validate_deep) + + def test_validate_deep_function(self) -> None: + """validate_deep is an async function.""" + assert asyncio.iscoroutinefunction(validate_deep) diff --git a/tests/unit/test_deep_validation_v07.py b/tests/unit/test_deep_validation_v07.py new file mode 100644 index 0000000..9654ad4 --- /dev/null +++ b/tests/unit/test_deep_validation_v07.py @@ -0,0 +1,212 @@ +"""Phase 3b acceptance tests: version-keyed deep-validation paths. + +This module covers the ``LINK_PATHS_BY_VERSION`` half of Phase 3b +(``docs/plans/UNTP_0.7.0_MIGRATION.md``): + +1. The dispatch table covers every key in :data:`SCHEMA_REGISTRY`. +2. :class:`DeepValidator` selects the correct path list when + ``follow_links`` is left at the default ``None``. +3. The ``[*]`` list-iteration token in v0.7 paths is normalised correctly + by ``_get_urls_at_path`` — paths like + ``credentialSubject.performanceClaim[*].evidence`` extract URLs from + every element of the list. +4. Backward-compat: the legacy ``DEFAULT_LINK_PATHS`` constant still + imports and matches the v0.6.1 list byte-for-byte. + +The crawl integration test (full async traversal) lives elsewhere — this +module exercises the *path table* without touching the network. +""" + +from __future__ import annotations + +import pytest + +from dppvalidator.schemas.registry import SCHEMA_REGISTRY +from dppvalidator.validators.deep import ( + DEFAULT_LINK_PATHS, + LINK_PATHS_BY_VERSION, + DeepValidator, +) + +# --------------------------------------------------------------------------- +# 1. Dispatch-table consistency +# --------------------------------------------------------------------------- + + +class TestLinkPathDispatchConsistency: + """``LINK_PATHS_BY_VERSION`` must cover every registered version.""" + + def test_every_registered_version_has_paths(self) -> None: + missing = sorted(set(SCHEMA_REGISTRY) - set(LINK_PATHS_BY_VERSION)) + assert not missing, ( + f"LINK_PATHS_BY_VERSION is missing {missing}. " + "Add entries in src/dppvalidator/validators/deep.py." + ) + + def test_no_orphan_path_lists(self) -> None: + extra = sorted(set(LINK_PATHS_BY_VERSION) - set(SCHEMA_REGISTRY)) + assert not extra, f"LINK_PATHS_BY_VERSION has entries {extra} not in SCHEMA_REGISTRY." + + def test_v07_paths_use_new_envelope_shape(self) -> None: + """v0.7 paths target the new envelope (no ``credentialSubject.product`` traversal).""" + v07_paths = LINK_PATHS_BY_VERSION["0.7.0"] + # Every path roots at credentialSubject directly (not credentialSubject.product). + for p in v07_paths: + assert "credentialSubject.product." not in p, ( + f"v0.7 path uses the v0.6 envelope shape: {p!r}" + ) + + def test_v06_paths_unchanged(self) -> None: + """v0.6 paths must not have drifted — they're a backward-compat surface.""" + assert LINK_PATHS_BY_VERSION["0.6.1"] == [ + "credentialSubject.traceabilityEvents", + "credentialSubject.conformityClaim", + "credentialSubject.product.traceabilityInfo", + "credentialSubject.materialsProvenance", + ] + + +# --------------------------------------------------------------------------- +# 2. DEFAULT_LINK_PATHS backward-compat +# --------------------------------------------------------------------------- + + +def test_default_link_paths_matches_v0_6_1() -> None: + """The old ``DEFAULT_LINK_PATHS`` constant still resolves to the v0.6.1 list. + + Anyone who imported it pre-Phase-3b sees the same value, just sourced + from the dispatch table now. + """ + assert LINK_PATHS_BY_VERSION["0.6.1"] == DEFAULT_LINK_PATHS + + +# --------------------------------------------------------------------------- +# 3. DeepValidator picks the right paths per version +# --------------------------------------------------------------------------- + + +class TestDeepValidatorVersionDispatch: + @pytest.mark.parametrize( + ("version", "expected"), + [ + ("0.6.0", LINK_PATHS_BY_VERSION["0.6.0"]), + ("0.6.1", LINK_PATHS_BY_VERSION["0.6.1"]), + ("0.7.0", LINK_PATHS_BY_VERSION["0.7.0"]), + ], + ) + def test_default_follow_links_per_version(self, version: str, expected: list[str]) -> None: + validator = DeepValidator(schema_version=version, max_depth=0) + assert validator.follow_links == expected + assert validator.schema_version == version + + def test_explicit_follow_links_override(self) -> None: + custom = ["credentialSubject.id"] + validator = DeepValidator( + schema_version="0.7.0", + follow_links=custom, + max_depth=0, + ) + assert validator.follow_links is custom + + def test_unknown_version_falls_back_to_default(self) -> None: + validator = DeepValidator(schema_version="9.9.9", max_depth=0) + assert validator.follow_links == DEFAULT_LINK_PATHS + + +# --------------------------------------------------------------------------- +# 4. ``[*]`` list-iteration token handling +# --------------------------------------------------------------------------- + + +class TestStarTokenHandling: + """The ``[*]`` token in v0.7 paths must not break path traversal.""" + + @pytest.fixture + def validator(self) -> DeepValidator: + return DeepValidator(schema_version="0.7.0", max_depth=0) + + def test_star_in_path_extracts_from_every_list_element(self, validator: DeepValidator) -> None: + """``performanceClaim[*].evidence[*].linkURL`` extracts URLs from each row.""" + data = { + "credentialSubject": { + "performanceClaim": [ + { + "evidence": [ + {"linkURL": "https://example.com/evidence/a.pdf"}, + {"linkURL": "https://example.com/evidence/b.pdf"}, + ], + }, + { + "evidence": [ + {"linkURL": "https://example.com/evidence/c.pdf"}, + ], + }, + ], + }, + } + urls = validator._get_urls_at_path(data, "credentialSubject.performanceClaim[*].evidence") + assert sorted(urls) == [ + "https://example.com/evidence/a.pdf", + "https://example.com/evidence/b.pdf", + "https://example.com/evidence/c.pdf", + ] + + def test_star_normalises_to_implicit_iteration(self, validator: DeepValidator) -> None: + """``foo[*].bar`` and ``foo.bar`` produce the same result. + + The ``[*]`` token is purely a readability aid; the underlying + resolver iterates lists implicitly. + """ + data = { + "credentialSubject": { + "relatedParty": [ + {"party": {"id": "https://example.com/party/1"}}, + {"party": {"id": "https://example.com/party/2"}}, + ], + }, + } + with_star = validator._get_urls_at_path(data, "credentialSubject.relatedParty[*].party.id") + without_star = validator._get_urls_at_path(data, "credentialSubject.relatedParty.party.id") + assert with_star == without_star + assert sorted(with_star) == [ + "https://example.com/party/1", + "https://example.com/party/2", + ] + + def test_v07_relatedDocument_extraction(self, validator: DeepValidator) -> None: + """The simple ``credentialSubject.relatedDocument`` v0.7 path works.""" + data = { + "credentialSubject": { + "relatedDocument": [ + {"linkURL": "https://example.com/spec.pdf", "name": "Spec"}, + {"linkURL": "https://example.com/care.pdf", "name": "Care"}, + ], + }, + } + urls = validator._get_urls_at_path(data, "credentialSubject.relatedDocument") + assert sorted(urls) == [ + "https://example.com/care.pdf", + "https://example.com/spec.pdf", + ] + + +# --------------------------------------------------------------------------- +# 5. Empty-data robustness +# --------------------------------------------------------------------------- + + +def test_extract_links_handles_empty_payload() -> None: + """The crawler returns no links for an empty document.""" + validator = DeepValidator(schema_version="0.7.0", max_depth=0) + links = validator._extract_links({}, depth=0) + assert links == [] + + +def test_extract_links_skips_missing_paths() -> None: + """Paths that don't resolve in the payload don't error — just yield no links.""" + validator = DeepValidator(schema_version="0.7.0", max_depth=0) + data = {"credentialSubject": {"id": "https://example.com/p/1"}} + # Only ``credentialSubject`` is populated; the v0.7 paths target deeper + # fields that aren't in this minimal doc. Should yield no links, no errors. + links = validator._extract_links(data, depth=0) + assert links == [] diff --git a/tests/unit/test_detection.py b/tests/unit/test_detection.py new file mode 100644 index 0000000..9cb31f1 --- /dev/null +++ b/tests/unit/test_detection.py @@ -0,0 +1,287 @@ +"""Unit tests for schema version auto-detection.""" + +from dppvalidator.validators.detection import ( + detect_schema_version, + is_dpp_document, +) + + +class TestDetectSchemaVersion: + """Tests for detect_schema_version function.""" + + def test_detect_from_schema_url_061(self) -> None: + """Detect version from $schema URL (0.6.1).""" + data = { + "$schema": "https://test.uncefact.org/vocabulary/untp/dpp/untp-dpp-schema-0.6.1.json", + "type": ["DigitalProductPassport"], + } + assert detect_schema_version(data) == "0.6.1" + + def test_detect_from_schema_url_060(self) -> None: + """Detect version from $schema URL (0.6.0).""" + data = { + "$schema": "https://test.uncefact.org/vocabulary/untp/dpp/untp-dpp-schema-0.6.0.json", + "type": ["DigitalProductPassport"], + } + assert detect_schema_version(data) == "0.6.0" + + def test_detect_from_context_url_061(self) -> None: + """Detect version from @context URL (0.6.1).""" + data = { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ], + "type": ["DigitalProductPassport"], + } + assert detect_schema_version(data) == "0.6.1" + + def test_detect_from_context_url_060(self) -> None: + """Detect version from @context URL (0.6.0).""" + data = { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.0/", + ], + "type": ["DigitalProductPassport"], + } + assert detect_schema_version(data) == "0.6.0" + + def test_detect_from_context_string(self) -> None: + """Detect version from @context as single string.""" + data = { + "@context": "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + "type": ["DigitalProductPassport"], + } + assert detect_schema_version(data) == "0.6.1" + + def test_schema_url_takes_priority_over_context(self) -> None: + """$schema URL takes priority over @context.""" + data = { + "$schema": "https://test.uncefact.org/vocabulary/untp/dpp/untp-dpp-schema-0.6.1.json", + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.0/", + ], + "type": ["DigitalProductPassport"], + } + assert detect_schema_version(data) == "0.6.1" + + def test_detect_from_dpp_type_fallback(self) -> None: + """Falls back to default when only type is present.""" + data = { + "type": ["DigitalProductPassport", "VerifiableCredential"], + "credentialSubject": {}, + } + # Should return default (0.6.1) when type is DPP but no version info + assert detect_schema_version(data) == "0.6.1" + + def test_detect_from_type_string(self) -> None: + """Detect DPP from type as string (not array).""" + data = {"type": "DigitalProductPassport"} + assert detect_schema_version(data) == "0.6.1" + + def test_fallback_to_default_empty_data(self) -> None: + """Falls back to default for empty data.""" + assert detect_schema_version({}) == "0.6.1" + + def test_fallback_to_default_unknown_version(self) -> None: + """Falls back to default for unknown version in $schema.""" + data = { + "$schema": "https://example.com/untp-dpp-schema-9.9.9.json", + "type": ["DigitalProductPassport"], + } + # Unknown version 9.9.9 not in registry, should fallback + assert detect_schema_version(data) == "0.6.1" + + def test_fallback_to_default_invalid_context(self) -> None: + """Falls back to default for non-UNTP context.""" + data = { + "@context": ["https://example.com/other-context"], + "type": ["DigitalProductPassport"], + } + assert detect_schema_version(data) == "0.6.1" + + def test_handles_none_schema(self) -> None: + """Handles None $schema value.""" + data = {"$schema": None, "type": ["DigitalProductPassport"]} + assert detect_schema_version(data) == "0.6.1" + + def test_handles_none_context(self) -> None: + """Handles None @context value.""" + data = {"@context": None, "type": ["DigitalProductPassport"]} + assert detect_schema_version(data) == "0.6.1" + + # ---- UNTP 0.7.0 detection (Phase 1) ---------------------------------- + + def test_detect_from_context_url_070_production(self) -> None: + """Detect 0.7.0 from the production CloudFront context URL.""" + data = { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/", + ], + "type": ["DigitalProductPassport"], + } + assert detect_schema_version(data) == "0.7.0" + + def test_detect_from_context_url_070_no_trailing_slash(self) -> None: + """``/context`` (no trailing slash) is also valid.""" + data = { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context", + ], + "type": ["DigitalProductPassport"], + } + assert detect_schema_version(data) == "0.7.0" + + def test_detect_from_schema_url_070_modern(self) -> None: + """Detect 0.7.0 from a modern ``…/v0.7.0/dpp/DigitalProductPassport.json`` URL.""" + data = { + "$schema": ( + "https://opensource.unicc.org/un/unece/uncefact/spec-untp/-/raw/" + "707cd5267deddede24bb74e453a758561972a109/artefacts/schema/v0.7.0/" + "dpp/DigitalProductPassport.json" + ), + "type": ["DigitalProductPassport"], + } + assert detect_schema_version(data) == "0.7.0" + + def test_detect_from_schema_url_070_cloudfront(self) -> None: + """Detect 0.7.0 from a CloudFront-style schema URL (no ``v`` prefix).""" + data = { + "$schema": ( + "https://vocabulary.uncefact.org/untp/0.7.0/schema/dpp/DigitalProductPassport.json" + ), + "type": ["DigitalProductPassport"], + } + assert detect_schema_version(data) == "0.7.0" + + def test_unregistered_modern_version_falls_back(self) -> None: + """An unregistered version in a modern URL falls back to default.""" + data = { + "$schema": ("https://example.com/foo/v9.9.9/bar/DigitalProductPassport.json"), + "type": ["DigitalProductPassport"], + } + # 9.9.9 is not in SCHEMA_REGISTRY so detection ignores it. + assert detect_schema_version(data) == "0.6.1" + + def test_detect_from_upstream_070_sample(self) -> None: + """Real-world: vendored 0.7.0 sample is detected as 0.7.0.""" + import json + from pathlib import Path + + sample_path = ( + Path(__file__).resolve().parents[1] + / "fixtures" + / "upstream" + / "v0.7.0" + / "samples" + / "DigitalProductPassport_instance.json" + ) + if not sample_path.is_file(): # pragma: no cover - vendored in Phase 0 + import pytest + + pytest.skip( + "Upstream 0.7.0 sample missing; vendor via Phase 0 of " + "docs/plans/UNTP_0.7.0_MIGRATION.md." + ) + with sample_path.open() as f: + data = json.load(f) + assert detect_schema_version(data) == "0.7.0" + + +class TestIsDppDocument: + """Tests for is_dpp_document function.""" + + def test_dpp_with_type_array(self) -> None: + """Identifies DPP from type array.""" + data = {"type": ["DigitalProductPassport", "VerifiableCredential"]} + assert is_dpp_document(data) is True + + def test_dpp_with_type_string(self) -> None: + """Identifies DPP from type string.""" + data = {"type": "DigitalProductPassport"} + assert is_dpp_document(data) is True + + def test_dpp_with_credential_subject(self) -> None: + """Identifies DPP from credentialSubject.""" + data = {"credentialSubject": {"product": {}}} + assert is_dpp_document(data) is True + + def test_dpp_with_untp_context(self) -> None: + """Identifies DPP from UNTP context.""" + data = {"@context": ["https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/"]} + assert is_dpp_document(data) is True + + def test_dpp_with_uncefact_context(self) -> None: + """Identifies DPP from UNCEFACT context.""" + data = {"@context": ["https://vocabulary.uncefact.org/something"]} + assert is_dpp_document(data) is True + + def test_not_dpp_empty(self) -> None: + """Empty dict is not identified as DPP.""" + assert is_dpp_document({}) is False + + def test_not_dpp_other_type(self) -> None: + """Other credential types are not DPP.""" + data = {"type": ["OtherCredential"]} + assert is_dpp_document(data) is False + + def test_not_dpp_non_dict(self) -> None: + """Non-dict input returns False.""" + assert is_dpp_document("not a dict") is False # type: ignore[arg-type] + assert is_dpp_document(None) is False # type: ignore[arg-type] + assert is_dpp_document([]) is False # type: ignore[arg-type] + + +class TestEngineAutoDetection: + """Integration tests for ValidationEngine auto-detection.""" + + def test_engine_auto_detect_from_context(self) -> None: + """Engine auto-detects version from @context.""" + from dppvalidator.validators import ValidationEngine + + engine = ValidationEngine(schema_version="auto", load_plugins=False) + + data = { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.0/", + ], + "type": ["DigitalProductPassport", "VerifiableCredential"], + "credentialSubject": {}, + } + + result = engine.validate(data) + assert result.schema_version == "0.6.0" + + def test_engine_auto_detect_default(self) -> None: + """Engine defaults to auto-detection.""" + from dppvalidator.validators import ValidationEngine + + engine = ValidationEngine(load_plugins=False) + assert engine._auto_detect is True + + def test_engine_explicit_version_no_auto(self) -> None: + """Engine with explicit version disables auto-detection.""" + from dppvalidator.validators import ValidationEngine + + engine = ValidationEngine(schema_version="0.6.1", load_plugins=False) + assert engine._auto_detect is False + + def test_engine_auto_detect_from_schema_url(self) -> None: + """Engine auto-detects version from $schema URL.""" + from dppvalidator.validators import ValidationEngine + + engine = ValidationEngine(schema_version="auto", load_plugins=False) + + data = { + "$schema": "https://test.uncefact.org/vocabulary/untp/dpp/untp-dpp-schema-0.6.1.json", + "type": ["DigitalProductPassport"], + "credentialSubject": {}, + } + + result = engine.validate(data) + assert result.schema_version == "0.6.1" diff --git a/tests/unit/test_did_resolver.py b/tests/unit/test_did_resolver.py new file mode 100644 index 0000000..610905a --- /dev/null +++ b/tests/unit/test_did_resolver.py @@ -0,0 +1,676 @@ +"""Unit tests for DID resolution behavior.""" + +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from dppvalidator.verifier.did import ( + MULTICODEC_ED25519_PUB, + MULTICODEC_P256_PUB, + DIDDocument, + DIDResolver, + VerificationMethod, + get_resolver, + resolve_did, +) + + +class TestDIDResolverConfiguration: + """Tests for DIDResolver configuration.""" + + def test_default_cache_size(self) -> None: + """Default cache size is 100.""" + resolver = DIDResolver() + assert resolver.cache_size == 100 + + def test_custom_cache_size(self) -> None: + """Cache size can be customized.""" + resolver = DIDResolver(cache_size=50) + assert resolver.cache_size == 50 + + def test_default_timeout(self) -> None: + """Default timeout is 10 seconds.""" + resolver = DIDResolver() + assert resolver.timeout == 10.0 + + def test_custom_timeout(self) -> None: + """Timeout can be customized.""" + resolver = DIDResolver(timeout=5.0) + assert resolver.timeout == 5.0 + + +class TestDIDKeyResolution: + """Tests for did:key resolution behavior.""" + + def test_resolve_ed25519_did_key(self) -> None: + """Ed25519 did:key resolves to self-describing document.""" + resolver = DIDResolver() + # Well-known Ed25519 did:key + did = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + + doc = resolver.resolve(did) + + assert doc is not None + assert doc.id == did + assert len(doc.verification_method) == 1 + assert doc.verification_method[0].type == "Ed25519VerificationKey2020" + + def test_ed25519_key_has_jwk(self) -> None: + """Ed25519 verification method has JWK with correct format.""" + resolver = DIDResolver() + did = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + + doc = resolver.resolve(did) + + assert doc is not None + jwk = doc.verification_method[0].public_key_jwk + assert jwk is not None + assert jwk["kty"] == "OKP" + assert jwk["crv"] == "Ed25519" + assert "x" in jwk + + def test_ed25519_doc_has_assertion_methods(self) -> None: + """Ed25519 DID document includes assertion method references.""" + resolver = DIDResolver() + did = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + + doc = resolver.resolve(did) + + assert doc is not None + assert len(doc.assertion_method) == 1 + assert len(doc.authentication) == 1 + + def test_unsupported_multibase_encoding(self) -> None: + """Non-z multibase prefix returns None.""" + resolver = DIDResolver() + # 'f' prefix is not supported (hex) + did = "did:key:fabcdef" + + doc = resolver.resolve(did) + assert doc is None + + def test_invalid_base58_returns_none(self) -> None: + """Invalid base58 in did:key returns None.""" + resolver = DIDResolver() + # Contains invalid characters + did = "did:key:zO0Il" + + doc = resolver.resolve(did) + assert doc is None + + +class TestDIDWebResolution: + """Tests for did:web resolution behavior.""" + + def test_did_web_url_construction_simple(self) -> None: + """Simple did:web constructs correct URL.""" + resolver = DIDResolver() + + # Mock httpx.Client + with patch.object(httpx, "Client") as mock_client_class: + mock_client = MagicMock() + mock_client_class.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_class.return_value.__exit__ = MagicMock(return_value=False) + + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "did:web:example.com", + "@context": ["https://www.w3.org/ns/did/v1"], + "verificationMethod": [], + } + mock_client.get.return_value = mock_response + + resolver._resolve_did_web("did:web:example.com") + + mock_client.get.assert_called_once_with("https://example.com/.well-known/did.json") + + def test_did_web_url_with_path(self) -> None: + """did:web with path constructs correct URL.""" + resolver = DIDResolver() + + with patch.object(httpx, "Client") as mock_client_class: + mock_client = MagicMock() + mock_client_class.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_class.return_value.__exit__ = MagicMock(return_value=False) + + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "did:web:example.com:users:alice", + "@context": ["https://www.w3.org/ns/did/v1"], + "verificationMethod": [], + } + mock_client.get.return_value = mock_response + + resolver._resolve_did_web("did:web:example.com:users:alice") + + mock_client.get.assert_called_once_with("https://example.com/users/alice/did.json") + + def test_did_web_with_port_encoding(self) -> None: + """did:web with encoded port is handled correctly.""" + resolver = DIDResolver() + + with patch.object(httpx, "Client") as mock_client_class: + mock_client = MagicMock() + mock_client_class.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_class.return_value.__exit__ = MagicMock(return_value=False) + + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "did:web:localhost:8080", + "@context": ["https://www.w3.org/ns/did/v1"], + "verificationMethod": [], + } + mock_client.get.return_value = mock_response + + resolver._resolve_did_web("did:web:localhost%3A8080") + + mock_client.get.assert_called_once_with("https://localhost:8080/.well-known/did.json") + + def test_did_web_network_error_returns_none(self) -> None: + """Network error during did:web resolution returns None.""" + resolver = DIDResolver() + + with patch.object(httpx, "Client") as mock_client_class: + mock_client = MagicMock() + mock_client_class.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_class.return_value.__exit__ = MagicMock(return_value=False) + mock_client.get.side_effect = httpx.RequestError("Network error") + + doc = resolver._resolve_did_web("did:web:unreachable.example.com") + assert doc is None + + +class TestDIDResolverCaching: + """Tests for DID document caching behavior.""" + + def test_resolved_did_is_cached(self) -> None: + """Resolved DID documents are cached.""" + resolver = DIDResolver(cache_size=10) + did = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + + doc1 = resolver.resolve(did) + doc2 = resolver.resolve(did) + + assert doc1 is doc2 # Same object + + def test_cache_respects_size_limit(self) -> None: + """Cache doesn't exceed size limit.""" + resolver = DIDResolver(cache_size=2) + + # Resolve 3 different DIDs + did1 = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + resolver.resolve(did1) + + # Cache should have 1 entry + assert len(resolver._cache) == 1 + + # When cache is full, new entries are not added + # (based on the implementation checking len < cache_size) + + def test_clear_cache_removes_all_entries(self) -> None: + """clear_cache removes all cached documents.""" + resolver = DIDResolver() + did = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + + resolver.resolve(did) + assert len(resolver._cache) == 1 + + resolver.clear_cache() + assert len(resolver._cache) == 0 + + +class TestUnsupportedDIDMethods: + """Tests for unsupported DID methods.""" + + def test_unsupported_method_returns_none(self) -> None: + """Unsupported DID method returns None.""" + resolver = DIDResolver() + doc = resolver.resolve("did:ethr:0x123") + assert doc is None + + def test_malformed_did_returns_none(self) -> None: + """Malformed DID returns None.""" + resolver = DIDResolver() + doc = resolver.resolve("not-a-did") + assert doc is None + + +class TestBase58Encoding: + """Tests for base58btc encoding/decoding.""" + + def test_decode_encodes_roundtrip(self) -> None: + """Base58 encode/decode roundtrips correctly.""" + resolver = DIDResolver() + original = b"\x01\x02\x03\x04\x05" + + encoded = resolver._encode_base58btc(original) + decoded = resolver._decode_base58btc(encoded) + + assert decoded == original + + def test_decode_handles_leading_zeros(self) -> None: + """Leading zero bytes become '1' characters.""" + resolver = DIDResolver() + data = b"\x00\x00\x01" + + encoded = resolver._encode_base58btc(data) + assert encoded.startswith("11") + + decoded = resolver._decode_base58btc(encoded) + assert decoded == data + + def test_encode_empty_bytes(self) -> None: + """Empty bytes encode to empty string.""" + resolver = DIDResolver() + encoded = resolver._encode_base58btc(b"") + assert encoded == "" + + +class TestMulticodecParsing: + """Tests for multicodec key parsing.""" + + def test_parse_ed25519_multicodec(self) -> None: + """Ed25519 multicodec prefix parsed correctly.""" + resolver = DIDResolver() + + # Create a valid Ed25519 public key (32 bytes) + public_key = bytes(32) # 32 zero bytes for testing + key_bytes = MULTICODEC_ED25519_PUB + public_key + + vm = resolver._parse_multicodec_key("did:key:z...", key_bytes) + + assert vm is not None + assert vm.type == "Ed25519VerificationKey2020" + assert vm.public_key_jwk is not None + assert vm.public_key_jwk["crv"] == "Ed25519" + + def test_parse_p256_multicodec(self) -> None: + """P-256 multicodec prefix parsed correctly.""" + resolver = DIDResolver() + + # Create mock P-256 key bytes + public_key = bytes(33) # Compressed point + key_bytes = MULTICODEC_P256_PUB + public_key + + vm = resolver._parse_multicodec_key("did:key:zDn...", key_bytes) + + assert vm is not None + assert vm.type == "JsonWebKey2020" + assert vm.public_key_jwk is not None + assert vm.public_key_jwk["crv"] == "P-256" + + def test_parse_unsupported_multicodec(self) -> None: + """Unsupported multicodec prefix returns None.""" + resolver = DIDResolver() + + # Unknown multicodec prefix + key_bytes = b"\xff\xff" + bytes(32) + + vm = resolver._parse_multicodec_key("did:key:z...", key_bytes) + assert vm is None + + def test_parse_ed25519_wrong_length(self) -> None: + """Ed25519 key with wrong length returns None.""" + resolver = DIDResolver() + + # Ed25519 needs exactly 32 bytes after prefix + key_bytes = MULTICODEC_ED25519_PUB + bytes(16) # Only 16 bytes + + vm = resolver._parse_multicodec_key("did:key:z...", key_bytes) + assert vm is None + + +class TestDIDDocumentParsing: + """Tests for DID document parsing.""" + + def test_parse_verification_methods(self) -> None: + """Verification methods are parsed from document.""" + resolver = DIDResolver() + + data = { + "id": "did:web:example.com", + "@context": ["https://www.w3.org/ns/did/v1"], + "verificationMethod": [ + { + "id": "did:web:example.com#key-1", + "type": "Ed25519VerificationKey2020", + "controller": "did:web:example.com", + "publicKeyJwk": {"kty": "OKP", "crv": "Ed25519", "x": "abc"}, + } + ], + "authentication": ["did:web:example.com#key-1"], + "assertionMethod": ["did:web:example.com#key-1"], + } + + doc = resolver._parse_did_document(data) + + assert doc.id == "did:web:example.com" + assert len(doc.verification_method) == 1 + assert doc.verification_method[0].id == "did:web:example.com#key-1" + assert doc.verification_method[0].public_key_jwk is not None + + def test_parse_context_as_string(self) -> None: + """Context as string is converted to list.""" + resolver = DIDResolver() + + data = { + "id": "did:web:example.com", + "@context": "https://www.w3.org/ns/did/v1", + "verificationMethod": [], + } + + doc = resolver._parse_did_document(data) + + assert doc.context == ["https://www.w3.org/ns/did/v1"] + + def test_parse_preserves_raw_document(self) -> None: + """Raw document is preserved in parsed result.""" + resolver = DIDResolver() + + data = { + "id": "did:web:example.com", + "@context": ["https://www.w3.org/ns/did/v1"], + "verificationMethod": [], + "customField": "value", + } + + doc = resolver._parse_did_document(data) + + assert doc.raw == data + assert doc.raw.get("customField") == "value" + + +class TestVerificationMethodKeyTypes: + """Tests for VerificationMethod key type detection.""" + + def test_key_type_p384_from_jwk(self) -> None: + """P-384 key type detected from JWK.""" + vm = VerificationMethod( + id="did:key:z...", + type="JsonWebKey2020", + controller="did:key:z...", + public_key_jwk={"kty": "EC", "crv": "P-384", "x": "x", "y": "y"}, + ) + assert vm.key_type == "P-384" + + def test_key_type_unknown_returns_none(self) -> None: + """Unknown key type returns None.""" + vm = VerificationMethod( + id="did:key:z...", + type="UnknownKeyType2030", + controller="did:key:z...", + ) + assert vm.key_type is None + + +class TestDIDDocumentMethods: + """Tests for DIDDocument helper methods.""" + + def test_get_verification_method_partial_match(self) -> None: + """Verification method found by partial fragment match.""" + vm = VerificationMethod( + id="did:web:example.com#key-1", + type="Ed25519VerificationKey2020", + controller="did:web:example.com", + ) + doc = DIDDocument( + id="did:web:example.com", + verification_method=[vm], + ) + + # Should match by fragment suffix + found = doc.get_verification_method("key-1") + assert found is vm + + def test_get_verification_method_not_found(self) -> None: + """Non-existent verification method returns None.""" + doc = DIDDocument( + id="did:web:example.com", + verification_method=[], + ) + + found = doc.get_verification_method("did:web:example.com#key-1") + assert found is None + + def test_get_assertion_methods_filters_correctly(self) -> None: + """get_assertion_methods only returns referenced methods.""" + vm1 = VerificationMethod( + id="did:web:example.com#key-1", + type="Ed25519VerificationKey2020", + controller="did:web:example.com", + ) + vm2 = VerificationMethod( + id="did:web:example.com#key-2", + type="Ed25519VerificationKey2020", + controller="did:web:example.com", + ) + + doc = DIDDocument( + id="did:web:example.com", + verification_method=[vm1, vm2], + assertion_method=["did:web:example.com#key-1"], # Only key-1 + ) + + methods = doc.get_assertion_methods() + + assert len(methods) == 1 + assert methods[0] is vm1 + + +class TestModuleFunctions: + """Tests for module-level functions.""" + + def test_get_resolver_returns_singleton(self) -> None: + """get_resolver returns the same instance.""" + resolver1 = get_resolver() + resolver2 = get_resolver() + + assert resolver1 is resolver2 + + def test_resolve_did_uses_default_resolver(self) -> None: + """resolve_did uses the module-level resolver.""" + did = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + doc = resolve_did(did) + + assert doc is not None + assert doc.id == did + + +class TestVerificationMethodKeyTypeEdgeCases: + """Tests for VerificationMethod key_type edge cases.""" + + def test_jsonwebkey2020_with_ed25519_jwk(self) -> None: + """JsonWebKey2020 type with Ed25519 JWK returns Ed25519.""" + vm = VerificationMethod( + id="did:web:example.com#key-1", + type="JsonWebKey2020", + controller="did:web:example.com", + public_key_jwk={"kty": "OKP", "crv": "Ed25519", "x": "abc"}, + ) + assert vm.key_type == "Ed25519" + + def test_jsonwebkey2020_with_p256_jwk(self) -> None: + """JsonWebKey2020 type with P-256 JWK returns P-256.""" + vm = VerificationMethod( + id="did:web:example.com#key-1", + type="JsonWebKey2020", + controller="did:web:example.com", + public_key_jwk={"kty": "EC", "crv": "P-256", "x": "x", "y": "y"}, + ) + assert vm.key_type == "P-256" + + def test_jsonwebkey2020_without_jwk_returns_none(self) -> None: + """JsonWebKey2020 without JWK returns None.""" + vm = VerificationMethod( + id="did:web:example.com#key-1", + type="JsonWebKey2020", + controller="did:web:example.com", + public_key_jwk=None, + ) + assert vm.key_type is None + + +class TestDIDDocumentAssertionMethods: + """Tests for DIDDocument assertion method handling.""" + + def test_get_assertion_methods_with_invalid_reference(self) -> None: + """Assertion method reference that doesn't exist is skipped.""" + vm = VerificationMethod( + id="did:web:example.com#key-1", + type="Ed25519VerificationKey2020", + controller="did:web:example.com", + ) + doc = DIDDocument( + id="did:web:example.com", + verification_method=[vm], + assertion_method=["did:web:example.com#nonexistent"], + ) + + methods = doc.get_assertion_methods() + assert len(methods) == 0 + + def test_get_assertion_methods_mixed_valid_invalid(self) -> None: + """Mix of valid and invalid assertion method references.""" + vm = VerificationMethod( + id="did:web:example.com#key-1", + type="Ed25519VerificationKey2020", + controller="did:web:example.com", + ) + doc = DIDDocument( + id="did:web:example.com", + verification_method=[vm], + assertion_method=[ + "did:web:example.com#key-1", + "did:web:example.com#nonexistent", + ], + ) + + methods = doc.get_assertion_methods() + assert len(methods) == 1 + assert methods[0] is vm + + +class TestAsyncDIDResolution: + """Tests for async DID resolution methods.""" + + @pytest.mark.asyncio + async def test_resolve_async_did_key(self) -> None: + """Async resolution of did:key works without network.""" + resolver = DIDResolver() + did = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + + doc = await resolver.resolve_async(did) + + assert doc is not None + assert doc.id == did + assert len(doc.verification_method) == 1 + + @pytest.mark.asyncio + async def test_resolve_async_caches_result(self) -> None: + """Async resolution caches the result.""" + resolver = DIDResolver() + did = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + + doc1 = await resolver.resolve_async(did) + doc2 = await resolver.resolve_async(did) + + assert doc1 is doc2 + + @pytest.mark.asyncio + async def test_resolve_async_unsupported_method(self) -> None: + """Async resolution of unsupported DID method returns None.""" + resolver = DIDResolver() + + doc = await resolver.resolve_async("did:ethr:0x123") + assert doc is None + + @pytest.mark.asyncio + async def test_resolve_async_did_web_network_error(self) -> None: + """Async did:web resolution handles network errors.""" + resolver = DIDResolver() + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = MagicMock() + mock_client_class.return_value.__aenter__ = MagicMock(return_value=mock_client) + mock_client_class.return_value.__aexit__ = MagicMock(return_value=None) + mock_client.get = MagicMock(side_effect=httpx.RequestError("Network error")) + + doc = await resolver._resolve_did_web_async("did:web:unreachable.com") + assert doc is None + + @pytest.mark.asyncio + async def test_resolve_async_did_web_success(self) -> None: + """Async did:web resolution succeeds with valid response.""" + from unittest.mock import AsyncMock + + resolver = DIDResolver() + + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "did:web:example.com", + "@context": ["https://www.w3.org/ns/did/v1"], + "verificationMethod": [], + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client_class.return_value.__aenter__.return_value = mock_client + mock_client_class.return_value.__aexit__.return_value = None + + doc = await resolver._resolve_did_web_async("did:web:example.com") + + assert doc is not None + assert doc.id == "did:web:example.com" + + @pytest.mark.asyncio + async def test_resolve_async_did_web_with_path(self) -> None: + """Async did:web with path constructs correct URL.""" + from unittest.mock import AsyncMock + + resolver = DIDResolver() + + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "did:web:example.com:users:alice", + "@context": ["https://www.w3.org/ns/did/v1"], + "verificationMethod": [], + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client_class.return_value.__aenter__.return_value = mock_client + mock_client_class.return_value.__aexit__.return_value = None + + doc = await resolver._resolve_did_web_async("did:web:example.com:users:alice") + + assert doc is not None + mock_client.get.assert_called_once_with("https://example.com/users/alice/did.json") + + +class TestDIDKeyExceptionHandling: + """Tests for exception handling in did:key resolution.""" + + def test_resolve_did_key_exception_returns_none(self) -> None: + """Exception during did:key resolution returns None.""" + resolver = DIDResolver() + + # Mock _decode_base58btc to raise an exception + with patch.object(resolver, "_decode_base58btc", side_effect=Exception("Decode error")): + doc = resolver._resolve_did_key("did:key:zValidButWillFail") + assert doc is None + + def test_resolve_did_key_multicodec_parse_fails(self) -> None: + """did:key with unsupported multicodec returns None.""" + resolver = DIDResolver() + + # Create a valid base58 that decodes to unsupported multicodec + with patch.object(resolver, "_decode_base58btc", return_value=b"\xff\xff" + bytes(32)): + doc = resolver._resolve_did_key("did:key:zUnknownCodec") + assert doc is None diff --git a/tests/unit/test_doctor.py b/tests/unit/test_doctor.py index 6410d9d..a53e817 100644 --- a/tests/unit/test_doctor.py +++ b/tests/unit/test_doctor.py @@ -161,7 +161,7 @@ def test_warns_when_some_missing(self): console = MagicMock() def mock_version(name): - if name == "httpx": + if name == "rich": raise Exception("Not found") return "1.0.0" diff --git a/tests/unit/test_engine_extended.py b/tests/unit/test_engine_extended.py new file mode 100644 index 0000000..65fd903 --- /dev/null +++ b/tests/unit/test_engine_extended.py @@ -0,0 +1,570 @@ +"""Extended unit tests for ValidationEngine behavior.""" + +import tempfile +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from dppvalidator.validators.engine import ValidationEngine +from dppvalidator.validators.results import ValidationResult + + +class TestEngineConfiguration: + """Tests for ValidationEngine configuration options.""" + + def test_default_max_input_size(self) -> None: + """Default max input size is 10 MB.""" + engine = ValidationEngine() + assert engine.max_input_size == 10 * 1024 * 1024 + + def test_custom_max_input_size(self) -> None: + """Max input size can be customized.""" + engine = ValidationEngine(max_input_size=1024) + assert engine.max_input_size == 1024 + + def test_disable_max_input_size(self) -> None: + """Max input size can be disabled with 0.""" + engine = ValidationEngine(max_input_size=0) + assert engine.max_input_size == 0 + + def test_auto_detect_mode_default(self) -> None: + """Auto-detect mode is enabled by default.""" + engine = ValidationEngine() + assert engine._auto_detect is True + assert engine.schema_version == "auto" + + def test_explicit_version_disables_auto_detect(self) -> None: + """Explicit version disables auto-detection.""" + engine = ValidationEngine(schema_version="0.6.1") + assert engine._auto_detect is False + assert engine.schema_version == "0.6.1" + + def test_strict_mode_configuration(self) -> None: + """Strict mode is stored correctly.""" + engine = ValidationEngine(strict_mode=True) + assert engine.strict_mode is True + + def test_layers_configuration(self) -> None: + """Custom layers override defaults.""" + engine = ValidationEngine(layers=["schema"]) + assert engine.layers == ["schema"] + + def test_default_layers(self) -> None: + """Default layers include schema, model, semantic.""" + engine = ValidationEngine() + assert "schema" in engine.layers + assert "model" in engine.layers + assert "semantic" in engine.layers + + def test_validate_jsonld_flag(self) -> None: + """validate_jsonld flag is stored.""" + engine = ValidationEngine(validate_jsonld=True) + assert engine.validate_jsonld is True + + def test_verify_signatures_flag(self) -> None: + """verify_signatures flag is stored.""" + engine = ValidationEngine(verify_signatures=True) + assert engine.verify_signatures is True + + +class TestInputParsing: + """Tests for input parsing behavior.""" + + def test_parse_dict_input(self) -> None: + """Dict input passes through unchanged.""" + engine = ValidationEngine() + data = {"id": "urn:uuid:123", "issuer": {"id": "did:web:test.com", "name": "Test"}} + + result = engine.validate(data) + # Should not fail on parsing + assert result is not None + + def test_parse_json_string_input(self) -> None: + """JSON string input is parsed.""" + engine = ValidationEngine() + json_str = '{"id": "urn:uuid:123", "issuer": {"id": "did:web:test.com", "name": "Test"}}' + + result = engine.validate(json_str) + assert result is not None + + def test_parse_invalid_json_string(self) -> None: + """Invalid JSON string returns parse error.""" + engine = ValidationEngine() + invalid_json = '{"id": "urn:uuid:123", invalid}' + + result = engine.validate(invalid_json) + assert result.valid is False + assert any(e.code == "PRS002" for e in result.errors) + + def test_parse_file_path(self) -> None: + """File path input is read and parsed.""" + engine = ValidationEngine() + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write('{"id": "urn:uuid:123", "issuer": {"id": "did:web:test.com", "name": "Test"}}') + path = Path(f.name) + + try: + result = engine.validate(path) + assert result is not None + finally: + path.unlink() + + def test_parse_nonexistent_file(self) -> None: + """Non-existent file returns file not found error.""" + engine = ValidationEngine() + path = Path("/nonexistent/path/to/file.json") + + result = engine.validate(path) + assert result.valid is False + assert any(e.code == "PRS001" for e in result.errors) + + def test_parse_invalid_json_file(self) -> None: + """File with invalid JSON returns parse error.""" + engine = ValidationEngine() + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write("{not valid json}") + path = Path(f.name) + + try: + result = engine.validate(path) + assert result.valid is False + assert any(e.code == "PRS002" for e in result.errors) + finally: + path.unlink() + + def test_input_size_limit_exceeded(self) -> None: + """Large input exceeding limit returns size error.""" + engine = ValidationEngine(max_input_size=100) + large_json = '{"data": "' + "x" * 200 + '"}' + + result = engine.validate(large_json) + assert result.valid is False + assert any(e.code == "PRS004" for e in result.errors) + + def test_input_size_limit_disabled(self) -> None: + """Disabled size limit allows large inputs.""" + engine = ValidationEngine(max_input_size=0) + large_json = ( + '{"id": "urn:uuid:123", "issuer": {"id": "did:web:test.com", "name": "Test"}, "data": "' + + "x" * 1000 + + '"}' + ) + + result = engine.validate(large_json) + # Should not fail on size check + assert not any(e.code == "PRS004" for e in result.errors) + + +class TestFailFastAndMaxErrors: + """Tests for fail_fast and max_errors behavior.""" + + def test_fail_fast_stops_on_first_error(self) -> None: + """fail_fast=True stops validation on first error.""" + engine = ValidationEngine(layers=["schema", "model"]) + + # Invalid schema should fail fast + invalid_data = {"not": "valid"} + result = engine.validate(invalid_data, fail_fast=True) + + assert result.valid is False + # Should have schema errors but stop before model errors + + def test_max_errors_limits_collected_errors(self) -> None: + """max_errors limits the number of errors collected.""" + engine = ValidationEngine() + + # Create data that will generate many errors + invalid_data = {"many": "invalid", "fields": "here"} + engine.validate(invalid_data, max_errors=1) + # Should stop after reaching max_errors + + +class TestValidateFile: + """Tests for validate_file method.""" + + def test_validate_file_with_path_object(self) -> None: + """validate_file accepts Path object.""" + engine = ValidationEngine() + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write('{"id": "urn:uuid:123", "issuer": {"id": "did:web:test.com", "name": "Test"}}') + path = Path(f.name) + + try: + result = engine.validate_file(path) + assert result is not None + finally: + path.unlink() + + def test_validate_file_with_string_path(self) -> None: + """validate_file accepts string path.""" + engine = ValidationEngine() + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write('{"id": "urn:uuid:123", "issuer": {"id": "did:web:test.com", "name": "Test"}}') + path = f.name + + try: + result = engine.validate_file(path) + assert result is not None + finally: + Path(path).unlink() + + +class TestAsyncValidation: + """Tests for async validation methods.""" + + @pytest.mark.asyncio + async def test_validate_async(self) -> None: + """validate_async returns ValidationResult.""" + engine = ValidationEngine() + data = {"id": "urn:uuid:123", "issuer": {"id": "did:web:test.com", "name": "Test"}} + + result = await engine.validate_async(data) + assert isinstance(result, ValidationResult) + + @pytest.mark.asyncio + async def test_validate_batch(self) -> None: + """validate_batch processes multiple items.""" + engine = ValidationEngine() + items = [ + {"id": "urn:uuid:1", "issuer": {"id": "did:web:test.com", "name": "Test"}}, + {"id": "urn:uuid:2", "issuer": {"id": "did:web:test.com", "name": "Test"}}, + ] + + results = await engine.validate_batch(items) + + assert len(results) == 2 + assert all(isinstance(r, ValidationResult) for r in results) + + @pytest.mark.asyncio + async def test_validate_batch_concurrency(self) -> None: + """validate_batch respects concurrency limit.""" + engine = ValidationEngine() + items = [ + {"id": f"urn:uuid:{i}", "issuer": {"id": "did:web:test.com", "name": "Test"}} + for i in range(5) + ] + + results = await engine.validate_batch(items, concurrency=2) + + assert len(results) == 5 + + +class TestVocabularyValidation: + """Tests for vocabulary validation behavior.""" + + def test_vocabulary_validation_disabled_by_default(self) -> None: + """Vocabulary validation is disabled by default.""" + engine = ValidationEngine() + assert engine.validate_vocabularies is False + assert engine._vocab_loader is None + + def test_vocabulary_validation_enabled(self) -> None: + """Vocabulary validation can be enabled.""" + engine = ValidationEngine(validate_vocabularies=True) + assert engine.validate_vocabularies is True + assert engine._vocab_loader is not None + + def test_invalid_country_code_warning(self) -> None: + """Invalid country code produces warning via VocabularyLayer.""" + from dppvalidator.validators.layers import ValidationContext, VocabularyLayer + + # Mock the vocab loader + mock_vocab_loader = MagicMock() + mock_vocab_loader.is_valid_country = MagicMock(return_value=False) + + # Create mock passport with invalid country + mock_passport = MagicMock() + mock_material = MagicMock() + mock_material.origin_country = "INVALID" + mock_passport.credential_subject.materials_provenance = [mock_material] + mock_passport.credential_subject.product = None + + # Test via VocabularyLayer directly + layer = VocabularyLayer(mock_vocab_loader, "0.6.1") + context = ValidationContext( + parsed_data={}, + schema_version="0.6.1", + ) + context.passport = mock_passport + + result = layer.execute(context) + + # Should produce warnings but remain valid + assert result.valid is True + assert any("Invalid country code" in str(w.message) for w in result.warnings) + + def test_invalid_unit_code_warning(self) -> None: + """Invalid unit code produces warning via VocabularyLayer.""" + from dppvalidator.validators.layers import ValidationContext, VocabularyLayer + + # Mock the vocab loader + mock_vocab_loader = MagicMock() + mock_vocab_loader.is_valid_country = MagicMock(return_value=True) + mock_vocab_loader.is_valid_unit = MagicMock(return_value=False) + + # Create mock passport with invalid unit + mock_passport = MagicMock() + mock_passport.credential_subject.materials_provenance = [] + mock_passport.credential_subject.product.dimensions.weight.unit = "INVALID" + mock_passport.credential_subject.product.dimensions.length = None + mock_passport.credential_subject.product.dimensions.width = None + mock_passport.credential_subject.product.dimensions.height = None + mock_passport.credential_subject.product.dimensions.volume = None + + # Test via VocabularyLayer directly + layer = VocabularyLayer(mock_vocab_loader, "0.6.1") + context = ValidationContext( + parsed_data={}, + schema_version="0.6.1", + ) + context.passport = mock_passport + + result = layer.execute(context) + + # Should produce warnings but remain valid + assert result.valid is True + assert any("Invalid unit code" in str(w.message) for w in result.warnings) + + +class TestPluginValidation: + """Tests for plugin validator behavior.""" + + def test_plugins_loaded_by_default(self) -> None: + """Plugins are loaded by default.""" + engine = ValidationEngine() + assert engine._load_plugins is True + + def test_plugins_can_be_disabled(self) -> None: + """Plugins can be disabled.""" + engine = ValidationEngine(load_plugins=False) + assert engine._load_plugins is False + assert engine._plugin_registry is None + + def test_plugin_validators_run(self) -> None: + """Plugin validators are executed via PluginLayer.""" + from dppvalidator.validators.layers import PluginLayer, ValidationContext + + # Mock the plugin registry + mock_registry = MagicMock() + mock_registry.run_all_validators = MagicMock(return_value=[]) + + # Test via PluginLayer directly + layer = PluginLayer(mock_registry, "0.6.1") + context = ValidationContext( + parsed_data={}, + schema_version="0.6.1", + ) + context.passport = MagicMock() + + result = layer.execute(context) + assert isinstance(result, ValidationResult) + mock_registry.run_all_validators.assert_called_once() + + +class TestSignatureVerification: + """Tests for signature verification behavior.""" + + def test_signature_verification_disabled_by_default(self) -> None: + """Signature verification is disabled by default.""" + engine = ValidationEngine() + assert engine.verify_signatures is False + + def test_signature_verification_enabled(self) -> None: + """Signature verification can be enabled.""" + engine = ValidationEngine(verify_signatures=True) + assert engine.verify_signatures is True + assert engine._credential_verifier is not None + + def test_verify_credential_returns_result(self) -> None: + """SignatureLayer returns ValidationResult.""" + from dppvalidator.validators.layers import SignatureLayer, ValidationContext + + # Mock the credential verifier + mock_verifier = MagicMock() + mock_vc_result = MagicMock() + mock_vc_result.valid = True + mock_vc_result.errors = [] + mock_vc_result.warnings = [] + mock_vc_result.signature_valid = True + mock_vc_result.issuer_did = "did:web:example.com" + mock_vc_result.verification_method = "did:web:example.com#key-1" + mock_verifier.verify = MagicMock(return_value=mock_vc_result) + + # Test via SignatureLayer directly + layer = SignatureLayer(mock_verifier, "0.6.1") + context = ValidationContext( + parsed_data={"issuer": "did:web:example.com"}, + schema_version="0.6.1", + ) + + result = layer.execute(context) + assert isinstance(result, ValidationResult) + assert result.signature_valid is True + + +class TestJSONLDValidation: + """Tests for JSON-LD validation behavior.""" + + def test_jsonld_validation_disabled_by_default(self) -> None: + """JSON-LD validation is disabled by default.""" + engine = ValidationEngine() + assert engine.validate_jsonld is False + + def test_jsonld_validation_enabled_via_flag(self) -> None: + """JSON-LD validation can be enabled via flag.""" + engine = ValidationEngine(validate_jsonld=True) + assert engine.validate_jsonld is True + + def test_jsonld_validation_enabled_via_layers(self) -> None: + """JSON-LD validation can be enabled via layers.""" + engine = ValidationEngine(layers=["schema", "model", "jsonld"]) + assert "jsonld" in engine.layers + + +class TestAutoDetection: + """Tests for schema version auto-detection.""" + + def test_auto_detect_from_context(self) -> None: + """Schema version auto-detected from @context.""" + engine = ValidationEngine(schema_version="auto") + + data = { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ], + "type": ["DigitalProductPassport", "VerifiableCredential"], + "id": "urn:uuid:123", + "issuer": {"id": "did:web:test.com", "name": "Test"}, + } + + result = engine.validate(data) + + # Should detect version and validate + assert result.schema_version is not None + + def test_explicit_version_skips_detection(self) -> None: + """Explicit version skips auto-detection.""" + engine = ValidationEngine(schema_version="0.6.1") + + data = { + "id": "urn:uuid:123", + "issuer": {"id": "did:web:test.com", "name": "Test"}, + } + + result = engine.validate(data) + + assert result.schema_version == "0.6.1" + + +class TestValidationLayers: + """Tests for validation layer execution.""" + + def test_schema_layer_only(self) -> None: + """Can run schema layer only.""" + engine = ValidationEngine(layers=["schema"]) + + data = {"id": "urn:uuid:123", "issuer": {"id": "did:web:test.com", "name": "Test"}} + result = engine.validate(data) + + assert result is not None + + def test_model_layer_only(self) -> None: + """Can run model layer only.""" + engine = ValidationEngine(layers=["model"]) + + data = {"id": "urn:uuid:123", "issuer": {"id": "did:web:test.com", "name": "Test"}} + result = engine.validate(data) + + assert result is not None + + def test_semantic_layer_requires_passport(self) -> None: + """Semantic layer requires parsed passport.""" + engine = ValidationEngine(layers=["semantic"]) + + # Without model layer, passport won't be available + data = {"id": "urn:uuid:123", "issuer": {"id": "did:web:test.com", "name": "Test"}} + result = engine.validate(data) + + # Should complete but semantic rules won't run without passport + assert result is not None + + +class TestEngineImportErrorHandling: + """Tests for import error handling in initialization.""" + + def test_shacl_not_available_raises_import_error(self) -> None: + """Enabling SHACL without rdflib raises ImportError.""" + from unittest.mock import patch + + with patch("dppvalidator.validators.engine.is_shacl_available", return_value=False): + with pytest.raises(ImportError) as exc_info: + ValidationEngine(enable_shacl=True) + + assert "rdf" in str(exc_info.value).lower() + + def test_jsonld_not_available_raises_import_error(self) -> None: + """Enabling JSON-LD without pyld raises ImportError.""" + from unittest.mock import patch + + with patch("dppvalidator.validators.engine._is_jsonld_available", return_value=False): + with pytest.raises(ImportError) as exc_info: + ValidationEngine(validate_jsonld=True) + + assert "pyld" in str(exc_info.value).lower() + + def test_crypto_not_available_raises_import_error(self) -> None: + """Enabling signature verification without cryptography raises ImportError.""" + from unittest.mock import patch + + with patch("dppvalidator.validators.engine._is_crypto_available", return_value=False): + with pytest.raises(ImportError) as exc_info: + ValidationEngine(verify_signatures=True) + + assert "cryptography" in str(exc_info.value).lower() + + +class TestEngineInitializationFailures: + """Tests for graceful handling of initialization failures.""" + + def test_jsonld_validator_init_failure_handled(self) -> None: + """JSON-LD validator import failure is handled gracefully.""" + import sys + from unittest.mock import patch + + engine = ValidationEngine(validate_jsonld=False) + + # Remove the module from cache to force reimport + original_module = sys.modules.get("dppvalidator.validators.jsonld_semantic") + + with patch.dict(sys.modules, {"dppvalidator.validators.jsonld_semantic": None}): + # Should not raise, just log warning + engine._init_jsonld_validator("0.6.1") + + # Restore module + if original_module: + sys.modules["dppvalidator.validators.jsonld_semantic"] = original_module + + # The validator should be None or initialized depending on import + assert engine._jsonld_validator is None or engine._jsonld_validator is not None + + def test_plugin_registry_init_failure_handled(self) -> None: + """Plugin registry initialization failure is handled gracefully.""" + from unittest.mock import patch + + engine = ValidationEngine(load_plugins=False) + engine._plugin_registry = None # Reset + + with patch( + "dppvalidator.plugins.registry.get_default_registry", + side_effect=TypeError("registry error"), + ): + engine._init_plugin_registry() + + # Should handle gracefully - may or may not be None depending on import path + assert True # Test passes if no exception raised diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index e2e83a4..517aaea 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -75,10 +75,25 @@ def test_finds_similar_type_values(self): assert "DigitalProductPassport" in matches def test_finds_similar_granularity_values(self): - """Finds similar granularity level values.""" + """Finds similar granularity level values (v0.6 spelling).""" matches = find_similar_values("itme", "granularityLevel") assert "item" in matches + def test_finds_similar_id_granularity_values(self): + """Finds similar id granularity values (v0.7 spelling). + + ``idGranularity`` is the v0.7 rename of ``granularityLevel``; + it shares the same enum values, so typo suggestions must work + for users on either UNTP version. + """ + matches = find_similar_values("itme", "idGranularity") + assert "item" in matches + + def test_finds_similar_party_role_values(self): + """Finds similar PartyRole.role values (v0.7 only).""" + matches = find_similar_values("manufactrer", "role") + assert "manufacturer" in matches + def test_finds_similar_scope_values(self): """Finds similar operational scope values.""" matches = find_similar_values("Scope1", "operationalScope") @@ -145,10 +160,47 @@ def test_type_values_include_dpp(self): assert "VerifiableCredential" in KNOWN_VALUES["type"] def test_granularity_levels_defined(self): - """Granularity levels are defined.""" - assert "item" in KNOWN_VALUES["granularityLevel"] - assert "batch" in KNOWN_VALUES["granularityLevel"] - assert "model" in KNOWN_VALUES["granularityLevel"] + """Granularity levels are defined under both v0.6 and v0.7 spellings.""" + for spelling in ("granularityLevel", "idGranularity"): + assert "item" in KNOWN_VALUES[spelling] + assert "batch" in KNOWN_VALUES[spelling] + assert "model" in KNOWN_VALUES[spelling] + # Both spellings share the same value list — drift would mean + # one version's users get worse typo suggestions than the other. + assert KNOWN_VALUES["granularityLevel"] == KNOWN_VALUES["idGranularity"] + + def test_party_role_values_defined(self): + """v0.7 PartyRole.role enum is exposed for typo suggestions.""" + assert "manufacturer" in KNOWN_VALUES["role"] + assert "recycler" in KNOWN_VALUES["role"] + assert "brandOwner" in KNOWN_VALUES["role"] + + def test_party_role_values_match_v07_enum(self): + """``KNOWN_VALUES['role']`` mirrors ``PartyRoleEnum`` exactly. + + Drift catch: adding a new role to the enum without updating + the suggestion list silently degrades typo support for that + role. Pinning equality here forces both surfaces to evolve + together. + """ + from dppvalidator.models.v0_7.identifiers import PartyRoleEnum + + enum_values = {member.value for member in PartyRoleEnum} + known_values = set(KNOWN_VALUES["role"]) + assert enum_values == known_values, ( + "PartyRoleEnum and KNOWN_VALUES['role'] disagree: " + f"in enum but not known: {enum_values - known_values}; " + f"known but not in enum: {known_values - enum_values}" + ) + + def test_id_granularity_values_match_v07_enum(self): + """``KNOWN_VALUES['idGranularity']`` mirrors ``IdGranularity`` exactly.""" + from dppvalidator.models.v0_7.product import IdGranularity + + enum_values = {member.value for member in IdGranularity} + assert enum_values == set(KNOWN_VALUES["idGranularity"]), ( + "IdGranularity enum and KNOWN_VALUES['idGranularity'] are out of sync." + ) def test_operational_scopes_defined(self): """Operational scopes are defined.""" diff --git a/tests/unit/test_eudpp_actors.py b/tests/unit/test_eudpp_actors.py new file mode 100644 index 0000000..eebfe87 --- /dev/null +++ b/tests/unit/test_eudpp_actors.py @@ -0,0 +1,389 @@ +"""Tests for EU DPP Core Ontology actors and roles (Phase 2).""" + +import pytest + +from dppvalidator.vocabularies.eudpp_actors import ( + ACTOR_HIERARCHY, + ROLE_HIERARCHY, + Actor, + AuthorisedRepresentativeRole, + AuthorityRole, + ConformityAssessmentBodyRole, + ConsumerRole, + CustomerRole, + CustomsAuthorityRole, + DealerRole, + DistributorRole, + DPPServiceProviderRole, + EndUserRole, + EUDPPActorClass, + EUDPPRoleClass, + Facility, + FulfilmentServiceProviderRole, + ImporterRole, + IndependentOperatorRole, + LegalPerson, + ManufacturerRole, + MarketSurveillanceAuthorityRole, + NaturalPerson, + NotifiedBodyRole, + ProfessionalRepairerRole, + RecyclerRole, + RefurbisherRole, + RemanufacturerRole, + Role, + get_actor_hierarchy, + get_all_circular_economy_roles, + get_all_economic_operator_roles, + get_role_hierarchy, + is_economic_operator_role, + is_role_subclass_of, +) + + +class TestEUDPPActorClass: + """Tests for EUDPPActorClass enum.""" + + def test_actor_classes_exist(self): + """Test actor class URIs exist.""" + assert EUDPPActorClass.ACTOR.value == "eudpp:Actor" + assert EUDPPActorClass.LEGAL_PERSON.value == "eudpp:LegalPerson" + assert EUDPPActorClass.NATURAL_PERSON.value == "eudpp:NaturalPerson" + assert EUDPPActorClass.FACILITY.value == "eudpp:Facility" + + +class TestEUDPPRoleClass: + """Tests for EUDPPRoleClass enum.""" + + def test_base_role_exists(self): + """Test base role URI exists.""" + assert EUDPPRoleClass.ROLE.value == "eudpp:Role" + + def test_economic_operator_roles_exist(self): + """Test economic operator role URIs exist.""" + assert EUDPPRoleClass.ECONOMIC_OPERATOR.value == "eudpp:EconomicOperatorRole" + assert EUDPPRoleClass.MANUFACTURER.value == "eudpp:ManufacturerRole" + assert EUDPPRoleClass.IMPORTER.value == "eudpp:ImporterRole" + assert EUDPPRoleClass.DISTRIBUTOR.value == "eudpp:DistributorRole" + assert EUDPPRoleClass.DEALER.value == "eudpp:DealerRole" + assert EUDPPRoleClass.FULFILMENT_PROVIDER.value == "eudpp:FulfilmentServiceProviderRole" + assert EUDPPRoleClass.AUTHORISED_REP.value == "eudpp:AuthorisedRepresentativeRole" + + def test_authority_roles_exist(self): + """Test authority role URIs exist.""" + assert EUDPPRoleClass.AUTHORITY.value == "eudpp:AuthorityRole" + assert EUDPPRoleClass.MARKET_SURVEILLANCE.value == "eudpp:MarketSurveillanceAuthorityRole" + assert EUDPPRoleClass.CUSTOMS.value == "eudpp:CustomsAuthorityRole" + + def test_circular_economy_roles_exist(self): + """Test circular economy role URIs exist.""" + assert EUDPPRoleClass.RECYCLER.value == "eudpp:RecyclerRole" + assert EUDPPRoleClass.REFURBISHER.value == "eudpp:RefurbisherRole" + assert EUDPPRoleClass.REMANUFACTURER.value == "eudpp:RemanufacturerRole" + + +class TestActor: + """Tests for Actor dataclass.""" + + def test_create_actor(self): + """Test creating an actor.""" + actor = Actor( + actor_name="ACME Corporation", + electronic_contact="info@acme.com", + postal_address="123 Main St, City, Country", + registered_trade_name="ACME Corp", + registered_trademark="ACME™", + ) + assert actor._class_uri == "eudpp:Actor" + assert actor.actor_name == "ACME Corporation" + assert actor.electronic_contact == "info@acme.com" + + def test_create_actor_minimal(self): + """Test creating actor with minimal fields.""" + actor = Actor() + assert actor._class_uri == "eudpp:Actor" + assert actor.actor_name is None + + def test_actor_immutable(self): + """Test actor is immutable.""" + actor = Actor(actor_name="Test") + with pytest.raises(AttributeError): + actor.actor_name = "Modified" # type: ignore[misc] + + +class TestLegalPerson: + """Tests for LegalPerson dataclass.""" + + def test_create_legal_person(self): + """Test creating a legal person.""" + lp = LegalPerson( + actor_name="Tech Corp GmbH", + unique_operator_id="urn:uuid:12345678-1234-1234-1234-123456789012", + established_in="DE", + ) + assert lp._class_uri == "eudpp:LegalPerson" + assert lp.unique_operator_id == "urn:uuid:12345678-1234-1234-1234-123456789012" + assert lp.established_in == "DE" + + def test_legal_person_inherits_actor(self): + """Test LegalPerson inherits from Actor.""" + lp = LegalPerson( + actor_name="Test Corp", + electronic_contact="test@corp.com", + ) + assert lp.actor_name == "Test Corp" + assert lp.electronic_contact == "test@corp.com" + + +class TestNaturalPerson: + """Tests for NaturalPerson dataclass.""" + + def test_create_natural_person(self): + """Test creating a natural person.""" + np = NaturalPerson( + actor_name="John Doe", + residing_in="FR", + ) + assert np._class_uri == "eudpp:NaturalPerson" + assert np.residing_in == "FR" + + def test_natural_person_inherits_actor(self): + """Test NaturalPerson inherits from Actor.""" + np = NaturalPerson(actor_name="Jane Doe") + assert np.actor_name == "Jane Doe" + + +class TestFacility: + """Tests for Facility dataclass.""" + + def test_create_facility(self): + """Test creating a facility.""" + facility = Facility( + unique_facility_id="urn:uuid:facility-12345", + name="Main Manufacturing Plant", + location="Berlin, Germany", + ) + assert facility._class_uri == "eudpp:Facility" + assert facility.unique_facility_id == "urn:uuid:facility-12345" + assert facility.name == "Main Manufacturing Plant" + + def test_facility_immutable(self): + """Test facility is immutable.""" + facility = Facility(unique_facility_id="test-id") + with pytest.raises(AttributeError): + facility.name = "Modified" # type: ignore[misc] + + +class TestRole: + """Tests for Role dataclass.""" + + def test_create_role(self): + """Test creating a role.""" + role = Role( + role_name="Test Role", + description="A test role", + ) + assert role._class_uri == "eudpp:Role" + assert role.role_name == "Test Role" + + +class TestEconomicOperatorRoles: + """Tests for economic operator role classes.""" + + def test_manufacturer_role(self): + """Test ManufacturerRole class.""" + role = ManufacturerRole() + assert role._class_uri == "eudpp:ManufacturerRole" + + def test_importer_role(self): + """Test ImporterRole class.""" + role = ImporterRole() + assert role._class_uri == "eudpp:ImporterRole" + + def test_distributor_role(self): + """Test DistributorRole class.""" + role = DistributorRole() + assert role._class_uri == "eudpp:DistributorRole" + + def test_dealer_role(self): + """Test DealerRole class.""" + role = DealerRole() + assert role._class_uri == "eudpp:DealerRole" + + def test_fulfilment_provider_role(self): + """Test FulfilmentServiceProviderRole class.""" + role = FulfilmentServiceProviderRole() + assert role._class_uri == "eudpp:FulfilmentServiceProviderRole" + + def test_authorised_rep_role(self): + """Test AuthorisedRepresentativeRole class.""" + role = AuthorisedRepresentativeRole() + assert role._class_uri == "eudpp:AuthorisedRepresentativeRole" + + +class TestAuthorityRoles: + """Tests for authority role classes.""" + + def test_authority_role(self): + """Test AuthorityRole class.""" + role = AuthorityRole() + assert role._class_uri == "eudpp:AuthorityRole" + + def test_market_surveillance_role(self): + """Test MarketSurveillanceAuthorityRole class.""" + role = MarketSurveillanceAuthorityRole() + assert role._class_uri == "eudpp:MarketSurveillanceAuthorityRole" + + def test_customs_authority_role(self): + """Test CustomsAuthorityRole class.""" + role = CustomsAuthorityRole() + assert role._class_uri == "eudpp:CustomsAuthorityRole" + + +class TestCustomerRoles: + """Tests for customer role classes.""" + + def test_customer_role(self): + """Test CustomerRole class.""" + role = CustomerRole() + assert role._class_uri == "eudpp:CustomerRole" + + def test_consumer_role(self): + """Test ConsumerRole class.""" + role = ConsumerRole() + assert role._class_uri == "eudpp:ConsumerRole" + + def test_end_user_role(self): + """Test EndUserRole class.""" + role = EndUserRole() + assert role._class_uri == "eudpp:EndUserRole" + + +class TestCircularEconomyRoles: + """Tests for circular economy role classes.""" + + def test_recycler_role(self): + """Test RecyclerRole class.""" + role = RecyclerRole() + assert role._class_uri == "eudpp:RecyclerRole" + + def test_refurbisher_role(self): + """Test RefurbisherRole class.""" + role = RefurbisherRole() + assert role._class_uri == "eudpp:RefurbisherRole" + + def test_remanufacturer_role(self): + """Test RemanufacturerRole class.""" + role = RemanufacturerRole() + assert role._class_uri == "eudpp:RemanufacturerRole" + + def test_professional_repairer_role(self): + """Test ProfessionalRepairerRole class.""" + role = ProfessionalRepairerRole() + assert role._class_uri == "eudpp:ProfessionalRepairerRole" + + def test_independent_operator_role(self): + """Test IndependentOperatorRole class.""" + role = IndependentOperatorRole() + assert role._class_uri == "eudpp:IndependentOperatorRole" + + +class TestServiceProviderRoles: + """Tests for service provider role classes.""" + + def test_dpp_service_provider_role(self): + """Test DPPServiceProviderRole class.""" + role = DPPServiceProviderRole() + assert role._class_uri == "eudpp:DPPServiceProviderRole" + + def test_conformity_body_role(self): + """Test ConformityAssessmentBodyRole class.""" + role = ConformityAssessmentBodyRole() + assert role._class_uri == "eudpp:ConformityAssessmentBodyRole" + + def test_notified_body_role(self): + """Test NotifiedBodyRole class.""" + role = NotifiedBodyRole() + assert role._class_uri == "eudpp:NotifiedBodyRole" + + +class TestRoleHierarchy: + """Tests for role hierarchy functions.""" + + def test_role_hierarchy_not_empty(self): + """Test role hierarchy is not empty.""" + assert len(ROLE_HIERARCHY) > 0 + + def test_get_economic_operator_subroles(self): + """Test getting economic operator subroles.""" + subroles = get_role_hierarchy("eudpp:EconomicOperatorRole") + assert "eudpp:ManufacturerRole" in subroles + assert "eudpp:ImporterRole" in subroles + assert "eudpp:DistributorRole" in subroles + + def test_get_authority_subroles(self): + """Test getting authority subroles.""" + subroles = get_role_hierarchy("eudpp:AuthorityRole") + assert "eudpp:MarketSurveillanceAuthorityRole" in subroles + assert "eudpp:CustomsAuthorityRole" in subroles + + def test_is_role_subclass_direct(self): + """Test direct role subclass relationship.""" + assert is_role_subclass_of("eudpp:ManufacturerRole", "eudpp:EconomicOperatorRole") + assert is_role_subclass_of("eudpp:CustomsAuthorityRole", "eudpp:AuthorityRole") + + def test_is_role_subclass_transitive(self): + """Test transitive role subclass relationship.""" + assert is_role_subclass_of("eudpp:ManufacturerRole", "eudpp:Role") + assert is_role_subclass_of("eudpp:NotifiedBodyRole", "eudpp:Role") + + def test_is_role_subclass_same(self): + """Test role is subclass of itself.""" + assert is_role_subclass_of("eudpp:ManufacturerRole", "eudpp:ManufacturerRole") + + def test_is_not_role_subclass(self): + """Test non-subclass relationship.""" + assert not is_role_subclass_of("eudpp:ManufacturerRole", "eudpp:AuthorityRole") + + +class TestActorHierarchy: + """Tests for actor hierarchy functions.""" + + def test_actor_hierarchy_not_empty(self): + """Test actor hierarchy is not empty.""" + assert len(ACTOR_HIERARCHY) > 0 + + def test_get_actor_subtypes(self): + """Test getting actor subtypes.""" + subtypes = get_actor_hierarchy("eudpp:Actor") + assert "eudpp:LegalPerson" in subtypes + assert "eudpp:NaturalPerson" in subtypes + + +class TestHelperFunctions: + """Tests for helper functions.""" + + def test_is_economic_operator_role(self): + """Test is_economic_operator_role function.""" + assert is_economic_operator_role("eudpp:ManufacturerRole") + assert is_economic_operator_role("eudpp:ImporterRole") + assert is_economic_operator_role("eudpp:EconomicOperatorRole") + assert not is_economic_operator_role("eudpp:ConsumerRole") + assert not is_economic_operator_role("eudpp:RecyclerRole") + + def test_get_all_economic_operator_roles(self): + """Test get_all_economic_operator_roles function.""" + roles = get_all_economic_operator_roles() + assert "eudpp:EconomicOperatorRole" in roles + assert "eudpp:ManufacturerRole" in roles + assert "eudpp:ImporterRole" in roles + assert len(roles) == 7 + + def test_get_all_circular_economy_roles(self): + """Test get_all_circular_economy_roles function.""" + roles = get_all_circular_economy_roles() + assert "eudpp:RecyclerRole" in roles + assert "eudpp:RefurbisherRole" in roles + assert "eudpp:RemanufacturerRole" in roles + assert "eudpp:ProfessionalRepairerRole" in roles + assert len(roles) == 4 diff --git a/tests/unit/test_eudpp_classes.py b/tests/unit/test_eudpp_classes.py new file mode 100644 index 0000000..13e302a --- /dev/null +++ b/tests/unit/test_eudpp_classes.py @@ -0,0 +1,641 @@ +"""Tests for EU DPP Core Ontology class hierarchy.""" + +from decimal import Decimal + +import pytest + +from dppvalidator.vocabularies.eudpp_classes import ( + EUDPP_CLASS_HIERARCHY, + EUDPP_DPP, + CarbonFootprint, + ClassificationCode, + DigitalInstruction, + Document, + Durability, + EmissionToAir, + EmissionToSoil, + EmissionToWater, + EnergyConsumption, + EnvironmentalFootprint, + EUDPPClass, + EUDPPProduct, + HazardousWasteAmount, + Height, + LandUse, + Length, + MaterialFootprint, + MicroplasticRelease, + NanoplasticRelease, + PackagingWasteAmount, + PlasticsWasteAmount, + QuantitativeProperty, + RecoverableRate, + RecycledMaterialsUse, + RecyclingCollectionRate, + RecyclingRate, + Reliability, + SustainableRenewableMaterialsUse, + Volume, + WaterConsumption, + Weight, + Width, + get_all_circular_economy_classes, + get_all_environmental_classes, + get_class_hierarchy, + is_subclass_of, +) + +# Note: CircularEconomyIndicator, EnvironmentalEmission, EnvironmentalPollution, +# PlasticsRelease, ProductDimension, QualityIndicator, ResourceConsumption, +# and WasteGenerationAmount are tested indirectly through their subclasses + + +class TestEUDPPClass: + """Tests for EUDPPClass enum.""" + + def test_core_classes_exist(self): + """Test core class URIs exist.""" + assert EUDPPClass.DPP.value == "eudpp:DPP" + assert EUDPPClass.PRODUCT.value == "eudpp:Product" + assert EUDPPClass.QUANTITATIVE_PROPERTY.value == "eudpp:QuantitativeProperty" + + def test_environmental_classes_exist(self): + """Test environmental class URIs exist.""" + assert EUDPPClass.ENVIRONMENTAL_FOOTPRINT.value == "eudpp:EnvironmentalFootprint" + assert EUDPPClass.CARBON_FOOTPRINT.value == "eudpp:CarbonFootprint" + assert EUDPPClass.MATERIAL_FOOTPRINT.value == "eudpp:MaterialFootprint" + + def test_emission_classes_exist(self): + """Test emission class URIs exist.""" + assert EUDPPClass.EMISSION_TO_AIR.value == "eudpp:EmissionToAir" + assert EUDPPClass.EMISSION_TO_WATER.value == "eudpp:EmissionToWater" + assert EUDPPClass.EMISSION_TO_SOIL.value == "eudpp:EmissionToSoil" + + def test_plastics_release_classes_exist(self): + """Test plastics release class URIs exist.""" + assert EUDPPClass.MICROPLASTIC_RELEASE.value == "eudpp:MicroplasticRelease" + assert EUDPPClass.NANOPLASTIC_RELEASE.value == "eudpp:NanoplasticRelease" + + def test_resource_consumption_classes_exist(self): + """Test resource consumption class URIs exist.""" + assert EUDPPClass.ENERGY_CONSUMPTION.value == "eudpp:EnergyConsumption" + assert EUDPPClass.WATER_CONSUMPTION.value == "eudpp:WaterConsumption" + assert EUDPPClass.LAND_USE.value == "eudpp:LandUse" + + def test_circular_economy_classes_exist(self): + """Test circular economy class URIs exist.""" + assert EUDPPClass.RECYCLING_RATE.value == "eudpp:RecyclingRate" + assert EUDPPClass.RECOVERABLE_RATE.value == "eudpp:RecoverableRate" + + def test_quality_classes_exist(self): + """Test quality class URIs exist.""" + assert EUDPPClass.DURABILITY.value == "eudpp:Durability" + assert EUDPPClass.RELIABILITY.value == "eudpp:Reliability" + + def test_dimension_classes_exist(self): + """Test dimension class URIs exist.""" + assert EUDPPClass.HEIGHT.value == "eudpp:Height" + assert EUDPPClass.LENGTH.value == "eudpp:Length" + assert EUDPPClass.WIDTH.value == "eudpp:Width" + assert EUDPPClass.VOLUME.value == "eudpp:Volume" + assert EUDPPClass.WEIGHT.value == "eudpp:Weight" + + +class TestQuantitativeProperty: + """Tests for QuantitativeProperty base class.""" + + def test_create_quantitative_property(self): + """Test creating a quantitative property.""" + prop = QuantitativeProperty( + numerical_value=Decimal("100.5"), + measurement_unit="kg", + ) + assert prop.numerical_value == Decimal("100.5") + assert prop.measurement_unit == "kg" + assert prop.tolerance is None + + def test_create_with_tolerance(self): + """Test creating with tolerance.""" + prop = QuantitativeProperty( + numerical_value=Decimal("100"), + measurement_unit="kg", + tolerance=Decimal("0.5"), + ) + assert prop.tolerance == Decimal("0.5") + + def test_immutable(self): + """Test property is immutable (frozen).""" + prop = QuantitativeProperty( + numerical_value=Decimal("100"), + measurement_unit="kg", + ) + with pytest.raises(AttributeError): + prop.numerical_value = Decimal("200") # type: ignore[misc] + + +class TestEnvironmentalFootprint: + """Tests for environmental footprint classes.""" + + def test_create_environmental_footprint(self): + """Test creating an environmental footprint.""" + ef = EnvironmentalFootprint( + numerical_value=Decimal("250.0"), + measurement_unit="kg CO2-eq", + ) + assert ef._class_uri == "eudpp:EnvironmentalFootprint" + assert ef.numerical_value == Decimal("250.0") + + def test_create_carbon_footprint(self): + """Test creating a carbon footprint.""" + cf = CarbonFootprint( + numerical_value=Decimal("125.5"), + measurement_unit="kg CO2-eq", + ) + assert cf._class_uri == "eudpp:CarbonFootprint" + assert cf.measurement_unit == "kg CO2-eq" + + def test_create_material_footprint(self): + """Test creating a material footprint.""" + mf = MaterialFootprint( + numerical_value=Decimal("500.0"), + measurement_unit="kg", + ) + assert mf._class_uri == "eudpp:MaterialFootprint" + + +class TestEmissions: + """Tests for emission classes.""" + + def test_create_emission_to_air(self): + """Test creating emission to air.""" + emission = EmissionToAir( + numerical_value=Decimal("10.0"), + measurement_unit="g/unit", + lifecycle_stage="manufacturing", + ) + assert emission._class_uri == "eudpp:EmissionToAir" + assert emission.lifecycle_stage == "manufacturing" + + def test_create_emission_to_water(self): + """Test creating emission to water.""" + emission = EmissionToWater( + numerical_value=Decimal("5.0"), + measurement_unit="mg/L", + ) + assert emission._class_uri == "eudpp:EmissionToWater" + + def test_create_emission_to_soil(self): + """Test creating emission to soil.""" + emission = EmissionToSoil( + numerical_value=Decimal("2.0"), + measurement_unit="mg/kg", + ) + assert emission._class_uri == "eudpp:EmissionToSoil" + + +class TestPlasticsRelease: + """Tests for plastics release classes.""" + + def test_create_microplastic_release(self): + """Test creating microplastic release.""" + release = MicroplasticRelease( + numerical_value=Decimal("0.5"), + measurement_unit="mg/wash", + lifecycle_stage="use", + ) + assert release._class_uri == "eudpp:MicroplasticRelease" + assert release.lifecycle_stage == "use" + + def test_create_nanoplastic_release(self): + """Test creating nanoplastic release.""" + release = NanoplasticRelease( + numerical_value=Decimal("0.01"), + measurement_unit="mg/wash", + ) + assert release._class_uri == "eudpp:NanoplasticRelease" + + +class TestResourceConsumption: + """Tests for resource consumption classes.""" + + def test_create_energy_consumption(self): + """Test creating energy consumption.""" + ec = EnergyConsumption( + numerical_value=Decimal("150.0"), + measurement_unit="kWh", + ) + assert ec._class_uri == "eudpp:EnergyConsumption" + + def test_create_water_consumption(self): + """Test creating water consumption.""" + wc = WaterConsumption( + numerical_value=Decimal("50.0"), + measurement_unit="L", + ) + assert wc._class_uri == "eudpp:WaterConsumption" + + def test_create_land_use(self): + """Test creating land use.""" + lu = LandUse( + numerical_value=Decimal("10.0"), + measurement_unit="m2", + ) + assert lu._class_uri == "eudpp:LandUse" + + def test_create_recycled_materials_use(self): + """Test creating recycled materials use.""" + rmu = RecycledMaterialsUse( + numerical_value=Decimal("30.0"), + measurement_unit="%", + ) + assert rmu._class_uri == "eudpp:RecycledMaterialsUse" + + def test_create_sustainable_materials_use(self): + """Test creating sustainable materials use.""" + smu = SustainableRenewableMaterialsUse( + numerical_value=Decimal("25.0"), + measurement_unit="%", + ) + assert smu._class_uri == "eudpp:SustainableRenewableMaterialsUse" + + +class TestCircularEconomyIndicators: + """Tests for circular economy indicator classes.""" + + def test_create_recycling_rate(self): + """Test creating recycling rate.""" + rr = RecyclingRate( + numerical_value=Decimal("85.0"), + measurement_unit="%", + ) + assert rr._class_uri == "eudpp:RecyclingRate" + + def test_create_recycling_collection_rate(self): + """Test creating recycling collection rate.""" + rcr = RecyclingCollectionRate( + numerical_value=Decimal("90.0"), + measurement_unit="%", + ) + assert rcr._class_uri == "eudpp:RecyclingCollectionRate" + + def test_create_recoverable_rate(self): + """Test creating recoverable rate.""" + rr = RecoverableRate( + numerical_value=Decimal("95.0"), + measurement_unit="%", + ) + assert rr._class_uri == "eudpp:RecoverableRate" + + +class TestWasteGeneration: + """Tests for waste generation classes.""" + + def test_create_hazardous_waste(self): + """Test creating hazardous waste amount.""" + hw = HazardousWasteAmount( + numerical_value=Decimal("5.0"), + measurement_unit="kg", + ) + assert hw._class_uri == "eudpp:HazardousWasteAmount" + + def test_create_packaging_waste(self): + """Test creating packaging waste amount.""" + pw = PackagingWasteAmount( + numerical_value=Decimal("2.0"), + measurement_unit="kg", + ) + assert pw._class_uri == "eudpp:PackagingWasteAmount" + + def test_create_plastics_waste(self): + """Test creating plastics waste amount.""" + pw = PlasticsWasteAmount( + numerical_value=Decimal("1.5"), + measurement_unit="kg", + ) + assert pw._class_uri == "eudpp:PlasticsWasteAmount" + + +class TestQualityIndicators: + """Tests for quality indicator classes.""" + + def test_create_durability(self): + """Test creating durability.""" + d = Durability( + numerical_value=Decimal("5.0"), + measurement_unit="years", + ) + assert d._class_uri == "eudpp:Durability" + + def test_create_reliability(self): + """Test creating reliability with MTBF.""" + r = Reliability( + numerical_value=Decimal("99.5"), + measurement_unit="%", + mtbf_hours=Decimal("10000"), + ) + assert r._class_uri == "eudpp:Reliability" + assert r.mtbf_hours == Decimal("10000") + + +class TestProductDimensions: + """Tests for product dimension classes.""" + + def test_create_height(self): + """Test creating height.""" + h = Height( + numerical_value=Decimal("50.0"), + measurement_unit="cm", + ) + assert h._class_uri == "eudpp:Height" + + def test_create_length(self): + """Test creating length.""" + length = Length( + numerical_value=Decimal("100.0"), + measurement_unit="cm", + ) + assert length._class_uri == "eudpp:Length" + + def test_create_width(self): + """Test creating width.""" + w = Width( + numerical_value=Decimal("30.0"), + measurement_unit="cm", + ) + assert w._class_uri == "eudpp:Width" + + def test_create_volume(self): + """Test creating volume.""" + v = Volume( + numerical_value=Decimal("150000.0"), + measurement_unit="cm3", + ) + assert v._class_uri == "eudpp:Volume" + + def test_create_weight(self): + """Test creating weight.""" + w = Weight( + numerical_value=Decimal("2.5"), + measurement_unit="kg", + ) + assert w._class_uri == "eudpp:Weight" + + +class TestClassHierarchy: + """Tests for class hierarchy functions.""" + + def test_class_hierarchy_not_empty(self): + """Test class hierarchy is not empty.""" + assert len(EUDPP_CLASS_HIERARCHY) > 0 + + def test_get_environmental_footprint_subclasses(self): + """Test getting environmental footprint subclasses.""" + subclasses = get_class_hierarchy("eudpp:EnvironmentalFootprint") + assert "eudpp:CarbonFootprint" in subclasses + assert "eudpp:MaterialFootprint" in subclasses + + def test_get_emission_subclasses(self): + """Test getting emission subclasses.""" + subclasses = get_class_hierarchy("eudpp:EnvironmentalEmission") + assert "eudpp:EmissionToAir" in subclasses + assert "eudpp:EmissionToWater" in subclasses + assert "eudpp:EmissionToSoil" in subclasses + + def test_get_unknown_class_returns_empty(self): + """Test getting subclasses of unknown class.""" + subclasses = get_class_hierarchy("eudpp:UnknownClass") + assert subclasses == [] + + def test_is_subclass_of_direct(self): + """Test direct subclass relationship.""" + assert is_subclass_of("eudpp:CarbonFootprint", "eudpp:EnvironmentalFootprint") + assert is_subclass_of("eudpp:EmissionToAir", "eudpp:EnvironmentalEmission") + + def test_is_subclass_of_transitive(self): + """Test transitive subclass relationship.""" + assert is_subclass_of("eudpp:CarbonFootprint", "eudpp:QuantitativeProperty") + assert is_subclass_of("eudpp:EmissionToAir", "eudpp:QuantitativeProperty") + + def test_is_subclass_of_same_class(self): + """Test class is subclass of itself.""" + assert is_subclass_of("eudpp:CarbonFootprint", "eudpp:CarbonFootprint") + + def test_is_not_subclass(self): + """Test non-subclass relationship.""" + assert not is_subclass_of("eudpp:CarbonFootprint", "eudpp:EmissionToAir") + assert not is_subclass_of("eudpp:Weight", "eudpp:CarbonFootprint") + + +class TestHelperFunctions: + """Tests for helper functions.""" + + def test_get_all_environmental_classes(self): + """Test getting all environmental classes.""" + classes = get_all_environmental_classes() + assert "eudpp:EnvironmentalFootprint" in classes + assert "eudpp:CarbonFootprint" in classes + assert "eudpp:EmissionToAir" in classes + assert "eudpp:MicroplasticRelease" in classes + assert len(classes) >= 15 + + def test_get_all_circular_economy_classes(self): + """Test getting all circular economy classes.""" + classes = get_all_circular_economy_classes() + assert "eudpp:CircularEconomyIndicator" in classes + assert "eudpp:RecyclingRate" in classes + assert "eudpp:RecycledMaterialsUse" in classes + assert len(classes) >= 6 + + +# ============================================================================= +# Phase 1: Core Entity Classes Tests (CIRPASS-2 Integration) +# ============================================================================= + + +class TestDocument: + """Tests for Document class.""" + + def test_create_document(self): + """Test creating a document.""" + doc = Document( + content_type="application/pdf", + web_link="https://example.com/manual.pdf", + title="User Manual", + language="en", + ) + assert doc._class_uri == "eudpp:Document" + assert doc.content_type == "application/pdf" + assert doc.web_link == "https://example.com/manual.pdf" + assert doc.title == "User Manual" + assert doc.language == "en" + + def test_create_document_minimal(self): + """Test creating a document with minimal fields.""" + doc = Document() + assert doc._class_uri == "eudpp:Document" + assert doc.content_type is None + assert doc.web_link is None + + def test_document_immutable(self): + """Test document is immutable (frozen).""" + doc = Document(title="Test") + with pytest.raises(AttributeError): + doc.title = "Modified" # type: ignore[misc] + + +class TestClassificationCode: + """Tests for ClassificationCode class.""" + + def test_create_classification_code(self): + """Test creating a classification code.""" + code = ClassificationCode( + code_set="TARIC", + code_value="8471300000", + description="Portable digital automatic data processing machines", + ) + assert code._class_uri == "eudpp:ClassificationCode" + assert code.code_set == "TARIC" + assert code.code_value == "8471300000" + assert code.description == "Portable digital automatic data processing machines" + + def test_create_hs_code(self): + """Test creating an HS code classification.""" + code = ClassificationCode( + code_set="HS", + code_value="6109.10", + ) + assert code.code_set == "HS" + assert code.code_value == "6109.10" + assert code.description is None + + def test_classification_code_immutable(self): + """Test classification code is immutable.""" + code = ClassificationCode(code_set="HS", code_value="1234") + with pytest.raises(AttributeError): + code.code_value = "5678" # type: ignore[misc] + + +class TestDigitalInstruction: + """Tests for DigitalInstruction class.""" + + def test_create_digital_instruction(self): + """Test creating a digital instruction.""" + instruction = DigitalInstruction( + content_type="text/html", + web_link="https://example.com/repair-guide", + title="Repair Guide", + language="en", + instruction_type="repair", + ) + assert instruction._class_uri == "eudpp:DigitalInstruction" + assert instruction.instruction_type == "repair" + assert instruction.title == "Repair Guide" + + def test_digital_instruction_inherits_document(self): + """Test DigitalInstruction inherits from Document.""" + instruction = DigitalInstruction( + content_type="application/pdf", + web_link="https://example.com/manual.pdf", + ) + # DigitalInstruction should have Document fields + assert instruction.content_type == "application/pdf" + assert instruction.web_link == "https://example.com/manual.pdf" + + +class TestEUDPPProduct: + """Tests for EUDPPProduct class.""" + + def test_create_product(self): + """Test creating a product.""" + product = EUDPPProduct( + unique_product_id="urn:uuid:12345678-1234-1234-1234-123456789012", + product_name="Sustainable Laptop", + description="Energy-efficient laptop computer", + gtin="01234567890128", + commodity_code="8471300000", + is_energy_related=True, + ) + assert product._class_uri == "eudpp:Product" + assert product.unique_product_id == "urn:uuid:12345678-1234-1234-1234-123456789012" + assert product.product_name == "Sustainable Laptop" + assert product.is_energy_related is True + + def test_create_product_minimal(self): + """Test creating a product with minimal fields.""" + product = EUDPPProduct() + assert product._class_uri == "eudpp:Product" + assert product.unique_product_id is None + assert product.gtin is None + + def test_product_immutable(self): + """Test product is immutable.""" + product = EUDPPProduct(product_name="Test") + with pytest.raises(AttributeError): + product.product_name = "Modified" # type: ignore[misc] + + +class TestEUDPP_DPP: + """Tests for EUDPP_DPP class.""" + + def test_create_dpp(self): + """Test creating a DPP.""" + dpp = EUDPP_DPP( + unique_dpp_id="https://example.com/dpp/12345", + granularity="product", + status="Active", + valid_from="2024-01-01T00:00:00Z", + valid_until="2034-01-01T00:00:00Z", + schema_version="1.0.0", + ) + assert dpp._class_uri == "eudpp:DPP" + assert dpp.unique_dpp_id == "https://example.com/dpp/12345" + assert dpp.granularity == "product" + assert dpp.status == "Active" + + def test_create_dpp_required_field(self): + """Test DPP requires unique_dpp_id.""" + dpp = EUDPP_DPP(unique_dpp_id="https://example.com/dpp/minimal") + assert dpp.unique_dpp_id == "https://example.com/dpp/minimal" + assert dpp.granularity is None + assert dpp.status is None + + def test_dpp_with_previous_link(self): + """Test DPP with link to previous version.""" + dpp = EUDPP_DPP( + unique_dpp_id="https://example.com/dpp/v2", + link_to_previous_dpp="https://example.com/dpp/v1", + last_update="2024-06-01T12:00:00Z", + ) + assert dpp.link_to_previous_dpp == "https://example.com/dpp/v1" + assert dpp.last_update == "2024-06-01T12:00:00Z" + + def test_dpp_immutable(self): + """Test DPP is immutable.""" + dpp = EUDPP_DPP(unique_dpp_id="https://example.com/dpp/test") + with pytest.raises(AttributeError): + dpp.status = "Archived" # type: ignore[misc] + + +class TestPhase1ClassHierarchy: + """Tests for Phase 1 class hierarchy additions.""" + + def test_document_in_hierarchy(self): + """Test Document is in class hierarchy.""" + assert "eudpp:Document" in EUDPP_CLASS_HIERARCHY + + def test_digital_instruction_subclass_of_document(self): + """Test DigitalInstruction is subclass of Document.""" + subclasses = get_class_hierarchy("eudpp:Document") + assert "eudpp:DigitalInstruction" in subclasses + + def test_dpp_in_hierarchy(self): + """Test DPP is in class hierarchy.""" + assert "eudpp:DPP" in EUDPP_CLASS_HIERARCHY + + def test_product_in_hierarchy(self): + """Test Product is in class hierarchy.""" + assert "eudpp:Product" in EUDPP_CLASS_HIERARCHY + + def test_classification_code_in_hierarchy(self): + """Test ClassificationCode is in class hierarchy.""" + assert "eudpp:ClassificationCode" in EUDPP_CLASS_HIERARCHY diff --git a/tests/unit/test_eudpp_export.py b/tests/unit/test_eudpp_export.py new file mode 100644 index 0000000..ef4dc20 --- /dev/null +++ b/tests/unit/test_eudpp_export.py @@ -0,0 +1,386 @@ +"""Tests for EU DPP JSON-LD export (Phase 9).""" + +import json + +import pytest + +from dppvalidator.exporters.eudpp_jsonld import ( + EUDPP_CONTEXT_URL, + EUDPPJsonLDExporter, + EUDPPTermMapper, + export_eudpp_jsonld, + export_eudpp_jsonld_dict, + get_eudpp_jsonld_context, + get_term_mapping_summary, + validate_eudpp_export, +) +from dppvalidator.vocabularies.ontology import EUDPPNamespace + + +class TestEUDPPTermMapper: + """Tests for EUDPPTermMapper class.""" + + def test_create_mapper(self): + """Test creating a term mapper.""" + mapper = EUDPPTermMapper() + assert mapper is not None + + def test_map_key_known(self): + """Test mapping known UNTP keys.""" + mapper = EUDPPTermMapper() + + # Test known mappings + assert mapper.map_key("id") == "uniqueDPPID" + assert mapper.map_key("Product") == "Product" + assert mapper.map_key("validFrom") == "validFrom" + + def test_map_key_unknown(self): + """Test mapping unknown keys returns original.""" + mapper = EUDPPTermMapper() + + assert mapper.map_key("unknownKey") == "unknownKey" + assert mapper.map_key("customField") == "customField" + + def test_map_type(self): + """Test mapping type values.""" + mapper = EUDPPTermMapper() + + # Known types get eudpp: prefix + result = mapper.map_type("DigitalProductPassport") + assert result == "eudpp:DPP" + + result = mapper.map_type("Product") + assert result == "eudpp:Product" + + def test_map_type_unknown(self): + """Test unknown types are returned unchanged.""" + mapper = EUDPPTermMapper() + + result = mapper.map_type("UnknownType") + assert result == "UnknownType" + + def test_get_eudpp_key(self): + """Test getting EU DPP key for UNTP key.""" + mapper = EUDPPTermMapper() + + assert mapper.get_eudpp_key("id") == "uniqueDPPID" + assert mapper.get_eudpp_key("unknownKey") is None + + def test_mapped_keys_list(self): + """Test getting list of mapped keys.""" + mapper = EUDPPTermMapper() + + keys = mapper.mapped_keys + assert isinstance(keys, list) + assert len(keys) > 0 + assert "id" in keys + assert "Product" in keys + + +class TestEUDPPJsonLDExporter: + """Tests for EUDPPJsonLDExporter class.""" + + def test_create_exporter_default(self): + """Test creating exporter with defaults.""" + exporter = EUDPPJsonLDExporter() + assert exporter._include_untp is False + assert exporter._map_terms is True + + def test_create_exporter_with_untp_context(self): + """Test creating exporter with UNTP context.""" + exporter = EUDPPJsonLDExporter(include_untp_context=True) + assert exporter._include_untp is True + + def test_create_exporter_no_term_mapping(self): + """Test creating exporter without term mapping.""" + exporter = EUDPPJsonLDExporter(map_terms=False) + assert exporter._map_terms is False + + +class TestGetEUDPPJsonLDContext: + """Tests for get_eudpp_jsonld_context function.""" + + def test_returns_list(self): + """Test function returns a list.""" + context = get_eudpp_jsonld_context() + assert isinstance(context, list) + + def test_contains_vc2_context(self): + """Test context contains W3C VC2.""" + context = get_eudpp_jsonld_context() + assert EUDPPNamespace.VC2.value in context + + def test_contains_eudpp_namespace(self): + """Test context contains EU DPP namespace.""" + context = get_eudpp_jsonld_context() + + # Should have a dict with eudpp key + has_eudpp = any(isinstance(c, dict) and "eudpp" in c for c in context) + assert has_eudpp + + +class TestValidateEUDPPExport: + """Tests for validate_eudpp_export function.""" + + def test_valid_export(self): + """Test validation of valid export.""" + data = { + "@context": [ + EUDPPNamespace.VC2.value, + {"eudpp": EUDPPNamespace.EUDPP.value}, + ], + "type": ["eudpp:DPP"], + } + + issues = validate_eudpp_export(data) + assert issues == [] + + def test_missing_context(self): + """Test validation detects missing @context.""" + data = {"type": ["eudpp:DPP"]} + + issues = validate_eudpp_export(data) + assert "Missing @context" in issues + + def test_missing_type(self): + """Test validation detects missing type.""" + data = { + "@context": [ + EUDPPNamespace.VC2.value, + {"eudpp": EUDPPNamespace.EUDPP.value}, + ], + } + + issues = validate_eudpp_export(data) + assert "Missing type" in issues + + def test_missing_vc2_context(self): + """Test validation detects missing VC2 context.""" + data = { + "@context": [{"eudpp": EUDPPNamespace.EUDPP.value}], + "type": ["eudpp:DPP"], + } + + issues = validate_eudpp_export(data) + assert "Missing W3C VC2 context" in issues + + def test_missing_eudpp_namespace(self): + """Test validation detects missing EU DPP namespace.""" + data = { + "@context": [EUDPPNamespace.VC2.value], + "type": ["eudpp:DPP"], + } + + issues = validate_eudpp_export(data) + assert "Missing EU DPP namespace in context" in issues + + +class TestGetTermMappingSummary: + """Tests for get_term_mapping_summary function.""" + + def test_returns_dict(self): + """Test function returns a dictionary.""" + summary = get_term_mapping_summary() + assert isinstance(summary, dict) + + def test_contains_mappings(self): + """Test summary contains expected mappings.""" + summary = get_term_mapping_summary() + + assert "id" in summary + assert summary["id"] == "uniqueDPPID" + + assert "Product" in summary + assert summary["Product"] == "Product" + + +class TestEUDPPExporterImports: + """Tests for EU DPP exporter imports from package.""" + + def test_import_from_exporters_package(self): + """Test importing from exporters package.""" + from dppvalidator.exporters import ( + EUDPP_CONTEXT_URL, + EUDPPJsonLDExporter, + EUDPPTermMapper, + export_eudpp_jsonld, + export_eudpp_jsonld_dict, + get_eudpp_jsonld_context, + get_term_mapping_summary, + validate_eudpp_export, + ) + + assert EUDPPJsonLDExporter is not None + assert EUDPPTermMapper is not None + assert EUDPP_CONTEXT_URL is not None + assert export_eudpp_jsonld is not None + assert export_eudpp_jsonld_dict is not None + assert get_eudpp_jsonld_context is not None + assert get_term_mapping_summary is not None + assert validate_eudpp_export is not None + + +class TestEUDPPContextURL: + """Tests for EUDPP_CONTEXT_URL constant.""" + + def test_context_url_defined(self): + """Test context URL is defined.""" + assert EUDPP_CONTEXT_URL is not None + assert isinstance(EUDPP_CONTEXT_URL, str) + assert "dpp" in EUDPP_CONTEXT_URL.lower() + + +class TestExporterWithMockPassport: + """Tests for exporter with mock passport data.""" + + @pytest.fixture + def mock_passport(self): + """Create a mock passport-like object for testing.""" + + class MockCredentialSubject: + granularity_level = "model" + + class MockPassport: + credential_subject = MockCredentialSubject() + + def model_dump(self, **_kwargs): # noqa: ARG002 + return { + "id": "urn:uuid:12345", + "type": ["DigitalProductPassport"], + "issuer": {"id": "did:example:issuer"}, + "validFrom": "2025-01-01T00:00:00Z", + "credentialSubject": { + "product": { + "id": "urn:gtin:1234567890123", + "name": "Test Product", + "description": "A test product", + } + }, + } + + return MockPassport() + + def test_export_dict(self, mock_passport): + """Test exporting passport to dictionary.""" + exporter = EUDPPJsonLDExporter() + result = exporter.export_dict(mock_passport) + + assert isinstance(result, dict) + assert "@context" in result + + def test_export_string(self, mock_passport): + """Test exporting passport to JSON string.""" + exporter = EUDPPJsonLDExporter() + result = exporter.export(mock_passport) + + assert isinstance(result, str) + + # Should be valid JSON + parsed = json.loads(result) + assert "@context" in parsed + + def test_export_contains_vc2_context(self, mock_passport): + """Test export contains W3C VC2 context.""" + exporter = EUDPPJsonLDExporter() + result = exporter.export_dict(mock_passport) + + assert EUDPPNamespace.VC2.value in result["@context"] + + def test_export_contains_eudpp_namespace(self, mock_passport): + """Test export contains EU DPP namespace.""" + exporter = EUDPPJsonLDExporter() + result = exporter.export_dict(mock_passport) + + # Context should contain eudpp namespace + has_eudpp = any(isinstance(c, dict) and "eudpp" in c for c in result["@context"]) + assert has_eudpp + + def test_export_with_term_mapping(self, mock_passport): + """Test export applies term mapping.""" + exporter = EUDPPJsonLDExporter(map_terms=True) + result = exporter.export_dict(mock_passport) + + # "id" should be mapped to "uniqueDPPID" + assert "uniqueDPPID" in result + + def test_export_without_term_mapping(self, mock_passport): + """Test export without term mapping.""" + exporter = EUDPPJsonLDExporter(map_terms=False) + result = exporter.export_dict(mock_passport) + + # "id" should remain "id" + assert "id" in result + + def test_export_with_untp_context(self, mock_passport): + """Test export includes UNTP context when requested.""" + exporter = EUDPPJsonLDExporter(include_untp_context=True) + result = exporter.export_dict(mock_passport) + + # Should have UNTP namespace in context + has_untp = any(isinstance(c, dict) and "untp" in c for c in result["@context"]) + assert has_untp + + def test_export_adds_schema_version(self, mock_passport): + """Test export adds schema version.""" + exporter = EUDPPJsonLDExporter() + result = exporter.export_dict(mock_passport) + + assert "schemaVersion" in result + assert "CIRPASS" in result["schemaVersion"] + + def test_export_adds_granularity(self, mock_passport): + """Test export adds granularity from credential subject.""" + exporter = EUDPPJsonLDExporter() + result = exporter.export_dict(mock_passport) + + assert "granularity" in result + assert result["granularity"] == "model" + + +class TestConvenienceFunctions: + """Tests for convenience export functions.""" + + @pytest.fixture + def mock_passport(self): + """Create a mock passport for testing.""" + + class MockCredentialSubject: + granularity_level = None + + class MockPassport: + credential_subject = MockCredentialSubject() + + def model_dump(self, **_kwargs): # noqa: ARG002 + return { + "id": "urn:uuid:test", + "type": ["DigitalProductPassport"], + } + + return MockPassport() + + def test_export_eudpp_jsonld(self, mock_passport): + """Test export_eudpp_jsonld convenience function.""" + result = export_eudpp_jsonld(mock_passport) + + assert isinstance(result, str) + parsed = json.loads(result) + assert "@context" in parsed + + def test_export_eudpp_jsonld_dict(self, mock_passport): + """Test export_eudpp_jsonld_dict convenience function.""" + result = export_eudpp_jsonld_dict(mock_passport) + + assert isinstance(result, dict) + assert "@context" in result + + def test_export_eudpp_jsonld_no_mapping(self, mock_passport): + """Test convenience function with no term mapping.""" + result = export_eudpp_jsonld_dict(mock_passport, map_terms=False) + + assert "id" in result + + def test_export_eudpp_jsonld_with_mapping(self, mock_passport): + """Test convenience function with term mapping.""" + result = export_eudpp_jsonld_dict(mock_passport, map_terms=True) + + assert "uniqueDPPID" in result diff --git a/tests/unit/test_eudpp_export_v07.py b/tests/unit/test_eudpp_export_v07.py new file mode 100644 index 0000000..37ae9e6 --- /dev/null +++ b/tests/unit/test_eudpp_export_v07.py @@ -0,0 +1,226 @@ +"""Phase 3c acceptance tests: v0.7 EU DPP JSON-LD export. + +This module covers the exporter half of Phase 3c (see +``docs/plans/UNTP_0.7.0_MIGRATION.md``): + +1. :class:`EUDPPTermMapper` indexes the right column per version. +2. :class:`EUDPPJsonLDExporter` auto-detects the source UNTP version from + the passport class's module path, and an explicit ``schema_version`` + override takes precedence. +3. v0.7 round-trip: a v0.7 sample exports to EU DPP JSON-LD with v0.7 + spellings (``itemNumber``, ``materialProvenance``, ``idGranularity``) + correctly mapped to the same EU DPP URIs as their v0.6 counterparts. +4. ``gtin`` does not appear in the v0.7 export (correctly removed). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from dppvalidator.exporters.eudpp_jsonld import ( + EUDPPJsonLDExporter, + EUDPPTermMapper, + export_eudpp_jsonld_dict, + get_term_mapping_summary, + validate_eudpp_export, +) +from dppvalidator.models import v0_7 +from dppvalidator.schemas.registry import DEFAULT_SCHEMA_VERSION + +_UPSTREAM_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "upstream" / "v0.7.0" + + +def _load_canonical() -> dict: + p = _UPSTREAM_DIR / "samples" / "DigitalProductPassport_instance.json" + if not p.is_file(): # pragma: no cover — vendored in Phase 0 + pytest.skip(f"Upstream sample missing: {p}") + with p.open(encoding="utf-8") as f: + return json.load(f) + + +@pytest.fixture +def passport_v07() -> v0_7.DigitalProductPassport: + return v0_7.DigitalProductPassport.model_validate(_load_canonical()) + + +# --------------------------------------------------------------------------- +# 1. EUDPPTermMapper picks the right column +# --------------------------------------------------------------------------- + + +class TestEudppTermMapperPerVersion: + """The mapper indexes the correct UNTP spellings per version.""" + + def test_v06_mapper_uses_canonical_spellings(self) -> None: + mapper = EUDPPTermMapper(schema_version="0.6.1") + assert mapper.map_key("serialNumber") == "uniqueProductID" + assert mapper.map_key("granularityLevel") == "granularity" + assert mapper.map_key("producedByParty") == "hasManufacturer" + assert mapper.map_key("gtin") == "GTIN" + + def test_v07_mapper_uses_renamed_spellings(self) -> None: + mapper = EUDPPTermMapper(schema_version="0.7.0") + assert mapper.map_key("itemNumber") == "uniqueProductID" + assert mapper.map_key("idGranularity") == "granularity" + assert mapper.map_key("relatedParty") == "hasManufacturer" + assert mapper.map_key("materialProvenance") == "hasMaterialProvenance" + assert mapper.map_key("performanceClaim") == "hasPerformanceClaim" + + def test_v07_mapper_does_not_recognise_v06_only_terms(self) -> None: + """v0.6-only terms (renamed in v0.7) don't resolve under the v0.7 mapper. + + ``map_key`` returns the input unchanged when the term isn't in the + mapper's index — that's the documented "passthrough" behaviour. + """ + mapper = EUDPPTermMapper(schema_version="0.7.0") + # ``serialNumber`` is the v0.6 spelling; v0.7 says ``itemNumber``. + assert mapper.map_key("serialNumber") == "serialNumber" + assert mapper.map_key("granularityLevel") == "granularityLevel" + assert mapper.map_key("materialsProvenance") == "materialsProvenance" + + def test_v07_mapper_omits_gtin(self) -> None: + """``gtin`` is removed in v0.7 → must not appear in the index.""" + mapper = EUDPPTermMapper(schema_version="0.7.0") + assert "gtin" not in mapper.mapped_keys + + def test_default_construction_matches_default_version(self) -> None: + """Constructing without args uses :data:`DEFAULT_SCHEMA_VERSION`.""" + m = EUDPPTermMapper() + assert m.schema_version == DEFAULT_SCHEMA_VERSION + + +# --------------------------------------------------------------------------- +# 2. EUDPPJsonLDExporter — explicit version + auto-detect +# --------------------------------------------------------------------------- + + +class TestExporterVersionDispatch: + """``EUDPPJsonLDExporter`` resolves the right mapper per call.""" + + def test_explicit_version_uses_pinned_mapper( + self, passport_v07: v0_7.DigitalProductPassport + ) -> None: + exporter = EUDPPJsonLDExporter(schema_version="0.7.0") + assert exporter.schema_version == "0.7.0" + result = exporter.export_dict(passport_v07) + assert "credentialSubject" in result + + def test_auto_detect_v07_from_module_path( + self, passport_v07: v0_7.DigitalProductPassport + ) -> None: + """A v0.7 passport auto-detects to the v0.7 mapper without explicit configuration.""" + exporter = EUDPPJsonLDExporter() + assert exporter.schema_version is None # not pinned + # Internal helper exposes the auto-detection result. + assert exporter._detect_version_from_passport(passport_v07) == "0.7.0" + + def test_auto_detect_unknown_falls_back_to_default(self) -> None: + """Passports outside the in-tree v0_X namespaces fall back to the default.""" + + class _Outsider: + credential_subject = None + + exporter = EUDPPJsonLDExporter() + assert ( + exporter._detect_version_from_passport(_Outsider()) # type: ignore[arg-type] + == DEFAULT_SCHEMA_VERSION + ) + + def test_explicit_version_overrides_auto_detect( + self, passport_v07: v0_7.DigitalProductPassport + ) -> None: + """An explicit ``schema_version='0.6.1'`` on a v0.7 passport overrides auto-detect. + + This is intentional — it lets callers force-export through the v0.6 + mapper for testing or downstream-compat scenarios. + """ + exporter = EUDPPJsonLDExporter(schema_version="0.6.1") + result = exporter.export_dict(passport_v07) + # The exporter pins to v0.6, so v0.7-only spellings (``itemNumber``, + # ``materialProvenance``, …) wouldn't be in the v0.6 index and pass + # through unchanged. Sanity-check that it didn't crash. + assert "credentialSubject" in result + + +# --------------------------------------------------------------------------- +# 3. v0.7 round-trip: spellings map to expected EU DPP URIs +# --------------------------------------------------------------------------- + + +class TestV07ExportRoundtrip: + """Full v0.7 export produces a JSON-LD doc with EU DPP terms.""" + + def test_v07_passport_exports_cleanly(self, passport_v07: v0_7.DigitalProductPassport) -> None: + result = export_eudpp_jsonld_dict(passport_v07) + # Validates as an EU DPP export (has @context, type, etc.). + issues = validate_eudpp_export(result) + assert issues == [], f"Unexpected validation issues: {issues}" + + def test_v07_renames_are_mapped(self, passport_v07: v0_7.DigitalProductPassport) -> None: + """v0.7 spellings on the source side land at the right EU DPP keys. + + The canonical 0.7.0 sample doesn't include every renamed field, but + it has ``materialProvenance``, ``idGranularity``, ``relatedParty`` + which all flow through. + """ + result = export_eudpp_jsonld_dict(passport_v07) + cs = result.get("credentialSubject") + assert isinstance(cs, dict) + # ``materialProvenance`` (v0.7) → ``hasMaterialProvenance`` (EU DPP) + assert "hasMaterialProvenance" in cs + # ``relatedParty`` (v0.7) → ``hasManufacturer`` (EU DPP) + assert "hasManufacturer" in cs + # ``idGranularity`` (v0.7) is also surfaced at the document root via + # the metadata helper. + assert result.get("granularity") == cs.get("granularity") + + def test_v07_export_does_not_carry_v06_only_fields( + self, passport_v07: v0_7.DigitalProductPassport + ) -> None: + """v0.7 source has no ``gtin``, ``serialNumber``, ``materialsProvenance`` keys.""" + result = export_eudpp_jsonld_dict(passport_v07) + cs = result.get("credentialSubject", {}) + # These are v0.6-only field names. Even before mapping, they shouldn't + # be present on a v0.7 source. + for v06_only in ("gtin", "serialNumber", "materialsProvenance"): + assert v06_only not in cs, f"v0.6-only field {v06_only!r} leaked into v0.7 export" + + def test_v07_export_uses_eudpp_namespace_in_type( + self, passport_v07: v0_7.DigitalProductPassport + ) -> None: + result = export_eudpp_jsonld_dict(passport_v07) + type_arr = result.get("type") + assert isinstance(type_arr, list) + assert "eudpp:DPP" in type_arr + + +# --------------------------------------------------------------------------- +# 4. get_term_mapping_summary per version +# --------------------------------------------------------------------------- + + +class TestTermMappingSummaryPerVersion: + """The summary helper takes a version and reflects the right column.""" + + def test_v06_summary_includes_legacy_terms(self) -> None: + summary = get_term_mapping_summary("0.6.1") + assert summary.get("serialNumber") == "uniqueProductID" + assert summary.get("granularityLevel") == "granularity" + assert summary.get("gtin") == "GTIN" + + def test_v07_summary_uses_renamed_terms(self) -> None: + summary = get_term_mapping_summary("0.7.0") + assert summary.get("itemNumber") == "uniqueProductID" + assert summary.get("idGranularity") == "granularity" + assert summary.get("relatedParty") == "hasManufacturer" + assert "gtin" not in summary + assert "serialNumber" not in summary + + def test_default_summary_is_default_version(self) -> None: + """Default invocation returns :data:`DEFAULT_SCHEMA_VERSION`'s view.""" + default = get_term_mapping_summary() + # Same as the explicit default-version call. + assert default == get_term_mapping_summary(DEFAULT_SCHEMA_VERSION) diff --git a/tests/unit/test_eudpp_lca.py b/tests/unit/test_eudpp_lca.py new file mode 100644 index 0000000..98d9094 --- /dev/null +++ b/tests/unit/test_eudpp_lca.py @@ -0,0 +1,390 @@ +"""Tests for EU DPP Core Ontology LCA module (Phase 4).""" + +from decimal import Decimal + +import pytest + +from dppvalidator.vocabularies.eudpp_lca import ( + IMPACT_CATEGORY_UNITS, + LCA_NAMESPACE, + LCA_PREFIX, + CharacterizationFactor, + CharacterizationModel, + EnvironmentalImpact, + ImpactCategory, + ImpactCategoryIndicator, + ImpactResult, + LCAClass, + LCAMethod, + LCAMethodology, + ProductEnvironmentalFootprint, + compact_lca_uri, + expand_lca_uri, + get_all_characterization_models, + get_all_impact_categories, + get_all_impact_indicators, + get_impact_category_unit, + is_climate_related, + is_resource_related, + is_toxicity_related, +) + + +class TestLCANamespace: + """Tests for LCA namespace constants.""" + + def test_namespace_constants(self): + """Test LCA namespace constants are defined.""" + assert LCA_NAMESPACE == "http://dpp.cea.fr/EUDPP/LCA#" + assert LCA_PREFIX == "lca" + + +class TestLCAClass: + """Tests for LCAClass enum.""" + + def test_lca_classes_exist(self): + """Test LCA class URIs exist.""" + assert LCAClass.ENVIRONMENTAL_FOOTPRINT.value == "lca:Environmental_Footprint" + assert LCAClass.CARBON_FOOTPRINT.value == "lca:Carbon_Footprint" + assert LCAClass.MATERIAL_FOOTPRINT.value == "lca:Material_Footprint" + assert LCAClass.IMPACT_CATEGORY.value == "lca:Impact_Category" + assert LCAClass.CHARACTERIZATION_MODEL.value == "lca:Characterization_Model" + + +class TestImpactCategory: + """Tests for ImpactCategory enum.""" + + def test_all_16_pef_categories_exist(self): + """Test all 16 PEF 3.1 impact categories are defined.""" + categories = list(ImpactCategory) + assert len(categories) == 16 + + def test_climate_change_category(self): + """Test climate change category.""" + assert ImpactCategory.CLIMATE_CHANGE.value == "lca:Climate_change_total" + + def test_toxicity_categories(self): + """Test toxicity categories.""" + assert ImpactCategory.HUMAN_TOXICITY_CANCER.value == "lca:Human_toxicity_cancer" + assert ImpactCategory.HUMAN_TOXICITY_NON_CANCER.value == "lca:Human_toxicity_non_cancer" + assert ImpactCategory.ECOTOXICITY_FRESHWATER.value == "lca:Ecotoxicity_freshwater" + + def test_eutrophication_categories(self): + """Test eutrophication categories.""" + assert ImpactCategory.EUTROPHICATION_FRESHWATER.value == "lca:Eutrophication_freshwater" + assert ImpactCategory.EUTROPHICATION_MARINE.value == "lca:Eutrophication_marine" + assert ImpactCategory.EUTROPHICATION_TERRESTRIAL.value == "lca:Eutrophication_terrestrial" + + def test_resource_categories(self): + """Test resource use categories.""" + assert ImpactCategory.RESOURCE_FOSSILS.value == "lca:Resource_use_fossils" + assert ImpactCategory.RESOURCE_MINERALS.value == "lca:Resource_use_minerals_and_metals" + assert ImpactCategory.WATER_USE.value == "lca:Water_use" + + +class TestImpactCategoryIndicator: + """Tests for ImpactCategoryIndicator enum.""" + + def test_gwp100_indicator(self): + """Test GWP100 indicator.""" + assert ImpactCategoryIndicator.GWP100.value == "lca:Global_Warming_Potential_GWP100" + + def test_toxicity_indicators(self): + """Test toxicity indicators.""" + assert ImpactCategoryIndicator.CTUH.value == "lca:Comparative_Toxic_Unit_for_humans_CTUh" + assert ( + ImpactCategoryIndicator.CTUE.value == "lca:Comparative_Toxic_Unit_for_ecosystems_CTUe" + ) + + +class TestCharacterizationModel: + """Tests for CharacterizationModel enum.""" + + def test_ipcc_model(self): + """Test IPCC model.""" + assert CharacterizationModel.IPCC_2021.value == "lca:Bern_model_based_on_IPCC_2021" + + def test_usetox_model(self): + """Test USEtox model.""" + assert CharacterizationModel.USETOX_2_1.value == "lca:Based_on_USEtox2.1_model" + + def test_aware_model(self): + """Test AWARE model.""" + assert CharacterizationModel.AWARE.value == "lca:Available_WAter_REmaining_AWARE_model" + + +class TestImpactCategoryUnits: + """Tests for impact category units mapping.""" + + def test_climate_change_unit(self): + """Test climate change unit.""" + assert IMPACT_CATEGORY_UNITS[ImpactCategory.CLIMATE_CHANGE.value] == "kg CO2-eq" + + def test_ozone_depletion_unit(self): + """Test ozone depletion unit.""" + assert IMPACT_CATEGORY_UNITS[ImpactCategory.OZONE_DEPLETION.value] == "kg CFC-11-eq" + + def test_water_use_unit(self): + """Test water use unit.""" + assert IMPACT_CATEGORY_UNITS[ImpactCategory.WATER_USE.value] == "m³ world-eq" + + +class TestImpactResult: + """Tests for ImpactResult dataclass.""" + + def test_create_impact_result(self): + """Test creating an impact result.""" + result = ImpactResult( + category=ImpactCategory.CLIMATE_CHANGE.value, + value=Decimal("12.5"), + unit="kg CO2-eq", + indicator=ImpactCategoryIndicator.GWP100.value, + model=CharacterizationModel.IPCC_2021.value, + ) + assert result.category == "lca:Climate_change_total" + assert result.value == Decimal("12.5") + assert result.unit == "kg CO2-eq" + + def test_validate_valid_category(self): + """Test validate with valid category.""" + result = ImpactResult( + category=ImpactCategory.CLIMATE_CHANGE.value, + value=Decimal("12.5"), + unit="kg CO2-eq", + ) + errors = result.validate() + assert len(errors) == 0 + + def test_validate_invalid_category(self): + """Test validate with invalid category.""" + result = ImpactResult( + category="lca:Unknown_category", + value=Decimal("12.5"), + unit="kg CO2-eq", + ) + errors = result.validate() + assert len(errors) == 1 + assert "Unknown impact category" in errors[0] + + def test_impact_result_immutable(self): + """Test impact result is immutable.""" + result = ImpactResult( + category=ImpactCategory.CLIMATE_CHANGE.value, + value=Decimal("12.5"), + unit="kg CO2-eq", + ) + with pytest.raises(AttributeError): + result.value = Decimal("20.0") # type: ignore[misc] + + +class TestCharacterizationFactor: + """Tests for CharacterizationFactor dataclass.""" + + def test_create_characterization_factor(self): + """Test creating a characterization factor.""" + cf = CharacterizationFactor( + name="GWP-100", + value=Decimal("1.0"), + unit="kg CO2-eq/kg", + model=CharacterizationModel.IPCC_2021.value, + ) + assert cf.name == "GWP-100" + assert cf.model == "lca:Bern_model_based_on_IPCC_2021" + + +class TestLCAMethodology: + """Tests for LCAMethodology dataclass.""" + + def test_create_methodology(self): + """Test creating an LCA methodology.""" + methodology = LCAMethodology( + name="EF v3.1", + version="3.1", + description="Product Environmental Footprint methodology", + ) + assert methodology.name == "EF v3.1" + assert methodology.version == "3.1" + + +class TestLCAMethod: + """Tests for LCAMethod dataclass.""" + + def test_create_method(self): + """Test creating an LCA method.""" + method = LCAMethod( + name="Climate change impact assessment", + methodology="PEF 3.1", + characterization_model=CharacterizationModel.IPCC_2021.value, + ) + assert method.name == "Climate change impact assessment" + + +class TestEnvironmentalImpact: + """Tests for EnvironmentalImpact dataclass.""" + + def test_create_environmental_impact(self): + """Test creating an environmental impact.""" + impact = EnvironmentalImpact( + description="CO2 emissions from manufacturing", + lifecycle_stage="production", + ) + assert impact.description == "CO2 emissions from manufacturing" + + +class TestProductEnvironmentalFootprint: + """Tests for ProductEnvironmentalFootprint dataclass.""" + + def test_create_pef(self): + """Test creating a PEF result.""" + pef = ProductEnvironmentalFootprint( + product_name="Test Product", + functional_unit="1 kg", + methodology_version="PEF 3.1", + ) + assert pef.product_name == "Test Product" + assert pef.methodology_version == "PEF 3.1" + + def test_get_impact_found(self): + """Test get_impact when category exists.""" + result = ImpactResult( + category=ImpactCategory.CLIMATE_CHANGE.value, + value=Decimal("12.5"), + unit="kg CO2-eq", + ) + pef = ProductEnvironmentalFootprint( + product_name="Test", + impact_results=(result,), + ) + found = pef.get_impact(ImpactCategory.CLIMATE_CHANGE) + assert found is not None + assert found.value == Decimal("12.5") + + def test_get_impact_not_found(self): + """Test get_impact when category doesn't exist.""" + pef = ProductEnvironmentalFootprint(product_name="Test") + found = pef.get_impact(ImpactCategory.CLIMATE_CHANGE) + assert found is None + + def test_has_all_categories_false(self): + """Test has_all_categories returns False when incomplete.""" + pef = ProductEnvironmentalFootprint(product_name="Test") + assert not pef.has_all_categories() + + def test_has_all_categories_true(self): + """Test has_all_categories returns True when complete.""" + results = tuple( + ImpactResult( + category=cat.value, + value=Decimal("1.0"), + unit="unit", + ) + for cat in ImpactCategory + ) + pef = ProductEnvironmentalFootprint( + product_name="Test", + impact_results=results, + ) + assert pef.has_all_categories() + + def test_missing_categories(self): + """Test missing_categories returns correct list.""" + result = ImpactResult( + category=ImpactCategory.CLIMATE_CHANGE.value, + value=Decimal("12.5"), + unit="kg CO2-eq", + ) + pef = ProductEnvironmentalFootprint( + product_name="Test", + impact_results=(result,), + ) + missing = pef.missing_categories() + assert len(missing) == 15 # 16 - 1 = 15 + assert ImpactCategory.CLIMATE_CHANGE.value not in missing + + +class TestHelperFunctions: + """Tests for helper functions.""" + + def test_get_all_impact_categories(self): + """Test get_all_impact_categories returns 16 categories.""" + categories = get_all_impact_categories() + assert len(categories) == 16 + assert "lca:Climate_change_total" in categories + + def test_get_impact_category_unit(self): + """Test get_impact_category_unit returns correct unit.""" + unit = get_impact_category_unit(ImpactCategory.CLIMATE_CHANGE.value) + assert unit == "kg CO2-eq" + + def test_get_impact_category_unit_unknown(self): + """Test get_impact_category_unit returns None for unknown.""" + unit = get_impact_category_unit("lca:Unknown") + assert unit is None + + def test_get_all_characterization_models(self): + """Test get_all_characterization_models returns all models.""" + models = get_all_characterization_models() + assert len(models) == len(CharacterizationModel) + assert "lca:Bern_model_based_on_IPCC_2021" in models + + def test_get_all_impact_indicators(self): + """Test get_all_impact_indicators returns all indicators.""" + indicators = get_all_impact_indicators() + assert len(indicators) == len(ImpactCategoryIndicator) + + def test_is_climate_related_true(self): + """Test is_climate_related returns True for climate change.""" + assert is_climate_related(ImpactCategory.CLIMATE_CHANGE.value) + + def test_is_climate_related_false(self): + """Test is_climate_related returns False for other categories.""" + assert not is_climate_related(ImpactCategory.WATER_USE.value) + + def test_is_toxicity_related_true(self): + """Test is_toxicity_related returns True for toxicity categories.""" + assert is_toxicity_related(ImpactCategory.HUMAN_TOXICITY_CANCER.value) + assert is_toxicity_related(ImpactCategory.HUMAN_TOXICITY_NON_CANCER.value) + assert is_toxicity_related(ImpactCategory.ECOTOXICITY_FRESHWATER.value) + + def test_is_toxicity_related_false(self): + """Test is_toxicity_related returns False for other categories.""" + assert not is_toxicity_related(ImpactCategory.CLIMATE_CHANGE.value) + + def test_is_resource_related_true(self): + """Test is_resource_related returns True for resource categories.""" + assert is_resource_related(ImpactCategory.RESOURCE_FOSSILS.value) + assert is_resource_related(ImpactCategory.RESOURCE_MINERALS.value) + assert is_resource_related(ImpactCategory.WATER_USE.value) + assert is_resource_related(ImpactCategory.LAND_USE.value) + + def test_is_resource_related_false(self): + """Test is_resource_related returns False for other categories.""" + assert not is_resource_related(ImpactCategory.CLIMATE_CHANGE.value) + + +class TestURIExpansion: + """Tests for URI expansion and compaction.""" + + def test_expand_lca_uri(self): + """Test expanding compact LCA URI.""" + compact = "lca:Climate_change_total" + full = expand_lca_uri(compact) + assert full == "http://dpp.cea.fr/EUDPP/LCA#Climate_change_total" + + def test_expand_lca_uri_already_full(self): + """Test expanding already full URI.""" + full = "http://example.org/other" + result = expand_lca_uri(full) + assert result == full + + def test_compact_lca_uri(self): + """Test compacting full LCA URI.""" + full = "http://dpp.cea.fr/EUDPP/LCA#Climate_change_total" + compact = compact_lca_uri(full) + assert compact == "lca:Climate_change_total" + + def test_compact_lca_uri_already_compact(self): + """Test compacting already compact URI.""" + compact = "other:prefix" + result = compact_lca_uri(compact) + assert result == compact diff --git a/tests/unit/test_eudpp_relations.py b/tests/unit/test_eudpp_relations.py new file mode 100644 index 0000000..87823ab --- /dev/null +++ b/tests/unit/test_eudpp_relations.py @@ -0,0 +1,296 @@ +"""Tests for EU DPP Core Ontology product relationship properties.""" + +import pytest + +from dppvalidator.vocabularies.eudpp_relations import ( + DATATYPE_PROPERTIES, + OBJECT_PROPERTIES, + DatatypePropertyDefinition, + EUDPPDatatypeProperty, + EUDPPObjectProperty, + ObjectPropertyDefinition, + ProductRelationMapper, + get_actor_properties, + get_lifecycle_properties, + get_product_hierarchy_properties, + is_product_relation, +) + + +class TestEUDPPObjectProperty: + """Tests for EUDPPObjectProperty enum.""" + + def test_dpp_product_relations_exist(self): + """Test DPP-Product relation URIs exist.""" + assert EUDPPObjectProperty.HAS_DPP.value == "eudpp:hasDPP" + assert EUDPPObjectProperty.APPLIES_TO_PRODUCT.value == "eudpp:appliesToProduct" + + def test_product_hierarchy_relations_exist(self): + """Test product hierarchy relation URIs exist.""" + assert EUDPPObjectProperty.IS_COMPONENT_OF.value == "eudpp:isComponentOf" + assert EUDPPObjectProperty.IS_SPARE_PART_OF.value == "eudpp:isSparePartOf" + + def test_actor_relations_exist(self): + """Test actor relation URIs exist.""" + assert EUDPPObjectProperty.HAS_ISSUER.value == "eudpp:hasIssuer" + assert EUDPPObjectProperty.HAS_MANUFACTURER.value == "eudpp:hasManufacturer" + assert EUDPPObjectProperty.HAS_ECONOMIC_OPERATOR.value == "eudpp:hasEconomicOperator" + assert EUDPPObjectProperty.HAS_BACKUP_COPY_HOST.value == "eudpp:hasBackUpCopyHost" + + def test_classification_relations_exist(self): + """Test classification relation URIs exist.""" + assert EUDPPObjectProperty.HAS_PRODUCT_GROUP.value == "eudpp:hasProductGroup" + + def test_property_relations_exist(self): + """Test property relation URIs exist.""" + assert EUDPPObjectProperty.HAS_PROPERTY.value == "eudpp:hasProperty" + assert EUDPPObjectProperty.HAS_MEASUREMENT_UNIT.value == "eudpp:hasMeasurementUnit" + + def test_substance_relations_exist(self): + """Test substance relation URIs exist.""" + assert ( + EUDPPObjectProperty.CONTAINS_SUBSTANCE_OF_CONCERN.value + == "eudpp:containsSubstanceOfConcern" + ) + + +class TestEUDPPDatatypeProperty: + """Tests for EUDPPDatatypeProperty enum.""" + + def test_identification_properties_exist(self): + """Test identification property URIs exist.""" + assert EUDPPDatatypeProperty.UNIQUE_DPP_ID.value == "eudpp:uniqueDPPID" + assert EUDPPDatatypeProperty.UNIQUE_PRODUCT_ID.value == "eudpp:uniqueProductID" + assert EUDPPDatatypeProperty.GTIN.value == "eudpp:GTIN" + assert EUDPPDatatypeProperty.COMMODITY_CODE.value == "eudpp:commodityCode" + + def test_product_info_properties_exist(self): + """Test product info property URIs exist.""" + assert EUDPPDatatypeProperty.PRODUCT_NAME.value == "eudpp:productName" + assert EUDPPDatatypeProperty.DESCRIPTION.value == "eudpp:description" + assert EUDPPDatatypeProperty.PRODUCT_IMAGE.value == "eudpp:productImage" + + def test_lifecycle_properties_exist(self): + """Test lifecycle property URIs exist.""" + assert EUDPPDatatypeProperty.VALID_FROM.value == "eudpp:validFrom" + assert EUDPPDatatypeProperty.VALID_UNTIL.value == "eudpp:validUntil" + assert EUDPPDatatypeProperty.LAST_UPDATE.value == "eudpp:lastUpdate" + assert EUDPPDatatypeProperty.STATUS.value == "eudpp:status" + assert EUDPPDatatypeProperty.SCHEMA_VERSION.value == "eudpp:schemaVersion" + assert EUDPPDatatypeProperty.LINK_TO_PREVIOUS_DPP.value == "eudpp:linkToPreviousDPP" + + def test_granularity_property_exists(self): + """Test granularity property URI exists.""" + assert EUDPPDatatypeProperty.GRANULARITY.value == "eudpp:granularity" + + def test_energy_related_property_exists(self): + """Test energy-related property URI exists.""" + assert EUDPPDatatypeProperty.IS_ENERGY_RELATED.value == "eudpp:isEnergyRelated" + + def test_quantitative_properties_exist(self): + """Test quantitative property URIs exist.""" + assert EUDPPDatatypeProperty.NUMERICAL_VALUE.value == "eudpp:numericalValue" + assert EUDPPDatatypeProperty.TOLERANCE.value == "eudpp:tolerance" + + +class TestObjectPropertyDefinition: + """Tests for ObjectPropertyDefinition dataclass.""" + + def test_create_object_property(self): + """Test creating an object property definition.""" + prop = ObjectPropertyDefinition( + uri="eudpp:testProperty", + domain="eudpp:Product", + range="eudpp:Actor", + description="Test property", + ) + assert prop.uri == "eudpp:testProperty" + assert prop.domain == "eudpp:Product" + assert prop.range == "eudpp:Actor" + assert prop.is_transitive is False + + def test_create_transitive_property(self): + """Test creating a transitive property.""" + prop = ObjectPropertyDefinition( + uri="eudpp:isComponentOf", + domain="eudpp:Product", + range="eudpp:Product", + description="Component relation", + is_transitive=True, + ) + assert prop.is_transitive is True + + def test_property_with_espr_reference(self): + """Test property with ESPR reference.""" + prop = ObjectPropertyDefinition( + uri="eudpp:hasIssuer", + domain="eudpp:DPP", + range="eudpp:Actor", + description="DPP issuer", + espr_reference="ESPR Annex III (g)", + ) + assert prop.espr_reference == "ESPR Annex III (g)" + + +class TestDatatypePropertyDefinition: + """Tests for DatatypePropertyDefinition dataclass.""" + + def test_create_datatype_property(self): + """Test creating a datatype property definition.""" + prop = DatatypePropertyDefinition( + uri="eudpp:productName", + domain="eudpp:Product", + range="xsd:string", + description="Product name", + ) + assert prop.uri == "eudpp:productName" + assert prop.range == "xsd:string" + + def test_property_with_espr_reference(self): + """Test property with ESPR reference.""" + prop = DatatypePropertyDefinition( + uri="eudpp:isEnergyRelated", + domain="eudpp:Product", + range="xsd:boolean", + description="Energy-related product", + espr_reference="ESPR Art 2(4)", + ) + assert prop.espr_reference == "ESPR Art 2(4)" + + +class TestPropertyCollections: + """Tests for property collection tuples.""" + + def test_object_properties_not_empty(self): + """Test OBJECT_PROPERTIES is not empty.""" + assert len(OBJECT_PROPERTIES) >= 10 + + def test_datatype_properties_not_empty(self): + """Test DATATYPE_PROPERTIES is not empty.""" + assert len(DATATYPE_PROPERTIES) >= 15 + + def test_all_object_properties_have_uri(self): + """Test all object properties have URI.""" + for prop in OBJECT_PROPERTIES: + assert prop.uri.startswith("eudpp:") + + def test_all_datatype_properties_have_uri(self): + """Test all datatype properties have URI.""" + for prop in DATATYPE_PROPERTIES: + assert prop.uri.startswith("eudpp:") + + def test_is_component_of_is_transitive(self): + """Test isComponentOf is marked as transitive.""" + component_prop = next( + (p for p in OBJECT_PROPERTIES if p.uri == "eudpp:isComponentOf"), + None, + ) + assert component_prop is not None + assert component_prop.is_transitive is True + + +class TestProductRelationMapper: + """Tests for ProductRelationMapper class.""" + + @pytest.fixture + def mapper(self) -> ProductRelationMapper: + """Create mapper instance.""" + return ProductRelationMapper() + + def test_get_object_property(self, mapper: ProductRelationMapper): + """Test getting object property by URI.""" + prop = mapper.get_object_property("eudpp:isComponentOf") + assert prop is not None + assert prop.uri == "eudpp:isComponentOf" + assert prop.is_transitive is True + + def test_get_object_property_not_found(self, mapper: ProductRelationMapper): + """Test getting unknown object property.""" + prop = mapper.get_object_property("eudpp:unknownProperty") + assert prop is None + + def test_get_datatype_property(self, mapper: ProductRelationMapper): + """Test getting datatype property by URI.""" + prop = mapper.get_datatype_property("eudpp:productName") + assert prop is not None + assert prop.uri == "eudpp:productName" + assert prop.range == "xsd:string" + + def test_get_datatype_property_not_found(self, mapper: ProductRelationMapper): + """Test getting unknown datatype property.""" + prop = mapper.get_datatype_property("eudpp:unknownProperty") + assert prop is None + + def test_is_transitive(self, mapper: ProductRelationMapper): + """Test checking if property is transitive.""" + assert mapper.is_transitive("eudpp:isComponentOf") is True + assert mapper.is_transitive("eudpp:hasManufacturer") is False + assert mapper.is_transitive("eudpp:unknownProperty") is False + + def test_get_domain(self, mapper: ProductRelationMapper): + """Test getting property domain.""" + assert mapper.get_domain("eudpp:isComponentOf") == "eudpp:Product" + assert mapper.get_domain("eudpp:productName") == "eudpp:Product" + assert mapper.get_domain("eudpp:unknownProperty") is None + + def test_get_range(self, mapper: ProductRelationMapper): + """Test getting property range.""" + assert mapper.get_range("eudpp:isComponentOf") == "eudpp:Product" + assert mapper.get_range("eudpp:productName") == "xsd:string" + assert mapper.get_range("eudpp:unknownProperty") is None + + def test_iter_object_properties(self, mapper: ProductRelationMapper): + """Test iterating object properties.""" + props = list(mapper.iter_object_properties()) + assert len(props) == mapper.object_property_count + + def test_iter_datatype_properties(self, mapper: ProductRelationMapper): + """Test iterating datatype properties.""" + props = list(mapper.iter_datatype_properties()) + assert len(props) == mapper.datatype_property_count + + def test_property_counts(self, mapper: ProductRelationMapper): + """Test property counts.""" + assert mapper.object_property_count >= 10 + assert mapper.datatype_property_count >= 15 + + +class TestHelperFunctions: + """Tests for helper functions.""" + + def test_get_product_hierarchy_properties(self): + """Test getting product hierarchy properties.""" + props = get_product_hierarchy_properties() + assert "eudpp:isComponentOf" in props + assert "eudpp:isSparePartOf" in props + assert len(props) == 2 + + def test_get_actor_properties(self): + """Test getting actor properties.""" + props = get_actor_properties() + assert "eudpp:hasIssuer" in props + assert "eudpp:hasManufacturer" in props + assert "eudpp:hasEconomicOperator" in props + assert "eudpp:hasBackUpCopyHost" in props + # Phase 2 added role and facility relationships + assert "eudpp:hasRole" in props + assert "eudpp:usesFacility" in props + assert len(props) == 12 + + def test_get_lifecycle_properties(self): + """Test getting lifecycle properties.""" + props = get_lifecycle_properties() + assert "eudpp:validFrom" in props + assert "eudpp:validUntil" in props + assert "eudpp:lastUpdate" in props + assert "eudpp:status" in props + assert "eudpp:schemaVersion" in props + assert "eudpp:linkToPreviousDPP" in props + assert len(props) == 6 + + def test_is_product_relation(self): + """Test checking if URI is a product relation.""" + assert is_product_relation("eudpp:isComponentOf") is True + assert is_product_relation("eudpp:isSparePartOf") is True + assert is_product_relation("eudpp:hasManufacturer") is False + assert is_product_relation("eudpp:productName") is False diff --git a/tests/unit/test_eudpp_substances.py b/tests/unit/test_eudpp_substances.py new file mode 100644 index 0000000..3be0c15 --- /dev/null +++ b/tests/unit/test_eudpp_substances.py @@ -0,0 +1,398 @@ +"""Tests for EU DPP Core Ontology substances of concern (Phase 3).""" + +from decimal import Decimal + +import pytest + +from dppvalidator.vocabularies.eudpp_substances import ( + CAS_NUMBER_PATTERN, + EC_NUMBER_PATTERN, + Concentration, + ConcentrationOfSubstanceOfConcern, + EUDPPSubstanceClass, + HazardCategory, + LifeCycleStage, + Substance, + SubstanceOfConcern, + Threshold, + get_all_hazard_categories, + get_lifecycle_stages, + is_pop, + is_svhc, + is_valid_cas_number, + is_valid_ec_number, + validate_cas_checksum, +) + + +class TestEUDPPSubstanceClass: + """Tests for EUDPPSubstanceClass enum.""" + + def test_substance_classes_exist(self): + """Test substance class URIs exist.""" + assert EUDPPSubstanceClass.SUBSTANCE.value == "eudpp:Substance" + assert EUDPPSubstanceClass.SUBSTANCE_OF_CONCERN.value == "eudpp:SubstanceOfConcern" + assert EUDPPSubstanceClass.CONCENTRATION.value == "eudpp:Concentration" + assert EUDPPSubstanceClass.THRESHOLD.value == "eudpp:Threshold" + + +class TestLifeCycleStage: + """Tests for LifeCycleStage enum.""" + + def test_lifecycle_stages_exist(self): + """Test lifecycle stage values exist.""" + assert LifeCycleStage.PRODUCTION.value == "production" + assert LifeCycleStage.IN_PRODUCT.value == "in_product" + assert LifeCycleStage.USE.value == "use" + assert LifeCycleStage.END_OF_LIFE.value == "end_of_life" + assert LifeCycleStage.WASTE.value == "waste" + assert LifeCycleStage.RECYCLING.value == "recycling" + + +class TestHazardCategory: + """Tests for HazardCategory enum.""" + + def test_carcinogenicity_categories(self): + """Test carcinogenicity hazard categories.""" + assert HazardCategory.CARCINOGENICITY_1.value == "carcinogenicity_cat_1" + assert HazardCategory.CARCINOGENICITY_2.value == "carcinogenicity_cat_2" + + def test_mutagenicity_categories(self): + """Test mutagenicity hazard categories.""" + assert HazardCategory.MUTAGENICITY_1.value == "mutagenicity_cat_1" + assert HazardCategory.MUTAGENICITY_2.value == "mutagenicity_cat_2" + + def test_svhc_and_pop(self): + """Test SVHC and POP categories.""" + assert HazardCategory.SVHC.value == "svhc_reach_art_57" + assert HazardCategory.POP.value == "persistent_organic_pollutant" + + +class TestCASNumberValidation: + """Tests for CAS number validation.""" + + def test_valid_cas_numbers(self): + """Test valid CAS number formats.""" + assert is_valid_cas_number("50-00-0") # Formaldehyde + assert is_valid_cas_number("7440-23-5") # Sodium + assert is_valid_cas_number("15829-53-5") # Mercurous Oxide + assert is_valid_cas_number("1234567-89-0") # Max digits + + def test_invalid_cas_numbers(self): + """Test invalid CAS number formats.""" + assert not is_valid_cas_number("invalid") + assert not is_valid_cas_number("50-00") # Missing check digit + assert not is_valid_cas_number("5-00-0") # Too few leading digits + assert not is_valid_cas_number("50-0-0") # Wrong middle format + assert not is_valid_cas_number("50-00-00") # Too many check digits + + def test_cas_pattern(self): + """Test CAS_NUMBER_PATTERN regex.""" + assert CAS_NUMBER_PATTERN.match("50-00-0") + assert not CAS_NUMBER_PATTERN.match("invalid") + + +class TestECNumberValidation: + """Tests for EC number validation.""" + + def test_valid_ec_numbers(self): + """Test valid EC number formats.""" + assert is_valid_ec_number("200-001-8") # Formaldehyde + assert is_valid_ec_number("231-132-9") # Sodium + assert is_valid_ec_number("239-934-0") # Mercurous Oxide + + def test_invalid_ec_numbers(self): + """Test invalid EC number formats.""" + assert not is_valid_ec_number("invalid") + assert not is_valid_ec_number("20-001-8") # Too few leading digits + assert not is_valid_ec_number("200-01-8") # Too few middle digits + assert not is_valid_ec_number("200-001-88") # Too many check digits + + def test_ec_pattern(self): + """Test EC_NUMBER_PATTERN regex.""" + assert EC_NUMBER_PATTERN.match("200-001-8") + assert not EC_NUMBER_PATTERN.match("invalid") + + +class TestCASChecksumValidation: + """Tests for CAS checksum validation.""" + + def test_valid_checksums(self): + """Test valid CAS checksums.""" + assert validate_cas_checksum("50-00-0") # Formaldehyde + assert validate_cas_checksum("7440-23-5") # Sodium + + def test_invalid_checksums(self): + """Test invalid CAS checksums.""" + assert not validate_cas_checksum("50-00-1") # Wrong check digit + assert not validate_cas_checksum("invalid") + + +class TestSubstance: + """Tests for Substance dataclass.""" + + def test_create_substance(self): + """Test creating a substance.""" + substance = Substance( + name_iupac="Methanal", + name_cas="Formaldehyde", + ) + assert substance._class_uri == "eudpp:Substance" + assert substance.name_iupac == "Methanal" + assert substance.name_cas == "Formaldehyde" + + def test_create_substance_minimal(self): + """Test creating substance with minimal fields.""" + substance = Substance() + assert substance._class_uri == "eudpp:Substance" + assert substance.name_iupac is None + + def test_substance_immutable(self): + """Test substance is immutable.""" + substance = Substance(name_iupac="Test") + with pytest.raises(AttributeError): + substance.name_iupac = "Modified" # type: ignore[misc] + + +class TestSubstanceOfConcern: + """Tests for SubstanceOfConcern dataclass.""" + + def test_create_soc(self): + """Test creating a substance of concern.""" + soc = SubstanceOfConcern( + name_iupac="Methanal", + name_cas="Formaldehyde", + number_cas="50-00-0", + number_ec="200-001-8", + hazard_category=HazardCategory.CARCINOGENICITY_1.value, + ) + assert soc._class_uri == "eudpp:SubstanceOfConcern" + assert soc.number_cas == "50-00-0" + assert soc.number_ec == "200-001-8" + + def test_soc_validate_identifiers_valid(self): + """Test validate_identifiers with valid identifiers.""" + soc = SubstanceOfConcern( + number_cas="50-00-0", + number_ec="200-001-8", + ) + errors = soc.validate_identifiers() + assert len(errors) == 0 + + def test_soc_validate_identifiers_invalid_cas(self): + """Test validate_identifiers with invalid CAS number.""" + soc = SubstanceOfConcern(number_cas="invalid") + errors = soc.validate_identifiers() + assert len(errors) == 1 + assert "CAS number" in errors[0] + + def test_soc_validate_identifiers_invalid_ec(self): + """Test validate_identifiers with invalid EC number.""" + soc = SubstanceOfConcern(number_ec="invalid") + errors = soc.validate_identifiers() + assert len(errors) == 1 + assert "EC number" in errors[0] + + def test_soc_has_valid_identification_true(self): + """Test has_valid_identification returns True.""" + soc = SubstanceOfConcern(name_iupac="Methanal") + assert soc.has_valid_identification() + + def test_soc_has_valid_identification_false(self): + """Test has_valid_identification returns False.""" + soc = SubstanceOfConcern() + assert not soc.has_valid_identification() + + def test_soc_with_location_and_lifecycle(self): + """Test SOC with location and lifecycle stage.""" + soc = SubstanceOfConcern( + name_iupac="Lead", + substance_location="Battery electrode", + lifecycle_stage=LifeCycleStage.IN_PRODUCT.value, + impact_on_health="Neurotoxic effects", + impact_on_environment="Soil contamination", + ) + assert soc.substance_location == "Battery electrode" + assert soc.lifecycle_stage == "in_product" + + +class TestConcentration: + """Tests for Concentration dataclass.""" + + def test_create_concentration(self): + """Test creating a concentration.""" + conc = Concentration( + value=Decimal("0.1"), + unit="%w/w", + ) + assert conc._class_uri == "eudpp:Concentration" + assert conc.value == Decimal("0.1") + assert conc.unit == "%w/w" + + def test_concentration_range(self): + """Test concentration with range.""" + conc = Concentration( + value=Decimal("0.15"), + unit="%w/w", + range_min=Decimal("0.1"), + range_max=Decimal("0.2"), + ) + assert conc.is_range() + assert conc.range_min == Decimal("0.1") + assert conc.range_max == Decimal("0.2") + + def test_concentration_not_range(self): + """Test concentration without range.""" + conc = Concentration(value=Decimal("0.1"), unit="%w/w") + assert not conc.is_range() + + def test_concentration_validate_valid(self): + """Test validate with valid concentration.""" + conc = Concentration( + value=Decimal("0.1"), + unit="%w/w", + range_min=Decimal("0.05"), + range_max=Decimal("0.15"), + ) + errors = conc.validate() + assert len(errors) == 0 + + def test_concentration_validate_negative_value(self): + """Test validate with negative value.""" + conc = Concentration(value=Decimal("-0.1"), unit="%w/w") + errors = conc.validate() + assert len(errors) == 1 + assert "negative" in errors[0] + + def test_concentration_validate_invalid_range(self): + """Test validate with min > max.""" + conc = Concentration( + value=Decimal("0.1"), + unit="%w/w", + range_min=Decimal("0.2"), + range_max=Decimal("0.1"), + ) + errors = conc.validate() + assert len(errors) == 1 + assert "cannot exceed" in errors[0] + + def test_concentration_immutable(self): + """Test concentration is immutable.""" + conc = Concentration(value=Decimal("0.1"), unit="%w/w") + with pytest.raises(AttributeError): + conc.value = Decimal("0.2") # type: ignore[misc] + + +class TestThreshold: + """Tests for Threshold dataclass.""" + + def test_create_threshold(self): + """Test creating a threshold.""" + threshold = Threshold( + value=Decimal("0.1"), + unit="%w/w", + regulation_reference="REACH Annex XVII", + ) + assert threshold._class_uri == "eudpp:Threshold" + assert threshold.value == Decimal("0.1") + assert threshold.regulation_reference == "REACH Annex XVII" + + def test_threshold_validate_valid(self): + """Test validate with valid threshold.""" + threshold = Threshold(value=Decimal("0.1"), unit="%w/w") + errors = threshold.validate() + assert len(errors) == 0 + + def test_threshold_validate_negative(self): + """Test validate with negative threshold.""" + threshold = Threshold(value=Decimal("-0.1"), unit="%w/w") + errors = threshold.validate() + assert len(errors) == 1 + assert "negative" in errors[0] + + +class TestConcentrationOfSubstanceOfConcern: + """Tests for ConcentrationOfSubstanceOfConcern dataclass.""" + + def test_create_concentration_of_soc(self): + """Test creating a concentration of SOC.""" + soc = SubstanceOfConcern(name_iupac="Lead", number_cas="7439-92-1") + conc = Concentration(value=Decimal("0.15"), unit="%w/w") + threshold = Threshold(value=Decimal("0.1"), unit="%w/w") + + csoc = ConcentrationOfSubstanceOfConcern( + substance=soc, + concentration=conc, + threshold=threshold, + ) + assert csoc.substance.name_iupac == "Lead" + assert csoc.concentration.value == Decimal("0.15") + + def test_exceeds_threshold_true(self): + """Test exceeds_threshold returns True.""" + soc = SubstanceOfConcern(name_iupac="Lead") + conc = Concentration(value=Decimal("0.15"), unit="%w/w") + threshold = Threshold(value=Decimal("0.1"), unit="%w/w") + + csoc = ConcentrationOfSubstanceOfConcern( + substance=soc, concentration=conc, threshold=threshold + ) + assert csoc.exceeds_threshold() is True + + def test_exceeds_threshold_false(self): + """Test exceeds_threshold returns False.""" + soc = SubstanceOfConcern(name_iupac="Lead") + conc = Concentration(value=Decimal("0.05"), unit="%w/w") + threshold = Threshold(value=Decimal("0.1"), unit="%w/w") + + csoc = ConcentrationOfSubstanceOfConcern( + substance=soc, concentration=conc, threshold=threshold + ) + assert csoc.exceeds_threshold() is False + + def test_exceeds_threshold_no_threshold(self): + """Test exceeds_threshold returns None without threshold.""" + soc = SubstanceOfConcern(name_iupac="Lead") + conc = Concentration(value=Decimal("0.15"), unit="%w/w") + + csoc = ConcentrationOfSubstanceOfConcern(substance=soc, concentration=conc, threshold=None) + assert csoc.exceeds_threshold() is None + + def test_exceeds_threshold_different_units(self): + """Test exceeds_threshold returns None for different units.""" + soc = SubstanceOfConcern(name_iupac="Lead") + conc = Concentration(value=Decimal("1500"), unit="ppm") + threshold = Threshold(value=Decimal("0.1"), unit="%w/w") + + csoc = ConcentrationOfSubstanceOfConcern( + substance=soc, concentration=conc, threshold=threshold + ) + assert csoc.exceeds_threshold() is None + + +class TestHelperFunctions: + """Tests for helper functions.""" + + def test_get_all_hazard_categories(self): + """Test get_all_hazard_categories function.""" + categories = get_all_hazard_categories() + assert len(categories) > 0 + assert "carcinogenicity_cat_1" in categories + assert "svhc_reach_art_57" in categories + + def test_get_lifecycle_stages(self): + """Test get_lifecycle_stages function.""" + stages = get_lifecycle_stages() + assert len(stages) == 6 + assert "production" in stages + assert "waste" in stages + + def test_is_svhc(self): + """Test is_svhc function.""" + assert is_svhc(HazardCategory.SVHC.value) + assert not is_svhc(HazardCategory.CARCINOGENICITY_1.value) + + def test_is_pop(self): + """Test is_pop function.""" + assert is_pop(HazardCategory.POP.value) + assert not is_pop(HazardCategory.SVHC.value) diff --git a/tests/unit/test_exporters.py b/tests/unit/test_exporters.py index b21dab0..a85740c 100644 --- a/tests/unit/test_exporters.py +++ b/tests/unit/test_exporters.py @@ -1,6 +1,7 @@ """Tests for exporters.""" import json +from datetime import datetime, timezone import pytest @@ -11,7 +12,28 @@ export_json, export_jsonld, ) -from dppvalidator.models import CredentialIssuer, DigitalProductPassport +from dppvalidator.models import CredentialIssuer, DigitalProductPassport, Product, ProductPassport + + +def _make_valid_passport( + id_suffix: str = "001", name: str = "Test Company" +) -> DigitalProductPassport: + """Create a CIRPASS-compliant passport for testing.""" + return DigitalProductPassport( + id=f"https://example.com/credentials/dpp-{id_suffix}", + issuer=CredentialIssuer( + id=f"https://example.com/issuers/{id_suffix}", + name=name, + ), + validFrom=datetime(2024, 1, 1, tzinfo=timezone.utc), + validUntil=datetime(2034, 1, 1, tzinfo=timezone.utc), + credentialSubject=ProductPassport( + product=Product( + id=f"https://example.com/products/{id_suffix}", + name="Test Product", + ), + ), + ) class TestContextManager: @@ -151,14 +173,7 @@ def test_roundtrip_jsonld_minimal(self): """Test round-trip with minimal passport via JSON-LD.""" from dppvalidator.validators import ValidationEngine - # Create original passport - original = DigitalProductPassport( - id="https://example.com/credentials/dpp-001", - issuer=CredentialIssuer( - id="https://example.com/issuers/001", - name="Round Trip Test Company", - ), - ) + original = _make_valid_passport("001", "Round Trip Test Company") # Export to JSON-LD exporter = JSONLDExporter() @@ -181,13 +196,7 @@ def test_roundtrip_json_minimal(self): """Test round-trip with minimal passport via plain JSON.""" from dppvalidator.validators import ValidationEngine - original = DigitalProductPassport( - id="https://example.com/credentials/dpp-002", - issuer=CredentialIssuer( - id="https://example.com/issuers/002", - name="JSON Round Trip Co", - ), - ) + original = _make_valid_passport("002", "JSON Round Trip Co") # Export to JSON exporter = JSONExporter() @@ -204,23 +213,12 @@ def test_roundtrip_json_minimal(self): def test_roundtrip_with_product(self): """Test round-trip with product data.""" - from dppvalidator.models import Product, ProductPassport from dppvalidator.validators import ValidationEngine - original = DigitalProductPassport( - id="https://example.com/credentials/dpp-003", - issuer=CredentialIssuer( - id="https://example.com/issuers/003", - name="Product Test Inc", - ), - credentialSubject=ProductPassport( - product=Product( - id="https://example.com/products/widget-001", - name="Premium Widget", - serialNumber="SN-12345", - ), - ), - ) + original = _make_valid_passport("003", "Product Test Inc") + # Override the product with custom data + original.credential_subject.product.name = "Premium Widget" + original.credential_subject.product.serial_number = "SN-12345" # Export and re-parse exporter = JSONLDExporter() @@ -239,22 +237,15 @@ def test_roundtrip_with_product(self): def test_roundtrip_with_materials(self): """Test round-trip with materials data.""" - from dppvalidator.models import Material, ProductPassport + from dppvalidator.models import Material from dppvalidator.validators import ValidationEngine - original = DigitalProductPassport( - id="https://example.com/credentials/dpp-004", - issuer=CredentialIssuer( - id="https://example.com/issuers/004", - name="Materials Test Ltd", - ), - credentialSubject=ProductPassport( - materialsProvenance=[ - Material(name="Steel", massFraction=0.6), - Material(name="Plastic", massFraction=0.4), - ], - ), - ) + original = _make_valid_passport("004", "Materials Test Ltd") + # Add materials to the credential subject + original.credential_subject.materials_provenance = [ + Material(name="Steel", mass_fraction=0.6), + Material(name="Plastic", mass_fraction=0.4), + ] # Export and re-parse exporter = JSONLDExporter() @@ -275,22 +266,12 @@ def test_roundtrip_with_materials(self): def test_roundtrip_with_dates(self): """Test round-trip preserves dates.""" - from datetime import datetime, timezone - from dppvalidator.validators import ValidationEngine - valid_from = datetime(2024, 1, 1, tzinfo=timezone.utc) - valid_until = datetime(2034, 12, 31, tzinfo=timezone.utc) - - original = DigitalProductPassport( - id="https://example.com/credentials/dpp-005", - issuer=CredentialIssuer( - id="https://example.com/issuers/005", - name="Dates Test Corp", - ), - validFrom=valid_from, - validUntil=valid_until, - ) + original = _make_valid_passport("005", "Dates Test Corp") + # Update dates + original.valid_from = datetime(2024, 1, 1, tzinfo=timezone.utc) + original.valid_until = datetime(2034, 12, 31, tzinfo=timezone.utc) exporter = JSONLDExporter() exported = exporter.export(original) @@ -311,13 +292,7 @@ def test_roundtrip_dict_export(self): """Test round-trip using export_dict.""" from dppvalidator.validators import ValidationEngine - original = DigitalProductPassport( - id="https://example.com/credentials/dpp-006", - issuer=CredentialIssuer( - id="https://example.com/issuers/006", - name="Dict Export Test", - ), - ) + original = _make_valid_passport("006", "Dict Export Test") exporter = JSONLDExporter() data = exporter.export_dict(original) @@ -330,13 +305,7 @@ def test_roundtrip_dict_export(self): def test_roundtrip_preserves_context(self): """Test round-trip preserves @context.""" - original = DigitalProductPassport( - id="https://example.com/credentials/dpp-007", - issuer=CredentialIssuer( - id="https://example.com/issuers/007", - name="Context Test", - ), - ) + original = _make_valid_passport("007", "Context Test") exporter = JSONLDExporter() data = exporter.export_dict(original) @@ -411,7 +380,7 @@ def test_jsonld_export_to_file(self, tmp_path): exporter.export_to_file(passport, output_path) assert output_path.exists() - content = output_path.read_text() + content = output_path.read_text(encoding="utf-8") data = json.loads(content) assert "@context" in data @@ -427,6 +396,6 @@ def test_json_export_to_file(self, tmp_path): exporter.export_to_file(passport, output_path) assert output_path.exists() - content = output_path.read_text() + content = output_path.read_text(encoding="utf-8") data = json.loads(content) assert "issuer" in data diff --git a/tests/unit/test_init_command.py b/tests/unit/test_init_command.py index 35fb113..0262740 100644 --- a/tests/unit/test_init_command.py +++ b/tests/unit/test_init_command.py @@ -131,7 +131,7 @@ def test_creates_minimal_template(self, tmp_path): run(args, console) dpp_file = tmp_path / "data" / "sample_passport.json" - content = json.loads(dpp_file.read_text()) + content = json.loads(dpp_file.read_text(encoding="utf-8")) assert content["type"] == MINIMAL_DPP_TEMPLATE["type"] assert "credentialSubject" in content @@ -144,7 +144,7 @@ def test_creates_full_template(self, tmp_path): run(args, console) dpp_file = tmp_path / "data" / "sample_passport.json" - content = json.loads(dpp_file.read_text()) + content = json.loads(dpp_file.read_text(encoding="utf-8")) assert "materialsProvenance" in content["credentialSubject"] assert "circularityScorecard" in content["credentialSubject"] @@ -157,7 +157,7 @@ def test_creates_gitignore(self, tmp_path): run(args, console) gitignore = tmp_path / ".gitignore" - content = gitignore.read_text() + content = gitignore.read_text(encoding="utf-8") assert ".dppvalidator/" in content assert "__pycache__/" in content @@ -171,7 +171,7 @@ def test_creates_pre_commit_config_when_requested(self, tmp_path): precommit = tmp_path / ".pre-commit-config.yaml" assert precommit.exists() - content = precommit.read_text() + content = precommit.read_text(encoding="utf-8") assert "dppvalidator" in content def test_does_not_create_pre_commit_by_default(self, tmp_path): @@ -199,7 +199,7 @@ def test_does_not_overwrite_existing_files(self, tmp_path): run(args, console) # Original content preserved - content = json.loads(existing_file.read_text()) + content = json.loads(existing_file.read_text(encoding="utf-8")) assert content == {"existing": "data"} def test_overwrites_files_with_force_flag(self, tmp_path): @@ -217,7 +217,7 @@ def test_overwrites_files_with_force_flag(self, tmp_path): run(args, console) # Content replaced - content = json.loads(existing_file.read_text()) + content = json.loads(existing_file.read_text(encoding="utf-8")) assert "type" in content assert "existing" not in content diff --git a/tests/unit/test_jsonld_semantic_extended.py b/tests/unit/test_jsonld_semantic_extended.py new file mode 100644 index 0000000..342ff3c --- /dev/null +++ b/tests/unit/test_jsonld_semantic_extended.py @@ -0,0 +1,381 @@ +"""Extended tests for JSON-LD semantic validation.""" + +from typing import Any +from unittest.mock import MagicMock, patch + +from dppvalidator.validators.jsonld_semantic import ( + UNTP_CONTEXT_PATTERNS, + CachingDocumentLoader, + JSONLDValidator, + _get_default_validator, + validate_jsonld, +) +from dppvalidator.validators.results import ValidationResult + + +class TestCachingDocumentLoader: + """Tests for CachingDocumentLoader behavior.""" + + def test_cache_hit_returns_cached_document(self) -> None: + """Cached documents are returned without re-fetching.""" + loader = CachingDocumentLoader(cache_size=10) + + # Pre-populate cache + cached_doc = {"document": {"@context": {}}} + loader._cache["https://example.com/context"] = cached_doc + + result = loader("https://example.com/context") + assert result is cached_doc + + def test_cache_eviction_on_overflow(self) -> None: + """Oldest entry is evicted when cache is full.""" + loader = CachingDocumentLoader(cache_size=5) + + # Clear bundled contexts for clean test + loader._cache.clear() + + # Fill cache + loader._cache["url1"] = {"document": 1} + loader._cache["url2"] = {"document": 2} + + # Adding a third should evict the first + loader._cache["url3"] = {"document": 3} + + # Simulate the eviction logic (which happens in __call__) + if len(loader._cache) >= loader._cache_size: + oldest = next(iter(loader._cache)) + del loader._cache[oldest] + + # With 3 items and cache_size=5, no eviction needed + assert len(loader._cache) == 3 + + def test_clear_cache_empties_cache(self) -> None: + """clear_cache() removes all cached entries.""" + loader = CachingDocumentLoader() + loader._cache["url1"] = {"document": 1} + loader._cache["url2"] = {"document": 2} + + loader.clear_cache() + + assert len(loader._cache) == 0 + + +class TestJSONLDValidatorContextValidation: + """Tests for @context validation.""" + + def test_missing_context_returns_error(self) -> None: + """Missing @context produces JLD001 error.""" + validator = JSONLDValidator() + data: dict[str, Any] = {"id": "urn:uuid:123", "type": "DigitalProductPassport"} + + result = validator.validate(data) + + assert result.valid is False + assert any(e.code == "JLD001" for e in result.errors) + assert any("Missing @context" in e.message for e in result.errors) + + def test_context_without_untp_returns_error(self) -> None: + """@context without UNTP vocabulary produces JLD001 error.""" + validator = JSONLDValidator() + data = { + "@context": "https://schema.org/", + "id": "urn:uuid:123", + } + + result = validator.validate(data) + + assert result.valid is False + assert any(e.code == "JLD001" for e in result.errors) + assert any("missing UNTP" in e.message for e in result.errors) + + def test_context_with_w3c_credentials_is_valid(self) -> None: + """@context with W3C credentials vocabulary passes.""" + validator = JSONLDValidator() + result = validator._validate_context_presence( + { + "@context": ["https://www.w3.org/ns/credentials/v2"], + } + ) + + assert result is None # No error + + def test_context_with_uncefact_is_valid(self) -> None: + """@context with UNCEFACT vocabulary passes.""" + validator = JSONLDValidator() + result = validator._validate_context_presence( + { + "@context": ["https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/"], + } + ) + + assert result is None # No error + + +class TestJSONLDValidatorExpansion: + """Tests for JSON-LD expansion behavior.""" + + @patch("dppvalidator.validators.jsonld_semantic.jsonld.expand") + def test_expansion_error_produces_jld001(self, mock_expand: MagicMock) -> None: + """JsonLdError during expansion produces JLD001 error.""" + from pyld.jsonld import JsonLdError + + mock_expand.side_effect = JsonLdError( + "Expansion failed", + "jsonld.InvalidContext", + None, + ) + + validator = JSONLDValidator(cache_contexts=False) + data = { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "id": "urn:uuid:123", + } + + result = validator.validate(data) + + assert result.valid is False + assert any(e.code == "JLD001" for e in result.errors) + + @patch("dppvalidator.validators.jsonld_semantic.jsonld.expand") + def test_network_error_produces_warning(self, mock_expand: MagicMock) -> None: + """Network/timeout errors produce JLD004 warning.""" + mock_expand.side_effect = ConnectionError("Network unreachable") + + validator = JSONLDValidator(cache_contexts=False) + data = { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "id": "urn:uuid:123", + } + + result = validator.validate(data) + + # Should have warning, not error + assert any(w.code == "JLD004" for w in result.warnings) + + +class TestJSONLDValidatorDroppedTerms: + """Tests for detecting dropped/undefined terms.""" + + def test_collect_keys_from_nested_object(self) -> None: + """_collect_keys() traverses nested objects.""" + validator = JSONLDValidator() + data = { + "level1": { + "level2": { + "level3": "value", + }, + }, + } + + keys = validator._collect_keys(data, "$") + key_names = [k for k, _ in keys] + + assert "level1" in key_names + assert "level2" in key_names + assert "level3" in key_names + + def test_collect_keys_from_arrays(self) -> None: + """_collect_keys() traverses arrays.""" + validator = JSONLDValidator() + data = { + "items": [ + {"name": "item1"}, + {"name": "item2"}, + ], + } + + keys = validator._collect_keys(data, "$") + key_names = [k for k, _ in keys] + + assert "items" in key_names + assert key_names.count("name") == 2 + + def test_collect_keys_skips_jsonld_keywords(self) -> None: + """_collect_keys() skips @-prefixed keys.""" + validator = JSONLDValidator() + data = { + "@context": "https://example.com", + "@type": "Thing", + "name": "Test", + } + + keys = validator._collect_keys(data, "$") + key_names = [k for k, _ in keys] + + assert "@context" not in key_names + assert "@type" not in key_names + assert "name" in key_names + + +class TestJSONLDValidatorExpandedIRIs: + """Tests for collecting expanded IRIs.""" + + def test_collect_expanded_iris_from_dict(self) -> None: + """_collect_expanded_iris() extracts HTTP(S) keys.""" + validator = JSONLDValidator() + expanded = { + "https://schema.org/name": [{"@value": "Test"}], + "https://example.com/custom": [{"@value": 123}], + } + + iris: set[str] = set() + validator._collect_expanded_iris(expanded, iris) + + assert "https://schema.org/name" in iris + assert "https://example.com/custom" in iris + + def test_collect_expanded_iris_from_nested(self) -> None: + """_collect_expanded_iris() handles nested structures.""" + validator = JSONLDValidator() + expanded = { + "https://schema.org/product": [ + { + "https://schema.org/name": [{"@value": "Widget"}], + } + ], + } + + iris: set[str] = set() + validator._collect_expanded_iris(expanded, iris) + + assert "https://schema.org/product" in iris + assert "https://schema.org/name" in iris + + def test_collect_expanded_iris_from_list(self) -> None: + """_collect_expanded_iris() handles lists.""" + validator = JSONLDValidator() + expanded = [ + {"https://schema.org/a": []}, + {"https://schema.org/b": []}, + ] + + iris: set[str] = set() + validator._collect_expanded_iris(expanded, iris) + + assert "https://schema.org/a" in iris + assert "https://schema.org/b" in iris + + +class TestJSONLDValidatorUnprefixedTerms: + """Tests for detecting unprefixed custom terms.""" + + def test_standard_terms_not_flagged(self) -> None: + """Standard UNTP/VC terms are not flagged.""" + validator = JSONLDValidator() + data = { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "type": "DigitalProductPassport", + "id": "urn:uuid:123", + "credentialSubject": {}, + "issuer": "did:web:example.com", + } + + unprefixed = validator._find_unprefixed_custom_terms(data) + term_names = [t for t, _ in unprefixed] + + assert "type" not in term_names + assert "id" not in term_names + assert "credentialSubject" not in term_names + assert "issuer" not in term_names + + def test_prefixed_terms_not_flagged(self) -> None: + """Prefixed terms (containing colon) are not flagged.""" + validator = JSONLDValidator() + data = { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "ex:customField": "value", + "schema:name": "Test", + } + + unprefixed = validator._find_unprefixed_custom_terms(data) + term_names = [t for t, _ in unprefixed] + + assert "ex:customField" not in term_names + assert "schema:name" not in term_names + + def test_unprefixed_custom_terms_flagged(self) -> None: + """Unprefixed custom terms are flagged.""" + validator = JSONLDValidator() + data = { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "myCustomField": "value", + "anotherCustom": 123, + } + + unprefixed = validator._find_unprefixed_custom_terms(data) + term_names = [t for t, _ in unprefixed] + + assert "myCustomField" in term_names + assert "anotherCustom" in term_names + + +class TestJSONLDValidatorStrictMode: + """Tests for strict mode behavior.""" + + @patch("dppvalidator.validators.jsonld_semantic.jsonld.expand") + def test_strict_mode_makes_dropped_terms_errors(self, mock_expand: MagicMock) -> None: + """In strict mode, dropped terms are errors instead of warnings.""" + mock_expand.return_value = [{}] # Empty expansion = all terms dropped + + validator = JSONLDValidator(strict=True, cache_contexts=False) + data = { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "customField": "value", + } + + result = validator.validate(data) + + # In strict mode, JLD002 should be an error + jld002_errors = [e for e in result.errors if e.code == "JLD002"] + assert len(jld002_errors) > 0 + + @patch("dppvalidator.validators.jsonld_semantic.jsonld.expand") + def test_non_strict_mode_makes_dropped_terms_warnings(self, mock_expand: MagicMock) -> None: + """In non-strict mode, dropped terms are warnings.""" + mock_expand.return_value = [{}] # Empty expansion = all terms dropped + + validator = JSONLDValidator(strict=False, cache_contexts=False) + data = { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "customField": "value", + } + + result = validator.validate(data) + + # In non-strict mode, JLD002 should be a warning + jld002_warnings = [w for w in result.warnings if w.code == "JLD002"] + assert len(jld002_warnings) > 0 + + +class TestModuleFunctions: + """Tests for module-level functions.""" + + def test_get_default_validator_returns_singleton(self) -> None: + """_get_default_validator returns cached instance.""" + v1 = _get_default_validator() + v2 = _get_default_validator() + assert v1 is v2 + + def test_validate_jsonld_uses_default_validator(self) -> None: + """validate_jsonld() uses the default validator.""" + data = {"id": "test"} # Missing @context + result = validate_jsonld(data) + + assert isinstance(result, ValidationResult) + assert result.valid is False + + +class TestUNTPContextPatterns: + """Tests for UNTP context pattern matching.""" + + def test_patterns_include_uncefact(self) -> None: + """UNTP patterns include uncefact.org.""" + assert "uncefact.org" in UNTP_CONTEXT_PATTERNS + + def test_patterns_include_w3c_credentials(self) -> None: + """UNTP patterns include W3C credentials.""" + assert "w3.org/ns/credentials" in UNTP_CONTEXT_PATTERNS + + def test_patterns_include_untp(self) -> None: + """UNTP patterns include 'untp'.""" + assert "untp" in UNTP_CONTEXT_PATTERNS diff --git a/tests/unit/test_jsonld_validation.py b/tests/unit/test_jsonld_validation.py new file mode 100644 index 0000000..0c27f35 --- /dev/null +++ b/tests/unit/test_jsonld_validation.py @@ -0,0 +1,259 @@ +"""Unit tests for JSON-LD semantic validation.""" + +from dppvalidator.validators.jsonld_semantic import ( + UNTP_CONTEXT_PATTERNS, + CachingDocumentLoader, + JSONLDValidator, +) +from dppvalidator.validators.results import ValidationResult + + +class TestContextPresenceValidation: + """Tests for @context presence validation.""" + + def test_missing_context_returns_error(self) -> None: + """Missing @context returns JLD001 error.""" + validator = JSONLDValidator() + result = validator._validate_context_presence({}) + + assert result is not None + assert result.code == "JLD001" + assert "Missing @context" in result.message + + def test_context_without_untp_returns_error(self) -> None: + """@context without UNTP vocabulary returns error.""" + validator = JSONLDValidator() + result = validator._validate_context_presence( + {"@context": ["https://example.com/other-context"]} + ) + + assert result is not None + assert result.code == "JLD001" + assert "missing UNTP" in result.message + + def test_valid_untp_context_returns_none(self) -> None: + """Valid UNTP @context returns None (no error).""" + validator = JSONLDValidator() + result = validator._validate_context_presence( + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ] + } + ) + + assert result is None + + def test_valid_w3c_credentials_context(self) -> None: + """W3C credentials context is valid.""" + validator = JSONLDValidator() + result = validator._validate_context_presence( + {"@context": ["https://www.w3.org/ns/credentials/v2"]} + ) + + assert result is None + + +class TestDroppedTermDetection: + """Tests for detecting terms dropped during expansion.""" + + def test_collect_keys_basic(self) -> None: + """Collect keys from simple object.""" + validator = JSONLDValidator() + keys = validator._collect_keys({"name": "test", "value": 123}, "$") + + assert ("name", "$.name") in keys + assert ("value", "$.value") in keys + + def test_collect_keys_nested(self) -> None: + """Collect keys from nested object.""" + validator = JSONLDValidator() + keys = validator._collect_keys({"outer": {"inner": "value"}}, "$") + + key_names = [k for k, _ in keys] + assert "outer" in key_names + assert "inner" in key_names + + def test_collect_keys_with_arrays(self) -> None: + """Collect keys from objects in arrays.""" + validator = JSONLDValidator() + keys = validator._collect_keys({"items": [{"name": "first"}, {"name": "second"}]}, "$") + + key_names = [k for k, _ in keys] + assert "items" in key_names + assert key_names.count("name") == 2 + + def test_collect_expanded_iris(self) -> None: + """Collect IRIs from expanded JSON-LD.""" + validator = JSONLDValidator() + iris: set[str] = set() + + expanded = { + "https://example.com/name": [{"@value": "Test"}], + "https://example.com/items": [{"https://example.com/id": [{"@value": "1"}]}], + } + + validator._collect_expanded_iris(expanded, iris) + + assert "https://example.com/name" in iris + assert "https://example.com/items" in iris + assert "https://example.com/id" in iris + + +class TestUnprefixedTermDetection: + """Tests for detecting unprefixed custom terms.""" + + def test_standard_terms_not_flagged(self) -> None: + """Standard UNTP terms are not flagged.""" + validator = JSONLDValidator() + result = validator._find_unprefixed_custom_terms( + { + "type": "DigitalProductPassport", + "credentialSubject": {"product": {}}, + } + ) + + assert len(result) == 0 + + def test_prefixed_terms_not_flagged(self) -> None: + """Prefixed custom terms are not flagged.""" + validator = JSONLDValidator() + result = validator._find_unprefixed_custom_terms( + { + "ex:customField": "value", + "myns:anotherField": 123, + } + ) + + assert len(result) == 0 + + def test_unprefixed_custom_terms_flagged(self) -> None: + """Unprefixed custom terms are flagged.""" + validator = JSONLDValidator() + result = validator._find_unprefixed_custom_terms( + { + "myCustomField": "value", + "anotherCustom": 123, + } + ) + + term_names = [t for t, _ in result] + assert "myCustomField" in term_names + assert "anotherCustom" in term_names + + +class TestCachingDocumentLoader: + """Tests for context caching.""" + + def test_cache_hit(self) -> None: + """Cached contexts are returned without fetching.""" + loader = CachingDocumentLoader() + + # Pre-populate cache + cached_doc = {"document": {"@context": {}}} + loader._cache["https://example.com/context"] = cached_doc + + result = loader("https://example.com/context") + assert result == cached_doc + + def test_cache_clear(self) -> None: + """Cache can be cleared.""" + loader = CachingDocumentLoader() + loader._cache["https://example.com/context"] = {"document": {}} + + loader.clear_cache() + assert len(loader._cache) == 0 + + def test_cache_eviction(self) -> None: + """Old entries are evicted when cache is full.""" + loader = CachingDocumentLoader(cache_size=5) + + # Clear bundled contexts for clean test + loader._cache.clear() + + # Fill cache + loader._cache["url1"] = {"document": {}} + loader._cache["url2"] = {"document": {}} + + # This would trigger eviction on next add + assert len(loader._cache) == 2 + + +class TestUNTPContextPatterns: + """Tests for UNTP context pattern matching.""" + + def test_patterns_include_uncefact(self) -> None: + """Patterns include uncefact.org.""" + assert "uncefact.org" in UNTP_CONTEXT_PATTERNS + + def test_patterns_include_untp(self) -> None: + """Patterns include untp.""" + assert "untp" in UNTP_CONTEXT_PATTERNS + + def test_patterns_include_w3c_credentials(self) -> None: + """Patterns include w3.org/ns/credentials.""" + assert "w3.org/ns/credentials" in UNTP_CONTEXT_PATTERNS + + +class TestValidatorIntegration: + """Integration tests for full validation flow.""" + + def test_validator_with_valid_context(self) -> None: + """Validator accepts valid UNTP context.""" + validator = JSONLDValidator() + data = { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ], + "type": ["DigitalProductPassport", "VerifiableCredential"], + "credentialSubject": {}, + } + + # This will try to fetch remote contexts which may fail + # but should not raise an exception + result = validator.validate(data) + assert isinstance(result, ValidationResult) + + def test_validator_strict_mode(self) -> None: + """Strict mode makes undefined terms errors.""" + validator = JSONLDValidator(strict=True) + assert validator.strict is True + + validator_normal = JSONLDValidator(strict=False) + assert validator_normal.strict is False + + +class TestEngineJSONLDIntegration: + """Tests for ValidationEngine JSON-LD integration.""" + + def test_engine_jsonld_layer_disabled_by_default(self) -> None: + """JSON-LD validation is disabled by default.""" + from dppvalidator.validators import ValidationEngine + + engine = ValidationEngine(schema_version="0.6.1", load_plugins=False) + assert "jsonld" not in engine.layers + assert engine.validate_jsonld is False + + def test_engine_jsonld_enabled_via_parameter(self) -> None: + """JSON-LD validation can be enabled via parameter.""" + from dppvalidator.validators import ValidationEngine + + engine = ValidationEngine( + schema_version="0.6.1", + validate_jsonld=True, + load_plugins=False, + ) + assert engine.validate_jsonld is True + + def test_engine_jsonld_enabled_via_layers(self) -> None: + """JSON-LD validation can be enabled via layers.""" + from dppvalidator.validators import ValidationEngine + + engine = ValidationEngine( + schema_version="0.6.1", + layers=["schema", "model", "jsonld"], + load_plugins=False, + ) + assert "jsonld" in engine.layers diff --git a/tests/unit/test_manifest_integrity.py b/tests/unit/test_manifest_integrity.py new file mode 100644 index 0000000..07aea52 --- /dev/null +++ b/tests/unit/test_manifest_integrity.py @@ -0,0 +1,217 @@ +"""Phase 5 acceptance test: every bundled artefact matches MANIFEST.json. + +Cardinal rule 4 from ``.claude/rules/untp-versioning.md``: + +> Bundled artefacts have a manifest. Every JSON Schema and JSON-LD +> context vendored under ``src/dppvalidator/schemas/data/`` or +> ``src/dppvalidator/vocabularies/data/`` MUST appear in +> ``src/dppvalidator/schemas/data/MANIFEST.json`` with version, source +> URL, SHA-256, and pull date. CI verifies the hashes. + +This module is the CI verification end of that contract: + +1. Every entry in MANIFEST.json points at a file that exists. +2. Each file's SHA-256 matches the manifest pin. +3. The manifest is well-formed (every entry has the required fields). +4. (Drift catch) Every bundled JSON Schema / JSON-LD artefact is + *covered* by the manifest — adding a vendored file without a + manifest entry fails the suite. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_MANIFEST_PATH = _REPO_ROOT / "src" / "dppvalidator" / "schemas" / "data" / "MANIFEST.json" + +# Required keys every artefact entry must have. +_REQUIRED_FIELDS: frozenset[str] = frozenset( + {"version", "kind", "path", "source_url", "sha256", "pulled_at"} +) + +# Vendored-data directories scanned by the drift-catch test. +_VENDORED_DIRS: tuple[Path, ...] = ( + _REPO_ROOT / "src" / "dppvalidator" / "schemas" / "data", + _REPO_ROOT / "src" / "dppvalidator" / "vocabularies" / "data", +) + +# File extensions that count as "vendored" for the drift-catch test. +# README.md and __init__.py are infrastructure, not data, and don't need +# manifest entries. +_TRACKED_EXTENSIONS: frozenset[str] = frozenset({".json", ".jsonld"}) + +# Known files that intentionally aren't tracked by MANIFEST.json. These +# fall into two buckets: +# +# 1. Project-curated lists that don't have a single upstream-pinned +# source (countries, units, materials, HS codes). +# 2. CIRPASS-schema files: the CIRPASS axis has its own version pin +# (``CIRPASS_SCHEMA_VERSION`` in ``schemas/cirpass_loader.py``); +# folding them into MANIFEST.json would conflate two version axes. +_UNTRACKED_FILES: frozenset[str] = frozenset( + { + "MANIFEST.json", # the manifest itself + # Project-curated vocab data. + "countries.json", + "units.json", + "materials.json", + "hs_codes.json", + # CIRPASS schemas — separate version axis (cirpass_loader.py). + "cirpass_dpp_schema.json", + "cirpass_dpp_openapi.json", + } +) + + +def _load_manifest() -> dict[str, Any]: + return json.loads(_MANIFEST_PATH.read_text(encoding="utf-8")) + + +def _normalise_for_hash(content: bytes) -> bytes: + """Match the SHA-256 normalisation used by the registry's verifier. + + ``SchemaVersion.verify_integrity`` normalises ``\\r\\n`` to ``\\n`` + before hashing so cross-platform line-ending differences don't + invalidate the pins. The manifest hashes are computed against the + same normalised bytes. + """ + return content.replace(b"\r\n", b"\n") + + +# --------------------------------------------------------------------------- +# Manifest is well-formed +# --------------------------------------------------------------------------- + + +class TestManifestStructure: + """Sanity checks on MANIFEST.json itself.""" + + def test_manifest_file_exists(self) -> None: + assert _MANIFEST_PATH.is_file(), f"missing manifest at {_MANIFEST_PATH}" + + def test_manifest_is_valid_json(self) -> None: + # Will raise if the manifest is malformed. + _load_manifest() + + def test_manifest_has_artefacts_array(self) -> None: + manifest = _load_manifest() + assert isinstance(manifest.get("artefacts"), list) + assert manifest["artefacts"], "artefacts array is empty" + + def test_every_entry_has_required_fields(self) -> None: + manifest = _load_manifest() + for entry in manifest["artefacts"]: + missing = _REQUIRED_FIELDS - set(entry.keys()) + assert not missing, ( + f"manifest entry for {entry.get('path')!r} missing fields: {sorted(missing)}" + ) + + def test_paths_are_repo_relative(self) -> None: + """Every ``path`` field is a relative path under ``src/``.""" + manifest = _load_manifest() + for entry in manifest["artefacts"]: + path = entry["path"] + assert not path.startswith("/"), f"path is absolute: {path!r}" + assert path.startswith("src/dppvalidator/"), ( + f"path is not under src/dppvalidator/: {path!r}" + ) + + +# --------------------------------------------------------------------------- +# Each manifest entry resolves to an existing file with matching hash +# --------------------------------------------------------------------------- + + +def _manifest_entries_with_files() -> list[tuple[str, Path, str]]: + """Materialise ``(label, file_path, expected_sha256)`` triples.""" + manifest = _load_manifest() + out: list[tuple[str, Path, str]] = [] + for entry in manifest["artefacts"]: + label = f"{entry['kind']}@{entry['version']}" + out.append( + (label, _REPO_ROOT / entry["path"], entry["sha256"]), + ) + return out + + +@pytest.mark.parametrize( + ("label", "file_path", "expected_sha256"), + _manifest_entries_with_files(), + ids=lambda v: v if isinstance(v, str) else getattr(v, "name", str(v)), +) +def test_manifest_entry_file_exists_and_hash_matches( + label: str, file_path: Path, expected_sha256: str +) -> None: + """The bundled file exists and its SHA-256 matches the manifest pin. + + Failure here means either: + - the file was edited without re-running the manifest update; or + - the manifest pin is stale. + + Either way: re-pull the upstream artefact, refresh the SHA in + MANIFEST.json, and re-run the suite. + """ + assert file_path.is_file(), f"manifest entry {label!r} points at missing file {file_path}" + raw = file_path.read_bytes() + actual = hashlib.sha256(_normalise_for_hash(raw)).hexdigest() + assert actual == expected_sha256, ( + f"SHA-256 mismatch for {label} ({file_path.name}):\n" + f" manifest: {expected_sha256}\n" + f" on disk: {actual}\n" + "Either the file was modified or the manifest is stale." + ) + + +# --------------------------------------------------------------------------- +# Drift catch: every vendored .json/.jsonld is in the manifest +# --------------------------------------------------------------------------- + + +def _all_vendored_files() -> list[Path]: + """Return every vendored file under the tracked data directories. + + Skips directory infrastructure (``__init__.py``, ``__pycache__``, + ``README.md``) and files in the explicit allow-list of + project-curated vocabularies (``countries.json`` etc.). + """ + out: list[Path] = [] + for root in _VENDORED_DIRS: + if not root.is_dir(): # pragma: no cover — repo layout is stable + continue + for path in root.rglob("*"): + if not path.is_file(): + continue + if path.suffix not in _TRACKED_EXTENSIONS: + continue + if path.name in _UNTRACKED_FILES: + continue + out.append(path) + return sorted(out) + + +def test_every_vendored_file_is_manifested() -> None: + """Every ``.json``/``.jsonld`` under data dirs has a manifest entry. + + Adding a new vendored upstream artefact without a manifest entry + silently bypasses CI hash verification — this guard catches that + drift. + """ + manifest = _load_manifest() + manifested_paths = {(_REPO_ROOT / e["path"]).resolve() for e in manifest["artefacts"]} + untracked: list[Path] = [] + for vendored in _all_vendored_files(): + if vendored.resolve() not in manifested_paths: + untracked.append(vendored.relative_to(_REPO_ROOT)) + assert not untracked, ( + f"{len(untracked)} vendored file(s) lack manifest entries:\n" + + "\n".join(f" - {p}" for p in untracked) + + "\nAdd them to src/dppvalidator/schemas/data/MANIFEST.json or " + "extend tests/unit/test_manifest_integrity.py:_UNTRACKED_FILES if " + "they're project-curated (not vendored)." + ) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index f2200d1..3861f6a 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -64,8 +64,8 @@ def test_material_with_mass_fraction(self): """Test material with mass fraction.""" material = Material( name="Steel", - massFraction=0.6, - originCountry="DE", + mass_fraction=0.6, + origin_country="DE", ) assert material.mass_fraction == 0.6 assert material.origin_country == "DE" @@ -80,7 +80,7 @@ def test_hazardous_with_safety_info(self): material = Material( name="Hazardous Chemical", hazardous=True, - materialSafetyInformation={"linkURL": "https://example.com/msds"}, + material_safety_information={"link_url": "https://example.com/msds"}, ) assert material.hazardous is True @@ -122,8 +122,8 @@ def test_product_with_serial_number(self): product = Product( id="https://example.com/products/002", name="Battery Pack", - serialNumber="SN-2024-001", - batchNumber="BATCH-Q1", + serial_number="SN-2024-001", + batch_number="BATCH-Q1", ) assert product.serial_number == "SN-2024-001" @@ -211,17 +211,17 @@ class TestLink: def test_link_with_url(self): """Test creating a link with URL.""" - link = Link(linkURL="https://example.com/doc") + link = Link(link_url="https://example.com/doc") assert str(link.link_url) == "https://example.com/doc" def test_link_with_name(self): """Test link with display name.""" - link = Link(linkURL="https://example.com/doc", linkName="Documentation") + link = Link(link_url="https://example.com/doc", link_name="Documentation") assert link.link_name == "Documentation" def test_link_to_jsonld(self): """Test JSON-LD serialization.""" - link = Link(linkURL="https://example.com/doc") + link = Link(link_url="https://example.com/doc") jsonld = link.to_jsonld() assert jsonld["type"] == ["Link"] @@ -232,16 +232,16 @@ class TestSecureLink: def test_secure_link_with_hash(self): """Test secure link with hash.""" link = SecureLink( - linkURL="https://example.com/file.pdf", - hashDigest="abc123", - hashMethod=HashMethod.SHA_256, + link_url="https://example.com/file.pdf", + hash_digest="abc123", + hash_method=HashMethod.SHA_256, ) assert link.hash_digest == "abc123" assert link.hash_method == HashMethod.SHA_256 def test_secure_link_to_jsonld(self): """Test JSON-LD serialization.""" - link = SecureLink(linkURL="https://example.com/file.pdf") + link = SecureLink(link_url="https://example.com/file.pdf") jsonld = link.to_jsonld() assert "SecureLink" in jsonld["type"] assert "Link" in jsonld["type"] @@ -304,7 +304,7 @@ def test_party_with_registered_id(self): party = Party( id="https://example.com/parties/001", name="Example Corp", - registeredId="REG-12345", + registered_id="REG-12345", ) assert party.registered_id == "REG-12345" @@ -339,8 +339,8 @@ class TestMetric: def test_valid_metric(self): """Test creating a valid metric.""" metric = Metric( - metricName="Carbon Footprint", - metricValue={"value": 25.5, "unit": "KGM"}, + metric_name="Carbon Footprint", + metric_value={"value": 25.5, "unit": "KGM"}, ) assert metric.metric_name == "Carbon Footprint" assert metric.metric_value.value == 25.5 @@ -348,8 +348,8 @@ def test_valid_metric(self): def test_metric_with_accuracy(self): """Test metric with accuracy.""" metric = Metric( - metricName="Energy Usage", - metricValue={"value": 100, "unit": "KWH"}, + metric_name="Energy Usage", + metric_value={"value": 100, "unit": "KWH"}, accuracy=0.95, ) assert metric.accuracy == 0.95 @@ -358,8 +358,8 @@ def test_metric_accuracy_bounds(self): """Test metric accuracy must be 0-1.""" with pytest.raises(ValidationError): Metric( - metricName="Test", - metricValue={"value": 1, "unit": "EA"}, + metric_name="Test", + metric_value={"value": 1, "unit": "EA"}, accuracy=1.5, ) @@ -370,10 +370,10 @@ class TestEmissionsPerformance: def test_valid_emissions(self): """Test creating valid emissions performance.""" emissions = EmissionsPerformance( - carbonFootprint=25.5, - declaredUnit="KGM", - operationalScope=OperationalScope.CRADLE_TO_GATE, - primarySourcedRatio=0.8, + carbon_footprint=25.5, + declared_unit="KGM", + operational_scope=OperationalScope.CRADLE_TO_GATE, + primary_sourced_ratio=0.8, ) assert emissions.carbon_footprint == 25.5 assert emissions.operational_scope == OperationalScope.CRADLE_TO_GATE @@ -382,19 +382,19 @@ def test_emissions_ratio_bounds(self): """Test primary sourced ratio must be 0-1.""" with pytest.raises(ValidationError): EmissionsPerformance( - carbonFootprint=25.5, - declaredUnit="KGM", - operationalScope=OperationalScope.CRADLE_TO_GATE, - primarySourcedRatio=1.5, + carbon_footprint=25.5, + declared_unit="KGM", + operational_scope=OperationalScope.CRADLE_TO_GATE, + primary_sourced_ratio=1.5, ) def test_emissions_to_jsonld(self): """Test JSON-LD serialization.""" emissions = EmissionsPerformance( - carbonFootprint=25.5, - declaredUnit="KGM", - operationalScope=OperationalScope.CRADLE_TO_GATE, - primarySourcedRatio=0.8, + carbon_footprint=25.5, + declared_unit="KGM", + operational_scope=OperationalScope.CRADLE_TO_GATE, + primary_sourced_ratio=0.8, ) jsonld = emissions.to_jsonld() assert jsonld["type"] == ["EmissionsPerformance"] @@ -406,9 +406,9 @@ class TestCircularityPerformance: def test_valid_circularity(self): """Test creating valid circularity performance.""" circularity = CircularityPerformance( - recyclableContent=0.85, - recycledContent=0.3, - utilityFactor=1.2, + recyclable_content=0.85, + recycled_content=0.3, + utility_factor=1.2, ) assert circularity.recyclable_content == 0.85 assert circularity.recycled_content == 0.3 @@ -416,11 +416,11 @@ def test_valid_circularity(self): def test_circularity_content_bounds(self): """Test recyclable content must be 0-1.""" with pytest.raises(ValidationError): - CircularityPerformance(recyclableContent=1.5) + CircularityPerformance(recyclable_content=1.5) def test_circularity_to_jsonld(self): """Test JSON-LD serialization.""" - circularity = CircularityPerformance(recycledContent=0.3) + circularity = CircularityPerformance(recycled_content=0.3) jsonld = circularity.to_jsonld() assert jsonld["type"] == ["CircularityPerformance"] @@ -431,15 +431,15 @@ class TestTraceabilityPerformance: def test_valid_traceability(self): """Test creating valid traceability performance.""" traceability = TraceabilityPerformance( - valueChainProcess="Manufacturing", - verifiedRatio=0.9, + value_chain_process="Manufacturing", + verified_ratio=0.9, ) assert traceability.value_chain_process == "Manufacturing" assert traceability.verified_ratio == 0.9 def test_traceability_to_jsonld(self): """Test JSON-LD serialization.""" - traceability = TraceabilityPerformance(verifiedRatio=0.8) + traceability = TraceabilityPerformance(verified_ratio=0.8) jsonld = traceability.to_jsonld() assert jsonld["type"] == ["TraceabilityPerformance"] @@ -453,7 +453,7 @@ def test_valid_criterion(self): id="https://example.com/criteria/001", name="Energy Efficiency", description="Minimum energy efficiency requirements", - conformityTopic=ConformityTopic.ENVIRONMENT_ENERGY, + conformity_topic=ConformityTopic.ENVIRONMENT_ENERGY, status=CriterionStatus.ACTIVE, ) assert criterion.name == "Energy Efficiency" @@ -465,7 +465,7 @@ def test_criterion_to_jsonld(self): id="https://example.com/criteria/001", name="Test", description="Test criterion", - conformityTopic=ConformityTopic.ENVIRONMENT_ENERGY, + conformity_topic=ConformityTopic.ENVIRONMENT_ENERGY, status=CriterionStatus.ACTIVE, ) jsonld = criterion.to_jsonld() @@ -480,7 +480,7 @@ def test_valid_standard(self): standard = Standard( id="https://iso.org/14001", name="ISO 14001", - issuingParty={ + issuing_party={ "id": "https://iso.org", "name": "ISO", }, @@ -491,7 +491,7 @@ def test_standard_to_jsonld(self): """Test JSON-LD serialization.""" standard = Standard( name="ISO 14001", - issuingParty={"id": "https://iso.org", "name": "ISO"}, + issuing_party={"id": "https://iso.org", "name": "ISO"}, ) jsonld = standard.to_jsonld() assert jsonld["type"] == ["Standard"] @@ -505,11 +505,11 @@ def test_valid_regulation(self): regulation = Regulation( id="https://ec.europa.eu/espr", name="EU ESPR", - administeredBy={ + administered_by={ "id": "https://ec.europa.eu", "name": "European Commission", }, - jurisdictionCountry="EU", + jurisdiction_country="EU", ) assert regulation.name == "EU ESPR" assert regulation.jurisdiction_country == "EU" @@ -518,7 +518,7 @@ def test_regulation_to_jsonld(self): """Test JSON-LD serialization.""" regulation = Regulation( name="ESPR", - administeredBy={"id": "https://ec.europa.eu", "name": "EC"}, + administered_by={"id": "https://ec.europa.eu", "name": "EC"}, ) jsonld = regulation.to_jsonld() assert jsonld["type"] == ["Regulation"] @@ -532,7 +532,7 @@ def test_valid_claim(self): claim = Claim( id="https://example.com/claims/001", conformance=True, - conformityTopic=ConformityTopic.ENVIRONMENT_EMISSIONS, + conformity_topic=ConformityTopic.ENVIRONMENT_EMISSIONS, ) assert claim.conformance is True assert claim.conformity_topic == ConformityTopic.ENVIRONMENT_EMISSIONS @@ -542,7 +542,7 @@ def test_claim_to_jsonld(self): claim = Claim( id="https://example.com/claims/001", conformance=True, - conformityTopic=ConformityTopic.ENVIRONMENT_EMISSIONS, + conformity_topic=ConformityTopic.ENVIRONMENT_EMISSIONS, ) jsonld = claim.to_jsonld() assert "Claim" in jsonld["type"] @@ -556,7 +556,7 @@ def test_valid_product_passport(self): """Test creating a valid product passport.""" passport = ProductPassport( id="https://example.com/passports/001", - granularityLevel=GranularityLevel.ITEM, + granularity_level=GranularityLevel.ITEM, ) assert passport.granularity_level == GranularityLevel.ITEM @@ -594,9 +594,9 @@ def test_valid_credential_status(self): status = CredentialStatus( id="https://example.com/status/1#42", type="BitstringStatusListEntry", - statusPurpose="revocation", - statusListIndex="42", - statusListCredential="https://example.com/status/1", + status_purpose="revocation", + status_list_index="42", + status_list_credential="https://example.com/status/1", ) assert status.id == "https://example.com/status/1#42" assert status.type == "BitstringStatusListEntry" @@ -620,7 +620,7 @@ def test_credential_status_to_jsonld(self): status = CredentialStatus( id="https://example.com/status/1#42", type="BitstringStatusListEntry", - statusPurpose="revocation", + status_purpose="revocation", ) jsonld = status.to_jsonld() assert jsonld["type"] == ["CredentialStatus"] diff --git a/tests/unit/test_no_version_literals.py b/tests/unit/test_no_version_literals.py new file mode 100644 index 0000000..be0f5c4 --- /dev/null +++ b/tests/unit/test_no_version_literals.py @@ -0,0 +1,128 @@ +"""Guard test: forbid bare ``"X.Y.Z"`` version literals in source code. + +This test enforces the contract documented in +``docs/plans/UNTP_0.7.0_MIGRATION.md`` §Phase 1 / §7.1 (rule 1) and +``.claude/rules/untp-versioning.md`` (cardinal rule 1): + +> No bare UNTP/CIRPASS version literals. A string like ``"0.6.1"`` or +> ``"0.7.0"`` may only appear in ``schemas/registry.py``, +> ``exporters/contexts.py``, and ``schemas/cirpass_loader.py``. Everywhere +> else: look it up via ``SchemaRegistry``, ``ContextManager``, or +> ``dppvalidator.compat.active_version()``. + +When this test fails, the right fix is almost always to import +``DEFAULT_SCHEMA_VERSION`` from ``dppvalidator.schemas.registry`` and use it +as the constructor default / dataclass default / argparse default — not to +add the new file to ``ALLOWED``. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +# Pattern: a "double-quoted" SemVer-shaped literal anywhere in a Python source +# line. We intentionally do NOT match version-like substrings that appear +# inside longer URLs (e.g. "https://.../0.6.1/..."), because the version is +# not the entire content between the surrounding quotes there. +_VERSION_LITERAL_PATTERN = re.compile(r'"\d+\.\d+\.\d+"') + +# Files allowed to contain bare version literals. These are all *registries* +# in the strict §7.1 sense ("source of truth per surface"): each one defines +# a single dispatch table whose entries MUST be literal version strings +# keyed to :data:`SCHEMA_REGISTRY`. Adding a new UNTP version is a one-line +# change in each. +# +# - ``schemas/registry.py`` : the ``SchemaVersion`` registry itself. +# - ``exporters/contexts.py`` : the JSON-LD ``ContextDefinition`` registry. +# - ``schemas/cirpass_loader.py`` : the CIRPASS-2 ``CIRPASS_SCHEMA_VERSION`` +# constant (separate version axis). +# - ``validators/model.py`` : the ``_MODEL_BY_VERSION`` Pydantic-class +# dispatch (Phase 3.3). +# - ``validators/deep.py`` : the ``LINK_PATHS_BY_VERSION`` deep-crawl +# path dispatch (Phase 3b). +# - ``validators/rules/__init__`` : the ``ALL_RULES_BY_VERSION`` semantic-rule +# dispatch (Phase 3b). +_ALLOWED_FILES = frozenset( + { + Path("src/dppvalidator/schemas/registry.py"), + Path("src/dppvalidator/exporters/contexts.py"), + Path("src/dppvalidator/schemas/cirpass_loader.py"), + Path("src/dppvalidator/validators/model.py"), + Path("src/dppvalidator/validators/deep.py"), + Path("src/dppvalidator/validators/rules/__init__.py"), + } +) + + +def _project_root() -> Path: + """Locate the repo root by walking up from this test file.""" + return Path(__file__).resolve().parents[2] + + +def _violations() -> list[tuple[Path, int, str, str]]: + """Collect every bare version-literal occurrence outside the allow-list. + + Returns: + Tuples of ``(relative_path, line_number, matched_literal, line_excerpt)``. + """ + root = _project_root() + src = root / "src" / "dppvalidator" + if not src.is_dir(): + # If the layout ever changes, fail loudly instead of silently passing. + raise RuntimeError(f"Expected source tree at {src}") + + out: list[tuple[Path, int, str, str]] = [] + for path in sorted(src.rglob("*.py")): + rel = path.relative_to(root) + if rel in _ALLOWED_FILES: + continue + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + for match in _VERSION_LITERAL_PATTERN.findall(line): + out.append((rel, lineno, match, line.strip())) + return out + + +def test_no_bare_version_literals_in_src() -> None: + """Every UNTP/CIRPASS version literal must live in one of the registries. + + See module docstring for the rationale and the fix. + """ + violations = _violations() + if not violations: + return + + pretty = "\n".join( + f" {rel}:{lineno}: {literal}\n {excerpt[:120]}" + for rel, lineno, literal, excerpt in violations + ) + allowed = "\n".join(f" - {p}" for p in sorted(_ALLOWED_FILES)) + pytest.fail( + f"\n{len(violations)} bare version literal(s) found outside the registry " + f"allow-list.\n\nViolations:\n{pretty}\n\nAllowed files:\n{allowed}\n\n" + "Fix: import DEFAULT_SCHEMA_VERSION from dppvalidator.schemas.registry " + "and reference that constant instead of hardcoding the string. " + "See docs/plans/UNTP_0.7.0_MIGRATION.md §Phase 1.\n", + ) + + +def test_allow_list_files_exist() -> None: + """Every allow-listed path must exist; otherwise the guard is a tautology.""" + root = _project_root() + missing = [p for p in _ALLOWED_FILES if not (root / p).is_file()] + assert not missing, ( + f"Allow-list references non-existent file(s): {missing}. " + "Update _ALLOWED_FILES to match the current source layout." + ) + + +def test_pattern_matches_expected_strings() -> None: + """Self-test: confirm the regex catches the kinds of literal we care about.""" + assert _VERSION_LITERAL_PATTERN.search('default = "0.6.1"') + assert _VERSION_LITERAL_PATTERN.search('schema_version: str = "0.7.0"') + # URL-embedded versions must NOT match — they're inside longer strings. + assert not _VERSION_LITERAL_PATTERN.search('"https://example.com/0.6.1/schema.json"') + # Two-part versions are not matched. + assert not _VERSION_LITERAL_PATTERN.search('python_requires = "3.10"') diff --git a/tests/unit/test_ontology_alignment.py b/tests/unit/test_ontology_alignment.py new file mode 100644 index 0000000..fca5bf4 --- /dev/null +++ b/tests/unit/test_ontology_alignment.py @@ -0,0 +1,367 @@ +"""Tests for EU DPP Core Ontology alignment and SHACL validation.""" + +import pytest + +from dppvalidator.models import CredentialIssuer, DigitalProductPassport +from dppvalidator.validators.shacl import ( + CIRPASS_SHAPES, + SHACLNodeShape, + SHACLPropertyShape, + SHACLSeverity, + SHACLValidationResult, + SHACLValidator, + get_cirpass_shapes, + validate_with_shacl, +) +from dppvalidator.vocabularies.ontology import ( + TERM_MAPPINGS, + CIRPASSNamespace, + EUDPPNamespace, + OntologyMapper, + TermMapping, + compact_cirpass_uri, + compact_eudpp_uri, + expand_cirpass_uri, + expand_eudpp_uri, + get_cirpass_context, + get_eudpp_context, +) + + +class TestEUDPPNamespace: + """Tests for EU DPP Core Ontology namespace definitions.""" + + def test_official_namespace(self): + """Test official EU DPP namespace is correct.""" + assert EUDPPNamespace.EUDPP.value == "http://dpp.taltech.ee/EUDPP#" + + def test_si_namespace(self): + """Test SI Digital Framework namespace.""" + assert EUDPPNamespace.SI.value == "https://si-digital-framework.org/SI#" + + def test_namespace_values(self): + """Test namespace enum values exist.""" + assert EUDPPNamespace.EUDPP.value.startswith("http") + assert EUDPPNamespace.UNTP_DPP.value.startswith("https://") + assert EUDPPNamespace.VC2.value.startswith("https://") + + def test_backward_compatibility_alias(self): + """Test CIRPASSNamespace is alias for EUDPPNamespace.""" + assert CIRPASSNamespace is EUDPPNamespace + + +class TestTermMapping: + """Tests for term mapping between UNTP and CIRPASS.""" + + def test_term_mappings_not_empty(self): + """Test TERM_MAPPINGS is not empty.""" + assert len(TERM_MAPPINGS) > 0 + + def test_term_mapping_structure(self): + """Test TermMapping has required fields.""" + for mapping in TERM_MAPPINGS: + assert isinstance(mapping, TermMapping) + assert mapping.untp_term + assert mapping.cirpass_uri + assert mapping.description + + def test_all_uris_have_eudpp_prefix(self): + """Test all URIs use eudpp: prefix.""" + for mapping in TERM_MAPPINGS: + assert mapping.cirpass_uri.startswith("eudpp:") + + +class TestOntologyMapper: + """Tests for OntologyMapper class.""" + + @pytest.fixture + def mapper(self) -> OntologyMapper: + """Create mapper instance.""" + return OntologyMapper() + + def test_to_cirpass_valid_term(self, mapper: OntologyMapper): + """Test mapping UNTP term to EU DPP URI.""" + result = mapper.to_cirpass("DigitalProductPassport") + assert result == "eudpp:DPP" + + def test_to_cirpass_invalid_term(self, mapper: OntologyMapper): + """Test mapping invalid term returns None.""" + result = mapper.to_cirpass("InvalidTerm") + assert result is None + + def test_to_untp_valid_uri(self, mapper: OntologyMapper): + """Test mapping EU DPP URI to UNTP term.""" + result = mapper.to_untp("eudpp:DPP") + assert result == "DigitalProductPassport" + + def test_to_untp_invalid_uri(self, mapper: OntologyMapper): + """Test mapping invalid URI returns None.""" + result = mapper.to_untp("eudpp:InvalidUri") + assert result is None + + def test_get_mapping_by_untp_term(self, mapper: OntologyMapper): + """Test getting full mapping by UNTP term.""" + mapping = mapper.get_mapping("Product") + assert mapping is not None + assert mapping.untp_term == "Product" + assert mapping.cirpass_uri == "eudpp:Product" + + def test_get_mapping_by_eudpp_uri(self, mapper: OntologyMapper): + """Test getting full mapping by EU DPP URI.""" + mapping = mapper.get_mapping("eudpp:Product") + assert mapping is not None + assert mapping.untp_term == "Product" + + def test_get_espr_reference(self, mapper: OntologyMapper): + """Test getting ESPR reference for term.""" + ref = mapper.get_espr_reference("DigitalProductPassport") + assert ref is not None + assert "ESPR" in ref + + def test_iter_mappings(self, mapper: OntologyMapper): + """Test iterating over all mappings.""" + mappings = list(mapper.iter_mappings()) + assert len(mappings) == len(TERM_MAPPINGS) + + def test_mapped_terms_property(self, mapper: OntologyMapper): + """Test mapped_terms property.""" + terms = mapper.mapped_terms + assert "DigitalProductPassport" in terms + assert "Product" in terms + + def test_mapping_count_property(self, mapper: OntologyMapper): + """Test mapping_count property.""" + assert mapper.mapping_count == len(TERM_MAPPINGS) + + +class TestURIExpansion: + """Tests for URI expansion and compaction.""" + + def test_expand_eudpp_uri(self): + """Test expanding compact EU DPP URI.""" + result = expand_eudpp_uri("eudpp:Product") + assert result == "http://dpp.taltech.ee/EUDPP#Product" + + def test_expand_already_full_uri(self): + """Test expanding already full URI.""" + full = "https://example.com/term" + result = expand_eudpp_uri(full) + assert result == full + + def test_compact_eudpp_uri(self): + """Test compacting full EU DPP URI.""" + full = f"{EUDPPNamespace.EUDPP.value}Product" + result = compact_eudpp_uri(full) + assert result == "eudpp:Product" + + def test_compact_unknown_uri(self): + """Test compacting unknown namespace.""" + unknown = "https://unknown.org/term" + result = compact_eudpp_uri(unknown) + assert result == unknown + + def test_backward_compat_expand(self): + """Test backward compatibility alias for expand.""" + result = expand_cirpass_uri("eudpp:Product") + assert "Product" in result + + def test_backward_compat_compact(self): + """Test backward compatibility alias for compact.""" + full = f"{EUDPPNamespace.EUDPP.value}Product" + result = compact_cirpass_uri(full) + assert result == "eudpp:Product" + + +class TestGetEUDPPContext: + """Tests for get_eudpp_context function.""" + + def test_context_has_eudpp(self): + """Test context includes eudpp namespace.""" + ctx = get_eudpp_context() + assert "eudpp" in ctx + assert ctx["eudpp"] == "http://dpp.taltech.ee/EUDPP#" + + def test_context_has_si(self): + """Test context includes SI namespace.""" + ctx = get_eudpp_context() + assert "si" in ctx + assert ctx["si"] == "https://si-digital-framework.org/SI#" + + def test_context_has_untp(self): + """Test context includes untp namespace.""" + ctx = get_eudpp_context() + assert "untp" in ctx + + def test_context_is_dict(self): + """Test context is a dictionary.""" + ctx = get_eudpp_context() + assert isinstance(ctx, dict) + assert len(ctx) >= 5 + + def test_backward_compat_context(self): + """Test backward compatibility alias.""" + ctx = get_cirpass_context() + assert "eudpp" in ctx + + +class TestSHACLShapes: + """Tests for SHACL shape definitions.""" + + def test_cirpass_shapes_not_empty(self): + """Test CIRPASS_SHAPES is not empty.""" + assert len(CIRPASS_SHAPES) > 0 + + def test_get_cirpass_shapes_function(self): + """Test get_cirpass_shapes returns shapes.""" + shapes = get_cirpass_shapes() + assert shapes == CIRPASS_SHAPES + + def test_shape_structure(self): + """Test SHACLNodeShape has required fields.""" + for shape in CIRPASS_SHAPES: + assert isinstance(shape, SHACLNodeShape) + assert shape.target_class + assert shape.name + assert shape.description + + def test_property_shapes_exist(self): + """Test shapes have property shapes.""" + dpp_shape = CIRPASS_SHAPES[0] + assert len(dpp_shape.properties) > 0 + + def test_property_shape_structure(self): + """Test SHACLPropertyShape has required fields.""" + dpp_shape = CIRPASS_SHAPES[0] + for prop in dpp_shape.properties: + assert isinstance(prop, SHACLPropertyShape) + assert prop.path + assert prop.name + assert prop.description + + +class TestSHACLSeverity: + """Tests for SHACL severity enum.""" + + def test_severity_values(self): + """Test severity enum values.""" + assert SHACLSeverity.VIOLATION.value == "sh:Violation" + assert SHACLSeverity.WARNING.value == "sh:Warning" + assert SHACLSeverity.INFO.value == "sh:Info" + + +class TestSHACLValidator: + """Tests for SHACLValidator class.""" + + @pytest.fixture + def validator(self) -> SHACLValidator: + """Create validator instance.""" + return SHACLValidator() + + def test_shape_count(self, validator: SHACLValidator): + """Test shape_count property.""" + assert validator.shape_count == len(CIRPASS_SHAPES) + + def test_shape_names(self, validator: SHACLValidator): + """Test shape_names property.""" + names = validator.shape_names + assert "CIRPASSDPPShape" in names + assert "CIRPASSProductShape" in names + + def test_get_shape(self, validator: SHACLValidator): + """Test get_shape method.""" + shape = validator.get_shape("CIRPASSDPPShape") + assert shape is not None + assert shape.name == "CIRPASSDPPShape" + + def test_get_shape_not_found(self, validator: SHACLValidator): + """Test get_shape returns None for unknown shape.""" + shape = validator.get_shape("UnknownShape") + assert shape is None + + def test_validate_valid_passport(self, validator: SHACLValidator): + """Test validating a valid passport.""" + from dppvalidator.models import Product, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + validFrom="2024-01-01T00:00:00Z", + credentialSubject=ProductPassport( + granularityLevel="model", + product=Product( + id="https://example.com/product", + name="Test Product", + ), + ), + ) + result = validator.validate_structure(passport) + assert result.conforms is True + assert len(result.violations) == 0 + + def test_validate_missing_valid_from_produces_violation(self, validator: SHACLValidator): + """Test validating passport without validFrom produces violation.""" + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + ) + result = validator.validate_structure(passport) + assert result.conforms is False + assert len(result.violations) >= 1 + assert any("marketPlacementDate" in v["path"] for v in result.violations) + + def test_validate_missing_valid_from(self, validator: SHACLValidator): + """Test validating passport without validFrom produces violation.""" + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + ) + result = validator.validate_structure(passport) + assert result.conforms is False + assert any("marketPlacementDate" in v["path"] for v in result.violations) + + def test_validate_missing_granularity_produces_warning(self, validator: SHACLValidator): + """Test validating passport without granularity produces warning.""" + from dppvalidator.models import ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + validFrom="2024-01-01T00:00:00Z", + credentialSubject=ProductPassport(), + ) + result = validator.validate_structure(passport) + assert len(result.warnings) >= 1 + assert any("granularityLevel" in w["path"] for w in result.warnings) + + +class TestValidateWithSHACL: + """Tests for validate_with_shacl convenience function.""" + + def test_validate_with_shacl_valid(self): + """Test validate_with_shacl with valid passport.""" + from dppvalidator.models import Product, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + validFrom="2024-01-01T00:00:00Z", + credentialSubject=ProductPassport( + granularityLevel="model", + product=Product( + id="https://example.com/product", + name="Test Product", + ), + ), + ) + result = validate_with_shacl(passport) + assert isinstance(result, SHACLValidationResult) + assert result.conforms is True + + def test_validate_with_shacl_missing_valid_from(self): + """Test validate_with_shacl with missing validFrom.""" + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + ) + result = validate_with_shacl(passport) + assert result.conforms is False diff --git a/tests/unit/test_ontology_v07.py b/tests/unit/test_ontology_v07.py new file mode 100644 index 0000000..2092bd8 --- /dev/null +++ b/tests/unit/test_ontology_v07.py @@ -0,0 +1,183 @@ +"""Phase 3c acceptance tests: per-version ``TermMapping`` columns. + +This module covers the ontology half of Phase 3c (see +``docs/plans/UNTP_0.7.0_MIGRATION.md``): + +1. ``TermMapping.term_for(version)`` resolves the correct spelling per + version. Renames (``serialNumber`` → ``itemNumber``, ``producedByParty`` + → ``relatedParty``, ``granularityLevel`` → ``idGranularity``, + ``materialsProvenance`` → ``materialProvenance``, + ``conformityClaim`` → ``performanceClaim``) round-trip. +2. The :data:`TERM_REMOVED` sentinel collapses to ``None`` when consulted + per-version: ``gtin`` is gone in v0.7. +3. :class:`OntologyMapper` exposes the right per-version forward index via + :meth:`mapped_terms_for` and :meth:`find_mapping_for_term`. +4. The default (no-version) API still returns the v0.6 canonical spelling + so pre-Phase-3c callers and the existing test suite are unaffected. +""" + +from __future__ import annotations + +import pytest + +from dppvalidator.vocabularies.ontology import ( + TERM_MAPPINGS, + TERM_REMOVED, + OntologyMapper, + TermMapping, +) + +# --------------------------------------------------------------------------- +# 1. TermMapping.term_for() resolution +# --------------------------------------------------------------------------- + + +class TestTermMappingPerVersion: + """The new ``term_for()`` method resolves to the right spelling.""" + + @pytest.mark.parametrize( + ("v06", "v07"), + [ + ("serialNumber", "itemNumber"), + ("granularityLevel", "idGranularity"), + ("producedByParty", "relatedParty"), + ("materialsProvenance", "materialProvenance"), + ("conformityClaim", "performanceClaim"), + ], + ) + def test_renames_round_trip(self, v06: str, v07: str) -> None: + """Renamed fields resolve to the new spelling under v0.7.""" + mapper = OntologyMapper() + # Forward look-up by the v0.6 canonical key still works. + mapping = mapper.get_mapping(v06) + assert mapping is not None, f"missing mapping for v0.6 term {v06!r}" + assert mapping.term_for("0.6.1") == v06 + assert mapping.term_for("0.6.0") == v06 + assert mapping.term_for("0.7.0") == v07 + + def test_unchanged_fields_resolve_to_canonical(self) -> None: + """Fields with no version-specific rename use ``untp_term`` for both.""" + mapping = next(m for m in TERM_MAPPINGS if m.untp_term == "validFrom") + assert mapping.term_for("0.6.1") == "validFrom" + assert mapping.term_for("0.7.0") == "validFrom" + + def test_unknown_version_falls_back_to_canonical(self) -> None: + """Forward-compat: an unrecognised version returns the canonical spelling.""" + mapping = next(m for m in TERM_MAPPINGS if m.untp_term == "name") + assert mapping.term_for("9.9.9") == "name" + + +class TestTermRemovedSentinel: + """``TERM_REMOVED`` collapses to ``None`` when resolving per-version.""" + + def test_gtin_removed_in_v07(self) -> None: + """``gtin`` exists in v0.6 but is removed in v0.7.""" + mapping = next(m for m in TERM_MAPPINGS if m.untp_term == "gtin") + assert mapping.term_for("0.6.1") == "gtin" + assert mapping.term_for("0.7.0") is None + # Sentinel is exposed for explicit comparison. + assert mapping.untp_v0_7 == TERM_REMOVED + + def test_term_removed_value_is_a_string_marker(self) -> None: + """The sentinel is a string so ``slots=True`` types stay clean.""" + assert isinstance(TERM_REMOVED, str) + assert TERM_REMOVED.startswith("<") and TERM_REMOVED.endswith(">") + + +# --------------------------------------------------------------------------- +# 2. OntologyMapper version-keyed lookup +# --------------------------------------------------------------------------- + + +class TestOntologyMapperVersionDispatch: + """``OntologyMapper`` per-version forward index.""" + + def test_mapped_terms_for_v06_includes_gtin(self) -> None: + mapper = OntologyMapper() + terms = mapper.mapped_terms_for("0.6.1") + assert "gtin" in terms + assert "serialNumber" in terms + + def test_mapped_terms_for_v07_excludes_gtin_renames_serialNumber(self) -> None: + mapper = OntologyMapper() + terms = mapper.mapped_terms_for("0.7.0") + assert "gtin" not in terms, "gtin must be excluded from the v0.7 index" + assert "serialNumber" not in terms, "serialNumber → itemNumber rename" + assert "itemNumber" in terms + assert "materialProvenance" in terms + assert "performanceClaim" in terms + + def test_find_mapping_for_term_with_v07_spelling(self) -> None: + """Looking up ``itemNumber`` (v0.7 spelling) returns the same row as ``serialNumber`` (v0.6).""" + mapper = OntologyMapper() + v06 = mapper.find_mapping_for_term("serialNumber", version="0.6.1") + v07 = mapper.find_mapping_for_term("itemNumber", version="0.7.0") + assert v06 is not None and v07 is not None + assert v06 is v07, "both version-spellings must resolve to the same TermMapping row" + + def test_find_mapping_default_falls_back_to_v07(self) -> None: + """Without an explicit ``version``, v0.7-only spellings still resolve. + + This keeps ``find_mapping_for_term('itemNumber')`` working for callers + who don't yet pass a version argument. + """ + mapper = OntologyMapper() + result = mapper.find_mapping_for_term("itemNumber") + assert result is not None + assert result.cirpass_uri == "eudpp:uniqueProductID" + + def test_to_untp_with_version_returns_correct_spelling(self) -> None: + """``to_untp(uri, version)`` returns the spelling for the given version.""" + mapper = OntologyMapper() + uri = "eudpp:uniqueProductID" + assert mapper.to_untp(uri, version="0.6.1") == "serialNumber" + assert mapper.to_untp(uri, version="0.7.0") == "itemNumber" + + def test_to_untp_with_version_returns_none_for_removed_terms(self) -> None: + """``to_untp`` returns ``None`` when the term is removed in that version.""" + mapper = OntologyMapper() + # ``eudpp:GTIN`` was the v0.6 ``gtin`` field; gone in v0.7. + assert mapper.to_untp("eudpp:GTIN", version="0.6.1") == "gtin" + assert mapper.to_untp("eudpp:GTIN", version="0.7.0") is None + + def test_to_untp_without_version_preserves_legacy_behaviour(self) -> None: + """No-version call returns the canonical (v0.6) spelling.""" + mapper = OntologyMapper() + # Legacy callers (pre-Phase-3c) get the same result they always did. + assert mapper.to_untp("eudpp:uniqueProductID") == "serialNumber" + assert mapper.to_untp("eudpp:GTIN") == "gtin" + + +# --------------------------------------------------------------------------- +# 3. Backward compatibility: pre-Phase-3c API surface +# --------------------------------------------------------------------------- + + +class TestBackwardCompatibility: + """Pre-Phase-3c callers and tests must keep working.""" + + def test_to_cirpass_unchanged(self) -> None: + mapper = OntologyMapper() + assert mapper.to_cirpass("Product") == "eudpp:Product" + assert mapper.to_cirpass("serialNumber") == "eudpp:uniqueProductID" + assert mapper.to_cirpass("nope") is None + + def test_mapping_count_includes_v07_only_rows(self) -> None: + """The mapping count reflects the table size — including new rows added in Phase 3c.""" + mapper = OntologyMapper() + # Phase 3c added 2 new rows (materialsProvenance, conformityClaim). + assert mapper.mapping_count == len(TERM_MAPPINGS) + + def test_mapped_terms_default_returns_canonical_spellings(self) -> None: + mapper = OntologyMapper() + # Default ``mapped_terms`` is keyed on the canonical (v0.6) spelling. + assert "serialNumber" in mapper.mapped_terms + assert "itemNumber" not in mapper.mapped_terms + + def test_termmapping_dataclass_is_frozen_and_slotted(self) -> None: + """The dataclass shape is part of the public contract.""" + from dataclasses import FrozenInstanceError + + m = TermMapping(untp_term="x", cirpass_uri="eudpp:y", description="z") + with pytest.raises(FrozenInstanceError): + m.untp_term = "mutated" # type: ignore[misc] diff --git a/tests/unit/test_phase2_schema_and_jsonld.py b/tests/unit/test_phase2_schema_and_jsonld.py new file mode 100644 index 0000000..f1a07a9 --- /dev/null +++ b/tests/unit/test_phase2_schema_and_jsonld.py @@ -0,0 +1,188 @@ +"""Phase 2 acceptance tests: schema-only and JSON-LD-only validation of 0.7.0. + +This module covers the two exit-criterion checks for Phase 2 of +``docs/plans/UNTP_0.7.0_MIGRATION.md``: + +1. The bundled 0.7.0 JSON Schema validates the upstream canonical 0.7.0 + sample with **zero** schema errors. This proves the in-tree + ``untp-dpp-schema-0.7.0.json`` is byte-equivalent to the upstream and + does not need ``Product.json`` ($ref-resolution at validate time) — the + schema is self-contained. + +2. The bundled JSON-LD context for 0.7.0 is reachable through + ``BUNDLED_CONTEXT_URLS`` so JSON-LD expansion works **offline**. The same + coverage holds retroactively for 0.6.1 (Phase 2 also vendored that + context to remove the prior implicit network dependency). + +Pydantic-model and semantic-rule validation for 0.7.0 are deliberately out +of scope here — they land in Phase 3 / 3b. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from dppvalidator.schemas.registry import SCHEMA_REGISTRY +from dppvalidator.validators.jsonld_semantic import BUNDLED_CONTEXT_URLS, JSONLDValidator +from dppvalidator.validators.schema import SchemaValidator + +# Vendored upstream samples (Phase 0). Skip individually if any path is +# missing so a partial checkout still runs the rest of the suite. +_UPSTREAM_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "upstream" / "v0.7.0" +_CANONICAL_SAMPLE = _UPSTREAM_DIR / "samples" / "DigitalProductPassport_instance.json" +_BATTERY_SAMPLE = _UPSTREAM_DIR / "samples" / "DigitalProductPassport_battery_instance.json" +_CATHODE_SAMPLE = _UPSTREAM_DIR / "samples" / "DigitalProductPassport_cathode_instance.json" + + +def _load(path: Path) -> dict: + if not path.is_file(): # pragma: no cover - vendored in Phase 0 + pytest.skip( + f"Upstream sample missing at {path}; vendor via Phase 0 of " + "docs/plans/UNTP_0.7.0_MIGRATION.md.", + ) + with path.open(encoding="utf-8") as f: + return json.load(f) + + +# ---------------------------------------------------------------------------- +# 1. Schema-only validation (Layer 1) +# ---------------------------------------------------------------------------- + + +class TestSchemaLayerForUntp070: + """Drive the bundled 0.7.0 JSON Schema against the upstream samples.""" + + @pytest.mark.parametrize( + ("label", "path"), + [ + ("canonical", _CANONICAL_SAMPLE), + ("battery", _BATTERY_SAMPLE), + ("cathode", _CATHODE_SAMPLE), + ], + ) + def test_upstream_sample_passes_schema_validation(self, label: str, path: Path) -> None: + """Every vendored upstream 0.7.0 sample validates against the bundled schema.""" + sample = _load(path) + validator = SchemaValidator(schema_version="0.7.0") + result = validator.validate(sample) + assert result.valid is True, ( + f"Sample {label} failed schema validation with {len(result.errors)} " + f"error(s); first: {result.errors[0].message if result.errors else '(none)'}" + ) + assert result.errors == [] + assert result.schema_version == "0.7.0" + + def test_registry_sha_matches_bundled_file(self) -> None: + """The registry SHA pin matches the on-disk bytes.""" + import hashlib + from importlib import resources + + schema = SCHEMA_REGISTRY["0.7.0"] + assert schema.sha256 is not None, "SCHEMA_REGISTRY['0.7.0'].sha256 must be pinned (Phase 2)" + f = resources.files("dppvalidator.schemas.data").joinpath("untp-dpp-schema-0.7.0.json") + actual = hashlib.sha256(f.read_bytes()).hexdigest() + assert actual == schema.sha256, ( + f"Bundled 0.7.0 schema SHA mismatch.\n" + f" expected: {schema.sha256}\n actual: {actual}\n" + "Re-vendor or update the registry pin." + ) + + def test_bundled_schema_has_expected_top_level_required_fields(self) -> None: + """0.7.0 makes ``validFrom``, ``name``, ``credentialSubject`` required.""" + validator = SchemaValidator(schema_version="0.7.0") + schema = validator._load_schema() + assert set(schema.get("required", [])) >= { + "@context", + "id", + "issuer", + "validFrom", + "name", + "credentialSubject", + } + + def test_missing_validFrom_now_fails_schema(self) -> None: + """A 0.7.0 payload missing ``validFrom`` must fail Layer 1.""" + sample = _load(_CANONICAL_SAMPLE) + broken = {k: v for k, v in sample.items() if k != "validFrom"} + validator = SchemaValidator(schema_version="0.7.0") + result = validator.validate(broken) + assert result.valid is False + assert any( + "validFrom" in e.message or e.path.endswith("validFrom") for e in result.errors + ), f"Expected a validFrom-related error; got: {[e.message for e in result.errors]}" + + +# ---------------------------------------------------------------------------- +# 2. JSON-LD layer offline-readiness (Layer 4) +# ---------------------------------------------------------------------------- + + +class TestJsonLdLayerOffline: + """The bundled UNTP contexts must satisfy JSON-LD expansion without network.""" + + def test_bundled_urls_cover_both_versions(self) -> None: + """Both the 0.6.1 and 0.7.0 UNTP context URLs are bundled.""" + assert "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/" in BUNDLED_CONTEXT_URLS + assert "https://vocabulary.uncefact.org/untp/0.7.0/context/" in BUNDLED_CONTEXT_URLS + # Plus the W3C VC v2 context that was already there pre-Phase 2. + assert "https://www.w3.org/ns/credentials/v2" in BUNDLED_CONTEXT_URLS + + def test_070_sample_jsonld_expands_with_no_undefined_terms(self) -> None: + """A real 0.7.0 sample expands cleanly through PyLD using bundled contexts.""" + sample = _load(_CANONICAL_SAMPLE) + validator = JSONLDValidator(schema_version="0.7.0", strict=False) + result = validator.validate(sample) + # JLD001 (missing @context) must NOT fire; JLD002 (undefined term) is + # advisory in non-strict mode but should not produce errors here. + assert result.errors == [], ( + f"JSON-LD expansion produced errors: {[(e.code, e.message) for e in result.errors]}" + ) + + def test_070_sample_jsonld_expands_without_network(self, monkeypatch) -> None: + """Expansion of the canonical 0.7.0 sample must not hit the network. + + We swap PyLD's default document loader with one that raises if it is + invoked, then run JSON-LD validation through the validator's + :class:`CachingDocumentLoader` (which pre-populates the cache from + ``BUNDLED_CONTEXT_URLS``). + """ + from pyld import jsonld + + sentinel = "NETWORK ACCESS UNEXPECTEDLY ATTEMPTED in JSON-LD layer" + + def _trip(url, options=None): # noqa: ARG001 + raise RuntimeError(f"{sentinel}: {url}") + + monkeypatch.setattr(jsonld, "get_document_loader", lambda: _trip) + + sample = _load(_CANONICAL_SAMPLE) + validator = JSONLDValidator(schema_version="0.7.0", strict=False) + result = validator.validate(sample) + # If the trip wired in by monkeypatch fired, the failure would surface + # as a JsonLdError caught and reported as a JLD000-class error. Assert + # we got a clean (non-erroring) run. + assert all(sentinel not in e.message for e in result.errors + result.warnings), ( + "Network was hit despite the bundled context being available." + ) + assert result.errors == [], ( + f"Offline 0.7.0 JSON-LD validation produced unexpected errors: " + f"{[(e.code, e.message) for e in result.errors]}" + ) + + def test_061_legacy_sample_jsonld_expands_without_network(self, monkeypatch) -> None: + """Same offline guarantee retroactively holds for the 0.6.1 fixture.""" + from pyld import jsonld + + def _trip(url, options=None): # noqa: ARG001 + raise RuntimeError(f"NETWORK HIT: {url}") + + monkeypatch.setattr(jsonld, "get_document_loader", lambda: _trip) + + legacy = Path(__file__).resolve().parents[1] / "fixtures" / "valid" / "minimal_dpp.json" + sample = _load(legacy) + validator = JSONLDValidator(schema_version="0.6.1", strict=False) + result = validator.validate(sample) + assert all("NETWORK HIT" not in e.message for e in result.errors + result.warnings) diff --git a/tests/unit/test_plugins.py b/tests/unit/test_plugins.py index c5dd875..587cba6 100644 --- a/tests/unit/test_plugins.py +++ b/tests/unit/test_plugins.py @@ -232,7 +232,7 @@ def check(self, _p): # noqa: ARG002 registry.register_validator("broken", BrokenRule()) errors = registry.run_all_validators(passport) assert len(errors) == 1 - assert errors[0].code == "PLG_ERROR" + assert errors[0].code == "PLG001" assert "Plugin error" in errors[0].message def test_run_all_validators_multiple_rules(self, passport): @@ -400,3 +400,68 @@ def mock_entry_points(group=None): # noqa: ARG001 assert len(result) == 1 assert result[0][0] == "good_plugin" assert result[0][1] is MockPlugin + + +class TestPluginRegistryAutoDiscover: + """Tests for PluginRegistry auto-discovery.""" + + def test_registry_auto_discover_runs_discovery(self, monkeypatch): + """Test registry auto-discovers validators and exporters.""" + from dppvalidator.plugins import registry as registry_module + + validators_discovered = [] + exporters_discovered = [] + + def mock_discover_validators(): + validators_discovered.append(True) + return iter([]) + + def mock_discover_exporters(): + exporters_discovered.append(True) + return iter([]) + + # Patch at the registry module level where they're imported + monkeypatch.setattr(registry_module, "discover_validators", mock_discover_validators) + monkeypatch.setattr(registry_module, "discover_exporters", mock_discover_exporters) + + # Reset and create new registry with auto_discover + reset_default_registry() + _registry = PluginRegistry(auto_discover=True) + + # Verify both discovery methods were called + assert len(validators_discovered) > 0 + assert len(exporters_discovered) > 0 + assert _registry is not None + + +class TestPluginRegistryStrictMode: + """Tests for PluginRegistry strict mode error handling.""" + + def test_run_all_validators_strict_raises_plugin_error(self): + """Test strict mode raises PluginError on validator failure.""" + from dppvalidator.plugins.registry import PluginError + + registry = PluginRegistry(auto_discover=False) + + class FailingValidator: + rule_id = "FAIL001" + description = "Always fails" + severity = "error" + + def check(self, _passport): # noqa: ARG002 + raise RuntimeError("Intentional failure") + + registry.register_validator("failing", FailingValidator()) + + passport = DigitalProductPassport( + id="https://example.com/dpp/test", + issuer=CredentialIssuer( + id="https://example.com/issuer", + name="Test Issuer", + ), + ) + + with pytest.raises(PluginError) as exc_info: + registry.run_all_validators(passport, strict=True) + + assert "failing" in str(exc_info.value).lower() or "fail" in str(exc_info.value).lower() diff --git a/tests/unit/test_production_url.py b/tests/unit/test_production_url.py new file mode 100644 index 0000000..7d78036 --- /dev/null +++ b/tests/unit/test_production_url.py @@ -0,0 +1,162 @@ +"""Cross-check the production-mirror URL split for UNTP DPP schemas. + +Polish step A from the post-Phase-6 review: ``SchemaVersion`` now +carries a ``production_url`` field for the canonical +``untp.unece.org`` hosting alongside the SHA-pinned ``url``. This +module pins the new contract so a future refactor doesn't silently +drop the production link. + +Pins: + +1. ``SchemaVersion.production_url`` is exposed as a frozen-dataclass + attribute and defaults to ``None`` when not set. +2. ``SchemaRegistry.get_production_url(version)`` returns the + registry's recorded production URL or ``None``. +3. The 0.7.0 entry has a non-``None`` production URL pointing at + ``untp.unece.org``. +4. The ``production_url`` recorded in MANIFEST.json (under the + ``untp-dpp-schema@0.7.0`` artefact) matches the registry entry — + the two surfaces must not drift. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from dppvalidator.schemas.registry import ( + SCHEMA_REGISTRY, + SchemaRegistry, + SchemaVersion, +) + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_MANIFEST_PATH = _REPO_ROOT / "src" / "dppvalidator" / "schemas" / "data" / "MANIFEST.json" +_EXPECTED_V07_PRODUCTION_URL = ( + "https://untp.unece.org/artefacts/schema/v0.7.0/dpp/DigitalProductPassport.json" +) + + +# --------------------------------------------------------------------------- +# 1. Dataclass shape +# --------------------------------------------------------------------------- + + +class TestSchemaVersionProductionUrlField: + """``SchemaVersion`` exposes ``production_url`` as a public attribute.""" + + def test_production_url_defaults_to_none(self) -> None: + sv = SchemaVersion( + version="9.9.9", + url="https://example.com/schema.json", + sha256=None, + context_urls=("https://www.w3.org/ns/credentials/v2",), + ) + assert sv.production_url is None + + def test_production_url_can_be_set(self) -> None: + sv = SchemaVersion( + version="9.9.9", + url="https://example.com/schema.json", + sha256=None, + context_urls=("https://www.w3.org/ns/credentials/v2",), + production_url="https://prod.example.com/schema.json", + ) + assert sv.production_url == "https://prod.example.com/schema.json" + + def test_production_url_field_is_frozen(self) -> None: + """The dataclass is frozen — assignment must raise.""" + from dataclasses import FrozenInstanceError + + sv = SchemaVersion( + version="9.9.9", + url="https://example.com/schema.json", + sha256=None, + context_urls=("https://www.w3.org/ns/credentials/v2",), + ) + with pytest.raises(FrozenInstanceError): + sv.production_url = "https://other.example.com/schema.json" # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# 2. Registry accessor +# --------------------------------------------------------------------------- + + +class TestRegistryGetProductionUrl: + """``SchemaRegistry.get_production_url`` exposes the field.""" + + def test_returns_url_for_v07(self) -> None: + registry = SchemaRegistry() + url = registry.get_production_url("0.7.0") + assert url == _EXPECTED_V07_PRODUCTION_URL + + def test_returns_none_for_versions_without_production_url(self) -> None: + # 0.6.0 and 0.6.1 don't currently carry a production URL — the + # accessor should report None rather than raise. + registry = SchemaRegistry() + assert registry.get_production_url("0.6.0") is None + assert registry.get_production_url("0.6.1") is None + + def test_unknown_version_raises(self) -> None: + registry = SchemaRegistry() + with pytest.raises(ValueError, match="Unknown schema version"): + registry.get_production_url("9.9.9") + + +# --------------------------------------------------------------------------- +# 3. v0.7.0 entry in the registry table +# --------------------------------------------------------------------------- + + +class TestV07ProductionUrlContent: + """The 0.7.0 entry's production URL points at the right host.""" + + def test_registry_v07_production_url_matches_expected(self) -> None: + entry = SCHEMA_REGISTRY["0.7.0"] + assert entry.production_url == _EXPECTED_V07_PRODUCTION_URL + + def test_registry_v07_production_url_is_not_the_source_url(self) -> None: + """Production URL is distinct from SHA-pinned source URL. + + The whole point of the field is to record provenance separately + from integrity. Collapsing both into the same string would + defeat the abstraction. + """ + entry = SCHEMA_REGISTRY["0.7.0"] + assert entry.production_url != entry.url + + +# --------------------------------------------------------------------------- +# 4. MANIFEST ↔ registry agreement +# --------------------------------------------------------------------------- + + +class TestManifestRegistryAgreement: + """``production_url`` recorded in MANIFEST matches the registry entry. + + The two surfaces document the same fact ("where does this artefact + live in production?") and must not drift. CI catches drift + immediately rather than letting one fall stale unnoticed. + """ + + def test_v07_dpp_schema_manifest_carries_production_url(self) -> None: + manifest = json.loads(_MANIFEST_PATH.read_text(encoding="utf-8")) + v07_entry = next( + entry + for entry in manifest["artefacts"] + if entry["kind"] == "untp-dpp-schema" and entry["version"] == "0.7.0" + ) + assert v07_entry.get("production_url") == _EXPECTED_V07_PRODUCTION_URL + + def test_v07_dpp_schema_manifest_and_registry_agree(self) -> None: + manifest = json.loads(_MANIFEST_PATH.read_text(encoding="utf-8")) + v07_entry = next( + entry + for entry in manifest["artefacts"] + if entry["kind"] == "untp-dpp-schema" and entry["version"] == "0.7.0" + ) + registry_entry = SCHEMA_REGISTRY["0.7.0"] + assert v07_entry["production_url"] == registry_entry.production_url diff --git a/tests/unit/test_public_api_stability.py b/tests/unit/test_public_api_stability.py new file mode 100644 index 0000000..fa39f5b --- /dev/null +++ b/tests/unit/test_public_api_stability.py @@ -0,0 +1,168 @@ +"""Public-API stability tests. + +Phase 3 of docs/plans/UNTP_0.7.0_MIGRATION.md formalises the +public-import contract in §4.1.8 / §7.6: + +> Within a minor (0.X.0 → 0.X.Y): no class is removed from +> ``dppvalidator.models.__all__``; no parameter on a documented +> constructor is removed or renamed. + +These tests guard the import paths that third-party plugins depend on +(specifically ``examples/dppvalidator_example_plugin/``). They snapshot +the set of public names exported from ``dppvalidator.models`` and +``dppvalidator.models.passport`` and fail the build if any name +disappears within a minor. + +If you legitimately rename or remove a class, update both the snapshot +**and** the tracking issue's CHANGELOG entry under "Removed". +""" + +from __future__ import annotations + +import dppvalidator +from dppvalidator import models as models_pkg +from dppvalidator.models import ( + passport as passport_module, +) + +# Snapshot of the names guaranteed to be importable from +# ``dppvalidator.models`` through 0.4.x. Adding to this set is fine; removing +# from it requires bumping the validator's minor version (see plan §7.6). +EXPECTED_MODELS_EXPORTS = frozenset( + { + # Base & primitives + "UNTPBaseModel", + "UNTPStrictModel", + "Classification", + "FlexibleUri", + "Link", + "Measure", + "SecureLink", + "Characteristics", + "Dimension", + # Enums + "ConformityTopic", + "CriterionStatus", + "EncryptionMethod", + "GranularityLevel", + "HashMethod", + "OperationalScope", + # Identifiers + "Facility", + "IdentifierScheme", + "Party", + # Materials + "Material", + # Product + "Product", + # Claims (v0.6 names, kept through 0.4.x) + "Claim", + "Criterion", + "Metric", + "Regulation", + "Standard", + # Performance scorecards (v0.6 only — gone in v0.7) + "CircularityPerformance", + "EmissionsPerformance", + "TraceabilityPerformance", + # Credential / envelope + "CredentialIssuer", + "CredentialStatus", + "ProductPassport", + "DigitalProductPassport", + } +) + + +class TestModelsPackagePublicAPI: + def test_top_level_dppvalidator_exports(self) -> None: + """Names guaranteed at the package root.""" + for name in ( + "DigitalProductPassport", + "ValidationEngine", + "ValidationError", + "ValidationResult", + "DeepValidator", + "DeepValidationResult", + ): + assert hasattr(dppvalidator, name), ( + f"dppvalidator.{name} disappeared — see plan §7.6 stability contract." + ) + + def test_models_package_keeps_v06_exports(self) -> None: + """Every name in the snapshot must still be importable.""" + actual = set(models_pkg.__all__) + missing = EXPECTED_MODELS_EXPORTS - actual + assert not missing, ( + "\nThe following names were REMOVED from dppvalidator.models.__all__:\n " + + "\n ".join(sorted(missing)) + + "\n\nIf this is intentional, you are breaking the public-API " + "stability contract documented in §7.6 of the migration plan. " + "Either restore the re-export or bump the validator's minor " + "version and update CHANGELOG.md." + ) + + def test_models_package_actually_exposes_each_name(self) -> None: + """``__all__`` and ``hasattr`` must agree.""" + for name in EXPECTED_MODELS_EXPORTS: + assert hasattr(models_pkg, name), ( + f"dppvalidator.models lists {name!r} in __all__ but " + "``hasattr`` says it isn't there. The re-export shim is broken." + ) + + +class TestSubmoduleImportPaths: + """The example plugin imports `from dppvalidator.models.passport import …`. + + That submodule path must keep resolving to the v0.6 class through + the 0.4.x line. Phase 9 will switch it to v0.7; that's a separate + minor release. + """ + + def test_passport_module_re_exports_v0_6_class(self) -> None: + from dppvalidator.models.v0_6 import ( + DigitalProductPassport as V06DigitalProductPassport, + ) + + assert passport_module.DigitalProductPassport is V06DigitalProductPassport, ( + "dppvalidator.models.passport must re-export the v0.6 " + "DigitalProductPassport class through 0.4.x. Phase 9 (validator 0.5.0) " + "switches the default — this test will be updated then." + ) + + def test_v0_6_namespace_is_accessible(self) -> None: + from dppvalidator.models import v0_6 + + assert hasattr(v0_6, "DigitalProductPassport") + assert hasattr(v0_6, "Product") + assert hasattr(v0_6, "Material") + assert hasattr(v0_6, "ProductPassport") # gone in v0.7 + + def test_v0_7_namespace_is_accessible(self) -> None: + from dppvalidator.models import v0_7 + + assert hasattr(v0_7, "DigitalProductPassport") + assert hasattr(v0_7, "Product") + assert hasattr(v0_7, "Material") + # v0.7-only classes + assert hasattr(v0_7, "IssuingSoftware") + assert hasattr(v0_7, "Country") + assert hasattr(v0_7, "PartyRole") + assert hasattr(v0_7, "Performance") + + def test_v0_7_drops_v0_6_only_classes(self) -> None: + """Deliberate non-exports from v0_7.""" + from dppvalidator.models import v0_7 + + assert not hasattr(v0_7, "ProductPassport"), ( + "v0.7 should not have ProductPassport — credentialSubject is Product directly." + ) + assert not hasattr(v0_7, "EmissionsPerformance"), ( + "v0.7 should not have EmissionsPerformance — collapsed into Claim.claimedPerformance." + ) + assert not hasattr(v0_7, "SecureLink"), ( + "v0.7 should not have SecureLink — absorbed into Link." + ) + assert not hasattr(v0_7, "Metric"), ( + "v0.7 should not have Metric — split into Performance.metric/measure/score." + ) diff --git a/tests/unit/test_rdf_loader.py b/tests/unit/test_rdf_loader.py new file mode 100644 index 0000000..a4effc7 --- /dev/null +++ b/tests/unit/test_rdf_loader.py @@ -0,0 +1,610 @@ +"""Tests for RDF loader with optional dependencies (Phase 7).""" + +import pytest + +from dppvalidator.vocabularies.rdf_loader import ( + RDFNotAvailableError, + is_rdf_available, + is_shacl_available, +) + + +class TestRDFAvailabilityChecks: + """Tests for RDF availability checking functions.""" + + def test_is_rdf_available_returns_bool(self): + """Test is_rdf_available returns a boolean.""" + result = is_rdf_available() + assert isinstance(result, bool) + + def test_is_shacl_available_returns_bool(self): + """Test is_shacl_available returns a boolean.""" + result = is_shacl_available() + assert isinstance(result, bool) + + +class TestRDFNotAvailableError: + """Tests for RDFNotAvailableError exception.""" + + def test_error_message_default(self): + """Test default error message.""" + error = RDFNotAvailableError() + assert "RDF functionality" in str(error) + assert "pip install dppvalidator[rdf]" in str(error) + + def test_error_message_custom_feature(self): + """Test custom feature in error message.""" + error = RDFNotAvailableError("SHACL validation") + assert "SHACL validation" in str(error) + assert "pip install dppvalidator[rdf]" in str(error) + + def test_error_is_import_error(self): + """Test error is subclass of ImportError.""" + error = RDFNotAvailableError() + assert isinstance(error, ImportError) + + +class TestRDFLoaderImports: + """Tests for RDF loader imports from package.""" + + def test_import_from_vocabularies_package(self): + """Test importing from vocabularies package.""" + from dppvalidator.vocabularies import ( + RDFNotAvailableError, + is_rdf_available, + is_shacl_available, + ) + + assert RDFNotAvailableError is not None + assert is_rdf_available is not None + assert is_shacl_available is not None + + def test_import_load_functions(self): + """Test importing load functions from submodule.""" + from dppvalidator.vocabularies.rdf_loader import ( + load_bundled_ontology, + load_ontology, + load_ontology_text, + ) + + assert load_ontology is not None + assert load_ontology_text is not None + assert load_bundled_ontology is not None + + def test_import_convenience_functions(self): + """Test importing convenience loading functions from submodule.""" + from dppvalidator.vocabularies.rdf_loader import ( + load_actor_ontology, + load_all_eudpp_ontologies, + load_cirpass_shacl_shapes, + load_eudpp_core_ontology, + load_lca_ontology, + load_soc_ontology, + ) + + assert load_eudpp_core_ontology is not None + assert load_soc_ontology is not None + assert load_lca_ontology is not None + assert load_actor_ontology is not None + assert load_all_eudpp_ontologies is not None + assert load_cirpass_shacl_shapes is not None + + def test_import_query_functions(self): + """Test importing query functions from submodule.""" + from dppvalidator.vocabularies.rdf_loader import ( + get_ontology_classes, + get_ontology_properties, + query_ontology, + ) + + assert query_ontology is not None + assert get_ontology_classes is not None + assert get_ontology_properties is not None + + +@pytest.mark.skipif(not is_rdf_available(), reason="rdflib not installed") +class TestRDFLoaderWithRDFLib: + """Tests that require rdflib to be installed.""" + + def test_load_bundled_ontology(self): + """Test loading a bundled ontology.""" + from dppvalidator.vocabularies.rdf_loader import load_bundled_ontology + + graph = load_bundled_ontology("soc_v1.4.7.ttl") + assert graph is not None + assert len(graph) > 0 + + def test_load_soc_ontology(self): + """Test loading SOC ontology.""" + from dppvalidator.vocabularies.rdf_loader import load_soc_ontology + + graph = load_soc_ontology() + assert graph is not None + assert len(graph) > 0 + + def test_load_lca_ontology(self): + """Test loading LCA ontology.""" + from dppvalidator.vocabularies.rdf_loader import load_lca_ontology + + graph = load_lca_ontology() + assert graph is not None + assert len(graph) > 0 + + def test_load_ontology_text(self): + """Test loading ontology from text.""" + from dppvalidator.vocabularies.rdf_loader import load_ontology_text + + ttl_content = """ + @prefix owl: . + @prefix rdfs: . + + a owl:Class ; + rdfs:label "Test Class" . + """ + + graph = load_ontology_text(ttl_content) + assert graph is not None + assert len(graph) > 0 + + def test_load_cirpass_shacl_shapes(self): + """Test loading CIRPASS SHACL shapes.""" + from dppvalidator.vocabularies.rdf_loader import load_cirpass_shacl_shapes + + graph = load_cirpass_shacl_shapes() + assert graph is not None + assert len(graph) > 0 + + def test_load_all_eudpp_ontologies(self): + """Test loading all EU DPP ontologies.""" + from dppvalidator.vocabularies.rdf_loader import load_all_eudpp_ontologies + + graph = load_all_eudpp_ontologies() + assert graph is not None + # Merged graph should have many triples + assert len(graph) > 100 + + +@pytest.mark.skipif(is_rdf_available(), reason="Test only when rdflib not installed") +class TestRDFLoaderWithoutRDFLib: + """Tests for graceful handling when rdflib is not installed.""" + + def test_load_ontology_raises_error(self): + """Test load_ontology raises RDFNotAvailableError.""" + from pathlib import Path + + from dppvalidator.vocabularies.rdf_loader import load_ontology + + with pytest.raises(RDFNotAvailableError): + load_ontology(Path("/nonexistent.ttl")) + + def test_load_bundled_ontology_raises_error(self): + """Test load_bundled_ontology raises RDFNotAvailableError.""" + from dppvalidator.vocabularies.rdf_loader import load_bundled_ontology + + with pytest.raises(RDFNotAvailableError): + load_bundled_ontology("soc_v1.4.7.ttl") + + +class TestPyprojectOptionalDependencies: + """Tests for pyproject.toml optional dependencies.""" + + def test_rdf_extra_defined(self): + """Test [rdf] extra is defined in pyproject.toml.""" + import sys + from pathlib import Path + + if sys.version_info >= (3, 11): + import tomllib + else: + import tomli as tomllib # noqa: PLC0415 + + pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml" + + with open(pyproject_path, "rb") as f: + pyproject = tomllib.load(f) # noqa: F821 + + optional_deps = pyproject.get("project", {}).get("optional-dependencies", {}) + assert "rdf" in optional_deps + + rdf_deps = optional_deps["rdf"] + assert any("rdflib" in dep for dep in rdf_deps) + assert any("pyshacl" in dep for dep in rdf_deps) + + def test_all_extra_includes_rdf(self): + """Test [all] extra includes [rdf].""" + import sys + from pathlib import Path + + if sys.version_info >= (3, 11): + import tomllib + else: + import tomli as tomllib # noqa: PLC0415 + + pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml" + + with open(pyproject_path, "rb") as f: + pyproject = tomllib.load(f) + + optional_deps = pyproject.get("project", {}).get("optional-dependencies", {}) + assert "all" in optional_deps + + all_deps = optional_deps["all"] + assert any("rdf" in dep for dep in all_deps) + + +@pytest.mark.skipif(not is_rdf_available(), reason="rdflib not installed") +class TestRDFLoaderErrorPaths: + """Tests for error handling paths in RDF loader.""" + + def test_load_ontology_file_not_found(self): + """Test load_ontology raises FileNotFoundError for missing file.""" + from pathlib import Path + + from dppvalidator.vocabularies.rdf_loader import load_ontology + + with pytest.raises(FileNotFoundError) as exc_info: + load_ontology(Path("/nonexistent/path/ontology.ttl")) + + assert "Ontology file not found" in str(exc_info.value) + + def test_load_ontology_parse_error(self): + """Test load_ontology raises RuntimeError for invalid TTL content.""" + import tempfile + from pathlib import Path + + from dppvalidator.vocabularies.rdf_loader import load_ontology + + with tempfile.NamedTemporaryFile(mode="w", suffix=".ttl", delete=False) as f: + f.write("this is not valid turtle syntax @#$%^&*") + temp_path = Path(f.name) + + try: + with pytest.raises(RuntimeError) as exc_info: + load_ontology(temp_path) + assert "Failed to parse ontology" in str(exc_info.value) + finally: + temp_path.unlink() + + def test_load_ontology_text_parse_error(self): + """Test load_ontology_text raises RuntimeError for invalid content.""" + from dppvalidator.vocabularies.rdf_loader import load_ontology_text + + with pytest.raises(RuntimeError) as exc_info: + load_ontology_text("invalid turtle @#$%^&* syntax") + + assert "Failed to parse ontology text" in str(exc_info.value) + + def test_load_bundled_ontology_not_found(self): + """Test load_bundled_ontology raises FileNotFoundError for missing file.""" + from dppvalidator.vocabularies.rdf_loader import load_bundled_ontology + + with pytest.raises(FileNotFoundError) as exc_info: + load_bundled_ontology("nonexistent_ontology.ttl") + + assert "Bundled ontology not found" in str(exc_info.value) + + def test_load_cirpass_shacl_shapes_success(self): + """Test load_cirpass_shacl_shapes loads successfully.""" + from dppvalidator.vocabularies.rdf_loader import load_cirpass_shacl_shapes + + graph = load_cirpass_shacl_shapes() + assert graph is not None + assert len(graph) > 0 + + def test_load_eudpp_core_ontology(self): + """Test load_eudpp_core_ontology loads the product DPP ontology.""" + from dppvalidator.vocabularies.rdf_loader import load_eudpp_core_ontology + + graph = load_eudpp_core_ontology() + assert graph is not None + assert len(graph) > 0 + + def test_load_actor_ontology(self): + """Test load_actor_ontology loads the actors/roles ontology.""" + from dppvalidator.vocabularies.rdf_loader import load_actor_ontology + + graph = load_actor_ontology() + assert graph is not None + assert len(graph) > 0 + + +@pytest.mark.skipif(not is_rdf_available(), reason="rdflib not installed") +class TestRDFQueryFunctions: + """Tests for RDF query functions.""" + + def test_query_ontology_returns_results(self): + """Test query_ontology returns list of dictionaries.""" + from dppvalidator.vocabularies.rdf_loader import ( + load_ontology_text, + query_ontology, + ) + + ttl_content = """ + @prefix owl: . + @prefix rdfs: . + + a owl:Class ; + rdfs:label "Test Class" . + """ + + graph = load_ontology_text(ttl_content) + query = "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 5" + results = query_ontology(graph, query) + + assert isinstance(results, list) + assert len(results) > 0 + assert all(isinstance(r, dict) for r in results) + + def test_query_ontology_empty_results(self): + """Test query_ontology returns empty list for no matches.""" + from dppvalidator.vocabularies.rdf_loader import ( + load_ontology_text, + query_ontology, + ) + + ttl_content = """ + @prefix owl: . + a owl:Class . + """ + + graph = load_ontology_text(ttl_content) + # Query for something that doesn't exist + query = "SELECT ?s WHERE { ?s a }" + results = query_ontology(graph, query) + + assert isinstance(results, list) + assert len(results) == 0 + + def test_get_ontology_classes_returns_classes(self): + """Test get_ontology_classes extracts OWL classes.""" + from dppvalidator.vocabularies.rdf_loader import ( + get_ontology_classes, + load_ontology_text, + ) + + ttl_content = """ + @prefix owl: . + @prefix rdfs: . + + a owl:Class ; + rdfs:label "Class A" . + a owl:Class ; + rdfs:label "Class B" . + """ + + graph = load_ontology_text(ttl_content) + classes = get_ontology_classes(graph) + + assert isinstance(classes, list) + assert len(classes) == 2 + assert "http://example.org/ClassA" in classes + assert "http://example.org/ClassB" in classes + + def test_get_ontology_classes_empty_graph(self): + """Test get_ontology_classes returns empty list for graph without classes.""" + from dppvalidator.vocabularies.rdf_loader import ( + get_ontology_classes, + load_ontology_text, + ) + + ttl_content = """ + @prefix rdfs: . + rdfs:label "Just a resource" . + """ + + graph = load_ontology_text(ttl_content) + classes = get_ontology_classes(graph) + + assert isinstance(classes, list) + assert len(classes) == 0 + + def test_get_ontology_properties_returns_properties(self): + """Test get_ontology_properties extracts OWL properties.""" + from dppvalidator.vocabularies.rdf_loader import ( + get_ontology_properties, + load_ontology_text, + ) + + ttl_content = """ + @prefix owl: . + @prefix rdfs: . + + a owl:ObjectProperty ; + rdfs:label "Object Property" . + a owl:DatatypeProperty ; + rdfs:label "Datatype Property" . + """ + + graph = load_ontology_text(ttl_content) + properties = get_ontology_properties(graph) + + assert isinstance(properties, list) + assert len(properties) == 2 + assert "http://example.org/objectProp" in properties + assert "http://example.org/dataProp" in properties + + def test_get_ontology_properties_empty_graph(self): + """Test get_ontology_properties returns empty list for graph without properties.""" + from dppvalidator.vocabularies.rdf_loader import ( + get_ontology_properties, + load_ontology_text, + ) + + ttl_content = """ + @prefix owl: . + a owl:Class . + """ + + graph = load_ontology_text(ttl_content) + properties = get_ontology_properties(graph) + + assert isinstance(properties, list) + assert len(properties) == 0 + + +@pytest.mark.skipif(not is_rdf_available(), reason="rdflib not installed") +class TestLoadAllOntologies: + """Tests for loading merged ontologies.""" + + def test_load_all_eudpp_ontologies_merges_graphs(self): + """Test load_all_eudpp_ontologies merges multiple ontology files.""" + from dppvalidator.vocabularies.rdf_loader import load_all_eudpp_ontologies + + graph = load_all_eudpp_ontologies() + + assert graph is not None + # Merged graph should have many triples from multiple files + assert len(graph) > 100 + + def test_load_all_eudpp_ontologies_handles_missing_files(self): + """Test load_all_eudpp_ontologies skips missing files with warning.""" + from dppvalidator.vocabularies.rdf_loader import load_all_eudpp_ontologies + + # Should not raise even if some files are missing + graph = load_all_eudpp_ontologies() + assert graph is not None + + +@pytest.mark.skipif(not is_rdf_available(), reason="rdflib not installed") +class TestRDFLoaderWithValidFile: + """Tests for successful ontology loading from file.""" + + def test_load_ontology_from_file(self): + """Test load_ontology successfully loads from a valid file.""" + import tempfile + from pathlib import Path + + from dppvalidator.vocabularies.rdf_loader import load_ontology + + ttl_content = """ + @prefix owl: . + @prefix rdfs: . + + a owl:Ontology ; + rdfs:label "Test Ontology" . + """ + + with tempfile.NamedTemporaryFile(mode="w", suffix=".ttl", delete=False) as f: + f.write(ttl_content) + temp_path = Path(f.name) + + try: + graph = load_ontology(temp_path) + assert graph is not None + assert len(graph) > 0 + finally: + temp_path.unlink() + + def test_load_ontology_with_different_format(self): + """Test load_ontology works with different RDF formats.""" + import tempfile + from pathlib import Path + + from dppvalidator.vocabularies.rdf_loader import load_ontology + + # N-Triples format + nt_content = ' "object" .\n' + + with tempfile.NamedTemporaryFile(mode="w", suffix=".nt", delete=False) as f: + f.write(nt_content) + temp_path = Path(f.name) + + try: + graph = load_ontology(temp_path, format="nt") + assert graph is not None + assert len(graph) == 1 + finally: + temp_path.unlink() + + +class TestRDFAvailabilityCheckInternal: + """Tests for internal RDF availability check function.""" + + def test_check_rdflib_available_function_exists(self): + """Test _check_rdflib_available function is importable.""" + from dppvalidator.vocabularies.rdf_loader import _check_rdflib_available + + assert _check_rdflib_available is not None + + @pytest.mark.skipif(is_rdf_available(), reason="Test only when rdflib not installed") + def test_check_rdflib_available_raises_when_missing(self): + """Test _check_rdflib_available raises RDFNotAvailableError.""" + from dppvalidator.vocabularies.rdf_loader import _check_rdflib_available + + with pytest.raises(RDFNotAvailableError): + _check_rdflib_available() + + @pytest.mark.skipif(not is_rdf_available(), reason="rdflib not installed") + def test_check_rdflib_available_succeeds_when_installed(self): + """Test _check_rdflib_available succeeds when rdflib is installed.""" + from dppvalidator.vocabularies.rdf_loader import _check_rdflib_available + + # Should not raise + _check_rdflib_available() + + +@pytest.mark.skipif(not is_rdf_available(), reason="rdflib not installed") +class TestRDFLoaderSHACLErrorPaths: + """Tests for SHACL loading error paths.""" + + def test_load_cirpass_shacl_shapes_parse_error(self): + """Test load_cirpass_shacl_shapes handles malformed content.""" + import unittest.mock as mock + + from dppvalidator.vocabularies.rdf_loader import load_cirpass_shacl_shapes + + # Mock the file read to return invalid TTL + mock_file = mock.MagicMock() + mock_file.read_text.return_value = "invalid @#$ turtle content" + + mock_data_dir = mock.MagicMock() + mock_data_dir.joinpath.return_value = mock_file + + with mock.patch( + "dppvalidator.vocabularies.rdf_loader._get_schema_data_dir", + return_value=mock_data_dir, + ): + with pytest.raises(RuntimeError) as exc_info: + load_cirpass_shacl_shapes() + + assert "Failed to load CIRPASS SHACL shapes" in str(exc_info.value) + + def test_load_bundled_ontology_runtime_error(self): + """Test load_bundled_ontology wraps parsing errors in RuntimeError.""" + import unittest.mock as mock + + from dppvalidator.vocabularies.rdf_loader import load_bundled_ontology + + # Mock to return invalid content that will fail parsing + mock_file = mock.MagicMock() + mock_file.read_text.return_value = "completely @invalid@ turtle $syntax$" + + mock_data_dir = mock.MagicMock() + mock_data_dir.joinpath.return_value = mock_file + + with mock.patch( + "dppvalidator.vocabularies.rdf_loader._get_ontology_data_dir", + return_value=mock_data_dir, + ): + with pytest.raises(RuntimeError) as exc_info: + load_bundled_ontology("test.ttl") + + assert "Failed to load bundled ontology" in str(exc_info.value) + + +class TestRDFLoaderDataDirectories: + """Tests for data directory accessor functions.""" + + def test_get_ontology_data_dir(self): + """Test _get_ontology_data_dir returns a traversable.""" + from dppvalidator.vocabularies.rdf_loader import _get_ontology_data_dir + + data_dir = _get_ontology_data_dir() + assert data_dir is not None + + def test_get_schema_data_dir(self): + """Test _get_schema_data_dir returns a traversable.""" + from dppvalidator.vocabularies.rdf_loader import _get_schema_data_dir + + data_dir = _get_schema_data_dir() + assert data_dir is not None diff --git a/tests/unit/test_samples_classification.py b/tests/unit/test_samples_classification.py new file mode 100644 index 0000000..3513536 --- /dev/null +++ b/tests/unit/test_samples_classification.py @@ -0,0 +1,111 @@ +"""Phase 5: pinned version detection on the vendored real-world samples. + +The Phase 5 plan calls for re-running ``scripts/fetch_dpp_samples.py`` +and regenerating ``tests/fixtures/samples_report.md`` to confirm the +new detection logic classifies samples correctly. The fetch step is +network-bound; this module is the durable, offline form of the +"verify detection still works" check. + +It walks every sample under ``tests/fixtures/samples/``, asks +:func:`detect_schema_version` what version it thinks the payload is, +and asserts the answer is one registered in ``SCHEMA_REGISTRY``. The +specific classification per sample is also asserted against a pinned +expectation map so a regression in URL-pattern detection trips the +suite immediately. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from dppvalidator.schemas.registry import SCHEMA_REGISTRY +from dppvalidator.validators.detection import detect_schema_version + +_SAMPLES_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "samples" + + +# Pinned classifications for the currently-vendored samples. Adding a +# new sample to ``tests/fixtures/samples/`` requires extending this map +# (or marking the file as not-applicable in +# ``_NOT_DPP_SAMPLES`` below). +_EXPECTED_VERSIONS: dict[str, str] = { + "BatteryPassDataModel_BatteryPass_GeneralProductInformation-payload.json": "0.6.1", + "batterypass_BatteryPassDataModel_CarbonFootprintForBatteries-ld.json": "0.6.1", + "batterypass_BatteryPassDataModel_Circularity-ld.json": "0.6.1", + "batterypass_BatteryPassDataModel_GeneralProductInformation-ld.json": "0.6.1", + "batterypass_BatteryPassDataModel_MaterialComposition-ld.json": "0.6.1", + "eclipse-tractusx_sldt-semantic-models_BatteryPass.json": "0.6.1", + "nfc-forum_org_long-dpp-example.json": "0.6.1", + "opensource_unicc_org_untp-digital-facility-record-v0.3.9.json": "0.6.1", + "opensource_unicc_org_untp-digital-product-passport-v0.3.10.json": "0.6.1", + "schemas_testing_breathable-t-shirt.json": "0.6.1", + "test_uncefact_org_DigitalIdentityAnchor-instance-0.6.1.json": "0.6.1", + "test_uncefact_org_untp-dpp-instance-0.6.0.json": "0.6.0", + "untp-verifiable-credentials_s3_amazonaws_com_bc075c5f-2304-4b3f-bb24-46d9fa9a8e60.json": "0.6.0", +} + + +def _sample_files() -> list[Path]: + """All sample files currently vendored for the detection test.""" + return sorted(_SAMPLES_DIR.glob("*.json")) + + +@pytest.mark.parametrize( + "sample_path", + _sample_files(), + ids=lambda p: p.name, +) +def test_sample_detects_to_known_version(sample_path: Path) -> None: + """Every sample must classify to a version present in the registry. + + A regression here means either: + - The detection regex no longer matches a URL it used to. + - A sample file's contents drifted (in which case re-pull it). + """ + data = json.loads(sample_path.read_text(encoding="utf-8")) + assert isinstance(data, dict), f"{sample_path.name} is not a JSON object" + detected = detect_schema_version(data) + assert detected in SCHEMA_REGISTRY, ( + f"{sample_path.name} detected as {detected!r}, which is not in " + f"SCHEMA_REGISTRY ({list(SCHEMA_REGISTRY)!r})." + ) + + +@pytest.mark.parametrize( + ("sample_path", "expected"), + [(_SAMPLES_DIR / name, version) for name, version in _EXPECTED_VERSIONS.items()], + ids=lambda val: val.name if isinstance(val, Path) else str(val), +) +def test_sample_pinned_classification(sample_path: Path, expected: str) -> None: + """Each sample's detected version matches the pinned expectation.""" + if not sample_path.is_file(): + pytest.skip(f"sample no longer present: {sample_path.name}") + data = json.loads(sample_path.read_text(encoding="utf-8")) + assert isinstance(data, dict) + detected = detect_schema_version(data) + assert detected == expected, ( + f"{sample_path.name}: expected {expected!r}, got {detected!r}. " + "Either the sample contents drifted or the detection regex changed; " + "update the pinned expectation if this is intentional." + ) + + +def test_every_sample_has_pinned_expectation() -> None: + """Drift catch: adding a sample without updating the pinned map fails. + + Without this check, a new sample could land with whatever + classification the detection happens to give it, masking a real + regression on existing samples. + """ + on_disk = {p.name for p in _sample_files()} + pinned = set(_EXPECTED_VERSIONS.keys()) + new_samples = on_disk - pinned + assert not new_samples, ( + f"Vendored sample(s) without pinned classification: " + f"{sorted(new_samples)}.\n" + "Add them to _EXPECTED_VERSIONS in this file with the version " + "they should detect to." + ) diff --git a/tests/unit/test_schema_dual_mode.py b/tests/unit/test_schema_dual_mode.py new file mode 100644 index 0000000..f26a39e --- /dev/null +++ b/tests/unit/test_schema_dual_mode.py @@ -0,0 +1,247 @@ +"""Tests for dual-mode schema validation (Phase 6).""" + +import pytest + +from dppvalidator.validators.schema import SchemaType, SchemaValidator + + +class TestSchemaTypeEdgeCases: + """Tests for schema_type validation edge cases.""" + + def test_invalid_schema_type_raises_value_error(self): + """Test invalid schema_type raises ValueError.""" + with pytest.raises(ValueError, match="Invalid schema_type"): + SchemaValidator(schema_type="invalid") + + def test_none_schema_type_raises_error(self): + """Test None schema_type raises error.""" + with pytest.raises((ValueError, TypeError)): + SchemaValidator(schema_type=None) + + def test_empty_string_schema_type_raises_error(self): + """Test empty string schema_type raises ValueError.""" + with pytest.raises(ValueError, match="Invalid schema_type"): + SchemaValidator(schema_type="") + + def test_case_sensitive_schema_type(self): + """Test schema_type is case-sensitive.""" + with pytest.raises(ValueError, match="Invalid schema_type"): + SchemaValidator(schema_type="UNTP") + + with pytest.raises(ValueError, match="Invalid schema_type"): + SchemaValidator(schema_type="Cirpass") + + +class TestSchemaType: + """Tests for SchemaType literal.""" + + def test_schema_type_untp(self): + """Test UNTP schema type.""" + schema_type: SchemaType = "untp" + assert schema_type == "untp" + + def test_schema_type_cirpass(self): + """Test CIRPASS schema type.""" + schema_type: SchemaType = "cirpass" + assert schema_type == "cirpass" + + +class TestSchemaValidatorDefaults: + """Tests for SchemaValidator default behavior.""" + + def test_default_schema_type_is_untp(self): + """Test default schema type is UNTP.""" + validator = SchemaValidator() + assert validator.schema_type == "untp" + + def test_default_schema_version(self): + """Test default schema version.""" + validator = SchemaValidator() + assert validator.schema_version == "0.6.1" + + def test_explicit_untp_mode(self): + """Test explicit UNTP mode.""" + validator = SchemaValidator(schema_type="untp") + assert validator.schema_type == "untp" + + +class TestCIRPASSMode: + """Tests for CIRPASS schema mode.""" + + def test_cirpass_mode_initialization(self): + """Test CIRPASS mode initialization.""" + validator = SchemaValidator(schema_type="cirpass") + assert validator.schema_type == "cirpass" + + def test_cirpass_mode_with_version(self): + """Test CIRPASS mode with version.""" + validator = SchemaValidator( + schema_type="cirpass", + schema_version="1.3.0", + ) + assert validator.schema_type == "cirpass" + assert validator.schema_version == "1.3.0" + + def test_cirpass_schema_loads(self): + """Test CIRPASS schema loads correctly.""" + validator = SchemaValidator(schema_type="cirpass") + schema = validator._load_schema() + + assert schema is not None + assert isinstance(schema, dict) + assert "properties" in schema + + def test_cirpass_schema_has_dpp_properties(self): + """Test CIRPASS schema has DPP properties.""" + validator = SchemaValidator(schema_type="cirpass") + schema = validator._load_schema() + + properties = schema.get("properties", {}) + assert "uniqueDPPID" in properties + assert "appliesToProduct" in properties + + +class TestUNTPMode: + """Tests for UNTP schema mode.""" + + def test_untp_mode_initialization(self): + """Test UNTP mode initialization.""" + validator = SchemaValidator(schema_type="untp") + assert validator.schema_type == "untp" + + def test_untp_schema_loads(self): + """Test UNTP schema loads (or returns empty if not bundled).""" + validator = SchemaValidator(schema_type="untp") + schema = validator._load_schema() + + # Schema may be empty if not bundled, but should be a dict + assert isinstance(schema, dict) + + +class TestDualModeValidation: + """Tests for dual-mode validation behavior.""" + + def test_cirpass_validation_valid_data(self): + """Test CIRPASS validation with valid data structure.""" + validator = SchemaValidator(schema_type="cirpass") + + # Minimal valid CIRPASS DPP data + data = { + "uniqueDPPID": ["urn:uuid:12345678-1234-1234-1234-123456789012"], + } + + result = validator.validate(data) + # Should complete without exception + assert result is not None + + def test_cirpass_validation_invalid_data(self): + """Test CIRPASS validation detects invalid data.""" + validator = SchemaValidator(schema_type="cirpass") + + # Invalid data - uniqueDPPID should be array + data = { + "uniqueDPPID": "not-an-array", + "unknownProperty": "should-fail-with-strict", + } + + result = validator.validate(data) + # Should have errors for type mismatch and additional properties + assert not result.valid or len(result.errors) > 0 or len(result.warnings) > 0 + + def test_strict_mode_with_cirpass(self): + """Test strict mode works with CIRPASS schema.""" + validator = SchemaValidator(schema_type="cirpass", strict=True) + schema = validator._load_schema() + + # In strict mode, additionalProperties should be false + # (already false in CIRPASS schema) + assert schema.get("additionalProperties") is False + + +class TestSchemaLoading: + """Tests for schema loading methods.""" + + def test_load_cirpass_schema_method(self): + """Test _load_cirpass_schema method.""" + validator = SchemaValidator(schema_type="cirpass") + schema = validator._load_cirpass_schema() + + assert isinstance(schema, dict) + assert "title" in schema + assert "CIRPASS" in schema.get("title", "") + + def test_load_untp_schema_method(self): + """Test _load_untp_schema method.""" + validator = SchemaValidator(schema_type="untp") + schema = validator._load_untp_schema() + + # May be empty if no bundled schema + assert isinstance(schema, dict) + + def test_schema_caching(self): + """Test schema is cached after first load.""" + validator = SchemaValidator(schema_type="cirpass") + + schema1 = validator._load_schema() + schema2 = validator._load_schema() + + assert schema1 is schema2 + + +class TestValidatorImports: + """Tests for validator imports from package.""" + + def test_import_schema_type_from_validators(self): + """Test importing SchemaType from validators package.""" + from dppvalidator.validators import SchemaType, SchemaValidator + + assert SchemaType is not None + assert SchemaValidator is not None + + def test_import_schema_type_from_schema_module(self): + """Test importing SchemaType from schema module.""" + + # Verify it's a type alias + validator = SchemaValidator(schema_type="cirpass") + assert validator.schema_type == "cirpass" + + +class TestBackwardsCompatibility: + """Tests for backwards compatibility.""" + + def test_existing_usage_unchanged(self): + """Test existing usage pattern still works.""" + # This is how users currently create validators + validator = SchemaValidator(schema_version="0.6.1") + + assert validator.schema_type == "untp" + assert validator.schema_version == "0.6.1" + + def test_schema_path_override_still_works(self): + """Test custom schema path still works.""" + import json + import tempfile + from pathlib import Path + + # Create a temporary schema file + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump({"type": "object", "properties": {}}, f) + temp_path = Path(f.name) + + try: + validator = SchemaValidator( + schema_type="cirpass", + schema_path=temp_path, + ) + schema = validator._load_schema() + + # Should use custom path, not bundled schema + assert schema == {"type": "object", "properties": {}} + finally: + temp_path.unlink() + + def test_strict_mode_still_works(self): + """Test strict mode still works.""" + validator = SchemaValidator(strict=True) + + assert validator.strict is True diff --git a/tests/unit/test_schema_validator.py b/tests/unit/test_schema_validator.py index 88cb2c1..779ae82 100644 --- a/tests/unit/test_schema_validator.py +++ b/tests/unit/test_schema_validator.py @@ -88,7 +88,7 @@ class TestSchemaValidatorMoreCoverage: def test_schema_validator_custom_path(self, tmp_path): """Test SchemaValidator with custom schema path.""" schema_file = tmp_path / "schema.json" - schema_file.write_text('{"type": "object"}') + schema_file.write_text('{"type": "object"}', encoding="utf-8") validator = SchemaValidator(schema_path=schema_file) result = validator.validate({"id": "test"}) @@ -97,7 +97,7 @@ def test_schema_validator_custom_path(self, tmp_path): def test_schema_validator_load_schema_cached(self, tmp_path): """Test that schema is cached after first load.""" schema_file = tmp_path / "schema.json" - schema_file.write_text('{"type": "object"}') + schema_file.write_text('{"type": "object"}', encoding="utf-8") validator = SchemaValidator(schema_path=schema_file) schema1 = validator._load_schema() @@ -127,7 +127,7 @@ def test_schema_validator_with_valid_data(self, tmp_path): "required": ["name"], } schema_file = tmp_path / "schema.json" - schema_file.write_text(json.dumps(schema)) + schema_file.write_text(json.dumps(schema), encoding="utf-8") validator = SchemaValidator(schema_path=schema_file) result = validator.validate({"name": "test"}) @@ -142,7 +142,7 @@ def test_schema_validator_with_invalid_data(self, tmp_path): "required": ["name"], } schema_file = tmp_path / "schema.json" - schema_file.write_text(json.dumps(schema)) + schema_file.write_text(json.dumps(schema), encoding="utf-8") validator = SchemaValidator(schema_path=schema_file) result = validator.validate({}) @@ -161,7 +161,7 @@ def test_schema_validator_iter_errors(self, tmp_path): "required": ["name", "age"], } schema_file = tmp_path / "schema.json" - schema_file.write_text(json.dumps(schema)) + schema_file.write_text(json.dumps(schema), encoding="utf-8") validator = SchemaValidator(schema_path=schema_file) result = validator.validate({"name": 123, "age": "not a number"}) @@ -172,7 +172,7 @@ def test_schema_validator_error_code_format(self, tmp_path): """Test error codes are properly formatted with stable codes.""" schema = {"type": "object", "required": ["a", "b", "c"]} schema_file = tmp_path / "schema.json" - schema_file.write_text(json.dumps(schema)) + schema_file.write_text(json.dumps(schema), encoding="utf-8") validator = SchemaValidator(schema_path=schema_file) result = validator.validate({}) @@ -191,7 +191,7 @@ def test_schema_validator_validation_time(self, tmp_path): """Test validation time is recorded.""" schema = {"type": "object"} schema_file = tmp_path / "schema.json" - schema_file.write_text(json.dumps(schema)) + schema_file.write_text(json.dumps(schema), encoding="utf-8") validator = SchemaValidator(schema_path=schema_file) result = validator.validate({}) @@ -205,7 +205,7 @@ def test_strict_mode_rejects_additional_properties(self, tmp_path): "additionalProperties": True, } schema_file = tmp_path / "schema.json" - schema_file.write_text(json.dumps(schema)) + schema_file.write_text(json.dumps(schema), encoding="utf-8") validator_normal = SchemaValidator(schema_path=schema_file, strict=False) result_normal = validator_normal.validate({"name": "test", "extra": "field"}) @@ -223,7 +223,7 @@ def test_stable_error_codes_for_required(self, tmp_path): """Test that 'required' violations always produce SCH001.""" schema = {"type": "object", "required": ["field_a", "field_b"]} schema_file = tmp_path / "schema.json" - schema_file.write_text(json.dumps(schema)) + schema_file.write_text(json.dumps(schema), encoding="utf-8") validator = SchemaValidator(schema_path=schema_file) result = validator.validate({}) @@ -236,7 +236,7 @@ def test_stable_error_codes_for_type(self, tmp_path): """Test that 'type' violations always produce SCH002.""" schema = {"type": "object", "properties": {"count": {"type": "integer"}}} schema_file = tmp_path / "schema.json" - schema_file.write_text(json.dumps(schema)) + schema_file.write_text(json.dumps(schema), encoding="utf-8") validator = SchemaValidator(schema_path=schema_file) result = validator.validate({"count": "not a number"}) @@ -268,31 +268,6 @@ def test_schema_error_code_mapping_completeness(self): assert SCHEMA_ERROR_CODES[validator].startswith("SCH") -class TestSchemaValidatorWithoutJsonschema: - """Tests for SchemaValidator when jsonschema is not available.""" - - def test_validate_without_jsonschema_returns_warning(self, monkeypatch): - """Test validation returns warning when jsonschema not installed.""" - from dppvalidator.validators import schema as schema_module - - monkeypatch.setattr(schema_module, "HAS_JSONSCHEMA", False) - - validator = SchemaValidator() - result = validator.validate({"test": "data"}) - assert result.valid is True - assert len(result.warnings) == 1 - assert "jsonschema not installed" in result.warnings[0].message - - def test_get_validator_without_jsonschema(self, monkeypatch): - """Test _get_validator returns None when jsonschema not installed.""" - from dppvalidator.validators import schema as schema_module - - monkeypatch.setattr(schema_module, "HAS_JSONSCHEMA", False) - - validator = SchemaValidator() - assert validator._get_validator() is None - - class TestSchemaValidatorWithJsonSchema: """Tests for SchemaValidator with jsonschema library.""" diff --git a/tests/unit/test_schemas.py b/tests/unit/test_schemas.py index 4db91a3..ac8650e 100644 --- a/tests/unit/test_schemas.py +++ b/tests/unit/test_schemas.py @@ -206,32 +206,6 @@ def test_cache_to_disk(self, tmp_path): assert cache_file.exists() assert cache_file.read_bytes() == content - def test_download_schema_no_httpx(self, tmp_path, monkeypatch): - """Test download_schema without httpx.""" - import dppvalidator.schemas.loader as loader_module - - monkeypatch.setattr(loader_module, "HAS_HTTPX", False) - - loader = SchemaLoader(cache_dir=tmp_path) - with pytest.raises(RuntimeError, match="httpx required"): - loader.download_schema("0.6.1", tmp_path) - - def test_fetch_remote_no_httpx(self, tmp_path, monkeypatch): - """Test _fetch_remote without httpx.""" - import dppvalidator.schemas.loader as loader_module - - monkeypatch.setattr(loader_module, "HAS_HTTPX", False) - - loader = SchemaLoader(cache_dir=tmp_path) - schema_def = SchemaVersion( - version="0.6.1", - url="https://example.com/schema.json", - sha256=None, - context_urls=(), - ) - result = loader._fetch_remote(schema_def) - assert result is None - def test_load_local_integrity_failure(self, tmp_path, monkeypatch): """Test _load_local with integrity check failure.""" from dppvalidator.schemas import loader as loader_module @@ -285,9 +259,24 @@ def test_load_schema_raises_on_failure(self, tmp_path, monkeypatch): """Test load_schema raises RuntimeError when all methods fail.""" from dppvalidator.schemas import loader as loader_module - monkeypatch.setattr(loader_module, "HAS_HTTPX", False) + # Mock to simulate failure: no local files, no cache, network error monkeypatch.setattr(loader_module, "_get_schema_data_dir", lambda: tmp_path / "nonexistent") + class MockClient: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def get(self, url, **kwargs): # noqa: ARG002 + raise ConnectionError("Network error") + + monkeypatch.setattr(loader_module.httpx, "Client", MockClient) + loader = SchemaLoader(cache_dir=tmp_path / "also_nonexistent") with pytest.raises(RuntimeError, match="Failed to load schema"): @@ -297,7 +286,6 @@ def test_load_schema_from_cached_after_local_fails(self, tmp_path, monkeypatch): """Test load_schema tries cache when local fails.""" from dppvalidator.schemas import loader as loader_module - monkeypatch.setattr(loader_module, "HAS_HTTPX", False) monkeypatch.setattr(loader_module, "_get_schema_data_dir", lambda: tmp_path / "nonexistent") loader = SchemaLoader(cache_dir=tmp_path) @@ -369,8 +357,8 @@ def __exit__(self, *args): def get(self, url, follow_redirects=True): # noqa: ARG002 return MockResponse() - monkeypatch.setattr(loader_module, "HAS_HTTPX", True) - monkeypatch.setattr(loader_module, "httpx", type("httpx", (), {"Client": MockClient})) + # httpx is now a core dependency + monkeypatch.setattr(loader_module.httpx, "Client", MockClient) loader = SchemaLoader(cache_dir=tmp_path) schema_def = SchemaVersion( @@ -412,8 +400,8 @@ def __exit__(self, *args): def get(self, url, follow_redirects=True): # noqa: ARG002 return MockResponse() - monkeypatch.setattr(loader_module, "HAS_HTTPX", True) - monkeypatch.setattr(loader_module, "httpx", type("httpx", (), {"Client": MockClient})) + # httpx is now a core dependency + monkeypatch.setattr(loader_module.httpx, "Client", MockClient) loader = SchemaLoader(cache_dir=tmp_path) schema_def = SchemaVersion( @@ -449,8 +437,8 @@ def __exit__(self, *args): def get(self, url, follow_redirects=True): # noqa: ARG002 return MockResponse() - monkeypatch.setattr(loader_module, "HAS_HTTPX", True) - monkeypatch.setattr(loader_module, "httpx", type("httpx", (), {"Client": MockClient})) + # httpx is now a core dependency + monkeypatch.setattr(loader_module.httpx, "Client", MockClient) loader = SchemaLoader(cache_dir=tmp_path) result = loader.download_schema("0.6.1", tmp_path) @@ -474,8 +462,8 @@ def __exit__(self, *args): def get(self, url, follow_redirects=True): # noqa: ARG002 raise ConnectionError("Network error") - monkeypatch.setattr(loader_module, "HAS_HTTPX", True) - monkeypatch.setattr(loader_module, "httpx", type("httpx", (), {"Client": MockClient})) + # httpx is now a core dependency + monkeypatch.setattr(loader_module.httpx, "Client", MockClient) loader = SchemaLoader(cache_dir=tmp_path) with pytest.raises(RuntimeError, match="Failed to download"): diff --git a/tests/unit/test_semantic_rules.py b/tests/unit/test_semantic_rules.py index 7f472ea..8a15ce3 100644 --- a/tests/unit/test_semantic_rules.py +++ b/tests/unit/test_semantic_rules.py @@ -150,10 +150,10 @@ def test_circularity_content_violation(self): passport = DigitalProductPassport( id="https://example.com/dpp", issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - credentialSubject=ProductPassport( - circularityScorecard=CircularityPerformance( - recycledContent=0.8, - recyclableContent=0.5, + credential_subject=ProductPassport( + circularity_scorecard=CircularityPerformance( + recycled_content=0.8, + recyclable_content=0.5, ) ), ) @@ -169,7 +169,7 @@ def test_conformity_claim_info(self): passport = DigitalProductPassport( id="https://example.com/dpp", issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - credentialSubject=ProductPassport(), + credential_subject=ProductPassport(), ) rule = ConformityClaimRule() violations = rule.check(passport) @@ -183,8 +183,8 @@ def test_granularity_serial_number_warning(self): passport = DigitalProductPassport( id="https://example.com/dpp", issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - credentialSubject=ProductPassport( - granularityLevel=GranularityLevel.ITEM, + credential_subject=ProductPassport( + granularity_level=GranularityLevel.ITEM, product=Product(id="https://example.com/product", name="Test Product"), ), ) @@ -200,12 +200,12 @@ def test_operational_scope_warning(self): passport = DigitalProductPassport( id="https://example.com/dpp", issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - credentialSubject=ProductPassport( - emissionsScorecard=EmissionsPerformance( - carbonFootprint=25.5, - declaredUnit="KGM", - operationalScope=OperationalScope.NONE, - primarySourcedRatio=0.8, + credential_subject=ProductPassport( + emissions_scorecard=EmissionsPerformance( + carbon_footprint=25.5, + declared_unit="KGM", + operational_scope=OperationalScope.NONE, + primary_sourced_ratio=0.8, ) ), ) @@ -224,10 +224,10 @@ def test_mass_fraction_rule_with_valid_sum(self): passport = DigitalProductPassport( id="https://example.com/dpp", issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - credentialSubject=ProductPassport( - materialsProvenance=[ - Material(name="A", massFraction=0.5), - Material(name="B", massFraction=0.5), + credential_subject=ProductPassport( + materials_provenance=[ + Material(name="A", mass_fraction=0.5), + Material(name="B", mass_fraction=0.5), ] ), ) @@ -242,8 +242,8 @@ def test_mass_fraction_rule_no_fractions(self): passport = DigitalProductPassport( id="https://example.com/dpp", issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - credentialSubject=ProductPassport( - materialsProvenance=[ + credential_subject=ProductPassport( + materials_provenance=[ Material(name="A"), Material(name="B"), ] @@ -260,12 +260,12 @@ def test_hazardous_material_rule_with_violation(self): passport = DigitalProductPassport( id="https://example.com/dpp", issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - credentialSubject=ProductPassport( - materialsProvenance=[ + credential_subject=ProductPassport( + materials_provenance=[ Material( name="Safe Chemical", hazardous=True, - materialSafetyInformation=Link(linkURL="https://example.com/msds"), + material_safety_information=Link(link_url="https://example.com/msds"), ), ] ), @@ -303,13 +303,19 @@ def test_mass_fraction_sum_partial_declaration_produces_warning(self): "https://www.w3.org/ns/credentials/v2", "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", ], + "type": ["DigitalProductPassport", "VerifiableCredential"], "id": "https://example.com/dpp", "issuer": {"id": "https://example.com/issuer", "name": "Test"}, + "validFrom": "2024-01-01T00:00:00Z", + "validUntil": "2034-01-01T00:00:00Z", "credentialSubject": { + "id": "https://example.com/subject/001", + "type": ["ProductPassport"], + "product": {"id": "https://example.com/products/001", "name": "Test"}, "materialsProvenance": [ {"name": "Steel", "massFraction": 0.3}, {"name": "Plastic", "massFraction": 0.2}, - ] + ], }, } result = engine.validate(data) @@ -354,10 +360,10 @@ def test_circularity_content_rule_produces_violation(self): passport = DigitalProductPassport( id="https://example.com/dpp", issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - credentialSubject=ProductPassport( - circularityScorecard=CircularityPerformance( - recycledContent=0.9, - recyclableContent=0.5, + credential_subject=ProductPassport( + circularity_scorecard=CircularityPerformance( + recycled_content=0.9, + recyclable_content=0.5, ) ), ) @@ -375,7 +381,7 @@ def test_conformity_claim_rule_produces_info(self): passport = DigitalProductPassport( id="https://example.com/dpp", issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - credentialSubject=ProductPassport(), + credential_subject=ProductPassport(), ) rule = ConformityClaimRule() violations = rule.check(passport) @@ -389,8 +395,8 @@ def test_granularity_serial_number_rule_produces_warning(self): passport = DigitalProductPassport( id="https://example.com/dpp", issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), - credentialSubject=ProductPassport( - granularityLevel=GranularityLevel.ITEM, + credential_subject=ProductPassport( + granularity_level=GranularityLevel.ITEM, product=Product( id="https://example.com/product", name="Test Product", diff --git a/tests/unit/test_semantic_rules_v07.py b/tests/unit/test_semantic_rules_v07.py new file mode 100644 index 0000000..643dd5f --- /dev/null +++ b/tests/unit/test_semantic_rules_v07.py @@ -0,0 +1,221 @@ +"""Phase 3b acceptance tests: v0.7 semantic rules + version-keyed dispatch. + +This module covers the Phase 3b deliverables in +``docs/plans/UNTP_0.7.0_MIGRATION.md``: + +1. The :data:`ALL_RULES_BY_VERSION` dispatch is in lock-step with + :data:`SCHEMA_REGISTRY` — every registered version has a rule list. +2. The :class:`SemanticValidator` selects the right rule list when + ``rules`` is left at the default ``None``. +3. Each ported v0.7 rule fires on the new envelope shape (no false + positives from v0.6-shaped checks) — see the per-class smoke tests. +4. The dropped rule (``OperationalScopeRule`` / SEM007) is documented + in :data:`DROPPED_RULES_V0_6_TO_V0_7` but is **not** in + :data:`ALL_RULES_V0_7`. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +from dppvalidator.models import v0_7 +from dppvalidator.schemas.registry import SCHEMA_REGISTRY +from dppvalidator.validators.rules import ALL_RULES_BY_VERSION +from dppvalidator.validators.rules.v0_7 import ( + ALL_RULES_V0_7, + DROPPED_RULES_V0_6_TO_V0_7, + CircularityContentRule, + CIRPASSMandatoryAttributesRule, + ConformityClaimRule, + GranularitySerialNumberRule, + HazardousMaterialRule, + MassFractionSumRule, + ValidityDateRule, +) +from dppvalidator.validators.semantic import SemanticValidator + +_UPSTREAM_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "upstream" / "v0.7.0" + + +def _load_canonical() -> dict: + p = _UPSTREAM_DIR / "samples" / "DigitalProductPassport_instance.json" + if not p.is_file(): # pragma: no cover — vendored in Phase 0 + pytest.skip(f"Upstream sample missing: {p}") + with p.open(encoding="utf-8") as f: + return json.load(f) + + +@pytest.fixture +def canonical_v07() -> v0_7.DigitalProductPassport: + return v0_7.DigitalProductPassport.model_validate(_load_canonical()) + + +# --------------------------------------------------------------------------- +# 1. Dispatch-table consistency +# --------------------------------------------------------------------------- + + +class TestRuleDispatchConsistency: + """``ALL_RULES_BY_VERSION`` must cover every key in ``SCHEMA_REGISTRY``.""" + + def test_every_registered_version_has_a_rule_set(self) -> None: + missing = sorted(set(SCHEMA_REGISTRY) - set(ALL_RULES_BY_VERSION)) + assert not missing, ( + f"ALL_RULES_BY_VERSION is missing entries for {missing}. " + "Add them in src/dppvalidator/validators/rules/__init__.py." + ) + + def test_no_orphan_rule_sets(self) -> None: + extra = sorted(set(ALL_RULES_BY_VERSION) - set(SCHEMA_REGISTRY)) + assert not extra, f"ALL_RULES_BY_VERSION has entries {extra} not in SCHEMA_REGISTRY." + + def test_v07_drops_operational_scope_rule(self) -> None: + """SEM007 is the canonical dropped rule — must be advertised in DROPPED_RULES.""" + assert "SEM007" in DROPPED_RULES_V0_6_TO_V0_7 + # And it must NOT appear in the v0.7 rule list. + rule_ids = {getattr(r, "rule_id", None) for r in ALL_RULES_V0_7} + assert "SEM007" not in rule_ids + + +# --------------------------------------------------------------------------- +# 2. SemanticValidator dispatch +# --------------------------------------------------------------------------- + + +class TestSemanticValidatorDispatch: + """The validator picks the right rule list when ``rules`` is None.""" + + def test_v06_uses_v06_rules(self) -> None: + v = SemanticValidator(schema_version="0.6.1") + ids = {getattr(r, "rule_id", None) for r in v.rules} + assert "SEM007" in ids, ( + "Expected the v0.6 rule set to include OperationalScopeRule (SEM007)." + ) + + def test_v07_uses_v07_rules(self) -> None: + v = SemanticValidator(schema_version="0.7.0") + ids = {getattr(r, "rule_id", None) for r in v.rules} + assert "SEM007" not in ids, ( + "v0.7 rule set must NOT include OperationalScopeRule — see DROPPED_RULES_V0_6_TO_V0_7." + ) + # Sanity: it should still include the ported rules. + assert {"SEM001", "SEM003", "CQ001"}.issubset(ids) + + def test_unknown_version_falls_back_to_default(self) -> None: + v = SemanticValidator(schema_version="9.9.9") + # Should fall back to ALL_RULES (v0.6.x default). + assert len(v.rules) > 0 + + def test_explicit_rules_override(self) -> None: + custom = [MassFractionSumRule()] + v = SemanticValidator(schema_version="0.7.0", rules=custom) + assert v.rules is custom + + +# --------------------------------------------------------------------------- +# 3. v0.7 rule semantics (no false positives, fires when expected) +# --------------------------------------------------------------------------- + + +class TestV07RulesNoFalsePositives: + """Ported rules walking the v0.7 shape produce zero violations on the + canonical sample (which is well-formed).""" + + @pytest.mark.parametrize( + "rule_cls", + [ + # NOTE: ``MassFractionSumRule`` is intentionally NOT in this set — + # the canonical 0.7.0 sample carries a partial-declaration material + # provenance (one material at 30 %), so SEM001 *should* fire as a + # warning. That's a "rule fires correctly", not a "false positive". + # See ``test_mass_fraction_rule_fires_on_partial_declaration`` below. + ValidityDateRule, + HazardousMaterialRule, + CircularityContentRule, + ConformityClaimRule, + GranularitySerialNumberRule, + CIRPASSMandatoryAttributesRule, + ], + ) + def test_rule_clean_on_canonical_sample( + self, + canonical_v07: v0_7.DigitalProductPassport, + rule_cls: type, + ) -> None: + rule = rule_cls() + violations = rule.check(canonical_v07) + assert violations == [], ( + f"{rule_cls.__name__} produced {len(violations)} violation(s) on " + f"a canonical 0.7.0 sample: {violations}" + ) + + def test_mass_fraction_rule_fires_on_partial_declaration( + self, canonical_v07: v0_7.DigitalProductPassport + ) -> None: + """SEM001 fires (as a warning) when material provenance sums to < 1.0. + + The canonical 0.7.0 sample declares one material at mass fraction + 0.3 — that's a documented partial declaration and SEM001 must + flag it so the user knows the material list is incomplete. + """ + rule = MassFractionSumRule() + violations = rule.check(canonical_v07) + assert len(violations) == 1 + path, message = violations[0] + assert path == "$.credentialSubject.materialProvenance" + assert "partial declaration" in message + # Severity is "warning" — not an error. + assert rule.severity == "warning" + + +class TestV07RulesFire: + """The ported rules still fire on the conditions they're meant to flag.""" + + def _envelope(self, **overrides): + sample = _load_canonical() + sample.update(overrides) + return v0_7.DigitalProductPassport.model_validate(sample) + + def test_validity_date_rule_fires_on_inverted_dates(self) -> None: + # Constructing the model directly bypasses the model-level invariant, + # but we can simulate the rule's view by building a stub object. + class _Stub: + valid_from = datetime(2030, 1, 1, tzinfo=timezone.utc) + valid_until = datetime(2025, 1, 1, tzinfo=timezone.utc) + + rule = ValidityDateRule() + violations = rule.check(_Stub()) + assert any("validFrom" in msg for _, msg in violations) + + def test_hazardous_material_rule_fires_when_no_safety_info(self) -> None: + # Build a stand-alone Material so we don't trip the model-level + # invariant — the rule walks the dotted path via getattr. + class _Mat: + name = "Lithium" + hazardous = True + material_safety_information = None + + class _Product: + material_provenance = [_Mat()] + + class _DPP: + credential_subject = _Product() + + rule = HazardousMaterialRule() + violations = rule.check(_DPP()) + assert violations and "Lithium" in violations[0][1] + + def test_conformity_claim_rule_warns_when_no_claims(self) -> None: + class _Product: + performance_claim = [] + + class _DPP: + credential_subject = _Product() + + rule = ConformityClaimRule() + violations = rule.check(_DPP()) + assert violations and "performance" in violations[0][1].lower() diff --git a/tests/unit/test_shacl_official.py b/tests/unit/test_shacl_official.py new file mode 100644 index 0000000..a1dd45a --- /dev/null +++ b/tests/unit/test_shacl_official.py @@ -0,0 +1,819 @@ +"""Tests for official SHACL validation (Phase 8).""" + +import pytest + +from dppvalidator.validators.shacl import ( + OfficialSHACLLoader, + RDFSHACLValidator, + SHACLValidationResult, + is_shacl_validation_available, +) + + +class TestOfficialSHACLLoader: + """Tests for OfficialSHACLLoader class.""" + + def test_create_loader(self): + """Test creating a SHACL loader.""" + loader = OfficialSHACLLoader() + assert loader.SHACL_FILE == "cirpass_dpp_shacl.ttl" + assert loader._shapes_graph is None + assert loader._shapes_text is None + + def test_is_available_returns_bool(self): + """Test is_available returns a boolean.""" + loader = OfficialSHACLLoader() + result = loader.is_available() + assert isinstance(result, bool) + + def test_load_shapes_text(self): + """Test loading SHACL shapes as text.""" + loader = OfficialSHACLLoader() + shapes_text = loader.load_shapes_text() + + assert isinstance(shapes_text, str) + assert len(shapes_text) > 0 + # SHACL files contain these prefixes + assert "@prefix" in shapes_text or "PREFIX" in shapes_text + + def test_load_shapes_text_cached(self): + """Test SHACL text is cached after first load.""" + loader = OfficialSHACLLoader() + text1 = loader.load_shapes_text() + text2 = loader.load_shapes_text() + + assert text1 is text2 + + def test_clear_cache(self): + """Test clearing the cache.""" + loader = OfficialSHACLLoader() + loader.load_shapes_text() + assert loader._shapes_text is not None + + loader.clear_cache() + assert loader._shapes_text is None + assert loader._shapes_graph is None + + +class TestRDFSHACLValidator: + """Tests for RDFSHACLValidator class.""" + + def test_create_validator_default(self): + """Test creating validator with default settings.""" + validator = RDFSHACLValidator() + assert validator._use_official is True + + def test_create_validator_placeholder(self): + """Test creating validator with placeholder shapes.""" + validator = RDFSHACLValidator(use_official_shapes=False) + assert validator._use_official is False + + def test_is_available_returns_bool(self): + """Test is_available returns a boolean.""" + validator = RDFSHACLValidator() + result = validator.is_available() + assert isinstance(result, bool) + + +class TestSHACLAvailabilityFunction: + """Tests for is_shacl_validation_available function.""" + + def test_returns_bool(self): + """Test function returns a boolean.""" + result = is_shacl_validation_available() + assert isinstance(result, bool) + + +class TestSHACLImports: + """Tests for SHACL imports from package.""" + + def test_import_from_validators_package(self): + """Test importing from validators package.""" + from dppvalidator.validators import ( + OfficialSHACLLoader, + RDFSHACLValidator, + is_shacl_validation_available, + load_official_shacl_shapes, + validate_jsonld_with_official_shacl, + ) + + assert OfficialSHACLLoader is not None + assert RDFSHACLValidator is not None + assert is_shacl_validation_available is not None + assert load_official_shacl_shapes is not None + assert validate_jsonld_with_official_shacl is not None + + +@pytest.mark.skipif( + not is_shacl_validation_available(), + reason="rdflib/pyshacl not installed", +) +class TestOfficialSHACLWithRDFLib: + """Tests that require rdflib and pyshacl to be installed.""" + + def test_load_shapes_graph(self): + """Test loading SHACL shapes as RDF graph.""" + loader = OfficialSHACLLoader() + graph = loader.load_shapes_graph() + + assert graph is not None + assert len(graph) > 0 + + def test_load_shapes_graph_cached(self): + """Test shapes graph is cached after first load.""" + loader = OfficialSHACLLoader() + graph1 = loader.load_shapes_graph() + graph2 = loader.load_shapes_graph() + + assert graph1 is graph2 + + def test_rdf_validator_load_shapes(self): + """Test RDF validator loads shapes.""" + validator = RDFSHACLValidator(use_official_shapes=True) + shapes = validator.load_shapes() + + assert shapes is not None + assert len(shapes) > 0 + + def test_load_official_shacl_shapes_function(self): + """Test load_official_shacl_shapes convenience function.""" + from dppvalidator.validators.shacl import load_official_shacl_shapes + + graph = load_official_shacl_shapes() + assert graph is not None + assert len(graph) > 0 + + +@pytest.mark.skipif( + is_shacl_validation_available(), + reason="Test only when rdflib/pyshacl not installed", +) +class TestSHACLWithoutRDFLib: + """Tests for graceful handling when rdflib/pyshacl not installed.""" + + def test_load_shapes_graph_raises_error(self): + """Test load_shapes_graph raises ImportError.""" + loader = OfficialSHACLLoader() + + with pytest.raises(ImportError) as exc_info: + loader.load_shapes_graph() + + assert "pip install dppvalidator[rdf]" in str(exc_info.value) + + def test_rdf_validator_load_shapes_raises_error(self): + """Test RDF validator raises ImportError.""" + validator = RDFSHACLValidator() + + with pytest.raises(ImportError) as exc_info: + validator.load_shapes() + + assert "pip install dppvalidator[rdf]" in str(exc_info.value) + + +class TestSHACLValidationResult: + """Tests for SHACLValidationResult dataclass.""" + + def test_create_result_conforms(self): + """Test creating conforming result.""" + result = SHACLValidationResult(conforms=True) + + assert result.conforms is True + assert result.violations == [] + assert result.warnings == [] + assert result.info == [] + + def test_create_result_violations(self): + """Test creating result with violations.""" + result = SHACLValidationResult( + conforms=False, + violations=[{"path": "test", "message": "error"}], + ) + + assert result.conforms is False + assert len(result.violations) == 1 + + def test_create_result_warnings(self): + """Test creating result with warnings.""" + result = SHACLValidationResult( + conforms=True, + warnings=[{"path": "test", "message": "warning"}], + ) + + assert result.conforms is True + assert len(result.warnings) == 1 + + +class TestSHACLShapesContent: + """Tests for SHACL shapes file content.""" + + def test_shapes_file_contains_shacl_prefixes(self): + """Test shapes file contains SHACL prefixes.""" + loader = OfficialSHACLLoader() + content = loader.load_shapes_text() + + # SHACL shapes should reference sh: prefix + assert "sh:" in content or "shacl" in content.lower() + + def test_shapes_file_contains_node_shapes(self): + """Test shapes file contains NodeShape definitions.""" + loader = OfficialSHACLLoader() + content = loader.load_shapes_text() + + # Should contain NodeShape or PropertyShape + assert "NodeShape" in content or "PropertyShape" in content + + def test_shapes_file_references_dpp_properties(self): + """Test shapes file references DPP properties.""" + loader = OfficialSHACLLoader() + content = loader.load_shapes_text() + + # Should reference DPP-related terms + # Check for common terms that would appear in DPP SHACL + has_dpp_terms = any( + term in content for term in ["DPP", "Product", "uniqueDPPID", "Operator", "Actor"] + ) + assert has_dpp_terms, "SHACL shapes should reference DPP terms" + + +class TestSHACLValidatorStructure: + """Tests for SHACLValidator structural validation.""" + + def test_validator_default_shapes(self): + """Test validator uses CIRPASS shapes by default.""" + from dppvalidator.validators.shacl import CIRPASS_SHAPES, SHACLValidator + + validator = SHACLValidator() + assert validator._shapes == CIRPASS_SHAPES + + def test_validator_custom_shapes(self): + """Test validator accepts custom shapes.""" + from dppvalidator.validators.shacl import SHACLNodeShape, SHACLValidator + + custom_shapes = ( + SHACLNodeShape( + target_class="custom:Class", + name="CustomShape", + description="Custom shape", + ), + ) + validator = SHACLValidator(shapes=custom_shapes) + assert validator._shapes == custom_shapes + + def test_validator_shape_count(self): + """Test shape_count property returns correct count.""" + from dppvalidator.validators.shacl import SHACLValidator + + validator = SHACLValidator() + assert validator.shape_count == 3 # DPP, Product, Material shapes + + def test_validator_shape_names(self): + """Test shape_names property returns shape names.""" + from dppvalidator.validators.shacl import SHACLValidator + + validator = SHACLValidator() + names = validator.shape_names + + assert "CIRPASSDPPShape" in names + assert "CIRPASSProductShape" in names + assert "CIRPASSMaterialShape" in names + + def test_validator_get_shape_found(self): + """Test get_shape returns shape by name.""" + from dppvalidator.validators.shacl import SHACLValidator + + validator = SHACLValidator() + shape = validator.get_shape("CIRPASSDPPShape") + + assert shape is not None + assert shape.name == "CIRPASSDPPShape" + + def test_validator_get_shape_not_found(self): + """Test get_shape returns None for unknown shape.""" + from dppvalidator.validators.shacl import SHACLValidator + + validator = SHACLValidator() + shape = validator.get_shape("NonexistentShape") + + assert shape is None + + +class TestSHACLValidatorConstraints: + """Tests for SHACL constraint validation on DPP structure.""" + + def test_validate_structure_missing_issuer(self): + """Test validation detects missing economic operator.""" + from unittest.mock import MagicMock + + from dppvalidator.validators.shacl import SHACLValidator + + passport = MagicMock() + passport.issuer = None + passport.valid_from = "2024-01-01" + passport.credential_subject = None + + validator = SHACLValidator() + result = validator.validate_structure(passport) + + assert result.conforms is False + assert any("economicOperator" in v["path"] for v in result.violations) + + def test_validate_structure_missing_valid_from(self): + """Test validation detects missing market placement date.""" + from unittest.mock import MagicMock + + from dppvalidator.validators.shacl import SHACLValidator + + passport = MagicMock() + passport.issuer = MagicMock(id="did:web:example.com") + passport.valid_from = None + passport.credential_subject = None + + validator = SHACLValidator() + result = validator.validate_structure(passport) + + assert result.conforms is False + assert any("marketPlacementDate" in v["path"] for v in result.violations) + + def test_validate_structure_missing_granularity_warning(self): + """Test validation warns about missing granularity level.""" + from unittest.mock import MagicMock + + from dppvalidator.validators.shacl import SHACLValidator + + passport = MagicMock() + passport.issuer = MagicMock(id="did:web:example.com") + passport.valid_from = "2024-01-01" + passport.credential_subject = MagicMock( + granularity_level=None, + product=None, + materials_provenance=None, + ) + + validator = SHACLValidator() + result = validator.validate_structure(passport) + + assert result.conforms is True # Only warning, not violation + assert any("granularityLevel" in w["path"] for w in result.warnings) + + def test_validate_structure_missing_product_name(self): + """Test validation detects missing product name.""" + from unittest.mock import MagicMock + + from dppvalidator.validators.shacl import SHACLValidator + + product = MagicMock() + product.name = None + + passport = MagicMock() + passport.issuer = MagicMock(id="did:web:example.com") + passport.valid_from = "2024-01-01" + passport.credential_subject = MagicMock( + granularity_level="batch", + product=product, + materials_provenance=None, + ) + + validator = SHACLValidator() + result = validator.validate_structure(passport) + + assert result.conforms is False + assert any("productName" in v["path"] for v in result.violations) + + def test_validate_structure_missing_material_name(self): + """Test validation detects missing material name.""" + from unittest.mock import MagicMock + + from dppvalidator.validators.shacl import SHACLValidator + + material = MagicMock() + material.name = None + material.mass_fraction = 0.5 + + passport = MagicMock() + passport.issuer = MagicMock(id="did:web:example.com") + passport.valid_from = "2024-01-01" + passport.credential_subject = MagicMock( + granularity_level="batch", + product=None, + materials_provenance=[material], + ) + + validator = SHACLValidator() + result = validator.validate_structure(passport) + + assert result.conforms is False + assert any("materialName" in v["path"] for v in result.violations) + + def test_validate_structure_missing_mass_fraction_warning(self): + """Test validation warns about missing mass percentage.""" + from unittest.mock import MagicMock + + from dppvalidator.validators.shacl import SHACLValidator + + material = MagicMock() + material.name = "Steel" + material.mass_fraction = None + + passport = MagicMock() + passport.issuer = MagicMock(id="did:web:example.com") + passport.valid_from = "2024-01-01" + passport.credential_subject = MagicMock( + granularity_level="batch", + product=None, + materials_provenance=[material], + ) + + validator = SHACLValidator() + result = validator.validate_structure(passport) + + assert result.conforms is True # Only warning, not violation + assert any("massPercentage" in w["path"] for w in result.warnings) + + def test_validate_structure_multiple_materials(self): + """Test validation checks all materials in list.""" + from unittest.mock import MagicMock + + from dppvalidator.validators.shacl import SHACLValidator + + # MagicMock has special handling for 'name', so we configure it explicitly + material1 = MagicMock() + material1.name = "Steel" + material1.mass_fraction = 0.6 + + material2 = MagicMock() + material2.name = None # Missing name - should cause violation + material2.mass_fraction = None + + material3 = MagicMock() + material3.name = "Plastic" + material3.mass_fraction = None # Missing mass - should cause warning + + passport = MagicMock() + passport.issuer = MagicMock(id="did:web:example.com") + passport.valid_from = "2024-01-01" + passport.credential_subject = MagicMock( + granularity_level="batch", + product=None, + materials_provenance=[material1, material2, material3], + ) + + validator = SHACLValidator() + result = validator.validate_structure(passport) + + assert result.conforms is False + # Should have violation for material2 missing name + assert any("materialName" in v["path"] and "[1]" in v["path"] for v in result.violations) + # Should have warnings for missing mass fractions + assert len(result.warnings) >= 1 + + +class TestSHACLConvenienceFunctions: + """Tests for SHACL convenience functions.""" + + def test_get_cirpass_shapes_returns_tuple(self): + """Test get_cirpass_shapes returns shapes tuple.""" + from dppvalidator.validators.shacl import CIRPASS_SHAPES, get_cirpass_shapes + + shapes = get_cirpass_shapes() + assert shapes == CIRPASS_SHAPES + assert len(shapes) == 3 + + def test_validate_with_shacl_creates_validator(self): + """Test validate_with_shacl convenience function.""" + from unittest.mock import MagicMock + + from dppvalidator.validators.shacl import validate_with_shacl + + passport = MagicMock() + passport.issuer = MagicMock(id="did:web:example.com") + passport.valid_from = "2024-01-01" + passport.credential_subject = MagicMock( + granularity_level="item", + product=None, + materials_provenance=None, + ) + + result = validate_with_shacl(passport) + + assert result.conforms is True + + +@pytest.mark.skipif( + not is_shacl_validation_available(), + reason="rdflib/pyshacl not installed", +) +class TestRDFSHACLValidatorAdvanced: + """Advanced tests for RDFSHACLValidator with pyshacl.""" + + def test_rdf_validator_placeholder_shapes(self): + """Test RDF validator loads placeholder shapes when requested.""" + validator = RDFSHACLValidator(use_official_shapes=False) + shapes = validator.load_shapes() + + assert shapes is not None + # Placeholder returns empty graph + assert len(shapes) == 0 + + def test_rdf_validator_validate_graph_conforming(self): + """Test validate_graph with conforming data.""" + from rdflib import RDF, RDFS, Graph, Namespace + + validator = RDFSHACLValidator(use_official_shapes=True) + + # Create a minimal conforming data graph + data_graph = Graph() + EX = Namespace("http://example.org/") + data_graph.add((EX.product, RDF.type, RDFS.Resource)) + + result = validator.validate_graph(data_graph) + + # Should return a SHACLValidationResult + assert isinstance(result, SHACLValidationResult) + + def test_rdf_validator_validate_jsonld(self): + """Test validate_jsonld with JSON-LD data.""" + validator = RDFSHACLValidator(use_official_shapes=True) + + jsonld_data = { + "@context": {"ex": "http://example.org/"}, + "@id": "ex:product1", + "@type": "ex:Product", + } + + result = validator.validate_jsonld(jsonld_data) + + assert isinstance(result, SHACLValidationResult) + + def test_validate_jsonld_with_official_shacl_function(self): + """Test validate_jsonld_with_official_shacl convenience function.""" + from dppvalidator.validators.shacl import validate_jsonld_with_official_shacl + + jsonld_data = { + "@context": {"ex": "http://example.org/"}, + "@id": "ex:product1", + "@type": "ex:Product", + } + + result = validate_jsonld_with_official_shacl(jsonld_data) + + assert isinstance(result, SHACLValidationResult) + + +class TestSHACLDataclasses: + """Tests for SHACL dataclass definitions.""" + + def test_shacl_property_shape_creation(self): + """Test SHACLPropertyShape dataclass creation.""" + from dppvalidator.validators.shacl import SHACLPropertyShape, SHACLSeverity + + shape = SHACLPropertyShape( + path="ex:property", + name="TestProperty", + description="Test property shape", + min_count=1, + max_count=5, + datatype="xsd:string", + pattern="^[A-Z]+$", + node_kind="sh:IRI", + severity=SHACLSeverity.WARNING, + ) + + assert shape.path == "ex:property" + assert shape.min_count == 1 + assert shape.max_count == 5 + assert shape.severity == SHACLSeverity.WARNING + + def test_shacl_node_shape_creation(self): + """Test SHACLNodeShape dataclass creation.""" + from dppvalidator.validators.shacl import ( + SHACLNodeShape, + SHACLPropertyShape, + ) + + prop = SHACLPropertyShape( + path="ex:prop", + name="Prop", + description="Property", + ) + shape = SHACLNodeShape( + target_class="ex:Class", + name="TestShape", + description="Test node shape", + properties=(prop,), + ) + + assert shape.target_class == "ex:Class" + assert len(shape.properties) == 1 + + def test_shacl_severity_enum_values(self): + """Test SHACLSeverity enum has correct values.""" + from dppvalidator.validators.shacl import SHACLSeverity + + assert SHACLSeverity.VIOLATION.value == "sh:Violation" + assert SHACLSeverity.WARNING.value == "sh:Warning" + assert SHACLSeverity.INFO.value == "sh:Info" + + def test_shacl_constraint_type_enum_values(self): + """Test SHACLConstraintType enum has correct values.""" + from dppvalidator.validators.shacl import SHACLConstraintType + + assert SHACLConstraintType.MIN_COUNT.value == "sh:minCount" + assert SHACLConstraintType.MAX_COUNT.value == "sh:maxCount" + assert SHACLConstraintType.DATATYPE.value == "sh:datatype" + assert SHACLConstraintType.PATTERN.value == "sh:pattern" + + +class TestOfficialSHACLLoaderEdgeCases: + """Tests for OfficialSHACLLoader edge cases.""" + + def test_loader_file_not_found_error(self): + """Test loader raises FileNotFoundError for missing file.""" + import unittest.mock as mock + + loader = OfficialSHACLLoader() + + # Mock the files function to raise FileNotFoundError + with ( + mock.patch( + "dppvalidator.validators.shacl.files", + side_effect=FileNotFoundError("Mock error"), + ), + pytest.raises(FileNotFoundError), + ): + loader.load_shapes_text() + + def test_loader_caches_text_on_reload(self): + """Test loader caches text and returns same instance.""" + loader = OfficialSHACLLoader() + + text1 = loader.load_shapes_text() + text2 = loader.load_shapes_text() + + assert text1 is text2 + + def test_loader_clear_cache_resets_state(self): + """Test clear_cache resets internal state.""" + loader = OfficialSHACLLoader() + + # Load to populate cache + loader.load_shapes_text() + assert loader._shapes_text is not None + + # Clear cache + loader.clear_cache() + assert loader._shapes_text is None + assert loader._shapes_graph is None + + +@pytest.mark.skipif( + not is_shacl_validation_available(), + reason="rdflib/pyshacl not installed", +) +class TestRDFSHACLValidationResultParsing: + """Tests for SHACL validation result parsing.""" + + def test_validate_graph_non_conforming_data(self): + """Test validate_graph with non-conforming data.""" + from rdflib import RDF, RDFS, Graph, Literal, Namespace + + validator = RDFSHACLValidator(use_official_shapes=True) + + # Create data that might trigger SHACL violations + data_graph = Graph() + EX = Namespace("http://example.org/") + EUDPP = Namespace("http://data.europa.eu/2024/dpp/") + + # Add a DPP without required properties + data_graph.add((EX.dpp1, RDF.type, EUDPP.DPP)) + data_graph.add((EX.dpp1, RDFS.label, Literal("Test DPP"))) + + result = validator.validate_graph(data_graph) + + # Result should be returned regardless of conformance + assert isinstance(result, SHACLValidationResult) + + def test_validate_graph_with_violations(self): + """Test result parsing captures violations and warnings.""" + from rdflib import RDF, Graph, Namespace + + validator = RDFSHACLValidator(use_official_shapes=True) + + # Create minimal data + data_graph = Graph() + EX = Namespace("http://example.org/") + data_graph.add((EX.item, RDF.type, EX.Thing)) + + result = validator.validate_graph(data_graph) + + # Should have parsed results into appropriate lists + assert isinstance(result.violations, list) + assert isinstance(result.warnings, list) + assert isinstance(result.info, list) + + def test_parse_validation_results_extracts_severity(self): + """Test _parse_validation_results extracts severity correctly.""" + from rdflib import Graph, Literal, Namespace, URIRef + + validator = RDFSHACLValidator() + + # Create a mock results graph with SHACL result structure + SH = Namespace("http://www.w3.org/ns/shacl#") + results_graph = Graph() + + report = URIRef("http://example.org/report") + validation_result = URIRef("http://example.org/result1") + + results_graph.add((report, SH.conforms, Literal(False))) + results_graph.add((report, SH.result, validation_result)) + results_graph.add((validation_result, SH.resultSeverity, SH.Violation)) + results_graph.add((validation_result, SH.resultMessage, Literal("Test error"))) + results_graph.add((validation_result, SH.resultPath, URIRef("http://example.org/path"))) + results_graph.add((validation_result, SH.focusNode, URIRef("http://example.org/node"))) + + result = SHACLValidationResult(conforms=False) + parsed = validator._parse_validation_results(results_graph, result) + + # Should extract violation info + assert len(parsed.violations) > 0 + assert parsed.violations[0]["message"] == "Test error" + + def test_parse_validation_results_warning_severity(self): + """Test _parse_validation_results handles warning severity.""" + from rdflib import Graph, Literal, Namespace, URIRef + + validator = RDFSHACLValidator() + + SH = Namespace("http://www.w3.org/ns/shacl#") + results_graph = Graph() + + report = URIRef("http://example.org/report") + validation_result = URIRef("http://example.org/result1") + + results_graph.add((report, SH.conforms, Literal(True))) + results_graph.add((report, SH.result, validation_result)) + results_graph.add((validation_result, SH.resultSeverity, SH.Warning)) + results_graph.add((validation_result, SH.resultMessage, Literal("Test warning"))) + + result = SHACLValidationResult(conforms=True) + parsed = validator._parse_validation_results(results_graph, result) + + # Should extract warning info + assert len(parsed.warnings) > 0 + + def test_parse_validation_results_info_severity(self): + """Test _parse_validation_results handles info severity.""" + from rdflib import Graph, Literal, Namespace, URIRef + + validator = RDFSHACLValidator() + + SH = Namespace("http://www.w3.org/ns/shacl#") + results_graph = Graph() + + report = URIRef("http://example.org/report") + validation_result = URIRef("http://example.org/result1") + + results_graph.add((report, SH.conforms, Literal(True))) + results_graph.add((report, SH.result, validation_result)) + results_graph.add((validation_result, SH.resultSeverity, SH.Info)) + results_graph.add((validation_result, SH.resultMessage, Literal("Test info"))) + + result = SHACLValidationResult(conforms=True) + parsed = validator._parse_validation_results(results_graph, result) + + # Should extract info + assert len(parsed.info) > 0 + + +@pytest.mark.skipif( + is_shacl_validation_available(), + reason="Test only when rdflib/pyshacl not installed", +) +class TestRDFSHACLWithoutDependencies: + """Tests for RDF SHACL validator without dependencies.""" + + def test_validate_graph_raises_import_error(self): + """Test validate_graph raises ImportError without pyshacl.""" + validator = RDFSHACLValidator() + + with pytest.raises(ImportError) as exc_info: + validator.validate_graph(None) + + assert "pyshacl" in str(exc_info.value) + + def test_validate_jsonld_raises_import_error(self): + """Test validate_jsonld raises ImportError without rdflib.""" + validator = RDFSHACLValidator() + + with pytest.raises(ImportError) as exc_info: + validator.validate_jsonld({"@id": "test"}) + + assert "rdflib" in str(exc_info.value) + + def test_placeholder_shapes_raises_import_error(self): + """Test _load_placeholder_shapes raises ImportError without rdflib.""" + validator = RDFSHACLValidator(use_official_shapes=False) + + with pytest.raises(ImportError) as exc_info: + validator.load_shapes() + + assert "rdflib" in str(exc_info.value) diff --git a/tests/unit/test_signatures.py b/tests/unit/test_signatures.py new file mode 100644 index 0000000..bffe900 --- /dev/null +++ b/tests/unit/test_signatures.py @@ -0,0 +1,419 @@ +"""Unit tests for signature verification behavior.""" + +import base64 + +import pytest +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 + +from dppvalidator.verifier.did import VerificationMethod +from dppvalidator.verifier.signatures import ( + SignatureInfo, + SignatureVerifier, + verify_jws, + verify_signature, +) + + +class TestSignatureVerifier: + """Tests for SignatureVerifier class.""" + + def test_supported_algorithms(self) -> None: + """Verifier lists supported algorithms.""" + verifier = SignatureVerifier() + assert "Ed25519" in verifier.SUPPORTED_ALGORITHMS + assert "ES256" in verifier.SUPPORTED_ALGORITHMS + assert "ES384" in verifier.SUPPORTED_ALGORITHMS + + def test_verify_ed25519_valid_signature(self) -> None: + """Valid Ed25519 signature verifies successfully.""" + verifier = SignatureVerifier() + + # Generate a real key pair + private_key = ed25519.Ed25519PrivateKey.generate() + public_key = private_key.public_key() + + # Sign a message + message = b"test message" + signature = private_key.sign(message) + + # Verify with raw bytes + public_bytes = public_key.public_bytes_raw() + result = verifier.verify(signature, message, public_bytes, "Ed25519") + + assert result is True + + def test_verify_ed25519_invalid_signature(self) -> None: + """Invalid Ed25519 signature fails verification.""" + verifier = SignatureVerifier() + + # Generate a key pair + private_key = ed25519.Ed25519PrivateKey.generate() + public_key = private_key.public_key() + + # Sign a message + message = b"test message" + signature = private_key.sign(message) + + # Verify with wrong message + public_bytes = public_key.public_bytes_raw() + result = verifier.verify(signature, b"wrong message", public_bytes, "Ed25519") + + assert result is False + + def test_verify_ed25519_with_jwk(self) -> None: + """Ed25519 signature verifies with JWK public key.""" + verifier = SignatureVerifier() + + # Generate a real key pair + private_key = ed25519.Ed25519PrivateKey.generate() + public_key = private_key.public_key() + + # Sign a message + message = b"test message for jwk" + signature = private_key.sign(message) + + # Create JWK from public key + public_bytes = public_key.public_bytes_raw() + jwk = { + "kty": "OKP", + "crv": "Ed25519", + "x": base64.urlsafe_b64encode(public_bytes).rstrip(b"=").decode(), + } + + result = verifier.verify(signature, message, jwk, "Ed25519") + assert result is True + + def test_verify_es256_valid_signature(self) -> None: + """Valid ES256 (P-256) signature verifies successfully.""" + verifier = SignatureVerifier() + + # Generate a P-256 key pair + private_key = ec.generate_private_key(ec.SECP256R1()) + public_key = private_key.public_key() + + # Sign a message (DER format) + from cryptography.hazmat.primitives import hashes + + message = b"test message for P-256" + der_signature = private_key.sign(message, ec.ECDSA(hashes.SHA256())) + + # Get public key numbers for JWK + numbers = public_key.public_numbers() + x_bytes = numbers.x.to_bytes(32, "big") + y_bytes = numbers.y.to_bytes(32, "big") + + jwk = { + "kty": "EC", + "crv": "P-256", + "x": base64.urlsafe_b64encode(x_bytes).rstrip(b"=").decode(), + "y": base64.urlsafe_b64encode(y_bytes).rstrip(b"=").decode(), + } + + result = verifier.verify(der_signature, message, jwk, "ES256") + assert result is True + + def test_verify_es384_valid_signature(self) -> None: + """Valid ES384 (P-384) signature verifies successfully.""" + verifier = SignatureVerifier() + + # Generate a P-384 key pair + private_key = ec.generate_private_key(ec.SECP384R1()) + public_key = private_key.public_key() + + # Sign a message + from cryptography.hazmat.primitives import hashes + + message = b"test message for P-384" + der_signature = private_key.sign(message, ec.ECDSA(hashes.SHA384())) + + # Get public key numbers for JWK + numbers = public_key.public_numbers() + x_bytes = numbers.x.to_bytes(48, "big") + y_bytes = numbers.y.to_bytes(48, "big") + + jwk = { + "kty": "EC", + "crv": "P-384", + "x": base64.urlsafe_b64encode(x_bytes).rstrip(b"=").decode(), + "y": base64.urlsafe_b64encode(y_bytes).rstrip(b"=").decode(), + } + + result = verifier.verify(der_signature, message, jwk, "ES384") + assert result is True + + def test_verify_unsupported_algorithm(self) -> None: + """Unsupported algorithm returns False.""" + verifier = SignatureVerifier() + result = verifier.verify(b"sig", b"msg", b"key", "RS256") + assert result is False + + def test_verify_with_invalid_key_returns_false(self) -> None: + """Invalid public key returns False without raising.""" + verifier = SignatureVerifier() + result = verifier.verify(b"signature", b"message", b"invalid", "Ed25519") + assert result is False + + def test_verify_p256_alias(self) -> None: + """P-256 alias maps to ES256.""" + verifier = SignatureVerifier() + + # Generate a P-256 key pair + private_key = ec.generate_private_key(ec.SECP256R1()) + public_key = private_key.public_key() + + from cryptography.hazmat.primitives import hashes + + message = b"test p256 alias" + signature = private_key.sign(message, ec.ECDSA(hashes.SHA256())) + + numbers = public_key.public_numbers() + jwk = { + "kty": "EC", + "crv": "P-256", + "x": base64.urlsafe_b64encode(numbers.x.to_bytes(32, "big")).rstrip(b"=").decode(), + "y": base64.urlsafe_b64encode(numbers.y.to_bytes(32, "big")).rstrip(b"=").decode(), + } + + result = verifier.verify(signature, message, jwk, "P-256") + assert result is True + + def test_verify_p384_alias(self) -> None: + """P-384 alias maps to ES384.""" + verifier = SignatureVerifier() + + # Generate a P-384 key pair + private_key = ec.generate_private_key(ec.SECP384R1()) + public_key = private_key.public_key() + + from cryptography.hazmat.primitives import hashes + + message = b"test p384 alias" + signature = private_key.sign(message, ec.ECDSA(hashes.SHA384())) + + numbers = public_key.public_numbers() + jwk = { + "kty": "EC", + "crv": "P-384", + "x": base64.urlsafe_b64encode(numbers.x.to_bytes(48, "big")).rstrip(b"=").decode(), + "y": base64.urlsafe_b64encode(numbers.y.to_bytes(48, "big")).rstrip(b"=").decode(), + } + + result = verifier.verify(signature, message, jwk, "P-384") + assert result is True + + +class TestVerifyFromMethod: + """Tests for verify_from_method using VerificationMethod.""" + + def test_verify_from_method_with_ed25519(self) -> None: + """Verification works with VerificationMethod object.""" + verifier = SignatureVerifier() + + # Generate key pair + private_key = ed25519.Ed25519PrivateKey.generate() + public_key = private_key.public_key() + + message = b"message from method" + signature = private_key.sign(message) + + # Create VerificationMethod with JWK + public_bytes = public_key.public_bytes_raw() + vm = VerificationMethod( + id="did:key:z6Mk...#key-1", + type="Ed25519VerificationKey2020", + controller="did:key:z6Mk...", + public_key_jwk={ + "kty": "OKP", + "crv": "Ed25519", + "x": base64.urlsafe_b64encode(public_bytes).rstrip(b"=").decode(), + }, + ) + + result = verifier.verify_from_method(signature, message, vm) + assert result is True + + def test_verify_from_method_no_jwk(self) -> None: + """Returns False when VerificationMethod has no JWK.""" + verifier = SignatureVerifier() + + vm = VerificationMethod( + id="did:key:z6Mk...", + type="Ed25519VerificationKey2020", + controller="did:key:z6Mk...", + public_key_jwk=None, + ) + + result = verifier.verify_from_method(b"sig", b"msg", vm) + assert result is False + + +class TestJWKConversion: + """Tests for JWK to key conversion.""" + + def test_jwk_to_ed25519_invalid_kty(self) -> None: + """Invalid kty in JWK raises ValueError.""" + verifier = SignatureVerifier() + + with pytest.raises(ValueError, match="Invalid JWK for Ed25519"): + verifier._jwk_to_ed25519_public_key({"kty": "EC", "crv": "Ed25519"}) + + def test_jwk_to_ed25519_invalid_crv(self) -> None: + """Invalid crv in JWK raises ValueError.""" + verifier = SignatureVerifier() + + with pytest.raises(ValueError, match="Invalid JWK for Ed25519"): + verifier._jwk_to_ed25519_public_key({"kty": "OKP", "crv": "X25519"}) + + def test_jwk_to_ec_invalid_kty(self) -> None: + """Invalid kty in EC JWK raises ValueError.""" + verifier = SignatureVerifier() + + with pytest.raises(ValueError, match="Invalid JWK for EC key"): + verifier._jwk_to_ec_public_key({"kty": "OKP", "crv": "P-256"}) + + def test_jwk_to_ec_unsupported_curve(self) -> None: + """Unsupported EC curve raises ValueError.""" + verifier = SignatureVerifier() + + # Use valid base64 values so the curve check is reached + with pytest.raises(ValueError, match="Unsupported curve"): + verifier._jwk_to_ec_public_key( + { + "kty": "EC", + "crv": "secp256k1", + "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "y": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + } + ) + + +class TestVerifyJWS: + """Tests for JWS token verification.""" + + def test_verify_jws_ed25519(self) -> None: + """JWS with EdDSA algorithm verifies correctly.""" + import jwt + + # Generate Ed25519 key + private_key = ed25519.Ed25519PrivateKey.generate() + public_key = private_key.public_key() + + # Create JWT/JWS + from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + ) + + private_pem = private_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()) + payload = {"sub": "test", "iat": 1234567890} + token = jwt.encode(payload, private_pem, algorithm="EdDSA") + + # Create JWK for verification + public_bytes = public_key.public_bytes_raw() + jwk = { + "kty": "OKP", + "crv": "Ed25519", + "x": base64.urlsafe_b64encode(public_bytes).rstrip(b"=").decode(), + } + + valid, decoded = verify_jws(token, jwk) + assert valid is True + assert decoded is not None + assert decoded["sub"] == "test" + + def test_verify_jws_es256(self) -> None: + """JWS with ES256 algorithm verifies correctly.""" + import jwt + + # Generate P-256 key + private_key = ec.generate_private_key(ec.SECP256R1()) + public_key = private_key.public_key() + + # Create JWT/JWS + from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + ) + + private_pem = private_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()) + payload = {"sub": "es256-test"} + token = jwt.encode(payload, private_pem, algorithm="ES256") + + # Create JWK for verification + numbers = public_key.public_numbers() + jwk = { + "kty": "EC", + "crv": "P-256", + "x": base64.urlsafe_b64encode(numbers.x.to_bytes(32, "big")).rstrip(b"=").decode(), + "y": base64.urlsafe_b64encode(numbers.y.to_bytes(32, "big")).rstrip(b"=").decode(), + } + + valid, decoded = verify_jws(token, jwk) + assert valid is True + assert decoded is not None + assert decoded["sub"] == "es256-test" + + def test_verify_jws_invalid_signature(self) -> None: + """Invalid JWS signature returns False.""" + # Tampered token + jwk = {"kty": "OKP", "crv": "Ed25519", "x": "abcd1234"} + valid, decoded = verify_jws("invalid.token.here", jwk) + assert valid is False + assert decoded is None + + def test_verify_jws_unsupported_key_type(self) -> None: + """Unsupported key type returns False.""" + jwk = {"kty": "RSA", "n": "abc", "e": "AQAB"} + valid, decoded = verify_jws("some.jwt.token", jwk) + assert valid is False + assert decoded is None + + +class TestVerifySignatureFunction: + """Tests for module-level verify_signature function.""" + + def test_verify_signature_creates_verifier(self) -> None: + """Module function creates SignatureVerifier internally.""" + # Generate key + private_key = ed25519.Ed25519PrivateKey.generate() + public_key = private_key.public_key() + + message = b"module level test" + signature = private_key.sign(message) + + result = verify_signature( + signature, + message, + public_key.public_bytes_raw(), + "Ed25519", + ) + assert result is True + + +class TestSignatureInfo: + """Tests for SignatureInfo dataclass.""" + + def test_signature_info_fields(self) -> None: + """SignatureInfo stores all fields.""" + info = SignatureInfo( + algorithm="Ed25519", + signature_bytes=b"signature", + message_bytes=b"message", + key_id="did:key:z6Mk...#key-1", + ) + + assert info.algorithm == "Ed25519" + assert info.signature_bytes == b"signature" + assert info.message_bytes == b"message" + assert info.key_id == "did:key:z6Mk...#key-1" + + def test_signature_info_optional_key_id(self) -> None: + """key_id is optional.""" + info = SignatureInfo( + algorithm="ES256", + signature_bytes=b"sig", + message_bytes=b"msg", + ) + assert info.key_id is None diff --git a/tests/unit/test_signatures_extended.py b/tests/unit/test_signatures_extended.py new file mode 100644 index 0000000..ca3d769 --- /dev/null +++ b/tests/unit/test_signatures_extended.py @@ -0,0 +1,292 @@ +"""Extended tests for signature verification - covering ECDSA and edge cases.""" + +import base64 +from typing import Any + +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 + +from dppvalidator.verifier.did import VerificationMethod +from dppvalidator.verifier.signatures import ( + SignatureInfo, + SignatureVerifier, + verify_jws, + verify_signature, +) + + +class TestSignatureInfo: + """Tests for SignatureInfo dataclass.""" + + def test_default_key_id_is_none(self) -> None: + """key_id defaults to None.""" + info = SignatureInfo( + algorithm="Ed25519", + signature_bytes=b"sig", + message_bytes=b"msg", + ) + assert info.key_id is None + + def test_key_id_can_be_set(self) -> None: + """key_id can be explicitly set.""" + info = SignatureInfo( + algorithm="ES256", + signature_bytes=b"sig", + message_bytes=b"msg", + key_id="key-1", + ) + assert info.key_id == "key-1" + + +class TestSignatureVerifierECDSA: + """Tests for ECDSA signature verification.""" + + def test_verify_es256_with_jwk(self) -> None: + """ES256 verification works with JWK public key.""" + # Generate P-256 key pair + private_key = ec.generate_private_key(ec.SECP256R1()) + public_key = private_key.public_key() + + # Create JWK + numbers = public_key.public_numbers() + x_bytes = numbers.x.to_bytes(32, "big") + y_bytes = numbers.y.to_bytes(32, "big") + jwk = { + "kty": "EC", + "crv": "P-256", + "x": base64.urlsafe_b64encode(x_bytes).rstrip(b"=").decode(), + "y": base64.urlsafe_b64encode(y_bytes).rstrip(b"=").decode(), + } + + # Sign a message + from cryptography.hazmat.primitives import hashes + + message = b"test message" + signature = private_key.sign(message, ec.ECDSA(hashes.SHA256())) + + verifier = SignatureVerifier() + result = verifier.verify(signature, message, jwk, "ES256") + assert result is True + + def test_verify_es384_with_jwk(self) -> None: + """ES384 verification works with JWK public key.""" + # Generate P-384 key pair + private_key = ec.generate_private_key(ec.SECP384R1()) + public_key = private_key.public_key() + + # Create JWK + numbers = public_key.public_numbers() + x_bytes = numbers.x.to_bytes(48, "big") + y_bytes = numbers.y.to_bytes(48, "big") + jwk = { + "kty": "EC", + "crv": "P-384", + "x": base64.urlsafe_b64encode(x_bytes).rstrip(b"=").decode(), + "y": base64.urlsafe_b64encode(y_bytes).rstrip(b"=").decode(), + } + + # Sign a message + from cryptography.hazmat.primitives import hashes + + message = b"test message for P-384" + signature = private_key.sign(message, ec.ECDSA(hashes.SHA384())) + + verifier = SignatureVerifier() + result = verifier.verify(signature, message, jwk, "ES384") + assert result is True + + def test_verify_p256_alias_works(self) -> None: + """P-256 alias works for ES256.""" + verifier = SignatureVerifier() + # Invalid signature should return False, not crash + result = verifier.verify(b"invalid", b"message", b"\x04" + b"\x00" * 64, "P-256") + assert result is False + + def test_verify_p384_alias_works(self) -> None: + """P-384 alias works for ES384.""" + verifier = SignatureVerifier() + # Invalid signature should return False, not crash + result = verifier.verify(b"invalid", b"message", b"\x04" + b"\x00" * 96, "P-384") + assert result is False + + def test_verify_invalid_ecdsa_signature_returns_false(self) -> None: + """Invalid ECDSA signature returns False.""" + private_key = ec.generate_private_key(ec.SECP256R1()) + public_key = private_key.public_key() + + numbers = public_key.public_numbers() + x_bytes = numbers.x.to_bytes(32, "big") + y_bytes = numbers.y.to_bytes(32, "big") + jwk = { + "kty": "EC", + "crv": "P-256", + "x": base64.urlsafe_b64encode(x_bytes).rstrip(b"=").decode(), + "y": base64.urlsafe_b64encode(y_bytes).rstrip(b"=").decode(), + } + + verifier = SignatureVerifier() + result = verifier.verify(b"invalid signature", b"message", jwk, "ES256") + assert result is False + + def test_unsupported_algorithm_returns_false(self) -> None: + """Unsupported algorithm returns False.""" + verifier = SignatureVerifier() + result = verifier.verify(b"sig", b"msg", b"key", "RS256") + assert result is False + + +class TestSignatureVerifierEd25519: + """Tests for Ed25519 signature verification.""" + + def test_verify_ed25519_with_jwk(self) -> None: + """Ed25519 verification works with JWK public key.""" + private_key = ed25519.Ed25519PrivateKey.generate() + public_key = private_key.public_key() + + jwk = { + "kty": "OKP", + "crv": "Ed25519", + "x": base64.urlsafe_b64encode(public_key.public_bytes_raw()).rstrip(b"=").decode(), + } + + message = b"test message" + signature = private_key.sign(message) + + verifier = SignatureVerifier() + result = verifier.verify(signature, message, jwk, "Ed25519") + assert result is True + + def test_verify_ed25519_with_raw_bytes(self) -> None: + """Ed25519 verification works with raw public key bytes.""" + private_key = ed25519.Ed25519PrivateKey.generate() + public_key = private_key.public_key() + + message = b"test message" + signature = private_key.sign(message) + + verifier = SignatureVerifier() + result = verifier.verify(signature, message, public_key.public_bytes_raw(), "Ed25519") + assert result is True + + def test_verify_ed25519_invalid_signature_returns_false(self) -> None: + """Invalid Ed25519 signature returns False.""" + private_key = ed25519.Ed25519PrivateKey.generate() + public_key = private_key.public_key() + + verifier = SignatureVerifier() + result = verifier.verify( + b"invalid" * 8, b"message", public_key.public_bytes_raw(), "Ed25519" + ) + assert result is False + + +class TestJWKConversion: + """Tests for JWK to key conversion.""" + + def test_invalid_ed25519_jwk_raises(self) -> None: + """Invalid Ed25519 JWK raises ValueError.""" + verifier = SignatureVerifier() + jwk: dict[str, Any] = {"kty": "RSA", "n": "abc", "e": "AQAB"} + + try: + verifier._jwk_to_ed25519_public_key(jwk) + raise AssertionError("Should have raised ValueError") + except ValueError as e: + assert "Invalid JWK for Ed25519" in str(e) + + def test_invalid_ec_jwk_raises(self) -> None: + """Invalid EC JWK raises ValueError.""" + verifier = SignatureVerifier() + jwk: dict[str, Any] = {"kty": "OKP", "crv": "Ed25519", "x": "abc"} + + try: + verifier._jwk_to_ec_public_key(jwk) + raise AssertionError("Should have raised ValueError") + except ValueError as e: + assert "Invalid JWK for EC key" in str(e) + + def test_unsupported_ec_curve_raises(self) -> None: + """Unsupported EC curve raises ValueError.""" + verifier = SignatureVerifier() + jwk = { + "kty": "EC", + "crv": "P-521", # Not supported + "x": base64.urlsafe_b64encode(b"\x00" * 66).decode(), + "y": base64.urlsafe_b64encode(b"\x00" * 66).decode(), + } + + try: + verifier._jwk_to_ec_public_key(jwk) + raise AssertionError("Should have raised ValueError") + except ValueError as e: + assert "Unsupported curve" in str(e) + + +class TestVerifyFromMethod: + """Tests for verify_from_method.""" + + def test_no_jwk_returns_false(self) -> None: + """Missing JWK in verification method returns False.""" + vm = VerificationMethod( + id="did:key:z6Mk...#key-1", + type="Ed25519VerificationKey2020", + controller="did:key:z6Mk...", + public_key_jwk=None, + ) + + verifier = SignatureVerifier() + result = verifier.verify_from_method(b"sig", b"msg", vm) + assert result is False + + def test_no_key_type_returns_false(self) -> None: + """Unknown key type returns False.""" + vm = VerificationMethod( + id="did:key:z6Mk...#key-1", + type="UnknownKeyType", + controller="did:key:z6Mk...", + public_key_jwk={"kty": "unknown"}, + ) + + verifier = SignatureVerifier() + result = verifier.verify_from_method(b"sig", b"msg", vm) + assert result is False + + +class TestVerifyJWS: + """Tests for JWS verification.""" + + def test_unsupported_key_type_returns_false(self) -> None: + """Unsupported key type returns (False, None).""" + jwk = {"kty": "RSA", "n": "abc", "e": "AQAB"} + valid, payload = verify_jws("eyJ...", jwk) + assert valid is False + assert payload is None + + def test_invalid_jws_returns_false(self) -> None: + """Invalid JWS token returns (False, None).""" + private_key = ed25519.Ed25519PrivateKey.generate() + public_key = private_key.public_key() + + jwk = { + "kty": "OKP", + "crv": "Ed25519", + "x": base64.urlsafe_b64encode(public_key.public_bytes_raw()).rstrip(b"=").decode(), + } + + valid, payload = verify_jws("not.a.valid.jws", jwk) + assert valid is False + assert payload is None + + +class TestModuleFunctions: + """Tests for module-level convenience functions.""" + + def test_verify_signature_uses_default_verifier(self) -> None: + """verify_signature creates SignatureVerifier internally.""" + private_key = ed25519.Ed25519PrivateKey.generate() + public_key = private_key.public_key() + + message = b"test" + signature = private_key.sign(message) + + result = verify_signature(signature, message, public_key.public_bytes_raw(), "Ed25519") + assert result is True diff --git a/tests/unit/test_textile_rules.py b/tests/unit/test_textile_rules.py new file mode 100644 index 0000000..8802a27 --- /dev/null +++ b/tests/unit/test_textile_rules.py @@ -0,0 +1,431 @@ +"""Tests for CIRPASS-2 textile sector validation rules.""" + +import pytest + +from dppvalidator.models import CredentialIssuer, DigitalProductPassport +from dppvalidator.validators.rules.textile import ( + TEXTILE_RULES, + TextileCareInstructionsRule, + TextileDurabilityRule, + TextileEnvironmentalCategory, + TextileHSCodeRule, + TextileMaterialCompositionRule, + TextileMicroplasticRule, + get_textile_environmental_categories, + is_textile_product, +) + + +class TestTextileRulesRegistration: + """Tests for textile rules registration.""" + + def test_textile_rules_list_not_empty(self): + """Test TEXTILE_RULES list is not empty.""" + assert len(TEXTILE_RULES) > 0 + + def test_textile_rules_count(self): + """Test TEXTILE_RULES has expected count.""" + assert len(TEXTILE_RULES) == 5 + + def test_all_rules_have_required_attributes(self): + """Test all textile rules have required attributes.""" + for rule in TEXTILE_RULES: + assert hasattr(rule, "rule_id") + assert hasattr(rule, "description") + assert hasattr(rule, "severity") + assert hasattr(rule, "suggestion") + assert hasattr(rule, "docs_url") + assert hasattr(rule, "check") + assert rule.rule_id.startswith("TXT") + + +class TestTextileEnvironmentalCategory: + """Tests for TextileEnvironmentalCategory enum.""" + + def test_environmental_categories_exist(self): + """Test environmental categories exist.""" + assert TextileEnvironmentalCategory.WATER_CONSUMPTION.value == "water_consumption" + assert TextileEnvironmentalCategory.ENERGY_CONSUMPTION.value == "energy_consumption" + assert TextileEnvironmentalCategory.MICROPLASTIC_RELEASE.value == "microplastic_release" + + def test_get_textile_environmental_categories(self): + """Test get_textile_environmental_categories function.""" + categories = get_textile_environmental_categories() + assert len(categories) == 7 + assert "water_consumption" in categories + assert "microplastic_release" in categories + + +class TestTextileHSCodeRule: + """Tests for TXT001: Textile HS code validation.""" + + @pytest.fixture + def rule(self) -> TextileHSCodeRule: + """Create rule instance.""" + return TextileHSCodeRule() + + def test_rule_attributes(self, rule: TextileHSCodeRule): + """Test rule has correct attributes.""" + assert rule.rule_id == "TXT001" + assert rule.severity == "warning" + + def test_no_product_no_violations(self, rule: TextileHSCodeRule): + """Test no product produces no violations.""" + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + ) + violations = rule.check(passport) + assert len(violations) == 0 + + def test_product_with_textile_hs_code(self, rule: TextileHSCodeRule): + """Test product with textile HS code produces no violations.""" + from dppvalidator.models import Classification, Product, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credentialSubject=ProductPassport( + product=Product( + id="https://example.com/product", + name="Cotton T-Shirt", + productCategory=[ + Classification( + id="https://hs.org/6109", + name="T-shirts", + code="6109", + ) + ], + ) + ), + ) + violations = rule.check(passport) + assert len(violations) == 0 + + def test_product_without_product_category(self, rule: TextileHSCodeRule): + """Test product without category produces violation.""" + from dppvalidator.models import Product, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credentialSubject=ProductPassport( + product=Product( + id="https://example.com/product", + name="Cotton T-Shirt", + ) + ), + ) + violations = rule.check(passport) + assert len(violations) == 1 + assert "productCategory" in violations[0][0] + + def test_product_with_non_textile_hs_code(self, rule: TextileHSCodeRule): + """Test product with non-textile HS code produces violation.""" + from dppvalidator.models import Classification, Product, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credentialSubject=ProductPassport( + product=Product( + id="https://example.com/product", + name="Electronic Device", + productCategory=[ + Classification( + id="https://hs.org/8471", + name="Computers", + code="8471", + ) + ], + ) + ), + ) + violations = rule.check(passport) + assert len(violations) == 1 + assert "50-63" in violations[0][1] + + +class TestTextileMaterialCompositionRule: + """Tests for TXT002: Material composition validation.""" + + @pytest.fixture + def rule(self) -> TextileMaterialCompositionRule: + """Create rule instance.""" + return TextileMaterialCompositionRule() + + def test_rule_attributes(self, rule: TextileMaterialCompositionRule): + """Test rule has correct attributes.""" + assert rule.rule_id == "TXT002" + assert rule.severity == "error" + + def test_no_materials_produces_violation(self, rule: TextileMaterialCompositionRule): + """Test no materials produces violation.""" + from dppvalidator.models import ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credentialSubject=ProductPassport(), + ) + violations = rule.check(passport) + assert len(violations) == 1 + assert "materialsProvenance" in violations[0][0] + + def test_materials_with_fraction_no_violations(self, rule: TextileMaterialCompositionRule): + """Test materials with mass fraction produces no violations.""" + from dppvalidator.models import Material, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credentialSubject=ProductPassport( + materialsProvenance=[ + Material(name="Cotton", massFraction=0.95), + Material(name="Elastane", massFraction=0.05), + ] + ), + ) + violations = rule.check(passport) + assert len(violations) == 0 + + def test_materials_without_fraction_produces_violation( + self, rule: TextileMaterialCompositionRule + ): + """Test materials without mass fraction produces violation.""" + from dppvalidator.models import Material, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credentialSubject=ProductPassport( + materialsProvenance=[ + Material(name="Cotton"), + Material(name="Elastane"), + ] + ), + ) + violations = rule.check(passport) + assert len(violations) == 1 + assert "fiber" in violations[0][1].lower() or "fraction" in violations[0][1].lower() + + +class TestTextileMicroplasticRule: + """Tests for TXT003: Microplastic release validation.""" + + @pytest.fixture + def rule(self) -> TextileMicroplasticRule: + """Create rule instance.""" + return TextileMicroplasticRule() + + def test_rule_attributes(self, rule: TextileMicroplasticRule): + """Test rule has correct attributes.""" + assert rule.rule_id == "TXT003" + assert rule.severity == "info" + + def test_no_materials_no_violations(self, rule: TextileMicroplasticRule): + """Test no materials produces no violations.""" + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + ) + violations = rule.check(passport) + assert len(violations) == 0 + + def test_natural_fiber_no_violations(self, rule: TextileMicroplasticRule): + """Test natural fiber produces no violations.""" + from dppvalidator.models import Material, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credentialSubject=ProductPassport( + materialsProvenance=[ + Material(name="Cotton", massFraction=1.0), + ] + ), + ) + violations = rule.check(passport) + assert len(violations) == 0 + + def test_synthetic_fiber_without_scorecard_produces_info(self, rule: TextileMicroplasticRule): + """Test synthetic fiber without scorecard produces info.""" + from dppvalidator.models import Material, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credentialSubject=ProductPassport( + materialsProvenance=[ + Material(name="Polyester", massFraction=1.0), + ] + ), + ) + violations = rule.check(passport) + assert len(violations) == 1 + assert "microplastic" in violations[0][1].lower() + + +class TestTextileDurabilityRule: + """Tests for TXT004: Durability information validation.""" + + @pytest.fixture + def rule(self) -> TextileDurabilityRule: + """Create rule instance.""" + return TextileDurabilityRule() + + def test_rule_attributes(self, rule: TextileDurabilityRule): + """Test rule has correct attributes.""" + assert rule.rule_id == "TXT004" + assert rule.severity == "info" + + def test_no_product_no_violations(self, rule: TextileDurabilityRule): + """Test no product produces no violations.""" + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + ) + violations = rule.check(passport) + assert len(violations) == 0 + + def test_product_without_characteristics_produces_info(self, rule: TextileDurabilityRule): + """Test product without characteristics produces info.""" + from dppvalidator.models import Product, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credentialSubject=ProductPassport( + product=Product( + id="https://example.com/product", + name="Test Product", + ) + ), + ) + violations = rule.check(passport) + assert len(violations) == 1 + assert "durability" in violations[0][1].lower() + + +class TestTextileCareInstructionsRule: + """Tests for TXT005: Care instructions validation.""" + + @pytest.fixture + def rule(self) -> TextileCareInstructionsRule: + """Create rule instance.""" + return TextileCareInstructionsRule() + + def test_rule_attributes(self, rule: TextileCareInstructionsRule): + """Test rule has correct attributes.""" + assert rule.rule_id == "TXT005" + assert rule.severity == "info" + + def test_product_with_further_info_no_violations(self, rule: TextileCareInstructionsRule): + """Test product with further information produces no violations.""" + from dppvalidator.models import Link, Product, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credentialSubject=ProductPassport( + product=Product( + id="https://example.com/product", + name="Test Product", + furtherInformation=[Link(linkURL="https://example.com/care")], + ) + ), + ) + violations = rule.check(passport) + assert len(violations) == 0 + + def test_product_without_further_info_produces_info(self, rule: TextileCareInstructionsRule): + """Test product without further information produces info.""" + from dppvalidator.models import Product, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credentialSubject=ProductPassport( + product=Product( + id="https://example.com/product", + name="Test Product", + ) + ), + ) + violations = rule.check(passport) + assert len(violations) == 1 + assert "care" in violations[0][1].lower() + + +class TestIsTextileProduct: + """Tests for is_textile_product function.""" + + def test_not_textile_no_subject(self): + """Test no credential subject returns False.""" + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + ) + assert is_textile_product(passport) is False + + def test_not_textile_no_category(self): + """Test no product category returns False.""" + from dppvalidator.models import Product, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credentialSubject=ProductPassport( + product=Product( + id="https://example.com/product", + name="Test Product", + ) + ), + ) + assert is_textile_product(passport) is False + + def test_is_textile_with_hs_code(self): + """Test textile HS code returns True.""" + from dppvalidator.models import Classification, Product, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credentialSubject=ProductPassport( + product=Product( + id="https://example.com/product", + name="Cotton T-Shirt", + productCategory=[ + Classification( + id="https://hs.org/6109", + name="T-shirts", + code="6109", + ) + ], + ) + ), + ) + assert is_textile_product(passport) is True + + def test_not_textile_with_non_textile_hs_code(self): + """Test non-textile HS code returns False.""" + from dppvalidator.models import Classification, Product, ProductPassport + + passport = DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + credentialSubject=ProductPassport( + product=Product( + id="https://example.com/product", + name="Electronic Device", + productCategory=[ + Classification( + id="https://hs.org/8471", + name="Computers", + code="8471", + ) + ], + ) + ), + ) + assert is_textile_product(passport) is False diff --git a/tests/unit/test_v07_models.py b/tests/unit/test_v07_models.py new file mode 100644 index 0000000..a1b947f --- /dev/null +++ b/tests/unit/test_v07_models.py @@ -0,0 +1,300 @@ +"""Acceptance tests for the UNTP v0.7.0 Pydantic models. + +Phase 3 of docs/plans/UNTP_0.7.0_MIGRATION.md introduced +``dppvalidator.models.v0_7``. These tests cover: + +1. The dispatch table is in lock-step with the schema registry — every + registered version has a model class. +2. Each vendored 0.7.0 sample (canonical, battery, cathode) parses + cleanly through ``v0_7.DigitalProductPassport``. +3. The cross-field invariants documented in §3.2 of the plan are wired + correctly (date order, granularity ↔ id required, hazardous ↔ safety + info, mass-fraction sum ≤ 1.0, performance has measure or score, + period ordering, country-code shape, image required fields). + +These tests deliberately do not exercise the engine (that's covered by +``test_phase3_engine.py``) — they pin the model layer's behaviour in +isolation so a regression there is identifiable. +""" + +from __future__ import annotations + +import json +from datetime import date, datetime, timedelta, timezone +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from dppvalidator.models import v0_6, v0_7 +from dppvalidator.schemas.registry import SCHEMA_REGISTRY +from dppvalidator.validators.model import _MODEL_BY_VERSION + +_UPSTREAM_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "upstream" / "v0.7.0" +_SAMPLES = { + "canonical": _UPSTREAM_DIR / "samples" / "DigitalProductPassport_instance.json", + "battery": _UPSTREAM_DIR / "samples" / "DigitalProductPassport_battery_instance.json", + "cathode": _UPSTREAM_DIR / "samples" / "DigitalProductPassport_cathode_instance.json", +} + + +def _load(name: str) -> dict: + path = _SAMPLES[name] + if not path.is_file(): # pragma: no cover - vendored in Phase 0 + pytest.skip(f"Vendor {path} via Phase 0 of the migration plan.") + with path.open(encoding="utf-8") as f: + return json.load(f) + + +# ---------------------------------------------------------------------------- +# Dispatch table contract +# ---------------------------------------------------------------------------- + + +class TestDispatchTable: + def test_dispatch_table_covers_every_registered_version(self) -> None: + """Every entry in SCHEMA_REGISTRY has a model in _MODEL_BY_VERSION.""" + missing = sorted(set(SCHEMA_REGISTRY) - set(_MODEL_BY_VERSION)) + assert not missing, ( + f"Versions registered without a Pydantic model: {missing}. " + "Add an entry to validators/model.py::_MODEL_BY_VERSION." + ) + + def test_dispatch_uses_v0_6_for_legacy_versions(self) -> None: + assert _MODEL_BY_VERSION["0.6.0"] is v0_6.DigitalProductPassport + assert _MODEL_BY_VERSION["0.6.1"] is v0_6.DigitalProductPassport + + def test_dispatch_uses_v0_7_for_modern_version(self) -> None: + assert _MODEL_BY_VERSION["0.7.0"] is v0_7.DigitalProductPassport + + def test_v0_6_and_v0_7_dpp_classes_are_distinct(self) -> None: + """The two version namespaces must not alias to the same class.""" + assert v0_6.DigitalProductPassport is not v0_7.DigitalProductPassport + + +# ---------------------------------------------------------------------------- +# Canonical sample parsing +# ---------------------------------------------------------------------------- + + +class TestUpstreamSampleParsing: + @pytest.mark.parametrize("name", sorted(_SAMPLES)) + def test_each_upstream_sample_parses_through_v0_7_model(self, name: str) -> None: + data = _load(name) + dpp = v0_7.DigitalProductPassport.model_validate(data) + assert dpp.id + assert dpp.name + assert isinstance(dpp.credential_subject, v0_7.Product) + assert dpp.credential_subject.name + assert dpp.credential_subject.id_granularity is not None + + def test_canonical_sample_round_trip_preserves_top_keys(self) -> None: + """Round-tripping through the model preserves the envelope shape.""" + data = _load("canonical") + dpp = v0_7.DigitalProductPassport.model_validate(data) + out = dpp.model_dump(by_alias=True, exclude_none=True) + assert out["id"] == data["id"] + assert out["name"] == data["name"] + assert "credentialSubject" in out + + +# ---------------------------------------------------------------------------- +# Cross-field invariants +# ---------------------------------------------------------------------------- + + +class TestEnvelopeInvariants: + def test_validfrom_must_precede_validuntil(self) -> None: + data = _load("canonical") + # Make validUntil precede validFrom + data["validFrom"] = "2030-01-01T00:00:00Z" + data["validUntil"] = "2025-01-01T00:00:00Z" + with pytest.raises(ValidationError, match="validFrom"): + v0_7.DigitalProductPassport.model_validate(data) + + def test_validfrom_required(self) -> None: + """v0.7.0 makes validFrom required (envelope-level).""" + data = _load("canonical") + del data["validFrom"] + with pytest.raises(ValidationError, match="validFrom"): + v0_7.DigitalProductPassport.model_validate(data) + + def test_name_required_and_non_empty(self) -> None: + data = _load("canonical") + data["name"] = "" + with pytest.raises(ValidationError): + v0_7.DigitalProductPassport.model_validate(data) + + +class TestProductInvariants: + def test_item_granularity_requires_item_number(self) -> None: + data = _load("canonical") + data["credentialSubject"]["idGranularity"] = "item" + data["credentialSubject"].pop("itemNumber", None) + with pytest.raises(ValidationError, match="itemNumber"): + v0_7.DigitalProductPassport.model_validate(data) + + def test_batch_granularity_requires_batch_number(self) -> None: + data = _load("canonical") + data["credentialSubject"]["idGranularity"] = "batch" + data["credentialSubject"].pop("batchNumber", None) + with pytest.raises(ValidationError, match="batchNumber"): + v0_7.DigitalProductPassport.model_validate(data) + + def test_mass_fraction_sum_above_unity_rejected(self) -> None: + data = _load("canonical") + # Pad materialProvenance until the sum exceeds 1.0 + materials = data["credentialSubject"]["materialProvenance"] + # Inject two materials whose mass fractions sum > 1.0 + proto = materials[0] + materials.append({**proto, "name": "Padding A", "massFraction": 0.7}) + materials.append({**proto, "name": "Padding B", "massFraction": 0.7}) + with pytest.raises(ValidationError, match="mass.?[Ff]raction"): + v0_7.DigitalProductPassport.model_validate(data) + + +class TestMaterialInvariants: + def _make_minimal_material(self, **overrides) -> dict: + base = { + "name": "Lithium", + "originCountry": {"countryCode": "AU", "countryName": "Australia"}, + "materialType": { + "schemeId": "https://example.com/scheme", + "schemeName": "Example", + "code": "Li", + "name": "Lithium", + }, + "massFraction": 0.5, + } + return {**base, **overrides} + + def test_hazardous_requires_safety_information(self) -> None: + with pytest.raises(ValidationError, match="materialSafetyInformation"): + v0_7.Material.model_validate(self._make_minimal_material(hazardous=True)) + + def test_hazardous_with_safety_info_passes(self) -> None: + m = self._make_minimal_material( + hazardous=True, + materialSafetyInformation={ + "linkURL": "https://example.com/sds.pdf", + "name": "SDS", + "mediaType": "application/pdf", + }, + ) + v0_7.Material.model_validate(m) + + +class TestPerformanceInvariants: + def test_at_least_one_of_measure_or_score(self) -> None: + with pytest.raises(ValidationError, match="measure.+score"): + v0_7.Performance.model_validate({"metric": {"name": "x"}}) + + def test_with_measure_only_is_ok(self) -> None: + v0_7.Performance.model_validate( + { + "metric": {"name": "co2"}, + "measure": {"value": 12.5, "unit": "KGM"}, + }, + ) + + def test_with_score_only_is_ok(self) -> None: + v0_7.Performance.model_validate( + { + "metric": {"name": "rating"}, + "score": {"code": "A"}, + }, + ) + + +class TestPeriodInvariants: + def test_start_after_end_rejected(self) -> None: + with pytest.raises(ValidationError, match="startDate"): + v0_7.Period.model_validate( + {"startDate": "2025-12-01", "endDate": "2025-01-01"}, + ) + + def test_open_ended_periods_ok(self) -> None: + v0_7.Period.model_validate({"startDate": "2025-01-01"}) # no end + v0_7.Period.model_validate({"endDate": "2025-12-31"}) # no start + + +class TestCountryInvariants: + def test_iso_3166_alpha2_required(self) -> None: + with pytest.raises(ValidationError, match="countryCode"): + v0_7.Country.model_validate({"countryCode": "USA"}) # 3 letters + with pytest.raises(ValidationError, match="countryCode"): + v0_7.Country.model_validate({"countryCode": "us"}) # lowercase + + def test_alpha2_uppercase_passes(self) -> None: + c = v0_7.Country.model_validate({"countryCode": "DE", "countryName": "Germany"}) + assert c.country_code == "DE" + + +# ---------------------------------------------------------------------------- +# Pydantic v2 patterns sanity check (per .claude/rules/dpp-domain.md) +# ---------------------------------------------------------------------------- + + +class TestPydanticV2Patterns: + def test_models_use_extra_allow_on_envelope(self) -> None: + """UNTPBaseModel ships extra='allow' so industry extensions flow through.""" + data = _load("canonical") + data["x:vendor-extension"] = {"key": "value"} + # Must not raise on extra + v0_7.DigitalProductPassport.model_validate(data) + + def test_model_dump_uses_aliases_for_jsonld_output(self) -> None: + """model_dump(by_alias=True) emits camelCase keys.""" + c = v0_7.Country.model_validate({"countryCode": "ES", "countryName": "Spain"}) + d = c.model_dump(by_alias=True, exclude_none=True) + assert "countryCode" in d + assert "countryName" in d + # snake_case must NOT be in the dumped output + assert "country_code" not in d + + +# ---------------------------------------------------------------------------- +# Smoke test on the constructor — Pydantic v2 import paths +# ---------------------------------------------------------------------------- + + +def test_construct_minimal_v07_dpp_programmatically() -> None: + """Build a minimal valid 0.7.0 DPP from scratch (not via JSON).""" + now = datetime.now(timezone.utc) + dpp = v0_7.DigitalProductPassport( + **{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/", + ], + }, + id="https://example.com/credentials/dpp-1", + name="Smoke test DPP", + issuer=v0_7.CredentialIssuer(id="did:web:example.com", name="Example Co"), + validFrom=now, + validUntil=now + timedelta(days=365), + credentialSubject=v0_7.Product( + id="https://example.com/products/p1", + name="Widget", + idScheme=v0_7.IdentifierScheme( + id="https://example.com/scheme", + name="Example scheme", + ), + idGranularity=v0_7.product.IdGranularity.MODEL, + productCategory=[ + v0_7.Classification( + schemeId="https://unstats.un.org/cpc", + schemeName="UN CPC", + code="14110", + name="Widgets", + ), + ], + producedAtFacility=v0_7.Facility( + id="https://example.com/facility/1", + name="Main plant", + ), + countryOfProduction=v0_7.Country(countryCode="DE", countryName="Germany"), + productionDate=date(2025, 1, 1), + ), + ) + assert dpp.credential_subject.name == "Widget" diff --git a/tests/unit/test_validation_engine.py b/tests/unit/test_validation_engine.py index b7ab0fe..b7248ba 100644 --- a/tests/unit/test_validation_engine.py +++ b/tests/unit/test_validation_engine.py @@ -12,28 +12,23 @@ FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" +@pytest.mark.dpp_version("0.6.1") class TestValidationEngine: - """Tests for ValidationEngine.""" + """Tests for ValidationEngine. + + Pinned to v0.6.1 via the class-level marker because the ``engine`` + fixture hardcodes ``schema_version="0.6.1"`` — see Phase 5 of + ``docs/plans/UNTP_0.7.0_MIGRATION.md`` for the matrix design. + """ @pytest.fixture def engine(self) -> ValidationEngine: """Create validation engine (model + semantic only for unit tests).""" return ValidationEngine(schema_version="0.6.1", layers=["model", "semantic"]) - def test_validate_minimal_valid_dpp(self, engine: ValidationEngine): + def test_validate_minimal_valid_dpp(self, engine: ValidationEngine, valid_dpp_data: dict): """Test validating a minimal valid DPP.""" - data = { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", - ], - "id": "https://example.com/credentials/dpp-001", - "issuer": { - "id": "https://example.com/issuers/001", - "name": "Example Company Ltd", - }, - } - result = engine.validate(data) + result = engine.validate(valid_dpp_data) assert result.valid is True def test_validate_missing_issuer(self, engine: ValidationEngine): @@ -61,7 +56,7 @@ def test_validate_fixture_valid(self, engine: ValidationEngine): """Test validating valid fixture.""" fixture_path = FIXTURES_DIR / "valid" / "minimal_dpp.json" if fixture_path.exists(): - data = json.loads(fixture_path.read_text()) + data = json.loads(fixture_path.read_text(encoding="utf-8")) result = engine.validate(data) assert result.valid is True @@ -69,7 +64,7 @@ def test_validate_fixture_invalid(self, engine: ValidationEngine): """Test validating invalid fixture.""" fixture_path = FIXTURES_DIR / "invalid" / "missing_issuer.json" if fixture_path.exists(): - data = json.loads(fixture_path.read_text()) + data = json.loads(fixture_path.read_text(encoding="utf-8")) result = engine.validate(data) assert result.valid is False @@ -77,7 +72,7 @@ def test_validate_official_untp_dpp_instance_schema_only(self): """Test validating the official UNTP DPP 0.6.1 example with schema layer only.""" fixture_path = FIXTURES_DIR / "valid" / "untp-dpp-instance-0.6.1.json" assert fixture_path.exists(), "Official UNTP DPP example fixture not found" - data = json.loads(fixture_path.read_text()) + data = json.loads(fixture_path.read_text(encoding="utf-8")) schema_engine = ValidationEngine(layers=["schema"], schema_version="0.6.1") result = schema_engine.validate(data) @@ -88,7 +83,7 @@ def test_validate_official_untp_dpp_instance_full(self): """Test validating the official UNTP DPP 0.6.1 example with full validation.""" fixture_path = FIXTURES_DIR / "valid" / "untp-dpp-instance-0.6.1.json" assert fixture_path.exists(), "Official UNTP DPP example fixture not found" - data = json.loads(fixture_path.read_text()) + data = json.loads(fixture_path.read_text(encoding="utf-8")) engine = ValidationEngine(schema_version="0.6.1") result = engine.validate(data) @@ -101,7 +96,7 @@ def test_validate_official_untp_dpp_instance_structure(self): """Test that official UNTP DPP example has expected structure.""" fixture_path = FIXTURES_DIR / "valid" / "untp-dpp-instance-0.6.1.json" assert fixture_path.exists(), "Official UNTP DPP example fixture not found" - data = json.loads(fixture_path.read_text()) + data = json.loads(fixture_path.read_text(encoding="utf-8")) assert "@context" in data, "Missing @context" assert "type" in data, "Missing type" @@ -118,7 +113,7 @@ def test_validate_product_passport_instance(self): fixture_path = FIXTURES_DIR / "valid" / "product_passport_instance_0.6.1.json" assert fixture_path.exists(), "ProductPassport example fixture not found" - data = json.loads(fixture_path.read_text()) + data = json.loads(fixture_path.read_text(encoding="utf-8")) assert "type" in data, "Missing type" assert "ProductPassport" in data["type"], "Missing ProductPassport type" @@ -131,6 +126,7 @@ def test_validate_product_passport_instance(self): assert passport.product.name == "EV battery 300Ah." +@pytest.mark.dpp_version("0.6.1") class TestValidationEngineExtended: """Extended tests for ValidationEngine.""" @@ -139,20 +135,10 @@ def engine(self) -> ValidationEngine: """Create validation engine (model + semantic only).""" return ValidationEngine(schema_version="0.6.1", layers=["model", "semantic"]) - def test_validate_file(self, engine: ValidationEngine): + def test_validate_file(self, engine: ValidationEngine, valid_dpp_data: dict): """Test validate_file method.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump( - { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", - ], - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - }, - f, - ) + json.dump(valid_dpp_data, f) f.flush() result = engine.validate_file(f.name) assert result.valid is True @@ -178,38 +164,18 @@ def test_validate_string_invalid_json(self, engine: ValidationEngine): assert result.valid is False assert any("Invalid JSON" in e.message for e in result.errors) - def test_validate_async(self, engine: ValidationEngine): + def test_validate_async(self, engine: ValidationEngine, valid_dpp_data: dict): """Test async validation.""" - data = { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", - ], - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } - result = asyncio.run(engine.validate_async(data)) + result = asyncio.run(engine.validate_async(valid_dpp_data)) assert result.valid is True - def test_validate_batch(self, engine: ValidationEngine): + def test_validate_batch(self, engine: ValidationEngine, valid_dpp_data: dict): """Test batch validation.""" - ctx = [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", - ] - items = [ - { - "@context": ctx, - "id": "https://example.com/dpp1", - "issuer": {"id": "https://a.com", "name": "A"}, - }, - { - "@context": ctx, - "id": "https://example.com/dpp2", - "issuer": {"id": "https://b.com", "name": "B"}, - }, - ] - results = asyncio.run(engine.validate_batch(items, concurrency=2)) + item1 = valid_dpp_data.copy() + item1["id"] = "https://example.com/dpp1" + item2 = valid_dpp_data.copy() + item2["id"] = "https://example.com/dpp2" + results = asyncio.run(engine.validate_batch([item1, item2], concurrency=2)) assert len(results) == 2 assert all(r.valid for r in results) @@ -285,30 +251,24 @@ def test_semantic_layer_skipped_without_passport(self): class TestValidationEngineBehavior: """Behavior tests for ValidationEngine.""" - def test_engine_validates_real_dpp_structure(self): + @pytest.mark.dpp_version("0.6.1") + def test_engine_validates_real_dpp_structure(self, valid_dpp_data: dict): """Test engine validates a complete DPP with all components.""" engine = ValidationEngine(schema_version="0.6.1", layers=["model", "semantic"]) - data = { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", - ], - "id": "https://example.com/dpp/001", - "issuer": { - "id": "https://example.com/issuer", - "name": "Test Corporation", - }, - "credentialSubject": { - "product": { - "id": "https://example.com/product/001", - "name": "Test Product", - "serialNumber": "SN-001", - }, - "materialsProvenance": [ - {"name": "Steel", "massFraction": 0.6}, - {"name": "Plastic", "massFraction": 0.4}, - ], + # Extend valid_dpp_data with additional fields + data = valid_dpp_data.copy() + data["credentialSubject"] = { + "id": "https://example.com/products/001", + "type": ["Product"], + "product": { + "id": "https://example.com/product/001", + "name": "Test Product", + "serialNumber": "SN-001", }, + "materialsProvenance": [ + {"name": "Steel", "massFraction": 0.6}, + {"name": "Plastic", "massFraction": 0.4}, + ], } result = engine.validate(data) assert result.valid is True @@ -362,18 +322,10 @@ def test_validate_with_all_layers_disabled(self): result = engine.validate({"id": "test"}) assert result is not None - def test_validate_dict_input(self): + def test_validate_dict_input(self, valid_dpp_data: dict): """Test validation accepts dict input directly.""" engine = ValidationEngine(layers=["model", "semantic"]) - data = { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", - ], - "id": "https://example.com/dpp", - "issuer": {"id": "https://a.com", "name": "Test"}, - } - result = engine.validate(data) + result = engine.validate(valid_dpp_data) assert result.valid is True def test_validate_path_input_invalid_path(self): @@ -389,10 +341,15 @@ class TestPhase2Features: def test_engine_strict_mode_passed_to_schema_validator(self): """Test ValidationEngine passes strict_mode to SchemaValidator.""" - engine = ValidationEngine(strict_mode=True, layers=["schema"]) + # Use explicit version to ensure validators are initialized immediately + engine = ValidationEngine(schema_version="0.6.1", strict_mode=True, layers=["schema"]) + assert engine._schema_validator is not None assert engine._schema_validator.strict is True - engine_normal = ValidationEngine(strict_mode=False, layers=["schema"]) + engine_normal = ValidationEngine( + schema_version="0.6.1", strict_mode=False, layers=["schema"] + ) + assert engine_normal._schema_validator is not None assert engine_normal._schema_validator.strict is False def test_engine_validate_vocabularies_initializes_loader(self): @@ -442,19 +399,10 @@ def test_input_size_exceeded_returns_error(self): assert result.errors[0].code == "PRS004" assert "exceeds maximum" in result.errors[0].message - def test_input_within_size_limit_passes(self): + def test_input_within_size_limit_passes(self, valid_dpp_data: dict): """Test that input within size limit is processed normally.""" engine = ValidationEngine(max_input_size=10000, layers=["model", "semantic"]) - small_input = json.dumps( - { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", - ], - "id": "https://example.com/dpp", - "issuer": {"id": "https://example.com/issuer", "name": "Test"}, - } - ) + small_input = json.dumps(valid_dpp_data) result = engine.validate(small_input) assert result.valid is True diff --git a/tests/unit/test_validation_layers.py b/tests/unit/test_validation_layers.py new file mode 100644 index 0000000..24f69c0 --- /dev/null +++ b/tests/unit/test_validation_layers.py @@ -0,0 +1,250 @@ +"""Tests for validation layer implementations.""" + +from unittest.mock import MagicMock + +from dppvalidator.validators.layers import ( + JsonLdLayer, + PluginLayer, + SignatureLayer, + ValidationContext, + VocabularyLayer, +) + + +class TestJsonLdLayer: + """Tests for JsonLdLayer.""" + + def test_name_property(self) -> None: + """JsonLdLayer name is 'jsonld'.""" + layer = JsonLdLayer(validator=None) + assert layer.name == "jsonld" + + def test_should_run_without_validator(self) -> None: + """JsonLdLayer should not run when validator is None.""" + layer = JsonLdLayer(validator=None) + context = ValidationContext(parsed_data={}, schema_version="0.6.1") + assert layer.should_run(context) is False + + def test_should_run_with_validator(self) -> None: + """JsonLdLayer should run when validator is provided.""" + mock_validator = MagicMock() + layer = JsonLdLayer(validator=mock_validator) + context = ValidationContext(parsed_data={}, schema_version="0.6.1") + assert layer.should_run(context) is True + + def test_execute_calls_validator(self) -> None: + """JsonLdLayer.execute calls the validator.""" + mock_validator = MagicMock() + mock_result = MagicMock() + mock_validator.validate.return_value = mock_result + + layer = JsonLdLayer(validator=mock_validator) + context = ValidationContext(parsed_data={"test": "data"}, schema_version="0.6.1") + + result = layer.execute(context) + + mock_validator.validate.assert_called_once_with({"test": "data"}) + assert result is mock_result + + +class TestVocabularyLayer: + """Tests for VocabularyLayer.""" + + def test_name_property(self) -> None: + """VocabularyLayer name is 'vocabulary'.""" + layer = VocabularyLayer(loader=None, schema_version="0.6.1") + assert layer.name == "vocabulary" + + def test_should_run_without_loader(self) -> None: + """VocabularyLayer should not run when loader is None.""" + layer = VocabularyLayer(loader=None, schema_version="0.6.1") + context = ValidationContext(parsed_data={}, schema_version="0.6.1") + assert layer.should_run(context) is False + + def test_should_run_without_passport(self) -> None: + """VocabularyLayer should not run when passport is None.""" + mock_loader = MagicMock() + layer = VocabularyLayer(loader=mock_loader, schema_version="0.6.1") + context = ValidationContext(parsed_data={}, schema_version="0.6.1", passport=None) + assert layer.should_run(context) is False + + def test_execute_without_passport_returns_valid(self) -> None: + """VocabularyLayer.execute returns valid result when passport is None.""" + mock_loader = MagicMock() + layer = VocabularyLayer(loader=mock_loader, schema_version="0.6.1") + context = ValidationContext(parsed_data={}, schema_version="0.6.1", passport=None) + + result = layer.execute(context) + + assert result.valid is True + + +class TestSignatureLayer: + """Tests for SignatureLayer.""" + + def test_name_property(self) -> None: + """SignatureLayer name is 'signature'.""" + layer = SignatureLayer(verifier=None, schema_version="0.6.1") + assert layer.name == "signature" + + def test_should_run_without_verifier(self) -> None: + """SignatureLayer should not run when verifier is None.""" + layer = SignatureLayer(verifier=None, schema_version="0.6.1") + context = ValidationContext(parsed_data={}, schema_version="0.6.1") + assert layer.should_run(context) is False + + def test_should_run_with_verifier(self) -> None: + """SignatureLayer should run when verifier is provided.""" + mock_verifier = MagicMock() + layer = SignatureLayer(verifier=mock_verifier, schema_version="0.6.1") + context = ValidationContext(parsed_data={}, schema_version="0.6.1") + assert layer.should_run(context) is True + + def test_execute_calls_verifier(self) -> None: + """SignatureLayer.execute calls the verifier and processes result.""" + mock_verifier = MagicMock() + mock_vc_result = MagicMock() + mock_vc_result.valid = True + mock_vc_result.signature_valid = True + mock_vc_result.issuer_did = "did:web:example.com" + mock_vc_result.verification_method = "did:web:example.com#key-1" + mock_vc_result.errors = [] + mock_vc_result.warnings = [] + mock_verifier.verify.return_value = mock_vc_result + + layer = SignatureLayer(verifier=mock_verifier, schema_version="0.6.1") + context = ValidationContext(parsed_data={"test": "data"}, schema_version="0.6.1") + + result = layer.execute(context) + + mock_verifier.verify.assert_called_once_with({"test": "data"}) + assert result.valid is True + assert result.signature_valid is True + assert result.issuer_did == "did:web:example.com" + + def test_execute_with_errors(self) -> None: + """SignatureLayer.execute processes verification errors.""" + mock_verifier = MagicMock() + mock_vc_result = MagicMock() + mock_vc_result.valid = False + mock_vc_result.signature_valid = False + mock_vc_result.issuer_did = None + mock_vc_result.verification_method = None + mock_vc_result.errors = ["Invalid signature"] + mock_vc_result.warnings = ["Missing proof"] + mock_verifier.verify.return_value = mock_vc_result + + layer = SignatureLayer(verifier=mock_verifier, schema_version="0.6.1") + context = ValidationContext(parsed_data={}, schema_version="0.6.1") + + result = layer.execute(context) + + assert result.valid is False + assert len(result.errors) == 1 + assert len(result.warnings) == 1 + + +class TestPluginLayer: + """Tests for PluginLayer.""" + + def test_name_property(self) -> None: + """PluginLayer name is 'plugin'.""" + layer = PluginLayer(registry=None, schema_version="0.6.1") + assert layer.name == "plugin" + + def test_should_run_without_registry(self) -> None: + """PluginLayer should not run when registry is None.""" + layer = PluginLayer(registry=None, schema_version="0.6.1") + context = ValidationContext(parsed_data={}, schema_version="0.6.1") + assert layer.should_run(context) is False + + def test_should_run_without_passport(self) -> None: + """PluginLayer should not run when passport is None.""" + mock_registry = MagicMock() + layer = PluginLayer(registry=mock_registry, schema_version="0.6.1") + context = ValidationContext(parsed_data={}, schema_version="0.6.1", passport=None) + assert layer.should_run(context) is False + + +class TestValidationContextMethods: + """Tests for ValidationContext helper methods.""" + + def test_should_stop_fail_fast_with_errors(self) -> None: + """should_stop returns True when fail_fast and has errors.""" + context = ValidationContext( + parsed_data={}, + schema_version="0.6.1", + fail_fast=True, + ) + # Add an error to make result invalid + from dppvalidator.validators.results import ValidationError, ValidationResult + + error_result = ValidationResult( + valid=False, + errors=[ + ValidationError( + path="$", message="Error", code="E001", layer="test", severity="error" + ) + ], + schema_version="0.6.1", + ) + context.merge_result(error_result) + + assert context.should_stop() is True + + def test_should_stop_max_errors_reached(self) -> None: + """should_stop returns True when max_errors reached.""" + from dppvalidator.validators.results import ValidationError, ValidationResult + + context = ValidationContext( + parsed_data={}, + schema_version="0.6.1", + max_errors=2, + ) + + # Add multiple errors + errors = [ + ValidationError( + path=f"$.field{i}", + message=f"Error {i}", + code="E001", + layer="test", + severity="error", + ) + for i in range(3) + ] + error_result = ValidationResult( + valid=False, + errors=errors, + schema_version="0.6.1", + ) + context.merge_result(error_result) + + assert context.should_stop() is True + + def test_merge_result_updates_passport(self) -> None: + """merge_result updates passport from layer result.""" + from dppvalidator.validators.results import ValidationResult + + context = ValidationContext(parsed_data={}, schema_version="0.6.1") + mock_passport = MagicMock() + + result_with_passport = ValidationResult( + valid=True, + schema_version="0.6.1", + ) + result_with_passport.passport = mock_passport + + context.merge_result(result_with_passport) + + assert context.passport is mock_passport + + def test_should_stop_returns_false_when_valid(self) -> None: + """should_stop returns False when no errors.""" + context = ValidationContext( + parsed_data={}, + schema_version="0.6.1", + fail_fast=True, + ) + # No errors merged, should not stop + assert context.should_stop() is False diff --git a/tests/unit/test_vc_verification.py b/tests/unit/test_vc_verification.py new file mode 100644 index 0000000..92e5732 --- /dev/null +++ b/tests/unit/test_vc_verification.py @@ -0,0 +1,244 @@ +"""Unit tests for Verifiable Credential verification.""" + +from dppvalidator.verifier.did import DIDDocument, DIDResolver, VerificationMethod +from dppvalidator.verifier.verifier import CredentialVerifier, VerificationResult + + +class TestDIDResolver: + """Tests for DID resolution.""" + + def test_resolve_did_key_ed25519(self) -> None: + """did:key with Ed25519 resolves to self-describing document.""" + resolver = DIDResolver() + # Example Ed25519 did:key (multicodec 0xed01) + did = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + doc = resolver.resolve(did) + + assert doc is not None + assert doc.id == did + assert len(doc.verification_method) == 1 + assert doc.verification_method[0].type == "Ed25519VerificationKey2020" + assert doc.verification_method[0].public_key_jwk is not None + assert doc.verification_method[0].public_key_jwk.get("kty") == "OKP" + assert doc.verification_method[0].public_key_jwk.get("crv") == "Ed25519" + + def test_resolve_did_key_caching(self) -> None: + """Resolved DIDs are cached.""" + resolver = DIDResolver(cache_size=10) + did = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + + doc1 = resolver.resolve(did) + doc2 = resolver.resolve(did) + + assert doc1 is doc2 # Same object from cache + + def test_resolve_unsupported_method(self) -> None: + """Unsupported DID methods return None.""" + resolver = DIDResolver() + doc = resolver.resolve("did:unsupported:12345") + assert doc is None + + def test_clear_cache(self) -> None: + """Cache can be cleared.""" + resolver = DIDResolver() + did = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + + resolver.resolve(did) + assert len(resolver._cache) == 1 + + resolver.clear_cache() + assert len(resolver._cache) == 0 + + +class TestVerificationMethod: + """Tests for VerificationMethod parsing.""" + + def test_key_type_ed25519_from_jwk(self) -> None: + """Ed25519 key type detected from JWK.""" + vm = VerificationMethod( + id="did:key:z6Mk...#z6Mk...", + type="JsonWebKey2020", + controller="did:key:z6Mk...", + public_key_jwk={"kty": "OKP", "crv": "Ed25519", "x": "abc123"}, + ) + assert vm.key_type == "Ed25519" + + def test_key_type_p256_from_jwk(self) -> None: + """P-256 key type detected from JWK.""" + vm = VerificationMethod( + id="did:key:zDn...", + type="JsonWebKey2020", + controller="did:key:zDn...", + public_key_jwk={"kty": "EC", "crv": "P-256", "x": "x", "y": "y"}, + ) + assert vm.key_type == "P-256" + + def test_key_type_ed25519_from_type(self) -> None: + """Ed25519 key type detected from verification method type.""" + vm = VerificationMethod( + id="did:key:z6Mk...", + type="Ed25519VerificationKey2020", + controller="did:key:z6Mk...", + ) + assert vm.key_type == "Ed25519" + + +class TestDIDDocument: + """Tests for DIDDocument operations.""" + + def test_get_verification_method_by_id(self) -> None: + """Verification method can be retrieved by ID.""" + vm = VerificationMethod( + id="did:key:z6Mk...#key-1", + type="Ed25519VerificationKey2020", + controller="did:key:z6Mk...", + ) + doc = DIDDocument( + id="did:key:z6Mk...", + verification_method=[vm], + assertion_method=["did:key:z6Mk...#key-1"], + ) + + found = doc.get_verification_method("did:key:z6Mk...#key-1") + assert found is vm + + def test_get_verification_method_by_fragment(self) -> None: + """Verification method can be found by fragment reference.""" + vm = VerificationMethod( + id="did:key:z6Mk...#key-1", + type="Ed25519VerificationKey2020", + controller="did:key:z6Mk...", + ) + doc = DIDDocument( + id="did:key:z6Mk...", + verification_method=[vm], + ) + + found = doc.get_verification_method("#key-1") + assert found is vm + + def test_get_assertion_methods(self) -> None: + """Assertion methods can be retrieved.""" + vm = VerificationMethod( + id="did:key:z6Mk...#key-1", + type="Ed25519VerificationKey2020", + controller="did:key:z6Mk...", + ) + doc = DIDDocument( + id="did:key:z6Mk...", + verification_method=[vm], + assertion_method=["did:key:z6Mk...#key-1"], + ) + + methods = doc.get_assertion_methods() + assert len(methods) == 1 + assert methods[0] is vm + + +class TestVerificationResult: + """Tests for VerificationResult.""" + + def test_verified_property(self) -> None: + """verified is True only when valid and signature_valid.""" + result = VerificationResult(valid=True, signature_valid=True) + assert result.verified is True + + result = VerificationResult(valid=True, signature_valid=False) + assert result.verified is False + + result = VerificationResult(valid=False, signature_valid=True) + assert result.verified is False + + result = VerificationResult(valid=True, signature_valid=None) + assert result.verified is False + + +class TestCredentialVerifier: + """Tests for CredentialVerifier.""" + + def test_verify_extracts_issuer_string(self) -> None: + """Issuer DID is extracted from string format.""" + verifier = CredentialVerifier() + credential = {"issuer": "did:web:example.com"} + + result = verifier.verify(credential) + assert result.issuer_did == "did:web:example.com" + + def test_verify_extracts_issuer_object(self) -> None: + """Issuer DID is extracted from object format.""" + verifier = CredentialVerifier() + credential = {"issuer": {"id": "did:web:example.com", "name": "Example"}} + + result = verifier.verify(credential) + assert result.issuer_did == "did:web:example.com" + + def test_verify_without_cryptography_returns_warning(self) -> None: + """Without cryptography, verification returns warning but extracts issuer.""" + verifier = CredentialVerifier() + credential = { + "issuer": "did:web:example.com", + "credentialSubject": {"id": "urn:uuid:123"}, + } + + result = verifier.verify(credential) + + # Should always extract issuer + assert result.issuer_did == "did:web:example.com" + # If cryptography not installed, should have warning + # If installed, should check for proof + assert result.valid is True + + +class TestValidationEngineIntegration: + """Tests for ValidationEngine integration with VC verification.""" + + def test_verify_signatures_parameter(self) -> None: + """verify_signatures parameter is stored.""" + from dppvalidator.validators.engine import ValidationEngine + + engine = ValidationEngine(verify_signatures=True) + assert engine.verify_signatures is True + + engine = ValidationEngine(verify_signatures=False) + assert engine.verify_signatures is False + + def test_validation_result_has_signature_fields(self) -> None: + """ValidationResult includes signature verification fields.""" + from dppvalidator.validators.results import ValidationResult + + result = ValidationResult( + valid=True, + signature_valid=True, + issuer_did="did:web:example.com", + verification_method="did:web:example.com#key-1", + ) + + assert result.signature_valid is True + assert result.issuer_did == "did:web:example.com" + assert result.verification_method == "did:web:example.com#key-1" + + result_dict = result.to_dict() + assert result_dict["signature_valid"] is True + assert result_dict["issuer_did"] == "did:web:example.com" + + +class TestModuleExports: + """Tests for verifier module exports.""" + + def test_verifier_module_exports(self) -> None: + """verifier module exports expected classes and functions.""" + from dppvalidator.verifier import ( + CredentialVerifier, + DIDResolver, + SignatureVerifier, + resolve_did, + verify_credential, + verify_signature, + ) + + assert callable(DIDResolver) + assert callable(CredentialVerifier) + assert callable(SignatureVerifier) + assert callable(resolve_did) + assert callable(verify_credential) + assert callable(verify_signature) diff --git a/tests/unit/test_version_mismatch.py b/tests/unit/test_version_mismatch.py new file mode 100644 index 0000000..3d51147 --- /dev/null +++ b/tests/unit/test_version_mismatch.py @@ -0,0 +1,117 @@ +"""Acceptance tests for the VER001 version-mismatch error. + +Phase 3.3 of docs/plans/UNTP_0.7.0_MIGRATION.md introduced VER001 to +prevent the silent field-loss that Pydantic's ``extra='allow'`` setting +would otherwise allow when a payload's declared version doesn't match +the engine's configured version. + +Three behaviours under test: + +1. **Mismatch is rejected.** Engine configured for ``schema_version=A`` + + payload declaring ``B`` ⇒ single ``VER001`` error and stop. No + layer-level errors leak through. +2. **Auto-detect is unaffected.** Engine in ``schema_version='auto'`` + mode never raises VER001 — it adopts the declared version. +3. **Unmarked payloads pass through.** Engine configured for any + version + payload that declares no version (no ``$schema``, no + versioned UNTP context URL) is trusted, no VER001. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from dppvalidator.validators import ValidationEngine + +_FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" +_V07_SAMPLE = _FIXTURES / "upstream" / "v0.7.0" / "samples" / "DigitalProductPassport_instance.json" +_V06_SAMPLE = _FIXTURES / "valid" / "full_dpp.json" + + +def _load(path: Path) -> dict: + if not path.is_file(): # pragma: no cover - vendored in Phase 0 + pytest.skip(f"Vendor {path} via Phase 0 of the migration plan.") + with path.open(encoding="utf-8") as f: + return json.load(f) + + +class TestVer001MismatchFailsFast: + def test_engine_06_rejects_v07_payload_with_VER001(self) -> None: + sample = _load(_V07_SAMPLE) + engine = ValidationEngine( + schema_version="0.6.1", + layers=["schema", "model", "jsonld"], + load_plugins=False, + ) + result = engine.validate(sample) + assert result.valid is False + assert len(result.errors) == 1, ( + f"Expected exactly one VER001 error, got " + f"{[(e.code, e.message[:60]) for e in result.errors]}" + ) + assert result.errors[0].code == "VER001" + assert "0.7.0" in result.errors[0].message + assert "0.6.1" in result.errors[0].message + + def test_engine_07_rejects_v06_payload_with_VER001(self) -> None: + sample = _load(_V06_SAMPLE) + engine = ValidationEngine( + schema_version="0.7.0", + layers=["schema", "model"], + load_plugins=False, + ) + result = engine.validate(sample) + assert result.valid is False + codes = [e.code for e in result.errors] + assert "VER001" in codes + # And we stopped before any layer ran (so no MDL/SCH errors). + assert all(c == "VER001" for c in codes) + + def test_VER001_carries_diagnostic_context(self) -> None: + sample = _load(_V07_SAMPLE) + engine = ValidationEngine(schema_version="0.6.1", layers=["schema"], load_plugins=False) + result = engine.validate(sample) + err = result.errors[0] + assert err.context is not None + assert err.context.get("declared_version") == "0.7.0" + assert err.context.get("configured_version") == "0.6.1" + + +class TestAutoDetectIgnoresVer001: + def test_auto_detect_adopts_declared_version_no_VER001(self) -> None: + sample = _load(_V07_SAMPLE) + engine = ValidationEngine( + schema_version="auto", + layers=["schema", "model", "jsonld"], + load_plugins=False, + ) + result = engine.validate(sample) + # Auto detection should set schema_version to 0.7.0 and validate + # cleanly — VER001 must not fire. + assert all(e.code != "VER001" for e in result.errors), ( + f"Unexpected VER001 in auto mode; errors: {[e.code for e in result.errors]}" + ) + assert result.schema_version == "0.7.0" + + +class TestUnmarkedPayloadsAccepted: + def test_payload_with_no_version_marker_does_not_trip_VER001(self) -> None: + # Strip every version marker (no $schema, no UNTP context URLs). + sample = { + "type": ["DigitalProductPassport", "VerifiableCredential"], + "@context": ["https://www.w3.org/ns/credentials/v2"], + "id": "https://example.com/credentials/dpp-1", + "issuer": {"id": "did:web:example.com", "name": "Example"}, + "validFrom": "2025-01-01T00:00:00Z", + "name": "Unmarked", + "credentialSubject": {}, + } + engine = ValidationEngine(schema_version="0.7.0", layers=["schema"], load_plugins=False) + result = engine.validate(sample) + assert all(e.code != "VER001" for e in result.errors), ( + "VER001 fired on a payload that declares no version — should " + "trust the user's configuration." + ) diff --git a/tests/unit/test_vocabularies.py b/tests/unit/test_vocabularies.py index c982f7c..e67edf6 100644 --- a/tests/unit/test_vocabularies.py +++ b/tests/unit/test_vocabularies.py @@ -4,12 +4,11 @@ import pytest -from dppvalidator.vocabularies import ( +from dppvalidator.vocabularies import VocabularyCache, VocabularyLoader +from dppvalidator.vocabularies.cache import CacheEntry +from dppvalidator.vocabularies.loader import ( VOCABULARIES, - CacheEntry, - VocabularyCache, VocabularyDefinition, - VocabularyLoader, get_bundled_country_codes, get_bundled_unit_codes, ) @@ -425,8 +424,7 @@ def __exit__(self, *args): def get(self, url, **kwargs): # noqa: ARG002 return MockResponse() - monkeypatch.setattr(loader_module, "HAS_HTTPX", True) - monkeypatch.setattr(loader_module, "httpx", type("httpx", (), {"Client": MockClient})) + monkeypatch.setattr(loader_module.httpx, "Client", MockClient) loader = VocabularyLoader(cache_dir=tmp_path, offline_mode=False) vocab_def = VocabularyDefinition( @@ -456,23 +454,7 @@ def __exit__(self, *args): def get(self, url, **kwargs): # noqa: ARG002 raise ConnectionError("Network error") - monkeypatch.setattr(loader_module, "HAS_HTTPX", True) - monkeypatch.setattr(loader_module, "httpx", type("httpx", (), {"Client": MockClient})) - - loader = VocabularyLoader(cache_dir=tmp_path, offline_mode=False) - vocab_def = VocabularyDefinition( - name="TestVocab", - url="https://test.example.com/vocab", - description="Test vocabulary", - ) - result = loader._fetch_vocabulary(vocab_def) - assert result is None - - def test_fetch_vocabulary_no_httpx(self, tmp_path, monkeypatch): - """Test _fetch_vocabulary returns None when httpx not available.""" - from dppvalidator.vocabularies import loader as loader_module - - monkeypatch.setattr(loader_module, "HAS_HTTPX", False) + monkeypatch.setattr(loader_module.httpx, "Client", MockClient) loader = VocabularyLoader(cache_dir=tmp_path, offline_mode=False) vocab_def = VocabularyDefinition( @@ -510,8 +492,7 @@ def get(self, url, **kwargs): # noqa: ARG002 fetch_called[0] = True return MockResponse() - monkeypatch.setattr(loader_module, "HAS_HTTPX", True) - monkeypatch.setattr(loader_module, "httpx", type("httpx", (), {"Client": MockClient})) + monkeypatch.setattr(loader_module.httpx, "Client", MockClient) loader = VocabularyLoader(cache_dir=tmp_path, offline_mode=False) # First call should fetch and cache diff --git a/tests/unit/test_vocabulary_data_imports.py b/tests/unit/test_vocabulary_data_imports.py new file mode 100644 index 0000000..dd0be2a --- /dev/null +++ b/tests/unit/test_vocabulary_data_imports.py @@ -0,0 +1,208 @@ +"""Tests for vocabulary data file imports via importlib.resources. + +These tests ensure that all bundled data files are properly packaged +and accessible at runtime. +""" + +import json + +import pytest + + +class TestVocabularyDataPackages: + """Tests that vocabulary data packages are importable.""" + + def test_vocabularies_data_package_exists(self): + """Test that vocabularies.data package is importable.""" + from importlib.resources import files + + data_pkg = files("dppvalidator.vocabularies.data") + assert data_pkg is not None + + def test_vocabularies_data_ontologies_package_exists(self): + """Test that vocabularies.data.ontologies package is importable.""" + from importlib.resources import files + + ontologies_pkg = files("dppvalidator.vocabularies.data.ontologies") + assert ontologies_pkg is not None + + def test_vocabularies_data_schemas_package_exists(self): + """Test that vocabularies.data.schemas package is importable.""" + from importlib.resources import files + + schemas_pkg = files("dppvalidator.vocabularies.data.schemas") + assert schemas_pkg is not None + + +class TestBundledJsonFiles: + """Tests for bundled JSON vocabulary files.""" + + def test_countries_json_exists_and_valid(self): + """Test countries.json is bundled and valid JSON.""" + from importlib.resources import files + + data_pkg = files("dppvalidator.vocabularies.data") + countries_file = data_pkg.joinpath("countries.json") + content = countries_file.read_text(encoding="utf-8") + data = json.loads(content) + + assert isinstance(data, dict) + assert "codes" in data or isinstance(data, list) or len(data) > 0 + + def test_units_json_exists_and_valid(self): + """Test units.json is bundled and valid JSON.""" + from importlib.resources import files + + data_pkg = files("dppvalidator.vocabularies.data") + units_file = data_pkg.joinpath("units.json") + content = units_file.read_text(encoding="utf-8") + data = json.loads(content) + + assert isinstance(data, (dict, list)) + + def test_materials_json_exists_and_valid(self): + """Test materials.json is bundled and valid JSON.""" + from importlib.resources import files + + data_pkg = files("dppvalidator.vocabularies.data") + materials_file = data_pkg.joinpath("materials.json") + content = materials_file.read_text(encoding="utf-8") + data = json.loads(content) + + assert isinstance(data, (dict, list)) + + def test_hs_codes_json_exists_and_valid(self): + """Test hs_codes.json is bundled and valid JSON.""" + from importlib.resources import files + + data_pkg = files("dppvalidator.vocabularies.data") + hs_codes_file = data_pkg.joinpath("hs_codes.json") + content = hs_codes_file.read_text(encoding="utf-8") + data = json.loads(content) + + assert isinstance(data, (dict, list)) + + +class TestBundledOntologyFiles: + """Tests for bundled ontology TTL files.""" + + @pytest.mark.parametrize( + "filename", + [ + "eudpp_core_v1.3.1.ttl", + "product_dpp_v1.7.1.ttl", + "actors_roles_v1.5.1.ttl", + "lca_v2.0.ttl", + "soc_v1.4.7.ttl", + ], + ) + def test_ontology_ttl_files_exist(self, filename): + """Test that ontology TTL files are bundled.""" + from importlib.resources import files + + ontologies_pkg = files("dppvalidator.vocabularies.data.ontologies") + ttl_file = ontologies_pkg.joinpath(filename) + + # Check file exists and has content + content = ttl_file.read_text(encoding="utf-8") + assert len(content) > 0 + assert "@prefix" in content or "prefix" in content.lower() + + +class TestBundledSchemaFiles: + """Tests for bundled CIRPASS schema files.""" + + def test_cirpass_schema_json_exists(self): + """Test cirpass_dpp_schema.json is bundled and valid.""" + from importlib.resources import files + + schemas_pkg = files("dppvalidator.vocabularies.data.schemas") + schema_file = schemas_pkg.joinpath("cirpass_dpp_schema.json") + content = schema_file.read_text(encoding="utf-8") + data = json.loads(content) + + assert isinstance(data, dict) + assert "$schema" in data or "type" in data or "properties" in data + + def test_cirpass_shacl_ttl_exists(self): + """Test cirpass_dpp_shacl.ttl is bundled.""" + from importlib.resources import files + + schemas_pkg = files("dppvalidator.vocabularies.data.schemas") + shacl_file = schemas_pkg.joinpath("cirpass_dpp_shacl.ttl") + content = shacl_file.read_text(encoding="utf-8") + + assert len(content) > 0 + assert "sh:" in content or "shacl" in content.lower() or "@prefix" in content + + def test_cirpass_openapi_json_exists(self): + """Test cirpass_dpp_openapi.json is bundled and valid.""" + from importlib.resources import files + + schemas_pkg = files("dppvalidator.vocabularies.data.schemas") + openapi_file = schemas_pkg.joinpath("cirpass_dpp_openapi.json") + content = openapi_file.read_text(encoding="utf-8") + data = json.loads(content) + + assert isinstance(data, dict) + + +class TestVocabularyLoaderIntegration: + """Integration tests for vocabulary loading functions.""" + + def test_get_bundled_country_codes_returns_data(self): + """Test get_bundled_country_codes returns non-empty frozenset.""" + from dppvalidator.vocabularies.loader import get_bundled_country_codes + + codes = get_bundled_country_codes() + assert isinstance(codes, frozenset) + assert len(codes) > 0 + + def test_get_bundled_unit_codes_returns_data(self): + """Test get_bundled_unit_codes returns non-empty frozenset.""" + from dppvalidator.vocabularies.loader import get_bundled_unit_codes + + codes = get_bundled_unit_codes() + assert isinstance(codes, frozenset) + assert len(codes) > 0 + + +class TestCodeListsLoader: + """Tests for code_lists module loading.""" + + def test_get_material_codes_returns_data(self): + """Test loading material codes via code_lists module.""" + from dppvalidator.vocabularies.code_lists import get_material_codes + + codes = get_material_codes() + assert isinstance(codes, frozenset) + assert len(codes) > 0 + + def test_get_hs_codes_returns_data(self): + """Test loading HS codes via code_lists module.""" + from dppvalidator.vocabularies.code_lists import get_hs_codes + + codes = get_hs_codes() + assert isinstance(codes, frozenset) + assert len(codes) > 0 + + def test_is_valid_material_code(self): + """Test material code validation.""" + from dppvalidator.vocabularies.code_lists import ( + get_material_codes, + is_valid_material_code, + ) + + codes = get_material_codes() + if codes: + sample_code = next(iter(codes)) + assert is_valid_material_code(sample_code) is True + + def test_is_valid_hs_code(self): + """Test HS code validation.""" + from dppvalidator.vocabularies.code_lists import get_hs_codes, is_valid_hs_code + + codes = get_hs_codes() + if codes: + sample_code = next(iter(codes)) + assert is_valid_hs_code(sample_code) is True diff --git a/tests/unit/test_vocabulary_loader.py b/tests/unit/test_vocabulary_loader.py new file mode 100644 index 0000000..73eb7de --- /dev/null +++ b/tests/unit/test_vocabulary_loader.py @@ -0,0 +1,58 @@ +"""Tests for vocabulary loader error handling.""" + +from unittest.mock import MagicMock, patch + + +class TestBundledVocabularyLoading: + """Tests for bundled vocabulary loading error paths.""" + + def test_load_bundled_vocabulary_file_not_found(self) -> None: + """Missing bundled vocabulary file returns empty frozenset.""" + from dppvalidator.vocabularies.loader import _load_bundled_vocabulary + + mock_data_files = MagicMock() + mock_path = MagicMock() + mock_path.read_text.side_effect = FileNotFoundError("File not found") + mock_data_files.joinpath.return_value = mock_path + + with patch( + "dppvalidator.vocabularies.loader._get_data_files", + return_value=mock_data_files, + ): + result = _load_bundled_vocabulary("nonexistent") + + assert result == frozenset() + + def test_load_bundled_vocabulary_invalid_json(self) -> None: + """Invalid JSON in bundled vocabulary returns empty frozenset.""" + from dppvalidator.vocabularies.loader import _load_bundled_vocabulary + + mock_data_files = MagicMock() + mock_path = MagicMock() + mock_path.read_text.return_value = "{ invalid json }" + mock_data_files.joinpath.return_value = mock_path + + with patch( + "dppvalidator.vocabularies.loader._get_data_files", + return_value=mock_data_files, + ): + result = _load_bundled_vocabulary("invalid") + + assert result == frozenset() + + def test_load_bundled_vocabulary_os_error(self) -> None: + """OSError during bundled vocabulary load returns empty frozenset.""" + from dppvalidator.vocabularies.loader import _load_bundled_vocabulary + + mock_data_files = MagicMock() + mock_path = MagicMock() + mock_path.read_text.side_effect = OSError("Permission denied") + mock_data_files.joinpath.return_value = mock_path + + with patch( + "dppvalidator.vocabularies.loader._get_data_files", + return_value=mock_data_files, + ): + result = _load_bundled_vocabulary("protected") + + assert result == frozenset() diff --git a/tests/unit/test_watch_command.py b/tests/unit/test_watch_command.py new file mode 100644 index 0000000..2ffea9c --- /dev/null +++ b/tests/unit/test_watch_command.py @@ -0,0 +1,490 @@ +"""Unit tests for watch command - behavior-focused testing.""" + +import argparse +import json +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +from dppvalidator.cli.commands.watch import ( + EXIT_ERROR, + EXIT_SUCCESS, + FileWatcher, + WatchLoop, + WatchStats, + _discover_files, + _find_json_files, + _is_valid_json_file, + _print_summary, + _validate_args, + _validate_files, + add_parser, + run, +) +from dppvalidator.cli.console import Console +from dppvalidator.validators import ValidationEngine + + +class TestWatchStats: + """Tests for WatchStats dataclass behavior.""" + + def test_default_values(self) -> None: + """WatchStats has correct defaults.""" + stats = WatchStats() + assert stats.total_validations == 0 + assert stats.total_valid == 0 + assert stats.total_invalid == 0 + assert stats.total_errors == 0 + assert stats.total_warnings == 0 + assert stats.files_watched == 0 + + def test_duration_minutes_calculation(self) -> None: + """duration_minutes calculates elapsed time correctly.""" + stats = WatchStats() + stats.start_time = time.time() - 120 # 2 minutes ago + assert 1.9 <= stats.duration_minutes <= 2.1 + + def test_record_validation_valid(self) -> None: + """record_validation updates counters for valid result.""" + stats = WatchStats() + stats.record_validation(valid=True, errors=0, warnings=1) + + assert stats.total_validations == 1 + assert stats.total_valid == 1 + assert stats.total_invalid == 0 + assert stats.total_errors == 0 + assert stats.total_warnings == 1 + + def test_record_validation_invalid(self) -> None: + """record_validation updates counters for invalid result.""" + stats = WatchStats() + stats.record_validation(valid=False, errors=3, warnings=2) + + assert stats.total_validations == 1 + assert stats.total_valid == 0 + assert stats.total_invalid == 1 + assert stats.total_errors == 3 + assert stats.total_warnings == 2 + + +class TestFileWatcher: + """Tests for FileWatcher behavior.""" + + def test_initialize_tracks_file_mtimes(self, tmp_path: Path) -> None: + """initialize() tracks modification times for given files.""" + f1 = tmp_path / "file1.json" + f1.write_text("{}") + f2 = tmp_path / "file2.json" + f2.write_text("{}") + + watcher = FileWatcher(watch_path=tmp_path, pattern="*.json") + watcher.initialize([f1, f2]) + + assert f1 in watcher._file_mtimes + assert f2 in watcher._file_mtimes + + def test_initialize_skips_nonexistent_files(self, tmp_path: Path) -> None: + """initialize() skips files that don't exist.""" + nonexistent = tmp_path / "nonexistent.json" + watcher = FileWatcher(watch_path=tmp_path, pattern="*.json") + watcher.initialize([nonexistent]) + + assert nonexistent not in watcher._file_mtimes + + def test_get_changed_files_detects_modifications(self, tmp_path: Path) -> None: + """get_changed_files() detects modified files.""" + f = tmp_path / "test.json" + f.write_text("{}") + + watcher = FileWatcher(watch_path=tmp_path, pattern="*.json") + watcher.initialize([f]) + + # Simulate file modification + time.sleep(0.01) + watcher._file_mtimes[f] = watcher._file_mtimes[f] - 1 # Fake older mtime + + console = MagicMock(spec=Console) + changed = watcher.get_changed_files(console) + + assert f in changed + + def test_debouncing_prevents_rapid_changes(self, tmp_path: Path) -> None: + """Rapid changes within debounce window are ignored.""" + f = tmp_path / "test.json" + f.write_text("{}") + + watcher = FileWatcher(watch_path=tmp_path, pattern="*.json", debounce_ms=1000) + watcher.initialize([f]) + + # Record a recent change + current_time = time.time() + watcher._last_change_time[f] = current_time - 0.1 # 100ms ago + + assert watcher._is_debounced(f, current_time) is True + + def test_debouncing_allows_after_threshold(self, tmp_path: Path) -> None: + """Changes after debounce threshold are allowed.""" + f = tmp_path / "test.json" + f.write_text("{}") + + watcher = FileWatcher(watch_path=tmp_path, pattern="*.json", debounce_ms=100) + watcher.initialize([f]) + + # Record an old change + current_time = time.time() + watcher._last_change_time[f] = current_time - 1.0 # 1 second ago + + assert watcher._is_debounced(f, current_time) is False + + def test_scan_detects_new_files(self, tmp_path: Path) -> None: + """_scan_for_new_and_removed() detects new files.""" + f1 = tmp_path / "file1.json" + f1.write_text("{}") + + watcher = FileWatcher(watch_path=tmp_path, pattern="*.json") + watcher.initialize([f1]) + + # Create a new file + f2 = tmp_path / "file2.json" + f2.write_text("{}") + + console = MagicMock(spec=Console) + changed: list[Path] = [] + watcher._scan_for_new_and_removed(changed, console) + + assert f2 in changed + console.print.assert_called() + + def test_scan_detects_removed_files(self, tmp_path: Path) -> None: + """_scan_for_new_and_removed() detects removed files.""" + f = tmp_path / "test.json" + f.write_text("{}") + + watcher = FileWatcher(watch_path=tmp_path, pattern="*.json") + watcher.initialize([f]) + + # Remove the file + f.unlink() + + console = MagicMock(spec=Console) + changed: list[Path] = [] + watcher._scan_for_new_and_removed(changed, console) + + assert f not in watcher._file_mtimes + console.print.assert_called() + + +class TestValidateArgs: + """Tests for argument validation.""" + + def test_valid_path_returns_path(self, tmp_path: Path) -> None: + """Valid path returns resolved Path object.""" + args = argparse.Namespace(path=str(tmp_path), interval=1.0) + console = MagicMock(spec=Console) + + result = _validate_args(args, console) + assert result == tmp_path.resolve() + + def test_nonexistent_path_returns_none(self) -> None: + """Nonexistent path returns None with error.""" + args = argparse.Namespace(path="/nonexistent/path", interval=1.0) + console = MagicMock(spec=Console) + + result = _validate_args(args, console) + assert result is None + console.print_error.assert_called() + + def test_interval_too_small_returns_none(self, tmp_path: Path) -> None: + """Interval < 0.1 returns None with error.""" + args = argparse.Namespace(path=str(tmp_path), interval=0.05) + console = MagicMock(spec=Console) + + result = _validate_args(args, console) + assert result is None + console.print_error.assert_called_with("Interval must be at least 0.1 seconds") + + def test_interval_too_large_returns_none(self, tmp_path: Path) -> None: + """Interval > 60 returns None with error.""" + args = argparse.Namespace(path=str(tmp_path), interval=61.0) + console = MagicMock(spec=Console) + + result = _validate_args(args, console) + assert result is None + console.print_error.assert_called_with("Interval cannot exceed 60 seconds") + + +class TestDiscoverFiles: + """Tests for file discovery.""" + + def test_single_file_returns_list(self, tmp_path: Path) -> None: + """Single valid JSON file returns list with one file.""" + f = tmp_path / "test.json" + f.write_text("{}") + + console = MagicMock(spec=Console) + result = _discover_files(f, "*.json", console) + + assert result == [f] + + def test_invalid_json_file_returns_none(self, tmp_path: Path) -> None: + """Invalid JSON file returns None.""" + f = tmp_path / "test.txt" + f.write_text("not json") + + console = MagicMock(spec=Console) + result = _discover_files(f, "*.json", console) + + assert result is None + + def test_directory_finds_matching_files(self, tmp_path: Path) -> None: + """Directory discovers files matching pattern.""" + (tmp_path / "a.json").write_text("{}") + (tmp_path / "b.json").write_text("{}") + (tmp_path / "c.txt").write_text("text") + + console = MagicMock(spec=Console) + result = _discover_files(tmp_path, "*.json", console) + + assert result is not None + assert len(result) == 2 + + def test_empty_directory_returns_none(self, tmp_path: Path) -> None: + """Empty directory returns None with error.""" + console = MagicMock(spec=Console) + result = _discover_files(tmp_path, "*.json", console) + + assert result is None + console.print_error.assert_called() + + +class TestFindJsonFiles: + """Tests for JSON file discovery.""" + + def test_finds_nested_json_files(self, tmp_path: Path) -> None: + """Finds JSON files in subdirectories.""" + subdir = tmp_path / "subdir" + subdir.mkdir() + (subdir / "nested.json").write_text("{}") + (tmp_path / "root.json").write_text("{}") + + files = _find_json_files(tmp_path, "*.json") + + assert len(files) == 2 + + def test_excludes_hidden_directories(self, tmp_path: Path) -> None: + """Excludes files in hidden directories.""" + hidden = tmp_path / ".hidden" + hidden.mkdir() + (hidden / "secret.json").write_text("{}") + (tmp_path / "visible.json").write_text("{}") + + files = _find_json_files(tmp_path, "*.json") + + assert len(files) == 1 + assert files[0].name == "visible.json" + + +class TestIsValidJsonFile: + """Tests for JSON file validation.""" + + def test_valid_json_returns_true(self, tmp_path: Path) -> None: + """Valid JSON file returns True.""" + f = tmp_path / "test.json" + f.write_text('{"key": "value"}') + assert _is_valid_json_file(f) is True + + def test_invalid_json_returns_false(self, tmp_path: Path) -> None: + """Invalid JSON content returns False.""" + f = tmp_path / "test.json" + f.write_text("not json") + assert _is_valid_json_file(f) is False + + def test_non_json_extension_returns_false(self, tmp_path: Path) -> None: + """Non-.json extension returns False.""" + f = tmp_path / "test.txt" + f.write_text("{}") + assert _is_valid_json_file(f) is False + + +class TestValidateFiles: + """Tests for file validation behavior.""" + + def test_validates_files_and_updates_stats(self, tmp_path: Path) -> None: + """_validate_files processes files and updates stats.""" + f = tmp_path / "test.json" + f.write_text( + json.dumps( + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://test.uncefact.org/vocabulary/untp/dpp/0.6.1/", + ], + "id": "https://example.com/dpp", + "issuer": {"id": "https://example.com/issuer"}, + } + ) + ) + + engine = ValidationEngine(layers=["model"]) + console = MagicMock(spec=Console) + stats = WatchStats() + + _validate_files([f], engine, console, stats) + + assert stats.total_validations == 1 + console.print.assert_called() + + def test_handles_json_decode_error(self, tmp_path: Path) -> None: + """_validate_files handles invalid JSON gracefully.""" + f = tmp_path / "bad.json" + f.write_text("{invalid json") + + engine = ValidationEngine(layers=["model"]) + console = MagicMock(spec=Console) + stats = WatchStats() + + _validate_files([f], engine, console, stats) + + # Should print error but not crash + assert any("PRS002" in str(call) for call in console.print.call_args_list) + + +class TestPrintSummary: + """Tests for summary printing.""" + + def test_prints_session_summary(self) -> None: + """_print_summary outputs session statistics.""" + stats = WatchStats() + stats.total_validations = 10 + stats.total_valid = 8 + stats.total_invalid = 2 + stats.total_errors = 5 + stats.total_warnings = 3 + stats.files_watched = 4 + + console = MagicMock(spec=Console) + _print_summary(console, stats) + + calls = [str(call) for call in console.print.call_args_list] + assert any("10" in c for c in calls) + assert any("8" in c for c in calls) + + +class TestAddParser: + """Tests for argument parser setup.""" + + def test_adds_watch_subparser(self) -> None: + """add_parser creates watch subparser with expected arguments.""" + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers() + + watch_parser = add_parser(subparsers) + + assert watch_parser is not None + # Parse with defaults + args = watch_parser.parse_args([]) + assert args.path == "." + assert args.pattern == "*.json" + assert args.interval == 1.0 + + +class TestRunCommand: + """Tests for the main run command.""" + + def test_run_with_nonexistent_path_returns_error(self) -> None: + """run() with nonexistent path returns EXIT_ERROR.""" + args = argparse.Namespace( + path="/nonexistent/path", + pattern="*.json", + interval=1.0, + strict=False, + schema_version="0.6.1", + ) + console = MagicMock(spec=Console) + + result = run(args, console) + assert result == EXIT_ERROR + + def test_run_with_no_matching_files_returns_error(self, tmp_path: Path) -> None: + """run() with no matching files returns EXIT_ERROR.""" + args = argparse.Namespace( + path=str(tmp_path), + pattern="*.json", + interval=1.0, + strict=False, + schema_version="0.6.1", + ) + console = MagicMock(spec=Console) + + result = run(args, console) + assert result == EXIT_ERROR + + +class TestWatchLoop: + """Tests for WatchLoop behavior.""" + + def test_run_handles_keyboard_interrupt(self, tmp_path: Path) -> None: + """WatchLoop.run() handles KeyboardInterrupt gracefully.""" + f = tmp_path / "test.json" + f.write_text("{}") + + watcher = FileWatcher(watch_path=tmp_path, pattern="*.json") + engine = ValidationEngine(layers=["model"]) + console = MagicMock(spec=Console) + stats = WatchStats() + + loop = WatchLoop( + watcher=watcher, + engine=engine, + console=console, + stats=stats, + interval=0.1, + ) + + # Patch _loop to raise KeyboardInterrupt + with patch.object(loop, "_loop", side_effect=KeyboardInterrupt): + result = loop.run([f]) + + assert result == EXIT_SUCCESS + + def test_print_header_sets_files_watched(self, tmp_path: Path) -> None: + """_print_header sets files_watched in stats.""" + watcher = FileWatcher(watch_path=tmp_path, pattern="*.json") + engine = ValidationEngine(layers=["model"]) + console = MagicMock(spec=Console) + stats = WatchStats() + + loop = WatchLoop( + watcher=watcher, + engine=engine, + console=console, + stats=stats, + interval=1.0, + ) + + files = [tmp_path / "a.json", tmp_path / "b.json"] + loop._print_header(files) + + assert stats.files_watched == 2 + + def test_on_change_validates_changed_files(self, tmp_path: Path) -> None: + """_on_change validates the provided files.""" + f = tmp_path / "test.json" + f.write_text('{"id": "test"}') + + watcher = FileWatcher(watch_path=tmp_path, pattern="*.json") + engine = ValidationEngine(layers=["model"]) + console = MagicMock(spec=Console) + stats = WatchStats() + + loop = WatchLoop( + watcher=watcher, + engine=engine, + console=console, + stats=stats, + interval=1.0, + ) + + loop._on_change([f]) + + assert stats.total_validations == 1 diff --git a/uv.lock b/uv.lock index 54f1c53..4dbd06f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,11 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14'", "python_full_version == '3.13.*'", - "python_full_version < '3.13'", + "python_full_version == '3.12.*'", + "python_full_version < '3.12'", ] [[package]] @@ -30,6 +31,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + [[package]] name = "attrs" version = "25.4.0" @@ -71,6 +85,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/e3/a4fa1946722c4c7b063cc25043a12d9ce9b4323777f89643be74cef2993c/backrefs-6.1-py39-none-any.whl", hash = "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0", size = 381058, upload-time = "2025-11-15T14:52:06.698Z" }, ] +[[package]] +name = "base58" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/45/8ae61209bb9015f516102fa559a2914178da1d5868428bd86a1b4421141d/base58-2.1.1.tar.gz", hash = "sha256:c5d0cb3f5b6e81e8e35da5754388ddcc6d0d14b6c6a132cb93d69ed580a7278c", size = 6528, upload-time = "2021-10-30T22:12:17.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/45/ec96b29162a402fc4c1c5512d114d7b3787b9d1c2ec241d9568b4816ee23/base58-2.1.1-py3-none-any.whl", hash = "sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2", size = 5621, upload-time = "2021-10-30T22:12:16.658Z" }, +] + [[package]] name = "boolean-py" version = "5.0" @@ -98,6 +121,43 @@ filecache = [ { name = "filelock" }, ] +[[package]] +name = "cachetools" +version = "6.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/91/d9ae9a66b01102a18cd16db0cf4cd54187ffe10f0865cc80071a4104fbb3/cachetools-6.2.6.tar.gz", hash = "sha256:16c33e1f276b9a9c0b49ab5782d901e3ad3de0dd6da9bf9bcd29ac5672f2f9e6", size = 32363, upload-time = "2026-01-27T20:32:59.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" }, +] + +[[package]] +name = "cairocffi" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/c5/1a4dc131459e68a173cbdab5fad6b524f53f9c1ef7861b7698e998b837cc/cairocffi-1.7.1.tar.gz", hash = "sha256:2e48ee864884ec4a3a34bfa8c9ab9999f688286eb714a15a43ec9d068c36557b", size = 88096, upload-time = "2024-06-18T10:56:06.741Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl", hash = "sha256:9803a0e11f6c962f3b0ae2ec8ba6ae45e957a146a004697a1ac1bbf16b073b3f", size = 75611, upload-time = "2024-06-18T10:55:59.489Z" }, +] + +[[package]] +name = "cairosvg" +version = "2.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cairocffi" }, + { name = "cssselect2" }, + { name = "defusedxml" }, + { name = "pillow" }, + { name = "tinycss2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/b9/5106168bd43d7cd8b7cc2a2ee465b385f14b63f4c092bb89eee2d48c8e67/cairosvg-2.8.2.tar.gz", hash = "sha256:07cbf4e86317b27a92318a4cac2a4bb37a5e9c1b8a27355d06874b22f85bef9f", size = 8398590, upload-time = "2025-05-15T06:56:32.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/48/816bd4aaae93dbf9e408c58598bc32f4a8c65f4b86ab560864cb3ee60adb/cairosvg-2.8.2-py3-none-any.whl", hash = "sha256:eab46dad4674f33267a671dce39b64be245911c901c70d65d2b7b0821e852bf5", size = 45773, upload-time = "2025-05-15T06:56:28.552Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -107,6 +167,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -116,6 +258,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] +[[package]] +name = "chardet" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -330,6 +481,96 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "46.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" }, + { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" }, + { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" }, + { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" }, + { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" }, + { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" }, + { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" }, + { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" }, + { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" }, + { url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" }, + { url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" }, + { url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" }, + { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" }, + { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" }, + { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" }, + { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" }, + { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" }, + { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" }, + { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" }, + { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" }, + { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" }, + { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" }, + { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" }, + { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" }, + { url = "https://files.pythonhosted.org/packages/59/e0/f9c6c53e1f2a1c2507f00f2faba00f01d2f334b35b0fbfe5286715da2184/cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b", size = 3476316, upload-time = "2026-01-28T00:24:24.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/7a/f8d2d13227a9a1a9fe9c7442b057efecffa41f1e3c51d8622f26b9edbe8f/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da", size = 4216693, upload-time = "2026-01-28T00:24:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/c5/de/3787054e8f7972658370198753835d9d680f6cd4a39df9f877b57f0dd69c/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80", size = 4382765, upload-time = "2026-01-28T00:24:27.577Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/60e0afb019973ba6a0b322e86b3d61edf487a4f5597618a430a2a15f2d22/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822", size = 4216066, upload-time = "2026-01-28T00:24:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/81/8e/bf4a0de294f147fee66f879d9bae6f8e8d61515558e3d12785dd90eca0be/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947", size = 4382025, upload-time = "2026-01-28T00:24:30.681Z" }, + { url = "https://files.pythonhosted.org/packages/79/f4/9ceb90cfd6a3847069b0b0b353fd3075dc69b49defc70182d8af0c4ca390/cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3", size = 3406043, upload-time = "2026-01-28T00:24:32.236Z" }, +] + +[[package]] +name = "cssselect2" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tinycss2" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/86/fd7f58fc498b3166f3a7e8e0cddb6e620fe1da35b02248b1bd59e95dbaaa/cssselect2-0.8.0.tar.gz", hash = "sha256:7674ffb954a3b46162392aee2a3a0aedb2e14ecf99fcc28644900f4e6e3e9d3a", size = 35716, upload-time = "2025-03-05T14:46:07.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/e7/aa315e6a749d9b96c2504a1ba0ba031ba2d0517e972ce22682e3fccecb09/cssselect2-0.8.0-py3-none-any.whl", hash = "sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e", size = 15454, upload-time = "2025-03-05T14:46:06.463Z" }, +] + +[[package]] +name = "cyclonedx-bom" +version = "7.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "chardet" }, + { name = "cyclonedx-python-lib", extra = ["validation"] }, + { name = "packageurl-python" }, + { name = "packaging" }, + { name = "pip-requirements-parser" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/b4/d6a3eee8622389893480758ada629842b8667e326ec8da311dbc7f5087f4/cyclonedx_bom-7.2.1.tar.gz", hash = "sha256:ead9923a23c71426bcc83ea371c87945b85f76c31728625dde35ecfe0fa2e712", size = 4416994, upload-time = "2025-10-29T15:31:47.238Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/3a/c30b624eb2b5f33d9f5a55f23a65f529c875897639961cf51d2af8a5e527/cyclonedx_bom-7.2.1-py3-none-any.whl", hash = "sha256:fdeabfec4f3274085320a40d916fc4dc2850abef7da5953d544eb5c98aa4afdd", size = 60696, upload-time = "2025-10-29T15:31:45.594Z" }, +] + [[package]] name = "cyclonedx-python-lib" version = "11.6.0" @@ -346,6 +587,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/1b/534ad8a5e0f9470522811a8e5a9bc5d328fb7738ba29faf357467a4ef6d0/cyclonedx_python_lib-11.6.0-py3-none-any.whl", hash = "sha256:94f4aae97db42a452134dafdddcfab9745324198201c4777ed131e64c8380759", size = 511157, upload-time = "2025-12-02T12:28:44.158Z" }, ] +[package.optional-dependencies] +validation = [ + { name = "jsonschema", extra = ["format-nongpl"] }, + { name = "lxml" }, + { name = "referencing" }, +] + [[package]] name = "defusedxml" version = "0.7.1" @@ -366,79 +614,91 @@ wheels = [ [[package]] name = "dppvalidator" -version = "0.1.0" +version = "0.3.2" source = { editable = "." } dependencies = [ + { name = "base58" }, + { name = "cryptography" }, + { name = "httpx" }, + { name = "jsonschema" }, { name = "pydantic" }, + { name = "pyjwt" }, + { name = "pyld" }, ] [package.optional-dependencies] all = [ - { name = "httpx" }, - { name = "jsonschema" }, + { name = "pyshacl" }, + { name = "rdflib" }, { name = "rich" }, ] cli = [ { name = "rich" }, ] -http = [ - { name = "httpx" }, -] -jsonschema = [ - { name = "jsonschema" }, +rdf = [ + { name = "pyshacl" }, + { name = "rdflib" }, ] [package.dev-dependencies] dev = [ - { name = "httpx" }, + { name = "cyclonedx-bom" }, { name = "hypothesis" }, - { name = "jsonschema" }, { name = "mutmut" }, { name = "pip-audit" }, + { name = "pip-licenses" }, { name = "pre-commit" }, + { name = "pyshacl" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "rdflib" }, { name = "rich" }, { name = "ruff" }, { name = "ty" }, ] docs = [ { name = "mkdocs" }, - { name = "mkdocs-material" }, + { name = "mkdocs-material", extra = ["imaging"] }, { name = "mkdocstrings", extra = ["python"] }, ] [package.metadata] requires-dist = [ - { name = "httpx", marker = "extra == 'all'", specifier = ">=0.28.0" }, - { name = "httpx", marker = "extra == 'http'", specifier = ">=0.28.0" }, - { name = "jsonschema", marker = "extra == 'all'", specifier = ">=4.23.0" }, - { name = "jsonschema", marker = "extra == 'jsonschema'", specifier = ">=4.23.0" }, + { name = "base58", specifier = ">=2.1.0" }, + { name = "cryptography", specifier = ">=43.0.0" }, + { name = "dppvalidator", extras = ["cli", "rdf"], marker = "extra == 'all'" }, + { name = "httpx", specifier = ">=0.28.0" }, + { name = "jsonschema", specifier = ">=4.23.0" }, { name = "pydantic", specifier = ">=2.12.5" }, - { name = "rich", marker = "extra == 'all'", specifier = ">=13.0.0" }, + { name = "pyjwt", specifier = ">=2.9.0" }, + { name = "pyld", specifier = ">=2.0.4" }, + { name = "pyshacl", marker = "extra == 'rdf'", specifier = ">=0.25.0" }, + { name = "rdflib", marker = "extra == 'rdf'", specifier = ">=7.0.0" }, { name = "rich", marker = "extra == 'cli'", specifier = ">=13.0.0" }, ] -provides-extras = ["http", "jsonschema", "cli", "all"] +provides-extras = ["cli", "rdf", "all"] [package.metadata.requires-dev] dev = [ - { name = "httpx", specifier = ">=0.28.0" }, + { name = "cyclonedx-bom", specifier = ">=7.0.0" }, { name = "hypothesis", specifier = ">=6.100.0" }, - { name = "jsonschema", specifier = ">=4.23.0" }, { name = "mutmut", specifier = ">=3.0.0" }, { name = "pip-audit", specifier = ">=2.7.0" }, + { name = "pip-licenses", specifier = ">=5.0.0" }, { name = "pre-commit", specifier = ">=3.6.0" }, + { name = "pyshacl", specifier = ">=0.25.0" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "pytest-cov", specifier = ">=4.1.0" }, + { name = "rdflib", specifier = ">=7.0.0" }, { name = "rich", specifier = ">=13.0.0" }, { name = "ruff", specifier = ">=0.8.0" }, { name = "ty", specifier = ">=0.0.1a0" }, ] docs = [ { name = "mkdocs", specifier = ">=1.6.0" }, - { name = "mkdocs-material", specifier = ">=9.5.0" }, + { name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.5.0" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=0.27.0" }, ] @@ -447,7 +707,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -463,6 +723,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, ] +[[package]] +name = "fqdn" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, +] + +[[package]] +name = "frozendict" +version = "2.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/b2/2a3d1374b7780999d3184e171e25439a8358c47b481f68be883c14086b4c/frozendict-2.4.7.tar.gz", hash = "sha256:e478fb2a1391a56c8a6e10cc97c4a9002b410ecd1ac28c18d780661762e271bd", size = 317082, upload-time = "2025-11-11T22:40:14.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/bd/920b1c5ff1df427a5fc3fd4c2f13b0b0e720c3d57fafd80557094c1fefe0/frozendict-2.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bd37c087a538944652363cfd77fb7abe8100cc1f48afea0b88b38bf0f469c3d2", size = 59848, upload-time = "2025-11-11T22:37:10.964Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9c/e3e186925b1d84f816d458be4e2ea785bbeba15fd2e9e85c5ae7e7a90421/frozendict-2.4.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2b96f224a5431889f04b2bc99c0e9abe285679464273ead83d7d7f2a15907d35", size = 38164, upload-time = "2025-11-11T22:37:12.622Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/af931d88c51ee2fcbf8c817557dcb975133a188f1b44bfa82caa940beeab/frozendict-2.4.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5c1781f28c4bbb177644b3cb6d5cf7da59be374b02d91cdde68d1d5ef32e046b", size = 38341, upload-time = "2025-11-11T22:37:13.611Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/c1fd4f736758cf93939cc3b7c8399fe1db0c121881431d41fcdbae344343/frozendict-2.4.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8a06f6c3d3b8d487226fdde93f621e04a54faecc5bf5d9b16497b8f9ead0ac3e", size = 112882, upload-time = "2025-11-11T22:37:15.098Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b0/304294f7cd099582a98d63e7a9cec34a9905d07f7628b42fc3f9c9a9bc94/frozendict-2.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b809d1c861436a75b2b015dbfd94f6154fa4e7cb0a70e389df1d5f6246b21d1e", size = 120482, upload-time = "2025-11-11T22:37:16.182Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/689212ea4124fcbd097c0ac02c2c6a4e345ccc132d9104d054ff6b43ab64/frozendict-2.4.7-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75eefdf257a84ea73d553eb80d0abbff0af4c9df62529e4600fd3f96ff17eeb3", size = 113527, upload-time = "2025-11-11T22:37:17.389Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9b/38a762f4e76903efd4340454cac2820f583929457822111ef6a00ff1a3f4/frozendict-2.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a4d2b27d8156922c9739dd2ff4f3934716e17cfd1cf6fb61aa17af7d378555e9", size = 130068, upload-time = "2025-11-11T22:37:18.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/41/9751e9ec1a2e810e8f961aea4f8958953157478daff6b868277ab7c5ef8c/frozendict-2.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ebd953c41408acfb8041ff9e6c3519c09988fb7e007df7ab6b56e229029d788", size = 126184, upload-time = "2025-11-11T22:37:19.789Z" }, + { url = "https://files.pythonhosted.org/packages/71/be/b179b5f200cb0f52debeccc63b786cabcc408c4542f47c4245f978ad36e3/frozendict-2.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c64d34b802912ee6d107936e970b90750385a1fdfd38d310098b2918ba4cbf2", size = 120168, upload-time = "2025-11-11T22:37:20.929Z" }, + { url = "https://files.pythonhosted.org/packages/25/c2/1536bc363dbce414e6b632f496aa8219c0db459a99eeafa02eba380e4cfa/frozendict-2.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:294a7d7d51dd979021a8691b46aedf9bd4a594ce3ed33a4bdf0a712d6929d712", size = 114997, upload-time = "2025-11-11T22:37:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/29/63/3e9efb490c00a0bf3c7bbf72fc73c90c4a6ebe30595e0fc44f59182b2ae7/frozendict-2.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f65d1b90e9ddc791ea82ef91a9ae0ab27ef6c0cfa88fadfa0e5ca5a22f8fa22f", size = 117292, upload-time = "2025-11-11T22:37:22.978Z" }, + { url = "https://files.pythonhosted.org/packages/5e/66/d25b1e94f9b0e64025d5cadc77b9b857737ebffd8963ee91de7c5a06415a/frozendict-2.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:82d5272d08451bcef6fb6235a0a04cf1816b6b6815cec76be5ace1de17e0c1a4", size = 110656, upload-time = "2025-11-11T22:38:37.652Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5d/0e7e3294e18bf41d38dbc9ee82539be607c8d26e763ae12d9e41f03f2dae/frozendict-2.4.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5943c3f683d3f32036f6ca975e920e383d85add1857eee547742de9c1f283716", size = 113225, upload-time = "2025-11-11T22:38:38.631Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fb/b72c9b261ac7a7803528aa63bba776face8ad8d39cc4ca4825ddaa7777a9/frozendict-2.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:88c6bea948da03087035bb9ca9625305d70e084aa33f11e17048cb7dda4ca293", size = 126713, upload-time = "2025-11-11T22:38:39.588Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d9/e13af40bd9ef27b5c9ba10b0e31b03acac9468236b878dab030c75102a47/frozendict-2.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ffd1a9f9babec9119712e76a39397d8aa0d72ef8c4ccad917c6175d7e7f81b74", size = 114166, upload-time = "2025-11-11T22:38:41.073Z" }, + { url = "https://files.pythonhosted.org/packages/40/2b/435583b11f5332cd3eb479d0a67a87bc9247c8b094169b07bd8f0777fc48/frozendict-2.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0ff6f57854cc8aa8b30947ec005f9246d96e795a78b21441614e85d39b708822", size = 121542, upload-time = "2025-11-11T22:38:42.199Z" }, + { url = "https://files.pythonhosted.org/packages/38/25/097f3c0dc916d7c76f782cb65544e683ff3940a0ed997fc32efdb0989c45/frozendict-2.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d774df483c12d6cba896eb9a1337bbc5ad3f564eb18cfaaee3e95fb4402f2a86", size = 118610, upload-time = "2025-11-11T22:38:43.339Z" }, + { url = "https://files.pythonhosted.org/packages/61/d1/6964158524484d7f3410386ff27cbc8f33ef06f8d9ee0e188348efb9a139/frozendict-2.4.7-cp310-cp310-win32.whl", hash = "sha256:a10d38fa300f6bef230fae1fdb4bc98706b78c8a3a2f3140fde748469ef3cfe8", size = 34547, upload-time = "2025-11-11T22:38:44.327Z" }, + { url = "https://files.pythonhosted.org/packages/94/27/c22d614332c61ace4406542787edafaf7df533c6f02d1de8979d35492587/frozendict-2.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:dd518f300e5eb6a8827bee380f2e1a31c01dc0af069b13abdecd4e5769bd8a97", size = 37693, upload-time = "2025-11-11T22:38:45.571Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d8/9d6604357b1816586612e0e89bab6d8a9c029e95e199862dc99ce8ae2ed5/frozendict-2.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:3842cfc2d69df5b9978f2e881b7678a282dbdd6846b11b5159f910bc633cbe4f", size = 35563, upload-time = "2025-11-11T22:38:46.642Z" }, + { url = "https://files.pythonhosted.org/packages/38/74/f94141b38a51a553efef7f510fc213894161ae49b88bffd037f8d2a7cb2f/frozendict-2.4.7-py3-none-any.whl", hash = "sha256:972af65924ea25cf5b4d9326d549e69a9a4918d8a76a9d3a7cd174d98b237550", size = 16264, upload-time = "2025-11-11T22:40:12.836Z" }, +] + [[package]] name = "ghp-import" version = "2.1.0" @@ -496,6 +794,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "html5rdf" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/55/1b839c43f5ed8207e17a9a02d8b395179520b8b4f00c00a41e113bc205ca/html5rdf-1.2.1.tar.gz", hash = "sha256:ace9b420ce52995bb4f05e7425eedf19e433c981dfe7a831ab391e2fa2e1a195", size = 287899, upload-time = "2024-10-30T05:06:56.384Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/c9/f6e1e8567660bc5b0aba281f2b0017b2a7665fcad6bf3ed67286a0c72cd4/html5rdf-1.2.1-py2.py3-none-any.whl", hash = "sha256:1f519121bc366af3e485310dc8041d2e86e5173c1a320fac3dc9d2604069b83e", size = 109765, upload-time = "2024-10-30T05:06:52.507Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -555,6 +862,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -564,6 +883,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "isodate" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, +] + +[[package]] +name = "isoduration" +version = "20.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -576,6 +916,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -591,6 +940,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] +[package.optional-dependencies] +format-nongpl = [ + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "rfc3987-syntax" }, + { name = "uri-template" }, + { name = "webcolors" }, +] + [[package]] name = "jsonschema-specifications" version = "2025.9.1" @@ -603,6 +965,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + [[package]] name = "libcst" version = "1.8.6" @@ -695,6 +1066,130 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, ] +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/8a/f8192a08237ef2fb1b19733f709db88a4c43bc8ab8357f01cb41a27e7f6a/lxml-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e77dd455b9a16bbd2a5036a63ddbd479c19572af81b624e79ef422f929eef388", size = 8590589, upload-time = "2025-09-22T04:00:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/27bcd07ae17ff5e5536e8d88f4c7d581b48963817a13de11f3ac3329bfa2/lxml-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d444858b9f07cefff6455b983aea9a67f7462ba1f6cbe4a21e8bf6791bf2153", size = 4629671, upload-time = "2025-09-22T04:00:15.411Z" }, + { url = "https://files.pythonhosted.org/packages/02/5a/a7d53b3291c324e0b6e48f3c797be63836cc52156ddf8f33cd72aac78866/lxml-6.0.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f952dacaa552f3bb8834908dddd500ba7d508e6ea6eb8c52eb2d28f48ca06a31", size = 4999961, upload-time = "2025-09-22T04:00:17.619Z" }, + { url = "https://files.pythonhosted.org/packages/f5/55/d465e9b89df1761674d8672bb3e4ae2c47033b01ec243964b6e334c6743f/lxml-6.0.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71695772df6acea9f3c0e59e44ba8ac50c4f125217e84aab21074a1a55e7e5c9", size = 5157087, upload-time = "2025-09-22T04:00:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/38/3073cd7e3e8dfc3ba3c3a139e33bee3a82de2bfb0925714351ad3d255c13/lxml-6.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f68764f35fd78d7c4cc4ef209a184c38b65440378013d24b8aecd327c3e0c8", size = 5067620, upload-time = "2025-09-22T04:00:21.877Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d3/1e001588c5e2205637b08985597827d3827dbaaece16348c8822bfe61c29/lxml-6.0.2-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:058027e261afed589eddcfe530fcc6f3402d7fd7e89bfd0532df82ebc1563dba", size = 5406664, upload-time = "2025-09-22T04:00:23.714Z" }, + { url = "https://files.pythonhosted.org/packages/20/cf/cab09478699b003857ed6ebfe95e9fb9fa3d3c25f1353b905c9b73cfb624/lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8ffaeec5dfea5881d4c9d8913a32d10cfe3923495386106e4a24d45300ef79c", size = 5289397, upload-time = "2025-09-22T04:00:25.544Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/02a2d0c38ac9a8b9f9e5e1bbd3f24b3f426044ad618b552e9549ee91bd63/lxml-6.0.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:f2e3b1a6bb38de0bc713edd4d612969dd250ca8b724be8d460001a387507021c", size = 4772178, upload-time = "2025-09-22T04:00:27.602Z" }, + { url = "https://files.pythonhosted.org/packages/56/87/e1ceadcc031ec4aa605fe95476892d0b0ba3b7f8c7dcdf88fdeff59a9c86/lxml-6.0.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d6690ec5ec1cce0385cb20896b16be35247ac8c2046e493d03232f1c2414d321", size = 5358148, upload-time = "2025-09-22T04:00:29.323Z" }, + { url = "https://files.pythonhosted.org/packages/fe/13/5bb6cf42bb228353fd4ac5f162c6a84fd68a4d6f67c1031c8cf97e131fc6/lxml-6.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2a50c3c1d11cad0ebebbac357a97b26aa79d2bcaf46f256551152aa85d3a4d1", size = 5112035, upload-time = "2025-09-22T04:00:31.061Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e2/ea0498552102e59834e297c5c6dff8d8ded3db72ed5e8aad77871476f073/lxml-6.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3efe1b21c7801ffa29a1112fab3b0f643628c30472d507f39544fd48e9549e34", size = 4799111, upload-time = "2025-09-22T04:00:33.11Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9e/8de42b52a73abb8af86c66c969b3b4c2a96567b6ac74637c037d2e3baa60/lxml-6.0.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:59c45e125140b2c4b33920d21d83681940ca29f0b83f8629ea1a2196dc8cfe6a", size = 5351662, upload-time = "2025-09-22T04:00:35.237Z" }, + { url = "https://files.pythonhosted.org/packages/28/a2/de776a573dfb15114509a37351937c367530865edb10a90189d0b4b9b70a/lxml-6.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:452b899faa64f1805943ec1c0c9ebeaece01a1af83e130b69cdefeda180bb42c", size = 5314973, upload-time = "2025-09-22T04:00:37.086Z" }, + { url = "https://files.pythonhosted.org/packages/50/a0/3ae1b1f8964c271b5eec91db2043cf8c6c0bce101ebb2a633b51b044db6c/lxml-6.0.2-cp310-cp310-win32.whl", hash = "sha256:1e786a464c191ca43b133906c6903a7e4d56bef376b75d97ccbb8ec5cf1f0a4b", size = 3611953, upload-time = "2025-09-22T04:00:39.224Z" }, + { url = "https://files.pythonhosted.org/packages/d1/70/bd42491f0634aad41bdfc1e46f5cff98825fb6185688dc82baa35d509f1a/lxml-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:dacf3c64ef3f7440e3167aa4b49aa9e0fb99e0aa4f9ff03795640bf94531bcb0", size = 4032695, upload-time = "2025-09-22T04:00:41.402Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d0/05c6a72299f54c2c561a6c6cbb2f512e047fca20ea97a05e57931f194ac4/lxml-6.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:45f93e6f75123f88d7f0cfd90f2d05f441b808562bf0bc01070a00f53f5028b5", size = 3680051, upload-time = "2025-09-22T04:00:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, + { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, + { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, + { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, + { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, + { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, + { url = "https://files.pythonhosted.org/packages/e7/9c/780c9a8fce3f04690b374f72f41306866b0400b9d0fdf3e17aaa37887eed/lxml-6.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e748d4cf8fef2526bb2a589a417eba0c8674e29ffcb570ce2ceca44f1e567bf6", size = 3939264, upload-time = "2025-09-22T04:04:32.892Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5a/1ab260c00adf645d8bf7dec7f920f744b032f69130c681302821d5debea6/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4ddb1049fa0579d0cbd00503ad8c58b9ab34d1254c77bc6a5576d96ec7853dba", size = 4216435, upload-time = "2025-09-22T04:04:34.907Z" }, + { url = "https://files.pythonhosted.org/packages/f2/37/565f3b3d7ffede22874b6d86be1a1763d00f4ea9fc5b9b6ccb11e4ec8612/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb233f9c95f83707dae461b12b720c1af9c28c2d19208e1be03387222151daf5", size = 4325913, upload-time = "2025-09-22T04:04:37.205Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/f3a1b169b2fb9d03467e2e3c0c752ea30e993be440a068b125fc7dd248b0/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc456d04db0515ce3320d714a1eac7a97774ff0849e7718b492d957da4631dd4", size = 4269357, upload-time = "2025-09-22T04:04:39.322Z" }, + { url = "https://files.pythonhosted.org/packages/77/a2/585a28fe3e67daa1cf2f06f34490d556d121c25d500b10082a7db96e3bcd/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2613e67de13d619fd283d58bda40bff0ee07739f624ffee8b13b631abf33083d", size = 4412295, upload-time = "2025-09-22T04:04:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/a57dd8bcebd7c69386c20263830d4fa72d27e6b72a229ef7a48e88952d9a/lxml-6.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:24a8e756c982c001ca8d59e87c80c4d9dcd4d9b44a4cbeb8d9be4482c514d41d", size = 3516913, upload-time = "2025-09-22T04:04:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, + { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, + { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, + { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, +] + [[package]] name = "markdown" version = "3.10.1" @@ -910,6 +1405,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/32/ed071cb721aca8c227718cffcf7bd539620e9799bbf2619e90c757bfd030/mkdocs_material-9.7.1-py3-none-any.whl", hash = "sha256:3f6100937d7d731f87f1e3e3b021c97f7239666b9ba1151ab476cabb96c60d5c", size = 9297166, upload-time = "2025-12-18T09:48:56.664Z" }, ] +[package.optional-dependencies] +imaging = [ + { name = "cairosvg" }, + { name = "pillow" }, +] + [[package]] name = "mkdocs-material-extensions" version = "1.3.1" @@ -1044,6 +1545,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "owlrl" +version = "7.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rdflib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/fc/ce12482d096d65fff01af58f555a6f25e9dbf416fad5d99f91eaab0e11ca/owlrl-7.1.4.tar.gz", hash = "sha256:60bd4067e346b9111f0a2924565afe97ac6595b98b2bbe953928b5113971daf7", size = 44420, upload-time = "2025-07-29T00:17:27.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/78/f857ff1a7207e967dc5e8414bbcc15e0aa5cf45f693b1d2ebe2afb3eb1ce/owlrl-7.1.4-py3-none-any.whl", hash = "sha256:e78b46020169783345636da93a467d318f18700c483184dd15e885850cf64775", size = 51981, upload-time = "2025-07-29T00:17:26.229Z" }, +] + [[package]] name = "packageurl-python" version = "0.17.6" @@ -1055,11 +1568,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.0" +version = "25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] @@ -1080,6 +1593,108 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/5d/45a3553a253ac8763f3561371432a90bdbe6000fbdcf1397ffe502aa206c/pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860", size = 5316554, upload-time = "2025-07-01T09:13:39.342Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c8/67c12ab069ef586a25a4a79ced553586748fad100c77c0ce59bb4983ac98/pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad", size = 4686548, upload-time = "2025-07-01T09:13:41.835Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bd/6741ebd56263390b382ae4c5de02979af7f8bd9807346d068700dd6d5cf9/pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0", size = 5859742, upload-time = "2025-07-03T13:09:47.439Z" }, + { url = "https://files.pythonhosted.org/packages/ca/0b/c412a9e27e1e6a829e6ab6c2dca52dd563efbedf4c9c6aa453d9a9b77359/pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b", size = 7633087, upload-time = "2025-07-03T13:09:51.796Z" }, + { url = "https://files.pythonhosted.org/packages/59/9d/9b7076aaf30f5dd17e5e5589b2d2f5a5d7e30ff67a171eb686e4eecc2adf/pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50", size = 5963350, upload-time = "2025-07-01T09:13:43.865Z" }, + { url = "https://files.pythonhosted.org/packages/f0/16/1a6bf01fb622fb9cf5c91683823f073f053005c849b1f52ed613afcf8dae/pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae", size = 6631840, upload-time = "2025-07-01T09:13:46.161Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e6/6ff7077077eb47fde78739e7d570bdcd7c10495666b6afcd23ab56b19a43/pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9", size = 6074005, upload-time = "2025-07-01T09:13:47.829Z" }, + { url = "https://files.pythonhosted.org/packages/c3/3a/b13f36832ea6d279a697231658199e0a03cd87ef12048016bdcc84131601/pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e", size = 6708372, upload-time = "2025-07-01T09:13:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e4/61b2e1a7528740efbc70b3d581f33937e38e98ef3d50b05007267a55bcb2/pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6", size = 6277090, upload-time = "2025-07-01T09:13:53.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d3/60c781c83a785d6afbd6a326ed4d759d141de43aa7365725cbcd65ce5e54/pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f", size = 6985988, upload-time = "2025-07-01T09:13:55.699Z" }, + { url = "https://files.pythonhosted.org/packages/9f/28/4f4a0203165eefb3763939c6789ba31013a2e90adffb456610f30f613850/pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f", size = 2422899, upload-time = "2025-07-01T09:13:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" }, + { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" }, + { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" }, + { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" }, + { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" }, + { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" }, + { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" }, + { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" }, + { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" }, + { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8b/209bd6b62ce8367f47e68a218bffac88888fdf2c9fcf1ecadc6c3ec1ebc7/pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967", size = 5270556, upload-time = "2025-07-01T09:16:09.961Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e6/231a0b76070c2cfd9e260a7a5b504fb72da0a95279410fa7afd99d9751d6/pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe", size = 4654625, upload-time = "2025-07-01T09:16:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/13/f4/10cf94fda33cb12765f2397fc285fa6d8eb9c29de7f3185165b702fc7386/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c", size = 4874207, upload-time = "2025-07-03T13:11:10.201Z" }, + { url = "https://files.pythonhosted.org/packages/72/c9/583821097dc691880c92892e8e2d41fe0a5a3d6021f4963371d2f6d57250/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25", size = 6583939, upload-time = "2025-07-03T13:11:15.68Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8e/5c9d410f9217b12320efc7c413e72693f48468979a013ad17fd690397b9a/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27", size = 4957166, upload-time = "2025-07-01T09:16:13.74Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/78347dbe13219991877ffb3a91bf09da8317fbfcd4b5f9140aeae020ad71/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a", size = 5581482, upload-time = "2025-07-01T09:16:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/d9/28/1000353d5e61498aaeaaf7f1e4b49ddb05f2c6575f9d4f9f914a3538b6e1/pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f", size = 6984596, upload-time = "2025-07-01T09:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" }, + { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" }, + { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" }, + { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, +] + [[package]] name = "pip" version = "25.3" @@ -1122,6 +1737,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/f3/4888f895c02afa085630a3a3329d1b18b998874642ad4c530e9a4d7851fe/pip_audit-2.10.0-py3-none-any.whl", hash = "sha256:16e02093872fac97580303f0848fa3ad64f7ecf600736ea7835a2b24de49613f", size = 61518, upload-time = "2025-12-01T23:42:39.193Z" }, ] +[[package]] +name = "pip-licenses" +version = "5.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prettytable" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/4c/b4be9024dae3b5b3c0a6c58cc1d4a35fffe51c3adb835350cb7dcd43b5cd/pip_licenses-5.5.1.tar.gz", hash = "sha256:7df370e6e5024a3f7449abf8e4321ef868ba9a795698ad24ab6851f3e7fc65a7", size = 49108, upload-time = "2026-01-27T21:46:41.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/a3/0b369cdffef3746157712804f1ded9856c75aa060217ee206f742c74e753/pip_licenses-5.5.1-py3-none-any.whl", hash = "sha256:ed5e229a93760e529cfa7edaec6630b5a2cd3874c1bddb8019e5f18a723fdead", size = 22108, upload-time = "2026-01-27T21:46:39.766Z" }, +] + [[package]] name = "pip-requirements-parser" version = "32.0.1" @@ -1169,6 +1797,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] +[[package]] +name = "prettytable" +version = "3.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/45/b0847d88d6cfeb4413566738c8bbf1e1995fad3d42515327ff32cc1eb578/prettytable-3.17.0.tar.gz", hash = "sha256:59f2590776527f3c9e8cf9fe7b66dd215837cca96a9c39567414cbc632e8ddb0", size = 67892, upload-time = "2025-11-14T17:33:20.212Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-py3-none-any.whl", hash = "sha256:aad69b294ddbe3e1f95ef8886a060ed1666a0b83018bbf56295f6f226c43d287", size = 34433, upload-time = "2025-11-14T17:33:19.093Z" }, +] + [[package]] name = "py-serializable" version = "2.1.0" @@ -1181,6 +1821,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -1323,6 +1972,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[[package]] +name = "pyld" +version = "2.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "frozendict" }, + { name = "lxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/0b/d97dddcc079d4961aa38bec1ad444b8a3e39ea0fd5627682cac25d452c82/PyLD-2.0.4.tar.gz", hash = "sha256:311e350f0dbc964311c79c28e86f84e195a81d06fef5a6f6ac2a4f6391ceeacc", size = 70976, upload-time = "2024-02-16T17:35:51.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/cd/80760be197a4bd08e7c136ef4bcb4a2c63fc799d8d91f4c177b21183135e/PyLD-2.0.4-py3-none-any.whl", hash = "sha256:6dab9905644616df33f8755489fc9b354ed7d832d387b7d1974b4fbd3b8d2a89", size = 70868, upload-time = "2024-02-16T17:35:49Z" }, +] + [[package]] name = "pymdown-extensions" version = "10.20.1" @@ -1345,6 +2017,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] +[[package]] +name = "pyshacl" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "owlrl" }, + { name = "packaging" }, + { name = "prettytable" }, + { name = "rdflib", extra = ["html"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/2d/8eaada41b9b57c028a54494688e45cfeefd6756098a6bf1bfa2dd9470cdf/pyshacl-0.31.0.tar.gz", hash = "sha256:327950875a5bb0d1a15c246a8a272b2dbf6bc9b96e28cfa8fdbfa4d73aadc0ba", size = 1406151, upload-time = "2026-01-16T06:34:06.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/3b/ebd7c9595fcdf176555aaf2fd2254f4d890658334ca3556b611e579f8294/pyshacl-0.31.0-py3-none-any.whl", hash = "sha256:5cae2184401d956b67deebb00e3c78ab7052784741a730e52e309e33c8a0b9a5", size = 1297210, upload-time = "2026-01-16T06:34:03.679Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -1503,6 +2191,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579, upload-time = "2025-06-10T15:32:14.34Z" }, ] +[[package]] +name = "rdflib" +version = "7.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "isodate", marker = "python_full_version < '3.11'" }, + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/1b/4cd9a29841951371304828d13282e27a5f25993702c7c87dcb7e0604bd25/rdflib-7.5.0.tar.gz", hash = "sha256:663083443908b1830e567350d72e74d9948b310f827966358d76eebdc92bf592", size = 4903859, upload-time = "2025-11-28T05:51:54.562Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/20/35d2baebacf357b562bd081936b66cd845775442973cb033a377fd639a84/rdflib-7.5.0-py3-none-any.whl", hash = "sha256:b011dfc40d0fc8a44252e906dcd8fc806a7859bc231be190c37e9568a31ac572", size = 587215, upload-time = "2025-11-28T05:51:38.178Z" }, +] + +[package.optional-dependencies] +html = [ + { name = "html5rdf" }, +] + [[package]] name = "referencing" version = "0.37.0" @@ -1532,6 +2238,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rfc3986-validator" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760, upload-time = "2019-10-28T16:00:19.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" }, +] + +[[package]] +name = "rfc3987-syntax" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lark" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d", size = 14239, upload-time = "2025-07-18T01:05:05.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, +] + [[package]] name = "rich" version = "14.3.1" @@ -1812,6 +2551,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/9c/4169ccffed6d53f78e3175eae0cd649990071c6e24b6ad8830812ebab726/textual-7.4.0-py3-none-any.whl", hash = "sha256:41a066cae649654d4ecfe53b8316f5737c0042d1693ce50690b769a7840780ac", size = 717985, upload-time = "2026-01-25T19:57:02.966Z" }, ] +[[package]] +name = "tinycss2" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, +] + [[package]] name = "toml" version = "0.10.2" @@ -1929,6 +2680,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + [[package]] name = "uc-micro-py" version = "1.0.3" @@ -1938,6 +2698,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, ] +[[package]] +name = "uri-template" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -1993,3 +2762,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] + +[[package]] +name = "wcwidth" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/62/a7c072fbfefb2980a00f99ca994279cb9ecf310cb2e6b2a4d2a28fe192b3/wcwidth-0.5.3.tar.gz", hash = "sha256:53123b7af053c74e9fe2e92ac810301f6139e64379031f7124574212fb3b4091", size = 157587, upload-time = "2026-01-31T03:52:10.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/c1/d73f12f8cdb1891334a2ccf7389eed244d3941e74d80dd220badb937f3fb/wcwidth-0.5.3-py3-none-any.whl", hash = "sha256:d584eff31cd4753e1e5ff6c12e1edfdb324c995713f75d26c29807bb84bf649e", size = 92981, upload-time = "2026-01-31T03:52:09.14Z" }, +] + +[[package]] +name = "webcolors" +version = "25.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] From e4ee27d13168848fab4209f4bc5efd9fb3308d5d Mon Sep 17 00:00:00 2001 From: Matthias Brenninkmeijer Date: Fri, 8 May 2026 02:09:57 +0200 Subject: [PATCH 03/12] Remove vulnerabilities --- pyproject.toml | 12 +- uv.lock | 369 ++++++++++++++++++++++++------------------------- 2 files changed, 191 insertions(+), 190 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a2f50c4..e36415c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,8 +50,12 @@ dependencies = [ "httpx>=0.28.0", "jsonschema>=4.23.0", "pyld>=2.0.4", - "cryptography>=43.0.0", - "PyJWT>=2.9.0", + # cryptography: floor lifted to >=46.0.7 to pull in fixes for + # CVE-2026-26007 (46.0.5), CVE-2026-34073 (46.0.6), + # CVE-2026-39892 (46.0.7). + "cryptography>=46.0.7", + # PyJWT: floor lifted to >=2.12.0 for CVE-2026-32597. + "PyJWT>=2.12.0", "base58>=2.1.0", ] @@ -92,8 +96,8 @@ include = [ [dependency-groups] dev = [ - # Testing - "pytest>=8.0.0", + # Testing — pytest >=9.0.3 closes CVE-2025-71176. + "pytest>=9.0.3", "pytest-cov>=4.1.0", "pytest-asyncio>=0.24.0", "hypothesis>=6.100.0", diff --git a/uv.lock b/uv.lock index 4dbd06f..de5c304 100644 --- a/uv.lock +++ b/uv.lock @@ -483,62 +483,62 @@ toml = [ [[package]] name = "cryptography" -version = "46.0.4" +version = "48.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" }, - { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" }, - { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" }, - { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" }, - { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" }, - { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" }, - { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" }, - { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" }, - { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" }, - { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" }, - { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" }, - { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" }, - { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" }, - { url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" }, - { url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" }, - { url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" }, - { url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" }, - { url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" }, - { url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" }, - { url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" }, - { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" }, - { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" }, - { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" }, - { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" }, - { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" }, - { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" }, - { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" }, - { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" }, - { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" }, - { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" }, - { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" }, - { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" }, - { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" }, - { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" }, - { url = "https://files.pythonhosted.org/packages/59/e0/f9c6c53e1f2a1c2507f00f2faba00f01d2f334b35b0fbfe5286715da2184/cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b", size = 3476316, upload-time = "2026-01-28T00:24:24.144Z" }, - { url = "https://files.pythonhosted.org/packages/27/7a/f8d2d13227a9a1a9fe9c7442b057efecffa41f1e3c51d8622f26b9edbe8f/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da", size = 4216693, upload-time = "2026-01-28T00:24:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/c5/de/3787054e8f7972658370198753835d9d680f6cd4a39df9f877b57f0dd69c/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80", size = 4382765, upload-time = "2026-01-28T00:24:27.577Z" }, - { url = "https://files.pythonhosted.org/packages/8a/5f/60e0afb019973ba6a0b322e86b3d61edf487a4f5597618a430a2a15f2d22/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822", size = 4216066, upload-time = "2026-01-28T00:24:29.056Z" }, - { url = "https://files.pythonhosted.org/packages/81/8e/bf4a0de294f147fee66f879d9bae6f8e8d61515558e3d12785dd90eca0be/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947", size = 4382025, upload-time = "2026-01-28T00:24:30.681Z" }, - { url = "https://files.pythonhosted.org/packages/79/f4/9ceb90cfd6a3847069b0b0b353fd3075dc69b49defc70182d8af0c4ca390/cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3", size = 3406043, upload-time = "2026-01-28T00:24:32.236Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, ] [[package]] @@ -666,12 +666,12 @@ docs = [ [package.metadata] requires-dist = [ { name = "base58", specifier = ">=2.1.0" }, - { name = "cryptography", specifier = ">=43.0.0" }, + { name = "cryptography", specifier = ">=46.0.7" }, { name = "dppvalidator", extras = ["cli", "rdf"], marker = "extra == 'all'" }, { name = "httpx", specifier = ">=0.28.0" }, { name = "jsonschema", specifier = ">=4.23.0" }, { name = "pydantic", specifier = ">=2.12.5" }, - { name = "pyjwt", specifier = ">=2.9.0" }, + { name = "pyjwt", specifier = ">=2.12.0" }, { name = "pyld", specifier = ">=2.0.4" }, { name = "pyshacl", marker = "extra == 'rdf'", specifier = ">=0.25.0" }, { name = "rdflib", marker = "extra == 'rdf'", specifier = ">=7.0.0" }, @@ -688,7 +688,7 @@ dev = [ { name = "pip-licenses", specifier = ">=5.0.0" }, { name = "pre-commit", specifier = ">=3.6.0" }, { name = "pyshacl", specifier = ">=0.25.0" }, - { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "pytest-cov", specifier = ">=4.1.0" }, { name = "rdflib", specifier = ">=7.0.0" }, @@ -1068,126 +1068,120 @@ wheels = [ [[package]] name = "lxml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/8a/f8192a08237ef2fb1b19733f709db88a4c43bc8ab8357f01cb41a27e7f6a/lxml-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e77dd455b9a16bbd2a5036a63ddbd479c19572af81b624e79ef422f929eef388", size = 8590589, upload-time = "2025-09-22T04:00:10.51Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/27bcd07ae17ff5e5536e8d88f4c7d581b48963817a13de11f3ac3329bfa2/lxml-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d444858b9f07cefff6455b983aea9a67f7462ba1f6cbe4a21e8bf6791bf2153", size = 4629671, upload-time = "2025-09-22T04:00:15.411Z" }, - { url = "https://files.pythonhosted.org/packages/02/5a/a7d53b3291c324e0b6e48f3c797be63836cc52156ddf8f33cd72aac78866/lxml-6.0.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f952dacaa552f3bb8834908dddd500ba7d508e6ea6eb8c52eb2d28f48ca06a31", size = 4999961, upload-time = "2025-09-22T04:00:17.619Z" }, - { url = "https://files.pythonhosted.org/packages/f5/55/d465e9b89df1761674d8672bb3e4ae2c47033b01ec243964b6e334c6743f/lxml-6.0.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71695772df6acea9f3c0e59e44ba8ac50c4f125217e84aab21074a1a55e7e5c9", size = 5157087, upload-time = "2025-09-22T04:00:19.868Z" }, - { url = "https://files.pythonhosted.org/packages/62/38/3073cd7e3e8dfc3ba3c3a139e33bee3a82de2bfb0925714351ad3d255c13/lxml-6.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f68764f35fd78d7c4cc4ef209a184c38b65440378013d24b8aecd327c3e0c8", size = 5067620, upload-time = "2025-09-22T04:00:21.877Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d3/1e001588c5e2205637b08985597827d3827dbaaece16348c8822bfe61c29/lxml-6.0.2-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:058027e261afed589eddcfe530fcc6f3402d7fd7e89bfd0532df82ebc1563dba", size = 5406664, upload-time = "2025-09-22T04:00:23.714Z" }, - { url = "https://files.pythonhosted.org/packages/20/cf/cab09478699b003857ed6ebfe95e9fb9fa3d3c25f1353b905c9b73cfb624/lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8ffaeec5dfea5881d4c9d8913a32d10cfe3923495386106e4a24d45300ef79c", size = 5289397, upload-time = "2025-09-22T04:00:25.544Z" }, - { url = "https://files.pythonhosted.org/packages/a3/84/02a2d0c38ac9a8b9f9e5e1bbd3f24b3f426044ad618b552e9549ee91bd63/lxml-6.0.2-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:f2e3b1a6bb38de0bc713edd4d612969dd250ca8b724be8d460001a387507021c", size = 4772178, upload-time = "2025-09-22T04:00:27.602Z" }, - { url = "https://files.pythonhosted.org/packages/56/87/e1ceadcc031ec4aa605fe95476892d0b0ba3b7f8c7dcdf88fdeff59a9c86/lxml-6.0.2-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d6690ec5ec1cce0385cb20896b16be35247ac8c2046e493d03232f1c2414d321", size = 5358148, upload-time = "2025-09-22T04:00:29.323Z" }, - { url = "https://files.pythonhosted.org/packages/fe/13/5bb6cf42bb228353fd4ac5f162c6a84fd68a4d6f67c1031c8cf97e131fc6/lxml-6.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2a50c3c1d11cad0ebebbac357a97b26aa79d2bcaf46f256551152aa85d3a4d1", size = 5112035, upload-time = "2025-09-22T04:00:31.061Z" }, - { url = "https://files.pythonhosted.org/packages/e4/e2/ea0498552102e59834e297c5c6dff8d8ded3db72ed5e8aad77871476f073/lxml-6.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3efe1b21c7801ffa29a1112fab3b0f643628c30472d507f39544fd48e9549e34", size = 4799111, upload-time = "2025-09-22T04:00:33.11Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9e/8de42b52a73abb8af86c66c969b3b4c2a96567b6ac74637c037d2e3baa60/lxml-6.0.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:59c45e125140b2c4b33920d21d83681940ca29f0b83f8629ea1a2196dc8cfe6a", size = 5351662, upload-time = "2025-09-22T04:00:35.237Z" }, - { url = "https://files.pythonhosted.org/packages/28/a2/de776a573dfb15114509a37351937c367530865edb10a90189d0b4b9b70a/lxml-6.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:452b899faa64f1805943ec1c0c9ebeaece01a1af83e130b69cdefeda180bb42c", size = 5314973, upload-time = "2025-09-22T04:00:37.086Z" }, - { url = "https://files.pythonhosted.org/packages/50/a0/3ae1b1f8964c271b5eec91db2043cf8c6c0bce101ebb2a633b51b044db6c/lxml-6.0.2-cp310-cp310-win32.whl", hash = "sha256:1e786a464c191ca43b133906c6903a7e4d56bef376b75d97ccbb8ec5cf1f0a4b", size = 3611953, upload-time = "2025-09-22T04:00:39.224Z" }, - { url = "https://files.pythonhosted.org/packages/d1/70/bd42491f0634aad41bdfc1e46f5cff98825fb6185688dc82baa35d509f1a/lxml-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:dacf3c64ef3f7440e3167aa4b49aa9e0fb99e0aa4f9ff03795640bf94531bcb0", size = 4032695, upload-time = "2025-09-22T04:00:41.402Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d0/05c6a72299f54c2c561a6c6cbb2f512e047fca20ea97a05e57931f194ac4/lxml-6.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:45f93e6f75123f88d7f0cfd90f2d05f441b808562bf0bc01070a00f53f5028b5", size = 3680051, upload-time = "2025-09-22T04:00:43.525Z" }, - { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, - { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, - { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, - { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, - { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, - { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, - { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, - { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, - { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, - { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, - { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, - { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, - { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, - { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, - { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, - { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, - { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, - { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, - { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, - { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, - { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, - { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, - { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, - { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, - { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, - { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, - { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, - { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, - { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, - { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, - { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, - { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, - { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, - { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, - { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, - { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, - { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, - { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, - { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, - { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, - { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, - { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, - { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, - { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, - { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, - { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, - { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, - { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, - { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, - { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, - { url = "https://files.pythonhosted.org/packages/e7/9c/780c9a8fce3f04690b374f72f41306866b0400b9d0fdf3e17aaa37887eed/lxml-6.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e748d4cf8fef2526bb2a589a417eba0c8674e29ffcb570ce2ceca44f1e567bf6", size = 3939264, upload-time = "2025-09-22T04:04:32.892Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5a/1ab260c00adf645d8bf7dec7f920f744b032f69130c681302821d5debea6/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4ddb1049fa0579d0cbd00503ad8c58b9ab34d1254c77bc6a5576d96ec7853dba", size = 4216435, upload-time = "2025-09-22T04:04:34.907Z" }, - { url = "https://files.pythonhosted.org/packages/f2/37/565f3b3d7ffede22874b6d86be1a1763d00f4ea9fc5b9b6ccb11e4ec8612/lxml-6.0.2-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb233f9c95f83707dae461b12b720c1af9c28c2d19208e1be03387222151daf5", size = 4325913, upload-time = "2025-09-22T04:04:37.205Z" }, - { url = "https://files.pythonhosted.org/packages/22/ec/f3a1b169b2fb9d03467e2e3c0c752ea30e993be440a068b125fc7dd248b0/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc456d04db0515ce3320d714a1eac7a97774ff0849e7718b492d957da4631dd4", size = 4269357, upload-time = "2025-09-22T04:04:39.322Z" }, - { url = "https://files.pythonhosted.org/packages/77/a2/585a28fe3e67daa1cf2f06f34490d556d121c25d500b10082a7db96e3bcd/lxml-6.0.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2613e67de13d619fd283d58bda40bff0ee07739f624ffee8b13b631abf33083d", size = 4412295, upload-time = "2025-09-22T04:04:41.647Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/a57dd8bcebd7c69386c20263830d4fa72d27e6b72a229ef7a48e88952d9a/lxml-6.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:24a8e756c982c001ca8d59e87c80c4d9dcd4d9b44a4cbeb8d9be4482c514d41d", size = 3516913, upload-time = "2025-09-22T04:04:43.602Z" }, - { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, - { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, - { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, - { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, - { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/6e/ee8fc0e01202eb3dd2b9e1ea4f0910d72425d35c66187c63931d7a3ea73f/lxml-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:41dcc4c7b10484257cbd6c37b83ddb26df2b0e5aff5ac00d095689015af868ec", size = 8540733, upload-time = "2026-04-18T04:27:33.185Z" }, + { url = "https://files.pythonhosted.org/packages/54/e8/325fe9b942824c773dffe1baf0c35b046a763851fdff4393af4450bceeb7/lxml-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a31286dbb5e74c8e9a5344465b77ab4c5bd511a253b355b5ca2fae7e579fafec", size = 4602805, upload-time = "2026-04-18T04:27:36.097Z" }, + { url = "https://files.pythonhosted.org/packages/2d/81/221aa3ea4a40370bb0358fa454cbe7e5a837e522f7630c24dfef3f9a73b0/lxml-6.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1bc4cc83fb7f66ffb16f74d6dd0162e144333fc36ebcce32246f80c8735b2551", size = 5002652, upload-time = "2026-04-18T04:27:30.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e1/fdbfb9019542f1875c093576df7f37adc2983c8ba7ecf17e5f14490bc107/lxml-6.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:20cf4d0651987c906a2f5cba4e3a8d6ba4bfdf973cfe2a96c0d6053888ea2ecd", size = 5155332, upload-time = "2026-04-18T04:27:33.507Z" }, + { url = "https://files.pythonhosted.org/packages/56/b1/4087c782fff397cd03abf9c551069be59bb04a7e548c50fb7b9c4cdaca28/lxml-6.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb34ea45a82dd637c2c97ae1bbb920850c1e59bcae79ce1c15af531d83e7215", size = 5057226, upload-time = "2026-04-18T04:27:37.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/66/516c79dec8417f3a972327330254c0b5fac93d5c3ecfd8a5b43650a5a4d9/lxml-6.1.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d9b99e5b2597e4f5aed2484fef835256fa1b68a19e4265c97628ef4bf8bcf4", size = 5287588, upload-time = "2026-04-18T04:27:41.4Z" }, + { url = "https://files.pythonhosted.org/packages/94/1d/e578f4cbeb42b9df9f29b0d44a45a7cdfa3a5ae300dd59ec68e3602d29bb/lxml-6.1.0-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:d43aa26dcda363f21e79afa0668f5029ed7394b3bb8c92a6927a3d34e8b610ea", size = 5412438, upload-time = "2026-04-18T04:27:45.589Z" }, + { url = "https://files.pythonhosted.org/packages/47/5b/2aa68307d6d15959e84d4882f9c04f2da63127eac463e1594166f681ef77/lxml-6.1.0-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:6262b87f9e5c1e5fe501d6c153247289af42eb44ad7660b9b3de17baaf92d6f6", size = 4770997, upload-time = "2026-04-18T04:27:49.853Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c9/3e51fc1228310a836b4eb32595ae00154ab12197fca944676a3ab3b163ea/lxml-6.1.0-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d1392c569c032f78a11a25d1de1c43fff13294c793b39e19d84fade3045cbbc3", size = 5359678, upload-time = "2026-04-18T04:31:56.184Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/ab8bc834f977fbbd310e697b120787c153db026f9151e02a88d2645d4e5b/lxml-6.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:045e387d1f4f42a418380930fa3f45c73c9b392faf67e495e58902e68e8f44a7", size = 5107890, upload-time = "2026-04-18T04:32:00.387Z" }, + { url = "https://files.pythonhosted.org/packages/bb/10/8a143cfa3ac99cb5b0523ff6d0429a9c9dddf25ffeae09caa3866c7964d9/lxml-6.1.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:9f93d5b8b07f73e8c77e3c6556a3db269918390c804b5e5fcdd4858232cc8f16", size = 4803977, upload-time = "2026-04-18T04:32:05.099Z" }, + { url = "https://files.pythonhosted.org/packages/45/fd/ee02faf52fa39c2fe32f824628958b9aa86dff21343dc3161f0e3c6ccd15/lxml-6.1.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:de550d129f18d8ab819651ffe4f38b1b713c7e116707de3c0c6400d0ef34fbc1", size = 5350277, upload-time = "2026-04-18T04:32:09.176Z" }, + { url = "https://files.pythonhosted.org/packages/85/8c/b3481364b8554b5d36d540189a87fc71e94b0b01c24f8f152bd662dd2e45/lxml-6.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c08da09dc003c9e8c70e06b53a11db6fb3b250c21c4236b03c7d7b443c318e7a", size = 5309717, upload-time = "2026-04-18T04:32:13.303Z" }, + { url = "https://files.pythonhosted.org/packages/74/e8/a6b21927077a9127afa17473b6576b322616f34ac50ee4f577e763b75ec0/lxml-6.1.0-cp310-cp310-win32.whl", hash = "sha256:37448bf9c7d7adfc5254763901e2bbd6bb876228dfc1fc7f66e58c06368a7544", size = 3598491, upload-time = "2026-04-18T04:27:24.288Z" }, + { url = "https://files.pythonhosted.org/packages/ea/82/14dea800d041274d96c07d49ff9191f011d1427450850de19bf541e2cc12/lxml-6.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:2593a0a6621545b9095b71ad74ed4226eba438a7d9fc3712a99bdb15508cf93a", size = 4020906, upload-time = "2026-04-18T04:27:27.53Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ba/d3539aaf4d9d21456b9a7b902816623227d05d63e7c5aafd8834c4b9bed6/lxml-6.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:e80807d72f96b96ad5588cb85c75616e4f2795a7737d4630784c51497beb7776", size = 3667787, upload-time = "2026-04-18T04:27:29.407Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5d/3bccad330292946f97962df9d5f2d3ae129cce6e212732a781e856b91e07/lxml-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cec05be8c876f92a5aa07b01d60bbb4d11cfbdd654cad0561c0d7b5c043a61b9", size = 8526232, upload-time = "2026-04-18T04:27:40.389Z" }, + { url = "https://files.pythonhosted.org/packages/a7/51/adc8826570a112f83bb4ddb3a2ab510bbc2ccd62c1b9fe1f34fae2d90b57/lxml-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9c03e048b6ce8e77b09c734e931584894ecd58d08296804ca2d0b184c933ce50", size = 4595448, upload-time = "2026-04-18T04:27:44.208Z" }, + { url = "https://files.pythonhosted.org/packages/54/84/5a9ec07cbe1d2334a6465f863b949a520d2699a755738986dcd3b6b89e3f/lxml-6.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:942454ff253da14218f972b23dc72fa4edf6c943f37edd19cd697618b626fac5", size = 4923771, upload-time = "2026-04-18T04:32:17.402Z" }, + { url = "https://files.pythonhosted.org/packages/a7/23/851cfa33b6b38adb628e45ad51fb27105fa34b2b3ba9d1d4aa7a9428dfe0/lxml-6.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d036ee7b99d5148072ac7c9b847193decdfeac633db350363f7bce4fff108f0e", size = 5068101, upload-time = "2026-04-18T04:32:21.437Z" }, + { url = "https://files.pythonhosted.org/packages/b0/38/41bf99c2023c6b79916ba057d83e9db21d642f473cac210201222882d38b/lxml-6.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ae5d8d5427f3cc317e7950f2da7ad276df0cfa37b8de2f5658959e618ea8512", size = 5002573, upload-time = "2026-04-18T04:32:25.373Z" }, + { url = "https://files.pythonhosted.org/packages/c2/20/053aa10bdc39747e1e923ce2d45413075e84f70a136045bb09e5eaca41d3/lxml-6.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:363e47283bde87051b821826e71dde47f107e08614e1aa312ba0c5711e77738c", size = 5202816, upload-time = "2026-04-18T04:32:29.393Z" }, + { url = "https://files.pythonhosted.org/packages/9a/da/bc710fad8bf04b93baee752c192eaa2210cd3a84f969d0be7830fea55802/lxml-6.1.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:f504d861d9f2a8f94020130adac88d66de93841707a23a86244263d1e54682f5", size = 5329999, upload-time = "2026-04-18T04:32:34.019Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/bf035dedbdf7fab49411aa52e4236f3445e98d38647d85419e6c0d2806b9/lxml-6.1.0-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:23a5dc68e08ed13331d61815c08f260f46b4a60fdd1640bbeb82cf89a9d90289", size = 4659643, upload-time = "2026-04-18T04:32:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/22be31f33727a5e4c7b01b0a874503026e50329b259d3587e0b923cf964b/lxml-6.1.0-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f15401d8d3dbf239e23c818afc10c7207f7b95f9a307e092122b6f86dd43209a", size = 5265963, upload-time = "2026-04-18T04:32:41.881Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2b/d44d0e5c79226017f4ab8c87a802ebe4f89f97e6585a8e4166dffcdd7b6e/lxml-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fcf3da95e93349e0647d48d4b36a12783105bcc74cb0c416952f9988410846a3", size = 5045444, upload-time = "2026-04-18T04:32:44.512Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c3/3f034fec1594c331a6dbf9491238fdcc9d66f68cc529e109ec75b97197e1/lxml-6.1.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0d082495c5fcf426e425a6e28daaba1fcb6d8f854a4ff01effb1f1f381203eb9", size = 4712703, upload-time = "2026-04-18T04:32:47.16Z" }, + { url = "https://files.pythonhosted.org/packages/12/16/0b83fccc158218aca75a7aa33e97441df737950734246b9fffa39301603d/lxml-6.1.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e3c4f84b24a1fcba435157d111c4b755099c6ff00a3daee1ad281817de75ed11", size = 5252745, upload-time = "2026-04-18T04:32:50.427Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ee/12e6c1b39a77666c02eaa77f94a870aaf63c4ac3a497b2d52319448b01c6/lxml-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:976a6b39b1b13e8c354ad8d3f261f3a4ac6609518af91bdb5094760a08f132c4", size = 5226822, upload-time = "2026-04-18T04:32:53.437Z" }, + { url = "https://files.pythonhosted.org/packages/34/20/c7852904858b4723af01d2fc14b5d38ff57cb92f01934a127ebd9a9e51aa/lxml-6.1.0-cp311-cp311-win32.whl", hash = "sha256:857efde87d365706590847b916baff69c0bc9252dc5af030e378c9800c0b10e3", size = 3594026, upload-time = "2026-04-18T04:27:31.903Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/d60c732b56da5085175c07c74b2df4e6d181b0c9a61e1691474f06ef4b39/lxml-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:183bfb45a493081943be7ea2b5adfc2b611e1cf377cefa8b8a8be404f45ef9a7", size = 4025114, upload-time = "2026-04-18T04:27:34.077Z" }, + { url = "https://files.pythonhosted.org/packages/c2/df/c84dcc175fd690823436d15b41cb920cd5ba5e14cd8bfb00949d5903b320/lxml-6.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:19f4164243fc206d12ed3d866e80e74f5bc3627966520da1a5f97e42c32a3f39", size = 3667742, upload-time = "2026-04-18T04:27:38.45Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d4/9326838b59dc36dfae42eec9656b97520f9997eee1de47b8316aaeed169c/lxml-6.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d", size = 8570663, upload-time = "2026-04-18T04:27:48.253Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a4/053745ce1f8303ccbb788b86c0db3a91b973675cefc42566a188637b7c40/lxml-6.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93", size = 4624024, upload-time = "2026-04-18T04:27:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/90/97/a517944b20f8fd0932ad2109482bee4e29fe721416387a363306667941f6/lxml-6.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d", size = 4930895, upload-time = "2026-04-18T04:32:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/e08a970727d556caa040a44773c7b7e3ad0f0d73dedc863543e9a8b931f2/lxml-6.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a", size = 5093820, upload-time = "2026-04-18T04:32:58.94Z" }, + { url = "https://files.pythonhosted.org/packages/88/ee/2a5c2aa2c32016a226ca25d3e1056a8102ea6e1fe308bf50213586635400/lxml-6.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105", size = 5005790, upload-time = "2026-04-18T04:33:01.272Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/a0db9be8f38ad6043ab9429487c128dd1d30f07956ef43040402f8da49e8/lxml-6.1.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485", size = 5630827, upload-time = "2026-04-18T04:33:04.036Z" }, + { url = "https://files.pythonhosted.org/packages/31/ba/3c13d3fc24b7cacf675f808a3a1baabf43a30d0cd24c98f94548e9aa58eb/lxml-6.1.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814", size = 5240445, upload-time = "2026-04-18T04:33:06.87Z" }, + { url = "https://files.pythonhosted.org/packages/55/ba/eeef4ccba09b2212fe239f46c1692a98db1878e0872ae320756488878a94/lxml-6.1.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32", size = 5350121, upload-time = "2026-04-18T04:33:09.365Z" }, + { url = "https://files.pythonhosted.org/packages/7e/01/1da87c7b587c38d0cbe77a01aae3b9c1c49ed47d76918ef3db8fc151b1ca/lxml-6.1.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad", size = 4694949, upload-time = "2026-04-18T04:33:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/7db0fe66d5aaf128443ee1623dec3db1576f3e4c17751ec0ef5866468590/lxml-6.1.0-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54", size = 5243901, upload-time = "2026-04-18T04:33:13.95Z" }, + { url = "https://files.pythonhosted.org/packages/00/a8/1346726af7d1f6fca1f11223ba34001462b0a3660416986d37641708d57c/lxml-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d", size = 5048054, upload-time = "2026-04-18T04:33:16.965Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b7/85057012f035d1a0c87e02f8c723ca3c3e6e0728bcf4cb62080b21b1c1e3/lxml-6.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69", size = 4777324, upload-time = "2026-04-18T04:33:19.832Z" }, + { url = "https://files.pythonhosted.org/packages/75/6c/ad2f94a91073ef570f33718040e8e160d5fb93331cf1ab3ca1323f939e2d/lxml-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d", size = 5645702, upload-time = "2026-04-18T04:33:22.436Z" }, + { url = "https://files.pythonhosted.org/packages/3b/89/0bb6c0bd549c19004c60eea9dc554dd78fd647b72314ef25d460e0d208c6/lxml-6.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5", size = 5232901, upload-time = "2026-04-18T04:33:26.21Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d9/d609a11fb567da9399f525193e2b49847b5a409cdebe737f06a8b7126bdc/lxml-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d", size = 5261333, upload-time = "2026-04-18T04:33:28.984Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3a/ac3f99ec8ac93089e7dd556f279e0d14c24de0a74a507e143a2e4b496e7c/lxml-6.1.0-cp312-cp312-win32.whl", hash = "sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f", size = 3596289, upload-time = "2026-04-18T04:27:42.819Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a7/0a915557538593cb1bbeedcd40e13c7a261822c26fecbbdb71dad0c2f540/lxml-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366", size = 3997059, upload-time = "2026-04-18T04:27:46.764Z" }, + { url = "https://files.pythonhosted.org/packages/92/96/a5dc078cf0126fbfbc35611d77ecd5da80054b5893e28fb213a5613b9e1d/lxml-6.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819", size = 3659552, upload-time = "2026-04-18T04:27:51.133Z" }, + { url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689, upload-time = "2026-04-18T04:31:57.785Z" }, + { url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892, upload-time = "2026-04-18T04:32:01.78Z" }, + { url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489, upload-time = "2026-04-18T04:33:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162, upload-time = "2026-04-18T04:33:34.262Z" }, + { url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247, upload-time = "2026-04-18T04:33:36.674Z" }, + { url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042, upload-time = "2026-04-18T04:33:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304, upload-time = "2026-04-18T04:33:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578, upload-time = "2026-04-18T04:33:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209, upload-time = "2026-04-18T04:33:47.552Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365, upload-time = "2026-04-18T04:33:50.249Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654, upload-time = "2026-04-18T04:33:52.71Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326, upload-time = "2026-04-18T04:33:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879, upload-time = "2026-04-18T04:33:58.509Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048, upload-time = "2026-04-18T04:34:00.943Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241, upload-time = "2026-04-18T04:34:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938, upload-time = "2026-04-18T04:31:56.206Z" }, + { url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728, upload-time = "2026-04-18T04:31:58.763Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372, upload-time = "2026-04-18T04:32:03.629Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/cee4cf203ef0bab5c52afc118da61d6b460c928f2893d40023cfa27e0b80/lxml-6.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ab863fd37458fed6456525f297d21239d987800c46e67da5ef04fc6b3dd93ac8", size = 8576713, upload-time = "2026-04-18T04:32:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a7/eda05babeb7e046839204eaf254cd4d7c9130ce2bbf0d9e90ea41af5654d/lxml-6.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6fd8b1df8254ff4fd93fd31da1fc15770bde23ac045be9bb1f87425702f61cc9", size = 4623874, upload-time = "2026-04-18T04:32:10.755Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e9/db5846de9b436b91890a62f29d80cd849ea17948a49bf532d5278ee69a9e/lxml-6.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:47024feaae386a92a146af0d2aeed65229bf6fff738e6a11dda6b0015fb8fd03", size = 4949535, upload-time = "2026-04-18T04:34:06.657Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ba/0d3593373dcae1d68f40dc3c41a5a92f2544e68115eb2f62319a4c2a6500/lxml-6.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3f00972f84450204cd5d93a5395965e348956aaceaadec693a22ec743f8ae3eb", size = 5086881, upload-time = "2026-04-18T04:34:09.556Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/759a7484539ad1af0d125a9afe9c3fb5f82a8779fd1f5f56319d9e4ea2fd/lxml-6.1.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97faa0860e13b05b15a51fb4986421ef7a30f0b3334061c416e0981e9450ca4c", size = 5031305, upload-time = "2026-04-18T04:34:12.336Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/c1f0daf981a11e47636126901fd4ab82429e18c57aeb0fc3ad2940b42d8b/lxml-6.1.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:972a6451204798675407beaad97b868d0c733d9a74dafefc63120b81b8c2de28", size = 5647522, upload-time = "2026-04-18T04:34:14.89Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/1f533dcd205275363d9ba3511bcec52fa2df86abf8abe6a5f2c599f0dc31/lxml-6.1.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fe022f20bc4569ec66b63b3fb275a3d628d9d32da6326b2982584104db6d3086", size = 5239310, upload-time = "2026-04-18T04:34:17.652Z" }, + { url = "https://files.pythonhosted.org/packages/c3/8c/4175fb709c78a6e315ed814ed33be3defd8b8721067e70419a6cf6f971da/lxml-6.1.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:75c4c7c619a744f972f4451bf5adf6d0fb00992a1ffc9fd78e13b0bc817cc99f", size = 5350799, upload-time = "2026-04-18T04:34:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/6ffdebc5994975f0dde4acb59761902bd9d9bb84422b9a0bd239a7da9ca8/lxml-6.1.0-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3648f20d25102a22b6061c688beb3a805099ea4beb0a01ce62975d926944d292", size = 4697693, upload-time = "2026-04-18T04:34:23.541Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/565f36bd5c73294602d48e04d23f81ff4c8736be6ba5e1d1ec670ac9be80/lxml-6.1.0-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77b9f99b17cbf14026d1e618035077060fc7195dd940d025149f3e2e830fbfcb", size = 5250708, upload-time = "2026-04-18T04:34:26.001Z" }, + { url = "https://files.pythonhosted.org/packages/5a/11/a68ab9dd18c5c499404deb4005f4bc4e0e88e5b72cd755ad96efec81d18d/lxml-6.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32662519149fd7a9db354175aa5e417d83485a8039b8aaa62f873ceee7ea4cad", size = 5084737, upload-time = "2026-04-18T04:34:28.32Z" }, + { url = "https://files.pythonhosted.org/packages/ab/78/e8f41e2c74f4af564e6a0348aea69fb6daaefa64bc071ef469823d22cc18/lxml-6.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:73d658216fc173cf2c939e90e07b941c5e12736b0bf6a99e7af95459cfe8eabb", size = 4737817, upload-time = "2026-04-18T04:34:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/06/2d/aa4e117aa2ce2f3b35d9ff246be74a2f8e853baba5d2a92c64744474603a/lxml-6.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ac4db068889f8772a4a698c5980ec302771bb545e10c4b095d4c8be26749616f", size = 5670753, upload-time = "2026-04-18T04:34:33.675Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/dd745d50c0409031dbfcc4881740542a01e54d6f0110bd420fa7782110b8/lxml-6.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:45e9dfbd1b661eb64ba0d4dbe762bd210c42d86dd1e5bd2bdf89d634231beb43", size = 5238071, upload-time = "2026-04-18T04:34:36.12Z" }, + { url = "https://files.pythonhosted.org/packages/3e/74/ad424f36d0340a904665867dab310a3f1f4c96ff4039698de83b77f44c1f/lxml-6.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:89e8d73d09ac696a5ba42ec69787913d53284f12092f651506779314f10ba585", size = 5264319, upload-time = "2026-04-18T04:34:39.035Z" }, + { url = "https://files.pythonhosted.org/packages/53/36/a15d8b3514ec889bfd6aa3609107fcb6c9189f8dc347f1c0b81eded8d87c/lxml-6.1.0-cp314-cp314-win32.whl", hash = "sha256:ebe33f4ec1b2de38ceb225a1749a2965855bffeef435ba93cd2d5d540783bf2f", size = 3657139, upload-time = "2026-04-18T04:32:20.006Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a4/263ebb0710851a3c6c937180a9a86df1206fdfe53cc43005aa2237fd7736/lxml-6.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:398443df51c538bd578529aa7e5f7afc6c292644174b47961f3bf87fe5741120", size = 4064195, upload-time = "2026-04-18T04:32:23.876Z" }, + { url = "https://files.pythonhosted.org/packages/80/68/2000f29d323b6c286de077ad20b429fc52272e44eae6d295467043e56012/lxml-6.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:8c8984e1d8c4b3949e419158fda14d921ff703a9ed8a47236c6eb7a2b6cb4946", size = 3741870, upload-time = "2026-04-18T04:32:27.922Z" }, + { url = "https://files.pythonhosted.org/packages/30/e9/21383c7c8d43799f0da90224c0d7c921870d476ec9b3e01e1b2c0b8237c5/lxml-6.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1081dd10bc6fa437db2500e13993abf7cc30716d0a2f40e65abb935f02ec559c", size = 8827548, upload-time = "2026-04-18T04:32:15.094Z" }, + { url = "https://files.pythonhosted.org/packages/a5/01/c6bc11cd587030dd4f719f65c5657960649fe3e19196c844c75bf32cd0d6/lxml-6.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dabecc48db5f42ba348d1f5d5afdc54c6c4cc758e676926c7cd327045749517d", size = 4735866, upload-time = "2026-04-18T04:32:18.924Z" }, + { url = "https://files.pythonhosted.org/packages/f3/01/757132fff5f4acf25463b5298f1a46099f3a94480b806547b29ce5e385de/lxml-6.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3dd5fe19c9e0ac818a9c7f132a5e43c1339ec1cbbfecb1a938bd3a47875b7c9", size = 4969476, upload-time = "2026-04-18T04:34:41.889Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fb/1bc8b9d27ed64be7c8903db6c89e74dc8c2cd9ec630a7462e4654316dc5b/lxml-6.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9e7b0a4ca6dcc007a4cef00a761bba2dea959de4bd2df98f926b33c92ca5dfb9", size = 5103719, upload-time = "2026-04-18T04:34:44.797Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e7/5bf82fa28133536a54601aae633b14988e89ed61d4c1eb6b899b023233aa/lxml-6.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d27bbe326c6b539c64b42638b18bc6003a8d88f76213a97ac9ed4f885efeab7", size = 5027890, upload-time = "2026-04-18T04:34:47.634Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/e048db5d4b4ea0366648aa595f26bb764b2670903fc585b87436d0a5032c/lxml-6.1.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4e425db0c5445ef0ad56b0eec54f89b88b2d884656e536a90b2f52aecb4ca86", size = 5596008, upload-time = "2026-04-18T04:34:51.503Z" }, + { url = "https://files.pythonhosted.org/packages/9a/c2/d10807bc8da4824b39e5bd01b5d05c077b6fd01bd91584167edf6b269d22/lxml-6.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b89b098105b8599dc57adac95d1813409ac476d3c948a498775d3d0c6124bfb", size = 5224451, upload-time = "2026-04-18T04:34:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/3c/15/2ebea45bea427e7f0057e9ce7b2d62c5aba20c6b001cca89ed0aadb3ad41/lxml-6.1.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:c4a699432846df86cc3de502ee85f445ebad748a1c6021d445f3e514d2cd4b1c", size = 5312135, upload-time = "2026-04-18T04:34:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/31/e2/87eeae151b0be2a308d49a7ec444ff3eb192b14251e62addb29d0bf3778f/lxml-6.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:30e7b2ed63b6c8e97cca8af048589a788ab5c9c905f36d9cf1c2bb549f450d2f", size = 4639126, upload-time = "2026-04-18T04:34:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/a3/51/8a3f6a20902ad604dd746ec7b4000311b240d389dac5e9d95adefd349e0c/lxml-6.1.0-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:022981127642fe19866d2907d76241bb07ed21749601f727d5d5dd1ce5d1b773", size = 5232579, upload-time = "2026-04-18T04:35:02.658Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d2/650d619bdbe048d2c3f2c31edb00e35670a5e2d65b4fe3b61bce37b19121/lxml-6.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:23cad0cc86046d4222f7f418910e46b89971c5a45d3c8abfad0f64b7b05e4a9b", size = 5084206, upload-time = "2026-04-18T04:35:05.175Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8a/672ca1a3cbeabd1f511ca275a916c0514b747f4b85bdaae103b8fa92f307/lxml-6.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:21c3302068f50d1e8728c67c87ba92aa87043abee517aa2576cca1855326b405", size = 4758906, upload-time = "2026-04-18T04:35:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/be/f1/ef4b691da85c916cb2feb1eec7414f678162798ac85e042fa164419ac05c/lxml-6.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:be10838781cb3be19251e276910cd508fe127e27c3242e50521521a0f3781690", size = 5620553, upload-time = "2026-04-18T04:35:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/59/17/94e81def74107809755ac2782fdad4404420f1c92ca83433d117a6d5acf0/lxml-6.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2173a7bffe97667bbf0767f8a99e587740a8c56fdf3befac4b09cb29a80276fd", size = 5229458, upload-time = "2026-04-18T04:35:14.254Z" }, + { url = "https://files.pythonhosted.org/packages/21/55/c4be91b0f830a871fc1b0d730943d56013b683d4671d5198260e2eae722b/lxml-6.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c6854e9cf99c84beb004eecd7d3a3868ef1109bf2b1df92d7bc11e96a36c2180", size = 5247861, upload-time = "2026-04-18T04:35:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ca/77123e4d77df3cb1e968ade7b1f808f5d3a5c1c96b18a33895397de292c1/lxml-6.1.0-cp314-cp314t-win32.whl", hash = "sha256:00750d63ef0031a05331b9223463b1c7c02b9004cef2346a5b2877f0f9494dd2", size = 3897377, upload-time = "2026-04-18T04:32:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/64/ce/3554833989d074267c063209bae8b09815e5656456a2d332b947806b05ff/lxml-6.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:80410c3a7e3c617af04de17caa9f9f20adaa817093293d69eae7d7d0522836f5", size = 4392701, upload-time = "2026-04-18T04:32:12.113Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120, upload-time = "2026-04-18T04:32:15.803Z" }, + { url = "https://files.pythonhosted.org/packages/f2/88/55143966481409b1740a3ac669e611055f49efd68087a5ce41582325db3e/lxml-6.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:546b66c0dd1bb8d9fa89d7123e5fa19a8aff3a1f2141eb22df96112afb17b842", size = 3930134, upload-time = "2026-04-18T04:32:35.008Z" }, + { url = "https://files.pythonhosted.org/packages/b5/97/28b985c2983938d3cb696dd5501423afb90a8c3e869ef5d3c62569282c0f/lxml-6.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5cfa1a34df366d9dc0d5eaf420f4cf2bb1e1bebe1066d1c2fc28c179f8a4004c", size = 4210749, upload-time = "2026-04-18T04:36:03.626Z" }, + { url = "https://files.pythonhosted.org/packages/29/67/dfab2b7d58214921935ccea7ce9b3df9b7d46f305d12f0f532ac7cf6b804/lxml-6.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db88156fcf544cdbf0d95588051515cfdfd4c876fc66444eb98bceb5d6db76de", size = 4318463, upload-time = "2026-04-18T04:36:06.309Z" }, + { url = "https://files.pythonhosted.org/packages/32/a2/4ac7eb32a4d997dd352c32c32399aae27b3f268d440e6f9cfa405b575d2f/lxml-6.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:07f98f5496f96bf724b1e3c933c107f0cbf2745db18c03d2e13a291c3afd2635", size = 4251124, upload-time = "2026-04-18T04:36:09.056Z" }, + { url = "https://files.pythonhosted.org/packages/33/ef/d6abd850bb4822f9b720cfe36b547a558e694881010ff7d012191e8769c6/lxml-6.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4642e04449a1e164b5ff71ffd901ddb772dfabf5c9adf1b7be5dffe1212bc037", size = 4401758, upload-time = "2026-04-18T04:36:11.803Z" }, + { url = "https://files.pythonhosted.org/packages/40/44/3ee09a5b60cb44c4f2fbc1c9015cfd6ff5afc08f991cab295d3024dcbf2d/lxml-6.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7da13bb6fbadfafb474e0226a30570a3445cfd47c86296f2446dafbd77079ace", size = 3508860, upload-time = "2026-04-18T04:32:48.619Z" }, ] [[package]] @@ -1965,20 +1959,23 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] [[package]] @@ -2035,7 +2032,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -2046,9 +2043,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -2225,7 +2222,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.33.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -2233,9 +2230,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] [[package]] From 65d57c04cdb342509ddbc36c3bc744ffd5eed760 Mon Sep 17 00:00:00 2001 From: Matthias Brenninkmeijer Date: Fri, 8 May 2026 02:19:53 +0200 Subject: [PATCH 04/12] Adapt to work --- .github/workflows/ci.yml | 14 ++++++++++++-- .pre-commit-config.yaml | 8 +++++++- pyproject.toml | 5 +++++ uv.lock | 8 +++++--- 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70832d5..0417155 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,9 +51,19 @@ jobs: - name: Run ty type checker run: uv run ty check src/dppvalidator/ - # Matches pre-commit: pip-audit (pre-push stage) + # Matches pre-commit: pip-audit (pre-push stage). + # + # We audit a frozen requirements file (the actually-installed + # non-editable packages) rather than ``pip-audit --skip-editable`` + # alone. The plain ``uv run pip-audit`` ships its own pip 25.3 in + # the tool environment, which surfaces pip-the-installer CVEs + # unrelated to anything dppvalidator imports or ships. ``pip + # freeze --exclude-editable`` doesn't emit ``pip`` itself, so the + # audit is scoped to runtime + dev dependencies only. - name: Run security scan (pip-audit) - run: uv run pip-audit --skip-editable + run: | + uv pip freeze --exclude-editable > /tmp/audit-requirements.txt + uv run pip-audit --requirement /tmp/audit-requirements.txt --strict # Check error documentation coverage - name: Check error documentation diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 020bdd5..38b0e87 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -67,9 +67,15 @@ repos: language: system types: [ python ] pass_filenames: false + # Audit the actually-installed non-editable packages rather than + # plain ``uv run pip-audit --skip-editable``. The bare command + # ships its own pip 25.3 in the tool environment, which surfaces + # pip-the-installer CVEs unrelated to anything dppvalidator + # imports or ships. ``pip freeze --exclude-editable`` excludes + # both pip itself and our editable package. - id: pip-audit name: pip-audit - entry: uv run pip-audit --skip-editable + entry: bash -c 'uv pip freeze --exclude-editable > /tmp/audit-requirements.txt && uv run pip-audit --requirement /tmp/audit-requirements.txt --strict' language: system pass_filenames: false stages: [ pre-push ] diff --git a/pyproject.toml b/pyproject.toml index e36415c..b43c343 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,6 +109,11 @@ dev = [ # Security & License Scanning "pip-audit>=2.7.0", "pip-licenses>=5.0.0", + # ``pip-audit`` pulls in ``pip-api`` which transitively pins + # ``pip`` itself. Without this floor, the resolver picks pip 25.3 + # (CVE-2026-1703 + CVE-2026-6357), which then surfaces during + # ``pip-audit`` runs against the lockfile-exported requirements. + "pip>=26.1", # SBOM Generation "cyclonedx-bom>=7.0.0", # Optional extras (include all for dev) diff --git a/uv.lock b/uv.lock index de5c304..9a15505 100644 --- a/uv.lock +++ b/uv.lock @@ -645,6 +645,7 @@ dev = [ { name = "cyclonedx-bom" }, { name = "hypothesis" }, { name = "mutmut" }, + { name = "pip" }, { name = "pip-audit" }, { name = "pip-licenses" }, { name = "pre-commit" }, @@ -684,6 +685,7 @@ dev = [ { name = "cyclonedx-bom", specifier = ">=7.0.0" }, { name = "hypothesis", specifier = ">=6.100.0" }, { name = "mutmut", specifier = ">=3.0.0" }, + { name = "pip", specifier = ">=26.1" }, { name = "pip-audit", specifier = ">=2.7.0" }, { name = "pip-licenses", specifier = ">=5.0.0" }, { name = "pre-commit", specifier = ">=3.6.0" }, @@ -1691,11 +1693,11 @@ wheels = [ [[package]] name = "pip" -version = "25.3" +version = "26.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/6e/74a3f0179a4a73a53d66ce57fdb4de0080a8baa1de0063de206d6167acc2/pip-25.3.tar.gz", hash = "sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343", size = 1803014, upload-time = "2025-10-25T00:55:41.394Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/48/cb9b7a682f6fe01a4221e1728941dd4ac3cd9090a17db3779d6ff490b602/pip-26.1.1.tar.gz", hash = "sha256:d36762751d156a4ee895de8af39aa0abeeeb577f93a2eca6ab62467bbf0f8a78", size = 1840400, upload-time = "2026-05-04T19:02:21.248Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl", hash = "sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd", size = 1778622, upload-time = "2025-10-25T00:55:39.247Z" }, + { url = "https://files.pythonhosted.org/packages/3a/eb/fea4d1d51c49832120f7f285d07306db3960f423a2612c6057caf3e8196f/pip-26.1.1-py3-none-any.whl", hash = "sha256:99cb1c2899893b075ff56e4ed0af55669a955b49ad7fb8d8603ecdaf4ed653fb", size = 1812777, upload-time = "2026-05-04T19:02:18.9Z" }, ] [[package]] From 7ac92a62fcae559a514f5cc93a5566b49e3bdfb9 Mon Sep 17 00:00:00 2001 From: Matthias Brenninkmeijer Date: Fri, 8 May 2026 02:24:59 +0200 Subject: [PATCH 05/12] Proper error management --- docs/errors/MDL098.md | 63 +++++++++++++++++++++++++++++++++++++ mkdocs.yml | 8 +++++ scripts/check_error_docs.py | 23 ++++++++++++-- 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 docs/errors/MDL098.md diff --git a/docs/errors/MDL098.md b/docs/errors/MDL098.md new file mode 100644 index 0000000..991646c --- /dev/null +++ b/docs/errors/MDL098.md @@ -0,0 +1,63 @@ +# MDL098 - No Model Registered for Schema Version + +## Description + +The model layer was asked to validate against an +`schema_version` for which no Pydantic model is registered in +`_MODEL_BY_VERSION` (see +[`validators/model.py`](https://github.com/artiso-ai/dppvalidator/blob/main/src/dppvalidator/validators/model.py)). +The engine fails fast rather than silently coercing to the default +model — silent coercion would mask the misconfiguration and produce +misleading downstream errors. + +## Category + +Model Errors + +## Severity + +`error` + +## Common Causes + +- The caller passed an `schema_version` string the engine doesn't + know about (e.g. a typo, a future-version pin, or a CI environment + that wasn't refreshed after a UNTP upgrade). +- A custom build of dppvalidator removed a model registration + without also removing the version from `SCHEMA_REGISTRY`. + +## How to Fix + +1. **Check the registered versions** — `dppvalidator schema list` + prints every version `_MODEL_BY_VERSION` knows about. The error + message also lists them under the `available` field. +1. **Pin a registered version** when constructing the engine, or + omit `schema_version` entirely to let auto-detection pick from + the payload's `@context`. +1. **If you need a new version**, follow the recipe in + [`.claude/rules/untp-versioning.md`](https://github.com/artiso-ai/dppvalidator/blob/main/.claude/rules/untp-versioning.md) + (or the `/untp-bump ` slash command). Adding a version + requires touching `_MODEL_BY_VERSION`, `SCHEMA_REGISTRY`, and a + handful of related dispatch tables — see the rule for the full + minimum touch list. + +## Example + +```python +from dppvalidator.validators import ValidationEngine + +# Wrong — "0.5.0" isn't registered. +engine = ValidationEngine(schema_version="0.5.0") +result = engine.validate(payload) +# result.errors[0].code == "MDL098" +# result.errors[0].context["requested_version"] == "0.5.0" + +# Right — register one of the supported versions, or auto-detect. +engine = ValidationEngine(schema_version="0.7.0") +``` + +## See Also + +- [VER001 - UNTP version mismatch](VER001.md) — the engine-vs-payload version mismatch (different failure mode, same family). +- [UNTP DPP versions](../concepts/untp-versions.md) +- [Error Overview](index.md) diff --git a/mkdocs.yml b/mkdocs.yml index 2326ba8..48d3bbf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -226,6 +226,7 @@ nav: - MDL081 - Invalid Emission Value: errors/MDL081.md - MDL090 - Invalid Facility: errors/MDL090.md - MDL091 - Invalid Facility Location: errors/MDL091.md + - MDL098 - No Model Registered for Schema Version: errors/MDL098.md - MDL099 - Unknown Model Error: errors/MDL099.md - JSON-LD Errors: - JLD001 - Missing Context: errors/JLD001.md @@ -259,5 +260,12 @@ nav: - TXT003 - Missing Microplastic Data: errors/TXT003.md - TXT004 - Missing Durability Info: errors/TXT004.md - TXT005 - Missing Care Instructions: errors/TXT005.md + - Version Errors: + - VER001 - UNTP Version Mismatch: errors/VER001.md + - Upgrade-shim Errors: + - UPG001 - Lossy Upgrade Transformation: errors/UPG001.md + - UPG002 - Synthesised Value During Upgrade: errors/UPG002.md + - UPG003 - Unmapped Country Code: errors/UPG003.md + - UPG004 - Required v0.7 Field Missing: errors/UPG004.md - FAQ: faq.md - Changelog: changelog.md diff --git a/scripts/check_error_docs.py b/scripts/check_error_docs.py index 9dc7f99..43ecc2c 100644 --- a/scripts/check_error_docs.py +++ b/scripts/check_error_docs.py @@ -24,8 +24,27 @@ # Error code pattern: 2-3 uppercase letters followed by 3 digits ERROR_CODE_PATTERN = re.compile(r"\b([A-Z]{2,3}\d{3})\b") -# Error code prefixes to check -KNOWN_PREFIXES = {"SCH", "PRS", "MDL", "SEM", "JLD", "VOC", "SIG", "CQ", "TXT"} +# Error code prefixes to check. +# +# - SCH/PRS/MDL/SEM/JLD/VOC: validator layers. +# - SIG: VC signature verification (Phase 5 of pre-0.4.0 work). +# - CQ: CIRPASS-2 conformance. +# - TXT: textile-pilot rules. +# - VER: UNTP version-mismatch errors (Phase 3.3 of UNTP 0.7.0 plan). +# - UPG: 0.6 → 0.7 compat-shim warnings (Phase 4 of UNTP 0.7.0 plan). +KNOWN_PREFIXES = { + "SCH", + "PRS", + "MDL", + "SEM", + "JLD", + "VOC", + "SIG", + "CQ", + "TXT", + "VER", + "UPG", +} def find_error_codes_in_source() -> set[str]: From b4a0e3cb63885da6c562c3d8e796816bdf573b38 Mon Sep 17 00:00:00 2001 From: Matthias Brenninkmeijer Date: Fri, 8 May 2026 02:28:17 +0200 Subject: [PATCH 06/12] Adaptation --- .pre-commit-config.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 38b0e87..d690299 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,8 +4,19 @@ repos: hooks: - id: check-yaml args: [ --unsafe ] + # ``end-of-file-fixer`` and ``trailing-whitespace`` mutate files — + # which is incompatible with the SHA-pinned vendored artefacts + # under ``src/dppvalidator/{schemas,vocabularies}/data/``. Those + # files are byte-pinned in + # ``src/dppvalidator/schemas/data/MANIFEST.json`` and verified by + # ``tests/unit/test_manifest_integrity.py``. Adding a trailing + # newline silently invalidates the SHA. The exclusion guards + # both that contract and the upstream-vendored fixtures under + # ``tests/fixtures/upstream/`` (also SHA-recorded in SOURCES.md). - id: end-of-file-fixer + exclude: ^(src/dppvalidator/(schemas|vocabularies)/data/|tests/fixtures/upstream/).*$ - id: trailing-whitespace + exclude: ^(src/dppvalidator/(schemas|vocabularies)/data/|tests/fixtures/upstream/).*$ - id: detect-private-key - id: debug-statements - id: check-added-large-files From b9d3bd0cfaa155764d6f14aa9d9d6aeabf5574b2 Mon Sep 17 00:00:00 2001 From: Matthias Brenninkmeijer Date: Fri, 8 May 2026 02:28:25 +0200 Subject: [PATCH 07/12] Right format --- mkdocs.yml | 10 ++++++++++ .../vocabularies/data/untp-context-0.6.1.jsonld | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index 48d3bbf..bccfd65 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -64,6 +64,16 @@ plugins: markdown_extensions: - pymdownx.highlight: anchor_linenums: true + # Force a non-None ``title`` for every code block. Without + # this, ``pymdownx.highlight`` passes ``filename=None`` to + # pygments' ``HtmlFormatter``, which crashes with + # ``AttributeError: 'NoneType' object has no attribute 'replace'`` + # under pygments >=2.20.0 (the latter tightened None handling + # in ``html.escape(self._decodeifneeded(...))``). Enabling + # ``auto_title`` makes pymdownx fill ``title`` from the lexer + # name when the markdown didn't set one. Tracked upstream as a + # pymdown-extensions / pygments compatibility issue. + auto_title: true - pymdownx.superfences: custom_fences: - name: mermaid diff --git a/src/dppvalidator/vocabularies/data/untp-context-0.6.1.jsonld b/src/dppvalidator/vocabularies/data/untp-context-0.6.1.jsonld index fd1aa93..c311c34 100644 --- a/src/dppvalidator/vocabularies/data/untp-context-0.6.1.jsonld +++ b/src/dppvalidator/vocabularies/data/untp-context-0.6.1.jsonld @@ -1161,4 +1161,4 @@ } } } -} +} \ No newline at end of file From 6d93f8d94b3a1c359bbb79f3c4f9d1ab4bd1bcb5 Mon Sep 17 00:00:00 2001 From: Matthias Brenninkmeijer Date: Fri, 8 May 2026 02:41:27 +0200 Subject: [PATCH 08/12] Solve version issue --- .../dppvalidator_example_plugin/validators.py | 68 +++++++++-- src/dppvalidator/cli/commands/export.py | 16 ++- src/dppvalidator/cli/commands/validate.py | 11 +- src/dppvalidator/cli/commands/watch.py | 9 +- src/dppvalidator/plugins/registry.py | 18 +++ src/dppvalidator/validators/layers.py | 9 +- tests/integration/test_example_plugin.py | 115 ++++++++++++++++++ tests/unit/test_plugins.py | 115 ++++++++++++++++++ 8 files changed, 345 insertions(+), 16 deletions(-) diff --git a/examples/dppvalidator_example_plugin/src/dppvalidator_example_plugin/validators.py b/examples/dppvalidator_example_plugin/src/dppvalidator_example_plugin/validators.py index fc5ca10..74ab7eb 100644 --- a/examples/dppvalidator_example_plugin/src/dppvalidator_example_plugin/validators.py +++ b/examples/dppvalidator_example_plugin/src/dppvalidator_example_plugin/validators.py @@ -2,27 +2,65 @@ These validators implement the SemanticRule protocol and are automatically discovered via Python entry points when the plugin is installed. + +Both rules below target the **v0.6.x** wire shape — `credentialSubject` +is a `ProductPassport` envelope wrapping `Product`, and material +provenance lives at `credentialSubject.materialsProvenance` (plural, +v0.6 spelling). They declare ``applies_to_versions`` so the engine's +per-version plugin dispatch (Phase 6 of +``docs/plans/UNTP_0.7.0_MIGRATION.md``) skips them when a v0.7 payload +flows through. As a defensive belt-and-braces, ``check()`` also ducks +on attribute presence so the rules no-op cleanly even if dispatch +ever misroutes a v0.7 passport into them. + +For the v0.7 sibling, see ``brand_name_v07.py``. """ from __future__ import annotations -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Any, Literal if TYPE_CHECKING: from dppvalidator.models.passport import DigitalProductPassport +# Both rules target v0.6.x. The engine's per-version dispatch reads +# this tuple to skip non-matching payloads; the duck-typing in +# ``check()`` is a second line of defence in case dispatch is bypassed +# (e.g. by a caller invoking ``PluginRegistry.run_all_validators`` +# directly without ``schema_version=``). +_V06_VERSIONS: tuple[str, ...] = ("0.6.0", "0.6.1") + + +def _is_v06_shape(passport: Any) -> bool: + """True when ``passport`` is a v0.6-shaped envelope. + + v0.6 wraps the product under ``credential_subject.product``; v0.7 + has the Product directly as ``credential_subject``. Presence of + the inner ``.product`` attribute is the cleanest discriminator. + """ + cs = getattr(passport, "credential_subject", None) + if cs is None: + return False + return hasattr(cs, "product") + class BrandNameRule: - """SEM_BRAND: Products should have a brand name for traceability. + """SEM_BRAND: v0.6 products should have a brand name for traceability. - This example validator checks if products have a brand name specified, - which is important for consumer identification and traceability. + This example validator checks if products have a brand name + specified — important for consumer identification and traceability. + Targets the v0.6.x wire shape (`credentialSubject.product.name`). + For v0.7, see :class:`BrandNameRuleV07` in ``brand_name_v07.py``. """ rule_id: str = "SEM_BRAND" description: str = "Products should have a brand name" severity: Literal["error", "warning", "info"] = "warning" + # Engine's per-version dispatch consults this; v0.7 payloads are + # routed past us to ``BrandNameRuleV07`` instead. + applies_to_versions: tuple[str, ...] = _V06_VERSIONS + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: """Check if product has a brand name. @@ -34,7 +72,12 @@ def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: """ violations: list[tuple[str, str]] = [] - if not passport.credential_subject: + if not _is_v06_shape(passport): + # Not a v0.6 passport — defensive no-op (the engine's + # per-version dispatch should already have skipped us). + return violations + + if passport.credential_subject is None: return violations product = passport.credential_subject.product @@ -50,16 +93,22 @@ def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: class MinMaterialsRule: - """SEM_MINMAT: Products should declare at least 2 materials. + """SEM_MINMAT: v0.6 products should declare at least 2 materials. This example validator encourages comprehensive material disclosure - by checking if at least 2 materials are declared. + by checking if at least 2 materials are declared. Targets the v0.6.x + `credentialSubject.materialsProvenance` array (plural, v0.6 spelling). + A v0.7 sibling would read `credentialSubject.materialProvenance` + (singular) — see ``brand_name_v07.py`` for the version-aware-rule + pattern. """ rule_id: str = "SEM_MINMAT" description: str = "Products should declare at least 2 materials" severity: Literal["error", "warning", "info"] = "info" + applies_to_versions: tuple[str, ...] = _V06_VERSIONS + def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: """Check if product has minimum materials declared. @@ -71,7 +120,10 @@ def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: """ violations: list[tuple[str, str]] = [] - if not passport.credential_subject: + if not _is_v06_shape(passport): + return violations + + if passport.credential_subject is None: return violations materials = passport.credential_subject.materials_provenance diff --git a/src/dppvalidator/cli/commands/export.py b/src/dppvalidator/cli/commands/export.py index 5a619d3..04cbc54 100644 --- a/src/dppvalidator/cli/commands/export.py +++ b/src/dppvalidator/cli/commands/export.py @@ -44,8 +44,12 @@ def add_parser(subparsers: Any) -> argparse.ArgumentParser: ) parser.add_argument( "--schema-version", - default=DEFAULT_SCHEMA_VERSION, - help=f"Schema version (default: {DEFAULT_SCHEMA_VERSION})", + default="auto", + help=( + "UNTP DPP schema version. Defaults to 'auto' — detected " + "from the payload's $schema or @context. Default-version " + f"fallback when detection finds nothing: {DEFAULT_SCHEMA_VERSION}." + ), ) parser.add_argument( "--compact", @@ -79,8 +83,14 @@ def run(args: argparse.Namespace, console: Console) -> int: indent = None if args.compact else 2 + # When the user passed --schema-version=auto (the default), the + # validator resolved a concrete version into ``result.schema_version``. + # Use that for the exporter so the output's @context URL matches the + # payload's actual version, not the literal 'auto' sentinel. + resolved_version = result.schema_version or args.schema_version + if args.format == "jsonld": - exporter = JSONLDExporter(version=args.schema_version) + exporter = JSONLDExporter(version=resolved_version) output = exporter.export(result.passport, indent=indent) else: exporter = JSONExporter() diff --git a/src/dppvalidator/cli/commands/validate.py b/src/dppvalidator/cli/commands/validate.py index 6f057ff..51f3670 100644 --- a/src/dppvalidator/cli/commands/validate.py +++ b/src/dppvalidator/cli/commands/validate.py @@ -49,8 +49,15 @@ def add_parser(subparsers: Any) -> argparse.ArgumentParser: ) parser.add_argument( "--schema-version", - default=DEFAULT_SCHEMA_VERSION, - help=f"Schema version (default: {DEFAULT_SCHEMA_VERSION})", + default="auto", + help=( + "UNTP DPP schema version to validate against. Defaults to " + "'auto' — the engine detects the version from the payload's " + "$schema or @context URLs. Pass an explicit version (e.g. " + "'0.6.1', '0.7.0') to fail fast with VER001 when the payload " + f"declares a different version. Default-version fallback when " + f"detection finds nothing: {DEFAULT_SCHEMA_VERSION}." + ), ) parser.add_argument( "--fail-fast", diff --git a/src/dppvalidator/cli/commands/watch.py b/src/dppvalidator/cli/commands/watch.py index c72089a..710a31a 100644 --- a/src/dppvalidator/cli/commands/watch.py +++ b/src/dppvalidator/cli/commands/watch.py @@ -217,8 +217,13 @@ def add_parser(subparsers: Any) -> argparse.ArgumentParser: ) parser.add_argument( "--schema-version", - default=DEFAULT_SCHEMA_VERSION, - help=f"Schema version (default: {DEFAULT_SCHEMA_VERSION})", + default="auto", + help=( + "UNTP DPP schema version. Defaults to 'auto' — detected " + "from each watched payload's $schema or @context. Pass an " + "explicit version to fail-fast on payloads that declare a " + f"different one. Default-version fallback: {DEFAULT_SCHEMA_VERSION}." + ), ) return parser diff --git a/src/dppvalidator/plugins/registry.py b/src/dppvalidator/plugins/registry.py index f575663..00d1518 100644 --- a/src/dppvalidator/plugins/registry.py +++ b/src/dppvalidator/plugins/registry.py @@ -119,6 +119,7 @@ def run_all_validators( passport: DigitalProductPassport, *, strict: bool = False, + schema_version: str | None = None, ) -> list[ValidationError]: """Run all registered validator plugins. @@ -126,6 +127,13 @@ def run_all_validators( passport: Parsed passport to validate strict: If True, raise PluginError on plugin failures instead of returning a warning. Useful for CI/CD pipelines. + schema_version: Resolved UNTP DPP version of the payload. When + supplied, plugins that declare an ``applies_to_versions`` + tuple are skipped for non-matching versions (the engine's + per-version dispatch contract — same pattern the built-in + semantic-rule registry uses via ``ALL_RULES_BY_VERSION``). + Plugins without that attribute keep running for every + payload — back-compat for pre-Phase-6 plugins. Returns: List of validation errors from all plugins @@ -139,6 +147,16 @@ def run_all_validators( try: instance = validator() if isinstance(validator, type) else validator + # Per-version filter. ``applies_to_versions`` is the + # declarative version-pin contract documented in + # ``docs/guides/plugins.md``. We honour it on both + # the class and the instance so authors can set it + # either way. Plugins that don't declare it run for + # every version (back-compat). + applies = getattr(instance, "applies_to_versions", None) + if applies and schema_version and schema_version not in applies: + continue + if hasattr(instance, "check"): violations = instance.check(passport) rule_id = getattr(instance, "rule_id", f"PLG_{name.upper()}") diff --git a/src/dppvalidator/validators/layers.py b/src/dppvalidator/validators/layers.py index e26127d..879dcd9 100644 --- a/src/dppvalidator/validators/layers.py +++ b/src/dppvalidator/validators/layers.py @@ -244,7 +244,14 @@ def execute(self, context: ValidationContext) -> ValidationResult: if self._registry is None: return ValidationResult(valid=True, schema_version=self._schema_version) - plugin_errors = self._registry.run_all_validators(context.passport) + # Pass the engine's resolved version so plugins that declare + # ``applies_to_versions`` get filtered out for non-matching + # payloads. Plugins without that attribute keep running for + # every payload (back-compat for pre-Phase-6 plugins). + plugin_errors = self._registry.run_all_validators( + context.passport, + schema_version=self._schema_version, + ) errors = [e for e in plugin_errors if e.severity == "error"] warnings = [e for e in plugin_errors if e.severity == "warning"] diff --git a/tests/integration/test_example_plugin.py b/tests/integration/test_example_plugin.py index 06926f4..b557000 100644 --- a/tests/integration/test_example_plugin.py +++ b/tests/integration/test_example_plugin.py @@ -241,6 +241,27 @@ def test_violation_when_product_name_missing(self, v06_passport: Any) -> None: path, _ = violations[0] assert path == "$.credentialSubject.product.name" + def test_applies_to_versions_pins_v06(self) -> None: + """Pins the per-version dispatch contract (Phase 6 / 0.4.0 polish).""" + from dppvalidator_example_plugin.validators import BrandNameRule + + assert BrandNameRule.applies_to_versions == ("0.6.0", "0.6.1") + + def test_silently_skips_v07_passports(self, v07_passport: Any) -> None: + """Defensive duck-typing: v0.7 input never crashes the rule. + + The engine's per-version dispatch normally filters this rule + out for v0.7 payloads (because ``applies_to_versions`` is + v0.6.x). This test exercises the **belt-and-braces path**: + if a caller bypasses dispatch (e.g. by invoking ``check()`` + directly), the rule must still no-op cleanly rather than + crash with ``AttributeError``. + """ + from dppvalidator_example_plugin.validators import BrandNameRule + + rule = BrandNameRule() + assert rule.check(v07_passport) == [] + class TestV06MinMaterialsRule: """The v0.6 ``MinMaterialsRule`` keeps its pre-Phase-3 behaviour.""" @@ -262,6 +283,18 @@ def test_warning_with_one_material(self, v06_passport: Any) -> None: violations = rule.check(v06_passport) assert len(violations) == 1 + def test_applies_to_versions_pins_v06(self) -> None: + from dppvalidator_example_plugin.validators import MinMaterialsRule + + assert MinMaterialsRule.applies_to_versions == ("0.6.0", "0.6.1") + + def test_silently_skips_v07_passports(self, v07_passport: Any) -> None: + """Defensive duck-typing — same contract as ``BrandNameRule``.""" + from dppvalidator_example_plugin.validators import MinMaterialsRule + + rule = MinMaterialsRule() + assert rule.check(v07_passport) == [] + # --------------------------------------------------------------------------- # v0.7 rule behaviour — new in Phase 6 @@ -326,6 +359,88 @@ def test_silently_skips_v06_passports(self, v06_passport: Any) -> None: assert rule.check(v06_passport) == [] +# --------------------------------------------------------------------------- +# Engine-level per-version dispatch (Phase 6 / 0.4.0 polish) +# --------------------------------------------------------------------------- + + +class TestEnginePerVersionPluginDispatch: + """The engine's plugin layer routes plugins by ``applies_to_versions``. + + Prior to the 0.4.0 polish round, all installed plugins ran for + every payload. v0.6 plugins reading ``credential_subject.product`` + crashed on v0.7 payloads, surfacing as ``PLG001`` warnings. The + fix wires ``schema_version`` through to ``run_all_validators`` so + plugins that pin to a specific version are silently skipped on + non-matching payloads. + + These tests pin the contract end-to-end through the engine. + """ + + def test_v07_engine_does_not_trip_v06_plugins(self) -> None: + """A v0.7 payload validated end-to-end produces zero PLG001 entries. + + Pre-fix this test would fail: v0.6 ``BrandNameRule`` and + ``MinMaterialsRule`` would crash on the v0.7 shape and emit + ``PLG001`` warnings. + """ + import json + from pathlib import Path + + from dppvalidator import ValidationEngine + + fixture = ( + Path(__file__).resolve().parents[1] + / "fixtures" + / "valid" + / "untp-dpp-instance-0.7.0.json" + ) + if not fixture.is_file(): + pytest.skip("v0.7 fixture not vendored") + data = json.loads(fixture.read_text(encoding="utf-8")) + + engine = ValidationEngine(schema_version="0.7.0") + result = engine.validate(data) + + plg001 = [ + issue + for issue in result.errors + result.warnings + result.info + if issue.code == "PLG001" + ] + assert plg001 == [], ( + "Plugin filter regressed — PLG001 entries leaked into a v0.7 " + "validation:\n" + "\n".join(f" [{e.code}] {e.path}: {e.message}" for e in plg001) + ) + + def test_v06_engine_runs_v06_plugins(self) -> None: + """The v0.6 plugins still run for v0.6 payloads (no regression).""" + import json + from pathlib import Path + + from dppvalidator import ValidationEngine + + fixture = ( + Path(__file__).resolve().parents[1] + / "fixtures" + / "valid" + / "untp-dpp-instance-0.6.1.json" + ) + if not fixture.is_file(): + pytest.skip("v0.6 fixture not vendored") + data = json.loads(fixture.read_text(encoding="utf-8")) + + engine = ValidationEngine(schema_version="0.6.1") + result = engine.validate(data) + + # No PLG001 (the rules don't crash on a valid v0.6 payload). + plg001 = [ + issue + for issue in result.errors + result.warnings + result.info + if issue.code == "PLG001" + ] + assert plg001 == [] + + # --------------------------------------------------------------------------- # CSV exporter — public-API smoke # --------------------------------------------------------------------------- diff --git a/tests/unit/test_plugins.py b/tests/unit/test_plugins.py index 587cba6..c768608 100644 --- a/tests/unit/test_plugins.py +++ b/tests/unit/test_plugins.py @@ -259,6 +259,121 @@ def check(self, _p): # noqa: ARG002 assert len(errors) == 2 +class TestRunAllValidatorsVersionFilter: + """Per-version plugin dispatch — Phase 6 of UNTP 0.7.0 migration. + + Plugins that declare ``applies_to_versions`` are skipped when the + payload's resolved version doesn't match. Plugins without that + attribute keep running for every version (back-compat). + """ + + @pytest.fixture + def passport(self) -> DigitalProductPassport: + """Minimal passport — the rules below don't introspect it.""" + return DigitalProductPassport( + id="https://example.com/dpp", + issuer=CredentialIssuer(id="https://example.com/issuer", name="Test"), + ) + + def test_plugin_with_matching_applies_to_versions_runs(self, passport): + """Plugin runs when ``schema_version`` is in ``applies_to_versions``.""" + registry = PluginRegistry(auto_discover=False) + + class V07OnlyRule: + rule_id = "V07_ONLY" + severity = "warning" + applies_to_versions = ("0.7.0",) + + def check(self, _p): # noqa: ARG002 + return [("$", "v0.7-only violation")] + + registry.register_validator("v07_only", V07OnlyRule()) + errors = registry.run_all_validators(passport, schema_version="0.7.0") + assert len(errors) == 1 + assert errors[0].code == "V07_ONLY" + + def test_plugin_with_non_matching_applies_to_versions_is_skipped(self, passport): + """Plugin is silently skipped when ``schema_version`` doesn't match. + + Critically: the skipped plugin must NOT raise and must NOT + produce a PLG001 entry — it's filtered before ``check()`` + is called. + """ + registry = PluginRegistry(auto_discover=False) + + class V07OnlyRuleThatWouldCrashOnV06: + rule_id = "V07_ONLY" + severity = "warning" + applies_to_versions = ("0.7.0",) + + def check(self, passport): + # Reach for a v0.7 attribute. If the engine routed a + # v0.6 passport here this would AttributeError. + return [(passport.credential_subject.does_not_exist, "x")] + + registry.register_validator("v07_only", V07OnlyRuleThatWouldCrashOnV06()) + errors = registry.run_all_validators(passport, schema_version="0.6.1") + # Filter applied, ``check()`` never called — zero errors. + assert errors == [] + + def test_plugin_without_applies_to_versions_runs_for_every_version(self, passport): + """Plugins predating Phase 6 still run for every version.""" + registry = PluginRegistry(auto_discover=False) + + class LegacyRule: + rule_id = "LEGACY" + severity = "info" + # No ``applies_to_versions``. + + def check(self, _p): # noqa: ARG002 + return [("$", "legacy violation")] + + registry.register_validator("legacy", LegacyRule()) + for version in ("0.6.0", "0.6.1", "0.7.0", "9.9.9"): + errors = registry.run_all_validators(passport, schema_version=version) + assert len(errors) == 1, f"legacy plugin should run for {version}" + + def test_no_schema_version_arg_runs_every_plugin(self, passport): + """Calling without ``schema_version`` ignores the filter entirely. + + Pre-Phase-6 callers (e.g. anyone invoking + ``PluginRegistry.run_all_validators(passport)``) get the same + behaviour they always had. + """ + registry = PluginRegistry(auto_discover=False) + + class V07OnlyRule: + rule_id = "V07_ONLY" + severity = "warning" + applies_to_versions = ("0.7.0",) + + def check(self, _p): # noqa: ARG002 + return [("$", "v0.7-only violation")] + + registry.register_validator("v07", V07OnlyRule()) + errors = registry.run_all_validators(passport) # no schema_version= + assert len(errors) == 1, "back-compat path: no version → no filter" + + def test_filter_works_on_class_registration_too(self, passport): + """``applies_to_versions`` on a class is honoured (not just on instances).""" + registry = PluginRegistry(auto_discover=False) + + class V07ClassRule: + rule_id = "V07_CLASS" + severity = "warning" + applies_to_versions = ("0.7.0",) + + def check(self, _p): # noqa: ARG002 + return [("$", "x")] + + # Register the class, not an instance — the filter must still see it. + registry.register_validator("v07_class", V07ClassRule) + errors_06 = registry.run_all_validators(passport, schema_version="0.6.1") + errors_07 = registry.run_all_validators(passport, schema_version="0.7.0") + assert errors_06 == [] + assert len(errors_07) == 1 + + class TestDefaultRegistry: """Tests for default registry singleton.""" From 56e203fb799a56a41766a4d7b5e19a3502e898e9 Mon Sep 17 00:00:00 2001 From: Matthias Brenninkmeijer Date: Fri, 8 May 2026 09:40:28 +0200 Subject: [PATCH 09/12] Adapted changes --- .../validators/rules/v0_6/base.py | 58 +++- .../validators/rules/v0_7/base.py | 52 +++- src/dppvalidator/vocabularies/code_lists.py | 59 ++++ tests/unit/test_code_lists.py | 80 ++++++ tests/unit/test_voc_rules_scheme_aware.py | 266 ++++++++++++++++++ 5 files changed, 500 insertions(+), 15 deletions(-) create mode 100644 tests/unit/test_voc_rules_scheme_aware.py diff --git a/src/dppvalidator/validators/rules/v0_6/base.py b/src/dppvalidator/validators/rules/v0_6/base.py index 0b39d53..b142e6c 100644 --- a/src/dppvalidator/validators/rules/v0_6/base.py +++ b/src/dppvalidator/validators/rules/v0_6/base.py @@ -6,6 +6,12 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Literal +from dppvalidator.vocabularies.code_lists import ( + is_hs_scheme as _is_hs_scheme, +) +from dppvalidator.vocabularies.code_lists import ( + is_unece_rec46_scheme as _is_unece_rec46_scheme, +) from dppvalidator.vocabularies.code_lists import ( is_valid_hs_code as _is_valid_hs_code, ) @@ -252,6 +258,13 @@ class MaterialCodeRule: Validates material codes in materialsProvenance against the UNECE Recommendation 46 material classification codes. + + The rule is **scheme-aware**: when ``materialType.schemeID`` + declares a non-Rec46 classification (e.g. UN CPC), the rule + skips silently rather than producing false positives. When + ``schemeID`` is missing, it falls back to the pre-fix + best-effort behaviour for back-compat with legacy v0.6 fixtures. + See :func:`dppvalidator.vocabularies.code_lists.is_unece_rec46_scheme`. """ rule_id: str = "VOC003" @@ -279,16 +292,24 @@ def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: return violations for i, material in enumerate(materials): - # Check material_type.code if present - if material.material_type and material.material_type.code: - code = material.material_type.code - if not self._is_valid_material_code(code): - violations.append( - ( - f"$.credentialSubject.materialsProvenance[{i}].materialType.code", - f"Invalid material code '{code}' - not found in UNECE Rec 46", - ) + mt = material.material_type + if not mt or not mt.code: + continue + code = mt.code + # v0.6 ``Classification`` exposes the scheme via the + # ``scheme_id`` attribute (alias of ``schemeID`` on the + # wire — see ``models/v0_6/primitives.py``). + scheme_id = getattr(mt, "scheme_id", None) + if scheme_id and not _is_unece_rec46_scheme(scheme_id): + # Code claims a different scheme — we have no opinion. + continue + if not self._is_valid_material_code(code): + violations.append( + ( + f"$.credentialSubject.materialsProvenance[{i}].materialType.code", + f"Invalid material code '{code}' - not found in UNECE Rec 46", ) + ) return violations @@ -298,6 +319,12 @@ class HSCodeRule: Validates HS codes in product information against the Harmonized System textile chapters (50-63). + + The rule is **scheme-aware**: when ``classification.schemeID`` + declares a non-HS classification, the rule skips silently. When + ``schemeID`` is missing, it falls back to the + length-and-digit heuristic that matches the pre-fix behaviour. + See :func:`dppvalidator.vocabularies.code_lists.is_hs_scheme`. """ rule_id: str = "VOC004" @@ -328,8 +355,17 @@ def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: if product.product_category: for i, classification in enumerate(product.product_category): code = classification.code if classification.code else "" - # Only validate if it looks like an HS code (4+ digits) - if code.isdigit() and len(code) >= 4 and not self._is_valid_hs_code(code): + scheme_id = getattr(classification, "scheme_id", None) + # Scheme-aware gate: when schemeID is set, only fire + # for HS schemes. When it isn't, fall back to the + # length / digit heuristic (4+ digits looks HS-shaped). + if scheme_id is not None: + if not _is_hs_scheme(scheme_id): + continue + should_check = bool(code) + else: + should_check = code.isdigit() and len(code) >= 4 + if should_check and not self._is_valid_hs_code(code): violations.append( ( f"$.credentialSubject.product.productCategory[{i}].code", diff --git a/src/dppvalidator/validators/rules/v0_7/base.py b/src/dppvalidator/validators/rules/v0_7/base.py index f277e88..82f97bd 100644 --- a/src/dppvalidator/validators/rules/v0_7/base.py +++ b/src/dppvalidator/validators/rules/v0_7/base.py @@ -40,6 +40,12 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Literal +from dppvalidator.vocabularies.code_lists import ( + is_hs_scheme as _is_hs_scheme, +) +from dppvalidator.vocabularies.code_lists import ( + is_unece_rec46_scheme as _is_unece_rec46_scheme, +) from dppvalidator.vocabularies.code_lists import ( is_valid_hs_code as _is_valid_hs_code, ) @@ -305,7 +311,18 @@ def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: class MaterialCodeRule: - """VOC003 (v0.7): material code must be valid per UNECE Rec 46.""" + """VOC003 (v0.7): material code must be valid per UNECE Rec 46. + + The rule is **scheme-aware**: it only fires when the + ``materialType.schemeId`` declares the code as UNECE Rec 46 + (detected by :func:`_is_unece_rec46_scheme`). Codes drawn from + other classifications (UN CPC, GS1 GPC, NACE, …) are skipped — + firing the textile-pilot validator on them produced false + positives. When ``schemeId`` is missing entirely, the rule falls + back to the pre-fix best-effort behaviour (a textile DPP without + a declared scheme is the most common producer of legacy v0.6 + fixtures). + """ rule_id: str = "VOC003" description: str = "Material code must be in UNECE Rec 46" @@ -328,8 +345,17 @@ def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: materials = getattr(product, "material_provenance", []) or [] for i, material in enumerate(materials): mt = getattr(material, "material_type", None) - code = getattr(mt, "code", None) if mt else None - if code and not self._is_valid_material_code(code): + if mt is None: + continue + code = getattr(mt, "code", None) + if not code: + continue + scheme_id = getattr(mt, "scheme_id", None) + # When schemeId is set but isn't UNECE Rec 46, the rule + # has no opinion — skip silently rather than false-positive. + if scheme_id and not _is_unece_rec46_scheme(scheme_id): + continue + if not self._is_valid_material_code(code): violations.append( ( f"$.credentialSubject.materialProvenance[{i}].materialType.code", @@ -344,6 +370,14 @@ class HSCodeRule: v0.7 ``Product.product_category`` is a list of :class:`Classification` (was a single Classification in v0.6); this rule iterates over them. + + The rule is **scheme-aware**: it only fires when the + ``classification.schemeId`` declares the code as a Harmonized + System code (detected by :func:`_is_hs_scheme`). Codes drawn + from other classifications are skipped. When ``schemeId`` is + missing, the rule falls back to a length+digit heuristic + (4+ digits) that matches the pre-fix best-effort behaviour for + legacy v0.6 fixtures that don't declare a scheme. """ rule_id: str = "VOC004" @@ -367,7 +401,17 @@ def check(self, passport: DigitalProductPassport) -> list[tuple[str, str]]: categories = getattr(product, "product_category", []) or [] for i, classification in enumerate(categories): code = getattr(classification, "code", None) or "" - if code.isdigit() and len(code) >= 4 and not self._is_valid_hs_code(code): + scheme_id = getattr(classification, "scheme_id", None) + # Scheme-aware gate: when schemeId is set, only fire for + # HS schemes. When it isn't, fall back to the length / + # digit heuristic (4+ digits looks HS-shaped). + if scheme_id is not None: + if not _is_hs_scheme(scheme_id): + continue + should_check = bool(code) + else: + should_check = code.isdigit() and len(code) >= 4 + if should_check and not self._is_valid_hs_code(code): violations.append( ( f"$.credentialSubject.productCategory[{i}].code", diff --git a/src/dppvalidator/vocabularies/code_lists.py b/src/dppvalidator/vocabularies/code_lists.py index 20093e6..c9560c8 100644 --- a/src/dppvalidator/vocabularies/code_lists.py +++ b/src/dppvalidator/vocabularies/code_lists.py @@ -110,6 +110,65 @@ def is_textile_hs_code(code: str) -> bool: return False +# --------------------------------------------------------------------------- +# Scheme-id detectors +# +# Used by ``MaterialCodeRule`` (VOC003) and ``HSCodeRule`` (VOC004) to gate +# their checks. A ``Classification.schemeId`` is the contract a payload +# uses to declare *which* code list a code belongs to. The textile-pilot +# validators in this module check codes against UNECE Rec 46 and the HS +# textile chapters specifically — firing them on UN CPC, NACE, GS1 GPC, +# or any other classification produces false positives. The rules only +# call ``is_valid_material_code`` / ``is_valid_hs_code`` when the scheme +# id matches one of the patterns below; otherwise they skip silently. +# +# The patterns are deliberately lenient (substring match, case-folded) +# because UN/CEFACT, WCO, and CIRPASS each publish slightly different +# canonical URLs over time. Adding a new positive pattern is a one-line +# change here when fixtures surface a real-world schemeId we want to +# pick up. +# --------------------------------------------------------------------------- + + +_UNECE_REC46_SCHEME_TOKENS: tuple[str, ...] = ( + "unece-rec-46", + "unecerec46", + "rec-46", + "rec46", + "uncefact:codelist:standard:unece:material", + "vocabulary.uncefact.org/unecerec46", +) + +_HS_SCHEME_TOKENS: tuple[str, ...] = ( + "wcoomd", + "harmonized-system", + "harmonized_system", + "uncefact:codelist:standard:wco:hs", + # ``/hs/`` and ``/hs-`` cover the most common URL paths that publish + # Harmonized-System nomenclature variants; the leading slash anchors + # the match to a path segment rather than e.g. an arbitrary brand + # name that happens to contain ``hs``. + "/hs/", + "/hs-", +) + + +def is_unece_rec46_scheme(scheme_id: str | None) -> bool: + """True when ``scheme_id`` plausibly identifies UNECE Rec 46.""" + if not scheme_id: + return False + s = scheme_id.lower() + return any(tok in s for tok in _UNECE_REC46_SCHEME_TOKENS) + + +def is_hs_scheme(scheme_id: str | None) -> bool: + """True when ``scheme_id`` plausibly identifies a Harmonized System code list.""" + if not scheme_id: + return False + s = scheme_id.lower() + return any(tok in s for tok in _HS_SCHEME_TOKENS) + + def validate_gtin(gtin: str) -> bool: """Validate a GTIN (Global Trade Item Number) checksum. diff --git a/tests/unit/test_code_lists.py b/tests/unit/test_code_lists.py index b98d807..01dcb49 100644 --- a/tests/unit/test_code_lists.py +++ b/tests/unit/test_code_lists.py @@ -283,3 +283,83 @@ def test_unknown_chapter_returns_none(self) -> None: """Unknown chapter returns None.""" assert get_hs_chapter_description("9901") is None assert get_hs_chapter_description("0100") is None + + +class TestSchemeDetectors: + """``is_unece_rec46_scheme`` / ``is_hs_scheme`` — added with the + VOC003 / VOC004 scheme-aware fix. + + These detectors decide whether the textile-pilot code validators + should fire for a given ``Classification.schemeId`` value. + Conservative substring matching (case-folded) avoids false + positives on UN CPC, NACE, GS1 GPC, and other classifications. + """ + + def test_unece_rec46_recognises_canonical_url_forms(self) -> None: + from dppvalidator.vocabularies.code_lists import is_unece_rec46_scheme + + assert is_unece_rec46_scheme("https://vocabulary.uncefact.org/unecerec46/") + assert is_unece_rec46_scheme("urn:un:unece:uncefact:codelist:standard:UNECE:Material:46") + assert is_unece_rec46_scheme("https://example.com/unece-rec-46/codes") + assert is_unece_rec46_scheme("https://example.com/unece/rec46/") + + def test_unece_rec46_rejects_other_classifications(self) -> None: + from dppvalidator.vocabularies.code_lists import is_unece_rec46_scheme + + # UN CPC — the scheme used by every v0.7 fixture under + # tests/fixtures/valid/. This is the failure mode the fix + # exists to prevent. + assert not is_unece_rec46_scheme("https://unstats.un.org/unsd/classifications/Econ/cpc/") + # Other common non-Rec46 schemes. + assert not is_unece_rec46_scheme("https://gs1.org/voc/CategoryCode") + assert not is_unece_rec46_scheme("https://wcoomd.org/hs-nomenclature") + assert not is_unece_rec46_scheme("urn:nace:r2:2008") + + def test_unece_rec46_rejects_falsy_inputs(self) -> None: + from dppvalidator.vocabularies.code_lists import is_unece_rec46_scheme + + assert not is_unece_rec46_scheme(None) + assert not is_unece_rec46_scheme("") + + def test_unece_rec46_is_case_insensitive(self) -> None: + from dppvalidator.vocabularies.code_lists import is_unece_rec46_scheme + + assert is_unece_rec46_scheme("HTTPS://EXAMPLE.COM/UNECE-REC-46") + assert is_unece_rec46_scheme("Rec46") + + def test_hs_scheme_recognises_canonical_url_forms(self) -> None: + from dppvalidator.vocabularies.code_lists import is_hs_scheme + + assert is_hs_scheme("https://wcoomd.org/hs-nomenclature/2017") + assert is_hs_scheme("urn:un:unece:uncefact:codelist:standard:WCO:HS:2022") + assert is_hs_scheme("https://example.com/harmonized-system/") + assert is_hs_scheme("https://example.com/customs/hs/") + + def test_hs_scheme_rejects_other_classifications(self) -> None: + from dppvalidator.vocabularies.code_lists import is_hs_scheme + + # UN CPC again — this is what the v0.7 fixtures use; the fix + # prevents VOC004 from firing on these. + assert not is_hs_scheme("https://unstats.un.org/unsd/classifications/Econ/cpc/") + assert not is_hs_scheme("urn:nace:r2:2008") + assert not is_hs_scheme("https://example.com/cpc/") + + def test_hs_scheme_rejects_falsy_inputs(self) -> None: + from dppvalidator.vocabularies.code_lists import is_hs_scheme + + assert not is_hs_scheme(None) + assert not is_hs_scheme("") + + def test_hs_scheme_anchors_path_tokens(self) -> None: + """``/hs/`` and ``/hs-`` only match path segments, not arbitrary text. + + The leading slash anchors the substring so e.g. ``brands.example.com`` + doesn't get false-matched by the ``hs`` substring. + """ + from dppvalidator.vocabularies.code_lists import is_hs_scheme + + # Negative — the bare ``hs`` substring inside a domain shouldn't + # match (it's not in a path segment). + assert not is_hs_scheme("https://example.com/things/") + # Positive — actual HS path segment. + assert is_hs_scheme("https://example.com/customs/hs/2022/") diff --git a/tests/unit/test_voc_rules_scheme_aware.py b/tests/unit/test_voc_rules_scheme_aware.py new file mode 100644 index 0000000..2d54627 --- /dev/null +++ b/tests/unit/test_voc_rules_scheme_aware.py @@ -0,0 +1,266 @@ +"""Regression tests: VOC003/VOC004 must be scheme-aware. + +Both rules check classification codes against textile-pilot +validators (UNECE Rec 46 for materials, HS chapters 50-63 for +products). Before the fix, they fired unconditionally on every +``Classification.code`` regardless of the scheme — producing false +positives for UN CPC, NACE, GS1 GPC, and any other classification. + +The fix gates the validator call on the ``schemeId``: + +- When ``schemeId`` is set AND matches the rule's scheme: validate. +- When ``schemeId`` is set AND does NOT match: skip silently. +- When ``schemeId`` is missing: fall back to pre-fix best-effort + behaviour (a length/digit heuristic for VOC004, validate + unconditionally for VOC003) for back-compat with legacy fixtures. + +These tests pin the contract end-to-end through both v0.6 and v0.7 +rule implementations. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +_FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "valid" + + +# --------------------------------------------------------------------------- +# Real-fixture round-trip — the canonical regression +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "fixture_name", + [ + "untp-dpp-instance-0.7.0.json", + "untp-dpp-battery-instance-0.7.0.json", + "untp-dpp-cathode-instance-0.7.0.json", + ], +) +def test_v07_un_cpc_fixtures_emit_no_voc003_or_voc004(fixture_name: str) -> None: + """v0.7 UN CPC fixtures must not trip the textile-pilot rules. + + All three vendored upstream samples use UN CPC scheme IDs + (``https://unstats.un.org/unsd/classifications/Econ/cpc/``). + Before the scheme-aware fix, every classification code in these + fixtures spuriously emitted VOC003 + VOC004 because the rules + blindly ran textile validators on UN CPC codes. The fix gates + the validation on the scheme — UN CPC isn't UNECE Rec 46 or HS, + so the rules skip silently. + """ + from dppvalidator.validators import ValidationEngine + + fixture_path = _FIXTURES / fixture_name + if not fixture_path.is_file(): + pytest.skip(f"fixture not vendored: {fixture_name}") + + data = json.loads(fixture_path.read_text(encoding="utf-8")) + engine = ValidationEngine(schema_version="0.7.0", layers=["semantic"]) + result = engine.validate(data) + + voc003 = [w for w in result.warnings + result.errors if w.code == "VOC003"] + voc004 = [w for w in result.warnings + result.errors if w.code == "VOC004"] + assert voc003 == [], ( + f"{fixture_name} unexpectedly emitted VOC003 (scheme-aware fix regressed):\n" + + "\n".join(f" {w.path}: {w.message}" for w in voc003) + ) + assert voc004 == [], ( + f"{fixture_name} unexpectedly emitted VOC004 (scheme-aware fix regressed):\n" + + "\n".join(f" {w.path}: {w.message}" for w in voc004) + ) + + +def test_v06_un_cpc_fixture_emits_no_voc003_or_voc004() -> None: + """The vendored v0.6.1 UN CPC fixture is also clean after the fix.""" + from dppvalidator.validators import ValidationEngine + + fixture_path = _FIXTURES / "untp-dpp-instance-0.6.1.json" + if not fixture_path.is_file(): + pytest.skip("v0.6 fixture not vendored") + + data = json.loads(fixture_path.read_text(encoding="utf-8")) + engine = ValidationEngine(schema_version="0.6.1", layers=["semantic"]) + result = engine.validate(data) + + voc003 = [w for w in result.warnings + result.errors if w.code == "VOC003"] + voc004 = [w for w in result.warnings + result.errors if w.code == "VOC004"] + assert voc003 == [] + assert voc004 == [] + + +# --------------------------------------------------------------------------- +# v0.7 unit-level: rule check() with constructed Material / Classification +# --------------------------------------------------------------------------- + + +def _build_v07_passport_with( + materials: list[dict[str, Any]] | None = None, categories: list[dict[str, Any]] | None = None +) -> Any: + """Construct a minimal v0.7 ``DigitalProductPassport`` for rule tests.""" + from dppvalidator.models.v0_7.envelope import DigitalProductPassport + + payload: dict[str, Any] = { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://vocabulary.uncefact.org/untp/0.7.0/context/", + ], + "type": ["DigitalProductPassport", "VerifiableCredential"], + "id": "https://example.com/credentials/test", + "name": "Test DPP", + "issuer": { + "type": ["CredentialIssuer"], + "id": "did:example:1", + "name": "Example", + }, + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "type": ["Product"], + "id": "https://example.com/products/1", + "name": "Test product", + "idScheme": { + "type": ["IdentifierScheme"], + "id": "https://example.com/scheme", + "name": "Internal", + }, + "idGranularity": "model", + "productCategory": categories + or [ + { + "type": ["Classification"], + "schemeId": "https://example.com/scheme", + "schemeName": "Internal", + "code": "X", + "name": "x", + } + ], + "producedAtFacility": { + "type": ["Facility"], + "id": "https://example.com/facilities/1", + "name": "Facility", + }, + "countryOfProduction": {"countryCode": "DE", "countryName": "Germany"}, + "materialProvenance": materials or [], + }, + } + return DigitalProductPassport.model_validate(payload) + + +class TestV07MaterialCodeRule: + """Scheme-gating on the v0.7 ``MaterialCodeRule`` (VOC003).""" + + def _material(self, *, scheme_id: str | None, code: str = "9999") -> dict[str, Any]: + material_type: dict[str, Any] = { + "type": ["Classification"], + "schemeName": "Test", + "code": code, + "name": "x", + } + if scheme_id is not None: + material_type["schemeId"] = scheme_id + return { + "name": "Test material", + "originCountry": {"countryCode": "DE", "countryName": "Germany"}, + "materialType": material_type, + "massFraction": 1.0, + } + + def test_un_cpc_scheme_skips_validation(self) -> None: + from dppvalidator.validators.rules.v0_7.base import MaterialCodeRule + + # ``9999`` is not a real UNECE Rec 46 code — but the rule + # must not fire because the scheme isn't Rec 46. + passport = _build_v07_passport_with( + materials=[ + self._material( + scheme_id="https://unstats.un.org/unsd/classifications/Econ/cpc/", + code="9999", + ), + ], + categories=[ + { + "type": ["Classification"], + "schemeId": "https://unstats.un.org/unsd/classifications/Econ/cpc/", + "schemeName": "UN CPC", + "code": "12345", + "name": "x", + }, + ], + ) + rule = MaterialCodeRule(material_validator=lambda code: code == "0000") + assert rule.check(passport) == [] + + def test_unece_rec46_scheme_fires_on_invalid_code(self) -> None: + from dppvalidator.validators.rules.v0_7.base import MaterialCodeRule + + passport = _build_v07_passport_with( + materials=[ + self._material( + scheme_id="https://vocabulary.uncefact.org/UNECERec46/", + code="9999", + ), + ], + categories=[ + { + "type": ["Classification"], + "schemeId": "https://vocabulary.uncefact.org/UNECERec46/", + "schemeName": "UNECE Rec 46", + "code": "12345", + "name": "x", + }, + ], + ) + # Stub validator: accepts only "0000". + rule = MaterialCodeRule(material_validator=lambda code: code == "0000") + violations = rule.check(passport) + assert len(violations) == 1 + assert "9999" in violations[0][1] + + +class TestV07HSCodeRule: + """Scheme-gating on the v0.7 ``HSCodeRule`` (VOC004).""" + + def _category(self, *, scheme_id: str | None, code: str = "12345") -> dict[str, Any]: + cat: dict[str, Any] = { + "type": ["Classification"], + "schemeName": "Test", + "code": code, + "name": "x", + } + if scheme_id is not None: + cat["schemeId"] = scheme_id + return cat + + def test_un_cpc_scheme_skips_validation(self) -> None: + from dppvalidator.validators.rules.v0_7.base import HSCodeRule + + passport = _build_v07_passport_with( + categories=[ + self._category( + scheme_id="https://unstats.un.org/unsd/classifications/Econ/cpc/", + code="9999", # would trip the textile validator if not gated + ), + ], + ) + rule = HSCodeRule(hs_validator=lambda _code: False) + assert rule.check(passport) == [] + + def test_hs_scheme_fires_on_invalid_code(self) -> None: + from dppvalidator.validators.rules.v0_7.base import HSCodeRule + + passport = _build_v07_passport_with( + categories=[ + self._category( + scheme_id="https://wcoomd.org/hs-nomenclature/2017", + code="9999", + ), + ], + ) + rule = HSCodeRule(hs_validator=lambda _code: False) + violations = rule.check(passport) + assert len(violations) == 1 + assert "9999" in violations[0][1] From 5228c586c51a7bc597299b33dd85c4dadb008b9f Mon Sep 17 00:00:00 2001 From: Matthias Brenninkmeijer Date: Fri, 8 May 2026 10:23:41 +0200 Subject: [PATCH 10/12] Smoke test --- .pre-commit-config.yaml | 9 +- pyproject.toml | 3 + scripts/smoke_test.py | 895 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 904 insertions(+), 3 deletions(-) create mode 100644 scripts/smoke_test.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d690299..a1b6d97 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,10 +42,13 @@ repos: rev: v0.14.14 hooks: # bandit (security) - exclude notebooks (example code uses random) + # ``scripts/`` is excluded too: it holds dev-only utilities (e.g. the + # functional smoke test) that legitimately drive ``subprocess.run`` + # with controlled, env-overridable binary paths — not user input. - id: ruff types_or: [ python, pyi ] args: [ "--fix", "--select=S", "--ignore=S101,S110" ] - exclude: ^(tests/|mutants/|examples/) + exclude: ^(tests/|mutants/|examples/|scripts/) # isort - id: ruff types_or: [ python, pyi, jupyter ] @@ -54,7 +57,7 @@ repos: - id: ruff types_or: [ python, pyi ] args: [ "--select", "ANN", "--ignore", "ANN101,ANN102,ANN401,ANN002,ANN003" ] - exclude: ^(tests/|mutants/|benchmarks/|examples/).*$ + exclude: ^(tests/|mutants/|benchmarks/|examples/|scripts/).*$ # Replace %s statements with f-string syntax - id: ruff types_or: [ python, pyi, jupyter ] @@ -64,7 +67,7 @@ repos: name: ruff-check-all types_or: [ python, pyi ] args: [ "--output-format=github" ] - exclude: ^(tests/|mutants/|benchmarks/|examples/) + exclude: ^(tests/|mutants/|benchmarks/|examples/|scripts/) # formatting - id: ruff-format types_or: [ python, pyi, jupyter ] diff --git a/pyproject.toml b/pyproject.toml index b43c343..d4d97f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,6 +93,9 @@ include = [ "src/dppvalidator/**/*.xsd", "src/dppvalidator/**/*.yaml", ] +exclude = [ + "scripts/**", +] [dependency-groups] dev = [ diff --git a/scripts/smoke_test.py b/scripts/smoke_test.py new file mode 100644 index 0000000..cd2390d --- /dev/null +++ b/scripts/smoke_test.py @@ -0,0 +1,895 @@ +#!/usr/bin/env python3 +"""Functional smoke test for ``dppvalidator``. + +Exercises every user-facing surface end-to-end against the bundled +test fixtures — CLI commands, Python APIs, plugin discovery, the +compat shim, and the EU DPP exporter. Standalone: no pytest, no +test framework, just subprocess + the project's installed Python. +Returns exit code ``0`` when every check passes, non-zero otherwise. + +Run from the repo root:: + + .venv/bin/python scripts/smoke_test.py + +Or, against the conda env:: + + DPP_BIN=$HOME/miniforge3/envs/dppvalidator/bin/dppvalidator \\ + PY_BIN=$HOME/miniforge3/envs/dppvalidator/bin/python \\ + python scripts/smoke_test.py + +Environment overrides: + +- ``DPP_BIN`` — path to the ``dppvalidator`` executable + (default: ``.venv/bin/dppvalidator``). +- ``PY_BIN`` — path to a Python that has ``dppvalidator`` installed + (default: ``.venv/bin/python``). + +Each section's checks are documented inline. The test is +intentionally **non-destructive**: it never writes outside +``tempfile.TemporaryDirectory()`` or pollutes the working tree. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import tempfile +import textwrap +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +FIXTURES = REPO / "tests" / "fixtures" +DPP_BIN = os.environ.get("DPP_BIN", str(REPO / ".venv" / "bin" / "dppvalidator")) +PY_BIN = os.environ.get("PY_BIN", str(REPO / ".venv" / "bin" / "python")) + +# ANSI colours — disabled when output is piped (CI logs). +_ISATTY = sys.stdout.isatty() +GREEN = "\033[32m" if _ISATTY else "" +RED = "\033[31m" if _ISATTY else "" +YELLOW = "\033[33m" if _ISATTY else "" +BLUE = "\033[34m" if _ISATTY else "" +DIM = "\033[2m" if _ISATTY else "" +RESET = "\033[0m" if _ISATTY else "" + + +# --------------------------------------------------------------------------- +# Tiny assertion harness — no test framework, just structured output +# --------------------------------------------------------------------------- + + +@dataclass +class Smoke: + passed: int = 0 + failed: int = 0 + skipped: int = 0 + failures: list[tuple[str, str]] = field(default_factory=list) + + def section(self, label: str) -> None: + print(f"\n{BLUE}═══ {label} {'═' * (60 - len(label))}{RESET}") + + def ok(self, label: str) -> None: + self.passed += 1 + print(f" {GREEN}✓{RESET} {label}") + + def fail(self, label: str, detail: str = "") -> None: + self.failed += 1 + print(f" {RED}✗{RESET} {label}") + if detail: + for line in detail.rstrip().splitlines()[:6]: + print(f" {DIM}{line}{RESET}") + self.failures.append((label, detail)) + + def skip(self, label: str, reason: str) -> None: + self.skipped += 1 + print(f" {YELLOW}~{RESET} {label} {DIM}({reason}){RESET}") + + def assert_(self, label: str, condition: bool, detail: str = "") -> None: + if condition: + self.ok(label) + else: + self.fail(label, detail) + + def cli( + self, + args: list[str], + *, + stdin: str | None = None, + ) -> subprocess.CompletedProcess[str]: + """Invoke the CLI; never raises. Returns the completed process.""" + return subprocess.run( + [DPP_BIN, *args], + capture_output=True, + text=True, + input=stdin, + cwd=str(REPO), + check=False, + ) + + def py(self, code: str) -> subprocess.CompletedProcess[str]: + """Run a Python snippet under PY_BIN. Never raises.""" + return subprocess.run( + [PY_BIN, "-c", textwrap.dedent(code)], + capture_output=True, + text=True, + cwd=str(REPO), + check=False, + ) + + def report(self) -> int: + total = self.passed + self.failed + self.skipped + print() + print("─" * 64) + if self.failed == 0: + colour = GREEN + verdict = "PASSED" + else: + colour = RED + verdict = "FAILED" + print( + f"{colour}{verdict}{RESET}: " + f"{self.passed} passed, {self.failed} failed, {self.skipped} skipped " + f"({total} total)" + ) + if self.failures: + print() + print(f"{RED}Failures:{RESET}") + for label, detail in self.failures: + print(f" • {label}") + if detail: + for line in detail.rstrip().splitlines()[:3]: + print(f" {DIM}{line}{RESET}") + return 0 if self.failed == 0 else 1 + + +# --------------------------------------------------------------------------- +# Pre-flight +# --------------------------------------------------------------------------- + + +def _check_environment(s: Smoke) -> bool: + """Bail out early if the binaries we expect aren't there.""" + s.section("0. Pre-flight") + dpp_path = Path(DPP_BIN) + py_path = Path(PY_BIN) + + s.assert_( + f"DPP_BIN exists ({DPP_BIN})", + dpp_path.is_file() and os.access(dpp_path, os.X_OK), + f"set DPP_BIN to a working dppvalidator executable (got {DPP_BIN})", + ) + s.assert_( + f"PY_BIN exists ({PY_BIN})", + py_path.is_file() and os.access(py_path, os.X_OK), + f"set PY_BIN to a Python that has dppvalidator installed (got {PY_BIN})", + ) + s.assert_( + "fixtures directory present", + (FIXTURES / "valid").is_dir(), + f"missing: {FIXTURES / 'valid'}", + ) + return s.failed == 0 + + +# --------------------------------------------------------------------------- +# Sections — each tests one cohesive surface +# --------------------------------------------------------------------------- + + +def section_cli_sanity(s: Smoke) -> None: + s.section("1. CLI sanity") + r = s.cli(["--version"]) + s.assert_("--version exits 0", r.returncode == 0, r.stderr) + s.assert_( + "--version output mentions 'dppvalidator'", + "dppvalidator" in r.stdout.lower(), + r.stdout, + ) + + r = s.cli(["--help"]) + s.assert_("--help exits 0", r.returncode == 0, r.stderr) + for cmd in ("validate", "migrate", "export", "schema"): + s.assert_( + f"--help advertises subcommand '{cmd}'", + cmd in r.stdout, + r.stdout[:200], + ) + + +def section_schema_management(s: Smoke) -> None: + s.section("2. Schema management") + r = s.cli(["schema", "list"]) + s.assert_("schema list exits 0", r.returncode == 0, r.stderr) + for version in ("0.6.0", "0.6.1", "0.7.0"): + s.assert_( + f"schema list lists {version}", + version in r.stdout, + r.stdout, + ) + + r = s.cli(["schema", "info", "-v", "0.7.0"]) + s.assert_("schema info -v 0.7.0 exits 0", r.returncode == 0, r.stderr) + + +def section_validate_auto_detect(s: Smoke) -> None: + s.section("3. validate — auto-detect") + v06 = FIXTURES / "valid" / "untp-dpp-instance-0.6.1.json" + v07 = FIXTURES / "valid" / "untp-dpp-instance-0.7.0.json" + + r = s.cli(["validate", str(v06)]) + s.assert_("v0.6 fixture validates clean", r.returncode == 0, r.stdout) + s.assert_( + "v0.6 reports 'Schema version: 0.6.1'", + "Schema version: 0.6.1" in r.stdout, + r.stdout, + ) + + r = s.cli(["validate", str(v07)]) + s.assert_("v0.7 fixture validates clean", r.returncode == 0, r.stdout) + s.assert_( + "v0.7 reports 'Schema version: 0.7.0'", + "Schema version: 0.7.0" in r.stdout, + r.stdout, + ) + # Regressions from earlier rounds — must not resurface. + s.assert_( + "v0.7 emits no PLG001 (per-version plugin filter)", "PLG001" not in r.stdout, r.stdout[:300] + ) + s.assert_( + "v0.7 emits no VOC003 on UN CPC fixture (scheme-aware)", + "VOC003" not in r.stdout, + r.stdout[:300], + ) + s.assert_( + "v0.7 emits no VOC004 on UN CPC fixture (scheme-aware)", + "VOC004" not in r.stdout, + r.stdout[:300], + ) + + # Sibling v0.7 fixtures exercise different ``credentialSubject`` shapes + # (battery + cathode have richer attestation/material structures) — they + # should also auto-detect cleanly with no PLG001 or VOC003/VOC004 noise. + for sibling in ("untp-dpp-battery-instance-0.7.0.json", "untp-dpp-cathode-instance-0.7.0.json"): + f = FIXTURES / "valid" / sibling + if not f.is_file(): + s.skip(f"{sibling} round-trip", "fixture not vendored") + continue + r = s.cli(["validate", str(f)]) + s.assert_(f"{sibling} validates clean", r.returncode == 0, r.stdout) + s.assert_( + f"{sibling} auto-detects as 0.7.0", + "Schema version: 0.7.0" in r.stdout, + r.stdout[:200], + ) + s.assert_( + f"{sibling} emits no PLG001/VOC003/VOC004", + not any(c in r.stdout for c in ("PLG001", "VOC003", "VOC004")), + r.stdout[:300], + ) + + +def section_validate_explicit_pin(s: Smoke) -> None: + s.section("4. validate — explicit pin + VER001") + v06 = FIXTURES / "valid" / "untp-dpp-instance-0.6.1.json" + + r = s.cli(["validate", str(v06), "--schema-version", "0.6.1"]) + s.assert_("v0.6 + matching pin exits 0", r.returncode == 0, r.stdout) + + r = s.cli(["validate", str(v06), "--schema-version", "0.7.0"]) + s.assert_("VER001 fail-fast on mismatched pin", r.returncode != 0, r.stdout) + s.assert_("VER001 code surfaces in output", "VER001" in r.stdout, r.stdout) + + +def section_validate_invalid(s: Smoke) -> None: + s.section("5. validate — invalid fixtures (parametrized)") + invalid_root = FIXTURES / "invalid" + if not invalid_root.is_dir(): + s.skip("invalid-fixture sweep", f"missing {invalid_root}") + return + + # Anchor case — pin the SCH001/MDL001 contract on one well-known fixture. + anchor = invalid_root / "missing_issuer.json" + if anchor.is_file(): + r = s.cli(["validate", str(anchor)]) + s.assert_("missing_issuer.json exits non-zero", r.returncode != 0, r.stdout) + s.assert_( + "missing_issuer.json reports SCH001 or MDL001", + "SCH001" in r.stdout or "MDL001" in r.stdout, + r.stdout, + ) + + # Sweep every other v0.6 invalid fixture — each must (a) exit non-zero and + # (b) emit at least one structured error code. We don't assert which code, + # since that's the job of the unit suite — we're verifying the CLI's + # error-surfacing contract holds across every failure shape. + v06_invalids = sorted(p for p in invalid_root.glob("*.json") if p.name != "missing_issuer.json") + for f in v06_invalids: + r = s.cli(["validate", str(f)]) + s.assert_( + f"{f.name} exits non-zero", + r.returncode != 0, + f"exit={r.returncode} stdout={r.stdout[:200]}", + ) + # An error code is any AAA000-style token the CLI surfaces. + has_code = bool(re.search(r"\b[A-Z]{2,4}\d{3}\b", r.stdout)) + s.assert_(f"{f.name} surfaces a structured error code", has_code, r.stdout[:200]) + + # Sweep all v0.7 invalid fixtures (subdir). + v07_dir = invalid_root / "0.7.0" + if v07_dir.is_dir(): + v07_invalids = sorted(v07_dir.glob("*.json")) + for f in v07_invalids: + r = s.cli(["validate", str(f), "--schema-version", "0.7.0"]) + s.assert_( + f"v0.7/{f.name} exits non-zero", + r.returncode != 0, + f"exit={r.returncode} stdout={r.stdout[:200]}", + ) + + +def section_output_formats(s: Smoke) -> None: + s.section("6. validate — output formats") + v07 = FIXTURES / "valid" / "untp-dpp-instance-0.7.0.json" + + # JSON format must be parseable. + r = s.cli(["validate", str(v07), "--format", "json"]) + s.assert_("--format json exits 0", r.returncode == 0, r.stderr) + try: + parsed = json.loads(r.stdout) + s.assert_("--format json output is valid JSON", True) + s.assert_( + "--format json includes 'valid' key", + "valid" in parsed, + json.dumps(parsed, indent=2)[:200], + ) + except json.JSONDecodeError as exc: + s.fail("--format json output is valid JSON", f"{exc}: {r.stdout[:200]}") + + # Table format runs without crashing (Rich required for nicest output but + # the fallback table formatter ships in-tree). + r = s.cli(["validate", str(v07), "--format", "table"]) + s.assert_("--format table exits 0", r.returncode == 0, r.stderr) + + +def section_stdin(s: Smoke) -> None: + s.section("7. validate — stdin input") + payload = (FIXTURES / "valid" / "minimal_dpp.json").read_text(encoding="utf-8") + r = s.cli(["validate", "-"], stdin=payload) + # Either valid (0) or invalid (1) is fine — what matters is that the CLI + # accepted stdin without crashing (which would be exit code 2). + s.assert_( + "stdin input accepted (exit 0 or 1)", + r.returncode in (0, 1), + f"exit={r.returncode} stderr={r.stderr[:200]}", + ) + s.assert_( + "stdin output contains 'Schema version'", + "Schema version" in r.stdout, + r.stdout[:200], + ) + + +def section_migrate(s: Smoke) -> None: + s.section("8. migrate — v0.6 → v0.7") + v06 = FIXTURES / "valid" / "untp-dpp-instance-0.6.1.json" + + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + + # Happy path: --accept-warnings writes both upgraded.json + sidecar. + out = tmp / "upgraded.json" + r = s.cli(["migrate", str(v06), "-o", str(out), "--accept-warnings"]) + s.assert_("migrate --accept-warnings exits 0", r.returncode == 0, r.stderr) + s.assert_("migrate produced upgraded.json", out.is_file()) + sidecar = tmp / "upgraded.json.warnings.json" + s.assert_("migrate produced sidecar .warnings.json", sidecar.is_file()) + + if out.is_file(): + upgraded = json.loads(out.read_text(encoding="utf-8")) + ctxs = upgraded.get("@context") or [] + s.assert_( + "upgraded payload @context references v0.7", + any(isinstance(c, str) and "vocabulary.uncefact.org/untp/0.7" in c for c in ctxs), + json.dumps(ctxs)[:200], + ) + + if sidecar.is_file(): + warnings_doc = json.loads(sidecar.read_text(encoding="utf-8")) + s.assert_( + "sidecar records the v0.6 → v0.7 transition", + "0.6" in str(warnings_doc.get("schema_version_from", "")) + and "0.7" in str(warnings_doc.get("schema_version_to", "")), + json.dumps(warnings_doc)[:200], + ) + + # Refusal path: no --accept-warnings → exit 1, sidecar STILL written, + # main output NOT written. + out2 = tmp / "blocked.json" + r = s.cli(["migrate", str(v06), "-o", str(out2)]) + s.assert_( + "migrate without --accept-warnings refuses (exit 1)", + r.returncode == 1, + r.stderr or r.stdout, + ) + s.assert_( + "migrate did NOT write main file when blocked", + not out2.is_file(), + ) + s.assert_( + "migrate STILL wrote sidecar when blocked", + (tmp / "blocked.json.warnings.json").is_file(), + ) + + +def section_validate_upgrade_from(s: Smoke) -> None: + s.section("9. validate --upgrade-from (one-shot)") + v06 = FIXTURES / "valid" / "untp-dpp-instance-0.6.1.json" + r = s.cli( + [ + "validate", + str(v06), + "--upgrade-from", + "0.6.1", + "--schema-version", + "0.7.0", + ] + ) + # exit 0 (clean upgrade) or 1 (residual schema warnings) — both fine. + s.assert_( + "--upgrade-from runs without crashing", + r.returncode in (0, 1), + f"exit={r.returncode} stderr={r.stderr[:200]}", + ) + has_upgrade_warnings = any(c in r.stdout for c in ("UPG001", "UPG002", "UPG003", "UPG004")) + s.assert_( + "--upgrade-from surfaces UPG warnings", + has_upgrade_warnings, + r.stdout[:300], + ) + + +def section_export(s: Smoke) -> None: + s.section("10. export — JSON-LD") + v07 = FIXTURES / "valid" / "untp-dpp-instance-0.7.0.json" + with tempfile.TemporaryDirectory() as tmpdir: + out = Path(tmpdir) / "exported.jsonld" + r = s.cli(["export", str(v07), "--format", "jsonld", "-o", str(out)]) + s.assert_("export jsonld exits 0", r.returncode == 0, r.stderr) + s.assert_("export wrote output file", out.is_file()) + if out.is_file(): + data = json.loads(out.read_text(encoding="utf-8")) + s.assert_("exported output has @context", "@context" in data) + ctxs = data.get("@context") or [] + s.assert_( + "export @context references v0.7 (auto-detect threaded)", + any(isinstance(c, str) and "vocabulary.uncefact.org/untp/0.7" in c for c in ctxs), + json.dumps(ctxs)[:200], + ) + + # JSON format also works. + out_json = Path(tmpdir) / "exported.json" + r = s.cli(["export", str(v07), "--format", "json", "-o", str(out_json)]) + s.assert_("export json exits 0", r.returncode == 0, r.stderr) + s.assert_("export json wrote output file", out_json.is_file()) + + +def section_python_api(s: Smoke) -> None: + s.section("11. Python API — ValidationEngine") + r = s.py( + """ + from dppvalidator.validators import ValidationEngine + engine = ValidationEngine(schema_version='0.7.0', layers=['model']) + result = engine.validate({ + '@context': [ + 'https://www.w3.org/ns/credentials/v2', + 'https://vocabulary.uncefact.org/untp/0.7.0/context/', + ], + 'type': ['DigitalProductPassport', 'VerifiableCredential'], + 'id': 'urn:test', + 'name': 'Test', + 'issuer': {'type': ['CredentialIssuer'], 'id': 'did:test', 'name': 'Test'}, + 'validFrom': '2024-01-01T00:00:00Z', + 'credentialSubject': { + 'type': ['Product'], + 'id': 'urn:p', + 'name': 'P', + 'idScheme': {'type': ['IdentifierScheme'], 'id': 'urn:s', 'name': 's'}, + 'idGranularity': 'model', + 'productCategory': [{ + 'type': ['Classification'], + 'schemeId': 'urn:cpc', + 'schemeName': 's', + 'code': '1', + 'name': 'n', + }], + 'producedAtFacility': {'type': ['Facility'], 'id': 'urn:f', 'name': 'f'}, + 'countryOfProduction': {'countryCode': 'DE', 'countryName': 'Germany'}, + }, + }) + assert result.valid, f'unexpected errors: {result.errors}' + assert result.passport is not None + assert result.schema_version == '0.7.0' + print('OK') + """ + ) + s.assert_( + "ValidationEngine validates a v0.7 dict end-to-end", + r.returncode == 0 and "OK" in r.stdout, + r.stderr or r.stdout, + ) + + +def section_compat_api(s: Smoke) -> None: + s.section("12. Python API — compat shim") + v06 = FIXTURES / "valid" / "untp-dpp-instance-0.6.1.json" + r = s.py( + f""" + import json + from pathlib import Path + from dppvalidator.compat import ( + upgrade, + active_version, + is_version, + UpgradeWarning, + UpgradeSeverity, + ) + + v06 = json.loads(Path({json.dumps(str(v06))}).read_text(encoding='utf-8')) + upgraded, warnings = upgrade(v06, country_lookup={{'AU': 'Australia'}}) + + # Shape contract. + assert isinstance(upgraded, dict), type(upgraded) + assert isinstance(warnings, list), type(warnings) + assert all(isinstance(w, UpgradeWarning) for w in warnings) + assert any(w.severity == UpgradeSeverity.WARNING or + w.severity == UpgradeSeverity.INFO for w in warnings) + + # Context rewritten. + ctxs = upgraded.get('@context', []) + assert any('vocabulary.uncefact.org/untp/0.7' in c for c in ctxs + if isinstance(c, str)), ctxs + + # Helper APIs. + v = active_version() + assert isinstance(v, str) and v.count('.') == 2, v + assert is_version(v) + assert not is_version('9.9.9') + + print('OK') + """ + ) + s.assert_( + "compat.upgrade + active_version() + is_version()", + r.returncode == 0 and "OK" in r.stdout, + r.stderr or r.stdout, + ) + + +def section_eudpp_exporter(s: Smoke) -> None: + s.section("13. Python API — EU DPP exporter") + v07 = FIXTURES / "valid" / "untp-dpp-instance-0.7.0.json" + r = s.py( + f""" + import json + from pathlib import Path + from dppvalidator.models.v0_7.envelope import DigitalProductPassport + from dppvalidator.exporters import EUDPPJsonLDExporter + + v07 = json.loads(Path({json.dumps(str(v07))}).read_text(encoding='utf-8')) + passport = DigitalProductPassport.model_validate(v07) + + # Auto-detect from passport class. + out = EUDPPJsonLDExporter().export_dict(passport) + assert 'credentialSubject' in out + assert isinstance(out.get('@context'), list) + # eudpp:DPP type marker proves the term mapper applied. + types = out.get('type') or [] + assert 'eudpp:DPP' in types, types + + # Explicit pin. + out07 = EUDPPJsonLDExporter(schema_version='0.7.0').export_dict(passport) + assert 'credentialSubject' in out07 + + print('OK') + """ + ) + s.assert_( + "EUDPPJsonLDExporter auto-detect + explicit pin", + r.returncode == 0 and "OK" in r.stdout, + r.stderr or r.stdout, + ) + + +def section_plugin_discovery(s: Smoke) -> None: + s.section("14. Plugin discovery + version-aware dispatch") + r = s.py( + """ + from importlib.util import find_spec + if find_spec('dppvalidator_example_plugin') is None: + print('SKIP example plugin not installed') + else: + from dppvalidator.plugins.discovery import ( + discover_validators, + discover_exporters, + ) + from dppvalidator.plugins.registry import PluginRegistry + from dppvalidator.models.passport import DigitalProductPassport + from dppvalidator.models import CredentialIssuer + + v_names = sorted(n for n, _ in discover_validators()) + e_names = sorted(n for n, _ in discover_exporters()) + assert 'brand_name' in v_names, v_names + assert 'brand_name_v07' in v_names, v_names + assert 'min_materials' in v_names, v_names + assert 'csv' in e_names, e_names + + # Per-version filter contract: v0.6 plugin must NOT run on v0.7 + # passport (filtered before .check()), and vice-versa. + registry = PluginRegistry() + passport = DigitalProductPassport( + id='urn:test', + issuer=CredentialIssuer(id='did:test', name='Test'), + ) + v06_errs = registry.run_all_validators(passport, schema_version='0.6.1') + v07_errs = registry.run_all_validators(passport, schema_version='0.7.0') + for e in v06_errs: + assert e.code != 'PLG001', f'v0.6 plugin crashed: {e.message}' + for e in v07_errs: + assert e.code != 'PLG001', f'v0.7 plugin crashed: {e.message}' + print('OK') + """ + ) + if "SKIP" in r.stdout: + s.skip("plugin discovery", "example plugin not installed") + else: + s.assert_( + "plugin discovery + version-aware dispatch", + r.returncode == 0 and "OK" in r.stdout, + r.stderr or r.stdout, + ) + + +def section_manifest_integrity(s: Smoke) -> None: + s.section("15. Manifest integrity (every vendored artefact pinned)") + r = s.py( + """ + import hashlib, json + from pathlib import Path + repo = Path.cwd() + manifest_path = repo / 'src' / 'dppvalidator' / 'schemas' / 'data' / 'MANIFEST.json' + manifest = json.loads(manifest_path.read_text(encoding='utf-8')) + bad = [] + for entry in manifest['artefacts']: + f = repo / entry['path'] + if not f.is_file(): + bad.append(f"missing: {entry['path']}") + continue + data = f.read_bytes().replace(b'\\r\\n', b'\\n') + actual = hashlib.sha256(data).hexdigest() + if actual != entry['sha256']: + bad.append(f"hash mismatch: {entry['path']}") + if bad: + print('FAIL\\n' + '\\n'.join(bad)) + else: + print(f'OK {len(manifest["artefacts"])} artefacts') + """ + ) + s.assert_( + "every MANIFEST.json artefact's SHA-256 still matches", + r.returncode == 0 and r.stdout.startswith("OK"), + r.stderr or r.stdout, + ) + + +def section_doctor(s: Smoke) -> None: + s.section("16. doctor — environment health") + r = s.cli(["doctor"]) + # 0 (healthy) or 2 (issues but the command itself worked). + s.assert_( + "doctor runs without crashing", + r.returncode in (0, 2), + f"exit={r.returncode} stderr={r.stderr[:200]}", + ) + + +# --------------------------------------------------------------------------- +# Content negotiation against the upstream UN/CEFACT vocabulary endpoint +# --------------------------------------------------------------------------- + + +def _http_head_get(url: str, accept: str, timeout: float = 10.0) -> tuple[int, str, bytes]: + """Issue a GET (with Accept) and return (status, content-type, body). + + GET (not HEAD) because S3/CloudFront sometimes serves different + Content-Type headers on HEAD vs GET. We need the negotiated body + too, to verify it's parseable JSON-LD when that's what we asked + for. Capped at 256 KB so we don't pull the full ~150 KB twice in + a tight loop. + """ + req = urllib.request.Request( # noqa: S310 — fixed https URLs only + url, + headers={"Accept": accept, "User-Agent": "dppvalidator-smoke/1.0"}, + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 + body = resp.read(262_144) + return resp.status, resp.headers.get("Content-Type", ""), body + + +def section_content_negotiation(s: Smoke) -> None: + """Verify upstream UN/CEFACT vocabulary honours JSON-LD content-negotiation. + + Two upstream endpoints with different contracts: + + 1. ``/untp/`` — vocabulary landing page. Full negotiation: + - ``Accept: application/ld+json`` → ``application/ld+json`` + - default / ``Accept: text/html`` → HTML landing page + - ``Accept: application/json`` → HTML (only ``ld+json`` is honoured) + + 2. ``/untp/0.7.0/context/`` — pure JSON-LD context document. + Unconditionally served as ``application/ld+json`` regardless of + ``Accept`` (there is no HTML alternative — a context IS the artefact). + + Both must always return parseable JSON-LD with a root ``@context`` when + dereferenced as part of normal validator flows. Network-dependent; + skips cleanly when the host is unreachable. + """ + s.section("17. Content negotiation — vocabulary.uncefact.org") + + # ── /untp/ — landing page with full negotiation ──────────────────────── + base = "https://vocabulary.uncefact.org/untp/" + base_label = "untp/" + try: + status, ctype, body = _http_head_get(base, "application/ld+json") + except (urllib.error.URLError, TimeoutError, OSError) as exc: + s.skip(f"{base_label} (network)", f"unavailable: {exc}") + else: + s.assert_(f"{base_label} ld+json returns 200", status == 200, f"got status={status}") + s.assert_( + f"{base_label} ld+json Content-Type is application/ld+json", + "application/ld+json" in ctype.lower(), + f"got Content-Type={ctype!r}", + ) + try: + doc = json.loads(body.decode("utf-8")) + s.assert_( + f"{base_label} ld+json body is parseable JSON-LD with @context", + isinstance(doc, dict) and "@context" in doc, + f"top keys: {sorted(doc) if isinstance(doc, dict) else type(doc).__name__}", + ) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + s.fail( + f"{base_label} ld+json body is parseable JSON-LD with @context", + f"{exc!r}: {body[:120]!r}", + ) + + # Default Accept must fall back to HTML — proves negotiation is real, + # not a hard-coded Content-Type. + try: + _, ctype_html, _ = _http_head_get(base, "text/html,*/*") + s.assert_( + f"{base_label} default Accept falls back to HTML (proves negotiation)", + "text/html" in ctype_html.lower(), + f"got Content-Type={ctype_html!r}", + ) + except (urllib.error.URLError, TimeoutError, OSError) as exc: + s.skip(f"{base_label} (HTML probe)", f"network blip: {exc}") + + # application/json currently falls back to HTML. Pin the observation + # so a future upstream change (good: starts honouring it; bad: 406s) + # is flagged loudly rather than silently changing validator semantics. + try: + _, ctype_json, _ = _http_head_get(base, "application/json") + s.assert_( + f"{base_label} application/json → HTML fallback (only ld+json is honoured)", + "text/html" in ctype_json.lower(), + f"got Content-Type={ctype_json!r} — upstream behaviour changed?", + ) + except (urllib.error.URLError, TimeoutError, OSError) as exc: + s.skip(f"{base_label} (json probe)", f"network blip: {exc}") + + # ── /untp/0.7.0/context/ — pure JSON-LD context, no negotiation ──────── + ctx_url = "https://vocabulary.uncefact.org/untp/0.7.0/context/" + ctx_label = "untp/0.7.0/context/" + for accept, accept_label in ( + ("application/ld+json", "ld+json"), + ("text/html,*/*", "default Accept"), + ("application/json", "application/json"), + ): + try: + status, ctype, body = _http_head_get(ctx_url, accept) + except (urllib.error.URLError, TimeoutError, OSError) as exc: + s.skip(f"{ctx_label} ({accept_label})", f"network blip: {exc}") + continue + s.assert_( + f"{ctx_label} {accept_label} returns 200", + status == 200, + f"got status={status}", + ) + # Context document is always served as ld+json — there's no + # HTML alternative because a JSON-LD context IS the artefact. + s.assert_( + f"{ctx_label} {accept_label} → application/ld+json (unconditional)", + "application/ld+json" in ctype.lower(), + f"got Content-Type={ctype!r}", + ) + + # Final cross-check: the context body really is the v0.7.0 UNTP context + # document — pin shape + a couple of well-known prefixes so silent upstream + # content drift is caught (e.g. if the file at this URL is replaced). + try: + _, _, body = _http_head_get(ctx_url, "application/ld+json") + doc = json.loads(body.decode("utf-8")) + ctx = doc.get("@context") + s.assert_( + "context body has @context as a dict (JSON-LD context document)", + isinstance(ctx, dict), + f"@context type: {type(ctx).__name__}", + ) + if isinstance(ctx, dict): + terms = list(ctx.keys()) + s.assert_( + "context body declares 'untp' prefix (right document at this URL)", + "untp" in terms, + f"terms[:8]: {terms[:8]}", + ) + # 105 KB context with ~30+ top-level terms is the expected shape; + # drop to >= 10 to leave headroom for upstream pruning while still + # catching catastrophic shrinkage (e.g. a stub or 404 page). + s.assert_( + "context body has substantial term mappings (not a stub)", + len(terms) >= 10, + f"only {len(terms)} terms — upstream stub?", + ) + except ( + urllib.error.URLError, + TimeoutError, + OSError, + json.JSONDecodeError, + UnicodeDecodeError, + ) as exc: + s.skip("context content cross-check", f"unavailable: {exc}") + + +# --------------------------------------------------------------------------- +# Orchestrator +# --------------------------------------------------------------------------- + + +def main() -> int: + s = Smoke() + print(f"{BLUE}dppvalidator functional smoke test{RESET}") + print(f"{DIM}DPP_BIN={DPP_BIN}{RESET}") + print(f"{DIM}PY_BIN={PY_BIN}{RESET}") + + if not _check_environment(s): + s.fail("pre-flight failed", "stopping early — fix the environment and re-run") + return s.report() + + section_cli_sanity(s) + section_schema_management(s) + section_validate_auto_detect(s) + section_validate_explicit_pin(s) + section_validate_invalid(s) + section_output_formats(s) + section_stdin(s) + section_migrate(s) + section_validate_upgrade_from(s) + section_export(s) + section_python_api(s) + section_compat_api(s) + section_eudpp_exporter(s) + section_plugin_discovery(s) + section_manifest_integrity(s) + section_doctor(s) + section_content_negotiation(s) + + return s.report() + + +if __name__ == "__main__": + sys.exit(main()) From 1b7120e6a160cb0fffc406d0dd48c8ac82be7b6f Mon Sep 17 00:00:00 2001 From: Matthias Brenninkmeijer Date: Fri, 8 May 2026 10:37:43 +0200 Subject: [PATCH 11/12] fix(verifier): disambiguate multibase vs base64 proofValue A leading "z" SHOULD indicate multibase base58btc per the W3C Data Integrity spec, but standard base64 also contains "z" in its alphabet. ~1/64 of Ed25519 signatures encode with a leading "z" in plain base64, causing the verifier to misroute them to the base58btc decoder, fail decode, and return None instead of validating. The verifier now tries base58btc first when the prefix matches and falls back to base64 if that decode fails or yields empty bytes. Tests: the round-trip test is now seeded for reproducibility, and a new regression test searches the seed space for a leading-"z" base64 signature and asserts the verifier accepts it. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/dppvalidator/verifier/verifier.py | 21 +++++-- tests/unit/test_credential_verifier.py | 81 ++++++++++++++++++++++---- 2 files changed, 88 insertions(+), 14 deletions(-) diff --git a/src/dppvalidator/verifier/verifier.py b/src/dppvalidator/verifier/verifier.py index 3830933..82a26b3 100644 --- a/src/dppvalidator/verifier/verifier.py +++ b/src/dppvalidator/verifier/verifier.py @@ -194,11 +194,24 @@ def _verify_ed25519_proof( if not proof_value: return None - # Decode multibase-encoded signature (z prefix = base58btc) + # Decode the proof value. A leading "z" SHOULD indicate + # multibase base58btc per the Data Integrity spec, but + # standard base64 also contains "z" in its alphabet — so a + # legacy base64-encoded signature may collide with the + # multibase prefix. Try base58btc first when the prefix + # matches; fall back to base64 if that decode fails or + # yields empty bytes. + signature = b"" if proof_value.startswith("z"): - signature = self._decode_base58btc(proof_value[1:]) - else: - signature = base64.b64decode(proof_value) + try: + signature = self._decode_base58btc(proof_value[1:]) + except Exception: + signature = b"" + if not signature: + try: + signature = base64.b64decode(proof_value) + except Exception: + return None if not signature: return None diff --git a/tests/unit/test_credential_verifier.py b/tests/unit/test_credential_verifier.py index 45d6530..7dd22e5 100644 --- a/tests/unit/test_credential_verifier.py +++ b/tests/unit/test_credential_verifier.py @@ -305,21 +305,24 @@ def test_ed25519_proof_with_base64_signature(self) -> None: Round-trips the verifier's own canonicalisation: we ask the verifier to produce ``verify-data`` for a (credential, proof - options) pair, sign **those** bytes with a fresh Ed25519 key, - attach the signature as the ``proofValue``, and then call the - verifier — which must recompute the same canonical bytes and + options) pair, sign **those** bytes with a deterministic Ed25519 + key, attach the signature as the ``proofValue``, and then call + the verifier — which must recompute the same canonical bytes and report a valid signature. - The credential carries an ``@context`` so URDNA2015 - canonicalisation produces non-empty n-quads (the verifier's - primary path since 0.3.2). This avoids the historical flake: - signing JSON-canonical bytes while the verifier compares - against URDNA2015-canonicalised bytes. + Uses a fixed seed so the signature's base64 encoding is + reproducible across runs; previously this test generated a + random key and flaked ~1.5% of runs when the signature happened + to base64-encode with a leading ``z`` (which collides with the + multibase base58btc prefix). See + ``test_ed25519_proof_with_leading_z_base64_signature`` for the + explicit regression covering that case. """ verifier = CredentialVerifier() - # Generate the signing key. - private_key = ed25519.Ed25519PrivateKey.generate() + # Deterministic key (32-byte seed). Reproducible across runs. + seed = bytes(range(32)) + private_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed) public_key = private_key.public_key() vm = VerificationMethod( @@ -358,6 +361,64 @@ def test_ed25519_proof_with_base64_signature(self) -> None: result = verifier._verify_ed25519_proof(credential, proof, vm) assert result is True + def test_ed25519_proof_with_leading_z_base64_signature(self) -> None: + """Regression: base64 signatures with leading ``z`` are not misread as multibase. + + Standard base64 contains ``z`` in its alphabet, so ~1/64 of + Ed25519 signatures encode with a leading ``z`` — colliding with + the multibase base58btc prefix. The verifier must fall back to + base64 when base58 decode fails on such a value. + """ + verifier = CredentialVerifier() + + credential = { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "id": "urn:uuid:test-leading-z", + "type": ["VerifiableCredential"], + } + proof_options = { + "type": "Ed25519Signature2020", + "created": "2024-01-01T00:00:00Z", + "verificationMethod": "did:key:z6Mk...#key-1", + "proofPurpose": "assertionMethod", + } + message = verifier._create_verify_data(credential, proof_options) + assert message + + # Search seed space deterministically for a (key, signature) + # pair whose base64-encoded signature starts with 'z'. Expected + # to find one within ~64 tries; cap at 1000 for safety. + private_key = None + signature = None + for seed_int in range(1000): + candidate_key = ed25519.Ed25519PrivateKey.from_private_bytes( + seed_int.to_bytes(32, "big") + ) + candidate_sig = candidate_key.sign(message) + if base64.b64encode(candidate_sig).decode().startswith("z"): + private_key = candidate_key + signature = candidate_sig + break + assert private_key is not None, "failed to find leading-z base64 signature in 1000 seeds" + assert signature is not None + + public_key = private_key.public_key() + vm = VerificationMethod( + id="did:key:z6Mk...#key-1", + type="Ed25519VerificationKey2020", + controller="did:key:z6Mk...", + public_key_jwk={ + "kty": "OKP", + "crv": "Ed25519", + "x": base64.urlsafe_b64encode(public_key.public_bytes_raw()).rstrip(b"=").decode(), + }, + ) + proof = {**proof_options, "proofValue": base64.b64encode(signature).decode()} + assert proof["proofValue"].startswith("z") + + result = verifier._verify_ed25519_proof(credential, proof, vm) + assert result is True + class TestModuleFunctions: """Tests for module-level functions.""" From 3e93d6946676f0c775034ecd36fc4f6f60e27c3f Mon Sep 17 00:00:00 2001 From: Matthias Brenninkmeijer Date: Fri, 8 May 2026 10:42:56 +0200 Subject: [PATCH 12/12] chore(release): 0.4.0 - Bump pyproject.toml + uv.lock to 0.4.0 - Datestamp CHANGELOG entry (2026-05-08) - Document the verifier proofValue decoding fix under Fixed Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 10 +++++++++- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ba2bc9..f7d9a1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.4.0] - _unreleased_ +## [0.4.0] - 2026-05-08 This release adds first-class support for **UNTP DPP 0.7.0** alongside the existing 0.6.x. Both wire formats coexist and are auto-detected @@ -94,6 +94,14 @@ each phase has its own implementation log there. occurrences. Feature code should call `dppvalidator.compat.active_version()` instead. +### Fixed + +- Credential verifier: `proofValue` decoding no longer misroutes + base64-encoded Ed25519 signatures whose first character happens to + be `z` (~1.5% of random signatures). The verifier now treats the + leading `z` as a multibase base58btc hint and falls back to base64 + if base58 decode fails, instead of raising and returning `None`. + ### Tests - 2019 passing, 13 skipped (by-design via the dpp_version marker), diff --git a/pyproject.toml b/pyproject.toml index d4d97f2..fcc5057 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dppvalidator" -version = "0.3.2" +version = "0.4.0" description = "Python library for validating Digital Product Passports (DPP) according to EU ESPR regulations and CIRPASS/UNECE ontologies" readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index 9a15505..69adc8f 100644 --- a/uv.lock +++ b/uv.lock @@ -614,7 +614,7 @@ wheels = [ [[package]] name = "dppvalidator" -version = "0.3.2" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "base58" },