unitysvc-core 0.2.32__tar.gz → 0.2.34__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. {unitysvc_core-0.2.32/src/unitysvc_core.egg-info → unitysvc_core-0.2.34}/PKG-INFO +14 -1
  2. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/README.md +13 -0
  3. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/pyproject.toml +1 -1
  4. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/models/base.py +5 -0
  5. unitysvc_core-0.2.34/src/unitysvc_core/parameter_usage.py +89 -0
  6. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/validator.py +22 -0
  7. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34/src/unitysvc_core.egg-info}/PKG-INFO +14 -1
  8. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core.egg-info/SOURCES.txt +2 -0
  9. unitysvc_core-0.2.34/tests/test_parameter_usage.py +121 -0
  10. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/tests/test_validators.py +8 -0
  11. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/LICENSE +0 -0
  12. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/setup.cfg +0 -0
  13. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/__init__.py +0 -0
  14. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/models/__init__.py +0 -0
  15. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/models/documents.py +0 -0
  16. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/models/listing_data.py +0 -0
  17. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/models/listing_v1.py +0 -0
  18. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/models/offering_data.py +0 -0
  19. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/models/offering_v1.py +0 -0
  20. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/models/pricing.py +0 -0
  21. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/models/promotion_data.py +0 -0
  22. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/models/provider_data.py +0 -0
  23. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/models/provider_v1.py +0 -0
  24. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/models/service.py +0 -0
  25. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/models/service_group_data.py +0 -0
  26. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/models/validators.py +0 -0
  27. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/py.typed +0 -0
  28. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/schema/base.json +0 -0
  29. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/schema/listing_v1.json +0 -0
  30. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/schema/offering_v1.json +0 -0
  31. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/schema/promotion_v1.json +0 -0
  32. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/schema/provider_v1.json +0 -0
  33. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/schema/service_group_v1.json +0 -0
  34. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core/utils.py +0 -0
  35. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
  36. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core.egg-info/requires.txt +0 -0
  37. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/src/unitysvc_core.egg-info/top_level.txt +0 -0
  38. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/tests/test_access_interface_routing.py +0 -0
  39. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/tests/test_effective_price.py +0 -0
  40. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/tests/test_hidden_path_skip.py +0 -0
  41. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/tests/test_offering_data.py +0 -0
  42. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/tests/test_pricing.py +0 -0
  43. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/tests/test_provider_account_rate_limits.py +0 -0
  44. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/tests/test_service_group.py +0 -0
  45. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/tests/test_service_status.py +0 -0
  46. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/tests/test_utils.py +0 -0
  47. {unitysvc_core-0.2.32 → unitysvc_core-0.2.34}/tests/test_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitysvc-core
3
- Version: 0.2.32
3
+ Version: 0.2.34
4
4
  Summary: Shared data models and validation helpers for UnitySVC packages
5
5
  Author-email: Bo Peng <bo.peng@unitysvc.com>
6
6
  Maintainer-email: Bo Peng <bo.peng@unitysvc.com>
@@ -132,3 +132,16 @@ moved to `unitysvc-sellers`.
132
132
  ## License
133
133
 
134
134
  MIT
135
+
136
+ ### Required enrollment parameter usage
137
+
138
+ The shared validator rejects required user parameters that no channel, interface,
139
+ or document consumes. File validation checks a listing against its paired offering
140
+ when present; backend ingest calls the same `validate_required_parameter_usage`
141
+ helper with the complete listing and offering. Schema declarations alone are not
142
+ consumers. Jinja attribute access, string-key lookups, and `params.get("name")`
143
+ are inspected; dynamic access and whole-map iteration conservatively consume all
144
+ parameters. Deprecated content can still be retired.
145
+
146
+ This validates usage, not channel attribution: a document reference counts as a
147
+ consumer without proving that the intended paid channel takes enrollment.
@@ -97,3 +97,16 @@ moved to `unitysvc-sellers`.
97
97
  ## License
98
98
 
99
99
  MIT
