openconceptlab 0.2.0__tar.gz → 0.2.1__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 (41) hide show
  1. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/PKG-INFO +3 -1
  2. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/README.md +2 -0
  3. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/pyproject.toml +1 -1
  4. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/tool.py +13 -2
  5. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/openconceptlab/client.py +148 -66
  6. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/openconceptlab.egg-info/PKG-INFO +3 -1
  7. openconceptlab-0.2.1/tests/test_client.py +498 -0
  8. openconceptlab-0.2.0/tests/test_client.py +0 -233
  9. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/LICENSE +0 -0
  10. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/setup.cfg +0 -0
  11. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/__init__.py +0 -0
  12. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/__main__.py +0 -0
  13. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/api_client.py +0 -0
  14. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/__init__.py +0 -0
  15. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/auth.py +0 -0
  16. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/cascade.py +0 -0
  17. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/concept.py +0 -0
  18. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/expansion.py +0 -0
  19. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/export.py +0 -0
  20. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/import_cmd.py +0 -0
  21. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/mapping.py +0 -0
  22. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/org.py +0 -0
  23. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/ref.py +0 -0
  24. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/reference.py +0 -0
  25. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/repo.py +0 -0
  26. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/server.py +0 -0
  27. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/task.py +0 -0
  28. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/user.py +0 -0
  29. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/config.py +0 -0
  30. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/main.py +0 -0
  31. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/output.py +0 -0
  32. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/openconceptlab/__init__.py +0 -0
  33. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/openconceptlab.egg-info/SOURCES.txt +0 -0
  34. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/openconceptlab.egg-info/dependency_links.txt +0 -0
  35. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/openconceptlab.egg-info/entry_points.txt +0 -0
  36. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/openconceptlab.egg-info/requires.txt +0 -0
  37. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/openconceptlab.egg-info/top_level.txt +0 -0
  38. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/tests/test_concept_commands.py +0 -0
  39. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/tests/test_core_package.py +0 -0
  40. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/tests/test_export_commands.py +0 -0
  41. {openconceptlab-0.2.0 → openconceptlab-0.2.1}/tests/test_repo_commands.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openconceptlab
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Python client and command-line interface for the Open Concept Lab API
5
5
  Author-email: Open Concept Lab <info@openconceptlab.org>
6
6
  License-Expression: MPL-2.0
@@ -100,6 +100,8 @@ Pass `auth_scheme="Bearer"` to use an OIDC access token instead of an API token.
100
100
 
101
101
  HTTP errors raise `openconceptlab.APIError`, with `status_code`, `detail` and the parsed response `body`. Network failures raise the httpx exception (`httpx.TimeoutException` or `httpx.TransportError`) once retries run out. A client holds one token, so create one per user.
102
102
 
103
+ Each ID, version or key you pass is encoded as one segment of the request path. An empty value, `.`, `..` or a value containing `/` raises `APIError` (`status_code` 0) before anything is sent. So does a request to a URL outside the client's `base_url`, such as one taken from a response.
104
+
103
105
  ## Quick Start
104
106
 
105
107
  ### 1. Authenticate
@@ -67,6 +67,8 @@ Pass `auth_scheme="Bearer"` to use an OIDC access token instead of an API token.
67
67
 
68
68
  HTTP errors raise `openconceptlab.APIError`, with `status_code`, `detail` and the parsed response `body`. Network failures raise the httpx exception (`httpx.TimeoutException` or `httpx.TransportError`) once retries run out. A client holds one token, so create one per user.
69
69
 
70
+ Each ID, version or key you pass is encoded as one segment of the request path. An empty value, `.`, `..` or a value containing `/` raises `APIError` (`status_code` 0) before anything is sent. So does a request to a URL outside the client's `base_url`, such as one taken from a response.
71
+
70
72
  ## Quick Start
71
73
 
72
74
  ### 1. Authenticate
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "openconceptlab"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Python client and command-line interface for the Open Concept Lab API"
9
9
  readme = "README.md"
10
10
  license = "MPL-2.0"
@@ -14,6 +14,7 @@ import click
14
14
 
15
15
  from ocl_cli.api_client import APIError
16
16
  from ocl_cli.main import handle_api_error
17
+ from openconceptlab.client import _segment
17
18
 
18
19
  # Regex to detect versioned concept/mapping expressions (e.g. /concepts/100/1/)
19
20
  VERSIONED_EXPR = re.compile(r"/(concepts|mappings)/\d+/\d+/?$")
@@ -117,6 +118,12 @@ def _collection_url(org: str | None, collection_id: str) -> str:
117
118
  return f"/user/collections/{collection_id}/"
118
119
 
119
120
 
121
+ def _collection_path(org: str | None, collection_id: str) -> str:
122
+ """The collection's API path, each identifier encoded as one segment."""
123
+ owner = f"/orgs/{_segment(org)}" if org else "/user"
124
+ return f"{owner}/collections/{_segment(collection_id)}/"
125
+
126
+
120
127
  def _build_references(export, org, collection_id, client, callback=None):
