cmem-client 0.5.0__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.
Files changed (52) hide show
  1. cmem_client/__init__.py +13 -0
  2. cmem_client/auth_provider/__init__.py +14 -0
  3. cmem_client/auth_provider/abc.py +124 -0
  4. cmem_client/auth_provider/client_credentials.py +207 -0
  5. cmem_client/auth_provider/password.py +252 -0
  6. cmem_client/auth_provider/prefetched_token.py +153 -0
  7. cmem_client/client.py +485 -0
  8. cmem_client/components/__init__.py +10 -0
  9. cmem_client/components/graph_store.py +316 -0
  10. cmem_client/components/marketplace.py +179 -0
  11. cmem_client/components/sparql_wrapper.py +53 -0
  12. cmem_client/components/workspace.py +194 -0
  13. cmem_client/config.py +364 -0
  14. cmem_client/exceptions.py +82 -0
  15. cmem_client/logging_utils.py +49 -0
  16. cmem_client/models/__init__.py +16 -0
  17. cmem_client/models/access_condition.py +147 -0
  18. cmem_client/models/base.py +30 -0
  19. cmem_client/models/dataset.py +32 -0
  20. cmem_client/models/error.py +67 -0
  21. cmem_client/models/graph.py +26 -0
  22. cmem_client/models/item.py +143 -0
  23. cmem_client/models/logging_config.py +51 -0
  24. cmem_client/models/package.py +35 -0
  25. cmem_client/models/project.py +46 -0
  26. cmem_client/models/python_package.py +26 -0
  27. cmem_client/models/token.py +40 -0
  28. cmem_client/models/url.py +34 -0
  29. cmem_client/models/workflow.py +80 -0
  30. cmem_client/repositories/__init__.py +15 -0
  31. cmem_client/repositories/access_conditions.py +62 -0
  32. cmem_client/repositories/base/__init__.py +12 -0
  33. cmem_client/repositories/base/abc.py +138 -0
  34. cmem_client/repositories/base/paged_list.py +63 -0
  35. cmem_client/repositories/base/plain_list.py +39 -0
  36. cmem_client/repositories/base/task_search.py +70 -0
  37. cmem_client/repositories/datasets.py +36 -0
  38. cmem_client/repositories/graph_imports.py +93 -0
  39. cmem_client/repositories/graphs.py +458 -0
  40. cmem_client/repositories/marketplace_packages.py +486 -0
  41. cmem_client/repositories/projects.py +214 -0
  42. cmem_client/repositories/protocols/__init__.py +15 -0
  43. cmem_client/repositories/protocols/create_item.py +125 -0
  44. cmem_client/repositories/protocols/delete_item.py +95 -0
  45. cmem_client/repositories/protocols/export_item.py +114 -0
  46. cmem_client/repositories/protocols/import_item.py +141 -0
  47. cmem_client/repositories/python_packages.py +58 -0
  48. cmem_client/repositories/workflows.py +143 -0
  49. cmem_client-0.5.0.dist-info/METADATA +64 -0
  50. cmem_client-0.5.0.dist-info/RECORD +52 -0
  51. cmem_client-0.5.0.dist-info/WHEEL +4 -0
  52. cmem_client-0.5.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,486 @@
