invenio-pidslink 0.1.0__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.
- invenio_pidslink-0.1.0/.gitignore +5 -0
- invenio_pidslink-0.1.0/CHANGES.rst +8 -0
- invenio_pidslink-0.1.0/LICENSE +21 -0
- invenio_pidslink-0.1.0/PKG-INFO +95 -0
- invenio_pidslink-0.1.0/README.rst +72 -0
- invenio_pidslink-0.1.0/docs/index.rst +4 -0
- invenio_pidslink-0.1.0/invenio_pidslink/__init__.py +10 -0
- invenio_pidslink-0.1.0/invenio_pidslink/client.py +114 -0
- invenio_pidslink-0.1.0/invenio_pidslink/config.py +40 -0
- invenio_pidslink-0.1.0/invenio_pidslink/errors.py +56 -0
- invenio_pidslink-0.1.0/invenio_pidslink/ext.py +21 -0
- invenio_pidslink-0.1.0/invenio_pidslink/providers.py +183 -0
- invenio_pidslink-0.1.0/pyproject.toml +52 -0
- invenio_pidslink-0.1.0/tests/test_client.py +109 -0
- invenio_pidslink-0.1.0/tests/test_provider.py +81 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 WACREN
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: invenio-pidslink
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: InvenioRDM persistent identifier provider for ARKs minted through WACREN PIDsLink.
|
|
5
|
+
Project-URL: Repository, https://gitlab.wacren.net/libsense/invenio-pidslink
|
|
6
|
+
Author-email: Donald Doe Seanedzu <donald.seanedzu@wacren.net>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: ARK,Invenio,InvenioRDM,PID,PIDsLink,WACREN
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: Flask
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Requires-Dist: invenio-base<3.0.0,>=2.4.0
|
|
15
|
+
Requires-Dist: invenio-i18n<4.0.0,>=3.0.0
|
|
16
|
+
Requires-Dist: invenio-pidstore<4.0.0,>=3.0.0
|
|
17
|
+
Requires-Dist: invenio-rdm-records<33.0.0,>=32.0.0
|
|
18
|
+
Requires-Dist: requests>=2.31.0
|
|
19
|
+
Provides-Extra: tests
|
|
20
|
+
Requires-Dist: pytest-invenio<5.0.0,>=4.0.0; extra == 'tests'
|
|
21
|
+
Requires-Dist: pytest>=7.0.0; extra == 'tests'
|
|
22
|
+
Description-Content-Type: text/x-rst
|
|
23
|
+
|
|
24
|
+
================
|
|
25
|
+
Invenio-PIDsLink
|
|
26
|
+
================
|
|
27
|
+
|
|
28
|
+
InvenioRDM persistent identifier provider that mints **ARKs** through the
|
|
29
|
+
WACREN `PIDsLink <https://pidslink.ren.africa>`_ public API, authenticated with
|
|
30
|
+
an organisation API key.
|
|
31
|
+
|
|
32
|
+
Install
|
|
33
|
+
-------
|
|
34
|
+
|
|
35
|
+
.. code-block:: console
|
|
36
|
+
|
|
37
|
+
pip install invenio-pidslink
|
|
38
|
+
|
|
39
|
+
Configure (``invenio.cfg``)
|
|
40
|
+
---------------------------
|
|
41
|
+
|
|
42
|
+
.. code-block:: python
|
|
43
|
+
|
|
44
|
+
import os
|
|
45
|
+
from invenio_i18n import lazy_gettext as _
|
|
46
|
+
from invenio_rdm_records.services.pids import providers
|
|
47
|
+
from invenio_pidslink.providers import PIDsLinkClient, PIDsLinkPIDProvider
|
|
48
|
+
|
|
49
|
+
PIDSLINK_ENABLED = True
|
|
50
|
+
PIDSLINK_API_KEY = os.environ["PIDSLINK_API_KEY"] # server-side secret
|
|
51
|
+
PIDSLINK_NAAN = "12345"
|
|
52
|
+
PIDSLINK_SHOULDER = "abc"
|
|
53
|
+
|
|
54
|
+
RDM_PERSISTENT_IDENTIFIER_PROVIDERS = [
|
|
55
|
+
PIDsLinkPIDProvider("ark", client=PIDsLinkClient("pidslink"),
|
|
56
|
+
label=_("ARK")),
|
|
57
|
+
providers.OAIPIDProvider("oai", label=_("OAI ID")),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
RDM_PERSISTENT_IDENTIFIERS = {
|
|
61
|
+
"ark": {
|
|
62
|
+
"providers": ["ark"],
|
|
63
|
+
"required": True,
|
|
64
|
+
"label": _("ARK"),
|
|
65
|
+
"is_enabled": PIDsLinkPIDProvider.is_enabled,
|
|
66
|
+
"system_managed": True,
|
|
67
|
+
},
|
|
68
|
+
"oai": {
|
|
69
|
+
"providers": ["oai"],
|
|
70
|
+
"required": True,
|
|
71
|
+
"is_enabled": providers.OAIPIDProvider.is_enabled,
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
To keep DOI off, remove ``doi`` from ``RDM_PERSISTENT_IDENTIFIERS`` or
|
|
76
|
+
disable ``DATACITE_ENABLED`` / ``CROSSREF_ENABLED``.
|
|
77
|
+
|
|
78
|
+
Public API coverage
|
|
79
|
+
-------------------
|
|
80
|
+
|
|
81
|
+
``invenio_pidslink.client.PIDsLinkClient`` wraps ``/api/public/pids/``:
|
|
82
|
+
``mint``, ``update``, ``query``, ``bulk_mint``, ``list``, ``stats``. It has no
|
|
83
|
+
Invenio dependency and can be used from plain scripts.
|
|
84
|
+
|
|
85
|
+
Development
|
|
86
|
+
-----------
|
|
87
|
+
|
|
88
|
+
.. code-block:: console
|
|
89
|
+
|
|
90
|
+
pip install -e ".[tests]"
|
|
91
|
+
./run-tests.sh
|
|
92
|
+
|
|
93
|
+
Releases are published to PyPI by the GitLab CI ``publish`` job when a ``v*`` tag is pushed.
|
|
94
|
+
|
|
95
|
+
License: MIT.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
================
|
|
2
|
+
Invenio-PIDsLink
|
|
3
|
+
================
|
|
4
|
+
|
|
5
|
+
InvenioRDM persistent identifier provider that mints **ARKs** through the
|
|
6
|
+
WACREN `PIDsLink <https://pidslink.ren.africa>`_ public API, authenticated with
|
|
7
|
+
an organisation API key.
|
|
8
|
+
|
|
9
|
+
Install
|
|
10
|
+
-------
|
|
11
|
+
|
|
12
|
+
.. code-block:: console
|
|
13
|
+
|
|
14
|
+
pip install invenio-pidslink
|
|
15
|
+
|
|
16
|
+
Configure (``invenio.cfg``)
|
|
17
|
+
---------------------------
|
|
18
|
+
|
|
19
|
+
.. code-block:: python
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from invenio_i18n import lazy_gettext as _
|
|
23
|
+
from invenio_rdm_records.services.pids import providers
|
|
24
|
+
from invenio_pidslink.providers import PIDsLinkClient, PIDsLinkPIDProvider
|
|
25
|
+
|
|
26
|
+
PIDSLINK_ENABLED = True
|
|
27
|
+
PIDSLINK_API_KEY = os.environ["PIDSLINK_API_KEY"] # server-side secret
|
|
28
|
+
PIDSLINK_NAAN = "12345"
|
|
29
|
+
PIDSLINK_SHOULDER = "abc"
|
|
30
|
+
|
|
31
|
+
RDM_PERSISTENT_IDENTIFIER_PROVIDERS = [
|
|
32
|
+
PIDsLinkPIDProvider("ark", client=PIDsLinkClient("pidslink"),
|
|
33
|
+
label=_("ARK")),
|
|
34
|
+
providers.OAIPIDProvider("oai", label=_("OAI ID")),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
RDM_PERSISTENT_IDENTIFIERS = {
|
|
38
|
+
"ark": {
|
|
39
|
+
"providers": ["ark"],
|
|
40
|
+
"required": True,
|
|
41
|
+
"label": _("ARK"),
|
|
42
|
+
"is_enabled": PIDsLinkPIDProvider.is_enabled,
|
|
43
|
+
"system_managed": True,
|
|
44
|
+
},
|
|
45
|
+
"oai": {
|
|
46
|
+
"providers": ["oai"],
|
|
47
|
+
"required": True,
|
|
48
|
+
"is_enabled": providers.OAIPIDProvider.is_enabled,
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
To keep DOI off, remove ``doi`` from ``RDM_PERSISTENT_IDENTIFIERS`` or
|
|
53
|
+
disable ``DATACITE_ENABLED`` / ``CROSSREF_ENABLED``.
|
|
54
|
+
|
|
55
|
+
Public API coverage
|
|
56
|
+
-------------------
|
|
57
|
+
|
|
58
|
+
``invenio_pidslink.client.PIDsLinkClient`` wraps ``/api/public/pids/``:
|
|
59
|
+
``mint``, ``update``, ``query``, ``bulk_mint``, ``list``, ``stats``. It has no
|
|
60
|
+
Invenio dependency and can be used from plain scripts.
|
|
61
|
+
|
|
62
|
+
Development
|
|
63
|
+
-----------
|
|
64
|
+
|
|
65
|
+
.. code-block:: console
|
|
66
|
+
|
|
67
|
+
pip install -e ".[tests]"
|
|
68
|
+
./run-tests.sh
|
|
69
|
+
|
|
70
|
+
Releases are published to PyPI by the GitLab CI ``publish`` job when a ``v*`` tag is pushed.
|
|
71
|
+
|
|
72
|
+
License: MIT.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 WACREN.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Thin client for the PIDsLink *Public API* (``/api/public/pids/*``).
|
|
5
|
+
|
|
6
|
+
Pure ``requests``; it has no Invenio dependency so it can be reused in scripts.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
from .errors import PIDsLinkConnectionError, PIDsLinkError
|
|
15
|
+
|
|
16
|
+
_RETRY_STATUS = {429, 500, 502, 503, 504}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def strip_ark_scheme(value):
|
|
20
|
+
"""``ark:/50962/abc`` -> ``50962/abc`` (the form used in URL paths)."""
|
|
21
|
+
value = (value or "").strip()
|
|
22
|
+
return value[5:] if value.startswith("ark:/") else value.lstrip("/")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resolver_url(ark, resolver="https://pidslink.ren.africa"):
|
|
26
|
+
"""Return the public resolver link for an ARK."""
|
|
27
|
+
return f"{resolver.rstrip('/')}/ark:/{strip_ark_scheme(ark)}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PIDsLinkClient:
|
|
31
|
+
"""API-key authenticated client. One instance per organisation key."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, api_key, base_url="https://pidslinkapi.ren.africa",
|
|
34
|
+
timeout=30, retries=3, session=None):
|
|
35
|
+
if not api_key:
|
|
36
|
+
raise ValueError("A PIDsLink API key is required.")
|
|
37
|
+
base = base_url.rstrip("/")
|
|
38
|
+
self.api_root = base if base.endswith("/api") else f"{base}/api"
|
|
39
|
+
self.timeout = timeout
|
|
40
|
+
self.retries = max(1, int(retries))
|
|
41
|
+
self.session = session or requests.Session()
|
|
42
|
+
self.session.headers.update(
|
|
43
|
+
{"X-API-Key": api_key, "Accept": "application/json"}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def __repr__(self): # never print the key
|
|
47
|
+
return f"<PIDsLinkClient {self.api_root}>"
|
|
48
|
+
|
|
49
|
+
def _request(self, method, path, json=None, params=None):
|
|
50
|
+
url = f"{self.api_root}/public/pids/{path}"
|
|
51
|
+
last = None
|
|
52
|
+
for attempt in range(self.retries):
|
|
53
|
+
try:
|
|
54
|
+
resp = self.session.request(
|
|
55
|
+
method, url, json=json, params=params, timeout=self.timeout
|
|
56
|
+
)
|
|
57
|
+
except requests.RequestException as exc:
|
|
58
|
+
last = PIDsLinkConnectionError(str(exc))
|
|
59
|
+
else:
|
|
60
|
+
if resp.status_code < 400:
|
|
61
|
+
try:
|
|
62
|
+
return resp.json()
|
|
63
|
+
except ValueError as exc:
|
|
64
|
+
raise PIDsLinkError(
|
|
65
|
+
"Invalid JSON from PIDsLink", resp.status_code, resp.text
|
|
66
|
+
) from exc
|
|
67
|
+
last = PIDsLinkError.factory(
|
|
68
|
+
resp.status_code,
|
|
69
|
+
f"{method} {path} failed with HTTP {resp.status_code}",
|
|
70
|
+
resp.text[:1000],
|
|
71
|
+
)
|
|
72
|
+
if resp.status_code not in _RETRY_STATUS:
|
|
73
|
+
raise last
|
|
74
|
+
if attempt + 1 < self.retries:
|
|
75
|
+
time.sleep(min(2 ** attempt, 8))
|
|
76
|
+
raise last
|
|
77
|
+
|
|
78
|
+
# -- single PID ---------------------------------------------------------
|
|
79
|
+
def mint(self, naan, shoulder, url, **fields):
|
|
80
|
+
"""``POST /public/pids/mint``. Returns the response ``data`` dict.
|
|
81
|
+
|
|
82
|
+
``fields``: metadata, type, commitment, identifier, format, relation,
|
|
83
|
+
source, title.
|
|
84
|
+
"""
|
|
85
|
+
payload = {"naan": naan, "shoulder": shoulder, "url": url, **fields}
|
|
86
|
+
payload.setdefault("source", url)
|
|
87
|
+
payload = {k: v for k, v in payload.items() if v is not None}
|
|
88
|
+
return self._request("POST", "mint", json=payload)["data"]
|
|
89
|
+
|
|
90
|
+
def update(self, pid, **fields):
|
|
91
|
+
"""``PATCH /public/pids/update/{pidsId}``."""
|
|
92
|
+
payload = {k: v for k, v in fields.items() if v is not None}
|
|
93
|
+
return self._request(
|
|
94
|
+
"PATCH", f"update/{quote(strip_ark_scheme(pid), safe='/')}", json=payload
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def query(self, pid):
|
|
98
|
+
"""``GET /public/pids/query/{pidsId}``. Returns the ``data`` dict."""
|
|
99
|
+
return self._request(
|
|
100
|
+
"GET", f"query/{quote(strip_ark_scheme(pid), safe='/')}"
|
|
101
|
+
)["data"]
|
|
102
|
+
|
|
103
|
+
# -- bulk / organisation ------------------------------------------------
|
|
104
|
+
def bulk_mint(self, naan, items):
|
|
105
|
+
"""``POST /public/pids/bulk-mint`` with ``{"naan", "data": [...]}``."""
|
|
106
|
+
return self._request("POST", "bulk-mint", json={"naan": naan, "data": list(items)})
|
|
107
|
+
|
|
108
|
+
def list(self, page=1):
|
|
109
|
+
"""``GET /public/pids/list/{pageNumber}``."""
|
|
110
|
+
return self._request("GET", f"list/{int(page)}")
|
|
111
|
+
|
|
112
|
+
def stats(self):
|
|
113
|
+
"""``GET /public/pids/stats``."""
|
|
114
|
+
return self._request("GET", "stats")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 WACREN.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Default configuration. Every key can be overridden in ``invenio.cfg``."""
|
|
5
|
+
|
|
6
|
+
PIDSLINK_ENABLED = False
|
|
7
|
+
"""Enable the ARK provider. Off by default so installing the package is inert."""
|
|
8
|
+
|
|
9
|
+
PIDSLINK_API_URL = "https://pidslinkapi.ren.africa"
|
|
10
|
+
"""PIDsLink API host (the ``/api`` path is added by the client)."""
|
|
11
|
+
|
|
12
|
+
PIDSLINK_RESOLVER_URL = "https://pidslink.ren.africa"
|
|
13
|
+
"""Resolver host used to build ``https://.../ark:/NAAN/...`` links."""
|
|
14
|
+
|
|
15
|
+
PIDSLINK_API_KEY = ""
|
|
16
|
+
"""Organisation API key (``pidslink_...``). Keep it server-side, e.g. in an
|
|
17
|
+
environment variable; never expose it to browser JavaScript."""
|
|
18
|
+
|
|
19
|
+
PIDSLINK_NAAN = ""
|
|
20
|
+
"""Name Assigning Authority Number issued to your organisation."""
|
|
21
|
+
|
|
22
|
+
PIDSLINK_SHOULDER = ""
|
|
23
|
+
"""ARK shoulder for your repository."""
|
|
24
|
+
|
|
25
|
+
PIDSLINK_TIMEOUT = 30
|
|
26
|
+
"""Request timeout in seconds."""
|
|
27
|
+
|
|
28
|
+
PIDSLINK_RETRY_ATTEMPTS = 3
|
|
29
|
+
"""Attempts for transient failures (connection errors, HTTP 429/5xx)."""
|
|
30
|
+
|
|
31
|
+
PIDSLINK_RECORD_URL_FORMAT = "{site_ui_url}/records/{id}"
|
|
32
|
+
"""Landing page URL registered as the ARK target. Variables: ``site_ui_url``
|
|
33
|
+
(from ``SITE_UI_URL``) and ``id`` (record id). A callable ``f(record)`` is
|
|
34
|
+
also accepted."""
|
|
35
|
+
|
|
36
|
+
PIDSLINK_RESOURCE_TYPE = "text"
|
|
37
|
+
PIDSLINK_COMMITMENT = "permanent"
|
|
38
|
+
PIDSLINK_TARGET_FORMAT = "html"
|
|
39
|
+
PIDSLINK_RELATION = "isPartOf"
|
|
40
|
+
"""Defaults sent in the mint payload."""
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 WACREN.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Exceptions raised by the PIDsLink client."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PIDsLinkError(Exception):
|
|
8
|
+
"""Base error. ``status_code`` and ``body`` are set for HTTP errors."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, message, status_code=None, body=None):
|
|
11
|
+
super().__init__(message)
|
|
12
|
+
self.status_code = status_code
|
|
13
|
+
self.body = body
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def factory(status_code, message, body=None):
|
|
17
|
+
"""Map an HTTP status code to the matching exception class."""
|
|
18
|
+
cls = {
|
|
19
|
+
400: PIDsLinkBadRequestError,
|
|
20
|
+
401: PIDsLinkAuthError,
|
|
21
|
+
403: PIDsLinkAuthError,
|
|
22
|
+
404: PIDsLinkNotFoundError,
|
|
23
|
+
409: PIDsLinkConflictError,
|
|
24
|
+
429: PIDsLinkRateLimitError,
|
|
25
|
+
}.get(status_code)
|
|
26
|
+
if cls is None:
|
|
27
|
+
cls = PIDsLinkServerError if status_code >= 500 else PIDsLinkError
|
|
28
|
+
return cls(message, status_code=status_code, body=body)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PIDsLinkConnectionError(PIDsLinkError):
|
|
32
|
+
"""The service could not be reached."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PIDsLinkBadRequestError(PIDsLinkError):
|
|
36
|
+
"""400: the payload was rejected."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PIDsLinkAuthError(PIDsLinkError):
|
|
40
|
+
"""401/403: key missing, inactive, expired, or IP not allow-listed."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PIDsLinkNotFoundError(PIDsLinkError):
|
|
44
|
+
"""404: identifier unknown to this organisation."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PIDsLinkConflictError(PIDsLinkError):
|
|
48
|
+
"""409: e.g. the URL is already bound to another PID."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PIDsLinkRateLimitError(PIDsLinkError):
|
|
52
|
+
"""429: API key rate limit exceeded."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PIDsLinkServerError(PIDsLinkError):
|
|
56
|
+
"""5xx: PIDsLink failed; retry later."""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 WACREN.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Flask extension: loads default config."""
|
|
5
|
+
|
|
6
|
+
from . import config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InvenioPIDsLink:
|
|
10
|
+
"""Invenio-PIDsLink extension."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, app=None):
|
|
13
|
+
if app:
|
|
14
|
+
self.init_app(app)
|
|
15
|
+
|
|
16
|
+
def init_app(self, app):
|
|
17
|
+
"""Flask application initialization."""
|
|
18
|
+
for key in dir(config):
|
|
19
|
+
if key.startswith("PIDSLINK_"):
|
|
20
|
+
app.config.setdefault(key, getattr(config, key))
|
|
21
|
+
app.extensions["invenio-pidslink"] = self
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 WACREN.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""InvenioRDM PID provider for ARKs minted via the PIDsLink public API."""
|
|
5
|
+
|
|
6
|
+
from collections import ChainMap
|
|
7
|
+
|
|
8
|
+
from flask import current_app
|
|
9
|
+
from invenio_pidstore.models import PIDStatus
|
|
10
|
+
from invenio_rdm_records.services.pids.providers import PIDProvider
|
|
11
|
+
|
|
12
|
+
from .client import PIDsLinkClient as _ApiClient
|
|
13
|
+
from .client import resolver_url
|
|
14
|
+
from .errors import PIDsLinkError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PIDsLinkClient:
|
|
18
|
+
"""Config-driven holder, mirroring ``DataCiteClient``.
|
|
19
|
+
|
|
20
|
+
Settings are read lazily from ``<config_prefix>_*`` keys of the app config.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, name, config_prefix="PIDSLINK", config_overrides=None):
|
|
24
|
+
self.name = name
|
|
25
|
+
self._config_prefix = config_prefix
|
|
26
|
+
self._config_overrides = config_overrides or {}
|
|
27
|
+
self._api = None
|
|
28
|
+
|
|
29
|
+
def cfgkey(self, key):
|
|
30
|
+
return f"{self._config_prefix}_{key.upper()}"
|
|
31
|
+
|
|
32
|
+
def cfg(self, key, default=None):
|
|
33
|
+
config = ChainMap(self._config_overrides, current_app.config)
|
|
34
|
+
return config.get(self.cfgkey(key), default)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def api(self):
|
|
38
|
+
"""Public API client (created on first use)."""
|
|
39
|
+
if self._api is None:
|
|
40
|
+
self._api = _ApiClient(
|
|
41
|
+
self.cfg("api_key"),
|
|
42
|
+
base_url=self.cfg("api_url"),
|
|
43
|
+
timeout=self.cfg("timeout", 30),
|
|
44
|
+
retries=self.cfg("retry_attempts", 3),
|
|
45
|
+
)
|
|
46
|
+
return self._api
|
|
47
|
+
|
|
48
|
+
def record_url(self, record):
|
|
49
|
+
"""Landing page URL registered as the ARK target."""
|
|
50
|
+
fmt = self.cfg("record_url_format")
|
|
51
|
+
if callable(fmt):
|
|
52
|
+
return fmt(record)
|
|
53
|
+
site = (current_app.config.get("SITE_UI_URL") or "").rstrip("/")
|
|
54
|
+
return fmt.format(site_ui_url=site, id=record["id"])
|
|
55
|
+
|
|
56
|
+
def mint_payload(self, record):
|
|
57
|
+
"""Fields sent to ``/public/pids/mint``."""
|
|
58
|
+
metadata = record.get("metadata") or {}
|
|
59
|
+
title = metadata.get("title") or f"Record {record['id']}"
|
|
60
|
+
url = self.record_url(record)
|
|
61
|
+
return {
|
|
62
|
+
"naan": self.cfg("naan"),
|
|
63
|
+
"shoulder": self.cfg("shoulder"),
|
|
64
|
+
"url": url,
|
|
65
|
+
"metadata": title,
|
|
66
|
+
"title": title,
|
|
67
|
+
"type": self.cfg("resource_type", "text"),
|
|
68
|
+
"commitment": self.cfg("commitment", "permanent"),
|
|
69
|
+
"identifier": record["id"],
|
|
70
|
+
"format": self.cfg("target_format", "html"),
|
|
71
|
+
"relation": self.cfg("relation", "isPartOf"),
|
|
72
|
+
"source": url,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def generate_ark(self, record):
|
|
76
|
+
"""Mint an ARK and return it (``ark:/NAAN/...``)."""
|
|
77
|
+
if not (self.cfg("naan") and self.cfg("shoulder") and self.cfg("api_key")):
|
|
78
|
+
raise RuntimeError(
|
|
79
|
+
f"{self.cfgkey('naan')}, {self.cfgkey('shoulder')} and "
|
|
80
|
+
f"{self.cfgkey('api_key')} must be configured."
|
|
81
|
+
)
|
|
82
|
+
payload = self.mint_payload(record)
|
|
83
|
+
data = self.api.mint(**payload)
|
|
84
|
+
ark = data.get("ark")
|
|
85
|
+
if not ark:
|
|
86
|
+
raise RuntimeError("PIDsLink mint response did not contain an ARK.")
|
|
87
|
+
return ark
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class PIDsLinkPIDProvider(PIDProvider):
|
|
91
|
+
"""ARK provider.
|
|
92
|
+
|
|
93
|
+
The ARK is minted when InvenioRDM first asks for an identifier (draft
|
|
94
|
+
creation) because PIDsLink assigns the value. ``register`` (publish) and
|
|
95
|
+
``update`` re-point the ARK at the record landing page.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, id_="ark", client=None, pid_type="ark",
|
|
99
|
+
default_status=PIDStatus.NEW, **kwargs):
|
|
100
|
+
super().__init__(
|
|
101
|
+
id_,
|
|
102
|
+
client=client or PIDsLinkClient("pidslink", config_prefix="PIDSLINK"),
|
|
103
|
+
pid_type=pid_type,
|
|
104
|
+
default_status=default_status,
|
|
105
|
+
managed=True,
|
|
106
|
+
**kwargs,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def is_enabled(cls, app=None):
|
|
111
|
+
"""InvenioRDM calls this with or without the app depending on version."""
|
|
112
|
+
app = app or current_app
|
|
113
|
+
return bool(app.config.get("PIDSLINK_ENABLED", False))
|
|
114
|
+
|
|
115
|
+
def generate_id(self, record, **kwargs):
|
|
116
|
+
return self.client.generate_ark(record)
|
|
117
|
+
|
|
118
|
+
def can_modify(self, pid, **kwargs):
|
|
119
|
+
return not pid.is_registered()
|
|
120
|
+
|
|
121
|
+
def _push(self, pid, record):
|
|
122
|
+
payload = self.client.mint_payload(record)
|
|
123
|
+
self.client.api.update(
|
|
124
|
+
pid.pid_value,
|
|
125
|
+
url=payload["url"],
|
|
126
|
+
metadata=payload["metadata"],
|
|
127
|
+
title=payload["title"],
|
|
128
|
+
source=payload["source"],
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def register(self, pid, record=None, **kwargs):
|
|
132
|
+
"""Mark registered locally, then make sure the target URL is current."""
|
|
133
|
+
if record is not None:
|
|
134
|
+
try:
|
|
135
|
+
self._push(pid, record)
|
|
136
|
+
except PIDsLinkError:
|
|
137
|
+
current_app.logger.exception(
|
|
138
|
+
"PIDsLink update failed while registering %s", pid.pid_value
|
|
139
|
+
)
|
|
140
|
+
return False
|
|
141
|
+
return super().register(pid)
|
|
142
|
+
|
|
143
|
+
def update(self, pid, record=None, **kwargs):
|
|
144
|
+
if record is None:
|
|
145
|
+
return False
|
|
146
|
+
try:
|
|
147
|
+
self._push(pid, record)
|
|
148
|
+
except PIDsLinkError:
|
|
149
|
+
current_app.logger.exception("PIDsLink update failed for %s", pid.pid_value)
|
|
150
|
+
return False
|
|
151
|
+
if pid.is_deleted():
|
|
152
|
+
return pid.sync_status(PIDStatus.REGISTERED)
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
def validate(self, record, identifier=None, provider=None, **kwargs):
|
|
156
|
+
success, errors = super().validate(record, identifier, provider, **kwargs)
|
|
157
|
+
if identifier and not self._is_valid_ark(identifier):
|
|
158
|
+
self._insert_pid_type_error_msg(
|
|
159
|
+
errors, "Invalid ARK. Expected ark:/NAAN/name."
|
|
160
|
+
)
|
|
161
|
+
return (not errors), errors
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _is_valid_ark(identifier):
|
|
165
|
+
rest = identifier[5:] if identifier.startswith("ark:/") else identifier
|
|
166
|
+
parts = rest.split("/", 1)
|
|
167
|
+
return len(parts) == 2 and parts[0].isdigit() and bool(parts[1])
|
|
168
|
+
|
|
169
|
+
def create_and_reserve(self, record, **kwargs):
|
|
170
|
+
"""Create the PID (minting an ARK) and store it on ``record.pids``."""
|
|
171
|
+
pid = self.create(record)
|
|
172
|
+
self.reserve(pid, record=record)
|
|
173
|
+
record.pids[self.pid_type] = {
|
|
174
|
+
"identifier": pid.pid_value,
|
|
175
|
+
"provider": self.name,
|
|
176
|
+
"client": self.client.name,
|
|
177
|
+
}
|
|
178
|
+
return pid
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def resolver_url(ark):
|
|
182
|
+
"""Public resolver link for an ARK."""
|
|
183
|
+
return resolver_url(ark, current_app.config["PIDSLINK_RESOLVER_URL"])
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 WACREN.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
[project]
|
|
5
|
+
name = "invenio-pidslink"
|
|
6
|
+
description = "InvenioRDM persistent identifier provider for ARKs minted through WACREN PIDsLink."
|
|
7
|
+
readme = "README.rst"
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
authors = [{ name = "Donald Doe Seanedzu", email = "donald.seanedzu@wacren.net" }]
|
|
11
|
+
keywords = ["Invenio", "InvenioRDM", "ARK", "PID", "PIDsLink", "WACREN"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Framework :: Flask",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"invenio-base>=2.4.0,<3.0.0",
|
|
19
|
+
"invenio-i18n>=3.0.0,<4.0.0",
|
|
20
|
+
"invenio-pidstore>=3.0.0,<4.0.0",
|
|
21
|
+
"invenio-rdm-records>=32.0.0,<33.0.0",
|
|
22
|
+
"requests>=2.31.0",
|
|
23
|
+
]
|
|
24
|
+
dynamic = ["version"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Repository = "https://gitlab.wacren.net/libsense/invenio-pidslink"
|
|
28
|
+
|
|
29
|
+
[project.entry-points."invenio_base.apps"]
|
|
30
|
+
invenio_pidslink = "invenio_pidslink:InvenioPIDsLink"
|
|
31
|
+
|
|
32
|
+
[project.entry-points."invenio_base.api_apps"]
|
|
33
|
+
invenio_pidslink = "invenio_pidslink:InvenioPIDsLink"
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
tests = [
|
|
37
|
+
"pytest>=7.0.0",
|
|
38
|
+
"pytest-invenio>=4.0.0,<5.0.0",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["hatchling"]
|
|
43
|
+
build-backend = "hatchling.build"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.version]
|
|
46
|
+
path = "invenio_pidslink/__init__.py"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.sdist]
|
|
49
|
+
include = ["/invenio_pidslink", "/tests", "/docs", "CHANGES.rst", "README.rst", "LICENSE"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 WACREN.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Client tests; need only ``requests`` and ``pytest``."""
|
|
5
|
+
|
|
6
|
+
from unittest.mock import MagicMock
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from invenio_pidslink.client import PIDsLinkClient, resolver_url, strip_ark_scheme
|
|
12
|
+
from invenio_pidslink.errors import (
|
|
13
|
+
PIDsLinkAuthError,
|
|
14
|
+
PIDsLinkConflictError,
|
|
15
|
+
PIDsLinkConnectionError,
|
|
16
|
+
PIDsLinkServerError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resp(status=200, body=None):
|
|
21
|
+
r = MagicMock()
|
|
22
|
+
r.status_code = status
|
|
23
|
+
r.json.return_value = body if body is not None else {}
|
|
24
|
+
r.text = str(body)
|
|
25
|
+
return r
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def client(*responses, retries=3):
|
|
29
|
+
session = MagicMock()
|
|
30
|
+
session.headers = {}
|
|
31
|
+
session.request.side_effect = list(responses)
|
|
32
|
+
c = PIDsLinkClient("pidslink_k", session=session, retries=retries)
|
|
33
|
+
return c, session
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.fixture(autouse=True)
|
|
37
|
+
def no_sleep(monkeypatch):
|
|
38
|
+
monkeypatch.setattr("invenio_pidslink.client.time.sleep", lambda s: None)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_requires_key():
|
|
42
|
+
with pytest.raises(ValueError):
|
|
43
|
+
PIDsLinkClient("")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_key_header_and_repr_hides_key():
|
|
47
|
+
c, s = client()
|
|
48
|
+
assert s.headers["X-API-Key"] == "pidslink_k"
|
|
49
|
+
assert "pidslink_k" not in repr(c)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_mint_posts_to_public_endpoint():
|
|
53
|
+
c, s = client(resp(201, {"data": {"ark": "ark:/1/a"}}))
|
|
54
|
+
data = c.mint("1", "s", "https://x/r/1", title="T", type=None)
|
|
55
|
+
assert data["ark"] == "ark:/1/a"
|
|
56
|
+
method, url = s.request.call_args.args
|
|
57
|
+
assert (method, url) == ("POST", "https://pidslinkapi.ren.africa/api/public/pids/mint")
|
|
58
|
+
body = s.request.call_args.kwargs["json"]
|
|
59
|
+
assert body["source"] == "https://x/r/1" and "type" not in body
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_update_and_query_strip_scheme():
|
|
63
|
+
c, s = client(resp(200, {}), resp(200, {"data": {"x": 1}}))
|
|
64
|
+
c.update("ark:/50962/bb1", url="https://x")
|
|
65
|
+
assert s.request.call_args.args[1].endswith("/public/pids/update/50962/bb1")
|
|
66
|
+
assert c.query("ark:/50962/bb1") == {"x": 1}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_bulk_list_stats():
|
|
70
|
+
c, s = client(resp(201, {}), resp(200, {}), resp(200, {}))
|
|
71
|
+
c.bulk_mint("1", [{"url": "u"}])
|
|
72
|
+
assert s.request.call_args.kwargs["json"] == {"naan": "1", "data": [{"url": "u"}]}
|
|
73
|
+
c.list(2)
|
|
74
|
+
assert s.request.call_args.args[1].endswith("/list/2")
|
|
75
|
+
c.stats()
|
|
76
|
+
assert s.request.call_args.args[1].endswith("/stats")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_no_retry_on_auth_or_conflict():
|
|
80
|
+
c, s = client(resp(401, "no"))
|
|
81
|
+
with pytest.raises(PIDsLinkAuthError):
|
|
82
|
+
c.stats()
|
|
83
|
+
assert s.request.call_count == 1
|
|
84
|
+
c, s = client(resp(409, "dup"))
|
|
85
|
+
with pytest.raises(PIDsLinkConflictError):
|
|
86
|
+
c.mint("1", "s", "u")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_retries_then_succeeds():
|
|
90
|
+
c, s = client(resp(503, "x"), resp(200, {"data": {}}))
|
|
91
|
+
assert c.query("1/a") == {}
|
|
92
|
+
assert s.request.call_count == 2
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_retries_exhausted():
|
|
96
|
+
c, s = client(resp(500, "x"), resp(500, "x"), retries=2)
|
|
97
|
+
with pytest.raises(PIDsLinkServerError):
|
|
98
|
+
c.stats()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_connection_error():
|
|
102
|
+
c, s = client(requests.ConnectionError("down"), retries=1)
|
|
103
|
+
with pytest.raises(PIDsLinkConnectionError):
|
|
104
|
+
c.stats()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_helpers():
|
|
108
|
+
assert strip_ark_scheme("ark:/1/a") == "1/a"
|
|
109
|
+
assert resolver_url("1/a") == "https://pidslink.ren.africa/ark:/1/a"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 WACREN.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Provider tests using a bare Flask app (no database)."""
|
|
5
|
+
|
|
6
|
+
from unittest.mock import MagicMock
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from flask import Flask
|
|
10
|
+
|
|
11
|
+
from invenio_pidslink import InvenioPIDsLink
|
|
12
|
+
from invenio_pidslink.providers import PIDsLinkClient, PIDsLinkPIDProvider
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture()
|
|
16
|
+
def app():
|
|
17
|
+
app = Flask("t")
|
|
18
|
+
app.config.update(
|
|
19
|
+
SITE_UI_URL="https://repo.example.org/",
|
|
20
|
+
PIDSLINK_ENABLED=True,
|
|
21
|
+
PIDSLINK_API_KEY="pidslink_k",
|
|
22
|
+
PIDSLINK_NAAN="12345",
|
|
23
|
+
PIDSLINK_SHOULDER="abc",
|
|
24
|
+
)
|
|
25
|
+
InvenioPIDsLink(app)
|
|
26
|
+
with app.app_context():
|
|
27
|
+
yield app
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
RECORD = {"id": "r1", "metadata": {"title": "My record"}}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_defaults_do_not_override(app):
|
|
34
|
+
assert app.config["PIDSLINK_NAAN"] == "12345"
|
|
35
|
+
assert app.config["PIDSLINK_TIMEOUT"] == 30
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_is_enabled(app):
|
|
39
|
+
assert PIDsLinkPIDProvider.is_enabled() is True
|
|
40
|
+
assert PIDsLinkPIDProvider.is_enabled(app) is True
|
|
41
|
+
app.config["PIDSLINK_ENABLED"] = False
|
|
42
|
+
assert PIDsLinkPIDProvider.is_enabled() is False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_mint_payload(app):
|
|
46
|
+
p = PIDsLinkClient("pidslink").mint_payload(RECORD)
|
|
47
|
+
assert p["url"] == "https://repo.example.org/records/r1"
|
|
48
|
+
assert p["naan"] == "12345" and p["identifier"] == "r1"
|
|
49
|
+
assert p["commitment"] == "permanent"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_generate_id_mints_via_public_api(app):
|
|
53
|
+
client = PIDsLinkClient("pidslink")
|
|
54
|
+
client._api = MagicMock()
|
|
55
|
+
client._api.mint.return_value = {"ark": "ark:/12345/abc9"}
|
|
56
|
+
assert PIDsLinkPIDProvider("ark", client=client).generate_id(RECORD) == "ark:/12345/abc9"
|
|
57
|
+
assert client._api.mint.call_args.kwargs["shoulder"] == "abc"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_requires_config(app):
|
|
61
|
+
app.config["PIDSLINK_API_KEY"] = ""
|
|
62
|
+
with pytest.raises(RuntimeError):
|
|
63
|
+
PIDsLinkClient("pidslink").generate_ark(RECORD)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.parametrize(
|
|
67
|
+
"value,ok",
|
|
68
|
+
[("ark:/12345/abc9", True), ("12345/abc9", True), ("ark:/x/abc", False), ("ark:/12345", False)],
|
|
69
|
+
)
|
|
70
|
+
def test_ark_validation(value, ok):
|
|
71
|
+
assert PIDsLinkPIDProvider._is_valid_ark(value) is ok
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_update_pushes_url(app):
|
|
75
|
+
client = PIDsLinkClient("pidslink")
|
|
76
|
+
client._api = MagicMock()
|
|
77
|
+
pid = MagicMock(pid_value="ark:/12345/abc9")
|
|
78
|
+
pid.is_deleted.return_value = False
|
|
79
|
+
assert PIDsLinkPIDProvider("ark", client=client).update(pid, record=RECORD) is True
|
|
80
|
+
client._api.update.assert_called_once()
|
|
81
|
+
assert client._api.update.call_args.kwargs["url"].endswith("/records/r1")
|