unitysvc-core 0.2.22__tar.gz → 0.2.24__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 (43) hide show
  1. {unitysvc_core-0.2.22/src/unitysvc_core.egg-info → unitysvc_core-0.2.24}/PKG-INFO +1 -1
  2. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/pyproject.toml +1 -1
  3. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/models/__init__.py +2 -0
  4. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/models/base.py +11 -6
  5. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/models/service_group_data.py +4 -3
  6. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/schema/service_group_v1.json +6 -5
  7. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/utils.py +32 -1
  8. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/validator.py +5 -3
  9. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24/src/unitysvc_core.egg-info}/PKG-INFO +1 -1
  10. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core.egg-info/SOURCES.txt +1 -0
  11. unitysvc_core-0.2.24/tests/test_hidden_path_skip.py +105 -0
  12. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/tests/test_service_group.py +27 -4
  13. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/LICENSE +0 -0
  14. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/README.md +0 -0
  15. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/setup.cfg +0 -0
  16. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/__init__.py +0 -0
  17. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/models/documents.py +0 -0
  18. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/models/listing_data.py +0 -0
  19. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/models/listing_v1.py +0 -0
  20. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/models/offering_data.py +0 -0
  21. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/models/offering_v1.py +0 -0
  22. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/models/pricing.py +0 -0
  23. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/models/promotion_data.py +0 -0
  24. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/models/provider_data.py +0 -0
  25. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/models/provider_v1.py +0 -0
  26. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/models/service.py +0 -0
  27. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/models/validators.py +0 -0
  28. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/py.typed +0 -0
  29. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/schema/base.json +0 -0
  30. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/schema/listing_v1.json +0 -0
  31. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/schema/offering_v1.json +0 -0
  32. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/schema/promotion_v1.json +0 -0
  33. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core/schema/provider_v1.json +0 -0
  34. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
  35. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core.egg-info/requires.txt +0 -0
  36. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/src/unitysvc_core.egg-info/top_level.txt +0 -0
  37. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/tests/test_access_interface_routing.py +0 -0
  38. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/tests/test_pricing.py +0 -0
  39. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/tests/test_provider_account_rate_limits.py +0 -0
  40. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/tests/test_service_status.py +0 -0
  41. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/tests/test_utils.py +0 -0
  42. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/tests/test_validator.py +0 -0
  43. {unitysvc_core-0.2.22 → unitysvc_core-0.2.24}/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.22