1
+ """Repository for eccenca marketplace package operations.
2
+
3
+ This module provides the PackagesRepository class for managing marketplace packages
4
+ in Corporate Memory.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import tempfile
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, ClassVar
13
+ from zipfile import BadZipFile, ZipFile
14
+
15
+ from eccenca_marketplace_client.dependencies import PythonPackageDependency, VocabularyDependency
16
+ from eccenca_marketplace_client.file_specs import GraphFileSpec, ProjectFileSpec
17
+ from eccenca_marketplace_client.ontology import (
18
+ NS_IRI,
19
+ get_data_graph_iri,
20
+ get_delete_query,
21
+ get_fetch_query,
22
+ get_ontology_graph,
23
+ )
24
+ from eccenca_marketplace_client.package_graph import PackageGraph
25
+ from eccenca_marketplace_client.package_version import PackageVersion
26
+
27
+ from cmem_client.exceptions import (
28
+ BaseError,
29
+ MarketplacePackagesExportError,
30
+ MarketplacePackagesImportError,
31
+ )
32
+ from cmem_client.models.python_package import PythonPackage
33
+ from cmem_client.repositories.graph_imports import GraphImport
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Sequence
37
+
38
+ from eccenca_marketplace_client.fields import PackageVersionIdentifier # noqa: TC002 # Pydantic needs this at runtime
39
+
40
+ from cmem_client.models.item import DirectoryImportItem, FileImportItem, ImportItem, ZipImportItem
41
+ from cmem_client.models.package import Package
42
+ from cmem_client.repositories.base.abc import Repository
43
+ from cmem_client.repositories.graphs import GraphImportConfig
44
+ from cmem_client.repositories.protocols.delete_item import DeleteConfig, DeleteItemProtocol
45
+ from cmem_client.repositories.protocols.export_item import ExportConfig, ExportItemProtocol
46
+ from cmem_client.repositories.protocols.import_item import ImportConfig, ImportItemProtocol
47
+
48
+ MAX_DEPENDENCY_DEPTH = 5
49
+
50
+
51
+ class MarketplacePackagesImportConfig(ImportConfig):
52
+ """Configuration for marketplace package import operations.
53
+
54
+ Attributes:
55
+ ignore_dependencies: If True, skips installation of package dependencies.
56
+ install_from_marketplace: If True, downloads packages from the marketplace server.
57
+ If False, loads packages from local filesystem.
58
+ package_version: Specific version to install. If None, installs the latest version.
59
+ dependency_level: Current recursion depth for dependency resolution. Used internally
60
+ to prevent infinite recursion. Should not be set manually.
61
+ use_cache: Weather to use the cache directory to look packages up which have already been downloaded.
62
+ To prevent the cache entirely, set this up in the marketplace component.
63
+ """
64
+
65
+ ignore_dependencies: bool = False
66
+ install_from_marketplace: bool = True
67
+ package_version: PackageVersionIdentifier | None = None
68
+ dependency_level: int = 0
69
+ use_cache: bool = True
70
+
71
+
72
+ class MarketplacePackagesExportConfig(ExportConfig):
73
+ """Package export configuration"""
74
+
75
+ export_as_zip: bool = True
76
+
77
+
78
+ class MarketplacePackagesDeleteConfig(DeleteConfig):
79
+ """Package deletion configuration"""
80
+
81
+ skip_missing_dependencies: bool = True
82
+ skip_missing_graphs: bool = True
83
+ skip_missing_projects: bool = True
84
+
85
+
86
+ class MarketplacePackagesRepository(Repository, ImportItemProtocol, ExportItemProtocol, DeleteItemProtocol):
87
+ """Repository for marketplace package operations."""
88
+
89
+ _dict: dict[str, Package]
90
+ _allowed_import_items: ClassVar[Sequence[type[ImportItem]]] = [FileImportItem, ZipImportItem, DirectoryImportItem]
91
+ _graph: PackageGraph
92
+
93
+ def fetch_data(self) -> None:
94
+ """Fetch installed packages from the config graph via SPARQL query.
95
+
96
+ Queries the config graph for all installed packages and their metadata.
97
+ """
98
+ result = self._client.store.sparql.query(get_fetch_query())
99
+
100
+ self._dict = {}
101
+ self._graph = PackageGraph()
102
+ for row in result:
103
+ manifest_json = str(row[0]) # type: ignore[index]
104
+ package_version = PackageVersion.from_json(manifest_json)
105
+ package_id = package_version.manifest.package_id
106
+ package = Package(package_version=package_version)
107
+ self._dict[package_id] = package
108
+ self._graph.add_package(package.package_version)
109
+
110
+ def _add_marketplace_vocabulary(self) -> None:
111
+ """Ensure the package vocabulary/ontology is imported into CMEM.
112
+
113
+ Checks if the marketplace ontology graph exists. If not, generates the
114
+ ontology from the marketplace client models and imports it as a vocabulary.
115
+ """
116
+ ontology_graph = get_ontology_graph()
117
+
118
+ self._client.graphs.fetch_data()
119
+
120
+ if NS_IRI not in self._client.graphs:
121
+ with tempfile.NamedTemporaryFile(mode="wb", suffix=".ttl", delete=False) as tmp_file:
122
+ tmp_path = Path(tmp_file.name)
123
+ tmp_file.write(ontology_graph.serialize(format="turtle").encode("utf-8"))
124
+
125
+ try:
126
+ self._client.graphs.import_item(
127
+ path=tmp_path, replace=False, key=None, configuration=GraphImportConfig(register_as_vocabulary=True)
128
+ )
129
+ finally:
130
+ tmp_path.unlink(missing_ok=True)
131
+
132
+ def _import_item( # noqa: C901, PLR0912
133
+ self,
134
+ path: Path | None = None,
135
+ replace: bool = False,
136
+ key: str | None = None,
137
+ configuration: MarketplacePackagesImportConfig | None = None,
138
+ ) -> str:
139
+ """Import a marketplace package from archive or marketplace server.
140
+
141
+ Extracts the package manifest from the archive, adds package metadata to the
142
+ config graph as RDF triples, then delegates to the appropriate repositories
143
+ based on the package.
144
+
145
+ If the import fails, all imported resources (graphs, projects, imports, packages)
146
+ are automatically rolled back to maintain consistency.
147
+
148
+ Args:
149
+ path: Path to the package archive file (.cpa) or directory. Required when
150
+ configuration.install_from_marketplace is False.
151
+ replace: Whether to replace an existing package with the same ID.
152
+ key: Package identifier for marketplace installation. Required when
153
+ configuration.install_from_marketplace is True.
154
+ configuration: Import configuration controlling source (marketplace vs. local),
155
+ dependency resolution, and version selection.
156
+
157
+ Returns:
158
+ The package_id of the successfully imported package.
159
+
160
+ Raises:
161
+ MarketplacePackagesImportError: If required parameters are missing, the package
162
+ already exists (when replace=False), or the import fails.
163
+ """
164
+ if configuration is None:
165
+ configuration = MarketplacePackagesImportConfig()
166
+
167
+ if path is None and not configuration.install_from_marketplace:
168
+ raise MarketplacePackagesImportError("No import path specified.")
169
+
170
+ package_version = self._get_package_version(configuration, key, path)
171
+
172
+ manifest = package_version.manifest
173
+
174
+ imported_graphs: list[str] = []
175
+ imported_projects: list[str] = []
176
+ imported_imports: list[str] = []
177
+ imported_python_packages: list[str] = []
178
+ imported_vocabulary_packages: list[str] = []
179
+
180
+ if manifest.package_id in self._dict:
181
+ if replace:
182
+ self.delete_item(manifest.package_id)
183
+ else:
184
+ raise MarketplacePackagesImportError("Package already imported. Try replace.")
185
+
186
+ if not configuration.ignore_dependencies:
187
+ # import python package dependencies first
188
+ for dependency in manifest.dependencies:
189
+ if isinstance(dependency, PythonPackageDependency):
190
+ self._client.python_packages.create_item(
191
+ item=PythonPackage(name=dependency.pypi_id), skip_if_existing=True
192
+ )
193
+ imported_python_packages.append(dependency.pypi_id)
194
+
195
+ for dependency in manifest.dependencies:
196
+ if isinstance(dependency, VocabularyDependency) and dependency.package_id not in self._dict:
197
+ if configuration.dependency_level >= MAX_DEPENDENCY_DEPTH:
198
+ self.logger.warning(
199
+ "Skipping dependency '%s' because the max depth of '%s' was reached.",
200
+ dependency,
201
+ MAX_DEPENDENCY_DEPTH,
202
+ )
203
+ continue
204
+ self._client.marketplace_packages.import_item(
205
+ key=dependency.package_id,
206
+ configuration=MarketplacePackagesImportConfig(
207
+ dependency_level=configuration.dependency_level + 1,
208
+ ),
209
+ skip_if_existing=True,
210
+ )
211
+ imported_vocabulary_packages.append(dependency.package_id)
212
+
213
+ try:
214
+ # import graphs first
215
+ for graph in manifest.get_graphs():
216
+ graph_iri = self._client.graphs.import_item(
217
+ key=str(graph.graph_iri),
218
+ path=package_version.get_file_path(graph.file_path),
219
+ replace=replace,
220
+ configuration=GraphImportConfig(register_as_vocabulary=graph.register_as_vocabulary),
221
+ )
222
+ imported_graphs.append(graph_iri)
223
+
224
+ # import projects after graphs
225
+ for project in manifest.get_projects():
226
+ project_id = self._client.projects.import_item(
227
+ key=str(project.project_id),
228
+ path=package_version.get_file_path(project.file_path),
229
+ replace=replace,
230
+ )
231
+ imported_projects.append(project_id)
232
+
233
+ # add graph imports
234
+ for graph in manifest.get_graphs():
235
+ to_graph = str(graph.graph_iri)
236
+ for from_graph in graph.import_into:
237
+ new_import = GraphImport(from_graph=str(from_graph), to_graph=to_graph)
238
+ self._client.graph_imports.create_item(item=new_import, skip_if_existing=True)
239
+ imported_imports.append(new_import.get_id())
240
+
241
+ self._add_package_triples(package_version)
242
+
243
+ # Rollback graphs + imports, projects, python packages
244
+ except (BaseError, BadZipFile) as error:
245
+ self._client.logger.exception("Failed to import package '%s'", package_version.manifest.package_id)
246
+ self._client.logger.warning("Deleting all imports from this package.")
247
+ for graph_import in imported_imports:
248
+ self._client.graph_imports.delete_item(key=graph_import, skip_if_missing=True)
249
+ for graph_iri in imported_graphs:
250
+ self._client.graphs.delete_item(key=graph_iri, skip_if_missing=True)
251
+ for project_id in imported_projects:
252
+ self._client.projects.delete_item(key=project_id, skip_if_missing=True)
253
+ for python_package_id in imported_python_packages:
254
+ self._client.python_packages.delete_item(key=python_package_id, skip_if_missing=True)
255
+ for vocabulary_id in imported_vocabulary_packages:
256
+ self._client.marketplace_packages.delete_item(key=vocabulary_id, skip_if_missing=True)
257
+ raise MarketplacePackagesImportError(f"Failed to import package ({error!s})") from error
258
+
259
+ self.fetch_data()
260
+ return str(manifest.package_id)
261
+
262
+ def _export_item( # noqa: C901
263
+ self,
264
+ key: str,
265
+ path: Path | None,
266
+ replace: bool = False,
267
+ configuration: MarketplacePackagesExportConfig | None = None,
268
+ ) -> Path:
269
+ """Export a marketplace package from eccenca Corporate Memory.
270
+
271
+ Args:
272
+ key: The package identifier.
273
+ path: The file path where the item should be exported. If None, a path
274
+ should be generated by the implementation.
275
+ replace: Whether to replace existing files at the target path.
276
+ configuration: Optional configuration for export behavior.
277
+
278
+ Returns:
279
+ The exported file path.
280
+ """
281
+ if configuration is None:
282
+ configuration = MarketplacePackagesExportConfig()
283
+
284
+ if key not in self._dict:
285
+ raise MarketplacePackagesExportError("Key is not a valid package identifier")
286
+
287
+ package = self._dict[key]
288
+ manifest_json_str = self._get_manifest_json(package.package_version.manifest.package_id)
289
+ manifest = PackageVersion.from_json(manifest_json_str).manifest
290
+
291
+ with tempfile.TemporaryDirectory() as tmpdir:
292
+ tmp_path = Path(tmpdir)
293
+ exported_files: list[tuple[Path, str]] = []
294
+
295
+ for file_spec in manifest.files:
296
+ if isinstance(file_spec, GraphFileSpec):
297
+ file_path = tmp_path / file_spec.file_path
298
+ file_path.parent.mkdir(parents=True, exist_ok=True)
299
+ exported_path = self._client.graphs.export_item(
300
+ key=str(file_spec.graph_iri),
301
+ path=file_path,
302
+ )
303
+ exported_files.append((exported_path, file_spec.file_path))
304
+
305
+ if isinstance(file_spec, ProjectFileSpec):
306
+ file_path = tmp_path / file_spec.file_path
307
+ file_path.parent.mkdir(parents=True, exist_ok=True)
308
+ exported_path = self._client.projects.export_item(
309
+ key=file_spec.project_id,
310
+ path=file_path,
311
+ )
312
+ exported_files.append((exported_path, file_spec.file_path))
313
+
314
+ manifest_path = tmp_path / "manifest.json"
315
+ manifest_path.write_text(manifest_json_str, encoding="utf-8")
316
+
317
+ if path is None:
318
+ path = Path.cwd() / (
319
+ f"{manifest.package_id}.zip" if configuration.export_as_zip else manifest.package_id
320
+ )
321
+
322
+ if path.exists() and not replace:
323
+ item_type = "File" if configuration.export_as_zip else "Directory"
324
+ raise MarketplacePackagesExportError(f"{item_type} {path} already exists and replace is False")
325
+
326
+ if configuration.export_as_zip:
327
+ with ZipFile(path, mode="w") as zipf:
328
+ for file_path, file_name in exported_files:
329
+ zipf.write(file_path, file_name)
330
+ zipf.write(manifest_path, "manifest.json")
331
+ else:
332
+ path.mkdir(parents=True, exist_ok=True)
333
+ for file_path, file_name in exported_files:
334
+ file_dest = path / file_name
335
+ file_dest.parent.mkdir(parents=True, exist_ok=True)
336
+ shutil.copy(file_path, file_dest)
337
+ shutil.copy(manifest_path, path / "manifest.json")
338
+
339
+ return path
340
+
341
+ def _delete_item(self, key: str, configuration: MarketplacePackagesDeleteConfig | None = None) -> None:
342
+ """Delete a package by its package_id.
343
+
344
+ This method need be extended for new FileSpecs.
345
+
346
+ Args:
347
+ key: The package_id of the package to delete.
348
+ configuration: Optional configuration to delete.
349
+ """
350
+ if not configuration:
351
+ # apply default configuration
352
+ configuration = MarketplacePackagesDeleteConfig()
353
+ package = self._dict[key]
354
+ manifest = package.package_version.manifest
355
+ package_id = manifest.package_id
356
+ package_iri = package.package_version.iri()
357
+
358
+ for dependency in manifest.dependencies:
359
+ if isinstance(dependency, PythonPackageDependency):
360
+ dependants = self._graph.get_python_dependants(dependency.pypi_id)
361
+ dependants.remove(package_id)
362
+ if len(dependants) > 0:
363
+ self._logger.warning(
364
+ f"Python plugin '{dependency.pypi_id}' can not be removed since it is in"
365
+ f"use by other packages: {', '.join(dependants)}"
366
+ )
367
+ continue
368
+ self._client.python_packages.delete_item(
369
+ key=dependency.pypi_id, skip_if_missing=configuration.skip_missing_dependencies
370
+ )
371
+
372
+ if isinstance(dependency, VocabularyDependency):
373
+ dependants = self._graph.get_package_dependants(dependency.package_id)
374
+ dependants.remove(package_id)
375
+ if len(dependants) > 0:
376
+ self._logger.warning(
377
+ f"Package '{dependency.package_id}' can not be removed since it is in"
378
+ f"use by other packages: {', '.join(dependants)}"
379
+ )
380
+ continue
381
+ self._client.marketplace_packages.delete_item(
382
+ key=dependency.package_id, skip_if_missing=configuration.skip_missing_dependencies
383
+ )
384
+
385
+ for project in manifest.get_projects():
386
+ self._client.projects.delete_item(
387
+ key=project.project_id, skip_if_missing=configuration.skip_missing_projects
388
+ )
389
+
390
+ for graph in manifest.get_graphs():
391
+ self._client.graphs.delete_item(key=str(graph.graph_iri), skip_if_missing=configuration.skip_missing_graphs)
392
+ for from_graph in graph.import_into:
393
+ deleted_import = GraphImport(from_graph=str(from_graph), to_graph=str(graph.graph_iri))
394
+ self._client.graph_imports.delete_item(key=deleted_import.get_id(), skip_if_missing=True)
395
+
396
+ self._client.store.sparql.update(get_delete_query(package_iri))
397
+
398
+ def _add_package_triples(self, package: PackageVersion) -> None:
399
+ """Add a package to the data config graph in the Corporate Memory instance.
400
+
401
+ Converts the package metadata to RDF triples and inserts them into the
402
+ config data graph using SPARQL UPDATE. Uses rdflib to programmatically
403
+ construct the RDF graph, avoiding manual string escaping.
404
+
405
+ Args:
406
+ package: The package metadata to add to the catalog.
407
+ """
408
+ self._add_marketplace_vocabulary()
409
+ g = package.to_rdf_graph()
410
+
411
+ triples = g.serialize(format="nt")
412
+
413
+ sparql_update = f"""
414
+ INSERT DATA {{
415
+ GRAPH <{get_data_graph_iri()}> {{
416
+ {triples}
417
+ }}
418
+ }}
419
+ """
420
+
421
+ self._client.store.sparql.update(sparql_update)
422
+
423
+ def _get_manifest_json(self, package_id: str) -> str:
424
+ """Fetch manifest JSON from config graph via SPARQL for a specific package_id."""
425
+ package_iri = f"{get_data_graph_iri()}{package_id}"
426
+ query = f"""
427
+ PREFIX eccm: <{NS_IRI}>
428
+ SELECT ?manifest_json
429
+ WHERE {{
430
+ GRAPH <{get_data_graph_iri()}> {{
431
+ <{package_iri}> eccm:property_manifest_json ?manifest_json .
432
+ }}
433
+ }}
434
+ """
435
+ response = self._client.http.get(
436
+ url=self._client.config.url_explore_api / "/proxy/default/sparql",
437
+ headers={"Accept": "application/sparql-results+json"},
438
+ params={"query": query},
439
+ )
440
+ response.raise_for_status()
441
+ bindings = response.json()["results"]["bindings"]
442
+ return str(bindings[0]["manifest_json"]["value"])
443
+
444
+ def _get_package_version(
445
+ self, configuration: MarketplacePackagesImportConfig, key: str | None, path: Path | None
446
+ ) -> PackageVersion:
447
+ """Load package version from marketplace server or local filesystem.
448
+
449
+ When installing from the marketplace server, the package is downloaded into the cache directory if path is
450
+ set to None. The method then returns the path to the cached file so it can be reused later.
451
+ If path is provided, the package is downloaded to that location instead.
452
+
453
+ When installing from the local filesystem, path must point directly to the package to be installed.
454
+
455
+ Args:
456
+ configuration: Import configuration specifying the package source and version.
457
+ key: Package identifier for marketplace downloads. Required when
458
+ configuration.install_from_marketplace is True.
459
+ path: Local filesystem path for non-marketplace installations. Required when
460
+ configuration.install_from_marketplace is False.
461
+
462
+ Returns:
463
+ The package version from marketplace server or local filesystem.
464
+
465
+ Raises:
466
+ MarketplacePackagesImportError: If path is None when loading from local filesystem.
467
+ """
468
+ if configuration.install_from_marketplace:
469
+ if key is None:
470
+ raise MarketplacePackagesImportError("No key was provided to download from marketplace.")
471
+
472
+ downloaded_package_path = self._client.marketplace.download_package(
473
+ path=path,
474
+ package_id=key,
475
+ package_version=configuration.package_version,
476
+ use_cache=configuration.use_cache,
477
+ )
478
+ package_version = PackageVersion.from_archive(downloaded_package_path)
479
+ else:
480
+ if path is None:
481
+ raise MarketplacePackagesImportError("No package path specified")
482
+ package_version = (
483
+ PackageVersion.from_directory(path) if path.is_dir() else PackageVersion.from_archive(path)
484
+ )
485
+
486
+ return package_version