unitysvc-core 0.2.29__tar.gz → 0.2.30__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 (45) hide show
  1. {unitysvc_core-0.2.29/src/unitysvc_core.egg-info → unitysvc_core-0.2.30}/PKG-INFO +1 -1
  2. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/pyproject.toml +1 -1
  3. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/models/__init__.py +4 -0
  4. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/models/pricing.py +1 -3
  5. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/models/validators.py +72 -1
  6. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/validator.py +10 -1
  7. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30/src/unitysvc_core.egg-info}/PKG-INFO +1 -1
  8. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/tests/test_validator.py +39 -0
  9. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/tests/test_validators.py +52 -0
  10. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/LICENSE +0 -0
  11. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/README.md +0 -0
  12. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/setup.cfg +0 -0
  13. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/__init__.py +0 -0
  14. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/models/base.py +0 -0
  15. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/models/documents.py +0 -0
  16. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/models/listing_data.py +0 -0
  17. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/models/listing_v1.py +0 -0
  18. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/models/offering_data.py +0 -0
  19. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/models/offering_v1.py +0 -0
  20. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/models/promotion_data.py +0 -0
  21. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/models/provider_data.py +0 -0
  22. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/models/provider_v1.py +0 -0
  23. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/models/service.py +0 -0
  24. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/models/service_group_data.py +0 -0
  25. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/py.typed +0 -0
  26. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/schema/base.json +0 -0
  27. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/schema/listing_v1.json +0 -0
  28. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/schema/offering_v1.json +0 -0
  29. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/schema/promotion_v1.json +0 -0
  30. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/schema/provider_v1.json +0 -0
  31. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/schema/service_group_v1.json +0 -0
  32. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core/utils.py +0 -0
  33. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core.egg-info/SOURCES.txt +0 -0
  34. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
  35. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core.egg-info/requires.txt +0 -0
  36. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/src/unitysvc_core.egg-info/top_level.txt +0 -0
  37. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/tests/test_access_interface_routing.py +0 -0
  38. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/tests/test_effective_price.py +0 -0
  39. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/tests/test_hidden_path_skip.py +0 -0
  40. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/tests/test_offering_data.py +0 -0
  41. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/tests/test_pricing.py +0 -0
  42. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/tests/test_provider_account_rate_limits.py +0 -0
  43. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/tests/test_service_group.py +0 -0
  44. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/tests/test_service_status.py +0 -0
  45. {unitysvc_core-0.2.29 → unitysvc_core-0.2.30}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitysvc-core
3
- Version: 0.2.29
3
+ Version: 0.2.30
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>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "unitysvc-core"
7
- version = "0.2.29"
7
+ version = "0.2.30"
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" }]
@@ -83,12 +83,14 @@ from .service_group_data import (
83
83
  validate_service_group,
84
84
  )
