cmem-client 0.5.0__tar.gz → 0.7.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {cmem_client-0.5.0 → cmem_client-0.7.1}/PKG-INFO +2 -2
  2. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/client.py +16 -0
  3. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/components/marketplace.py +9 -0
  4. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/exceptions.py +12 -0
  5. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/models/package.py +21 -1
  6. cmem_client-0.7.1/cmem_client/models/resource.py +25 -0
  7. cmem_client-0.7.1/cmem_client/repositories/files.py +181 -0
  8. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/repositories/graphs.py +1 -0
  9. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/repositories/marketplace_packages.py +122 -14
  10. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/repositories/protocols/delete_item.py +1 -2
  11. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/repositories/python_packages.py +11 -1
  12. {cmem_client-0.5.0 → cmem_client-0.7.1}/pyproject.toml +3 -3
  13. {cmem_client-0.5.0 → cmem_client-0.7.1}/LICENSE +0 -0
  14. {cmem_client-0.5.0 → cmem_client-0.7.1}/README.md +0 -0
  15. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/__init__.py +0 -0
  16. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/auth_provider/__init__.py +0 -0
  17. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/auth_provider/abc.py +0 -0
  18. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/auth_provider/client_credentials.py +0 -0
  19. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/auth_provider/password.py +0 -0
  20. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/auth_provider/prefetched_token.py +0 -0
  21. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/components/__init__.py +0 -0
  22. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/components/graph_store.py +0 -0
  23. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/components/sparql_wrapper.py +0 -0
  24. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/components/workspace.py +0 -0
  25. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/config.py +0 -0
  26. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/logging_utils.py +0 -0
  27. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/models/__init__.py +0 -0
  28. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/models/access_condition.py +0 -0
  29. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/models/base.py +0 -0
  30. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/models/dataset.py +0 -0
  31. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/models/error.py +0 -0
  32. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/models/graph.py +0 -0
  33. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/models/item.py +0 -0
  34. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/models/logging_config.py +0 -0
  35. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/models/project.py +0 -0
  36. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/models/python_package.py +0 -0
  37. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/models/token.py +0 -0
  38. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/models/url.py +0 -0
  39. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/models/workflow.py +0 -0
  40. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/repositories/__init__.py +0 -0
  41. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/repositories/access_conditions.py +0 -0
  42. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/repositories/base/__init__.py +0 -0
  43. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/repositories/base/abc.py +0 -0
  44. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/repositories/base/paged_list.py +0 -0
  45. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/repositories/base/plain_list.py +0 -0
  46. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/repositories/base/task_search.py +0 -0
  47. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/repositories/datasets.py +0 -0
  48. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/repositories/graph_imports.py +0 -0
  49. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/repositories/projects.py +0 -0
  50. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/repositories/protocols/__init__.py +0 -0
  51. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/repositories/protocols/create_item.py +0 -0
  52. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/repositories/protocols/export_item.py +0 -0
  53. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/repositories/protocols/import_item.py +0 -0
  54. {cmem_client-0.5.0 → cmem_client-0.7.1}/cmem_client/repositories/workflows.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cmem-client
3
- Version: 0.5.0
3
+ Version: 0.7.1
4
4
  Summary: Next generation eccenca Corporate Memory client library.
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3
14
14
  Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Programming Language :: Python :: 3.14
16
16
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
- Requires-Dist: eccenca-marketplace-client (>=0.5.0,<0.6.0)
17
+ Requires-Dist: eccenca-marketplace-client (>=0.7.0,<0.8.0)
18
18
  Requires-Dist: httpx (>=0.27.0,<0.28.0)
19
19
  Requires-Dist: pydantic (>=2.8.2,<3.0.0)
20
20
  Requires-Dist: pyjwt (>=2.8.0,<3.0.0)
@@ -40,6 +40,7 @@ from cmem_client.config import Config
40
40
  from cmem_client.exceptions import ClientNoAuthProviderError
41
41
  from cmem_client.logging_utils import install_trace_logger
42
42
  from cmem_client.models.logging_config import LoggingConfig
43
+ from cmem_client.repositories.files import FilesRepository
43
44
  from cmem_client.repositories.graph_imports import GraphImportsRepository
44
45
  from cmem_client.repositories.graphs import GraphsRepository
45
46
  from cmem_client.repositories.marketplace_packages import MarketplacePackagesRepository
@@ -113,6 +114,9 @@ class Client:
113
114
  _workflows: WorkflowsRepository
