cmem-cmemc 25.6.0__py3-none-any.whl → 26.1.0rc1__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 (39) hide show
  1. cmem_cmemc/cli.py +11 -6
  2. cmem_cmemc/command.py +1 -1
  3. cmem_cmemc/command_group.py +27 -0
  4. cmem_cmemc/commands/acl.py +388 -20
  5. cmem_cmemc/commands/admin.py +10 -10
  6. cmem_cmemc/commands/client.py +12 -5
  7. cmem_cmemc/commands/config.py +106 -12
  8. cmem_cmemc/commands/dataset.py +162 -118
  9. cmem_cmemc/commands/file.py +117 -73
  10. cmem_cmemc/commands/graph.py +200 -72
  11. cmem_cmemc/commands/graph_imports.py +12 -5
  12. cmem_cmemc/commands/graph_insights.py +61 -25
  13. cmem_cmemc/commands/metrics.py +15 -9
  14. cmem_cmemc/commands/migration.py +12 -4
  15. cmem_cmemc/commands/package.py +548 -0
  16. cmem_cmemc/commands/project.py +155 -22
  17. cmem_cmemc/commands/python.py +8 -4
  18. cmem_cmemc/commands/query.py +119 -25
  19. cmem_cmemc/commands/scheduler.py +6 -4
  20. cmem_cmemc/commands/store.py +2 -1
  21. cmem_cmemc/commands/user.py +124 -24
  22. cmem_cmemc/commands/validation.py +15 -10
  23. cmem_cmemc/commands/variable.py +264 -61
  24. cmem_cmemc/commands/vocabulary.py +18 -13
  25. cmem_cmemc/commands/workflow.py +21 -11
  26. cmem_cmemc/completion.py +105 -105
  27. cmem_cmemc/context.py +38 -8
  28. cmem_cmemc/exceptions.py +8 -2
  29. cmem_cmemc/manual_helper/multi_page.py +0 -1
  30. cmem_cmemc/object_list.py +234 -7
  31. cmem_cmemc/string_processor.py +142 -5
  32. cmem_cmemc/title_helper.py +50 -0
  33. cmem_cmemc/utils.py +8 -7
  34. {cmem_cmemc-25.6.0.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/METADATA +6 -6
  35. cmem_cmemc-26.1.0rc1.dist-info/RECORD +62 -0
  36. {cmem_cmemc-25.6.0.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/WHEEL +1 -1
  37. cmem_cmemc-25.6.0.dist-info/RECORD +0 -61
  38. {cmem_cmemc-25.6.0.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/entry_points.txt +0 -0
  39. {cmem_cmemc-25.6.0.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/licenses/LICENSE +0 -0
cmem_cmemc/exceptions.py CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  from typing import TYPE_CHECKING
4
4
 
5
+ from click.globals import get_current_context
6
+
5
7
  if TYPE_CHECKING:
6
8
  from cmem_cmemc.context import ApplicationContext
7
9
 
@@ -9,8 +11,12 @@ if TYPE_CHECKING:
9
11
  class CmemcError(ValueError):
10
12
  """Base exception for CMEM-CMEMC-related errors."""
11
13
 
12
- def __init__(self, app: "ApplicationContext", *args: str):
13
- super().__init__(*args)
14
+ def __init__(self, message: str, app: "ApplicationContext | None" = None):
15
+ super().__init__(message)
16
+ if app is None:
17
+ ctx = get_current_context(silent=True)
18
+ if ctx and hasattr(ctx, "obj"):
19
+ app = ctx.obj
14
20
  self.app = app
15
21
 
16
22
 
@@ -43,7 +43,6 @@ def get_icon_for_command_group(full_name: str) -> str:
43
43
 
44
44
  def get_tags_for_command_group(full_name: str) -> str:
45
45
  """Get list of tags for a command group name as markdown head section."""
46
- # pylint: disable=consider-using-join
47
46
  tags = {
48
47
  "admin": ["cmemc"],
49
48
  "admin metrics": ["cmemc"],
cmem_cmemc/object_list.py CHANGED
@@ -1,4 +1,4 @@
1
- """Filterable object list."""
1
+ """Filterable object list with enhanced filter types."""
2
2
 
3
3
  import re
4
4
  from abc import ABC, abstractmethod
@@ -9,7 +9,11 @@ from typing import Literal
9
9
  from click import Argument, Context, UsageError
10
10
  from click.shell_completion import CompletionItem
11
11
 
12
- from cmem_cmemc.completion import finalize_completion, get_completion_args
12
+ from cmem_cmemc.completion import (
13
+ finalize_completion,
14
+ get_completion_args,
15
+ suppress_completion_errors,
16
+ )
13
17
  from cmem_cmemc.context import ApplicationContext
14
18
  from cmem_cmemc.title_helper import TitleHelper
15
19
 
@@ -19,6 +23,7 @@ class Filter(ABC):
19
23
 
20
24
  name: str
21
25
  description: str
26
+ hidden: bool = False # If True, filter is hidden from help text and shell completion
22
27
 
23
28
  @abstractmethod
24
29
  def is_filtered(self, object_: dict, value: str) -> bool:
@@ -82,6 +87,16 @@ def transform_lower(ctx: Filter, value: str) -> str: # noqa: ARG001
82
87
  return value.lower()
83
88
 
84
89
 
90
+ def transform_list_none(ctx: Filter, value: list) -> list: # noqa: ARG001
91
+ """Transform: do nothing"""
92
+ return value
93
+
94
+
95
+ def transform_extract_labels(ctx: Filter, value: list) -> list: # noqa: ARG001
96
+ """Transform: extract label values from list of dictionaries"""
97
+ return [item["label"] for item in value if isinstance(item, dict) and "label" in item]
98
+
99
+
85
100
  class DirectValuePropertyFilter(Filter):
86
101
  """Class to create a filter based on direct properties of an object.
87
102
 
@@ -202,6 +217,118 @@ class DirectValuePropertyFilter(Filter):
202
217
  raise NotImplementedError(f"Completion method {self.completion_method} not implemented.")
203
218
 
204
219
 
220
+ class MultiFieldPropertyFilter(Filter):
221
+ """Filter that searches across multiple object fields with a single value.
222
+
223
+ This filter can search fields either:
224
+ 1. Individually (match_mode="any") - matches if ANY field matches
225
+ 2. Concatenated (match_mode="concat") - matches against all fields joined together
226
+
227
+ The "any" mode is more flexible and typically what users expect - e.g.,
228
+ searching "workflow" will match if it appears in name OR label OR description.
229
+
230
+ The "concat" mode is useful when you need to match patterns that span fields,
231
+ though this is less common in practice.
232
+
233
+ Example:
234
+ # Search for regex in ANY of: name, label, or description
235
+ filter = MultiFieldPropertyFilter(
236
+ name="regex",
237
+ description="Search in name, label, or description",
238
+ property_keys=["name", "label", "description"],
239
+ compare=compare_regex,
240
+ match_mode="any" # This is the default
241
+ )
242
+
243
+ """
244
+
245
+ name: str
246
+ description: str
247
+ property_keys: list[str]
248
+ separator: str
249
+ compare: Callable[[Filter, str, str], bool]
250
+ match_mode: Literal["any", "concat"]
251
+ fixed_completion: list[CompletionItem]
252
+ fixed_completion_only: bool
253
+
254
+ def __init__( # noqa: PLR0913
255
+ self,
256
+ name: str,
257
+ description: str,
258
+ property_keys: list[str],
259
+ separator: str = " ",
260
+ compare: Callable[[Filter, str, str], bool] = compare_regex,
261
+ match_mode: Literal["any", "concat"] = "any",
262
+ fixed_completion: list[CompletionItem] | None = None,
263
+ fixed_completion_only: bool = False,
264
+ ):
265
+ """Create a multi-field filter.
266
+
267
+ Args:
268
+ name: The name of the filter
269
+ description: The description of the filter
270
+ property_keys: List of property keys to search across
271
+ separator: String to use when joining field values (default: space)
272
+ compare: Comparison function (default: case-insensitive regex)
273
+ match_mode: "any" to match if ANY field matches (OR logic),
274
+ "concat" to match against concatenated fields
275
+ fixed_completion: A fixed list of CompletionItem objects used for value completion
276
+ fixed_completion_only: Raise an UsageError if a value is not from fixed completion
277
+
278
+ """
279
+ self.name = name
280
+ self.description = description
281
+ self.property_keys = property_keys
282
+ self.separator = separator
283
+ self.compare = compare
284
+ self.match_mode = match_mode
285
+ if fixed_completion is not None:
286
+ self.fixed_completion = fixed_completion
287
+ else:
288
+ self.fixed_completion = []
289
+ self.fixed_completion_only = fixed_completion_only
290
+
291
+ def is_filtered(self, object_: dict, value: str) -> bool:
292
+ """Return True if comparison matches based on match_mode.
293
+
294
+ - "any" mode: Returns True if value matches ANY field (OR logic)
295
+ - "concat" mode: Returns True if value matches concatenated fields
296
+ """
297
+ # Validate fixed_completion_only constraint
298
+ fixed_values = [_.value for _ in self.fixed_completion]
299
+ if self.fixed_completion_only and value not in fixed_values:
300
+ raise UsageError(
301
+ f"'{value}' is not a correct filter value for filter '{self.name}'. "
302
+ f"Use one of {', '.join(fixed_values)}."
303
+ )
304
+
305
+ # Collect all field values
306
+ field_values = [
307
+ str(object_[key])
308
+ for key in self.property_keys
309
+ if key in object_ and object_[key] is not None
310
+ ]
311
+ if not field_values:
312
+ return False
313
+
314
+ if self.match_mode == "any":
315
+ # Match if ANY individual field matches (OR logic)
316
+ return any(self.compare(self, field_value, value) for field_value in field_values)
317
+ # Match against concatenated string
318
+ combined_value = self.separator.join(field_values)
319
+ return bool(self.compare(self, combined_value, value))
320
+
321
+ def complete_values(self, objects: list[dict], incomplete: str) -> list[CompletionItem]:
322
+ """Provide completion items for filter values.
323
+
324
+ Returns fixed_completion if available, otherwise no completion.
325
+ """
326
+ _ = objects, incomplete
327
+ if self.fixed_completion:
328
+ return self.fixed_completion
329
+ return []
330
+
331
+
205
332
  class DirectListPropertyFilter(Filter):
206
333
  """Class to create filter based on direct list properties of an object
207
334
 
@@ -213,6 +340,7 @@ class DirectListPropertyFilter(Filter):
213
340
  description: str
214
341
  property_key: str
215
342
  title_helper: TitleHelper | None
343
+ transform: Callable[[Filter, list], list]
216
344
 
217
345
  def __init__(
218
346
  self,
@@ -220,6 +348,7 @@ class DirectListPropertyFilter(Filter):
220
348
  description: str,
221
349
  property_key: str,
222
350
  title_helper: TitleHelper | None = None,
351
+ transform: Callable[[Filter, list], list] = transform_list_none,
223
352
  ):
224
353
  """Create the new filter
225
354
 
@@ -232,11 +361,15 @@ class DirectListPropertyFilter(Filter):
232
361
  title_helper:
233
362
  (Optional) TitleHelper instance which will be used to provide
234
363
  resource titles as descriptions of completions candidates
364
+ transform:
365
+ (Optional) Transform function to apply to the list before filtering/completion.
366
+ Receives the filter instance and the list, returns a transformed list.
235
367
  """
236
368
  self.name = name
237
369
  self.description = description
238
370
  self.property_key = property_key
239
371
  self.title_helper = title_helper
372
+ self.transform = transform
240
373
 
241
374
  def is_filtered(self, object_: dict, value: str) -> bool:
242
375
  """Return True if the object is filtered (stays in list)."""
@@ -246,7 +379,8 @@ class DirectListPropertyFilter(Filter):
246
379
  return False # key value is None
247
380
  if not isinstance(object_[self.property_key], list):
248
381
  return False # key value is not a list
249
- return value in [str(_) for _ in object_[self.property_key]]
382
+ transformed_list = self.transform(self, object_[self.property_key])
383
+ return value in [str(_) for _ in transformed_list]
250
384
 
251
385
  def complete_values(self, objects: list[dict], incomplete: str) -> list[CompletionItem]:
252
386
  """Provide completion items for filter values"""
@@ -258,13 +392,100 @@ class DirectListPropertyFilter(Filter):
258
392
  continue # key value is None
259
393
  if not isinstance(object_[self.property_key], list):
260
394
  continue # key value is not a list
261
- candidates.extend([str(_) for _ in object_[self.property_key]])
395
+ transformed_list = self.transform(self, object_[self.property_key])
396
+ candidates.extend([str(_) for _ in transformed_list])
262
397
  if self.title_helper:
263
398
  self.title_helper.get(list(set(candidates)))
264
399
  candidates = [(_, self.title_helper.get(_)) for _ in candidates]
265
400
  return finalize_completion(candidates=candidates, incomplete=incomplete)
266
401
 
267
402
 
403
+ class DirectMultiValuePropertyFilter(Filter):
404
+ """Filter that matches when object property equals ANY value in a delimiter-separated string.
405
+
406
+ This filter accepts a single string containing multiple values separated by a delimiter
407
+ (comma by default) and returns True if the object's property matches any of them.
408
+ Typically used as a hidden internal filter for multi-ID operations.
409
+
410
+ Missing keys or Null values are treated as no match.
411
+
412
+ Example:
413
+ filter = DirectMultiValuePropertyFilter("ids", "IDs filter", "iri", delimiter=",")
414
+ # Pass comma-separated values: "id1,id2,id3"
415
+ # Matches objects where iri equals "id1" OR "id2" OR "id3"
416
+
417
+ """
418
+
419
+ name: str
420
+ description: str
421
+ property_key: str
422
+ transform: Callable[[Filter, str], str]
423
+ hidden: bool = True
424
+
425
+ def __init__(
426
+ self,
427
+ name: str,
428
+ description: str,
429
+ property_key: str,
430
+ transform: Callable[[Filter, str], str] = transform_none,
431
+ delimiter: str = ",",
432
+ ):
433
+ """Create a new multi-value filter.
434
+
435
+ Args:
436
+ name: The name of the filter (used as identifier)
437
+ description: The description of the filter (used in filter name completion)
438
+ property_key: The key of the property which is compared
439
+ transform: Optional value transformation function (e.g., to lowercase)
440
+ delimiter: String delimiter for splitting multiple values (default: ",")
441
+
442
+ """
443
+ self.name = name
444
+ self.description = description
445
+ self.property_key = property_key
446
+ self.transform = transform
447
+ self.delimiter = delimiter
448
+
449
+ def is_filtered(self, object_: dict, value: str) -> bool:
450
+ """Return True if object property matches ANY value in delimiter-separated string.
451
+
452
+ Args:
453
+ object_: The object to filter
454
+ value: Delimiter-separated string of values (e.g., "val1,val2,val3")
455
+
456
+ Returns:
457
+ True if object's property value matches any of the provided values (OR logic)
458
+
459
+ """
460
+ if self.property_key not in object_ or object_[self.property_key] is None:
461
+ return False
462
+
463
+ input_values = value.split(self.delimiter)
464
+
465
+ obj_val = str(object_[self.property_key])
466
+
467
+ input_values = [v.strip() for v in input_values]
468
+
469
+ return obj_val in input_values
470
+
471
+ def complete_values(self, objects: list[dict], incomplete: str) -> list[CompletionItem]:
472
+ """Provide completion items for filter values.
473
+
474
+ Note: Hidden filters return no completions.
475
+
476
+ Args:
477
+ objects: List of objects (unused for hidden filters)
478
+ incomplete: Incomplete value being typed (unused for hidden filters)
479
+
480
+ Returns:
481
+ Empty list (hidden filters provide no completion)
482
+
483
+ """
484
+ # Hidden filters provide no completion
485
+ _ = objects, incomplete
486
+ return []
487
+
488
+
268
489
  class ObjectList:
269
490
  """Filterable object list"""
270
491
 
@@ -303,10 +524,14 @@ class ObjectList:
303
524
  self.filters = {}
304
525
 
305
526
  def get_filter_help_text(self) -> str:
306
- """Get help text for the filter option."""
527
+ """Get help text for the filter option.
528
+
529
+ Hidden filters are excluded from the help text.
530
+ """
531
+ visible_filters = [name for name in self.filters if not self.filters[name].hidden]
307
532
  return (
308
533
  f"Filter {self.name} by one of the following filter names and a "
309
- f"corresponding value: {', '.join(self.filters.keys())}."
534
+ f"corresponding value: {', '.join(visible_filters)}."
310
535
  )
311
536
 
312
537
  def get_filter_names(self) -> list[str]:
@@ -336,6 +561,7 @@ class ObjectList:
336
561
  filtered = the_filter.filter_list(value=filter_value, objects=filtered)
337
562
  return filtered
338
563
 
564
+ @suppress_completion_errors
339
565
  def complete_values(
340
566
  self,
341
567
  ctx: Context,
@@ -350,11 +576,12 @@ class ObjectList:
350
576
  last_argument = args[len(args) - 1]
351
577
 
352
578
  if last_argument == "--filter":
353
- # complete filter names and descriptions
579
+ # complete filter names and descriptions (hide hidden filters)
354
580
  candidates = [
355
581
  (name, self.get_filter(name).description)
356
582
  for name in self.get_filter_names()
357
583
  if name not in previous_filter_names # do not show already used filters
584
+ and not self.get_filter(name).hidden # hide hidden filters
358
585
  ]
359
586
  return finalize_completion(
360
587
  candidates=candidates,
@@ -4,9 +4,11 @@ from abc import ABC, abstractmethod
4
4
  from datetime import datetime, timezone
5
5
  from urllib.parse import quote
6
6
 
7
- import timeago
8
- from cmem.cmempy.config import get_cmem_base_uri
9
- from humanize import naturalsize
7
+ from cmem.cmempy.config import get_cmem_base_uri, get_di_api_endpoint
8
+ from cmem.cmempy.workflow.workflow import get_workflow_editor_uri
9
+ from cmem.cmempy.workspace import get_task_plugins
10
+ from cmem.cmempy.workspace.search import list_items
11
+ from humanize import naturalsize, naturaltime
10
12
 
11
13
  from cmem_cmemc.title_helper import TitleHelper
12
14
  from cmem_cmemc.utils import get_graphs_as_dict
@@ -40,13 +42,13 @@ class TimeAgo(StringProcessor):
40
42
  return ""
41
43
  try:
42
44
  stamp = datetime.fromisoformat(str(text))
43
- return str(timeago.format(stamp, datetime.now(tz=timezone.utc)))
45
+ return str(naturaltime(stamp, when=datetime.now(tz=timezone.utc)))
44
46
  except (ValueError, TypeError):
45
47
  pass
46
48
  try:
47
49
  text_as_int = int(text)
48
50
  stamp = datetime.fromtimestamp(text_as_int / 1000, tz=timezone.utc)
49
- return str(timeago.format(stamp, datetime.now(tz=timezone.utc)))
51
+ return str(naturaltime(stamp, when=datetime.now(tz=timezone.utc)))
50
52
  except ValueError:
51
53
  return text
52
54
 
@@ -71,6 +73,28 @@ class GraphLink(StringProcessor):
71
73
  return f"[link={link}]{label}[/link]" if label else text
72
74
 
73
75
 
76
+ class QueryLink(StringProcessor):
77
+ """Create a query link from a query IRI cell
78
+
79
+ "Visit my [link=https://www.willmcgugan.com]blog[/link]!"
80
+ """
81
+
82
+ def __init__(self, catalog_graph: str, queries: dict):
83
+ self.catalog_graph = catalog_graph
84
+ self.queries = queries
85
+
86
+ def process(self, text: str) -> str:
87
+ """Process a single string content and output the processed string."""
88
+ # Find the query entry from the queries dict
89
+ query_entry = self.queries.get(text)
90
+ if query_entry:
91
+ # Use the SparqlQuery's get_editor_url method for consistent URL generation
92
+ link = query_entry.get_editor_url(graph=self.catalog_graph)
93
+ label = query_entry.label
94
+ return f"[link={link}]{label}[/link]"
95
+ return text
96
+
97
+
74
98
  class ResourceLink(StringProcessor):
75
99
  """Create a resource link from an IRI cell
76
100
 
@@ -89,6 +113,119 @@ class ResourceLink(StringProcessor):
89
113
  return f"[link={link}]{label}[/link]"
90
114
 
91
115
 
116
+ class ProjectLink(StringProcessor):
117
+ """Create a project link from a project ID cell
118
+
119
+ "Visit my [link=https://www.willmcgugan.com]blog[/link]!"
120
+ """
121
+
122
+ def __init__(self, projects: dict):
123
+ self.projects = projects
124
+ self.base = get_di_api_endpoint() + "/workbench/projects/"
125
+
126
+ def process(self, text: str) -> str:
127
+ """Process a single string content and output the processed string."""
128
+ project = self.projects.get(text)
129
+ if project:
130
+ link = self.base + text
131
+ label = project["metaData"].get("label", text)
132
+ return f"[link={link}]{label}[/link]"
133
+ return text
134
+
135
+
136
+ class WorkflowLink(StringProcessor):
137
+ """Create a workflow link from a workflow ID cell
138
+
139
+ "Visit my [link=https://www.willmcgugan.com]blog[/link]!"
140
+ """
141
+
142
+ def __init__(self, workflows: dict):
143
+ self.workflows = workflows
144
+ self.base_uri = get_workflow_editor_uri()
145
+
146
+ def process(self, text: str) -> str:
147
+ """Process a single string content and output the processed string."""
148
+ workflow = self.workflows.get(text)
149
+ if workflow:
150
+ project_id, task_id = text.split(":")
151
+ link = self.base_uri.format(project_id, task_id)
152
+ label = workflow["label"]
153
+ return f"[link={link}]{label}[/link]"
154
+ return text
155
+
156
+
157
+ class DatasetTypeLink(StringProcessor):
158
+ """Create a documentation link from a dataset type (pluginId) cell
159
+
160
+ Links to the Corporate Memory dataset documentation.
161
+ Example: "json" -> "[link=https://documentation.eccenca.com/latest/build/reference/dataset/json/]JSON[/link]"
162
+ """
163
+
164
+ def __init__(
165
+ self, base_url: str = "https://documentation.eccenca.com/latest/build/reference/dataset/"
166
+ ):
167
+ self.base_url = base_url
168
+ self.type_labels: dict[str, str] = {}
169
+ # Dataset types that don't have documentation pages
170
+ self.undocumented_types = {"variableDataset"}
171
+ # Import here to avoid circular imports
172
+
173
+ plugins = get_task_plugins()
174
+ for plugin_id, plugin in plugins.items():
175
+ if plugin["taskType"] == "Dataset":
176
+ self.type_labels[plugin_id] = plugin["title"]
177
+
178
+ def process(self, text: str) -> str:
179
+ """Process a dataset type and create a documentation link if available."""
180
+ if not text:
181
+ return text
182
+
183
+ # Use the title if available, otherwise fall back to the text
184
+ label = self.type_labels.get(text, text)
185
+
186
+ # Only create link if this type is not in the undocumented list
187
+ if text not in self.undocumented_types:
188
+ link = f"{self.base_url}{text}/"
189
+ return f"[link={link}]{label}[/link]"
190
+
191
+ return label
192
+
193
+
194
+ class DatasetLink(StringProcessor):
195
+ """Create a workspace link from a dataset ID cell
196
+
197
+ Links to the Corporate Memory workspace dataset page and displays the dataset label.
198
+ Example: "project:dataset" ->
199
+ "[link=https://cmem.example.com/workspaces/datasets/...]Dataset Label[/link]"
200
+ """
201
+
202
+ def __init__(self) -> None:
203
+ self.cmem_base_uri = get_cmem_base_uri()
204
+ self.dataset_urls: dict[str, str] = {}
205
+ self.dataset_labels: dict[str, str] = {}
206
+ # Build a mapping of dataset_id -> URL path and label
207
+ datasets = list_items(item_type="dataset")["results"]
208
+ for dataset in datasets:
209
+ dataset_id = dataset["projectId"] + ":" + dataset["id"]
210
+ url_path = dataset["itemLinks"][0]["path"]
211
+ self.dataset_urls[dataset_id] = url_path
212
+ self.dataset_labels[dataset_id] = dataset["label"]
213
+
214
+ def process(self, text: str) -> str:
215
+ """Process a dataset ID and create a workspace link with label if available."""
216
+ if not text:
217
+ return text
218
+
219
+ # Check if we have a URL for this dataset ID
220
+ if text in self.dataset_urls:
221
+ full_url = self.cmem_base_uri + self.dataset_urls[text]
222
+ label = self.dataset_labels.get(text, text)
223
+ return f"[link={full_url}]{label}[/link]"
224
+
225
+ # If no URL found, return the text as-is
226
+ return text
227
+
228
+
92
229
  def process_row(row: list[str], hints: dict[int, StringProcessor]) -> list[str]:
93
230
  """Process all cells in a row according to the StringProcessors"""
94
231
  processed_row = []
@@ -1,9 +1,12 @@
1
1
  """Title helper functions."""
2
2
 
3
3
  import json
4
+ from typing import ClassVar
4
5
 
5
6
  from cmem.cmempy.api import get_json
6
7
  from cmem.cmempy.config import get_dp_api_endpoint
8
+ from cmem.cmempy.workspace import get_task_plugins
9
+ from cmem.cmempy.workspace.projects.project import get_projects
7
10
 
8
11
 
9
12
  class TitleHelper:
@@ -39,3 +42,50 @@ class TitleHelper:
39
42
  output[title["iri"]] = title["title"]
40
43
 
41
44
  return output[iri] if isinstance(iri, str) else output
45
+
46
+
47
+ class ProjectTitleHelper(TitleHelper):
48
+ """Title helper for project IDs with class-level caching."""
49
+
50
+ _labels_cache: ClassVar[dict[str, str]] = {}
51
+ _cache_initialized: ClassVar[bool] = False
52
+
53
+ def get(self, project_id: str | list[str]) -> str | dict[str, str]:
54
+ """Get the label of a project (or list of projects)."""
55
+ # Fetch all project labels once at class level
56
+ if not ProjectTitleHelper._cache_initialized:
57
+ projects = get_projects()
58
+ for project in projects:
59
+ ProjectTitleHelper._labels_cache[project["name"]] = project["metaData"].get(
60
+ "label", ""
61
+ )
62
+ ProjectTitleHelper._cache_initialized = True
63
+
64
+ # Build and return output
65
+ if isinstance(project_id, str):
66
+ return ProjectTitleHelper._labels_cache.get(project_id, "")
67
+ return {pid: ProjectTitleHelper._labels_cache.get(pid, "") for pid in project_id}
68
+
69
+
70
+ class DatasetTypeTitleHelper(TitleHelper):
71
+ """Title helper for dataset types with class-level caching."""
72
+
73
+ _labels_cache: ClassVar[dict[str, str]] = {}
74
+ _cache_initialized: ClassVar[bool] = False
75
+
76
+ def get(self, plugin_id: str | list[str]) -> str | dict[str, str]:
77
+ """Get the description of a dataset type (or list of types)."""
78
+ # Fetch all plugin descriptions once at class level
79
+ if not DatasetTypeTitleHelper._cache_initialized:
80
+ plugins = get_task_plugins()
81
+ for pid, plugin in plugins.items():
82
+ if plugin["taskType"] == "Dataset":
83
+ title = plugin["title"]
84
+ description = plugin["description"].partition("\n")[0]
85
+ DatasetTypeTitleHelper._labels_cache[pid] = f"{title}: {description}"
86
+ DatasetTypeTitleHelper._cache_initialized = True
87
+
88
+ # Build and return output
89
+ if isinstance(plugin_id, str):
90
+ return DatasetTypeTitleHelper._labels_cache.get(plugin_id, plugin_id)
91
+ return {pid: DatasetTypeTitleHelper._labels_cache.get(pid, pid) for pid in plugin_id}
cmem_cmemc/utils.py CHANGED
@@ -12,7 +12,7 @@ from typing import TYPE_CHECKING
12
12
  from zipfile import BadZipFile, ZipFile
13
13
 
14
14
  import requests
15
- from click import Argument, ClickException
15
+ from click import Argument
16
16
  from cmem.cmempy.dp.proxy.graph import get_graphs_list
17
17
  from cmem.cmempy.queries import QueryCatalog
18
18
  from cmem.cmempy.workspace.projects.project import get_projects
@@ -20,6 +20,7 @@ from prometheus_client import Metric
20
20
 
21
21
  from cmem_cmemc.config_parser import PureSectionConfigParser
22
22
  from cmem_cmemc.constants import NAMESPACES
23
+ from cmem_cmemc.exceptions import CmemcError
23
24
  from cmem_cmemc.smart_path import SmartPath
24
25
 
25
26
  if TYPE_CHECKING:
@@ -246,7 +247,7 @@ def split_task_id(task_id: str) -> tuple[str, str]:
246
247
  project_part = task_id.split(":")[0]
247
248
  task_part = task_id.split(":")[1]
248
249
  except IndexError as error:
249
- raise ClickException(f"{task_id} is not a valid task ID.") from error
250
+ raise CmemcError(f"{task_id} is not a valid task ID.") from error
250
251
  return project_part, task_part
251
252
 
252
253
 
@@ -303,13 +304,13 @@ def check_or_select_project(app: "ApplicationContext", project_id: str | None =
303
304
  return project_name
304
305
 
305
306
  if len(projects) == 0:
306
- raise ClickException(
307
+ raise CmemcError(
307
308
  "There are no projects available. "
308
309
  "Please create a project with 'cmemc project create'."
309
310
  )
310
311
 
311
312
  # more than one project
312
- raise ClickException(
313
+ raise CmemcError(
313
314
  "There is more than one project available so you need to "
314
315
  "specify the project with '--project'."
315
316
  )
@@ -391,10 +392,10 @@ def get_query_text(file_or_uri: str, required_projections: set) -> str:
391
392
  """
392
393
  sparql_query = QueryCatalog().get_query(file_or_uri)
393
394
  if sparql_query is None:
394
- raise ClickException(f"{file_or_uri} is neither a readable file nor a query URI.")
395
+ raise CmemcError(f"{file_or_uri} is neither a readable file nor a query URI.")
395
396
 
396
397
  if sparql_query.get_placeholder_keys():
397
- raise ClickException("Placeholder queries are not supported.")
398
+ raise CmemcError("Placeholder queries are not supported.")
398
399
 
399
400
  result = sparql_query.get_json_results()
400
401
  projected_vars = set(result["head"]["vars"])
@@ -402,7 +403,7 @@ def get_query_text(file_or_uri: str, required_projections: set) -> str:
402
403
  missing_projections = required_projections - projected_vars
403
404
  if missing_projections:
404
405
  missing = ", ".join(missing_projections)
405
- raise ClickException(f"Select query must include projections for: {missing}")
406
+ raise CmemcError(f"Select query must include projections for: {missing}")
406
407
  txt: str = sparql_query.text
407
408
  return txt
408
409