121
128
  """Build deduplicated, versionless reference lines from export data.
122
129
 
@@ -238,6 +245,11 @@ def recreate_collection(ctx, input_path, org, name, output_path, dry_run):
238
245
  if not collection_id:
239
246
  click.echo("Error: Collection id not found in export and --name not provided.", err=True)
240
247
  sys.exit(1)
248
+ try:
249
+ collection_path = _collection_path(org, collection_id)
250
+ except APIError as exc:
251
+ click.echo(f"Error: {exc}", err=True)
252
+ sys.exit(1)
241
253
 
242
254
  # Default output filename
243
255
  if not output_path:
@@ -273,7 +285,6 @@ def recreate_collection(ctx, input_path, org, name, output_path, dry_run):
273
285
 
274
286
  # Delete and recreate collection
275
287
  click.echo("Recreating collection and importing references...")
276
- collection_path = _collection_url(org, collection_id)
277
288
  try:
278
289
  # Check if collection exists
279
290
  try:
@@ -289,7 +300,7 @@ def recreate_collection(ctx, input_path, org, name, output_path, dry_run):
289
300
  # Create collection
290
301
  click.echo(f"Creating collection {collection_id}...")
291
302
  payload = _build_collection_payload(export_data, name)
292
- collections_path = f"/orgs/{org}/collections/" if org else "/user/collections/"
303
+ collections_path = f"/orgs/{_segment(org)}/collections/" if org else "/user/collections/"
293
304
  client.post(collections_path, json=payload)
294
305
 
295
306
  # Bulk import
@@ -6,6 +6,7 @@ The open core shared by the `ocl` command and other OCL tools.
6
6
  import platform
7
7
  import sys
8
8
  from typing import Any, Optional
9
+ from urllib.parse import quote, unquote
9
10
 
10
11
  import httpx
11
12
  from tenacity import (
@@ -51,6 +52,56 @@ def _repo_type_stem(repo_type: str) -> str:
51
52
  raise ValueError(f"repo_type must be 'source' or 'collection', got '{repo_type}'")
52
53
 
53
54
 
55
+ def _segment(value: Any) -> str:
56
+ """Encode a caller's value (an ID, a version, a key) as one path segment.
57
+
58
+ Refuses a value that would name a different endpoint: empty, ``.``, ``..`` or
59
+ containing ``/``, which the server decodes back into a separator.
60
+ """
61
+ text = str(value)
62
+ if text in ("", ".", "..") or "/" in text:
63
+ raise APIError(f"Invalid identifier {text!r}: it can't be used in an API path", status_code=0)
64
+ return quote(text, safe="")
65
+
66
+
67
+ def _resolve_dot_segments(path: str) -> str:
68
+ """The path with its ``.`` and ``..`` segments resolved, as a normalizing server routes it."""
69
+ parts: list[str] = []
70
+ for part in path.split("/"):
71
+ if part == "..":
72
+ if parts:
73
+ parts.pop()
74
+ elif part not in ("", "."):
75
+ parts.append(part)
76
+ return "/" + "/".join(parts)
77
+
78
+
79
+ def _path_readings(path: str) -> Optional[list[str]]:
80
+ """Ways a server or gateway may route a decoded path.
81
+
82
+ At each of five decoding depths: as is; with dot segments resolved; with
83
+ backslashes read as slashes; and with ``;`` parameters dropped from each segment
84
+ too. Every path gives the same number of readings, so two paths can be compared
85
+ reading by reading. None when the path is still changing after the last depth.
86
+ """
87
+ readings: list[str] = []
88
+ for _ in range(5):
89
+ slashed = path.replace("\\", "/")
90
+ unparameterized = "/".join(part.split(";")[0] for part in slashed.split("/"))
91
+ readings += [
92
+ path,
93
+ _resolve_dot_segments(path),
94
+ _resolve_dot_segments(slashed),
95
+ _resolve_dot_segments(unparameterized),
96
+ ]
97
+ path, previous = unquote(path), path
98
+ return readings if path == previous else None
99
+
100
+
101
+ def _within(path: str, prefix: str) -> bool:
102
+ return path == prefix or path.startswith(prefix + "/")
103
+
104
+
54
105
  def _validate_owner_type(owner_type: str) -> None:
55
106
  if owner_type not in ("users", "orgs"):
56
107
  raise ValueError(f"owner_type must be 'users' or 'orgs', got '{owner_type}'")
@@ -67,12 +118,21 @@ def _build_repo_endpoint(
67
118
  """Build a repository-scoped endpoint URL."""
68
119
  _validate_owner_type(owner_type)
69
120
  stem = _repo_type_stem(repo_type)
70
- base = f"/{owner_type}/{owner}/{stem}/{repo}"
121
+ base = f"/{owner_type}/{_segment(owner)}/{stem}/{_segment(repo)}"
71
122
  if repo_version:
72
- base = f"{base}/{repo_version}"
123
+ base = f"{base}/{_segment(repo_version)}"
73
124
  return f"{base}/{suffix}" if suffix else f"{base}/"
74
125
 
75
126
 
127
+ class _HTTPClient(httpx.Client):
128
+ def request(self, *args: Any, **kwargs: Any) -> httpx.Response:
129
+ # httpx decodes a URL's host when it builds a request, a redirect's next one included.
130
+ try:
131
+ return super().request(*args, **kwargs)
132
+ except UnicodeError as e: # idna's error for a host that isn't a valid domain name
133
+ raise APIError(f"Refusing a URL whose host isn't a valid domain name: {e}", status_code=0) from e
134
+
135
+
76
136
  class OCLAPIClient:
77
137
  """Synchronous client for the OCL API."""
78
138
 
@@ -113,7 +173,7 @@ class OCLAPIClient:
113
173
  merged = httpx.Headers(default_headers)
114
174
  merged.update(headers or {})
115
175
 
116
- self.client = httpx.Client(
176
+ self.client = _HTTPClient(
117
177
  base_url=self.base_url,
118
178
  headers=merged,
119
179
  timeout=timeout,
@@ -122,18 +182,34 @@ class OCLAPIClient:
122
182
  )
123
183
 
124
184
  def _check_destination(self, request: httpx.Request) -> None:
125
- """Refuse any request outside the API's origin and path: the token goes only there."""
185
+ """Refuse any request outside the API's origin and path: the token goes only there.
186
+
187
+ Hosts are compared in their encoded (punycode) form. A path prefix must hold
188
+ for the raw path, and for each reading of the decoded path against the same
189
+ reading of the prefix (``_path_readings``).
190
+ """
126
191
  base, url = self._base, request.url