100
+
101
+ ### Required enrollment parameter usage
102
+
103
+ The shared validator rejects required user parameters that no channel, interface,
104
+ or document consumes. File validation checks a listing against its paired offering
105
+ when present; backend ingest calls the same `validate_required_parameter_usage`
106
+ helper with the complete listing and offering. Schema declarations alone are not
107
+ consumers. Jinja attribute access, string-key lookups, and `params.get("name")`
108
+ are inspected; dynamic access and whole-map iteration conservatively consume all
109
+ parameters. Deprecated content can still be retired.
110
+
111
+ This validates usage, not channel attribution: a document reference counts as a
112
+ consumer without proving that the intended paid channel takes enrollment.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "unitysvc-core"
7
- version = "0.2.32"
7
+ version = "0.2.34"
8
8
  description = "Shared data models and validation helpers for UnitySVC packages"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Bo Peng", email = "bo.peng@unitysvc.com" }]
@@ -281,6 +281,11 @@ class RequestTransformEnum(StrEnum):
281
281
  # the gateway auto-detects the incoming format and translates in-process only
282
282
  # when needed (#923/#929/#1400). Replaces the earlier ``llm_translate`` key.
283
283
  llm_translator = "llm_translator"
284
+ # Normalize a `msg` envelope (title, body, links, attachment, format) for
285
+ # the destination channel's declared capabilities, optionally mapping the
286
+ # result into the upstream body. Runs before body_transformer, which can
287
+ # template the normalized msg (unitysvc/unitysvc#2650).
288
+ msg_transformer = "msg_transformer"
284
289
 
285
290
 
286
291
  class ServiceTypeEnum(StrEnum):
