oarepo-runtime 2.0.0.dev27__py3-none-any.whl → 2.0.0.dev28__py3-none-any.whl

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.
@@ -19,6 +19,6 @@ from .api import Model
19
19
  from .ext import OARepoRuntime
20
20
  from .proxies import current_runtime
21
21
 
22
- __version__ = "2.0.0dev27"
22
+ __version__ = "2.0.0dev28"
23
23
 
24
24
  __all__ = ("Model", "OARepoRuntime", "__version__", "current_runtime")
oarepo_runtime/api.py CHANGED
@@ -88,6 +88,9 @@ class Export:
88
88
  oai_namespace: str | None = None
89
89
  """OAI namespace, if applicable. If not set, the export can not be used in OAI-PMH responses."""
90
90
 
91
+ description: LazyString | None = None
92
+ """Description of the export format, human readable."""
93
+
91
94
 
92
95
  class Model[
93
96
  S: RecordService = RecordService,
@@ -0,0 +1,11 @@
1
+ #
2
+ # Copyright (c) 2025 CESNET z.s.p.o.
3
+ #
4
+ # This file is a part of oarepo-runtime (see http://github.com/oarepo/oarepo-runtime).
5
+ #
6
+ # oarepo-runtime is free software; you can redistribute it and/or modify it
7
+ # under the terms of the MIT License; see LICENSE file for more details.
8
+ #
9
+ """Info module."""
10
+
11
+ from __future__ import annotations
@@ -0,0 +1,440 @@
1
+ #
2
+ # Copyright (c) 2025 CESNET z.s.p.o.
3
+ #
4
+ # This file is a part of oarepo-runtime (see http://github.com/oarepo/oarepo-runtime).
5
+ #
6
+ # oarepo-runtime is free software; you can redistribute it and/or modify it
7
+ # under the terms of the MIT License; see LICENSE file for more details.
8
+ #
9
+ # TODO: better docstring
10
+ """Info Resource."""
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib
15
+ import logging
16
+ import os
17
+ import re
18
+ from functools import cached_property
19
+ from typing import TYPE_CHECKING, Any, ClassVar, cast
20
+ from urllib.parse import urljoin, urlparse, urlunparse
21
+
22
+ import marshmallow as ma
23
+ from flask import Blueprint, Flask, current_app, request, url_for
24
+ from flask_resources import (
25
+ Resource,
26
+ ResourceConfig,
27
+ from_conf,
28
+ request_parser,
29
+ resource_requestctx,
30
+ response_handler,
31
+ route,
32
+ )
33
+ from invenio_base import invenio_url_for
34
+ from invenio_base.utils import obj_or_import_string
35
+ from invenio_jsonschemas import current_jsonschemas
36
+ from invenio_records_resources.proxies import (
37
+ current_transfer_registry,
38
+ )
39
+ from werkzeug.routing import BuildError
40
+
41
+ if TYPE_CHECKING:
42
+ from invenio_records.systemfields import ConstantField
43
+ from invenio_records_resources.records.api import Record
44
+
45
+ from oarepo_runtime import Model
46
+ from oarepo_runtime.proxies import current_runtime
47
+
48
+ logger = logging.getLogger("oarepo_runtime.info")
49
+
50
+
51
+ class InfoConfig(ResourceConfig):
52
+ """Info resource config."""
53
+
54
+ blueprint_name = "oarepo_runtime_info"
55
+ url_prefix = "/.well-known/repository"
56
+
57
+ schema_view_args: ClassVar[dict[str, ma.fields.Str]] = {"schema": ma.fields.Str()}
58
+ model_view_args: ClassVar[dict[str, ma.fields.Str]] = {"model": ma.fields.Str()}
59
+
60
+ def __init__(self, app: Flask):
61
+ """Initialize Info config."""
62
+ self.app = app
63
+
64
+ @cached_property
65
+ def components(self) -> tuple[object, ...]:
66
+ """Get the components for the info resource from config."""
67
+ return tuple(obj_or_import_string(x) for x in self.app.config.get("INFO_ENDPOINT_COMPONENTS", []))
68
+
69
+
70
+ schema_view_args = request_parser(from_conf("schema_view_args"), location="view_args")
71
+ model_view_args = request_parser(from_conf("model_view_args"), location="view_args")
72
+
73
+
74
+ class InfoResource(Resource):
75
+ """Info resource."""
76
+
77
+ def create_url_rules(self) -> list[dict[str, Any]]:
78
+ """Create the URL rules for the info resource."""
79
+ return [
80
+ route("GET", "/", self.repository),
81
+ route("GET", "/models", self.models),
82
+ route("GET", "/schema/<path:schema>", self.schema),
83
+ ]
84
+
85
+ def call_components(self, method_name: str, **kwargs: Any) -> None:
86
+ """Call components for the given method name."""
87
+ for component in self.components:
88
+ if hasattr(component, method_name):
89
+ getattr(component, method_name)(**kwargs)
90
+
91
+ @cached_property
92
+ def components(self) -> list[Any]:
93
+ """Get the components for the info resource from config."""
94
+ return [x(self) for x in self.config.components]
95
+
96
+ @response_handler(many=True)
97
+ def models(self) -> tuple[list[dict], int]:
98
+ """Models endpoint."""
99
+ return self.model_data + self.vocabulary_data, 200
100
+
101
+ @schema_view_args
102
+ @response_handler()
103
+ def schema(self) -> tuple[dict, int]:
104
+ """Return jsonschema for the current schema."""
105
+ schema = resource_requestctx.view_args["schema"]
106
+ return current_jsonschemas.get_schema(schema, resolved=True), 200
107
+
108
+ def _get_model_content_types(self, model: Model) -> list[dict]:
109
+ """Get the content types supported by the model.
110
+
111
+ Returns a list of:
112
+
113
+ content_type="application/json",
114
+ name="Invenio RDM JSON",
115
+ description="Invenio RDM JSON as described in",
116
+ schema=url / "schemas" / "records" / "record-v6.0.0.json",
117
+ can_export=True,
118
+ can_deposit=True,
119
+ """
120
+ content_types: list[dict] = []
121
+ # export content types
122
+ for model_export in model.exports:
123
+ curr_item = {
124
+ "content_type": model_export.mimetype,
125
+ "code": model_export.code,
126
+ "name": model_export.name,
127
+ "description": model_export.description,
128
+ "can_export": True,
129
+ "can_deposit": model_export.mimetype == "application/json",
130
+ }
131
+ if model_export.mimetype == "application/json":
132
+ curr_item["schema"] = url_for(
133
+ "oarepo_runtime_info.schema",
134
+ schema=model.record_json_schema.replace("local://", ""),
135
+ _external=True,
136
+ )
137
+ content_types.append(curr_item)
138
+ return content_types
139
+
140
+ def _get_model_features(self, model: Model) -> list[str]:
141
+ """Get a list of features supported by the model."""
142
+ feature_keys = []
143
+ model_features = model.features or {}
144
+ if model_features.get("requests", {}):
145
+ feature_keys.append("requests")
146
+ if model_features.get("draft", {}):
147
+ feature_keys.append("drafts")
148
+ if model_features.get("files", {}):
149
+ feature_keys.append("files")
150
+ return feature_keys
151
+
152
+ def _get_model_html_endpoint(self, model: Model) -> Any:
153
+ base = self._get_model_api_endpoint(model)
154
+ if not base:
155
+ return None
156
+ suffix = model.resource_config.url_prefix or ""
157
+ return urljoin(base, suffix)
158
+
159
+ def _get_model_api_endpoint(self, model: Model) -> str | None:
160
+ try:
161
+ alias = model.api_blueprint_name
162
+ return model.api_url("search", type=alias, _external=True)
163
+ except BuildError:
164
+ logger.exception("Failed to get model api endpoint")
165
+ return None
166
+
167
+ def _get_model_draft_endpoint(self, model: Model) -> str | None:
168
+ try:
169
+ alias = model.api_blueprint_name
170
+ return model.api_url("search_user_records", type=alias, _external=True)
171
+ except BuildError:
172
+ logger.exception("Failed to get model draft endpoint")
173
+ return None
174
+
175
+ @cached_property
176
+ def model_data(self) -> list[dict]:
177
+ """Get the model data."""
178
+ data: list[dict] = []
179
+ # iterate entrypoint oarepo.models
180
+ for model in current_runtime.rdm_models:
181
+ service = model.service
182
+ service_class = model.service.__class__
183
+ if not service or not isinstance(service, service_class):
184
+ continue
185
+
186
+ model_features = self._get_model_features(model)
187
+
188
+ links: dict[str, str | None] = {
189
+ "html": self._get_model_html_endpoint(model),
190
+ "records": self._get_model_api_endpoint(model),
191
+ "deposit": self._get_model_api_endpoint(model),
192
+ }
193
+
194
+ if "drafts" in model_features:
195
+ links["drafts"] = self._get_model_draft_endpoint(model)
196
+
197
+ data.append(
198
+ {
199
+ "schema": model.record_json_schema,
200
+ # TODO: stejna hodnota jako nasledujici prop name, je to spravne?
201
+ "type": model.name,
202
+ "name": model.name,
203
+ "description": model.description,
204
+ "version": model.version,
205
+ "features": model_features,
206
+ "links": links,
207
+ "content_types": self._get_model_content_types(model),
208
+ # rdm models always have metadata element
209
+ "metadata": True,
210
+ }
211
+ )
212
+ self.call_components("model", data=data)
213
+ data.sort(key=lambda x: x["type"])
214
+ return data
215
+
216
+ @cached_property
217
+ def vocabulary_data(self) -> list[dict]:
218
+ """Get the vocabulary data."""
219
+ ret: list[dict] = []
220
+ try:
221
+ from invenio_vocabularies.contrib.affiliations.api import Affiliation
222
+ from invenio_vocabularies.contrib.awards.api import Award
223
+ from invenio_vocabularies.contrib.funders.api import Funder
224
+ from invenio_vocabularies.contrib.names.api import Name
225
+ from invenio_vocabularies.contrib.subjects.api import Subject
226
+ from invenio_vocabularies.records.api import Vocabulary
227
+ from invenio_vocabularies.records.models import VocabularyType
228
+ except ImportError: # pragma: no cover
229
+ return ret
230
+
231
+ def _generate_rdm_vocabulary( # noqa: PLR0913 more attributes
232
+ base_url: str,
233
+ record: type[Record],
234
+ vocabulary_type: str,
235
+ vocabulary_name: str,
236
+ vocabulary_description: str,
237
+ special: bool,
238
+ can_export: bool = True,
239
+ can_deposit: bool = False,
240
+ ) -> dict:
241
+ schema_field = cast("ConstantField[Record, str] | None", getattr(record, "schema", None))
242
+ if schema_field is not None:
243
+ schema_field_value = schema_field.value
244
+ schema_path = base_url + schema_field_value.replace("local://", "")
245
+ else:
246
+ raise ValueError(f"Record {record} has no schema field") # pragma: no cover
247
+
248
+ if not base_url.endswith("/"):
249
+ base_url += "/"
250
+ url_prefix = base_url + "api" if special else base_url + "api/vocabularies"
251
+ links = {
252
+ "records": f"{url_prefix}/{vocabulary_type}",
253
+ }
254
+ if can_deposit:
255
+ links["deposit"] = f"{url_prefix}/{vocabulary_type}"
256
+
257
+ return {
258
+ "schema": schema_field_value,
259
+ "type": vocabulary_type,
260
+ "name": vocabulary_name,
261
+ "description": vocabulary_description,
262
+ "version": "unknown",
263
+ "features": ["rdm", "vocabulary"],
264
+ "links": links,
265
+ "content_types": [
266
+ {
267
+ "content_type": "application/json",
268
+ "name": "Invenio RDM JSON",
269
+ "description": "Vocabulary JSON",
270
+ "schema": schema_path,
271
+ "can_export": can_export,
272
+ "can_deposit": can_deposit,
273
+ }
274
+ ],
275
+ "metadata": False,
276
+ }
277
+
278
+ base_url = invenio_url_for("vocabularies.search", type="languages", _external=True)
279
+ base_url = replace_path_in_url(base_url, "/")
280
+ ret = [
281
+ _generate_rdm_vocabulary(base_url, Affiliation, "affiliations", "Affiliations", "", special=True),
282
+ _generate_rdm_vocabulary(base_url, Award, "awards", "Awards", "", special=True),
283
+ _generate_rdm_vocabulary(base_url, Funder, "funders", "Funders", "", special=True),
284
+ _generate_rdm_vocabulary(base_url, Subject, "subjects", "Subjects", "", special=True),
285
+ _generate_rdm_vocabulary(base_url, Name, "names", "Names", "", special=True),
286
+ _generate_rdm_vocabulary(
287
+ base_url,
288
+ Affiliation,
289
+ "affiliations-vocab",
290
+ "Affiliations",
291
+ "Specialized vocabulary for affiliations",
292
+ special=False,
293
+ can_deposit=True,
294
+ ),
295
+ _generate_rdm_vocabulary(
296
+ base_url,
297
+ Award,
298
+ "awards-vocab",
299
+ "Awards",
300
+ "Specialized vocabulary for awards",
301
+ special=False,
302
+ can_deposit=True,
303
+ ),
304
+ _generate_rdm_vocabulary(
305
+ base_url,
306
+ Funder,
307
+ "funders-vocab",
308
+ "Funders",
309
+ "Specialized vocabulary for funders",
310
+ special=False,
311
+ can_deposit=True,
312
+ ),
313
+ _generate_rdm_vocabulary(
314
+ base_url,
315
+ Subject,
316
+ "subjects-vocab",
317
+ "Subjects",
318
+ "Specialized vocabulary for subjects",
319
+ special=False,
320
+ can_deposit=True,
321
+ ),
322
+ _generate_rdm_vocabulary(
323
+ base_url,
324
+ Name,
325
+ "names-vocab",
326
+ "Names",
327
+ "Specialized vocabulary for names",
328
+ special=False,
329
+ can_deposit=True,
330
+ ),
331
+ ]
332
+
333
+ vc_types = {vc.id for vc in cast("Any", VocabularyType).query.all()}
334
+ vocab_type_metadata = current_app.config.get("INVENIO_VOCABULARY_TYPE_METADATA", {})
335
+ vc_types.update(vocab_type_metadata.keys())
336
+
337
+ for vc in sorted(vc_types):
338
+ vc_metadata = vocab_type_metadata.get(vc, {})
339
+ ret.append(
340
+ _generate_rdm_vocabulary(
341
+ base_url,
342
+ Vocabulary,
343
+ vc,
344
+ to_current_language(vc_metadata.get("name")) or vc,
345
+ to_current_language(vc_metadata.get("description")) or "",
346
+ special=False,
347
+ can_export=True,
348
+ can_deposit=True,
349
+ )
350
+ )
351
+
352
+ return ret
353
+
354
+ @response_handler()
355
+ def repository(self) -> tuple[dict, int]:
356
+ """Repository endpoint."""
357
+ endpoint = request.endpoint
358
+ self_url = url_for(endpoint, _external=True) if endpoint else request.url
359
+ links = {
360
+ "self": self_url,
361
+ "api": replace_path_in_url(self_url, "/api"),
362
+ "models": url_for("oarepo_runtime_info.models", _external=True),
363
+ }
364
+ try:
365
+ import invenio_requests # noqa
366
+
367
+ links["requests"] = invenio_url_for("requests.search")
368
+ except ImportError: # pragma: no cover
369
+ pass
370
+
371
+ ret = {
372
+ "schema": "local://introspection-v1.0.0",
373
+ "name": current_app.config.get("THEME_SITENAME", ""),
374
+ "description": current_app.config.get("REPOSITORY_DESCRIPTION", ""),
375
+ "version": os.environ.get("DEPLOYMENT_VERSION", "local development"),
376
+ "invenio_version": get_package_version("oarepo"),
377
+ "transfers": list(current_transfer_registry.get_transfer_types()),
378
+ "links": links,
379
+ "features": [
380
+ *_add_feature_if_can_import("drafts", "invenio_drafts_resources"),
381
+ *_add_feature_if_can_import("workflows", "oarepo_workflows"),
382
+ *_add_feature_if_can_import("requests", "invenio_requests"),
383
+ *_add_feature_if_can_import("communities", "invenio_communities"),
384
+ *_add_feature_if_can_import("request_types", "oarepo_requests"),
385
+ ],
386
+ }
387
+ if len(self.model_data) == 1:
388
+ ret["default_model"] = self.model_data[0]["name"]
389
+
390
+ self.call_components("repository", data=ret)
391
+ return ret, 200
392
+
393
+
394
+ def create_wellknown_blueprint(app: Flask) -> Blueprint:
395
+ """Create an info blueprint."""
396
+ info_endpoint_config = app.config.get("INFO_ENDPOINT_CONFIG")
397
+ config_class = (
398
+ cast("type[InfoConfig]", obj_or_import_string(info_endpoint_config)) if info_endpoint_config else InfoConfig
399
+ )
400
+
401
+ return InfoResource(config=config_class(app)).as_blueprint()
402
+
403
+
404
+ def get_package_version(package_name: str) -> str | None:
405
+ """Get package version."""
406
+ from pkg_resources import get_distribution
407
+
408
+ return re.sub(r"\+.*", "", get_distribution(package_name).version)
409
+
410
+
411
+ def replace_path_in_url(url: str, path: str) -> str:
412
+ """Replace the path in a URL."""
413
+ # Parse the URL into its components
414
+ parsed_url = urlparse(url)
415
+
416
+ # Replace the path with '/api'
417
+ new_parsed_url = parsed_url._replace(path=path)
418
+
419
+ # Return the reconstructed URL with the new path
420
+ return str(urlunparse(new_parsed_url))
421
+
422
+
423
+ def _add_feature_if_can_import(feature: str, module: str) -> list[str]:
424
+ try:
425
+ importlib.import_module(module)
426
+ except ImportError:
427
+ return []
428
+ else:
429
+ return [feature]
430
+
431
+
432
+ def to_current_language(data: dict | Any) -> Any:
433
+ """Convert data to current language."""
434
+ if isinstance(data, dict):
435
+ from flask_babel import get_locale
436
+
437
+ current_locale = get_locale()
438
+ if current_locale:
439
+ return data.get(current_locale.language)
440
+ return data
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oarepo-runtime
3
- Version: 2.0.0.dev27
3
+ Version: 2.0.0.dev28
4
4
  Summary: A set of runtime extensions of Invenio repository
5
5
  Project-URL: Homepage, https://github.com/oarepo/oarepo-runtime
6
6
  License-Expression: MIT
@@ -1,5 +1,5 @@
1
- oarepo_runtime/__init__.py,sha256=N1wgbJGO9HUbud5zt0WK95JDDr4Q0zny8Nb4zI_A2m4,686
2
- oarepo_runtime/api.py,sha256=f3SQ7atMAGRpnfL8MfLCAxNGxEo9Lebl_9IuUNKRYEk,13134
1
+ oarepo_runtime/__init__.py,sha256=SB5ZZKCuzkUbuBlw_ngzm1suKgwrWG8OMXF4hw49T5Y,686
2
+ oarepo_runtime/api.py,sha256=5MjhNwwjbbS7w7aql6pg8haKvqqr8QBmZBqJkWRWEw4,13237
3
3
  oarepo_runtime/config.py,sha256=RUEPFn_5bKp9Wb0OY-Fb3VK30m35vF5IsLjYaQHhP3g,3838
4
4
  oarepo_runtime/ext.py,sha256=NgiRNl_hwTvEWcXnNwVh_XCPJyvwr3dZkdPmkWvN1xo,8785
5
5
  oarepo_runtime/proxies.py,sha256=JhOklHoJO8-gPamvjY6jtF9tlOrvcuBXG9kVHPO-x3Q,782
@@ -7,6 +7,8 @@ oarepo_runtime/py.typed,sha256=RznSCjXReEUI9zkmD25E8XniG_MvPpLBF6MyNZA8MmE,42
7
7
  oarepo_runtime/typing.py,sha256=vH8UUb4QTJowuvibwHaOOEwxx8i21LcOeplxJl0Yrew,1594
8
8
  oarepo_runtime/cli/__init__.py,sha256=H7GOeOBf0udgKWOdlAQswIMvRrD8BwcEjOVxIqP0Suw,731
9
9
  oarepo_runtime/cli/search.py,sha256=4fHkrjltUUPVUzJiuWaiWxTk62rIYxal3_3jRsZVMmI,1175
10
+ oarepo_runtime/info/__init__.py,sha256=qRG3mSyoiw7sKm9StiuBJs6l15HrdAQ4sphsAQsJtQc,336
11
+ oarepo_runtime/info/views.py,sha256=PRfVaSsrQywE0lDpiLgDkor0xWzzYEQPkgHW7ITtQAc,16339
10
12
  oarepo_runtime/records/__init__.py,sha256=AbWzmVCY7MhrpdEeI0e3lKzeugPMUSo8T08-NBVeig4,339
11
13
  oarepo_runtime/records/drafts.py,sha256=b45ROjd9lwy6ratrpAruimcKvQmJradk5JgILoBAHmY,1965
12
14
  oarepo_runtime/records/mapping.py,sha256=fn6M208axxBqHtRV6qKQukwUw1z0hq_KF4qfuB2rr98,2630
@@ -39,8 +41,8 @@ oarepo_runtime/services/schema/__init__.py,sha256=jgAPI_uKC6Ug4KQWnwQVg3-aNaw-eH
39
41
  oarepo_runtime/services/schema/i18n.py,sha256=9D1zOQaPKAnYzejB0vO-m2BJYnam0N0Lrq4jID7twfE,3174
40
42
  oarepo_runtime/services/schema/i18n_ui.py,sha256=DbusphhGDeaobTt4nuwNgKZ6Houlu4Sv3SuMGkdjRRY,3582
41
43
  oarepo_runtime/services/schema/ui.py,sha256=6HPRMOytE7UQnGd4Z21kvYTpX5p_g2TmP2_v4pwkU8w,4524
42
- oarepo_runtime-2.0.0.dev27.dist-info/METADATA,sha256=LrWUiEQlh69eFU1KU5f2WcWjSlOVA80oaoN1SjsimUI,4723
43
- oarepo_runtime-2.0.0.dev27.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
44
- oarepo_runtime-2.0.0.dev27.dist-info/entry_points.txt,sha256=rOfs8R1oXFN_dLH9zAZ6ydkvr83mDajegc6NBIRsCMQ,318
45
- oarepo_runtime-2.0.0.dev27.dist-info/licenses/LICENSE,sha256=h2uWz0OaB3EN-J1ImdGJZzc7yvfQjvHVYdUhQ-H7ypY,1064
46
- oarepo_runtime-2.0.0.dev27.dist-info/RECORD,,
44
+ oarepo_runtime-2.0.0.dev28.dist-info/METADATA,sha256=p_h3OTOgDOBbAPP19O2iOVtCxMf7dc48yVRcz42O1yY,4723
45
+ oarepo_runtime-2.0.0.dev28.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
46
+ oarepo_runtime-2.0.0.dev28.dist-info/entry_points.txt,sha256=rOfs8R1oXFN_dLH9zAZ6ydkvr83mDajegc6NBIRsCMQ,318
47
+ oarepo_runtime-2.0.0.dev28.dist-info/licenses/LICENSE,sha256=h2uWz0OaB3EN-J1ImdGJZzc7yvfQjvHVYdUhQ-H7ypY,1064
48
+ oarepo_runtime-2.0.0.dev28.dist-info/RECORD,,