3
+ Version: 0.2.24
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.22"
7
+ version = "0.2.24"
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" }]
@@ -1,4 +1,5 @@
1
1
  from .base import (
2
+ ROUTABLE_GROUP_TYPES,
2
3
  AccessMethodEnum,
3
4
  AuthMethodEnum,
4
5
  ContentFilterEnum,
@@ -131,6 +132,7 @@ __all__ = [
131
132
  "QuotaResetCycleEnum",
132
133
  "RateLimitUnitEnum",
133
134
  "RequestTransformEnum",
135
+ "ROUTABLE_GROUP_TYPES",
134
136
  "SellerTypeEnum",
135
137
  "ServiceGroupStatusEnum",
136
138
  "ServiceTypeEnum",
@@ -136,16 +136,21 @@ class GroupOwnerTypeEnum(StrEnum):
136
136
 
137
137
 
138
138
  class GroupTypeEnum(StrEnum):
139
- """Type of service group.
139
+ """Type of service group. Derived from members, not authored.
140
140
 
141
- - ``regular``: Enrollable, services-bearing group (the common case).
142
- - ``category``: Non-enrollable parent that organizes child groups.
143
- - ``misc``: System-generated catch-all for uncategorized services.
141
+ - ``category``: Parent group for browsing/organization only.
142
+ - ``collection``: Services-bearing group that is not routable.
143
+ - ``open``: Routable group where keyless requests can fan safely.
144
+ - ``keyed``: Routable group where callers must provide a routing key.
144
145
  """
145
146
 
146
- regular = "regular"
147
147
  category = "category"
148
- misc = "misc"
148
+ collection = "collection"
149
+ open = "open"
150
+ keyed = "keyed"
151
+
152
+
153
+ ROUTABLE_GROUP_TYPES = frozenset({GroupTypeEnum.open, GroupTypeEnum.keyed})
149
154
 
150
155
 
151
156
  class SellerTypeEnum(StrEnum):
@@ -84,10 +84,11 @@ class ServiceGroupData(BaseModel):
84
84
  )
85
85
 
86
86
  group_type: GroupTypeEnum = Field(
87
- default=GroupTypeEnum.regular,
87
+ default=GroupTypeEnum.category,
88
88
  description=(
89
- "Type of group: regular (enrollable), category (non-enrollable "
90
- "parent), or misc (system-generated catch-all)."
89
+ "Backend-derived group type: category (browse-only parent), "
90
+ "collection (not routable), open (routable keyless fanout), "
91
+ "or keyed (routable by key)."
91
92
  ),
92
93
  )
93
94
 
@@ -11,11 +11,12 @@
11
11
  "type": "string"
12
12
  },
13
13
  "GroupTypeEnum": {
14
- "description": "Type of service group.\n\n- ``regular``: Enrollable, services-bearing group (the common case).\n- ``category``: Non-enrollable parent that organizes child groups.\n- ``misc``: System-generated catch-all for uncategorized services.",
14
+ "description": "Type of service group. Derived from members, not authored.\n\n- ``category``: Parent group for browsing/organization only.\n- ``collection``: Services-bearing group that is not routable.\n- ``open``: Routable group where keyless requests can fan safely.\n- ``keyed``: Routable group where callers must provide a routing key.",
15
15
  "enum": [
16
- "regular",
17
16
  "category",
18
- "misc"
17
+ "collection",
18
+ "open",
19
+ "keyed"
19
20
  ],
20
21
  "title": "GroupTypeEnum",
21
22
  "type": "string"
@@ -69,8 +70,8 @@
69
70
  },
70
71
  "group_type": {
71
72
  "$ref": "#/$defs/GroupTypeEnum",
72
- "default": "regular",
73
- "description": "Type of group: regular (enrollable), category (non-enrollable parent), or misc (system-generated catch-all)."
73
+ "default": "category",
74
+ "description": "Backend-derived group type: category (browse-only parent), collection (not routable), open (routable keyless fanout), or keyed (routable by key)."
74
75
  },
75
76
  "membership_rules": {
76
77
  "anyOf": [
@@ -357,10 +357,41 @@ def write_data_file(file_path: Path, data: dict[str, Any], format: str) -> None:
357
357
  raise ValueError(f"Unsupported format: {format}")
358
358
 
359
359
 
360
+ def is_hidden_path(path: Path, root: Path | str) -> bool:
361
+ """True when *path* lies inside a dot-directory (or is a dot-file) under *root*.
362
+
363
+ Hidden directories are tooling territory, never catalog data — and the
364
+ expensive case is a **second copy of the catalog parked inside it**: a git
365
+ worktree at ``.claude/worktrees/<branch>/``, a ``.venv`` holding vendored
366
+ fixtures. Such a tree carries duplicate spec files whose listings have the
367
+ *same* ``listing.name``, so a walk that descends into one discovers every
368
+ service twice and a consumer that uploads what it finds races the stale
369
+ copy onto the real service's backend id.
370
+
371
+ The check is on the path **relative to the walk root**, so a catalog that
372
+ legitimately lives under a hidden directory (``~/.cache/repo/specs/…``) is
373
+ still discovered in full. Paths outside *root* are kept: the caller named
374
+ them explicitly rather than finding them by walking.
375
+ """
376
+ path = Path(path)
377
+ try:
378
+ rel = path.relative_to(root)
379
+ except ValueError:
380
+ try:
381
+ rel = path.resolve().relative_to(Path(root).resolve())
382
+ except ValueError:
383
+ return False
384
+ return any(part.startswith(".") for part in rel.parts)
385
+
386
+
360
387
  def find_data_files(data_dir: Path, extensions: tuple[str, ...] | None = None) -> list[Path]:
361
388
  """
362
389
  Find all data files in a directory with specified extensions.
363
390
 
391
+ Hidden trees are skipped — see :func:`is_hidden_path`. This is the walker
392
+ under :func:`find_files_by_pattern` and :func:`find_files_by_schema`, so
393
+ every schema-based discovery inherits the skip.
394
+
364
395
  Args:
365
396
  data_dir: Directory to search
366
397
  extensions: Tuple of extensions to search for (default: ("json", "toml"))
@@ -373,7 +404,7 @@ def find_data_files(data_dir: Path, extensions: tuple[str, ...] | None = None) -
373
404
 
374
405
  data_files: list[Path] = []
375
406
  for ext in extensions:
376
- data_files.extend(data_dir.rglob(f"*.{ext}"))
407
+ data_files.extend(p for p in data_dir.rglob(f"*.{ext}") if not is_hidden_path(p, data_dir))
377
408
 
378
409
  return data_files
379
410
 
@@ -10,7 +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 .utils import SCHEMA_TO_STEM, load_data_file, schema_for_path
13
+ from .utils import SCHEMA_TO_STEM, is_hidden_path, load_data_file, schema_for_path
14
14
 
15
15
  # svcpass api_key dispositions (#1198, #1786). On an upstream_access_config
16
16
  # interface these literal values are not credentials: "" / "__strip__" strip
@@ -1059,8 +1059,10 @@ class DataValidator:
1059
1059
 
1060
1060
  # Find all data and MD files recursively, skipping hidden directories
1061
1061
  for file_path in self.data_dir.rglob("*"):
1062
- # Skip hidden directories (those starting with .)
1063
- if any(part.startswith(".") for part in file_path.parts):
1062
+ # Skip hidden directories (those starting with .) — relative to the
1063
+ # data dir, so a catalog that itself lives under one (~/.cache/repo)
1064
+ # still validates instead of silently reporting zero files.
1065
+ if is_hidden_path(file_path, self.data_dir):
1064
1066
  continue
1065
1067
 
1066
1068
  # Skip schema directory, docs directory, and pyproject.toml (not data files)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitysvc-core
3
- Version: 0.2.22
3
+ Version: 0.2.24
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>
@@ -31,6 +31,7 @@ src/unitysvc_core/schema/promotion_v1.json
31
31
  src/unitysvc_core/schema/provider_v1.json
32
32
  src/unitysvc_core/schema/service_group_v1.json
33
33
  tests/test_access_interface_routing.py
34
+ tests/test_hidden_path_skip.py
34
35
  tests/test_pricing.py
35
36
  tests/test_provider_account_rate_limits.py
36
37
  tests/test_service_group.py
@@ -0,0 +1,105 @@
1
+ """Discovery walks must ignore hidden dot-directories.
2
+
3
+ Tooling routinely parks a *second copy of the catalog* inside the catalog: a
4
+ git worktree under ``.claude/worktrees/<branch>/``, a ``.venv`` holding
5
+ vendored fixtures, an editor cache. Those copies carry the same spec files with
6
+ the same ``listing.name``, so a walk that descends into them yields every
7
+ service twice — and a consumer that uploads what it discovers races the stale
8
+ copy onto the real service's backend id.
9
+
10
+ The skip is computed **relative to the walk root**, which is the other half of
11
+ the contract: a catalog that legitimately lives under a hidden directory
12
+ (``~/.cache/repo/specs/…``) must still be discovered in full.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from pathlib import Path
19
+
20
+ import pytest
21
+
22
+ from unitysvc_core.utils import find_data_files, find_files_by_pattern, is_hidden_path
23
+ from unitysvc_core.validator import DataValidator
24
+
25
+
26
+ @pytest.fixture
27
+ def schema_dir() -> Path:
28
+ return Path(__file__).parent.parent / "src" / "unitysvc_core" / "schema"
29
+
30
+
31
+ def _write_listing(folder: Path, name: str) -> Path:
32
+ folder.mkdir(parents=True, exist_ok=True)
33
+ listing = folder / "listing.json"
34
+ listing.write_text(json.dumps({"name": name}))
35
+ return listing
36
+
37
+
38
+ # --- the predicate ---------------------------------------------------------
39
+
40
+
41
+ def test_is_hidden_path_detects_a_dot_directory_below_the_root(tmp_path: Path) -> None:
42
+ assert is_hidden_path(tmp_path / ".claude" / "worktrees" / "wt" / "listing.json", tmp_path)
43
+ assert is_hidden_path(tmp_path / "specs" / ".git" / "listing.json", tmp_path)
44
+
45
+
46
+ def test_is_hidden_path_ignores_dots_above_the_root(tmp_path: Path) -> None:
47
+ """A repo cloned into ``.cache/`` is a normal catalog, not a hidden one."""
48
+ root = tmp_path / ".cache" / "repo"
49
+ assert not is_hidden_path(root / "specs" / "svc" / "listing.json", root)
50
+
51
+
52
+ def test_is_hidden_path_keeps_paths_outside_the_root(tmp_path: Path) -> None:
53
+ """Unrelatable paths were named explicitly, not found by walking."""
54
+ assert not is_hidden_path(Path("/elsewhere/listing.json"), tmp_path)
55
+
56
+
57
+ # --- the walks ------------------------------------------------------------
58
+
59
+
60
+ def test_find_data_files_skips_hidden_directories(tmp_path: Path) -> None:
61
+ real = _write_listing(tmp_path / "specs" / "svc", "labs/svc")
62
+ worktree = _write_listing(tmp_path / ".claude" / "worktrees" / "wt" / "specs" / "svc", "labs/svc")
63
+
64
+ found = find_data_files(tmp_path)
65
+
66
+ assert real in found
67
+ assert worktree not in found
68
+
69
+
70
+ def test_find_data_files_keeps_a_catalog_under_a_hidden_root(tmp_path: Path) -> None:
71
+ root = tmp_path / ".cache" / "repo"
72
+ real = _write_listing(root / "specs" / "svc", "labs/svc")
73
+
74
+ assert find_data_files(root) == [real]
75
+
76
+
77
+ def test_find_files_by_pattern_skips_hidden_directories(tmp_path: Path) -> None:
78
+ """The seller/admin upload path: one service, discovered once."""
79
+ real = _write_listing(tmp_path / "specs" / "svc", "labs/svc")
80
+ worktree = _write_listing(tmp_path / ".claude" / "worktrees" / "wt" / "specs" / "svc", "labs/svc")
81
+
82
+ found = [p for p, _fmt, _data in find_files_by_pattern(tmp_path, "listing_v1")]
83
+
84
+ assert found == [real]
85
+ assert worktree not in found
86
+
87
+
88
+ def test_validate_all_reaches_a_catalog_under_a_hidden_root(tmp_path: Path, schema_dir: Path) -> None:
89
+ """``validate_all`` skipped dot-parts of the *absolute* path, so a catalog
90
+ under ``~/.cache/`` validated zero files and reported success."""
91
+ root = tmp_path / ".cache" / "repo"
92
+ _write_listing(root / "specs" / "svc", "labs/svc")
93
+
94
+ results = DataValidator(root, schema_dir).validate_all()
95
+
96
+ assert [Path(k).as_posix() for k in results] == ["specs/svc/listing.json"]
97
+
98
+
99
+ def test_validate_all_skips_hidden_directories(tmp_path: Path, schema_dir: Path) -> None:
100
+ _write_listing(tmp_path / "specs" / "svc", "labs/svc")
101
+ _write_listing(tmp_path / ".claude" / "worktrees" / "wt" / "specs" / "svc", "labs/svc")
102
+
103
+ results = DataValidator(tmp_path, schema_dir).validate_all()
104
+
105
+ assert [Path(k).as_posix() for k in results] == ["specs/svc/listing.json"]
@@ -46,22 +46,44 @@ class TestServiceGroupDataAdminFields:
46
46
  g = ServiceGroupData(name="ok", display_name="X")
47
47
  assert g.owner_type is GroupOwnerTypeEnum.platform
48
48
  assert g.owner_id is None
49
- assert g.group_type is GroupTypeEnum.regular
49
+ assert g.group_type is GroupTypeEnum.category
50
50
  assert g.access_interface_data_template is None
51
51
 
52
+ @pytest.mark.parametrize(
53
+ "group_type",
54
+ [
55
+ GroupTypeEnum.category,
56
+ GroupTypeEnum.collection,
57
+ GroupTypeEnum.open,
58
+ GroupTypeEnum.keyed,
59
+ ],
60
+ )
61
+ def test_backend_group_types_set(self, group_type: GroupTypeEnum):
62
+ g = ServiceGroupData(
63
+ name="ok",
64
+ display_name="X",
65
+ group_type=group_type,
66
+ )
67
+ assert g.group_type is group_type
68
+
52
69
  def test_admin_fields_set(self):
53
70
  g = ServiceGroupData(
54
71
  name="ok",
55
72
  display_name="X",
56
73
  owner_type=GroupOwnerTypeEnum.seller,
57
74
  owner_id="00000000-0000-0000-0000-000000000001",
58
- group_type=GroupTypeEnum.category,
75
+ group_type=GroupTypeEnum.open,
59
76
  access_interface_data_template='{"base_url": "https://x"}',
60
77
  )
61
78
  assert g.owner_type is GroupOwnerTypeEnum.seller
62
- assert g.group_type is GroupTypeEnum.category
79
+ assert g.group_type is GroupTypeEnum.open
63
80
  assert g.access_interface_data_template == '{"base_url": "https://x"}'
64
81
 
82
+ @pytest.mark.parametrize("legacy_type", ["regular", "misc"])
83
+ def test_legacy_group_types_rejected(self, legacy_type: str):
84
+ with pytest.raises(ValidationError):
85
+ ServiceGroupData(name="ok", display_name="X", group_type=legacy_type)
86
+
65
87
 
66
88
  class TestServiceGroupV1FileModel:
67
89
  """``extra='forbid'`` keeps file-level validation strict."""
@@ -78,10 +100,11 @@ class TestServiceGroupV1FileModel:
78
100
  name="my-group",
79
101
  display_name="X",
80
102
  owner_type="platform",
81
- group_type="regular",
103
+ group_type="keyed",
82
104
  status="active",
83
105
  )
84
106
  assert g.status is ServiceGroupStatusEnum.active
107
+ assert g.group_type is GroupTypeEnum.keyed
85
108
 
86
109
 
87
110
  class TestValidateServiceGroupDict:
File without changes
File without changes
File without changes