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.
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/PKG-INFO +3 -1
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/README.md +2 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/pyproject.toml +1 -1
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/tool.py +13 -2
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/openconceptlab/client.py +148 -66
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/openconceptlab.egg-info/PKG-INFO +3 -1
- openconceptlab-0.2.1/tests/test_client.py +498 -0
- openconceptlab-0.2.0/tests/test_client.py +0 -233
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/LICENSE +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/setup.cfg +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/__init__.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/__main__.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/api_client.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/__init__.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/auth.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/cascade.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/concept.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/expansion.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/export.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/import_cmd.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/mapping.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/org.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/ref.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/reference.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/repo.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/server.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/task.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/commands/user.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/config.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/main.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/ocl_cli/output.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/openconceptlab/__init__.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/openconceptlab.egg-info/SOURCES.txt +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/openconceptlab.egg-info/dependency_links.txt +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/openconceptlab.egg-info/entry_points.txt +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/openconceptlab.egg-info/requires.txt +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/src/openconceptlab.egg-info/top_level.txt +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/tests/test_concept_commands.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/tests/test_core_package.py +0 -0
- {openconceptlab-0.2.0 → openconceptlab-0.2.1}/tests/test_export_commands.py +0 -0
- {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.
|
|
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
|
|
@@ -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 =
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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.
|
|
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
|
-
|
|
429
|
-
endpoint = f"/{
|
|
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
|
|
1742
|
+
owner_type, owner, repo_type, repo, suffix=f"{_segment(version)}/export/"
|
|
1661
1743
|
)
|
|
1662
1744
|
|
|
1663
1745
|
def export_status(
|