127
- prefix = base.path.rstrip("/")
128
- if (
129
- (url.scheme, url.host, url.port) != (base.scheme, base.host, base.port)
130
- or (prefix and url.path != prefix and not url.path.startswith(prefix + "/"))
131
- ):
192
+ if url.userinfo:
193
+ raise APIError("Refusing to send a request with credentials in its URL", status_code=0)
194
+ if (url.scheme, url.raw_host, url.port) != (base.scheme, base.raw_host, base.port) or not self._within_prefix(url):
132
195
  raise APIError(
133
- f"Refusing to send a request outside {base.scheme}://{base.host}: {url.host}",
196
+ f"Refusing to send a request outside {base.scheme}://{base.raw_host.decode()}{base.path.rstrip('/')}: "
197
+ f"{url.raw_host.decode('ascii', 'replace')}",
134
198
  status_code=0,
135
199
  )
136
200
 
201
+ def _within_prefix(self, url: httpx.URL) -> bool:
202
+ prefix = self._base.path.rstrip("/")
203
+ if not prefix:
204
+ return True
205
+ raw_prefix = self._base.raw_path.decode("ascii").rstrip("/")
206
+ if not _within(url.raw_path.decode("ascii").split("?")[0], raw_prefix):
207
+ return False
208
+ readings, prefix_readings = _path_readings(url.path), _path_readings(prefix)
209
+ return bool(readings and prefix_readings) and all(
210
+ _within(path, p) for path, p in zip(readings, prefix_readings)
211
+ )
212
+
137
213
  def close(self):
138
214
  self.client.close()
139
215
 
@@ -338,7 +414,7 @@ class OCLAPIClient:
338
414
 
339
415
  def get_org(self, org: str) -> dict:
340
416
  """Get a specific organization."""
341
- return self.get(f"/orgs/{org}/")
417
+ return self.get(f"/orgs/{_segment(org)}/")
342
418
 