@@ -0,0 +1,89 @@
1
+ """Shared cross-file validation of required enrollment parameter usage.
2
+
3
+ This checks that required parameters have consumers. It does not prove that
4
+ an intended enrollment channel consumes them; interface/document use counts.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ from jinja2 import Environment, TemplateSyntaxError, nodes
10
+
11
+ _ENV = Environment()
12
+
13
+
14
+ def referenced_param_names(value: Any) -> set[str] | None:
15
+ """Names read from ``params`` in Jinja, or None for dynamic/whole-map use.
16
+
17
+ Literal strings/comments are not references. Dictionary iteration and
18
+ dynamic lookups conservatively count as consuming all parameters.
19
+ """
20
+ names: set[str] = set()
21
+ if isinstance(value, str):
22
+ if "{{" not in value and "{%" not in value:
23
+ return names
24
+ tree = _ENV.parse(value)
25
+ parents = {id(child): parent for parent in tree.find_all(nodes.Node) for child in parent.iter_child_nodes()}
26
+ for node in tree.find_all(nodes.Name):
27
+ if node.name != "params" or node.ctx != "load":
28
+ continue
29
+ parent = parents.get(id(node))
30
+ if isinstance(parent, nodes.Getitem) and isinstance(parent.arg, nodes.Const):
31
+ if isinstance(parent.arg.value, str):
32
+ names.add(parent.arg.value)
33
+ continue
34
+ if isinstance(parent, nodes.Getattr):
35
+ call = parents.get(id(parent))
36
+ if isinstance(call, nodes.Call) and call.node is parent:
37
+ if parent.attr == "get" and call.args and isinstance(call.args[0], nodes.Const):
38
+ if isinstance(call.args[0].value, str):
39
+ names.add(call.args[0].value)
40
+ continue
41
+ return None
42
+ names.add(parent.attr)
43
+ continue
44
+ return None
45
+ return names
46
+ items = value.values() if isinstance(value, dict) else value if isinstance(value, (list, tuple)) else ()
47
+ for item in items:
48
+ found = referenced_param_names(item)
49
+ if found is None:
50
+ return None
51
+ names |= found
52
+ return names
53
+
54
+
55
+ def unused_required_params(parameters_schema: dict[str, Any] | None, *sources: Any) -> list[str]:
56
+ """Required names that no source consumes (schema declarations are not sources)."""
57
+ required = (parameters_schema or {}).get("required") or []
58
+ if not required:
59
+ return []
60
+ used: set[str] = set()
61
+ for source in sources:
62
+ names = referenced_param_names(source)
63
+ if names is None:
64
+ return []
65
+ used |= names
66
+ return [name for name in required if name not in used]
67
+
68
+
69
+ def validate_required_parameter_usage(listing: dict[str, Any], offering: dict[str, Any]) -> list[str]:
70
+ """Return hard validation errors, shared by seller validation and backend ingest.
71
+
72
+ Deprecated content can still be retired. Referencing a parameter in a
73
+ document satisfies usage without guaranteeing correct channel attribution.
74
+ """
75
+ if listing.get("status") == "deprecated" or offering.get("status") == "deprecated":
76
+ return []
77
+ schema = listing.get("user_parameters_schema") or listing.get("parameters_schema")
78
+ content = {k: v for k, v in listing.items() if k not in ("user_parameters_schema", "parameters_schema")}
79
+ try:
80
+ unused = unused_required_params(schema, content, offering)
81
+ except TemplateSyntaxError as exc:
82
+ return [f"Cannot validate required parameter usage: invalid Jinja template: {exc}"]
83
+ if not unused:
84
+ return []
85
+ return [
86
+ f"Required user parameter(s) {', '.join(unused)} are used by no channel, interface or document. "
87
+ "Reference each one as {{ params.NAME }} (for example ${ customer_secrets.{{ params.NAME }} }), "
88
+ "or drop it from the required list."
89
+ ]
@@ -10,6 +10,7 @@ from urllib.parse import urlparse
10
10
  from jinja2 import Environment, StrictUndefined, TemplateSyntaxError, UndefinedError
11
11
  from jsonschema.validators import Draft7Validator
12
12
 
13
+ from .parameter_usage import validate_required_parameter_usage
13
14
  from .utils import SCHEMA_TO_STEM, is_hidden_path, load_data_file, schema_for_path
14
15
 
15
16
  # ``details.model``: a provider-neutral model name (``glm-5.2``, not
@@ -1004,10 +1005,31 @@ class DataValidator:
1004
1005
  errors.extend(validate_listing_smtp_base_urls(uai))
1005
1006
  errors.extend(validate_listing_mcp_base_urls(uai))
1006
1007
  errors.extend(validate_listing_jinja_var_references(data))
1008
+ errors.extend(self.validate_required_parameter_usage(data, file_path))
1007
1009
  errors.extend(self.validate_listing_name_namespace(data, file_path))
1008
1010
 
1009
1011
  return len(errors) == 0, errors
1010
1012
 
1013
+ def validate_required_parameter_usage(self, listing: dict[str, Any], file_path: Path) -> list[str]:
1014
+ """Check the listing against its paired offering, after overrides/presets.
1015
+
1016
+ Standalone listings without an offering cannot establish cross-file
1017
+ usage; full service uploads are always checked by backend ingest too.
1018
+ """
1019
+ schema = listing.get("user_parameters_schema") or listing.get("parameters_schema")
1020
+ if not isinstance(schema, dict) or not schema.get("required"):
1021
+ return []
1022
+ for candidate in sorted(file_path.parent.glob("offering*.*")):
1023
+ if candidate.suffix not in (".json", ".toml") or ".override." in candidate.name:
1024
+ continue
1025
+ try:
1026
+ offering, _ = load_data_file(candidate)
1027
+ except Exception as exc:
1028
+ return [f"Cannot check required parameter usage: {exc}"]
1029
+ if isinstance(offering, dict) and (offering.get("schema") == "offering_v1" or candidate.stem == "offering"):
1030
+ return validate_required_parameter_usage(listing, offering)
1031
+ return []
1032
+
1011
1033
  def _resolve_provider_name(self, file_path: Path) -> str | None:
1012
1034
  """Find the provider name (``provider_v1.name``) governing a listing file.
