jsonprofile 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/PKG-INFO +8 -7
  2. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/pyproject.toml +14 -11
  3. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/pyproject.toml.orig +14 -12
  4. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/__init__.py +1 -1
  5. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/jsonprofile.schema.json +52 -1
  6. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/profile/base.py +1 -0
  7. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/profile/constraints/constraints.py +62 -7
  8. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/validator/abstract_checker.py +23 -5
  9. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/validator/checkers/default_checker.py +80 -70
  10. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/validator/default/ols_cv_term_search.py +10 -7
  11. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/validator/json_validator.py +21 -13
  12. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/validator/local_opa_engine.py +43 -39
  13. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/README.md +0 -0
  14. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/cv_term_search.py +0 -0
  15. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/profile/__init__.py +0 -0
  16. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/profile/constraints/__init__.py +0 -0
  17. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/profile/model.py +0 -0
  18. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/profile/profile_validator.py +0 -0
  19. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/utils.py +0 -0
  20. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/validator/__init__.py +0 -0
  21. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/validator/base.py +0 -0
  22. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/validator/checkers/__init__.py +0 -0
  23. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/validator/checkers/custom_checker.py +0 -0
  24. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/validator/context.py +0 -0
  25. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/validator/decorators.py +0 -0
  26. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/validator/default/__init__.py +0 -0
  27. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/validator/default/profile_validator.py +0 -0
  28. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/validator/default/profile_validator_factory.py +0 -0
  29. {jsonprofile-0.2.0 → jsonprofile-0.2.2}/src/jsonprofile/validator/opa_engine.py +0 -0
@@ -1,20 +1,21 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: jsonprofile
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Dynamic profile management for json files share same schema
5
- Requires-Dist: bioregistry>=0.13.64
6
- Requires-Dist: cachetools>=7.1.4
5
+ Requires-Dist: bioregistry>=0.13.67
6
+ Requires-Dist: cachetools>=7.2.8
7
7
  Requires-Dist: email-validator>=2.3.0
8
- Requires-Dist: httpx2>=0.28.1
8
+ Requires-Dist: httpx2>=2.14.0
9
9
  Requires-Dist: jsonpath-ng>=1.8.0
10
- Requires-Dist: jsonschema-rs>=0.46.9
10
+ Requires-Dist: jsonschema-rs>=0.60.3
11
11
  Requires-Dist: ontology-lookup>=0.1.1
12
- Requires-Dist: orjson>=3.11.9
13
- Requires-Dist: pydantic>=2.12.4
12
+ Requires-Dist: orjson>=3.13.0
13
+ Requires-Dist: pydantic>=2.14.0
14
14
  Requires-Dist: pyyaml>=6.0.3
15
15
  Requires-Dist: rfc3986>=2.0.0
16
16
  Requires-Dist: opa-wasmtime>=0.1.1 ; extra == 'opa'
17
17
  Requires-Python: >=3.10
18
+ Project-URL: Repository, https://github.com/MetabolomicsHub/jsonprofile
18
19
  Provides-Extra: opa
19
20
  Description-Content-Type: text/markdown
20
21
 
@@ -1,23 +1,26 @@
1
1
  [project]
2
2
  name = "jsonprofile"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Dynamic profile management for json files share same schema"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  dependencies = [
8
- "bioregistry>=0.13.64",
9
- "cachetools>=7.1.4",
8
+ "bioregistry>=0.13.67",
9
+ "cachetools>=7.2.8",
10
10
  "email-validator>=2.3.0",
11
- "httpx2>=0.28.1",
11
+ "httpx2>=2.14.0",
12
12
  "jsonpath-ng>=1.8.0",
13
- "jsonschema-rs>=0.46.9",
13
+ "jsonschema-rs>=0.60.3",
14
14
  "ontology-lookup>=0.1.1",
15
- "orjson>=3.11.9",
16
- "pydantic>=2.12.4",
15
+ "orjson>=3.13.0",
16
+ "pydantic>=2.14.0",
17
17
  "pyyaml>=6.0.3",
18
18
  "rfc3986>=2.0.0",
19
19
  ]
20
20
 
21
+ [project.urls]
22
+ Repository = "https://github.com/MetabolomicsHub/jsonprofile"
23
+
21
24
  [project.optional-dependencies]
22
25
  opa = ["opa-wasmtime>=0.1.1"]
23
26
 
@@ -27,13 +30,13 @@ dev = [
27
30
  "commitizen>=4.13.9",
28
31
  "pytest>=9.0.3",
29
32
  "ruff>=0.17.00",
30
- "uv-build>=0.9.8",
33
+ "uv-build>=0.13.0,<0.14.0",
31
34
  "mkdocs-material>=9.7.0",
32
35
  ]
33
36
 
34
37
  [tool.ruff]
35
38
  target-version = "py310"
36
- cache-dir = "../.cache/python/ruff"
39
+ cache-dir = ".cache/ruff"
37
40
  src = ["src"]
38
41
 
39
42
  [tool.ruff.lint]
@@ -67,7 +70,7 @@ update_changelog_on_bump = true
67
70
 
68
71
  [tool.pytest.ini_options]
69
72
  addopts = "-v"
70
- cache_dir = "../.cache/python/pytest"
73
+ cache_dir = ".cache/pytest"
71
74
  testpaths = ["tests"]
72
75
  log_cli = true
73
76
  log_cli_level = "INFO"
@@ -78,5 +81,5 @@ log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(linen
78
81
  module-name = "jsonprofile"
79
82
 
80
83
  [build-system]
81
- requires = ["uv_build>=0.9.8,<0.12.0"]
84
+ requires = ["uv_build>=0.13.0,<0.14.0"]
82
85
  build-backend = "uv_build"
@@ -1,23 +1,25 @@
1
1
  [project]
2
2
  name = "jsonprofile"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Dynamic profile management for json files share same schema"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  dependencies = [
8
- "bioregistry>=0.13.64",
9
- "cachetools>=7.1.4",
8
+ "bioregistry>=0.13.67",
9
+ "cachetools>=7.2.8",
10
10
  "email-validator>=2.3.0",
11
- "httpx2>=0.28.1",
11
+ "httpx2>=2.14.0",
12
12
  "jsonpath-ng>=1.8.0",
13
- # "jsonschema>=4.26.0",
14
- "jsonschema-rs>=0.46.9",
13
+ "jsonschema-rs>=0.60.3",
15
14
  "ontology-lookup>=0.1.1",
16
- "orjson>=3.11.9",
17
- "pydantic>=2.12.4",
15
+ "orjson>=3.13.0",
16
+ "pydantic>=2.14.0",
18
17
  "pyyaml>=6.0.3",
19
18
  "rfc3986>=2.0.0",
20
19
  ]