85
85
  from .validators import (
86
+ PLATFORM_SERVICES_DIRNAME,
86
87
  SUPPORTED_SERVICE_OPTIONS,
87
88
  suggest_valid_name,
88
89
  validate_listing_gateway_base_urls,
89
90
  validate_listing_s3_base_urls,
90
91
  validate_listing_smtp_base_urls,
91
92
  validate_name,
93
+ validate_platform_listing_base_urls,
92
94
  validate_s3_gateway_alias,
93
95
  validate_service_identifier,
94
96
  validate_service_options,
@@ -177,7 +179,9 @@ __all__ = [
177
179
  "validate_service_identifier",
178
180
  "validate_service_options",
179
181
  "validate_s3_gateway_alias",
182
+ "PLATFORM_SERVICES_DIRNAME",
180
183
  "validate_listing_gateway_base_urls",
184
+ "validate_platform_listing_base_urls",
181
185
  "validate_listing_s3_base_urls",
182
186
  "validate_listing_smtp_base_urls",
183
187
  "suggest_valid_name",
@@ -1917,7 +1917,5 @@ def validate_pricing(
1917
1917
  """
1918
1918
  from pydantic import TypeAdapter
1919
1919
 
1920
- adapter: TypeAdapter[TokenPriceData | TimePriceData | ImagePriceData | StepPriceData | RevenueSharePriceData] = (
1921
- TypeAdapter(Pricing)
1922
- )
1920
+ adapter: TypeAdapter[Pricing] = TypeAdapter(Pricing)
1923
1921
  return adapter.validate_python(data)
@@ -558,9 +558,16 @@ _API_GATEWAY_PREFIX = "${API_GATEWAY_BASE_URL}"
558
558
  _GATEWAY_SEGMENT_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$")
559
559
 
560
560
 
561
- def validate_listing_gateway_base_urls(user_access_interfaces: dict[str, Any] | None) -> list[str]:
561
+ def validate_listing_gateway_base_urls(
562
+ user_access_interfaces: dict[str, Any] | None,
563
+ *,
564
+ platform_service: bool = False,
565
+ ) -> list[str]:
562
566
  """Validate ``${API_GATEWAY_BASE_URL}/...`` base_urls.
563
567
 
568
+ A **platform service**'s listing (``platform_service=True``) follows its own
569
+ rule instead: see :func:`validate_platform_listing_base_urls`.
570
+
564
571
  The gateway base_url is intentionally left almost unconstrained: a provider
565
572
  may route many services through a single base_url and differentiate them by
566
573
  other parts of the request (e.g. ``routing_key`` / ``model``), so we cannot
@@ -582,6 +589,8 @@ def validate_listing_gateway_base_urls(user_access_interfaces: dict[str, Any] |
582
589
 
583
590
  Returns a list of error messages (empty if all valid).
584
591
  """
592
+ if platform_service:
593
+ return validate_platform_listing_base_urls(user_access_interfaces)
585
594
  if not user_access_interfaces or not isinstance(user_access_interfaces, dict):
586
595
  return []
587
596
 
@@ -633,6 +642,68 @@ def validate_listing_gateway_base_urls(user_access_interfaces: dict[str, Any] |
633
642
  return errors
634
643
 
635
644
 
645
+ # A platform service is published from ``platform-services/<provider>/<name>/``
646
+ # (unitysvc#2569); its listing is the only one allowed a ``/p`` address.
647
+ PLATFORM_SERVICES_DIRNAME = "platform-services"
648
+
649
+ # Same grammar the platform enforces at publish time.
650
+ _PLATFORM_BASE_URL_RE = re.compile(r"^\$\{API_GATEWAY_BASE_URL\}/p/(?P<route>[a-z0-9][a-z0-9_-]*)$")
651
+
652
+
653
+ def validate_platform_listing_base_urls(user_access_interfaces: dict[str, Any] | None) -> list[str]:
654
+ """Validate a platform service listing's ``/p`` address.
655
+
656
+ A platform service is called at ``${API_GATEWAY_BASE_URL}/p/<route>``, which
657
+ ordinary listings may not use (single-letter prefixes are reserved). Its
658
+ listing must declare that address and nothing else:
659
+
660
+ - at least one interface;
661
+ - every ``base_url`` exactly ``${API_GATEWAY_BASE_URL}/p/<route>`` — lowercase
662
+ letters, digits, ``-`` and ``_``, starting with a letter or digit, no
663
+ further path;
664
+ - one ``<route>`` shared by all interfaces;
665
+ - the primary interface's ``routing_key``, if set, an object.
666
+
667
+ Mirrors the platform's publish-time check, so authors see these errors
668
+ before upload. Returns a list of error messages (empty if valid).
669
+ """
670
+ if (
671
+ not isinstance(user_access_interfaces, dict)
672
+ or not user_access_interfaces
673
+ or not all(isinstance(v, dict) for v in user_access_interfaces.values())
674
+ ):
675
+ return [
676
+ "user_access_interfaces: a platform service must declare its "
677
+ "'${API_GATEWAY_BASE_URL}/p/<route>' address (an object of interface objects keyed by name)"
678
+ ]
679
+
680
+ errors: list[str] = []
681
+ routes: set[str] = set()
682
+ for iface_name, iface in user_access_interfaces.items():
683
+ base_url = iface.get("base_url")
684
+ match = _PLATFORM_BASE_URL_RE.match(base_url) if isinstance(base_url, str) else None
685
+ if match is None:
686
+ errors.append(
687
+ f"user_access_interfaces.{iface_name}.base_url: a platform service's base_url must be "
688
+ f"exactly '${{API_GATEWAY_BASE_URL}}/p/<route>', got {base_url!r}"
689
+ )
690
+ else:
691
+ routes.add(match.group("route"))
692
+ if len(routes) > 1:
693
+ errors.append(
694
+ f"user_access_interfaces: all interfaces must share one /p route, got {', '.join(sorted(routes))}"
695
+ )
696
+
697
+ primary = sorted(
698
+ user_access_interfaces.items(),
699
+ key=lambda item: (not bool(item[1].get("is_primary")), int(item[1].get("sort_order") or 0), item[0]),
700
+ )[0]
701
+ routing_key = primary[1].get("routing_key")
702
+ if routing_key is not None and not isinstance(routing_key, dict):
703
+ errors.append(f"user_access_interfaces.{primary[0]}.routing_key: must be an object")
704
+ return errors
705
+
706
+
636
707
  def _earliest_dynamic_marker(s: str) -> int | None:
637
708
  """Return the index of the earliest dynamic-substitution marker in ``s``.
638
709
 
@@ -940,6 +940,7 @@ class DataValidator:
940
940
  # Validate S3, SMTP, MCP, and API-gateway interfaces (listing_v1 only)
941
941
  if schema_name == "listing_v1":
942
942
  from .models.validators import (
943
+ PLATFORM_SERVICES_DIRNAME,
943
944
  validate_access_interface_names,
944
945
  validate_listing_gateway_base_urls,
945
946
  validate_listing_jinja_var_references,
@@ -950,7 +951,15 @@ class DataValidator:
950
951
 
951
952
  uai = data.get("user_access_interfaces")
952
953
  errors.extend(validate_access_interface_names(uai))
953
- errors.extend(validate_listing_gateway_base_urls(uai))
954
+ # A listing under platform-services/ is a platform service: its
955
+ # address is /p, which every other listing is refused.
956
+ try:
957
+ parts = file_path.resolve().relative_to(self.data_dir.resolve()).parts
958
+ except ValueError:
959
+ parts = file_path.parts
960
+ errors.extend(
961
+ validate_listing_gateway_base_urls(uai, platform_service=PLATFORM_SERVICES_DIRNAME in parts[:-1])
962
+ )
954
963
  errors.extend(validate_listing_s3_base_urls(uai))
955
964
  errors.extend(validate_listing_smtp_base_urls(uai))
956
965
  errors.extend(validate_listing_mcp_base_urls(uai))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitysvc-core
3
- Version: 0.2.29
3
+ Version: 0.2.30
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>
@@ -1637,3 +1637,42 @@ class TestSvcpassDisposition:
1637
1637
  data = {"service_options": {"ops_testing_parameters": {"api_key": "__sigv4__"}}}
1638
1638
  errors = validator.validate_api_key_secrets(data)
1639
1639
  assert errors and "secrets reference format" in errors[0]
1640
+
1641
+
1642
+ class TestPlatformServiceListingFile:
1643
+ """``DataValidator`` applies the ``/p`` rule to listings under
1644
+ ``platform-services/`` and the reserved-prefix rule everywhere else (#2569)."""
1645
+
1646
+ @staticmethod
1647
+ def _write(path: Path, base_url: str) -> Path:
1648
+ import json
1649
+
1650
+ path.parent.mkdir(parents=True)
1651
+ path.write_text(
1652
+ json.dumps(
1653
+ {
1654
+ "name": "labs/demo",
1655
+ "user_access_interfaces": {"default": {"access_method": "http", "base_url": base_url}},
1656
+ }
1657
+ )
1658
+ )
1659
+ return path
1660
+
1661
+ @staticmethod
1662
+ def _gateway_errors(validator: DataValidator, path: Path) -> list[str]:
1663
+ _, errors = validator.validate_data_file(path, schema_name="listing_v1", check_name_consistency=False)
1664
+ return [e for e in errors if "base_url" in e]
1665
+
1666
+ def test_platform_service_listing_may_use_p(self, schema_dir, tmp_path):
1667
+ path = self._write(tmp_path / "platform-services/labs/demo/listing.json", "${API_GATEWAY_BASE_URL}/p/llm")
1668
+ assert self._gateway_errors(DataValidator(tmp_path, schema_dir), path) == []
1669
+
1670
+ def test_platform_service_listing_must_use_p(self, schema_dir, tmp_path):
1671
+ path = self._write(tmp_path / "platform-services/labs/demo/listing.json", "${API_GATEWAY_BASE_URL}/llm")
1672
+ errors = self._gateway_errors(DataValidator(tmp_path, schema_dir), path)
1673
+ assert len(errors) == 1 and "/p/<route>" in errors[0]
1674
+
1675
+ def test_ordinary_listing_may_not_use_p(self, schema_dir, tmp_path):
1676
+ path = self._write(tmp_path / "specs/demo/listing.json", "${API_GATEWAY_BASE_URL}/p/llm")
1677
+ errors = self._gateway_errors(DataValidator(tmp_path, schema_dir), path)
1678
+ assert len(errors) == 1 and "reserved single-letter prefix" in errors[0]
@@ -13,6 +13,7 @@ from unitysvc_core.models.validators import (
13
13
  validate_listing_mcp_base_urls,
14
14
  validate_mcp_namespace,
15
15
  validate_mcp_offering,
16
+ validate_platform_listing_base_urls,
16
17
  validate_service_identifier,
17
18
  )
18
19
 
@@ -623,3 +624,54 @@ class TestValidateMcpOffering:
623
624
  offering = self._offering()
624
625
  offering["user_access_interfaces"] = _mcp_uai()
625
626
  assert validate_mcp_offering(offering) == []
627
+
628
+
629
+ class TestPlatformListingBaseUrls:
630
+ """A platform service's listing declares exactly its ``/p`` address (#2569)."""
631
+
632
+ @staticmethod
633
+ def _p(*base_urls: str, routing_key=None) -> dict:
634
+ uai = {f"i{n}": {"access_method": "http", "base_url": url} for n, url in enumerate(base_urls)}
635
+ if routing_key is not None:
636
+ uai["i0"]["routing_key"] = routing_key
637
+ return uai
638
+
639
+ def test_accepts_one_route(self) -> None:
640
+ uai = self._p("${API_GATEWAY_BASE_URL}/p/llm", "${API_GATEWAY_BASE_URL}/p/llm", routing_key={"model": "x"})
641
+ assert validate_platform_listing_base_urls(uai) == []
642
+ assert validate_listing_gateway_base_urls(uai, platform_service=True) == []
643
+
644
+ def test_ordinary_listing_still_refuses_p(self) -> None:
645
+ errors = validate_listing_gateway_base_urls(self._p("${API_GATEWAY_BASE_URL}/p/llm"))
646
+ assert any("reserved single-letter prefix" in e for e in errors)
647
+
648
+ @pytest.mark.parametrize(
649
+ "base_url",
650
+ [
651
+ "${API_GATEWAY_BASE_URL}/llm",
652
+ "${API_GATEWAY_BASE_URL}/p/llm/v1",
653
+ "${API_GATEWAY_BASE_URL}/p/LLM",
654
+ "${API_GATEWAY_BASE_URL}/p/{{ service_name }}",
655
+ "${API_GATEWAY_BASE_URL}/p/",
656
+ "https://example.com/p/llm",
657
+ ],
658
+ )
659
+ def test_rejects_anything_but_exactly_p_route(self, base_url: str) -> None:
660
+ errors = validate_platform_listing_base_urls(self._p(base_url))
661
+ assert len(errors) == 1
662
+ assert "user_access_interfaces.i0.base_url" in errors[0]
663
+
664
+ def test_rejects_mixed_routes(self) -> None:
665
+ errors = validate_platform_listing_base_urls(
666
+ self._p("${API_GATEWAY_BASE_URL}/p/llm", "${API_GATEWAY_BASE_URL}/p/embed")
667
+ )
668
+ assert errors == ["user_access_interfaces: all interfaces must share one /p route, got embed, llm"]
669
+
670
+ def test_rejects_non_object_routing_key(self) -> None:
671
+ errors = validate_platform_listing_base_urls(self._p("${API_GATEWAY_BASE_URL}/p/llm", routing_key="x"))
672
+ assert errors == ["user_access_interfaces.i0.routing_key: must be an object"]
673
+
674
+ @pytest.mark.parametrize("value", [None, {}, "not a dict", {"a": "not an object"}])
675
+ def test_requires_the_address(self, value) -> None:
676
+ errors = validate_platform_listing_base_urls(value)
677
+ assert len(errors) == 1 and "/p/<route>" in errors[0]
File without changes
File without changes
File without changes