1013
1035
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitysvc-core
3
- Version: 0.2.32
3
+ Version: 0.2.34
4
4
  Summary: Shared data models and validation helpers for UnitySVC packages
5
5
  Author-email: Bo Peng <bo.peng@unitysvc.com>
6
6
  Maintainer-email: Bo Peng <bo.peng@unitysvc.com>
@@ -132,3 +132,16 @@ moved to `unitysvc-sellers`.
132
132
  ## License
133
133
 
134
134
  MIT
135
+
136
+ ### Required enrollment parameter usage
137
+
138
+ The shared validator rejects required user parameters that no channel, interface,
139
+ or document consumes. File validation checks a listing against its paired offering
140
+ when present; backend ingest calls the same `validate_required_parameter_usage`
141
+ helper with the complete listing and offering. Schema declarations alone are not
142
+ consumers. Jinja attribute access, string-key lookups, and `params.get("name")`
143
+ are inspected; dynamic access and whole-map iteration conservatively consume all
144
+ parameters. Deprecated content can still be retired.
145
+
146
+ This validates usage, not channel attribution: a document reference counts as a
147
+ consumer without proving that the intended paid channel takes enrollment.
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  src/unitysvc_core/__init__.py
5
+ src/unitysvc_core/parameter_usage.py
5
6
  src/unitysvc_core/py.typed
6
7
  src/unitysvc_core/utils.py
7
8
  src/unitysvc_core/validator.py
@@ -34,6 +35,7 @@ tests/test_access_interface_routing.py
34
35
  tests/test_effective_price.py
35
36
  tests/test_hidden_path_skip.py
36
37
  tests/test_offering_data.py
38
+ tests/test_parameter_usage.py
37
39
  tests/test_pricing.py
38
40
  tests/test_provider_account_rate_limits.py
39
41
  tests/test_service_group.py
