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.
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ build/
5
+ .pytest_cache/
@@ -0,0 +1,8 @@
1
+ Changes
2
+ =======
3
+
4
+ Version 0.1.0 (unreleased)
5
+
6
+ - Initial release: PIDsLink public API client (mint, update, query, bulk-mint,
7
+ list, stats) and an InvenioRDM ARK PID provider authenticated with an
8
+ organisation API key.
@@ -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,4 @@
1
+ Invenio-PIDsLink
2
+ ================
3
+
4
+ .. include:: ../README.rst
@@ -0,0 +1,10 @@
1
+ # SPDX-FileCopyrightText: 2026 WACREN.
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """InvenioRDM ARK provider backed by the WACREN PIDsLink public API."""
5
+
6
+ from .ext import InvenioPIDsLink
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = ("__version__", "InvenioPIDsLink")
@@ -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")