instance-repo 1.4.7__tar.gz → 1.4.8.dev0__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 (58) hide show
  1. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/PKG-INFO +1 -1
  2. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/__init__.py +1 -1
  3. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/clients/config.py +7 -4
  4. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/clients/instances.py +342 -61
  5. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/models.py +1 -1
  6. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/store/oss_discovery.py +35 -15
  7. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/store/registry.py +9 -5
  8. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo.egg-info/PKG-INFO +1 -1
  9. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/pyproject.toml +1 -1
  10. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/README.md +0 -0
  11. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/_routing.py +0 -0
  12. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/_site_defaults.py +0 -0
  13. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/artifacts.py +0 -0
  14. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/bootstrap.py +0 -0
  15. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/cache.py +0 -0
  16. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/cli.py +0 -0
  17. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/clients/__init__.py +0 -0
  18. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/clients/_rfc3339.py +0 -0
  19. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/clients/ap_tool.py +0 -0
  20. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/clients/benchmarks.py +0 -0
  21. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/clients/datasets.py +0 -0
  22. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/clients/images.py +0 -0
  23. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/clients/reports.py +0 -0
  24. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/clients/scaffold.py +0 -0
  25. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/clients/shared.py +0 -0
  26. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/clients/splits.py +0 -0
  27. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/clients/user_data_acl.py +0 -0
  28. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/clients/user_data_directories.py +0 -0
  29. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/clients/user_data_entries.py +0 -0
  30. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/clients/user_data_reads.py +0 -0
  31. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/clients/user_data_upload.py +0 -0
  32. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/clients/user_data_writes.py +0 -0
  33. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/clients/versions.py +0 -0
  34. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/clients/workflows.py +0 -0
  35. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/concurrency.py +0 -0
  36. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/content.py +0 -0
  37. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/endpoints.py +0 -0
  38. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/errors.py +0 -0
  39. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/image.py +0 -0
  40. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/layout.py +0 -0
  41. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/loader.py +0 -0
  42. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/paths.py +0 -0
  43. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/release.py +0 -0
  44. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/repo.py +0 -0
  45. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/retry.py +0 -0
  46. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/store/__init__.py +0 -0
  47. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/store/acr.py +0 -0
  48. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/store/base.py +0 -0
  49. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/store/oss.py +0 -0
  50. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/tasktoml.py +0 -0
  51. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/transport.py +0 -0
  52. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo/validate.py +0 -0
  53. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo.egg-info/SOURCES.txt +0 -0
  54. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo.egg-info/dependency_links.txt +0 -0
  55. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo.egg-info/entry_points.txt +0 -0
  56. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo.egg-info/requires.txt +0 -0
  57. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/instance_repo.egg-info/top_level.txt +0 -0
  58. {instance_repo-1.4.7 → instance_repo-1.4.8.dev0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instance-repo
3
- Version: 1.4.7
3
+ Version: 1.4.8.dev0
4
4
  Summary: InstanceRepo SDK — 评测 Instance 统一存储客户端(SDK-only;控制面由 apiserver 承载)
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -20,7 +20,7 @@ from .errors import (
20
20
  RegistrationFailed, TransportError,
21
21
  )
22
22
 
23
- __version__ = "1.4.7"
23
+ __version__ = "1.4.8.dev0"
24
24
 
25
25
  __all__ = [
26
26
  "Repo", "load_instances",
@@ -151,18 +151,21 @@ def normalize_storage_env(storage_env: str | None) -> str:
151
151
  return (storage_env or "").strip().lower()
152
152
 
153
153
 
154
- def business_metadata_model(metadata_model: str | None) -> str:
155
- """Normalize a normal business entry to the only supported model.
154
+ def business_metadata_model(metadata_model: str | None, *,
155
+ allow_version_first: bool = False) -> str:
156
+ """Normalize a business model, optionally allowing explicit legacy reads.
156
157
 
157
158
  ``None``, the empty compatibility value, and ``split_first`` are accepted.
158
- ``version_first`` remains available only through low-level ``Layout`` and
159
- legacy discovery helpers, never through Repo/client business I/O.
159
+ ``version_first`` remains rejected by normal business I/O unless the caller
160
+ explicitly opts into legacy metadata addressing.
160
161
  """
161
162
  from ..errors import SchemaError
162
163
 
163
164
  if metadata_model is not None and not isinstance(metadata_model, str):
164
165
  raise SchemaError("metadata_model must be a string or None")
165
166
  requested = (metadata_model or "").strip()
167
+ if allow_version_first and requested == "version_first":
168
+ return requested
166
169
  if requested not in ("", "split_first"):
167
170
  if requested == "version_first":
168
171
  raise SchemaError(
@@ -32,17 +32,169 @@ logger = logging.getLogger(__name__)
32
32
 
33
33
  def _registry_overrides(environment: str | None,
34
34
  metadata_model: str | None,
35
- profile_model: str | None = None) -> dict:
35
+ profile_model: str | None = None, *,
36
+ allow_version_first: bool = False) -> dict:
36
37
  """Build explicit split-first registry parameters before any registry I/O."""
37
38
  overrides: dict = {
38
39
  "metadata_model": business_metadata_model(
39
- metadata_model if metadata_model is not None else profile_model),
40
+ metadata_model if metadata_model is not None else profile_model,
41
+ allow_version_first=allow_version_first),
40
42
  }
41
43
  if environment is not None:
42
44
  overrides["environment"] = environment
43
45
  return overrides
44
46
 
45
47
 
48
+ _MAX_TAG_FILTER_KEYS = 64
49
+ _MAX_TAG_FILTER_VALUES = 64
50
+
51
+
52
+ def _validate_tag_key(key: str) -> str:
53
+ if not isinstance(key, str):
54
+ raise SchemaError("tag keys must be strings")
55
+ key = key.strip()
56
+ if not key:
57
+ raise SchemaError("tag key must not be empty or whitespace-only")
58
+ if "." in key or "$" in key:
59
+ raise SchemaError(
60
+ f"invalid tag key {key!r}: keys must not contain '.' or "
61
+ f"'$' (server rejects them)")
62
+ return key
63
+
64
+
65
+ def _copy_tags(tags: dict[str, str | list[str]], *,
66
+ allow_empty: bool = False) -> dict[str, str | list[str]]:
67
+ if not isinstance(tags, dict):
68
+ raise SchemaError("tags must be an object of string or string-array values")
69
+ copied: dict[str, str | list[str]] = {}
70
+ for raw_key, value in tags.items():
71
+ key = _validate_tag_key(raw_key)
72
+ if key in copied:
73
+ raise SchemaError(f"duplicate tag key {key!r} after normalization")
74
+ if isinstance(value, str):
75
+ value = value.strip()
76
+ if not value and not allow_empty:
77
+ raise SchemaError(
78
+ f"invalid value for tag {key!r}: scalar must be non-empty")
79
+ copied[key] = value
80
+ elif isinstance(value, list) and all(isinstance(item, str) for item in value):
81
+ copied[key] = _normalize_tag_values(value, allow_empty=allow_empty)
82
+ else:
83
+ raise SchemaError(
84
+ f"invalid value for tag {key!r}: expected a string or a list of strings")
85
+ return copied
86
+
87
+
88
+ def _effective_tags(tags: dict[str, str | list[str]]) -> dict[str, str | list[str]]:
89
+ """Project tags through the server's write normalization for no-op detection."""
90
+ normalized = _copy_tags(tags, allow_empty=True)
91
+ return {key: value for key, value in normalized.items()
92
+ if not isinstance(value, list) or value}
93
+
94
+
95
+ def _clean_legacy_tags(
96
+ tags: dict[str, str | list[str]]) -> dict[str, str | list[str]]:
97
+ """Remove legacy empty scalars and array items from an internal PATCH map."""
98
+ normalized = _copy_tags(tags, allow_empty=True)
99
+ cleaned: dict[str, str | list[str]] = {}
100
+ for key, value in normalized.items():
101
+ if isinstance(value, str):
102
+ if value:
103
+ cleaned[key] = value
104
+ else:
105
+ cleaned[key] = [item for item in value if item]
106
+ return cleaned
107
+
108
+
109
+ def _normalize_tag_values(values: list[str], *,
110
+ allow_empty: bool = False) -> list[str]:
111
+ normalized: list[str] = []
112
+ seen: set[str] = set()
113
+ for value in values:
114
+ value = value.strip()
115
+ if (value or allow_empty) and value not in seen:
116
+ seen.add(value)
117
+ normalized.append(value)
118
+ return normalized
119
+
120
+
121
+ def _parse_tag_filters_csv(tags: str) -> dict[str, str]:
122
+ """Parse legacy CSV using the server's skip/trim/duplicate-key rules."""
123
+ parsed: dict[str, str] = {}
124
+ for pair in tags.split(","):
125
+ key, separator, value = pair.partition(":")
126
+ key, value = key.strip(), value.strip()
127
+ if not separator or not key or not value:
128
+ continue
129
+ if key in parsed:
130
+ raise SchemaError(f"duplicate tag filter key {key!r}")
131
+ parsed[key] = value
132
+ return _normalize_tag_filters(parsed)
133
+
134
+
135
+ def _normalize_tag_filters(
136
+ tags: dict[str, str | dict[str, list[str]]] | str | None) -> dict:
137
+ """Return the single normalized filter representation used by both paths."""
138
+ if tags is None:
139
+ return {}
140
+ if isinstance(tags, str):
141
+ return _parse_tag_filters_csv(tags)
142
+ if not isinstance(tags, dict):
143
+ raise SchemaError("tags must be a dict, legacy CSV string, or None")
144
+ if len(tags) > _MAX_TAG_FILTER_KEYS:
145
+ raise SchemaError(f"tag filters must contain at most {_MAX_TAG_FILTER_KEYS} keys")
146
+ normalized: dict = {}
147
+ for raw_key, condition in tags.items():
148
+ key = _validate_tag_key(raw_key)
149
+ if key in normalized:
150
+ raise SchemaError(f"duplicate tag filter key {key!r} after normalization")
151
+ if isinstance(condition, str):
152
+ value = condition.strip()
153
+ if not value:
154
+ raise SchemaError(f"tag filter {key!r} requires a non-empty value")
155
+ normalized[key] = value
156
+ continue
157
+ if not isinstance(condition, dict) or len(condition) != 1:
158
+ raise SchemaError(
159
+ f"invalid filter for tag {key!r}: expected a string or one any/all object")
160
+ operation, values = next(iter(condition.items()))
161
+ if (operation not in ("any", "all") or not isinstance(values, list)
162
+ or not all(isinstance(value, str) for value in values)):
163
+ raise SchemaError(
164
+ f"invalid filter for tag {key!r}: any/all requires a string array")
165
+ if len(values) > _MAX_TAG_FILTER_VALUES:
166
+ raise SchemaError(
167
+ f"tag filter {key!r} must contain at most "
168
+ f"{_MAX_TAG_FILTER_VALUES} raw values")
169
+ values = _normalize_tag_values(values)
170
+ if not values:
171
+ raise SchemaError(
172
+ f"invalid filter for tag {key!r}: any/all requires a nonempty string array")
173
+ normalized[key] = {operation: values}
174
+ return normalized
175
+
176
+
177
+ def _matches_tag_filters(instance_tags: dict, filters: dict) -> bool:
178
+ for key, condition in filters.items():
179
+ actual = instance_tags.get(key)
180
+ if isinstance(actual, str):
181
+ actual_values = [actual]
182
+ elif isinstance(actual, list):
183
+ actual_values = list(actual)
184
+ else:
185
+ actual_values = []
186
+ if isinstance(condition, str):
187
+ if condition not in actual_values:
188
+ return False
189
+ continue
190
+ operation, expected = next(iter(condition.items()))
191
+ if operation == "any" and not any(value in actual_values for value in expected):
192
+ return False
193
+ if operation == "all" and not all(value in actual_values for value in expected):
194
+ return False
195
+ return True
196
+
197
+
46
198
  def _validate_custom_asset_rel(rel: str) -> None:
47
199
  """白名单校验 custom 资产相对路径(防元数据被篡改造成 pull 路径穿越)。
48
200
 
@@ -225,7 +377,8 @@ class InstancesClient:
225
377
  # 保持缺省调用形态与历史一致,避免破坏既有实现。
226
378
  overrides = _registry_overrides(
227
379
  environment, metadata_model,
228
- getattr(self._p, "metadata_model", None))
380
+ getattr(self._p, "metadata_model", None),
381
+ allow_version_first=True)
229
382
  try:
230
383
  result = self._reg.get(
231
384
  dataset, version, instance_id, split, **overrides)
@@ -271,7 +424,7 @@ class InstancesClient:
271
424
  def list_paged(self, dataset: str, version: str = "", *,
272
425
  split: str | None = None, q: str | None = None,
273
426
  instance_id: str | None = None,
274
- tags: dict[str, str] | str | None = None,
427
+ tags: dict[str, str | dict[str, list[str]]] | str | None = None,
275
428
  difficulty: str | None = None,
276
429
  page: int = 1, page_size: int = 50,
277
430
  environment: str | None = None,
@@ -281,9 +434,10 @@ class InstancesClient:
281
434
 
282
435
  与 :meth:`list` 的自动翻页取全量互补:精确控制页码/过滤/搜索。
283
436
  参数逐项对齐服务端(无 SDK 发明项):``q`` 是 instance_id **子串**
284
- 模糊搜索(不区分大小写);``instance_id`` 精确等值;``tags`` dict 或
285
- CSV("k1:v1,k2:v2",key 含 ``.``/``$`` 服务端拒绝——SDK 前置校验并
286
- 给出修复提示);``difficulty`` easy/medium/hard。**服务端无排序参数**
437
+ 模糊搜索(不区分大小写);``instance_id`` 精确等值;``tags`` dict 编码为
438
+ JSON object(值为 string、``{"any": [...]}`` 或 ``{"all": [...]}``),
439
+ 旧 CSV 字符串("k1:v1,k2:v2")保持兼容;key 含 ``.``/``$`` 前置拒绝。
440
+ ``difficulty`` easy/medium/hard。**服务端无排序参数**
287
441
  (固定 instance_id asc),SDK 不暴露。
288
442
 
289
443
  **OSS 回退**:当 ``TransportError(biz_code=92001)`` 或 ``92023`` 时回退到 OSS 存储层,
@@ -296,20 +450,15 @@ class InstancesClient:
296
450
  if (difficulty or "").strip() and difficulty.strip() not in ("easy", "medium", "hard"):
297
451
  raise SchemaError(
298
452
  f"invalid difficulty {difficulty!r}; must be easy/medium/hard")
299
- # tags 归一:dict → CSV;str 原样(仍校验 key 合法性)。
453
+ # 控制面编码与 OSS 回退共享同一份归一化结果,避免两条路径语义漂移。
454
+ normalized_filters = _normalize_tag_filters(tags)
300
455
  tags_q = ""
301
- if tags:
302
- if isinstance(tags, dict):
303
- pairs = [f"{k}:{v}" for k, v in tags.items()]
304
- else:
305
- pairs = [p for p in str(tags).split(",") if p.strip()]
306
- for p in pairs:
307
- key = p.split(":", 1)[0]
308
- if not key or "." in key or "$" in key:
309
- raise SchemaError(
310
- f"invalid tag key {key!r}: keys must not contain '.' or "
311
- f"'$' (server rejects them)")
312
- tags_q = ",".join(pairs)
456
+ if isinstance(tags, dict) and normalized_filters:
457
+ tags_q = json.dumps(
458
+ normalized_filters, ensure_ascii=False, separators=(",", ":"))
459
+ elif isinstance(tags, str) and normalized_filters:
460
+ tags_q = ",".join(f"{key}:{value}"
461
+ for key, value in normalized_filters.items())
313
462
  params: list[tuple[str, str]] = [("dataset_name", dataset.strip())]
314
463
  if (version or "").strip():
315
464
  params.append(("version", version.strip()))
@@ -346,7 +495,8 @@ class InstancesClient:
346
495
  return self._discover_instances_paged(
347
496
  dataset, version, split,
348
497
  q=q, instance_id=instance_id,
349
- tags=tags, difficulty=difficulty,
498
+ tags=normalized_filters,
499
+ difficulty=difficulty,
350
500
  page=page, page_size=page_size)
351
501
  raise
352
502
  rows = data.get("instances", data) if isinstance(data, dict) else data
@@ -371,26 +521,30 @@ class InstancesClient:
371
521
  return req == derived
372
522
 
373
523
  def _discover_instances(self, dataset: str, version: str,
374
- split: str | None = None) -> list[DatasetInstance]:
524
+ split: str | None = None, *,
525
+ read_content: bool = False) -> list[DatasetInstance]:
375
526
  """OSS 回退的 instances.list — 从 OSS 列举指定 version/split 下的实例。"""
376
527
  from ..store.oss_discovery import build_synthetic_instances
377
528
  return build_synthetic_instances(
378
529
  self._content, dataset, version, split,
379
- oss_prefix=getattr(self, '_oss_prefix', 'swe/datasets'))
530
+ oss_prefix=getattr(self, '_oss_prefix', 'swe/datasets'),
531
+ read_content=read_content)
380
532
 
381
533
  def _discover_instance(self, dataset: str, version: str,
382
534
  instance_id: str,
383
- split: str | None = None) -> DatasetInstance | None:
535
+ split: str | None = None, *,
536
+ read_content: bool = False) -> DatasetInstance | None:
384
537
  """OSS 回退的 instances.get — 精确查找单个实例。"""
385
538
  from ..store.oss_discovery import build_synthetic_instance
386
539
  return build_synthetic_instance(
387
540
  self._content, dataset, version, instance_id, split,
388
- oss_prefix=getattr(self, '_oss_prefix', 'swe/datasets'))
541
+ oss_prefix=getattr(self, '_oss_prefix', 'swe/datasets'),
542
+ read_content=read_content)
389
543
 
390
544
  def _discover_instances_paged(
391
545
  self, dataset: str, version: str, split: str | None, *,
392
546
  q: str | None, instance_id: str | None,
393
- tags: dict[str, str] | str | None,
547
+ tags: dict[str, str | dict[str, list[str]]],
394
548
  difficulty: str | None,
395
549
  page: int, page_size: int,
396
550
  ) -> "PagedResult":
@@ -400,47 +554,32 @@ class InstancesClient:
400
554
  """
401
555
  from ..store.oss_discovery import _in_memory_paged
402
556
 
403
- # 优化:instance_id 精确匹配时直接 GET,不列举全量
557
+ # list_paged 已归一化并共享同一份 filters,这里不再重复校验/归一化。
558
+ tags_dict = tags or {}
559
+ normalized_difficulty = (difficulty or "").strip()
560
+ # tags/difficulty 过滤需要 GetObject;普通过滤/列表保持 list-only 零新增读取。
561
+ read_content = bool(tags_dict) or bool(normalized_difficulty)
404
562
  if instance_id:
405
- inst = self._discover_instance(dataset, version, instance_id, split)
563
+ inst = self._discover_instance(
564
+ dataset, version, instance_id, split, read_content=read_content)
406
565
  items: list[DatasetInstance] = [inst] if inst else []
407
566
  else:
408
- items = self._discover_instances(dataset, version, split)
567
+ items = self._discover_instances(
568
+ dataset, version, split, read_content=read_content)
409
569
 
410
570
  # 客户端过滤
411
571
  if q:
412
572
  q_lower = q.lower()
413
573
  items = [i for i in items
414
574
  if q_lower in i.instance_id.lower()]
415
- if difficulty:
416
- items = [i for i in items
417
- if i.difficulty == difficulty]
418
- if tags:
419
- tags_dict = (
420
- tags if isinstance(tags, dict)
421
- else self._parse_tags_csv(tags)
422
- )
575
+ if normalized_difficulty:
423
576
  items = [i for i in items
424
- if all(i.tags.get(k) == v
425
- for k, v in tags_dict.items())]
577
+ if i.difficulty == normalized_difficulty]
578
+ if tags_dict:
579
+ items = [i for i in items if _matches_tag_filters(i.tags, tags_dict)]
426
580
 
427
581
  return _in_memory_paged(items, int(page), int(page_size))
428
582
 
429
- @staticmethod
430
- def _parse_tags_csv(tags_str: str) -> dict[str, str]:
431
- """解析 ``"k1:v1,k2:v2"`` 格式的 tags CSV 字符串为 dict。"""
432
- result: dict[str, str] = {}
433
- for pair in str(tags_str).split(","):
434
- pair = pair.strip()
435
- if not pair:
436
- continue
437
- parts = pair.split(":", 1)
438
- if len(parts) == 2:
439
- result[parts[0]] = parts[1]
440
- else:
441
- result[parts[0]] = ""
442
- return result
443
-
444
583
  # ── 实例目录/文件列举(GET /datasets/objects)──────────────────
445
584
 
446
585
  def list_objects(self, dataset: str, instance_id: str,
@@ -703,14 +842,149 @@ class InstancesClient:
703
842
  if failure is not None:
704
843
  raise failure from None
705
844
 
845
+ def _read_tag_record(self, dataset: str, version: str, instance_id: str, *,
846
+ split: str | None, environment: str | None,
847
+ metadata_model: str | None) -> DatasetInstance:
848
+ record = self.get(
849
+ dataset, version, instance_id, split=split,
850
+ environment=environment, metadata_model=metadata_model,
851
+ fallback_to_oss=False)
852
+ if record is None:
853
+ raise SchemaError(f"instance {instance_id!r} not found")
854
+ return record
855
+
856
+ def _read_tags(self, dataset: str, version: str, instance_id: str, *,
857
+ split: str | None, environment: str | None,
858
+ metadata_model: str | None) -> dict[str, str | list[str]]:
859
+ record = self._read_tag_record(
860
+ dataset, version, instance_id, split=split, environment=environment,
861
+ metadata_model=metadata_model)
862
+ return _copy_tags(record.tags, allow_empty=True)
863
+
864
+ def list_tags(self, dataset: str, version: str, instance_id: str, *,
865
+ split: str | None = None, environment: str | None = None,
866
+ metadata_model: str | None = None) -> dict[str, str | list[str]]:
867
+ """Read the complete tag map through the existing metadata/RBAC path."""
868
+ return self._read_tags(
869
+ dataset, version, instance_id, split=split, environment=environment,
870
+ metadata_model=metadata_model)
871
+
872
+ def set_tags(self, dataset: str, version: str, instance_id: str,
873
+ tags: dict[str, str | list[str]], *, split: str | None = None,
874
+ environment: str | None = None,
875
+ metadata_model: str | None = None) -> DatasetInstance:
876
+ """Set keys in the complete tag map, preserving every unspecified key.
877
+
878
+ This is a non-atomic read-modify-write of the whole map. A concurrent
879
+ writer can be overwritten by this call's later whole-map update.
880
+ """
881
+ normalized = _copy_tags(tags)
882
+ record = self._read_tag_record(
883
+ dataset, version, instance_id, split=split, environment=environment,
884
+ metadata_model=metadata_model)
885
+ current = _copy_tags(record.tags, allow_empty=True)
886
+ merged = dict(current)
887
+ merged.update(normalized)
888
+ if _effective_tags(merged) == _effective_tags(current):
889
+ return record
890
+ patch_tags = _clean_legacy_tags(merged)
891
+ return self.update(
892
+ dataset, version, instance_id, split=split, tags=patch_tags,
893
+ environment=environment, metadata_model=metadata_model)
894
+
895
+ def append_tags(self, dataset: str, version: str, instance_id: str,
896
+ tags: dict[str, str | list[str]], *, split: str | None = None,
897
+ environment: str | None = None,
898
+ metadata_model: str | None = None) -> DatasetInstance:
899
+ """Append unique values, converting scalars only when a value is added."""
900
+ normalized = _copy_tags(tags)
901
+ record = self._read_tag_record(
902
+ dataset, version, instance_id, split=split, environment=environment,
903
+ metadata_model=metadata_model)
904
+ current_tags = _copy_tags(record.tags, allow_empty=True)
905
+ merged = dict(current_tags)
906
+ for key, added in normalized.items():
907
+ current = merged.get(key)
908
+ values = ([current] if isinstance(current, str)
909
+ else list(current) if isinstance(current, list) else [])
910
+ has_new_value = False
911
+ for value in [added] if isinstance(added, str) else added:
912
+ if value not in values:
913
+ values.append(value)
914
+ has_new_value = True
915
+ if has_new_value:
916
+ merged[key] = values
917
+ if _effective_tags(merged) == _effective_tags(current_tags):
918
+ return record
919
+ patch_tags = _clean_legacy_tags(merged)
920
+ return self.update(
921
+ dataset, version, instance_id, split=split, tags=patch_tags,
922
+ environment=environment, metadata_model=metadata_model)
923
+
924
+ def remove_tags(self, dataset: str, version: str, instance_id: str,
925
+ tags: dict[str, str | list[str]], *, split: str | None = None,
926
+ environment: str | None = None,
927
+ metadata_model: str | None = None) -> DatasetInstance:
928
+ """Remove values by non-atomic whole-map replacement; arrays stay arrays."""
929
+ normalized = _copy_tags(tags, allow_empty=True)
930
+ record = self._read_tag_record(
931
+ dataset, version, instance_id, split=split, environment=environment,
932
+ metadata_model=metadata_model)
933
+ current_tags = _copy_tags(record.tags, allow_empty=True)
934
+ merged = dict(current_tags)
935
+ for key, removed in normalized.items():
936
+ if key not in merged:
937
+ continue
938
+ current = merged[key]
939
+ current_is_array = isinstance(current, list)
940
+ values = list(current) if current_is_array else [current]
941
+ removed_values = [removed] if isinstance(removed, str) else removed
942
+ remaining = [value for value in values if value not in removed_values]
943
+ if not remaining:
944
+ merged.pop(key)
945
+ else:
946
+ merged[key] = remaining if current_is_array else remaining[0]
947
+ if _effective_tags(merged) == _effective_tags(current_tags):
948
+ return record
949
+ patch_tags = _clean_legacy_tags(merged)
950
+ return self.update(
951
+ dataset, version, instance_id, split=split, tags=patch_tags,
952
+ environment=environment, metadata_model=metadata_model)
953
+
954
+ def clear_tags(self, dataset: str, version: str, instance_id: str,
955
+ keys: list[str], *, split: str | None = None,
956
+ environment: str | None = None,
957
+ metadata_model: str | None = None) -> DatasetInstance:
958
+ """Delete complete keys using non-atomic whole-map replacement."""
959
+ if isinstance(keys, (str, bytes)):
960
+ raise SchemaError("clear_tags keys must be an iterable of strings")
961
+ try:
962
+ validated_keys = [_validate_tag_key(key) for key in keys]
963
+ except TypeError as exc:
964
+ raise SchemaError("clear_tags keys must be an iterable of strings") from exc
965
+ record = self._read_tag_record(
966
+ dataset, version, instance_id, split=split, environment=environment,
967
+ metadata_model=metadata_model)
968
+ current = _copy_tags(record.tags, allow_empty=True)
969
+ merged = dict(current)
970
+ for key in validated_keys:
971
+ merged.pop(key, None)
972
+ if _effective_tags(merged) == _effective_tags(current):
973
+ return record
974
+ patch_tags = _clean_legacy_tags(merged)
975
+ return self.update(
976
+ dataset, version, instance_id, split=split, tags=patch_tags,
977
+ environment=environment, metadata_model=metadata_model)
978
+
706
979
  def update(self, dataset: str, version: str, instance_id: str, *,
707
980
  split: str | None = None, dataset_split_id: str | None = None,
708
981
  manifest: list[dict] | None = None,
709
- tags: dict[str, str] | None = None,
982
+ tags: dict[str, str | list[str]] | None = None,
710
983
  difficulty: str | None = None,
711
984
  docker_image: str | None = None,
712
985
  extend_meta: dict | None = None,
713
- environment: str | None = None) -> DatasetInstance:
986
+ environment: str | None = None,
987
+ metadata_model: str | None = None) -> DatasetInstance:
714
988
  """更新实例元数据(``PATCH /datasets/instances/detail``,不动 OSS 字节)。
715
989
 
716
990
  服务端 body 白名单(严格解析,未知字段 400):``manifest``(整体替换,
@@ -720,14 +994,15 @@ class InstancesClient:
720
994
  **至少一项必填**(SDK 前置校验)。
721
995
 
722
996
  正常业务固定 split_first,使用 ``dataset_split_id`` 或
723
- dataset+version+split+instance_id 寻址。
997
+ dataset+version+split+instance_id 寻址。``metadata_model=None`` 保持
998
+ split_first;显式 ``version_first`` 可读取并更新历史布局记录。
724
999
  """
725
1000
  from urllib.parse import quote
726
1001
  body: dict = {}
727
1002
  if manifest is not None:
728
1003
  body["manifest"] = manifest
729
1004
  if tags is not None:
730
- body["tags"] = tags
1005
+ body["tags"] = _copy_tags(tags)
731
1006
  if difficulty is not None:
732
1007
  if difficulty not in ("", "easy", "medium", "hard"):
733
1008
  raise SchemaError(
@@ -762,7 +1037,9 @@ class InstancesClient:
762
1037
  env = (getattr(self._p, "dataset_environment", "") or "").strip()
763
1038
  if env:
764
1039
  q += f"&environment={quote(env, safe='')}"
765
- q += "&metadata_model=split_first"
1040
+ model = business_metadata_model(
1041
+ metadata_model, allow_version_first=True)
1042
+ q += f"&metadata_model={quote(model, safe='')}"
766
1043
  data = self._t.patch(f"{self._BASE}/detail?{q}", body)
767
1044
  return DatasetInstance.from_dict(data) if isinstance(data, dict) else DatasetInstance(
768
1045
  instance_id=instance_id, dataset=dataset, dataset_version=version or "")
@@ -1949,9 +2226,13 @@ class InstancesClient:
1949
2226
  def _tags(metadata: dict) -> dict:
1950
2227
  t = metadata.get("tags", {})
1951
2228
  if isinstance(t, dict):
1952
- return {str(k): str(v) for k, v in t.items()}
2229
+ return _copy_tags(t)
2230
+ # 与 DatasetInstance.from_dict 的旧读取口径一致:顶层 []string 不再猜测成 map。
1953
2231
  if isinstance(t, list):
1954
- return {str(v): "" for v in t}
2232
+ logger.warning(
2233
+ "legacy top-level tags array is ignored; use a tags map with "
2234
+ "string or string-array values instead")
2235
+ return {}
1955
2236
  return {}
1956
2237
 
1957
2238
 
@@ -218,7 +218,7 @@ class DatasetInstance:
218
218
  split: str = "default"
219
219
  id: str | None = None
220
220
  manifest: list[ManifestEntry] = field(default_factory=list)
221
- tags: dict[str, str] = field(default_factory=dict)
221
+ tags: dict[str, str | list[str]] = field(default_factory=dict)
222
222
  difficulty: str | None = None
223
223
  docker_image: str | None = None
224
224
  extend_meta: dict[str, str] = field(default_factory=dict)
@@ -361,12 +361,12 @@ def _warn_if_truncated(info: OssDatasetLevelInfo, dataset: str, max_versions: in
361
361
  # JSON reading helper
362
362
  # ---------------------------------------------------------------------------
363
363
 
364
- def _read_json_object(content_store, key: str) -> dict:
365
- """Best-effort read and parse of a JSON object from OSS.
364
+ def _read_json_object(content_store, key: str, *, required: bool = False) -> dict:
365
+ """Read a JSON object; ``required=True`` never hides read/parse failures.
366
366
 
367
- Performs a HEAD check first: if ``Content-Length`` exceeds 10 MB the object
368
- is skipped (returns ``{}``) to prevent OOM on malformed uploads. Any other
369
- failure (network, parse error, missing key) also returns ``{}``.
367
+ Best-effort discovery retains the historical empty-object fallback. Tag-filtered
368
+ discovery uses ``required=True`` so missing GetObject permission, oversized data,
369
+ network errors and malformed JSON cannot be misreported as "no match".
370
370
  """
371
371
  try:
372
372
  # Use the internal _execute_oss to do a HEAD and check size.
@@ -375,6 +375,10 @@ def _read_json_object(content_store, key: str) -> dict:
375
375
  head = bucket.head_object(key)
376
376
  content_length = getattr(head, "content_length", 0)
377
377
  if content_length and content_length > _MAX_JSON_BYTES:
378
+ if required:
379
+ raise TransportError(
380
+ f"required instance metadata JSON {key!r} exceeds "
381
+ f"{_MAX_JSON_BYTES} bytes (content_length={content_length})")
378
382
  logger.warning(
379
383
  "Skipping oversized JSON object %s (%d bytes)",
380
384
  key, content_length,
@@ -389,15 +393,35 @@ def _read_json_object(content_store, key: str) -> dict:
389
393
  return content_store._execute_oss(
390
394
  _OperationIntent.DOWNLOAD, key, None, _head_and_read)
391
395
  except PrivateNetworkRequired:
396
+ if required:
397
+ raise
392
398
  logger.warning(
393
399
  "Cannot read instance metadata JSON (private network required): %s",
394
400
  key)
395
401
  return {}
396
402
  except Exception:
403
+ if required:
404
+ raise
397
405
  logger.debug("Failed to read JSON object %s", key, exc_info=True)
398
406
  return {}
399
407
 
400
408
 
409
+ def _apply_synthetic_content(inst: DatasetInstance, data: dict) -> None:
410
+ """Reuse DatasetInstance's read normalization, including legacy tags handling."""
411
+ parsed = DatasetInstance.from_dict({
412
+ **data,
413
+ "instance_id": inst.instance_id,
414
+ "dataset": inst.dataset,
415
+ "dataset_version": inst.dataset_version,
416
+ "split": inst.split,
417
+ "registered": False,
418
+ })
419
+ inst.tags = parsed.tags
420
+ inst.difficulty = parsed.difficulty
421
+ inst.docker_image = parsed.docker_image
422
+ inst.extend_meta = parsed.extend_meta
423
+
424
+
401
425
  # ---------------------------------------------------------------------------
402
426
  # Synthetic builders
403
427
  # ---------------------------------------------------------------------------
@@ -544,12 +568,9 @@ def build_synthetic_instances(
544
568
  registered=False,
545
569
  )
546
570
  if read_content:
547
- data = _read_json_object(content_store, key)
571
+ data = _read_json_object(content_store, key, required=True)
548
572
  if data:
549
- inst.tags = data.get("tags", {})
550
- inst.difficulty = data.get("difficulty")
551
- inst.docker_image = data.get("docker_image")
552
- inst.extend_meta = data.get("extend_meta", {})
573
+ _apply_synthetic_content(inst, data)
553
574
  instances.append(inst)
554
575
 
555
576
  return instances
@@ -705,6 +726,8 @@ def build_synthetic_instance(
705
726
  keys = content_store.list_objects(
706
727
  parent, list_only=True, dataset=dataset)
707
728
  except Exception: # noqa: BLE001
729
+ if read_content:
730
+ raise
708
731
  return None
709
732
 
710
733
  if key not in keys:
@@ -725,11 +748,8 @@ def build_synthetic_instance(
725
748
  except Exception: # noqa: BLE001, S110
726
749
  pass # best-effort:manifest 枚举失败不阻断实例返回
727
750
  if read_content:
728
- data = _read_json_object(content_store, key)
751
+ data = _read_json_object(content_store, key, required=True)
729
752
  if data:
730
- inst.tags = data.get("tags", {})
731
- inst.difficulty = data.get("difficulty")
732
- inst.docker_image = data.get("docker_image")
733
- inst.extend_meta = data.get("extend_meta", {})
753
+ _apply_synthetic_content(inst, data)
734
754
 
735
755
  return inst
@@ -65,7 +65,8 @@ class ApiMetadataRegistry(BaseMetadataRegistry):
65
65
  def _q(self, dataset: str, version: str, split: str | None = None,
66
66
  instance_id: str | None = None, *,
67
67
  environment: str | None = None,
68
- metadata_model: str | None = None) -> str:
68
+ metadata_model: str | None = None,
69
+ allow_version_first: bool = False) -> str:
69
70
  """读路径查询串:与写路径(ingest)对称下发 metadata_model 与 environment。
70
71
 
71
72
  两个字段都**必须**对称:写路径由 storage_env 派生 environment(dev→pre)写入 pre
@@ -80,7 +81,8 @@ class ApiMetadataRegistry(BaseMetadataRegistry):
80
81
  """
81
82
  requested_model = (self._metadata_model if metadata_model is None
82
83
  else metadata_model)
83
- model = business_metadata_model(requested_model)
84
+ model = business_metadata_model(
85
+ requested_model, allow_version_first=allow_version_first)
84
86
  env = (self._environment if environment is None else environment).strip()
85
87
  base = _q(dataset, version, split, instance_id,
86
88
  keep_default_split=(model == "split_first"))
@@ -157,9 +159,11 @@ class ApiMetadataRegistry(BaseMetadataRegistry):
157
159
  92001。只吞前者:数据集名拼错必须照常报错,否则会被静默当成"没有这个实例"。
158
160
  """
159
161
  try:
160
- data = self._t.get(
161
- f"{self._BASE}/detail?"
162
- f"{self._q(dataset, version, split, instance_id, environment=environment, metadata_model=metadata_model)}")
162
+ query = self._q(
163
+ dataset, version, split, instance_id,
164
+ environment=environment, metadata_model=metadata_model,
165
+ allow_version_first=True)
166
+ data = self._t.get(f"{self._BASE}/detail?{query}")
163
167
  except TransportError as e:
164
168
  if e.status == 404 and e.biz_code == CODE_INSTANCE_NOT_FOUND:
165
169
  return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instance-repo
3
- Version: 1.4.7
3
+ Version: 1.4.8.dev0
4
4
  Summary: InstanceRepo SDK — 评测 Instance 统一存储客户端(SDK-only;控制面由 apiserver 承载)
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "instance-repo"
7
- version = "1.4.7"
7
+ version = "1.4.8.dev0"
8
8
  description = "InstanceRepo SDK — 评测 Instance 统一存储客户端(SDK-only;控制面由 apiserver 承载)"
9
9
  requires-python = ">=3.10"
10
10
  readme = "README.md"