343
419
  def list_org_repos(
344
420
  self,
@@ -351,7 +427,7 @@ class OCLAPIClient:
351
427
  params: dict[str, Any] = {"limit": limit, "page": page}
352
428
  if repo_type != "all":
353
429
  params["repoType"] = "Source" if repo_type == "source" else "Collection"
354
- return self._get_list(f"/orgs/{org}/repos/", params=params)
430
+ return self._get_list(f"/orgs/{_segment(org)}/repos/", params=params)
355
431
 
356
432
  # ── User operations ──────────────────────────────────────────
357
433
 
@@ -372,7 +448,7 @@ class OCLAPIClient:
372
448
 
373
449
  def get_user_detail(self, username: str) -> dict:
374
450
  """Get a specific user by username."""
375
- return self.get(f"/users/{username}/")
451
+ return self.get(f"/users/{_segment(username)}/")
376
452
 
377
453
  def list_user_repos(
378
454
  self,
@@ -385,15 +461,15 @@ class OCLAPIClient:
385
461
  params: dict[str, Any] = {"limit": limit, "page": page}
386
462
  if repo_type != "all":
387
463
  params["repoType"] = "Source" if repo_type == "source" else "Collection"
388
- return self._get_list(f"/users/{username}/repos/", params=params)
464
+ return self._get_list(f"/users/{_segment(username)}/repos/", params=params)
389
465
 
390
466
  def list_user_orgs(self, username: str, limit: int = 100) -> dict:
391
467
  """List organizations a user belongs to."""
392
- return self._get_list(f"/users/{username}/orgs/", params={"limit": limit, "verbose": "true"})
468
+ return self._get_list(f"/users/{_segment(username)}/orgs/", params={"limit": limit, "verbose": "true"})
393
469
 
394
470
  def get_org_members(self, org: str, limit: int = 100) -> dict:
395
471
  """List members of an organization."""
396
- return self._get_list(f"/orgs/{org}/members/", params={"limit": limit})
472
+ return self._get_list(f"/orgs/{_segment(org)}/members/", params={"limit": limit})
397
473
 
398
474
  def search_repos(
399
475
  self,
@@ -425,11 +501,11 @@ class OCLAPIClient:
425
501
 
426
502
  # Use scoped endpoint when owner is specified
427
503
  if owner and owner_type and owner_type != "all":
428
- ot = owner_type # "orgs" or "users"
429
- endpoint = f"/{ot}/{owner}/repos/"
504
+ _validate_owner_type(owner_type)
505
+ endpoint = f"/{owner_type}/{_segment(owner)}/repos/"
430
506
  elif owner:
431
507
  # Default to orgs scope
432
- endpoint = f"/orgs/{owner}/repos/"
508
+ endpoint = f"/orgs/{_segment(owner)}/repos/"
433
509
  else:
434
510
  # Global search — add owner/ownerType as query params
435
511
  endpoint = "/repos/"
@@ -555,12 +631,12 @@ class OCLAPIClient:
555
631
  """Get a single concept."""
556
632
  _validate_owner_type(owner_type)
557
633
  stem = _repo_type_stem("source")
558
- base = f"/{owner_type}/{owner}/{stem}/{source}"
634
+ base = f"/{owner_type}/{_segment(owner)}/{stem}/{_segment(source)}"
559
635
  if repo_version:
560
- base = f"{base}/{repo_version}"
561
- endpoint = f"{base}/concepts/{concept_id}/"
636
+ base = f"{base}/{_segment(repo_version)}"
637
+ endpoint = f"{base}/concepts/{_segment(concept_id)}/"
562
638
  if concept_version:
563
- endpoint = f"{base}/concepts/{concept_id}/{concept_version}/"
639
+ endpoint = f"{base}/concepts/{_segment(concept_id)}/{_segment(concept_version)}/"
564
640
 
565
641
  params: dict[str, Any] = {}
566
642
  if include_mappings:
@@ -583,7 +659,7 @@ class OCLAPIClient:
583
659
  ) -> dict:
584
660
  """List version history for a concept."""
585
661
  _validate_owner_type(owner_type)
586
- endpoint = f"/{owner_type}/{owner}/sources/{source}/concepts/{concept_id}/versions/"
662
+ endpoint = f"/{owner_type}/{_segment(owner)}/sources/{_segment(source)}/concepts/{_segment(concept_id)}/versions/"
587
663
  return self._get_list(endpoint, params={"limit": limit, "page": page})
588
664
 
589
665
  def get_concept_names(
@@ -595,7 +671,7 @@ class OCLAPIClient:
595
671
  ) -> dict:
596
672
  """List names for a concept."""
597
673
  _validate_owner_type(owner_type)
598
- endpoint = f"/{owner_type}/{owner}/sources/{source}/concepts/{concept_id}/names/"
674
+ endpoint = f"/{owner_type}/{_segment(owner)}/sources/{_segment(source)}/concepts/{_segment(concept_id)}/names/"
599
675
  return self._get_list(endpoint)
600
676
 
601
677
  def get_concept_descriptions(
@@ -607,7 +683,7 @@ class OCLAPIClient:
607
683
  ) -> dict:
608
684
  """List descriptions for a concept."""
609
685
  _validate_owner_type(owner_type)
610
- endpoint = f"/{owner_type}/{owner}/sources/{source}/concepts/{concept_id}/descriptions/"
686
+ endpoint = f"/{owner_type}/{_segment(owner)}/sources/{_segment(source)}/concepts/{_segment(concept_id)}/descriptions/"
611
687
  return self._get_list(endpoint)
612
688
 
613
689
  def get_concept_extras(
@@ -619,7 +695,7 @@ class OCLAPIClient:
619
695
  ) -> dict:
620
696
  """Get extras (custom attributes) for a concept."""
621
697
  _validate_owner_type(owner_type)
622
- endpoint = f"/{owner_type}/{owner}/sources/{source}/concepts/{concept_id}/extras/"
698
+ endpoint = f"/{owner_type}/{_segment(owner)}/sources/{_segment(source)}/concepts/{_segment(concept_id)}/extras/"
623
699
  return self.get(endpoint)
624
700
 
625
701
  def search_mappings(
@@ -701,10 +777,10 @@ class OCLAPIClient:
701
777
  ) -> dict:
702
778
  """Get a single mapping."""
703
779
  _validate_owner_type(owner_type)
704
- base = f"/{owner_type}/{owner}/sources/{source}"
780
+ base = f"/{owner_type}/{_segment(owner)}/sources/{_segment(source)}"
705
781
  if repo_version:
706
- base = f"{base}/{repo_version}"
707
- return self.get(f"{base}/mappings/{mapping_id}/")
782
+ base = f"{base}/{_segment(repo_version)}"
783
+ return self.get(f"{base}/mappings/{_segment(mapping_id)}/")
708
784
 
709
785
  def get_mapping_versions(
710
786
  self,
@@ -717,7 +793,7 @@ class OCLAPIClient:
717
793
  ) -> dict:
718
794
  """List version history for a mapping."""
719
795
  _validate_owner_type(owner_type)
720
- endpoint = f"/{owner_type}/{owner}/sources/{source}/mappings/{mapping_id}/versions/"
796
+ endpoint = f"/{owner_type}/{_segment(owner)}/sources/{_segment(source)}/mappings/{_segment(mapping_id)}/versions/"
721
797
  return self._get_list(endpoint, params={"limit": limit, "page": page})
722
798
 
723
799
  def cascade(
@@ -744,7 +820,7 @@ class OCLAPIClient:
744
820
  """Execute $cascade operation on a concept."""
745
821
  endpoint = _build_repo_endpoint(
746
822
  owner_type, owner, repo_type, repo, repo_version,
747
- suffix=f"concepts/{concept_id}/$cascade/"
823
+ suffix=f"concepts/{_segment(concept_id)}/$cascade/"
748
824
  )
749
825
 
750
826
  params: dict[str, Any] = {}
@@ -841,7 +917,7 @@ class OCLAPIClient:
841
917
  ) -> dict:
842
918
  """List expansions for a collection version."""
843
919
  _validate_owner_type(owner_type)
844
- endpoint = f"/{owner_type}/{owner}/collections/{collection}/{collection_version}/expansions/"
920
+ endpoint = f"/{owner_type}/{_segment(owner)}/collections/{_segment(collection)}/{_segment(collection_version)}/expansions/"
845
921
  return self._get_list(endpoint, params={"verbose": True})
846
922
 
847
923
  def get_expansion(
@@ -857,16 +933,16 @@ class OCLAPIClient:
857
933
 
858
934
  if expansion_id:
859
935
  if collection_version:
860
- endpoint = f"/{owner_type}/{owner}/collections/{collection}/{collection_version}/expansions/{expansion_id}/"
936
+ endpoint = f"/{owner_type}/{_segment(owner)}/collections/{_segment(collection)}/{_segment(collection_version)}/expansions/{_segment(expansion_id)}/"
861
937
  else:
862
- endpoint = f"/{owner_type}/{owner}/collections/{collection}/expansions/{expansion_id}/"
938
+ endpoint = f"/{owner_type}/{_segment(owner)}/collections/{_segment(collection)}/expansions/{_segment(expansion_id)}/"
863
939
  return self.get(endpoint, params={"verbose": True})
864
940
 
865
941
  # Look up default expansion from collection version
866
942
  if collection_version:
867
- col_endpoint = f"/{owner_type}/{owner}/collections/{collection}/{collection_version}/"
943
+ col_endpoint = f"/{owner_type}/{_segment(owner)}/collections/{_segment(collection)}/{_segment(collection_version)}/"
868
944
  else:
869
- col_endpoint = f"/{owner_type}/{owner}/collections/{collection}/"
945
+ col_endpoint = f"/{owner_type}/{_segment(owner)}/collections/{_segment(collection)}/"
870
946
 
871
947
  col_data = self.get(col_endpoint)
872
948
  expansion_url = col_data.get("expansion_url")
@@ -893,9 +969,9 @@ class OCLAPIClient:
893
969
  Uses the version's default expansion when ``expansion_id`` is not given.
894
970
  """
895
971
  _validate_owner_type(owner_type)
896
- version_endpoint = f"/{owner_type}/{owner}/collections/{collection}/{collection_version}/"
972
+ version_endpoint = f"/{owner_type}/{_segment(owner)}/collections/{_segment(collection)}/{_segment(collection_version)}/"
897
973
  if expansion_id:
898
- expansion_url = f"{version_endpoint}expansions/{expansion_id}/"
974
+ expansion_url = f"{version_endpoint}expansions/{_segment(expansion_id)}/"
899
975
  else:
900
976
  expansion_url = self.get(version_endpoint).get("expansion_url")
901
977
  if not expansion_url:
@@ -945,9 +1021,9 @@ class OCLAPIClient:
945
1021
  ) -> dict:
946
1022
  """List references in a collection."""
947
1023
  _validate_owner_type(owner_type)
948
- base = f"/{owner_type}/{owner}/collections/{collection}"
1024
+ base = f"/{owner_type}/{_segment(owner)}/collections/{_segment(collection)}"
949
1025
  if collection_version:
950
- base = f"{base}/{collection_version}"
1026
+ base = f"{base}/{_segment(collection_version)}"
951
1027
  endpoint = f"{base}/references/"
952
1028
  return self._get_list(endpoint, params={"limit": limit, "page": page})
953
1029
 
@@ -982,7 +1058,7 @@ class OCLAPIClient:
982
1058
  self._require_auth()
983
1059
  _validate_owner_type(owner_type)
984
1060
  stem = _repo_type_stem(repo_type)
985
- endpoint = f"/{owner_type}/{owner}/{stem}/"
1061
+ endpoint = f"/{owner_type}/{_segment(owner)}/{stem}/"
986
1062
 
987
1063
  body: dict[str, Any] = {"id": repo_id, "short_code": repo_id, "name": name}
988
1064
  if description:
@@ -1060,7 +1136,7 @@ class OCLAPIClient:
1060
1136
  self._require_auth()
1061
1137
  _validate_owner_type(owner_type)
1062
1138
  stem = _repo_type_stem(repo_type)
1063
- endpoint = f"/{owner_type}/{owner}/{stem}/{repo}/{version_id}/"
1139
+ endpoint = f"/{owner_type}/{_segment(owner)}/{stem}/{_segment(repo)}/{_segment(version_id)}/"
1064
1140
  body = {k: v for k, v in fields.items() if v is not None}
1065
1141
  return self.patch(endpoint, json=body)
1066
1142
 
@@ -1084,7 +1160,7 @@ class OCLAPIClient:
1084
1160
  if not names:
1085
1161
  raise ValueError("At least one name is required")
1086
1162
 
1087
- endpoint = f"/{owner_type}/{owner}/sources/{source}/concepts/"
1163
+ endpoint = f"/{owner_type}/{_segment(owner)}/sources/{_segment(source)}/concepts/"
1088
1164
  body: dict[str, Any] = {
1089
1165
  "id": concept_id,
1090
1166
  "concept_class": concept_class,
@@ -1113,7 +1189,7 @@ class OCLAPIClient:
1113
1189
  """Update an existing concept."""
1114
1190
  self._require_auth()
1115
1191
  _validate_owner_type(owner_type)
1116
- endpoint = f"/{owner_type}/{owner}/sources/{source}/concepts/{concept_id}/"
1192
+ endpoint = f"/{owner_type}/{_segment(owner)}/sources/{_segment(source)}/concepts/{_segment(concept_id)}/"
1117
1193
  body = {k: v for k, v in fields.items() if v is not None}
1118
1194
  if update_comment:
1119
1195
  body["update_comment"] = update_comment
@@ -1145,7 +1221,7 @@ class OCLAPIClient:
1145
1221
  """Hard-delete a concept (?hardDelete=true). Requires staff token."""
1146
1222
  self._require_auth()
1147
1223
  _validate_owner_type(owner_type)
1148
- endpoint = f"/{owner_type}/{owner}/sources/{source}/concepts/{concept_id}/"
1224
+ endpoint = f"/{owner_type}/{_segment(owner)}/sources/{_segment(source)}/concepts/{_segment(concept_id)}/"
1149
1225
  return self.delete(endpoint, params={"hardDelete": "true"})
1150
1226
 
1151
1227
  def add_concept_name(
@@ -1162,7 +1238,7 @@ class OCLAPIClient:
1162
1238
  """Add a name/translation to a concept."""
1163
1239
  self._require_auth()
1164
1240
  _validate_owner_type(owner_type)
1165
- endpoint = f"/{owner_type}/{owner}/sources/{source}/concepts/{concept_id}/names/"
1241
+ endpoint = f"/{owner_type}/{_segment(owner)}/sources/{_segment(source)}/concepts/{_segment(concept_id)}/names/"
1166
1242
  body: dict[str, Any] = {"name": name, "locale": locale}
1167
1243
  if name_type:
1168
1244
  body["name_type"] = name_type
@@ -1183,7 +1259,7 @@ class OCLAPIClient:
1183
1259
  """Add a description to a concept."""
1184
1260
  self._require_auth()
1185
1261
  _validate_owner_type(owner_type)
1186
- endpoint = f"/{owner_type}/{owner}/sources/{source}/concepts/{concept_id}/descriptions/"
1262
+ endpoint = f"/{owner_type}/{_segment(owner)}/sources/{_segment(source)}/concepts/{_segment(concept_id)}/descriptions/"
1187
1263
  body: dict[str, Any] = {"description": description, "locale": locale}
1188
1264
  if description_type:
1189
1265
  body["description_type"] = description_type
@@ -1201,7 +1277,7 @@ class OCLAPIClient:
1201
1277
  """Set a custom attribute on a concept."""
1202
1278
  self._require_auth()
1203
1279
  _validate_owner_type(owner_type)
1204
- endpoint = f"/{owner_type}/{owner}/sources/{source}/concepts/{concept_id}/extras/{key}/"
1280
+ endpoint = f"/{owner_type}/{_segment(owner)}/sources/{_segment(source)}/concepts/{_segment(concept_id)}/extras/{_segment(key)}/"
1205
1281
  return self.put(endpoint, json={key: value})
1206
1282
 
1207
1283
  def delete_concept_extra(
@@ -1215,7 +1291,7 @@ class OCLAPIClient:
1215
1291
  """Delete a custom attribute from a concept."""
1216
1292
  self._require_auth()
1217
1293
  _validate_owner_type(owner_type)
1218
- endpoint = f"/{owner_type}/{owner}/sources/{source}/concepts/{concept_id}/extras/{key}/"
1294
+ endpoint = f"/{owner_type}/{_segment(owner)}/sources/{_segment(source)}/concepts/{_segment(concept_id)}/extras/{_segment(key)}/"
1219
1295
  return self.delete(endpoint)
1220
1296
 
1221
1297
  def create_mapping(
@@ -1244,7 +1320,7 @@ class OCLAPIClient:
1244
1320
  if not to_concept_url and not (to_source_url and to_concept_code):
1245
1321
  raise ValueError("to_concept requires either to_concept_url or (to_source_url + to_concept_code)")
1246
1322
 
1247
- endpoint = f"/{owner_type}/{owner}/sources/{source}/mappings/"
1323
+ endpoint = f"/{owner_type}/{_segment(owner)}/sources/{_segment(source)}/mappings/"
1248
1324
  body: dict[str, Any] = {"map_type": map_type}
1249
1325
 
1250
1326
  if from_concept_url:
@@ -1282,7 +1358,7 @@ class OCLAPIClient:
1282
1358
  """Update an existing mapping."""
1283
1359
  self._require_auth()
1284
1360
  _validate_owner_type(owner_type)
1285
- endpoint = f"/{owner_type}/{owner}/sources/{source}/mappings/{mapping_id}/"
1361
+ endpoint = f"/{owner_type}/{_segment(owner)}/sources/{_segment(source)}/mappings/{_segment(mapping_id)}/"
1286
1362
  body = {k: v for k, v in fields.items() if v is not None}
1287
1363
  if update_comment:
1288
1364
  body["update_comment"] = update_comment
@@ -1314,7 +1390,7 @@ class OCLAPIClient:
1314
1390
  """Hard-delete a mapping (?hardDelete=true). Requires staff token."""
1315
1391
  self._require_auth()
1316
1392
  _validate_owner_type(owner_type)
1317
- endpoint = f"/{owner_type}/{owner}/sources/{source}/mappings/{mapping_id}/"
1393
+ endpoint = f"/{owner_type}/{_segment(owner)}/sources/{_segment(source)}/mappings/{_segment(mapping_id)}/"
1318
1394
  return self.delete(endpoint, params={"hardDelete": "true"})
1319
1395
 
1320
1396
  def add_collection_ref(
@@ -1328,7 +1404,7 @@ class OCLAPIClient:
1328
1404
  """Add references to a collection."""
1329
1405
  self._require_auth()
1330
1406
  _validate_owner_type(owner_type)
1331
- endpoint = f"/{owner_type}/{owner}/collections/{collection}/references/"
1407
+ endpoint = f"/{owner_type}/{_segment(owner)}/collections/{_segment(collection)}/references/"
1332
1408
  body: dict[str, Any] = {"data": {"expressions": expressions}}
1333
1409
  params: dict[str, Any] = {}
1334
1410
  if cascade:
@@ -1345,7 +1421,7 @@ class OCLAPIClient:
1345
1421
  """Remove references from a collection."""
1346
1422
  self._require_auth()
1347
1423
  _validate_owner_type(owner_type)
1348
- endpoint = f"/{owner_type}/{owner}/collections/{collection}/references/"
1424
+ endpoint = f"/{owner_type}/{_segment(owner)}/collections/{_segment(collection)}/references/"
1349
1425
  # DELETE with body
1350
1426
  response = self.client.request(
1351
1427
  "DELETE", endpoint, json={"references": expressions}
@@ -1366,7 +1442,7 @@ class OCLAPIClient:
1366
1442
  """Create/trigger an expansion for a collection version."""
1367
1443
  self._require_auth()
1368
1444
  _validate_owner_type(owner_type)
1369
- endpoint = f"/{owner_type}/{owner}/collections/{collection}/{version}/expansions/"
1445
+ endpoint = f"/{owner_type}/{_segment(owner)}/collections/{_segment(collection)}/{_segment(version)}/expansions/"
1370
1446
  body = {k: v for k, v in params.items() if v is not None}
1371
1447
  return self.post(endpoint, json=body or None)
1372
1448
 
@@ -1386,7 +1462,7 @@ class OCLAPIClient:
1386
1462
  """Clone concepts using $clone operation."""
1387
1463
  self._require_auth()
1388
1464
  _validate_owner_type(owner_type)
1389
- endpoint = f"/{owner_type}/{owner}/sources/{dest_source}/$clone/"
1465
+ endpoint = f"/{owner_type}/{_segment(owner)}/sources/{_segment(dest_source)}/$clone/"
1390
1466
 
1391
1467
  body: dict[str, Any] = {"expressions": expressions}
1392
1468
  params: dict[str, Any] = {}
@@ -1416,7 +1492,7 @@ class OCLAPIClient:
1416
1492
  ) -> dict:
1417
1493
  """Set a custom attribute on a repository."""
1418
1494
  self._require_auth()
1419
- endpoint = _build_repo_endpoint(owner_type, owner, repo_type, repo, suffix=f"extras/{key}/")
1495
+ endpoint = _build_repo_endpoint(owner_type, owner, repo_type, repo, suffix=f"extras/{_segment(key)}/")
1420
1496
  return self.put(endpoint, json={key: value})
1421
1497
 
1422
1498
  def delete_repo_extra(
@@ -1431,7 +1507,7 @@ class OCLAPIClient:
1431
1507
  self._require_auth()
1432
1508
  _validate_owner_type(owner_type)
1433
1509
  stem = _repo_type_stem(repo_type)
1434
- return self.delete(f"/{owner_type}/{owner}/{stem}/{repo}/extras/{key}/")
1510
+ return self.delete(f"/{owner_type}/{_segment(owner)}/{stem}/{_segment(repo)}/extras/{_segment(key)}/")
1435
1511
 
1436
1512
  def get_repo_extras(
1437
1513
  self,
@@ -1447,12 +1523,12 @@ class OCLAPIClient:
1447
1523
  def add_org_member(self, org: str, username: str) -> dict:
1448
1524
  """Add a member to an organization."""
1449
1525
  self._require_auth()
1450
- return self.put(f"/orgs/{org}/members/{username}/")
1526
+ return self.put(f"/orgs/{_segment(org)}/members/{_segment(username)}/")
1451
1527
 
1452
1528
  def remove_org_member(self, org: str, username: str) -> dict:
1453
1529
  """Remove a member from an organization."""
1454
1530
  self._require_auth()
1455
- return self.delete(f"/orgs/{org}/members/{username}/")
1531
+ return self.delete(f"/orgs/{_segment(org)}/members/{_segment(username)}/")
1456
1532
 
1457
1533
  # ── Task operations ─────────────────────────────────────────────
1458
1534
 
@@ -1472,7 +1548,7 @@ class OCLAPIClient:
1472
1548
  def get_task(self, task_id: str) -> dict:
1473
1549
  """Get details of a specific task."""
1474
1550
  self._require_auth()
1475
- return self.get(f"/tasks/{task_id}/")
1551
+ return self.get(f"/tasks/{_segment(task_id)}/")
1476
1552
 
1477
1553
  # ── Organization CRUD ───────────────────────────────────────────
1478
1554
 
@@ -1506,7 +1582,7 @@ class OCLAPIClient:
1506
1582
  def delete_org(self, org: str) -> dict:
1507
1583
  """Delete an organization."""
1508
1584
  self._require_auth()
1509
- return self.delete(f"/orgs/{org}/")
1585
+ return self.delete(f"/orgs/{_segment(org)}/")
1510
1586
 
1511
1587
  # ── Repository delete ───────────────────────────────────────────
1512
1588
 
@@ -1580,7 +1656,7 @@ class OCLAPIClient:
1580
1656
 
1581
1657
  endpoint = "/importers/bulk-import/"
1582
1658
  if queue:
1583
- endpoint = f"/importers/bulk-import/{queue}/"
1659
+ endpoint = f"/importers/bulk-import/{_segment(queue)}/"
1584
1660
 
1585
1661
  params = {"update_if_exists": "true" if update_if_exists else "false"}
1586
1662
 
@@ -1612,7 +1688,7 @@ class OCLAPIClient:
1612
1688
  self._require_auth()
1613
1689
  endpoint = "/importers/bulk-import/"
1614
1690
  if queue:
1615
- endpoint = f"/importers/bulk-import/{queue}/"
1691
+ endpoint = f"/importers/bulk-import/{_segment(queue)}/"
1616
1692
  return self.get(endpoint)
1617
1693
 
1618
1694
  def import_status(self, task_id: str) -> Any:
@@ -1624,6 +1700,11 @@ class OCLAPIClient:
1624
1700
 
1625
1701
  def fetch_cascade_children(self, concept_url: str) -> set[str]:
1626
1702
  """Fetch child concept URLs via cascade for a given concept expression."""
1703
+ # The expression comes from data, so it may only name its own cascade endpoint.
1704
+ if "?" in concept_url or "#" in concept_url or any(
1705
+ part in (".", "..") for part in unquote(concept_url).split("/")
1706
+ ):
1707
+ return set()
1627
1708
  url = concept_url.rstrip("/") + "/cascade/"
1628
1709
  params = {
1629
1710
  "method": "sourcetoconcepts",
@@ -1656,8 +1737,9 @@ class OCLAPIClient:
1656
1737
  self, owner: str, repo: str, version: str,
1657
1738
  owner_type: str, repo_type: str,
1658
1739
  ) -> str:
1740
+ # The version is required: without it the path would name a version called "export".
1659
1741
  return _build_repo_endpoint(
1660
- owner_type, owner, repo_type, repo, version, "export/"
1742
+ owner_type, owner, repo_type, repo, suffix=f"{_segment(version)}/export/"
1661
1743
  )
1662
1744
 
1663
1745
  def export_status(