unitysvc-core 0.2.33__tar.gz → 0.2.35__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.33/src/unitysvc_core.egg-info → unitysvc_core-0.2.35}/PKG-INFO +18 -1
  2. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/README.md +17 -0
  3. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/pyproject.toml +1 -1
  4. unitysvc_core-0.2.35/src/unitysvc_core/parameter_usage.py +120 -0
  5. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/validator.py +46 -0
  6. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35/src/unitysvc_core.egg-info}/PKG-INFO +18 -1
  7. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core.egg-info/SOURCES.txt +2 -0
  8. unitysvc_core-0.2.35/tests/test_parameter_usage.py +185 -0
  9. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/LICENSE +0 -0
  10. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/setup.cfg +0 -0
  11. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/__init__.py +0 -0
  12. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/models/__init__.py +0 -0
  13. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/models/base.py +0 -0
  14. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/models/documents.py +0 -0
  15. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/models/listing_data.py +0 -0
  16. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/models/listing_v1.py +0 -0
  17. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/models/offering_data.py +0 -0
  18. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/models/offering_v1.py +0 -0
  19. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/models/pricing.py +0 -0
  20. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/models/promotion_data.py +0 -0
  21. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/models/provider_data.py +0 -0
  22. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/models/provider_v1.py +0 -0
  23. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/models/service.py +0 -0
  24. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/models/service_group_data.py +0 -0
  25. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/models/validators.py +0 -0
  26. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/py.typed +0 -0
  27. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/schema/base.json +0 -0
  28. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/schema/listing_v1.json +0 -0
  29. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/schema/offering_v1.json +0 -0
  30. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/schema/promotion_v1.json +0 -0
  31. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/schema/provider_v1.json +0 -0
  32. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/schema/service_group_v1.json +0 -0
  33. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core/utils.py +0 -0
  34. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
  35. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core.egg-info/requires.txt +0 -0
  36. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/src/unitysvc_core.egg-info/top_level.txt +0 -0
  37. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/tests/test_access_interface_routing.py +0 -0
  38. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/tests/test_effective_price.py +0 -0
  39. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/tests/test_hidden_path_skip.py +0 -0
  40. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/tests/test_offering_data.py +0 -0
  41. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/tests/test_pricing.py +0 -0
  42. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/tests/test_provider_account_rate_limits.py +0 -0
  43. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/tests/test_service_group.py +0 -0
  44. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/tests/test_service_status.py +0 -0
  45. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/tests/test_utils.py +0 -0
  46. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/tests/test_validator.py +0 -0
  47. {unitysvc_core-0.2.33 → unitysvc_core-0.2.35}/tests/test_validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitysvc-core