@@ -0,0 +1,121 @@
1
+ """Required enrollment parameters must have consumers across service data."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from unitysvc_core.parameter_usage import referenced_param_names, validate_required_parameter_usage
9
+ from unitysvc_core.validator import DataValidator
10
+
11
+
12
+ @pytest.mark.parametrize("expression", ["params.name", "params['name']", 'params.get("name")'])
13
+ def test_named_reads(expression):
14
+ assert referenced_param_names("{{ " + expression + " }}") == {"name"}
15
+
16
+
17
+ @pytest.mark.parametrize(
18
+ "expression",
19
+ [
20
+ "params",
21
+ "params | tojson",
22
+ "params.items()",
23
+ "params.keys()",
24
+ "params.values()",
25
+ "params.get(key)",
26
+ "params[key]",
27
+ ],
28
+ )
29
+ def test_dynamic_and_whole_map_reads(expression):
30
+ assert referenced_param_names("{{ " + expression + " }}") is None
31
+
32
+
33
+ def test_strings_comments_and_non_template_text_are_not_consumers():
34
+ assert referenced_param_names(["params.name", "{# params.name #}", '{{ "params.name" }}']) == set()
35
+
36
+
37
+ def listing(**changes):
38
+ return {"status": "ready", "user_parameters_schema": {"required": ["host", "token"]}, **changes}
39
+
40
+
41
+ def test_required_names_in_the_schema_do_not_count_as_use():
42
+ errors = validate_required_parameter_usage(
43
+ listing(), {"upstream_access_config": {"free": {"api_key": "${ customer_secrets.TOKEN }"}}}
44
+ )
45
+ assert len(errors) == 1
46
+ assert "host, token are used by no channel" in errors[0]
47
+
48
+
49
+ def test_partial_use_is_rejected():
50
+ errors = validate_required_parameter_usage(listing(), {"host": "{{ params.host }}"})
51
+ assert "token are used by no channel" in errors[0]
52
+
53
+
54
+ def test_channel_interface_and_document_reads_count_without_proving_attribution():
55
+ data = listing(documents={"example": {"body": "{{ params.token }}"}})
56
+ assert validate_required_parameter_usage(data, {"host": "{{ params.host }}"}) == []
57
+ assert (
58
+ validate_required_parameter_usage(
59
+ listing(user_access_interfaces={"url": "{{ params.host }}"}, documents={"body": "{{ params.token }}"}), {}
60
+ )
61
+ == []
62
+ )
63
+
64
+
65
+ def test_required_parameter_can_have_punctuation_in_its_name():
66
+ assert (
67
+ validate_required_parameter_usage(
68
+ listing(user_parameters_schema={"required": ["host-name"]}), {"host": "{{ params['host-name'] }}"}
69
+ )
70
+ == []
71
+ )
72
+
73
+
74
+ def test_deprecated_content_can_be_retired():
75
+ assert validate_required_parameter_usage(listing(status="deprecated"), {}) == []
76
+ assert validate_required_parameter_usage(listing(), {"status": "deprecated"}) == []
77
+
78
+
79
+ def test_no_required_parameters():
80
+ assert validate_required_parameter_usage({}, {}) == []
81
+
82
+
83
+ @pytest.mark.parametrize("suffix", ["json", "toml"])
84
+ def test_file_validator_checks_paired_offering(tmp_path, suffix):
85
+ schema_dir = Path(__file__).parents[1] / "src/unitysvc_core/schema"
86
+ validator = DataValidator(tmp_path, schema_dir)
87
+ data = listing()
88
+ (tmp_path / "listing.json").write_text(json.dumps(data))
89
+ path = tmp_path / f"offering.{suffix}"
90
+ path.write_text("{}" if suffix == "json" else "")
91
+ errors = validator.validate_required_parameter_usage(data, tmp_path / "listing.json")
92
+ assert "host, token" in errors[0]
93
+ path.write_text(
94
+ json.dumps({"host": "{{ params.host }}", "token": "{{ params.token }}"})
95
+ if suffix == "json"
96
+ else 'host = "{{ params.host }}"\ntoken = "{{ params.token }}"\n'
97
+ )
98
+ assert validator.validate_required_parameter_usage(data, tmp_path / "listing.json") == []
99
+
100
+
101
+ def test_validation_pipeline_invokes_the_shared_rule(tmp_path, monkeypatch):
102
+ schema_dir = Path(__file__).parents[1] / "src/unitysvc_core/schema"
103
+ validator = DataValidator(tmp_path, schema_dir)
104
+ path = tmp_path / "listing.json"
105
+ path.write_text(json.dumps({"name": "labs/test"}))
106
+ calls = []
107
+
108
+ def check(data, file_path):
109
+ calls.append(file_path)
110
+ return ["unused-param-sentinel"]
111
+
112
+ monkeypatch.setattr(validator, "validate_required_parameter_usage", check)
113
+ valid, errors = validator.validate_data_file(path, "listing_v1")
114
+ assert not valid
115
+ assert "unused-param-sentinel" in errors
116
+ assert calls == [path]
117
+
118
+
119
+ def test_invalid_jinja_does_not_bypass_the_rule():
120
+ errors = validate_required_parameter_usage(listing(), {"host": "{{ params.host"})
121
+ assert "invalid Jinja template" in errors[0]
@@ -675,3 +675,11 @@ class TestPlatformListingBaseUrls:
675
675
  def test_requires_the_address(self, value) -> None:
676
676
  errors = validate_platform_listing_base_urls(value)
677
677
  assert len(errors) == 1 and "/p/<route>" in errors[0]
678
+
679
+
680
+ def test_request_transformer_enum_has_msg_transformer():
681
+ """The gateway's msg_transformer key must be a valid request_transformer
682
+ key, or the backend rejects routes that use it (unitysvc/unitysvc#2650)."""
683
+ from unitysvc_core.models import RequestTransformEnum
684
+
685
+ assert RequestTransformEnum("msg_transformer") is RequestTransformEnum.msg_transformer
File without changes
File without changes