114
115
  """WorkflowsRepository object for workflow operations."""
115
116
 
117
+ _files: FilesRepository
118
+ """FilesRepository object for file operations."""
119
+
116
120
  def __init__(
117
121
  self,
118
122
  config: Config,
@@ -483,3 +487,15 @@ class Client:
483
487
  except AttributeError:
484
488
  self._workflows = WorkflowsRepository(client=self)
485
489
  return self._workflows
490
+
491
+ @property
492
+ def files(self) -> FilesRepository:
493
+ """Get the files repository for managing files
494
+
495
+ Returns: The files repository instance, created lazy on first access.
496
+ """
497
+ try:
498
+ return self._files
499
+ except AttributeError:
500
+ self._files = FilesRepository(client=self)
501
+ return self._files
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
  import logging
15
15
  from typing import TYPE_CHECKING
16
16
 
17
+ from eccenca_marketplace_client.models.responses import PackageMetadata
17
18
  from httpx import HTTPError
18
19
  from xdg_base_dirs import xdg_cache_home
19
20
 
@@ -158,6 +159,14 @@ class Marketplace:
158
159
 
159
160
  return sorted([version.get("package_version") for version in versions])
160
161
 
162
+ def get_available_packages(self) -> list[PackageMetadata]:
163
+ """Get the available packages from the marketplace server."""
164
+ url = self.marketplace_url / "api/packages"
165
+ response = self._client.http.get(url=url)
166
+ response.raise_for_status()
167
+ packages_data = response.json()
168
+ return [PackageMetadata.model_validate(item) for item in packages_data]
169
+
161
170
  @property
162
171
  def marketplace_url(self) -> HttpUrl:
163
172
  """Get the marketplace server URL."""
@@ -80,3 +80,15 @@ class MarketplacePackagesDeleteError(RepositoryModificationError):
80
80
 
81
81
  class MarketplacePackagesExportError(BaseError):
82
82
  """Exception raised when a marketplace packages export fails."""
83
+
84
+
85
+ class FilesImportError(RepositoryModificationError):
86
+ """Exception raised when a file import fails."""
87
+
88
+
89
+ class FilesDeleteError(RepositoryModificationError):
90
+ """Exception raised when a file import fails."""
91
+
92
+
93
+ class FilesExportError(RepositoryModificationError):
94
+ """Exception raised when a file export fails."""
@@ -1,7 +1,9 @@
1
1
  """Marketplace package models."""
2
2
 
3
+ from datetime import UTC, datetime
4
+
3
5
  from eccenca_marketplace_client.package_version import PackageVersion
4
- from pydantic import ConfigDict
6
+ from pydantic import BaseModel, ConfigDict, Field
5
7
 
6
8
  from cmem_client.models.base import Model, ReadRepositoryItem
7
9
 
@@ -33,3 +35,21 @@ class Package(ReadRepositoryItem):
33
35
  The package_id which uniquely identifies this package.
34
36
  """
35
37
  return str(self.package_version.manifest.package_id)
38
+
39
+
40
+ class PackageInstallationMetadata(BaseModel):
41
+ """Metadata about how and when a marketplace package was installed.
42
+
43
+ This metadata is stored as JSON in the RDF graph and used to determine
44
+ whether packages can be automatically removed when they are dependencies.
45
+ """
46
+
47
+ dependency_level: int = Field(
48
+ ge=0, description="Dependency depth (0 for direct installs, >0 for dependencies", default=0
49
+ )
50
+ installed_at: datetime = Field(description="Timestamp when the package was installed", default=datetime.now(tz=UTC))
51
+
52
+ @property
53
+ def is_direct_installed(self) -> bool:
54
+ """Indicates whether this package was installed directly"""
55
+ return self.dependency_level == 0
@@ -0,0 +1,25 @@
1
+ """A file resource model"""
2
+
3
+ from pydantic import Field
4
+
5
+ from cmem_client.models.base import Model, ReadRepositoryItem
6
+
7
+
8
+ class ResourceResponse(Model):
9
+ """API response model for a file resource"""
10
+
11
+ name: str = Field(description="Resource name")
12
+ full_path: str = Field(description="Path of the resource", alias="fullPath")
13
+ modified: str = Field(description="Resource last modified time")
14
+ size: int = Field(description="Resource size")
15
+
16
+
17
+ class Resource(Model, ReadRepositoryItem):
18
+ """A file resource."""
19
+
20
+ file_id: str
21
+ project_id: str
22
+
23
+ def get_id(self) -> str:
24
+ """Get the resource ID in format 'project_id:file_id'"""
25
+ return f"{self.project_id}:{self.file_id}"
@@ -0,0 +1,181 @@
1
+ """Files Repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, ClassVar
7
+
8
+ from httpx import HTTPError
9
+ from pydantic import TypeAdapter
10
+
11
+ from cmem_client.repositories.protocols.export_item import ExportConfig, ExportItemProtocol
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Sequence
15
+
16
+ from cmem_client.client import Client
17
+
18
+ from cmem_client.exceptions import FilesDeleteError, FilesExportError, FilesImportError
19
+ from cmem_client.models.item import FileImportItem, ImportItem
20
+ from cmem_client.models.resource import Resource, ResourceResponse
21
+ from cmem_client.repositories.base.plain_list import PlainListRepository
22
+ from cmem_client.repositories.protocols.delete_item import DeleteConfig, DeleteItemProtocol
23
+ from cmem_client.repositories.protocols.import_item import ImportConfig, ImportItemProtocol
24
+
25
+
26
+ class FilesImportConfig(ImportConfig):
27
+ """Files Import Configuration."""
28
+
29
+
30
+ class FilesExportConfig(ExportConfig):
31
+ """Files Export Configuration."""
32
+
33
+
34
+ class FilesDeleteConfig(DeleteConfig):
35
+ """Files Delete Configuration."""
36
+
37
+
38
+ class FilesRepository(PlainListRepository, ImportItemProtocol, DeleteItemProtocol, ExportItemProtocol):
39
+ """Repository for files"""
40
+
41
+ _client: Client
42
+ _dict: dict[str, Resource]
43
+ _allowed_import_items: ClassVar[Sequence[type[ImportItem]]] = [FileImportItem]
44
+
45
+ def fetch_data(self) -> None:
46
+ """Fetch all file resources from all projects."""
47
+ self._dict = {}
48
+ for project_id in self._client.projects:
49
+ for resource_data in self._get_resources(project_id):
50
+ resource = Resource(file_id=resource_data.full_path, project_id=project_id)
51
+ self._dict[resource.get_id()] = resource
52
+
53
+ def _get_resources(self, project_id: str) -> list[ResourceResponse]:
54
+ """GET retrieve list of resources."""
55
+ url = self._client.config.url_build_api / "workspace/projects" / project_id / "resources"
56
+ response = self._client.http.get(url=url)
57
+ response.raise_for_status()
58
+ adapter = TypeAdapter(list[ResourceResponse])
59
+ return adapter.validate_json(response.content)
60
+
61
+ def _import_item(
62
+ self,
63
+ path: Path | None = None,
64
+ replace: bool = False,
65
+ key: str | None = None,
66
+ configuration: FilesImportConfig | None = None,
67
+ ) -> str:
68
+ """Import a file to a specific project.
69
+
70
+ Args:
71
+ path: Local file path to upload
72
+ replace: Whether to replace existing file
73
+ key: Composite key in format 'project_id:file_path'
74
+ configuration: Import configuration
75
+
76
+ Returns:
77
+ Composite key in format 'project_id:file_path'
78
+ """
79
+ _ = configuration
80
+
81
+ if path is None:
82
+ raise FilesImportError("Path to file necessary.")
83
+
84
+ if key is None:
85
+ raise FilesImportError("Key in format 'project_id:file_path' is required.")
86
+
87
+ if ":" not in key:
88
+ raise FilesImportError(f"Invalid key format. Expected 'project_id:file_path', got '{key}'")
89
+
90
+ project_id, resource_name = key.split(":", 1)
91
+
92
+ if not replace and key in self._dict:
93
+ raise FilesImportError(f"File {key} already exists. Try replace.")
94
+
95
+ url = self._client.config.url_build_api / "workspace/projects" / project_id / "files"
96
+
97
+ with path.open("rb") as file:
98
+ try:
99
+ response = self._client.http.put(url, params={"path": resource_name}, content=file)
100
+ response.raise_for_status()
101
+ except HTTPError as e:
102
+ raise FilesImportError(f"Could not upload file '{resource_name}' to project '{project_id}'.") from e
103
+
104
+ self.fetch_data()
105
+ return key
106
+
107
+ def _delete_item(self, key: str, configuration: FilesDeleteConfig | None = None) -> None:
108
+ """Delete a file.
109
+
110
+ Args:
111
+ key: Composite key in format 'project_id:file_path'
112
+ configuration: Delete configuration
113
+ """
114
+ _ = configuration
115
+
116
+ if ":" not in key:
117
+ raise FilesDeleteError(f"Invalid key format. Expected 'project_id:file_path', got '{key}'")
118
+
119
+ project_id, file_path = key.split(":", 1)
120
+
121
+ url = self._client.config.url_build_api / "workspace/projects" / project_id / "files"
122
+ try:
123
+ response = self._client.http.delete(url=url, params={"path": file_path})
124
+ response.raise_for_status()
125
+ except HTTPError as e:
126
+ raise FilesDeleteError(f"Could not delete file '{file_path}' from project '{project_id}'.") from e
127
+
128
+ def _export_item(
129
+ self,
130
+ key: str,
131
+ path: Path | None,
132
+ replace: bool = False,
133
+ configuration: FilesExportConfig | None = None,
134
+ ) -> Path:
135
+ """Export a file from a specific project.
136
+
137
+ Args:
138
+ key: Composite key in format 'project_id:file_path'
139
+ path: Target export path
140
+ replace: Whether to replace existing file
141
+ configuration: Export configuration
142
+ """
143
+ _ = configuration
144
+
145
+ if key is None:
146
+ raise FilesExportError("No resource key specified.")
147
+
148
+ if ":" not in key:
149
+ raise FilesExportError(f"Invalid key format. Expected 'project_id:file_path', got '{key}'")
150
+
151
+ project_id, file_path = key.split(":", 1)
152
+
153
+ if path is None:
154
+ target_path = Path(file_path)
155
+ elif path.is_dir():
156
+ target_path = path / file_path
157
+ else:
158
+ target_path = path
159
+
160
+ if target_path.exists() and not replace:
161
+ raise FilesExportError(f"File '{target_path}' already exists. Try replace=True.")
162
+
163
+ url = self._client.config.url_build_api / "workspace/projects" / project_id / "files"
164
+
165
+ try:
166
+ with self._client.http.stream(
167
+ "GET",
168
+ url,
169
+ params={"path": file_path},
170
+ ) as response:
171
+ response.raise_for_status()
172
+
173
+ target_path.parent.mkdir(parents=True, exist_ok=True)
174
+ with target_path.open("wb") as file:
175
+ for chunk in response.iter_bytes():
176
+ file.write(chunk)
177
+
178
+ except HTTPError as e:
179
+ raise FilesExportError(f"Could not export file '{file_path}' from project '{project_id}'.") from e
180
+
181
+ return target_path
@@ -209,6 +209,7 @@ class GraphsRepository(PlainListRepository, DeleteItemProtocol, ImportItemProtoc
209
209
  params = {"graph": key}
210
210
  response = self._client.http.delete(url=url, params=params)
211
211
  response.raise_for_status()
212
+ self._reload_vocabularies(key)
212
213
 
213
214
  def guess_file_type(self, path: Path) -> GraphFileSerialization:
214
215
  """Guess the RDF serialization format from a file path.
@@ -8,14 +8,16 @@ from __future__ import annotations
8
8
 
9
9
  import shutil
10
10
  import tempfile
11
+ from datetime import UTC, datetime
11
12
  from pathlib import Path
12
13
  from typing import TYPE_CHECKING, ClassVar
13
14
  from zipfile import BadZipFile, ZipFile
14
15
 
15
- from eccenca_marketplace_client.dependencies import PythonPackageDependency, VocabularyDependency
16
- from eccenca_marketplace_client.file_specs import GraphFileSpec, ProjectFileSpec
16
+ from eccenca_marketplace_client.models.dependencies import MarketplacePackageDependency, PythonPackageDependency
17
+ from eccenca_marketplace_client.models.files import GraphFileSpec, ImageFileSpec, ProjectFileSpec, TextFileSpec
17
18
  from eccenca_marketplace_client.ontology import (
18
19
  NS_IRI,
20
+ NS_PREFIX,
19
21
  get_data_graph_iri,
20
22
  get_delete_query,
21
23
  get_fetch_query,
@@ -23,22 +25,28 @@ from eccenca_marketplace_client.ontology import (
23
25
  )
24
26
  from eccenca_marketplace_client.package_graph import PackageGraph
25
27
  from eccenca_marketplace_client.package_version import PackageVersion
28
+ from rdflib import Literal, Namespace, Variable
26
29
 
27
30
  from cmem_client.exceptions import (
28
31
  BaseError,
29
32
  MarketplacePackagesExportError,
30
33
  MarketplacePackagesImportError,
31
34
  )
35
+ from cmem_client.models.project import Project, ProjectMetaData
32
36
  from cmem_client.models.python_package import PythonPackage
37
+ from cmem_client.repositories.files import FilesImportConfig
33
38
  from cmem_client.repositories.graph_imports import GraphImport
34
39
 
35
40
  if TYPE_CHECKING:
36
41
  from collections.abc import Sequence
37
42
 
38
- from eccenca_marketplace_client.fields import PackageVersionIdentifier # noqa: TC002 # Pydantic needs this at runtime
43
+ from eccenca_marketplace_client.fields import ( # Pydantic needs this at runtime
44
+ MANIFEST_NAME,
45
+ PackageVersionIdentifier,
46
+ )
39
47
 
40
48
  from cmem_client.models.item import DirectoryImportItem, FileImportItem, ImportItem, ZipImportItem
41
- from cmem_client.models.package import Package
49
+ from cmem_client.models.package import Package, PackageInstallationMetadata
42
50
  from cmem_client.repositories.base.abc import Repository
43
51
  from cmem_client.repositories.graphs import GraphImportConfig
44
52
  from cmem_client.repositories.protocols.delete_item import DeleteConfig, DeleteItemProtocol
@@ -46,6 +54,19 @@ from cmem_client.repositories.protocols.export_item import ExportConfig, ExportI
46
54
  from cmem_client.repositories.protocols.import_item import ImportConfig, ImportItemProtocol
47
55
 
48
56
  MAX_DEPENDENCY_DEPTH = 5
57
+ MARKETPLACE_PROJECT_ID = "marketplace-packages"
58
+
59
+
60
+ def get_installation_metadata_query(package_iri: str) -> str:
61
+ """Get the query for the installation metadata of the package."""
62
+ return f"""
63
+ PREFIX {NS_PREFIX}: <{NS_IRI}>
64
+
65
+ SELECT ?installation_metadata
66
+ WHERE {{
67
+ <{get_data_graph_iri()}{package_iri}> {NS_PREFIX}:installationMetadata ?installation_metadata .
68
+ }}
69
+ """
49
70
 
50
71
 
51
72
  class MarketplacePackagesImportConfig(ImportConfig):
@@ -123,13 +144,16 @@ class MarketplacePackagesRepository(Repository, ImportItemProtocol, ExportItemPr
123
144
  tmp_file.write(ontology_graph.serialize(format="turtle").encode("utf-8"))
124
145
 
125
146
  try:
147
+ get_ontology_graph()
126
148
  self._client.graphs.import_item(
127
- path=tmp_path, replace=False, key=None, configuration=GraphImportConfig(register_as_vocabulary=True)
149
+ path=tmp_path,
150
+ replace=False,
151
+ key=NS_IRI,
128
152
  )
129
153
  finally:
130
154
  tmp_path.unlink(missing_ok=True)
131
155
 
132
- def _import_item( # noqa: C901, PLR0912
156
+ def _import_item( # noqa: C901, PLR0912, PLR0915
133
157
  self,
134
158
  path: Path | None = None,
135
159
  replace: bool = False,
@@ -176,6 +200,7 @@ class MarketplacePackagesRepository(Repository, ImportItemProtocol, ExportItemPr
176
200
  imported_imports: list[str] = []
177
201
  imported_python_packages: list[str] = []
178
202
  imported_vocabulary_packages: list[str] = []
203
+ imported_files: list[str] = []
179
204
 
180
205
  if manifest.package_id in self._dict:
181
206
  if replace:
@@ -183,6 +208,8 @@ class MarketplacePackagesRepository(Repository, ImportItemProtocol, ExportItemPr
183
208
  else:
184
209
  raise MarketplacePackagesImportError("Package already imported. Try replace.")
185
210
 
211
+ self._create_assets_project()
212
+
186
213
  if not configuration.ignore_dependencies:
187
214
  # import python package dependencies first
188
215
  for dependency in manifest.dependencies:
@@ -193,7 +220,7 @@ class MarketplacePackagesRepository(Repository, ImportItemProtocol, ExportItemPr
193
220
  imported_python_packages.append(dependency.pypi_id)
194
221
 
195
222
  for dependency in manifest.dependencies:
196
- if isinstance(dependency, VocabularyDependency) and dependency.package_id not in self._dict:
223
+ if isinstance(dependency, MarketplacePackageDependency) and dependency.package_id not in self._dict:
197
224
  if configuration.dependency_level >= MAX_DEPENDENCY_DEPTH:
198
225
  self.logger.warning(
199
226
  "Skipping dependency '%s' because the max depth of '%s' was reached.",
@@ -238,7 +265,18 @@ class MarketplacePackagesRepository(Repository, ImportItemProtocol, ExportItemPr
238
265
  self._client.graph_imports.create_item(item=new_import, skip_if_existing=True)
239
266
  imported_imports.append(new_import.get_id())
240
267
 
241
- self._add_package_triples(package_version)
268
+ for file in manifest.files:
269
+ file_resource_path = f"{manifest.package_id}/{file.file_path}"
270
+ composite_key = f"{MARKETPLACE_PROJECT_ID}:{file_resource_path}"
271
+ self._client.files.import_item(
272
+ path=package_version.get_file_path(file.file_path),
273
+ key=composite_key,
274
+ replace=True,
275
+ configuration=FilesImportConfig(use_archive_handler=False),
276
+ )
277
+ imported_files.append(composite_key)
278
+
279
+ self._add_package_triples(package_version, configuration)
242
280
 
243
281
  # Rollback graphs + imports, projects, python packages
244
282
  except (BaseError, BadZipFile) as error:
@@ -254,6 +292,10 @@ class MarketplacePackagesRepository(Repository, ImportItemProtocol, ExportItemPr
254
292
  self._client.python_packages.delete_item(key=python_package_id, skip_if_missing=True)
255
293
  for vocabulary_id in imported_vocabulary_packages:
256
294
  self._client.marketplace_packages.delete_item(key=vocabulary_id, skip_if_missing=True)
295
+ for file in imported_files:
296
+ self._client.files.delete_item(key=file, skip_if_missing=True)
297
+ if len(self._dict) == 0:
298
+ self._client.projects.delete_item(MARKETPLACE_PROJECT_ID, skip_if_missing=True)
257
299
  raise MarketplacePackagesImportError(f"Failed to import package ({error!s})") from error
258
300
 
259
301
  self.fetch_data()
@@ -311,7 +353,15 @@ class MarketplacePackagesRepository(Repository, ImportItemProtocol, ExportItemPr
311
353
  )
312
354
  exported_files.append((exported_path, file_spec.file_path))
313
355
 
314
- manifest_path = tmp_path / "manifest.json"
356
+ if isinstance(file_spec, (ImageFileSpec, TextFileSpec)):
357
+ file_path = tmp_path / file_spec.file_path
358
+ file_path.parent.mkdir(parents=True, exist_ok=True)
359
+ file_resource_path = f"{manifest.package_id}/{file_spec.file_path}"
360
+ composite_key = f"{MARKETPLACE_PROJECT_ID}:{file_resource_path}"
361
+ self._client.files.export_item(key=composite_key, path=file_path)
362
+ exported_files.append((file_path, file_spec.file_path))
363
+
364
+ manifest_path = tmp_path / MANIFEST_NAME
315
365
  manifest_path.write_text(manifest_json_str, encoding="utf-8")
316
366
 
317
367
  if path is None:
@@ -327,18 +377,18 @@ class MarketplacePackagesRepository(Repository, ImportItemProtocol, ExportItemPr
327
377
  with ZipFile(path, mode="w") as zipf:
328
378
  for file_path, file_name in exported_files:
329
379
  zipf.write(file_path, file_name)
330
- zipf.write(manifest_path, "manifest.json")
380
+ zipf.write(manifest_path, MANIFEST_NAME)
331
381
  else:
332
382
  path.mkdir(parents=True, exist_ok=True)
333
383
  for file_path, file_name in exported_files:
334
384
  file_dest = path / file_name
335
385
  file_dest.parent.mkdir(parents=True, exist_ok=True)
336
386
  shutil.copy(file_path, file_dest)
337
- shutil.copy(manifest_path, path / "manifest.json")
387
+ shutil.copy(manifest_path, path / MANIFEST_NAME)
338
388
 
339
389
  return path
340
390
 
341
- def _delete_item(self, key: str, configuration: MarketplacePackagesDeleteConfig | None = None) -> None:
391
+ def _delete_item(self, key: str, configuration: MarketplacePackagesDeleteConfig | None = None) -> None: # noqa: C901, PLR0912
342
392
  """Delete a package by its package_id.
343
393
 
344
394
  This method need be extended for new FileSpecs.
@@ -369,7 +419,7 @@ class MarketplacePackagesRepository(Repository, ImportItemProtocol, ExportItemPr
369
419
  key=dependency.pypi_id, skip_if_missing=configuration.skip_missing_dependencies
370
420
  )
371
421
 
372
- if isinstance(dependency, VocabularyDependency):
422
+ if isinstance(dependency, MarketplacePackageDependency):
373
423
  dependants = self._graph.get_package_dependants(dependency.package_id)
374
424
  dependants.remove(package_id)
375
425
  if len(dependants) > 0:
@@ -378,6 +428,21 @@ class MarketplacePackagesRepository(Repository, ImportItemProtocol, ExportItemPr
378
428
  f"use by other packages: {', '.join(dependants)}"
379
429
  )
380
430
  continue
431
+
432
+ # check if to be deleted package was directly installed prior
433
+ install_metadata_bindings = self._client.store.sparql.query(
434
+ get_installation_metadata_query(dependency.package_id)
435
+ ).bindings
436
+ if install_metadata_bindings:
437
+ metadata_json_str = str(install_metadata_bindings[0][Variable("installation_metadata")])
438
+ metadata = PackageInstallationMetadata.model_validate_json(metadata_json_str)
439
+ if metadata.is_direct_installed:
440
+ self._logger.warning(
441
+ f"Marketplace package '{dependency.package_id}' can not be removed since "
442
+ f"it was installed directly, not just via dependencies'"
443
+ )
444
+ continue
445
+
381
446
  self._client.marketplace_packages.delete_item(
382
447
  key=dependency.package_id, skip_if_missing=configuration.skip_missing_dependencies
383
448
  )
@@ -393,9 +458,19 @@ class MarketplacePackagesRepository(Repository, ImportItemProtocol, ExportItemPr
393
458
  deleted_import = GraphImport(from_graph=str(from_graph), to_graph=str(graph.graph_iri))
394
459
  self._client.graph_imports.delete_item(key=deleted_import.get_id(), skip_if_missing=True)
395
460
 
461
+ for file in manifest.files:
462
+ file_resource_path = f"{manifest.package_id}/{file.file_path}"
463
+ composite_key = f"{MARKETPLACE_PROJECT_ID}:{file_resource_path}"
464
+ self._client.files.delete_item(key=composite_key, skip_if_missing=True)
465
+
396
466
  self._client.store.sparql.update(get_delete_query(package_iri))
397
467
 
398
- def _add_package_triples(self, package: PackageVersion) -> None:
468
+ # Remaining package is removed by the protocol, marketplace vocabulary and project can be deleted safely
469
+ if len(self._dict) == 1 and key in self._dict:
470
+ self._client.graphs.delete_item(NS_IRI, skip_if_missing=True)
471
+ self._client.projects.delete_item(MARKETPLACE_PROJECT_ID, skip_if_missing=True)
472
+
473
+ def _add_package_triples(self, package: PackageVersion, config: MarketplacePackagesImportConfig) -> None:
399
474
  """Add a package to the data config graph in the Corporate Memory instance.
400
475
 
401
476
  Converts the package metadata to RDF triples and inserts them into the
@@ -404,10 +479,22 @@ class MarketplacePackagesRepository(Repository, ImportItemProtocol, ExportItemPr
404
479
 
405
480
  Args:
406
481
  package: The package metadata to add to the catalog.
482
+ config: The config object to determine if package was installed directly or
483
+ as a dependency and more metadata.
407
484
  """
408
485
  self._add_marketplace_vocabulary()
409
486
  g = package.to_rdf_graph()
410
487
 
488
+ eccm = Namespace(NS_IRI)
489
+
490
+ metadata = PackageInstallationMetadata(
491
+ dependency_level=config.dependency_level,
492
+ installed_at=datetime.now(tz=UTC),
493
+ )
494
+
495
+ json_literal = Literal(metadata.model_dump_json())
496
+ g.add((package.iri(), eccm.installationMetadata, json_literal))
497
+
411
498
  triples = g.serialize(format="nt")
412
499
 
413
500
  sparql_update = f"""
@@ -484,3 +571,24 @@ class MarketplacePackagesRepository(Repository, ImportItemProtocol, ExportItemPr
484
571
  )
485
572
 
486
573
  return package_version
574
+
575
+ def _create_assets_project(self) -> None:
576
+ """Create the assets project for the marketplace."""
577
+ if MARKETPLACE_PROJECT_ID in self._client.projects:
578
+ return
579
+ mp_assets = Project(
580
+ name=MARKETPLACE_PROJECT_ID,
581
+ metaData=ProjectMetaData(
582
+ label="Marketplace Packages",
583
+ description="""This project contains all files that were installed via Marketplace packages.
584
+
585
+ This project was created when you installed your first package.
586
+ It will be deleted after the last package is uninstalled.
587
+
588
+ For more information about marketplace packages, have a look at
589
+ [documentation.eccenca.com](https://go.eccenca.com/feature/marketplace-packages).
590
+ """,
591
+ ),
592
+ )
593
+ self._client.projects.create_item(mp_assets)
594
+ self._client.projects.fetch_data()
@@ -67,14 +67,13 @@ class DeleteItemProtocol(Protocol[ItemType, DeleteItemConfig_contra]):
67
67
  RepositoryModificationError: if an error occurs while creating the item
68
68
  HTTPError: for any other http error
69
69
  """
70
- _ = configuration
71
70
  if key not in self._dict:
72
71
  if not skip_if_missing:
73
72
  raise RepositoryModificationError(f"Repository item '{key}' does not exists.")
74
73
  self.logger.info("Item '%s' does not exists, therefore not deleting.", key)
75
74
  return
76
75
  try:
77
- self._delete_item(key=key)
76
+ self._delete_item(key=key, configuration=configuration)
78
77
  except HTTPError as error:
79
78
  raise RepositoryModificationError(f"Error on deleting repository item '{key}'.") from error
80
79
 
@@ -38,7 +38,10 @@ class PythonPackagesRepository(PlainListRepository, DeleteItemProtocol, CreateIt
38
38
  """Install a Python package."""
39
39
  _ = configuration
40
40
  url = self._url("/api/python/installPackageByName")
41
- return self._client.http.post(url, params={"name": item.name})
41
+ response = self._client.http.post(url, params={"name": item.name})
42
+ response.raise_for_status()
43
+ self._update_plugins()
44
+ return response
42
45
 
43
46
  def _delete_item(self, key: str, configuration: PythonPackagesDeleteConfig | None = None) -> None:
44
47
  """Delete item from repository."""
@@ -46,6 +49,7 @@ class PythonPackagesRepository(PlainListRepository, DeleteItemProtocol, CreateIt
46
49
  url = self._url("/api/python/uninstallPackage")
47
50
  response = self._client.http.post(url, params={"name": key})
48
51
  response.raise_for_status()
52
+ self._update_plugins()
49
53
 
50
54
  def delete_all(self) -> None:
51
55
  """Delete all items from the repository
@@ -56,3 +60,9 @@ class PythonPackagesRepository(PlainListRepository, DeleteItemProtocol, CreateIt
56
60
  self._delete_item("--all")
57
61
  if hasattr(self, "fetch_data"):
58
62
  self.fetch_data()
63
+
64
+ def _update_plugins(self) -> None:
65
+ """Update the python packages in CMEM"""
66
+ url = self._url("/api/python/updatePlugins")
67
+ response = self._client.http.get(url)
68
+ response.raise_for_status()
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "cmem-client"
3
- version = "0.5.0"
3
+ version = "0.7.1"
4
4
  license = "Apache-2.0"
5
5
  description = "Next generation eccenca Corporate Memory client library."
6
6
  authors = ["eccenca GmbH <cmempy-developer@eccenca.com>"]
@@ -19,7 +19,7 @@ pydantic = "^2.8.2"
19
19
  httpx = "^0.27.0"
20
20
  pyjwt = "^2.8.0"
21
21
  rdflib = "^7.2.1"
22
- eccenca-marketplace-client = "^0.5.0"
22
+ eccenca-marketplace-client = "^0.7.0"
23
23
  # eccenca-marketplace-client = {path = "eccenca-marketplace-client", develop = true}
24
24
  xdg-base-dirs = "^6.0.2"
25
25
 
@@ -34,7 +34,7 @@ pytest-dotenv = "^0.5.2"
34
34
  pytest-html = "^4.1.1"
35
35
  pytest-memray = { version = "^1.8.0", markers = "platform_system != 'Windows'" }
36
36
  ruff = "^0.13.3"
37
- trivy-py-ecc = "^0.67.2"
37
+ trivy-py-ecc = "^0.68.2"
38
38
  mkdocs = "^1.5.3"
39
39
  mkdocstrings = {extras = ["python"], version = "^0.26.1"}
40
40
  mkdocs-material = "^9.5.11"
File without changes
File without changes