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.
- cmem_cmemc/cli.py +11 -6
- cmem_cmemc/command.py +1 -1
- cmem_cmemc/command_group.py +27 -0
- cmem_cmemc/commands/acl.py +388 -20
- cmem_cmemc/commands/admin.py +10 -10
- cmem_cmemc/commands/client.py +12 -5
- cmem_cmemc/commands/config.py +106 -12
- cmem_cmemc/commands/dataset.py +162 -118
- cmem_cmemc/commands/file.py +117 -73
- cmem_cmemc/commands/graph.py +200 -72
- cmem_cmemc/commands/graph_imports.py +12 -5
- cmem_cmemc/commands/graph_insights.py +61 -25
- cmem_cmemc/commands/metrics.py +15 -9
- cmem_cmemc/commands/migration.py +12 -4
- cmem_cmemc/commands/package.py +548 -0
- cmem_cmemc/commands/project.py +155 -22
- cmem_cmemc/commands/python.py +8 -4
- cmem_cmemc/commands/query.py +119 -25
- cmem_cmemc/commands/scheduler.py +6 -4
- cmem_cmemc/commands/store.py +2 -1
- cmem_cmemc/commands/user.py +124 -24
- cmem_cmemc/commands/validation.py +15 -10
- cmem_cmemc/commands/variable.py +264 -61
- cmem_cmemc/commands/vocabulary.py +18 -13
- cmem_cmemc/commands/workflow.py +21 -11
- cmem_cmemc/completion.py +105 -105
- cmem_cmemc/context.py +38 -8
- cmem_cmemc/exceptions.py +8 -2
- cmem_cmemc/manual_helper/multi_page.py +0 -1
- cmem_cmemc/object_list.py +234 -7
- cmem_cmemc/string_processor.py +142 -5
- cmem_cmemc/title_helper.py +50 -0
- cmem_cmemc/utils.py +8 -7
- {cmem_cmemc-25.6.0.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/METADATA +6 -6
- cmem_cmemc-26.1.0rc1.dist-info/RECORD +62 -0
- {cmem_cmemc-25.6.0.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/WHEEL +1 -1
- cmem_cmemc-25.6.0.dist-info/RECORD +0 -61
- {cmem_cmemc-25.6.0.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/entry_points.txt +0 -0
- {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"
|
|
13
|
-
super().__init__(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
cmem_cmemc/string_processor.py
CHANGED
|
@@ -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
|
|
8
|
-
from cmem.cmempy.
|
|
9
|
-
from
|
|
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(
|
|
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(
|
|
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 = []
|
cmem_cmemc/title_helper.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
406
|
+
raise CmemcError(f"Select query must include projections for: {missing}")
|
|
406
407
|
txt: str = sparql_query.text
|
|
407
408
|
return txt
|
|
408
409
|
|