3
- Version: 0.2.33
3
+ Version: 0.2.35
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,20 @@ 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. Only those consumer fields count — the offering's
140
+ `upstream_access_config`, the listing's `user_access_interfaces`, and document
141
+ content (a `.j2` document's template source, which the seller validator reads from
142
+ disk as the SDK uploads it); metadata such as `display_name` or `description` is
143
+ never rendered with `params`. File validation checks a listing against its paired offering
144
+ when present; backend ingest calls the same `validate_required_parameter_usage`
145
+ helper with the complete listing and offering. Schema declarations alone are not
146
+ consumers. Jinja attribute access, string-key lookups, and `params.get("name")`
147
+ are inspected; dynamic access and whole-map iteration conservatively consume all
148
+ parameters. Deprecated content can still be retired.
149
+
150
+ This validates usage, not channel attribution: a document reference counts as a
151
+ consumer without proving that the intended paid channel takes enrollment.
@@ -97,3 +97,20 @@ 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. Only those consumer fields count — the offering's
105
+ `upstream_access_config`, the listing's `user_access_interfaces`, and document
106
+ content (a `.j2` document's template source, which the seller validator reads from
107
+ disk as the SDK uploads it); metadata such as `display_name` or `description` is
108
+ never rendered with `params`. File validation checks a listing against its paired offering
109
+ when present; backend ingest calls the same `validate_required_parameter_usage`
110
+ helper with the complete listing and offering. Schema declarations alone are not
111
+ consumers. Jinja attribute access, string-key lookups, and `params.get("name")`
112
+ are inspected; dynamic access and whole-map iteration conservatively consume all
113
+ parameters. Deprecated content can still be retired.
114
+
115
+ This validates usage, not channel attribution: a document reference counts as a
116
+ 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.33"
7
+ version = "0.2.35"
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" }]
@@ -0,0 +1,120 @@
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
+ Only the fields the platform renders with an enrollment's ``params`` are
7
+ consumers: the offering's channels (``upstream_access_config``), the listing's
8
+ interfaces (``user_access_interfaces``) and document content. Metadata such as
9
+ ``display_name`` or ``description`` is never rendered with ``params``, so a
10
+ reference there does not count (unitysvc/unitysvc#2649).
11
+ """
12
+
13
+ from typing import Any
14
+
15
+ from jinja2 import Environment, TemplateSyntaxError, nodes
16
+
17
+ _ENV = Environment()
18
+
19
+
20
+ def referenced_param_names(value: Any) -> set[str] | None:
21
+ """Names read from ``params`` in Jinja, or None for dynamic/whole-map use.
22
+
23
+ Literal strings/comments are not references. Dictionary iteration and
24
+ dynamic lookups conservatively count as consuming all parameters.
25
+ """
26
+ names: set[str] = set()
27
+ if isinstance(value, str):
28
+ if "{{" not in value and "{%" not in value:
29
+ return names
30
+ tree = _ENV.parse(value)
31
+ parents = {id(child): parent for parent in tree.find_all(nodes.Node) for child in parent.iter_child_nodes()}
32
+ for node in tree.find_all(nodes.Name):
33
+ if node.name != "params" or node.ctx != "load":
34
+ continue
35
+ parent = parents.get(id(node))
36
+ if isinstance(parent, nodes.Getitem) and isinstance(parent.arg, nodes.Const):
37
+ if isinstance(parent.arg.value, str):
38
+ names.add(parent.arg.value)
39
+ continue
40
+ if isinstance(parent, nodes.Getattr):
41
+ call = parents.get(id(parent))
42
+ if isinstance(call, nodes.Call) and call.node is parent:
43
+ if parent.attr == "get" and call.args and isinstance(call.args[0], nodes.Const):
44
+ if isinstance(call.args[0].value, str):
45
+ names.add(call.args[0].value)
46
+ continue
47
+ return None
48
+ names.add(parent.attr)
49
+ continue
50
+ return None
51
+ return names
52
+ items = value.values() if isinstance(value, dict) else value if isinstance(value, (list, tuple)) else ()
53
+ for item in items:
54
+ found = referenced_param_names(item)
55
+ if found is None:
56
+ return None
57
+ names |= found
58
+ return names
59
+
60
+
61
+ def unused_required_params(parameters_schema: dict[str, Any] | None, *sources: Any) -> list[str]:
62
+ """Required names that no source consumes (schema declarations are not sources)."""
63
+ required = (parameters_schema or {}).get("required") or []
64
+ if not required:
65
+ return []
66
+ used: set[str] = set()
67
+ for source in sources:
68
+ names = referenced_param_names(source)
69
+ if names is None:
70
+ return []
71
+ used |= names
72
+ return [name for name in required if name not in used]
73
+
74
+
75
+ # Keys of a document entry that hold its rendered content. Backend ingest sees
76
+ # a ``.j2`` document's template source inlined as ``file_content``; the seller
77
+ # validator inlines the same file before calling this module.
78
+ _DOCUMENT_CONTENT_KEYS = ("file_content", "content")
79
+
80
+
81
+ def _document_contents(documents: Any) -> list[str]:
82
+ entries = documents.values() if isinstance(documents, dict) else documents if isinstance(documents, list) else ()
83
+ out: list[str] = []
84
+ for doc in entries:
85
+ if isinstance(doc, dict):
86
+ out.extend(doc[key] for key in _DOCUMENT_CONTENT_KEYS if isinstance(doc.get(key), str))
87
+ return out
88
+
89
+
90
+ def consumer_sources(listing: dict[str, Any], offering: dict[str, Any]) -> list[Any]:
91
+ """The parts of a service the platform renders with enrollment ``params``."""
92
+ return [
93
+ offering.get("upstream_access_config"),
94
+ listing.get("user_access_interfaces"),
95
+ _document_contents(listing.get("documents")),
96
+ _document_contents(offering.get("documents")),
97
+ ]
98
+
99
+
100
+ def validate_required_parameter_usage(listing: dict[str, Any], offering: dict[str, Any]) -> list[str]:
101
+ """Return hard validation errors, shared by seller validation and backend ingest.
102
+
103
+ Deprecated content can still be retired. Referencing a parameter in a
104
+ document satisfies usage without guaranteeing correct channel attribution.
105
+ """
106
+ if listing.get("status") == "deprecated" or offering.get("status") == "deprecated":
107
+ return []
108
+ schema = listing.get("user_parameters_schema") or listing.get("parameters_schema")
109
+ try:
110
+ unused = unused_required_params(schema, *consumer_sources(listing, offering))
111
+ except TemplateSyntaxError as exc:
112
+ return [f"Cannot validate required parameter usage: invalid Jinja template: {exc}"]
113
+ if not unused:
114
+ return []
115
+ return [
116
+ f"Required user parameter(s) {', '.join(unused)} are used by no channel (upstream_access_config), "
117
+ "interface (user_access_interfaces) or document content. "
118
+ "Reference each one as {{ params.NAME }} (for example ${ customer_secrets.{{ params.NAME }} }), "
119
+ "or drop it from the required list."
120
+ ]
@@ -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,55 @@ 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(
1031
+ self._with_template_documents(listing, file_path.parent),
1032
+ self._with_template_documents(offering, candidate.parent),
1033
+ )
1034
+ return []
1035
+
1036
+ @staticmethod
1037
+ def _with_template_documents(data: dict[str, Any], base_dir: Path) -> dict[str, Any]:
1038
+ """``data`` with each ``.j2`` document's template source inlined as
1039
+ ``file_content``, as the seller SDK uploads it — so the seller check
1040
+ sees the same document content backend ingest does."""
1041
+ documents = data.get("documents")
1042
+ if not isinstance(documents, dict):
1043
+ return data
1044
+ inlined: dict[str, Any] = {}
1045
+ for title, doc in documents.items():
1046
+ if isinstance(doc, dict) and "file_content" not in doc:
1047
+ rel = doc.get("file_path")
1048
+ if isinstance(rel, str) and rel.endswith(".j2"):
1049
+ path = Path(rel) if Path(rel).is_absolute() else base_dir / rel
1050
+ try:
1051
+ doc = {**doc, "file_content": path.read_text(encoding="utf-8")}
1052
+ except OSError:
1053
+ pass # a missing file is reported by the file-reference checks
1054
+ inlined[title] = doc
1055
+ return {**data, "documents": inlined}
1056
+
1011
1057
  def _resolve_provider_name(self, file_path: Path) -> str | None:
1012
1058
  """Find the provider name (``provider_v1.name``) governing a listing file.
1013
1059
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitysvc-core
3
- Version: 0.2.33
3
+ Version: 0.2.35
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,20 @@ 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. Only those consumer fields count — the offering's
140
+ `upstream_access_config`, the listing's `user_access_interfaces`, and document
141
+ content (a `.j2` document's template source, which the seller validator reads from
142
+ disk as the SDK uploads it); metadata such as `display_name` or `description` is
143
+ never rendered with `params`. File validation checks a listing against its paired offering
144
+ when present; backend ingest calls the same `validate_required_parameter_usage`
145
+ helper with the complete listing and offering. Schema declarations alone are not
146
+ consumers. Jinja attribute access, string-key lookups, and `params.get("name")`
147
+ are inspected; dynamic access and whole-map iteration conservatively consume all
148
+ parameters. Deprecated content can still be retired.
149
+
150
+ This validates usage, not channel attribution: a document reference counts as a
151
+ 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,185 @@
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 channel(**fields):
42
+ """An offering whose one channel carries ``fields``."""
43
+ return {"upstream_access_config": {"plus": fields}}
44
+
45
+
46
+ def test_required_names_in_the_schema_do_not_count_as_use():
47
+ errors = validate_required_parameter_usage(
48
+ listing(), {"upstream_access_config": {"free": {"api_key": "${ customer_secrets.TOKEN }"}}}
49
+ )
50
+ assert len(errors) == 1
51
+ assert "host, token are used by no channel" in errors[0]
52
+
53
+
54
+ @pytest.mark.parametrize(
55
+ ("listing_changes", "offering"),
56
+ [
57
+ ({"display_name": "{{ params.host }} {{ params.token }}"}, {}),
58
+ ({"description": "{{ params.host }}", "summary": "{{ params.token }}"}, {}),
59
+ ({}, {"description": "{{ params.host }} {{ params.token }}", "name": "{{ params.host }}"}),
60
+ # a document's metadata is not its content
61
+ ({"documents": {"Doc": {"description": "{{ params.host }} {{ params.token }}"}}}, {}),
62
+ ({"service_options": {"note": "{{ params.host }} {{ params.token }}"}}, {}),
63
+ ],
64
+ )
65
+ def test_metadata_is_not_a_consumer(listing_changes, offering):
66
+ """Only channels, interfaces and document content are rendered with
67
+ params; a reference anywhere else must not satisfy the rule (#2649)."""
68
+ errors = validate_required_parameter_usage(listing(**listing_changes), offering)
69
+ assert len(errors) == 1
70
+ assert "host, token are used by no channel" in errors[0]
71
+
72
+
73
+ def test_dynamic_use_in_metadata_does_not_disable_the_rule():
74
+ errors = validate_required_parameter_usage(listing(description="{{ params | tojson }}"), {})
75
+ assert "host, token" in errors[0]
76
+
77
+
78
+ def test_partial_use_is_rejected():
79
+ errors = validate_required_parameter_usage(listing(), channel(base_url="{{ params.host }}"))
80
+ assert "token are used by no channel" in errors[0]
81
+
82
+
83
+ def test_channel_interface_and_document_reads_count_without_proving_attribution():
84
+ data = listing(documents={"example": {"file_content": "{{ params.token }}"}})
85
+ assert validate_required_parameter_usage(data, channel(base_url="{{ params.host }}")) == []
86
+ assert (
87
+ validate_required_parameter_usage(
88
+ listing(
89
+ user_access_interfaces={"canonical": {"base_url": "{{ params.host }}"}},
90
+ documents={"Example": {"file_content": "{{ params.token }}"}},
91
+ ),
92
+ {},
93
+ )
94
+ == []
95
+ )
96
+ # offering-level document content counts too
97
+ assert (
98
+ validate_required_parameter_usage(
99
+ listing(),
100
+ {**channel(base_url="{{ params.host }}"), "documents": {"Doc": {"file_content": "{{ params.token }}"}}},
101
+ )
102
+ == []
103
+ )
104
+
105
+
106
+ def test_required_parameter_can_have_punctuation_in_its_name():
107
+ assert (
108
+ validate_required_parameter_usage(
109
+ listing(user_parameters_schema={"required": ["host-name"]}), channel(host="{{ params['host-name'] }}")
110
+ )
111
+ == []
112
+ )
113
+
114
+
115
+ def test_deprecated_content_can_be_retired():
116
+ assert validate_required_parameter_usage(listing(status="deprecated"), {}) == []
117
+ assert validate_required_parameter_usage(listing(), {"status": "deprecated"}) == []
118
+
119
+
120
+ def test_no_required_parameters():
121
+ assert validate_required_parameter_usage({}, {}) == []
122
+
123
+
124
+ @pytest.mark.parametrize("suffix", ["json", "toml"])
125
+ def test_file_validator_checks_paired_offering(tmp_path, suffix):
126
+ schema_dir = Path(__file__).parents[1] / "src/unitysvc_core/schema"
127
+ validator = DataValidator(tmp_path, schema_dir)
128
+ data = listing()
129
+ (tmp_path / "listing.json").write_text(json.dumps(data))
130
+ path = tmp_path / f"offering.{suffix}"
131
+ path.write_text("{}" if suffix == "json" else "")
132
+ errors = validator.validate_required_parameter_usage(data, tmp_path / "listing.json")
133
+ assert "host, token" in errors[0]
134
+ path.write_text(
135
+ json.dumps(channel(host="{{ params.host }}", token="{{ params.token }}"))
136
+ if suffix == "json"
137
+ else '[upstream_access_config.plus]\nhost = "{{ params.host }}"\ntoken = "{{ params.token }}"\n'
138
+ )
139
+ assert validator.validate_required_parameter_usage(data, tmp_path / "listing.json") == []
140
+
141
+
142
+ def test_file_validator_reads_template_documents_like_ingest(tmp_path):
143
+ """The seller SDK uploads a .j2 document's source as file_content, so the
144
+ file validator must count a param used only there, as ingest does."""
145
+ schema_dir = Path(__file__).parents[1] / "src/unitysvc_core/schema"
146
+ validator = DataValidator(tmp_path, schema_dir)
147
+ (tmp_path / "offering.json").write_text(json.dumps(channel(base_url="{{ params.host }}")))
148
+ (tmp_path / "connectivity.sh.j2").write_text('curl "$URL" -H "X-Token: {{ params.token }}"\n')
149
+ data = listing(documents={"Connectivity": {"file_path": "connectivity.sh.j2", "category": "connectivity_test"}})
150
+ (tmp_path / "listing.json").write_text(json.dumps(data))
151
+ assert validator.validate_required_parameter_usage(data, tmp_path / "listing.json") == []
152
+ # a non-template document is uploaded as-is, never rendered: not a consumer
153
+ (tmp_path / "usage.md").write_text("{{ params.token }}")
154
+ data = listing(documents={"Usage": {"file_path": "usage.md"}})
155
+ errors = validator.validate_required_parameter_usage(data, tmp_path / "listing.json")
156
+ assert "token are used by no channel" in errors[0]
157
+
158
+
159
+ def test_validation_pipeline_invokes_the_shared_rule(tmp_path, monkeypatch):
160
+ schema_dir = Path(__file__).parents[1] / "src/unitysvc_core/schema"
161
+ validator = DataValidator(tmp_path, schema_dir)
162
+ path = tmp_path / "listing.json"
163
+ path.write_text(json.dumps({"name": "labs/test"}))
164
+ calls = []
165
+
166
+ def check(data, file_path):
167
+ calls.append(file_path)
168
+ return ["unused-param-sentinel"]
169
+
170
+ monkeypatch.setattr(validator, "validate_required_parameter_usage", check)
171
+ valid, errors = validator.validate_data_file(path, "listing_v1")
172
+ assert not valid
173
+ assert "unused-param-sentinel" in errors
174
+ assert calls == [path]
175
+
176
+
177
+ def test_invalid_jinja_does_not_bypass_the_rule():
178
+ errors = validate_required_parameter_usage(listing(), channel(base_url="{{ params.host"))
179
+ assert "invalid Jinja template" in errors[0]
180
+
181
+
182
+ def test_invalid_jinja_in_metadata_is_ignored():
183
+ """Metadata is never rendered with params, so it is not parsed either."""
184
+ errors = validate_required_parameter_usage(listing(description="Go: {{ .Name }}"), {})
185
+ assert "host, token are used by no channel" in errors[0]
File without changes
File without changes