20
+ [project.urls]
21
+ 'Repository' = 'https://github.com/MetabolomicsHub/jsonprofile'
22
+
21
23
  [project.optional-dependencies]
22
24
  opa = ["opa-wasmtime>=0.1.1"]
23
25
 
@@ -27,13 +29,13 @@ dev = [
27
29
  "commitizen>=4.13.9",
28
30
  "pytest>=9.0.3",
29
31
  "ruff>=0.17.00",
30
- "uv-build>=0.9.8",
32
+ "uv-build>=0.13.0,<0.14.0",
31
33
  "mkdocs-material>=9.7.0",
32
34
  ]
33
35
 
34
36
  [tool.ruff]
35
37
  target-version = "py310"
36
- cache-dir = "../.cache/python/ruff"
38
+ cache-dir = ".cache/ruff"
37
39
  src = ["src"]
38
40
 
39
41
  [tool.commitizen]
@@ -58,7 +60,7 @@ known-first-party = ["jsonprofile"]
58
60
 
59
61
  [tool.pytest.ini_options]
60
62
  addopts = "-v"
61
- cache_dir = "../.cache/python/pytest"
63
+ cache_dir = ".cache/pytest"
62
64
  testpaths = ["tests"]
63
65
  log_cli = true
64
66
  log_cli_level = "INFO"
@@ -66,7 +68,7 @@ log_cli_date_format = "%Y-%m-%d %H:%M:%S"
66
68
  log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)"
67
69
 
68
70
  [build-system]
69
- requires = ["uv_build>=0.9.8,<0.12.0"]
71
+ requires = ["uv_build>=0.13.0,<0.14.0"]
70
72
  build-backend = "uv_build"
71
73
 
72
74
  [tool.uv.build-backend]
@@ -1,6 +1,6 @@
1
1
  from jsonprofile import profile, utils, validator
2
2
 
3
- __version__ = "v0.2.0"
3
+ __version__ = "v0.2.2"
4
4
 
5
5
 
6
6
  __all__ = ["profile", "validator", "utils", "cv_term_search"]
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "$defs": {
3
3
  "BaseCvTerm": {
4
+ "additionalProperties": false,
4
5
  "description": "A lightweight reference to a controlled vocabulary (CV) term.\n\nUsed within constraint definitions to identify specific CV terms\nby their label, accession, or human-readable name.",
5
6
  "properties": {
6
7
  "cv_label": {
@@ -47,6 +48,7 @@
47
48
  "type": "object"
48
49
  },
49
50
  "BooleanConstraint": {
51
+ "additionalProperties": false,
50
52
  "description": "Validates that a value is boolean.",
51
53
  "properties": {
52
54
  "validator": {
@@ -260,6 +262,7 @@
260
262
  "type": "object"
261
263
  },
262
264
  "CVListConstraint": {
265
+ "additionalProperties": false,
263
266
  "description": "Restricts CV terms to those from specific controlled vocabularies.\n\nThe ``allowed_cv_list`` field specifies which CV namespace labels are\nallowed (e.g. ``['MS', 'UO']``).",
264
267
  "properties": {
265
268
  "validator": {
@@ -496,6 +499,7 @@
496
499
  "type": "object"
497
500
  },
498
501
  "CVTermConstraint": {
502
+ "additionalProperties": false,
499
503
  "description": "Base constraint for values that must be controlled vocabulary terms.\n\nSubclasses refine which CV terms are acceptable (by list, by parent\nterm, or by CV namespace). This class captures the common options\nshared across all CV term constraints.",
500
504
  "properties": {
501
505
  "validator": {
@@ -715,6 +719,7 @@
715
719
  "type": "object"
716
720
  },
717
721
  "CVTermEnumConstraint": {
722
+ "additionalProperties": false,
718
723
  "description": "Restricts the value to a specific set of CV terms.\n\nUnlike ``CVListConstraint`` which filters by CV namespace, this\nconstraint enumerates the exact terms that are acceptable.",
719
724
  "properties": {
720
725
  "validator": {
@@ -946,6 +951,7 @@
946
951
  "type": "object"
947
952
  },
948
953
  "CVTermValueConstraint": {
954
+ "additionalProperties": false,
949
955
  "description": "Validates the value slot of a specific CV term.\n\nWhen a field contains a CV term matching ``key_cv_term``, its\nvalue is validated against the nested ``value_constraint``.\nThis enables context-dependent validation (e.g. a numeric range\nthat only applies when a specific CV term is used).",
950
956
  "properties": {
951
957
  "validator": {
@@ -1209,6 +1215,7 @@
1209
1215
  "type": "object"
1210
1216
  },
1211
1217
  "CollectionConstraint": {
1218
+ "additionalProperties": false,
1212
1219
  "description": "Validates collection size and item matches against reference values.",
1213
1220
  "properties": {
1214
1221
  "validator": {
@@ -1591,6 +1598,7 @@
1591
1598
  "type": "object"
1592
1599
  },
1593
1600
  "ConstraintGroup": {
1601
+ "additionalProperties": false,
1594
1602
  "description": "Combines multiple constraints using boolean or valid-count logic.",
1595
1603
  "properties": {
1596
1604
  "validator": {
@@ -1875,6 +1883,7 @@
1875
1883
  "type": "object"
1876
1884
  },
1877
1885
  "CustomConstraint": {
1886
+ "additionalProperties": false,
1878
1887
  "description": "Runs a named custom constraint checker.",
1879
1888
  "properties": {
1880
1889
  "validator": {
@@ -2083,6 +2092,7 @@
2083
2092
  "type": "object"
2084
2093
  },
2085
2094
  "CvTerm": {
2095
+ "additionalProperties": false,
2086
2096
  "description": "Controlled vocabulary term with an optional user-provided value.",
2087
2097
  "properties": {
2088
2098
  "cv_label": {
@@ -2142,6 +2152,7 @@
2142
2152
  "type": "object"
2143
2153
  },
2144
2154
  "CvTermFieldMapping": {
2155
+ "additionalProperties": false,
2145
2156
  "properties": {
2146
2157
  "label_field": {
2147
2158
  "anyOf": [
@@ -2196,6 +2207,7 @@
2196
2207
  "type": "object"
2197
2208
  },
2198
2209
  "DateTimeConstraint": {
2210
+ "additionalProperties": false,
2199
2211
  "description": "Validates that a string value represents a valid date, time,\nor datetime.\n\nWhen ``format`` is provided, the value must match that specific\npattern (e.g. ISO-8601). When omitted, ISO-8601 is accepted.",
2200
2212
  "properties": {
2201
2213
  "validator": {
@@ -2376,7 +2388,8 @@
2376
2388
  "type": "object"
2377
2389
  },
2378
2390
  "DecimalConstraint": {
2379
- "description": "Validates that a value is a valid floating-point number.\n\nSupports optional range bounds, scientific notation control,\nand decimal precision limits.",
2391
+ "additionalProperties": false,
2392
+ "description": "Validates that a value is a valid decimal number.\n\nSupports optional range bounds, scientific notation control,\nand decimal precision limits.",
2380
2393
  "properties": {
2381
2394
  "validator": {
2382
2395
  "anyOf": [
@@ -2541,6 +2554,8 @@
2541
2554
  "minimum": {
2542
2555
  "anyOf": [
2543
2556
  {
2557
+ "maximum": 7.922816251426434e+28,
2558
+ "minimum": -7.922816251426434e+28,
2544
2559
  "type": "number"
2545
2560
  },
2546
2561
  {
@@ -2557,6 +2572,8 @@
2557
2572
  "maximum": {
2558
2573
  "anyOf": [
2559
2574
  {
2575
+ "maximum": 7.922816251426434e+28,
2576
+ "minimum": -7.922816251426434e+28,
2560
2577
  "type": "number"
2561
2578
  },
2562
2579
  {
@@ -2629,6 +2646,7 @@
2629
2646
  "type": "object"
2630
2647
  },
2631
2648
  "EmailConstraint": {
2649
+ "additionalProperties": false,
2632
2650
  "description": "Validates that a string value is a well-formed email address.",
2633
2651
  "properties": {
2634
2652
  "validator": {
@@ -2820,6 +2838,7 @@
2820
2838
  "type": "string"
2821
2839
  },
2822
2840
  "Evaluation": {
2841
+ "additionalProperties": false,
2823
2842
  "description": "Evaluates a constraint against either the current value or another JSONPath.",
2824
2843
  "properties": {
2825
2844
  "root_value_evaluation": {
@@ -3005,6 +3024,7 @@
3005
3024
  "type": "object"
3006
3025
  },
3007
3026
  "ExtendedCvTerm": {
3027
+ "additionalProperties": false,
3008
3028
  "description": "Controlled vocabulary term whose value may be another CV term.",
3009
3029
  "properties": {
3010
3030
  "cv_label": {
@@ -3067,6 +3087,7 @@
3067
3087
  "type": "object"
3068
3088
  },
3069
3089
  "FieldRequirement": {
3090
+ "additionalProperties": false,
3070
3091
  "description": "Validation rules that apply to one profiled JSON field.",
3071
3092
  "properties": {
3072
3093
  "code": {
@@ -3239,6 +3260,7 @@
3239
3260
  "type": "object"
3240
3261
  },
3241
3262
  "FieldRequirementGroup": {
3263
+ "additionalProperties": false,
3242
3264
  "description": "Group of requirements combined by valid-count bounds.",
3243
3265
  "properties": {
3244
3266
  "code": {
@@ -3334,6 +3356,7 @@
3334
3356
  "type": "object"
3335
3357
  },
3336
3358
  "IntegerConstraint": {
3359
+ "additionalProperties": false,
3337
3360
  "description": "Validates that a value is a valid integer within an optional range.\n\nUse ``PositiveIntegerConstraint`` or ``NonNegativeIntegerConstraint``\nfor common presets.",
3338
3361
  "properties": {
3339
3362
  "validator": {
@@ -3499,6 +3522,8 @@
3499
3522
  "minimum": {
3500
3523
  "anyOf": [
3501
3524
  {
3525
+ "maximum": 9223372036854775807,
3526
+ "minimum": -9223372036854775808,
3502
3527
  "type": "integer"
3503
3528
  },
3504
3529
  {
@@ -3512,6 +3537,8 @@
3512
3537
  "maximum": {
3513
3538
  "anyOf": [
3514
3539
  {
3540
+ "maximum": 9223372036854775807,
3541
+ "minimum": -9223372036854775808,
3515
3542
  "type": "integer"
3516
3543
  },
3517
3544
  {
@@ -3527,6 +3554,7 @@
3527
3554
  "type": "object"
3528
3555
  },
3529
3556
  "IntegerEnumConstraint": {
3557
+ "additionalProperties": false,
3530
3558
  "description": "Validates that an integer value is one of a fixed set of allowed options.\n\nEach option maps an integer value to a display value, integer code,\nor ``ExtendedCvTerm`` metadata.",
3531
3559
  "properties": {
3532
3560
  "validator": {
@@ -3715,6 +3743,7 @@
3715
3743
  "type": "object"
3716
3744
  },
3717
3745
  "JsonProfileConfiguration": {
3746
+ "additionalProperties": false,
3718
3747
  "description": "Optional configuration shared by validators for a profile.",
3719
3748
  "properties": {
3720
3749
  "cv_term_field_mapping": {
@@ -3866,6 +3895,7 @@
3866
3895
  "type": "object"
3867
3896
  },
3868
3897
  "NonNegativeIntegerConstraint": {
3898
+ "additionalProperties": false,
3869
3899
  "description": "Validates that a value is a non-negative integer (>= 0).\n\nConvenience subclass of ``IntegerConstraint`` with ``minimum=0``.",
3870
3900
  "properties": {
3871
3901
  "validator": {
@@ -4031,6 +4061,8 @@
4031
4061
  "minimum": {
4032
4062
  "anyOf": [
4033
4063
  {
4064
+ "maximum": 9223372036854775807,
4065
+ "minimum": -9223372036854775808,
4034
4066
  "type": "integer"
4035
4067
  },
4036
4068
  {
@@ -4044,6 +4076,8 @@
4044
4076
  "maximum": {
4045
4077
  "anyOf": [
4046
4078
  {
4079
+ "maximum": 9223372036854775807,
4080
+ "minimum": -9223372036854775808,
4047
4081
  "type": "integer"
4048
4082
  },
4049
4083
  {
@@ -4059,6 +4093,7 @@
4059
4093
  "type": "object"
4060
4094
  },
4061
4095
  "NotNullConstraint": {
4096
+ "additionalProperties": false,
4062
4097
  "description": "Validates that a value is not null or one of the configured null values.",
4063
4098
  "properties": {
4064
4099
  "validator": {
@@ -4239,6 +4274,7 @@
4239
4274
  "type": "object"
4240
4275
  },
4241
4276
  "OpaFieldRequirement": {
4277
+ "additionalProperties": false,
4242
4278
  "description": "Requirement will be evaluated and messages are produced by OPA engine",
4243
4279
  "properties": {
4244
4280
  "code": {
@@ -4306,6 +4342,7 @@
4306
4342
  "type": "object"
4307
4343
  },
4308
4344
  "OpaPolicyConstraint": {
4345
+ "additionalProperties": false,
4309
4346
  "description": "Evaluates an Open Policy Agent policy against the input value.",
4310
4347
  "properties": {
4311
4348
  "validator": {
@@ -4494,6 +4531,7 @@
4494
4531
  "type": "object"
4495
4532
  },
4496
4533
  "ParentCVTermConstraint": {
4534
+ "additionalProperties": false,
4497
4535
  "description": "Restricts the value to CV terms that are descendants of\nspecified parent terms in the ontology hierarchy.\n\nSupports exclusion of specific terms or name patterns to\nfilter out unwanted branches of the ontology tree.",
4498
4536
  "properties": {
4499
4537
  "validator": {
@@ -4746,6 +4784,7 @@
4746
4784
  "type": "object"
4747
4785
  },
4748
4786
  "PositiveIntegerConstraint": {
4787
+ "additionalProperties": false,
4749
4788
  "description": "Validates that a value is a positive integer (>= 1).\n\nConvenience subclass of ``IntegerConstraint`` with ``minimum=1``.",
4750
4789
  "properties": {
4751
4790
  "validator": {
@@ -4911,6 +4950,8 @@
4911
4950
  "minimum": {
4912
4951
  "anyOf": [
4913
4952
  {
4953
+ "maximum": 9223372036854775807,
4954
+ "minimum": -9223372036854775808,
4914
4955
  "type": "integer"
4915
4956
  },
4916
4957
  {
@@ -4924,6 +4965,8 @@
4924
4965
  "maximum": {
4925
4966
  "anyOf": [
4926
4967
  {
4968
+ "maximum": 9223372036854775807,
4969
+ "minimum": -9223372036854775808,
4927
4970
  "type": "integer"
4928
4971
  },
4929
4972
  {
@@ -4939,6 +4982,7 @@
4939
4982
  "type": "object"
4940
4983
  },
4941
4984
  "Precondition": {
4985
+ "additionalProperties": false,
4942
4986
  "description": "Condition that controls whether a constraint should be evaluated.",
4943
4987
  "properties": {
4944
4988
  "evaluations": {
@@ -4995,6 +5039,7 @@
4995
5039
  "type": "object"
4996
5040
  },
4997
5041
  "ProfileValidatorDefinition": {
5042
+ "additionalProperties": false,
4998
5043
  "description": "Definition of a profile validator implementation.",
4999
5044
  "properties": {
5000
5045
  "label": {
@@ -5038,6 +5083,7 @@
5038
5083
  "type": "object"
5039
5084
  },
5040
5085
  "RegexConstraint": {
5086
+ "additionalProperties": false,
5041
5087
  "description": "Validates a string value against a regular expression pattern.\n\nUse for structured identifiers or format patterns that can be\nexpressed as a single regex (e.g. ORCID, adduct ion notation).",
5042
5088
  "properties": {
5043
5089
  "validator": {
@@ -5227,6 +5273,7 @@
5227
5273
  "type": "object"
5228
5274
  },
5229
5275
  "StringConstraint": {
5276
+ "additionalProperties": false,
5230
5277
  "description": "Validates that a string value's length falls within a range.\n\nAt least one of ``minimum`` or ``maximum`` should be set.",
5231
5278
  "properties": {
5232
5279
  "validator": {
@@ -5422,6 +5469,7 @@
5422
5469
  "type": "object"
5423
5470
  },
5424
5471
  "StringEnumConstraint": {
5472
+ "additionalProperties": false,
5425
5473
  "description": "Validates that a string value is one of a fixed set of allowed options.\n\nOptions may be a simple list of strings or a mapping from a string key\nto a display value, integer code, or ``ExtendedCvTerm`` metadata.",
5426
5474
  "properties": {
5427
5475
  "validator": {
@@ -5620,6 +5668,7 @@
5620
5668
  "type": "object"
5621
5669
  },
5622
5670
  "UriConstraint": {
5671
+ "additionalProperties": false,
5623
5672
  "description": "Validates that a string value is a well-formed URI.\n\nOptionally restricts accepted URI schemes (e.g. only ``https``,\n``ftp``).",
5624
5673
  "properties": {
5625
5674
  "validator": {
@@ -5803,6 +5852,7 @@
5803
5852
  "type": "object"
5804
5853
  },
5805
5854
  "WasmFileDefinition": {
5855
+ "additionalProperties": false,
5806
5856
  "description": "Configuration for an Open Policy Agent policy module.",
5807
5857
  "properties": {
5808
5858
  "wasm_file_download_url": {
@@ -5830,6 +5880,7 @@
5830
5880
  "type": "object"
5831
5881
  }
5832
5882
  },
5883
+ "additionalProperties": false,
5833
5884
  "description": "A JSON profile containing metadata, configuration, and field requirements.",
5834
5885
  "properties": {
5835
5886
  "id": {
@@ -35,6 +35,7 @@ class JsonProfileBaseModel(BaseModel):
35
35
  """Base model configuration shared by JSON Profile schemas."""
36
36
 
37
37
  model_config = ConfigDict(
38
+ extra="forbid",
38
39
  populate_by_name=True,
39
40
  str_strip_whitespace=True,
40
41
  validate_default=True,
@@ -14,6 +14,28 @@ from jsonprofile.profile.base import (
14
14
  JsonProfileBaseModel,
15
15
  )
16
16
 
17
+ INT64_MIN = -(2**63)
18
+ INT64_MAX = 2**63 - 1
19
+ DECIMAL_MAX_COEFFICIENT = 2**96 - 1
20
+ DECIMAL_MAX_SCALE = 28
21
+
22
+
23
+ def decimal_fits_shared_range(value: Decimal) -> bool:
24
+ """Return whether a finite Decimal fits Rust Decimal's 96-bit representation."""
25
+ if not value.is_finite():
26
+ return False
27
+ parts = value.as_tuple()
28
+ coefficient = int("".join(map(str, parts.digits)) or "0")
29
+ exponent = parts.exponent
30
+ scale = max(0, -exponent)
31
+ if scale > DECIMAL_MAX_SCALE:
32
+ return False
33
+ if exponent > 0 and coefficient:
34
+ if len(str(coefficient)) + exponent > len(str(DECIMAL_MAX_COEFFICIENT)):
35
+ return False
36
+ coefficient *= 10**exponent
37
+ return coefficient <= DECIMAL_MAX_COEFFICIENT
38
+
17
39
 
18
40
  class Precondition(JsonProfileBaseModel):
19
41
  """Condition that controls whether a constraint should be evaluated."""
@@ -306,6 +328,13 @@ class IntegerEnumConstraint(Constraint):
306
328
  ),
307
329
  ]
308
330
 
331
+ @field_validator("options")
332
+ @classmethod
333
+ def int64_option_keys(cls, value):
334
+ if any(key < INT64_MIN or key > INT64_MAX for key in value):
335
+ raise ValueError("integer-enum option keys must fit signed 64-bit range")
336
+ return value
337
+
309
338
 
310
339
  class IntegerConstraint(Constraint):
311
340
  """Validates that a value is a valid integer within an optional range.
@@ -320,13 +349,17 @@ class IntegerConstraint(Constraint):
320
349
  minimum: Annotated[
321
350
  Optional[int],
322
351
  Field(
323
- description="Minimum allowed value (inclusive). None means no lower bound."
352
+ description="Minimum allowed value (inclusive). None means no lower bound.",
353
+ ge=INT64_MIN,
354
+ le=INT64_MAX,
324
355
  ),
325
356
  ] = None
326
357
  maximum: Annotated[
327
358
  Optional[int],
328
359
  Field(
329
- description="Maximum allowed value (inclusive). None means no upper bound."
360
+ description="Maximum allowed value (inclusive). None means no upper bound.",
361
+ ge=INT64_MIN,
362
+ le=INT64_MAX,
330
363
  ),
331
364
  ] = None
332
365
 
@@ -342,7 +375,11 @@ class PositiveIntegerConstraint(IntegerConstraint):
342
375
  )
343
376
  minimum: Annotated[
344
377
  Optional[int],
345
- Field(description="Minimum allowed value (inclusive). Defaults to 1."),
378
+ Field(
379
+ description="Minimum allowed value (inclusive). Defaults to 1.",
380
+ ge=INT64_MIN,
381
+ le=INT64_MAX,
382
+ ),
346
383
  ] = 1
347
384
 
348
385
 
@@ -357,7 +394,11 @@ class NonNegativeIntegerConstraint(IntegerConstraint):
357
394
  )
358
395
  minimum: Annotated[
359
396
  Optional[int],
360
- Field(description="Minimum allowed value (inclusive). Defaults to 0."),
397
+ Field(
398
+ description="Minimum allowed value (inclusive). Defaults to 0.",
399
+ ge=INT64_MIN,
400
+ le=INT64_MAX,
401
+ ),
361
402
  ] = 0
362
403
 
363
404
 
@@ -385,7 +426,7 @@ class BooleanConstraint(Constraint):
385
426
 
386
427
 
387
428
  class DecimalConstraint(Constraint):
388
- """Validates that a value is a valid floating-point number.
429
+ """Validates that a value is a valid decimal number.
389
430
 
390
431
  Supports optional range bounds, scientific notation control,
391
432
  and decimal precision limits.
@@ -397,15 +438,29 @@ class DecimalConstraint(Constraint):
397
438
  minimum: Annotated[
398
439
  Optional[Decimal],
399
440
  Field(
400
- description="Minimum allowed value (inclusive). None means no lower bound."
441
+ description="Minimum allowed value (inclusive). None means no lower bound.",
442
+ ge=Decimal(-DECIMAL_MAX_COEFFICIENT),
443
+ le=Decimal(DECIMAL_MAX_COEFFICIENT),
401
444
  ),
402
445
  ] = None
403
446
  maximum: Annotated[
404
447
  Optional[Decimal],
405
448
  Field(
406
- description="Maximum allowed value (inclusive). None means no upper bound."
449
+ description="Maximum allowed value (inclusive). None means no upper bound.",
450
+ ge=Decimal(-DECIMAL_MAX_COEFFICIENT),
451
+ le=Decimal(DECIMAL_MAX_COEFFICIENT),
407
452
  ),
408
453
  ] = None
454
+
455
+ @field_validator("minimum", "maximum")
456
+ @classmethod
457
+ def bounds_fit_shared_decimal_range(cls, value):
458
+ if value is not None and not decimal_fits_shared_range(value):
459
+ raise ValueError(
460
+ "decimal bounds must fit a 96-bit coefficient and scale <= 28"
461
+ )
462
+ return value
463
+
409
464
  allow_scientific_notation: Annotated[
410
465
  Optional[bool],
411
466
  Field(
@@ -9,6 +9,7 @@ import jsonpath_ng
9
9
  from jsonprofile.profile.base import JsonPath, JsonProfileBaseModel
10
10
  from jsonprofile.profile.constraints.constraints import (
11
11
  Constraint,
12
+ CustomConstraint,
12
13
  DecimalConstraint,
13
14
  )
14
15
 
@@ -204,22 +205,39 @@ class ConstraintChecker(abc.ABC):
204
205
 
205
206
  sub_value = self.get_json_path_value(constraint, value, context)
206
207
  if success:
207
- is_valid, message = checker.validate(
208
- constraint, sub_value, root=root, context=context
209
- )
208
+ if isinstance(constraint, CustomConstraint):
209
+ null_value = sub_value is None or (
210
+ constraint.null_values
211
+ and str(sub_value) in constraint.null_values
212
+ )
213
+ exceptional = constraint.exceptional_values or []
214
+ if sub_value in exceptional or (null_value and None in exceptional):
215
+ is_valid, message = True, "value is in exceptional list"
216
+ else:
217
+ is_valid, message = checker.validate(
218
+ constraint,
219
+ None if null_value else sub_value,
220
+ root=root,
221
+ context=context,
222
+ )
223
+ if constraint.negated:
224
+ is_valid = not is_valid
225
+ message = message or ""
226
+ else:
227
+ is_valid, message = checker.validate(
228
+ constraint, sub_value, root=root, context=context
229
+ )
210
230
  else:
211
231
  is_valid = True
212
232
  message = f"Precondition does not meet: {message}"
213
233
  return ConstraintValidationResult(
214
234
  is_valid=is_valid,
215
235
  message=message,
216
- constraint_name=getattr(constraint, "name", "unknown"),
217
236
  )
218
237
 
219
238
  return ConstraintValidationResult(
220
239
  is_valid=False,
221
240
  message=f"Unknown constraint type: {type(constraint)}",
222
- constraint_name=getattr(constraint, "name", "unknown"),
223
241
  )
224
242
 
225
243
  def get_json_path_value(
@@ -19,6 +19,8 @@ from jsonprofile.profile.base import (
19
19
  ValidationRuntimeConfiguration,
20
20
  )
21
21
  from jsonprofile.profile.constraints.constraints import (
22
+ INT64_MAX,
23
+ INT64_MIN,
22
24
  BooleanConstraint,
23
25
  CollectionConstraint,
24
26
  ConstraintGroup,
@@ -40,6 +42,7 @@ from jsonprofile.profile.constraints.constraints import (
40
42
  StringConstraint,
41
43
  StringEnumConstraint,
42
44
  UriConstraint,
45
+ decimal_fits_shared_range,
43
46
  )
44
47
  from jsonprofile.profile.model import (
45
48
  JsonProfileConfiguration,
@@ -52,6 +55,14 @@ from jsonprofile.validator.decorators import constraint_checker
52
55
  logger = logging.getLogger(__name__)
53
56
 
54
57
 
58
+ def _parse_int64(value: str) -> int | None:
59
+ try:
60
+ parsed = int(value)
61
+ except (ValueError, TypeError, OverflowError):
62
+ return None
63
+ return parsed if INT64_MIN <= parsed <= INT64_MAX else None
64
+
65
+
55
66
  def _extract_cv_info(value: Any, mapping: CvTermFieldMapping) -> CvTerm:
56
67
  """Extracts label, accession, name, and value from a CvTerm or dict."""
57
68
  if isinstance(value, CvTerm):
@@ -95,16 +106,23 @@ class NotNullConstraintChecker(ConstraintChecker):
95
106
  ) -> Tuple[bool, Optional[str]]:
96
107
  evaluation = False
97
108
  messages = []
109
+ is_exceptional = bool(
110
+ constraint.exceptional_values and value in constraint.exceptional_values
111
+ )
98
112
  if value is not None and constraint.null_values:
99
113
  str_val = str(value) if constraint.case_sensitive else str(value).lower()
100
114
  if str_val in constraint.null_values:
101
115
  messages.append("value is in null value list")
102
116
  value = None
103
117
  message = ""
104
- if value is not None:
118
+ if is_exceptional:
119
+ evaluation = True
120
+ message = "value is in exceptional list"
121
+ elif value is not None:
105
122
  evaluation = True
106
123
  message = f"{value} is not null"
107
- message = ". ".join(messages)
124
+ elif messages:
125
+ message = ". ".join(messages)
108
126
 
109
127
  if constraint.negated:
110
128
  evaluation = not evaluation
@@ -275,7 +293,9 @@ class CollectionConstraintChecker(ConstraintChecker):
275
293
  message = "value is not defined"
276
294
  elif not isinstance(collection_values, list):
277
295
  message = f"{constraint.json_path} is not a collection."
278
- return False, message
296
+ if constraint.negated:
297
+ return True, self.format_message(constraint, message)
298
+ return False, self.format_message(constraint, message)
279
299
  else:
280
300
  val = collection_values
281
301
  if constraint.exceptional_values and (
@@ -526,10 +546,7 @@ class IntegerEnumConstraintChecker(ConstraintChecker):
526
546
  ):
527
547
  evaluation = True
528
548
  else:
529
- try:
530
- int_val = int(val)
531
- except: # noqa: E722
532
- int_val = None
549
+ int_val = _parse_int64(val)
533
550
  if int_val is not None:
534
551
  if int_val in constraint.options:
535
552
  evaluation = True
@@ -575,10 +592,7 @@ class IntegerConstraintChecker(ConstraintChecker):
575
592
  min_req = True
576
593
  max_req = True
577
594
  else:
578
- try:
579
- int_val = int(val)
580
- except: # noqa: E722
581
- int_val = None
595
+ int_val = _parse_int64(val)
582
596
  if int_val is not None:
583
597
  min_req = (
584
598
  True
@@ -724,13 +738,18 @@ class DecimalConstraintChecker(ConstraintChecker):
724
738
  pass
725
739
  if decimal_val is not None:
726
740
  if decimal_val.is_finite():
727
- if (
728
- not constraint.allow_scientific_notation
741
+ if not decimal_fits_shared_range(decimal_val):
742
+ message = (
743
+ "value is outside the shared decimal range "
744
+ "(96-bit coefficient, scale <= 28)"
745
+ )
746
+ elif (
747
+ constraint.allow_scientific_notation is False
729
748
  and "e" in val.lower()
730
749
  ):
731
750
  message = "scientific notation is not allowed"
732
751
  else:
733
- scale = -decimal_val.as_tuple().exponent
752
+ scale = max(0, -decimal_val.as_tuple().exponent)
734
753
  min_scale_req = (
735
754
  True
736
755
  if constraint.min_scale is None
@@ -966,40 +985,34 @@ class CVTermConstraintChecker(ConstraintChecker):
966
985
  and param.cv_label in constraint.exceptional_cv_list
967
986
  ):
968
987
  message = "CV source is in exception list"
969
- return True, self.format_message(constraint, message)
970
- is_user_defined = (
971
- not param.cv_label
972
- and not param.cv_accession
973
- and param.name
974
- and len(param.name) > 0
975
- )
976
- if is_user_defined:
977
- if constraint.allow_user_defined_terms:
978
- name_req = True
979
- message = "User-defined terms is allowed."
980
- else:
981
- message = "User-defined terms is not allowed."
988
+ name_req = True
982
989
  else:
983
- if param.name:
984
- if not param.cv_label or not param.cv_accession:
985
- message = f"invalid cv term label or accession. {param}"
990
+ is_user_defined = (
991
+ not param.cv_label
992
+ and not param.cv_accession
993
+ and param.name
994
+ and len(param.name) > 0
995
+ )
996
+ if is_user_defined:
997
+ if constraint.allow_user_defined_terms:
998
+ name_req = True
999
+ message = "User-defined terms is allowed."
986
1000
  else:
987
- if runtime_config and runtime_config.offline_mode:
1001
+ message = "User-defined terms is not allowed."
1002
+ else:
1003
+ if param.name:
1004
+ if not param.cv_label or not param.cv_accession:
1005
+ message = f"invalid cv term label or accession. {param}"
1006
+ elif runtime_config and runtime_config.offline_mode:
988
1007
  name_req = True
989
1008
  message = "term is valid."
990
1009
  else:
991
- verified, message = cv_term_search.check_cv_term(
1010
+ name_req, message = cv_term_search.check_cv_term(
992
1011
  param,
993
- allow_synonym_search=True
994
- if constraint.allow_synonym
995
- else False,
1012
+ allow_synonym_search=bool(constraint.allow_synonym),
996
1013
  )
997
- return verified, self.format_message(
998
- constraint, message
999
- )
1000
-
1001
- else:
1002
- message = f"invalid cv term name: {param}"
1014
+ else:
1015
+ message = f"invalid cv term name: {param}"
1003
1016
 
1004
1017
  messages = [message] if message else []
1005
1018
 
@@ -1063,6 +1076,13 @@ class CVListConstraintChecker(ConstraintChecker):
1063
1076
  message = (
1064
1077
  f"'{param}' term is in exception cv term list."
1065
1078
  )
1079
+ if (
1080
+ not name_req
1081
+ and constraint.exceptional_cv_list
1082
+ and param.cv_label in constraint.exceptional_cv_list
1083
+ ):
1084
+ name_req = True
1085
+ message = "CV source is in exception list"
1066
1086
  if not name_req:
1067
1087
  if constraint.allowed_cv_list:
1068
1088
  if param.cv_label.upper() in [
@@ -1080,9 +1100,7 @@ class CVListConstraintChecker(ConstraintChecker):
1080
1100
  else False,
1081
1101
  )
1082
1102
  )
1083
- return verified, self.format_message(
1084
- constraint, message
1085
- )
1103
+ name_req = verified
1086
1104
  else:
1087
1105
  message = (
1088
1106
  f"'{param}' not in the selected cv list: "
@@ -1099,9 +1117,7 @@ class CVListConstraintChecker(ConstraintChecker):
1099
1117
  if constraint.allow_synonym
1100
1118
  else False,
1101
1119
  )
1102
- return verified, self.format_message(
1103
- constraint, message
1104
- )
1120
+ name_req = verified
1105
1121
 
1106
1122
  else:
1107
1123
  message = "invalid cv term name."
@@ -1240,40 +1256,34 @@ class ParentCVTermConstraintChecker(ConstraintChecker):
1240
1256
  if param in constraint.exceptional_values:
1241
1257
  name_req = True
1242
1258
  message = "cv term is in exception list."
1259
+ if (
1260
+ not name_req
1261
+ and constraint.exceptional_cv_list
1262
+ and param.cv_label in constraint.exceptional_cv_list
1263
+ ):
1264
+ name_req = True
1265
+ message = "CV source is in exception list"
1243
1266
  if not name_req:
1244
1267
  if runtime_config and runtime_config.offline_mode:
1245
1268
  name_req = True
1246
1269
  message = "offline mode."
1247
- else:
1248
- verified_parent = None
1249
- for parent in constraint.parent_cv_terms or []:
1270
+ elif constraint.parent_cv_terms:
1271
+ for parent in constraint.parent_cv_terms:
1250
1272
  verified, message = cv_term_search.check_cv_term(
1251
1273
  cv_term=param,
1252
1274
  parent_cv_term=parent,
1253
- allow_synonym_search=True
1254
- if constraint.allow_synonym
1255
- else False,
1275
+ allow_synonym_search=bool(
1276
+ constraint.allow_synonym
1277
+ ),
1256
1278
  )
1257
1279
  if verified:
1258
- verified_parent = parent
1280
+ name_req = True
1281
+ message = (
1282
+ f"CV term {param} is parent of {parent}."
1283
+ )
1259
1284
  break
1260
- if verified_parent:
1261
- name_req = True
1262
- message = (
1263
- f"CV term {param} is "
1264
- f"parent of {verified_parent}."
1265
- )
1266
- else:
1285
+ if not name_req:
1267
1286
  message = "term in not child of the parent."
1268
-
1269
- if constraint.parent_cv_terms:
1270
- input_val = BaseCvTerm.model_validate(
1271
- param, from_attributes=True
1272
- )
1273
- if input_val in (constraint.exceptional_values or []):
1274
- name_req = True
1275
- else:
1276
- message = "not child of the parent."
1277
1287
  else:
1278
1288
  name_req = True
1279
1289
  message = "not child of the parent."
@@ -15,6 +15,10 @@ logger = logging.getLogger(__name__)
15
15
  _T = TypeVar("T")
16
16
 
17
17
 
18
+ class _OlsSearchFailure(RuntimeError):
19
+ """An unsuccessful OLS response that must not be kept in the result cache."""
20
+
21
+
18
22
  class _OlsSearchModel(JsonProfileBaseModel, Generic[_T]):
19
23
  page: int
20
24
  num_elements: int
@@ -53,18 +57,17 @@ class _ChildrenSearchModel(JsonProfileBaseModel):
53
57
  def _search_ols(
54
58
  url: str, params: dict, headers: dict, timeout: int = 10
55
59
  ) -> tuple[int, dict[str, Any]]:
56
- try:
57
- result = httpx2.get(url, params=params, headers=headers, timeout=timeout)
58
- if result.status_code in {200, 201}:
59
- return result.status_code, result.json()
60
- except Exception as ex:
61
- return 500, {"error": str(ex)}
60
+ result = httpx2.get(url, params=params, headers=headers, timeout=timeout)
61
+ if result.status_code in {200, 201}:
62
+ return result.status_code, result.json()
63
+ if result.status_code == 404:
64
+ return result.status_code, {}
62
65
  logger.warning(
63
66
  "Could not find CV term: %s %s",
64
67
  result.status_code,
65
68
  orjson.dumps(params, orjson.OPT_SORT_KEYS),
66
69
  )
67
- return result.status_code, {}
70
+ raise _OlsSearchFailure(f"OLS returned HTTP {result.status_code} for {url}")
68
71
 
69
72
 
70
73
  class OlsCvTermSearch(CvTermSearch):
@@ -125,7 +125,7 @@ class JsonValidator:
125
125
  logger.debug("Json schema will be loaded.")
126
126
  self.json_schema = None
127
127
  self.schema_validator = None
128
- if json_schema:
128
+ if json_schema is not None:
129
129
  self.json_schema = self.load_jsonschema(json_schema)
130
130
  self.validate_jsonschema(self.json_schema)
131
131
  logger.debug("Json schema validated.")
@@ -233,7 +233,7 @@ class JsonValidator:
233
233
  elif isinstance(profile, dict):
234
234
  target_profile = JsonProfile.model_validate(profile, by_alias=True)
235
235
  if isinstance(profile, JsonProfile):
236
- target_profile = profile
236
+ target_profile = profile.model_copy(deep=True)
237
237
  if not target_profile or not isinstance(target_profile, JsonProfile):
238
238
  raise ValueError(
239
239
  "Json profile load failure. "
@@ -284,6 +284,8 @@ class JsonValidator:
284
284
 
285
285
  @staticmethod
286
286
  def extend_profile(parent_profile: None | JsonProfile, input_profile: JsonProfile):
287
+ # Merge into a detached copy because profiles may be reused or cached.
288
+ input_profile = input_profile.model_copy(deep=True)
287
289
  if not parent_profile:
288
290
  if not input_profile.configuration:
289
291
  input_profile.configuration = JsonProfileConfiguration(
@@ -301,16 +303,21 @@ class JsonValidator:
301
303
  parent_profile.name,
302
304
  )
303
305
  new_requirements = input_profile.requirements or {}
304
- input_profile.requirements = dict(parent_profile.requirements or {})
306
+ input_profile.requirements = {
307
+ key: value.model_copy(deep=True) if value is not None else None
308
+ for key, value in (parent_profile.requirements or {}).items()
309
+ }
305
310
 
306
311
  if not input_profile.configuration:
307
312
  input_profile.configuration = (
308
313
  parent_profile.configuration or JsonProfileConfiguration()
309
- )
314
+ ).model_copy(deep=True)
310
315
 
311
316
  # set configuration fields from parent if they are not defined.
312
317
  config = input_profile.configuration
313
- parent_config = parent_profile.configuration or JsonProfileConfiguration()
318
+ parent_config = (
319
+ parent_profile.configuration or JsonProfileConfiguration()
320
+ ).model_copy(deep=True)
314
321
  for field, _ in config.__class__.model_fields.items():
315
322
  val = getattr(config, field)
316
323
  if val is None:
@@ -526,8 +533,9 @@ class JsonValidator:
526
533
  process_reporting_threshold_in_secs: float = 5.0,
527
534
  ) -> JsonValidationResult:
528
535
  id_fields = self.json_profile.configuration.object_unique_id_fields
529
- if not runtime_config:
530
- runtime_config = ValidationRuntimeConfiguration()
536
+ runtime_config = (
537
+ runtime_config or ValidationRuntimeConfiguration()
538
+ ).model_copy(deep=True)
531
539
  unique_id_objects = {}
532
540
  if id_fields:
533
541
  unique_id_objects = self.update_unique_object_dict(
@@ -973,7 +981,9 @@ class JsonValidator:
973
981
  )
974
982
 
975
983
  if field_requirement.value_constraint:
976
- constraint = field_requirement.value_constraint
984
+ constraint = field_requirement.value_constraint.model_copy(
985
+ deep=True
986
+ )
977
987
  checker = self.profile_validator_factory.get_checker(constraint)
978
988
  skip = False
979
989
  if runtime_config and runtime_config.skip_decimal_validations:
@@ -981,12 +991,10 @@ class JsonValidator:
981
991
  skip = True
982
992
 
983
993
  if not skip:
984
- if not field_requirement.value_constraint.description:
985
- field_requirement.value_constraint.description = (
986
- field_requirement.description
987
- )
994
+ if not constraint.description:
995
+ constraint.description = field_requirement.description
988
996
  res = checker.validate_constraint(
989
- constraint=field_requirement.value_constraint,
997
+ constraint=constraint,
990
998
  value=x.value,
991
999
  root=input_json,
992
1000
  context=context,
@@ -5,6 +5,7 @@ import re
5
5
  import shutil
6
6
  import tarfile
7
7
  import tempfile
8
+ import threading
8
9
  import time
9
10
  from datetime import datetime, timezone
10
11
  from pathlib import Path
@@ -222,6 +223,7 @@ def _builtin_time_parse_ns(*args):
222
223
 
223
224
  class LocalOpaEngine(OpaEngine):
224
225
  def __init__(self, wasm_path: str):
226
+ self._evaluation_lock = threading.RLock()
225
227
  try:
226
228
  self._bundle_data = None
227
229
  if tarfile.is_tarfile(wasm_path):
@@ -396,23 +398,24 @@ class LocalOpaEngine(OpaEngine):
396
398
  bundle data loaded from data.json is restored if available.
397
399
  """
398
400
  input_data = _sanitize_json_value(input_data)
399
- if data is not None:
400
- self.policy.set_data(_sanitize_json_value(data))
401
- elif self._bundle_data is not None:
402
- self.policy.set_data(_sanitize_json_value(self._bundle_data))
403
- entrypoint = self.resolve_entrypoint(entrypoint)
404
- if entrypoint is None:
405
- result = self.policy.evaluate(input_data)
406
- else:
407
- result = self.policy.evaluate(input_data, entrypoint=entrypoint)
408
- # Return the raw OPA evaluation result so callers can inspect
409
- # the returned list of evaluation records
410
- return result[0].get("result") if result and result[0] else []
401
+ with self._evaluation_lock:
402
+ if data is not None:
403
+ self.policy.set_data(_sanitize_json_value(data))
404
+ elif self._bundle_data is not None:
405
+ self.policy.set_data(_sanitize_json_value(self._bundle_data))
406
+ entrypoint = self.resolve_entrypoint(entrypoint)
407
+ if entrypoint is None:
408
+ result = self.policy.evaluate(input_data)
409
+ else:
410
+ result = self.policy.evaluate(input_data, entrypoint=entrypoint)
411
+ # Return the raw OPA evaluation result for caller inspection.
412
+ return result[0].get("result") if result and result[0] else []
411
413
 
412
414
 
413
415
  class LocalOpaEngineFactory(OpaEngineFactory):
414
416
  def __init__(self):
415
417
  self.engines: dict[Path, OpaEngine] = {}
418
+ self._engines_lock = threading.RLock()
416
419
 
417
420
  def load_from_url(self, url: str, file_path: str) -> None:
418
421
  """Download a file from a URL and save it to the given file path.
@@ -441,30 +444,31 @@ class LocalOpaEngineFactory(OpaEngineFactory):
441
444
  wasm_file_path: str,
442
445
  wasm_file_download_url: Optional[str] = None,
443
446
  ):
444
- if not wasm_file_path:
445
- raise ValueError("Invalid WASM file path")
446
- wasm_path = Path(wasm_file_path)
447
- if not wasm_path.exists():
448
- alt_parent = Path("..") / wasm_path
449
- alt_stripped = Path(wasm_file_path.removeprefix("../"))
450
- if alt_parent.exists():
451
- wasm_path = alt_parent
452
- wasm_file_path = str(alt_parent)
453
- elif alt_stripped.exists():
454
- wasm_path = alt_stripped
455
- wasm_file_path = str(alt_stripped)
456
- if wasm_path in self.engines:
457
- return self.engines[wasm_path]
458
-
459
- if not wasm_path.exists() and not wasm_file_download_url:
460
- raise ValueError(
461
- "Invalid WASM file does not exists and URL is not defined. "
462
- "Select a valid file path or a valid URL to download WASM file"
463
- )
464
-
465
- if not wasm_path.exists():
466
- self.load_from_url(wasm_file_download_url, wasm_file_path)
467
-
468
- engine = LocalOpaEngine(wasm_path=wasm_file_path)
469
- self.engines[wasm_path] = engine
470
- return engine
447
+ with self._engines_lock:
448
+ if not wasm_file_path:
449
+ raise ValueError("Invalid WASM file path")
450
+ wasm_path = Path(wasm_file_path)
451
+ if not wasm_path.exists():
452
+ alt_parent = Path("..") / wasm_path
453
+ alt_stripped = Path(wasm_file_path.removeprefix("../"))
454
+ if alt_parent.exists():
455
+ wasm_path = alt_parent
456
+ wasm_file_path = str(alt_parent)
457
+ elif alt_stripped.exists():
458
+ wasm_path = alt_stripped
459
+ wasm_file_path = str(alt_stripped)
460
+ if wasm_path in self.engines:
461
+ return self.engines[wasm_path]
462
+
463
+ if not wasm_path.exists() and not wasm_file_download_url:
464
+ raise ValueError(
465
+ "Invalid WASM file does not exists and URL is not defined. "
466
+ "Select a valid file path or a valid URL to download WASM file"
467
+ )
468
+
469
+ if not wasm_path.exists():
470
+ self.load_from_url(wasm_file_download_url, wasm_file_path)
471
+
472
+ engine = LocalOpaEngine(wasm_path=wasm_file_path)
473
+ self.engines[wasm_path] = engine
474
+ return engine
File without changes