cmem-cmemc 25.5.0rc1__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 (42) hide show
  1. cmem_cmemc/cli.py +11 -6
  2. cmem_cmemc/command.py +1 -1
  3. cmem_cmemc/command_group.py +59 -31
  4. cmem_cmemc/commands/acl.py +403 -26
  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 +163 -172
  9. cmem_cmemc/commands/file.py +509 -0
  10. cmem_cmemc/commands/graph.py +200 -72
  11. cmem_cmemc/commands/graph_imports.py +12 -5
  12. cmem_cmemc/commands/graph_insights.py +157 -53
  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 +157 -22
  17. cmem_cmemc/commands/python.py +9 -5
  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 +31 -17
  25. cmem_cmemc/commands/workflow.py +21 -11
  26. cmem_cmemc/completion.py +126 -109
  27. cmem_cmemc/context.py +40 -10
  28. cmem_cmemc/exceptions.py +8 -2
  29. cmem_cmemc/manual_helper/graph.py +2 -2
  30. cmem_cmemc/manual_helper/multi_page.py +5 -7
  31. cmem_cmemc/object_list.py +234 -7
  32. cmem_cmemc/placeholder.py +2 -2
  33. cmem_cmemc/string_processor.py +153 -4
  34. cmem_cmemc/title_helper.py +50 -0
  35. cmem_cmemc/utils.py +9 -8
  36. {cmem_cmemc-25.5.0rc1.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/METADATA +7 -6
  37. cmem_cmemc-26.1.0rc1.dist-info/RECORD +62 -0
  38. {cmem_cmemc-25.5.0rc1.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/WHEEL +1 -1
  39. cmem_cmemc/commands/resource.py +0 -220
  40. cmem_cmemc-25.5.0rc1.dist-info/RECORD +0 -61
  41. {cmem_cmemc-25.5.0rc1.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/entry_points.txt +0 -0
  42. {cmem_cmemc-25.5.0rc1.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/licenses/LICENSE +0 -0
cmem_cmemc/context.py CHANGED
@@ -15,7 +15,9 @@ from shutil import which
15
15
  import click
16
16
  import cmem.cmempy.config as cmempy_config
17
17
  import urllib3
18
+ from cmem.cmempy.config import get_cmem_base_uri
18
19
  from cmem.cmempy.health import get_di_version, get_explore_version
20
+ from cmem_client.client import Client
19
21
  from pygments import highlight
20
22
  from pygments.formatters import get_formatter_by_name
21
23
  from pygments.lexers import get_lexer_by_name
@@ -29,9 +31,9 @@ from cmem_cmemc.exceptions import InvalidConfigurationError
29
31
  from cmem_cmemc.string_processor import StringProcessor, process_row
30
32
  from cmem_cmemc.utils import is_enabled, str_to_bool
31
33
 
32
- DI_TARGET_VERSION = "v25.2.0"
34
+ DI_TARGET_VERSION = "v25.3.0"
33
35
 
34
- EXPLORE_TARGET_VERSION = "v25.2.0"
36
+ EXPLORE_TARGET_VERSION = "v25.3.0"
35
37
 
36
38
  KNOWN_CONFIG_KEYS = {
37
39
  "CMEM_BASE_URI": cmempy_config.get_cmem_base_uri,
@@ -55,6 +57,34 @@ KNOWN_SECRET_KEYS = ("OAUTH_PASSWORD", "OAUTH_CLIENT_SECRET", "OAUTH_ACCESS_TOKE
55
57
  SSL_VERIFY_WARNING = "SSL verification is disabled (SSL_VERIFY=False)."
56
58
 
57
59
 
60
+ def build_caption(
61
+ count: int,
62
+ item_name: str,
63
+ instance: str | None = None,
64
+ filtered: bool = False,
65
+ plural: str | None = None,
66
+ ) -> str:
67
+ """Build a standardized caption for table outputs.
68
+
69
+ Args:
70
+ ----
71
+ count: Number of items in the table
72
+ item_name: Name of the items (e.g., "file", "project", "query")
73
+ instance: Instance URI (if None, uses get_cmem_base_uri())
74
+ filtered: Whether the results are filtered
75
+ plural: Optional Plural form of the item_name (e.g. "queries"), with giving
76
+ a plural form, simply a 's' is added for plural.
77
+
78
+ Returns:
79
+ -------
80
+ Caption string in format: "X thing(s) of INSTANCE (filtered)"
81
+
82
+ """
83
+ term = (plural if plural else f"{item_name}s") if count != 1 else item_name
84
+ base = f"{count} {term} of {instance or get_cmem_base_uri()}"
85
+ return f"{base} (filtered)" if filtered else base
86
+
87
+
58
88
  class ApplicationContext:
59
89
  """Context of the command line interface."""
60
90
 
@@ -72,9 +102,6 @@ class ApplicationContext:
72
102
  console: Console
73
103
  console_width: int | None = None
74
104
 
75
- # pylint: disable=too-many-instance-attributes
76
- # pylint: disable=too-many-public-methods
77
-
78
105
  def __init__(
79
106
  self,
80
107
  config_file: str,
@@ -93,6 +120,11 @@ class ApplicationContext:
93
120
  self.console = Console(markup=True, emoji_variant="emoji")
94
121
  self.update_console_width()
95
122
 
123
+ @property
124
+ def client(self) -> Client:
125
+ """The cmem_client Client object."""
126
+ return Client.from_cmempy()
127
+
96
128
  @staticmethod
97
129
  def from_params(params: dict) -> "ApplicationContext":
98
130
  """Create an ApplicationContext instance from a dictionary.
@@ -273,9 +305,9 @@ class ApplicationContext:
273
305
  return
274
306
  if section_string not in self.get_config():
275
307
  raise InvalidConfigurationError(
276
- self,
277
308
  f"There is no connection '{section_string}' configured in "
278
309
  f"config '{self.config_file}'.",
310
+ app=self,
279
311
  )
280
312
  self.connection = section_string
281
313
 
@@ -294,10 +326,10 @@ class ApplicationContext:
294
326
  config.read(self.get_config_file(), encoding="utf-8")
295
327
  except configparser.Error as error:
296
328
  raise InvalidConfigurationError(
297
- self,
298
329
  "The following config parser error needs to be fixed with your config file:\n"
299
330
  f"{error!s}\n"
300
331
  "You can use the 'config edit' command to fix this.",
332
+ app=self,
301
333
  ) from error
302
334
  except Exception as error: # noqa: BLE001
303
335
  self.echo_debug(f"Could not read config file - provide empty config: {error!s}")
@@ -337,7 +369,6 @@ class ApplicationContext:
337
369
  2024-05-17: also allows list of strings now
338
370
  2024-05-17: new prepend_line parameter
339
371
  """
340
- # pylint: disable=invalid-name
341
372
  click.echo("") if prepend_line is True else None
342
373
  messages: list[str] = [message] if isinstance(message, str) else message
343
374
  for _ in messages:
@@ -434,6 +465,7 @@ class ApplicationContext:
434
465
  )
435
466
  if caption is not None:
436
467
  table.caption = caption
468
+ table.min_width = len(caption)
437
469
  for header in headers:
438
470
  table.add_column(header, overflow="fold")
439
471
  for row_source in rows:
@@ -450,13 +482,11 @@ class ApplicationContext:
450
482
 
451
483
  def echo_success(self, message: str, nl: bool = True, condition: bool = True) -> None:
452
484
  """Output success message, if not suppressed by --quiet."""
453
- # pylint: disable=invalid-name
454
485
  self.echo_info(message, fg="green", nl=nl, condition=condition)
455
486
 
456
487
  @staticmethod
457
488
  def echo_result(message: str, nl: bool = True) -> None:
458
489
  """Output result message, can NOT be suppressed by --quiet."""
459
- # pylint: disable=invalid-name
460
490
  click.echo(message, nl=nl)
461
491
 
462
492
  def check_concrete_version(self, name: str, version: str, target_version: str) -> None:
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
 
@@ -56,7 +56,7 @@ def print_group_manual_graph_recursive(
56
56
  ctx.obj.echo_info(f"{iri} a cli:CommandGroup .")
57
57
  ctx.obj.echo_info(f"{iri} rdfs:label '{prefix}{key} Command Group' .")
58
58
  ctx.obj.echo_info(f"{iri} cli:subGroupOf {sub_group_iri} .")
59
- ctx.obj.echo_info(f"{iri} rdfs:comment '{comment}' .")
59
+ ctx.obj.echo_info(f'{iri} rdfs:comment """{comment}""" .')
60
60
  print_group_manual_graph_recursive(item, ctx=ctx, prefix=f"{prefix}{key}-")
61
61
  elif isinstance(item, click.Command):
62
62
  comment = item.get_short_help_str(limit=200)
@@ -64,7 +64,7 @@ def print_group_manual_graph_recursive(
64
64
  ctx.obj.echo_info(f"{iri} a cli:Command .")
65
65
  ctx.obj.echo_info(f"{iri} rdfs:label '{prefix}{key} Command' .")
66
66
  ctx.obj.echo_info(f"{iri} cli:group {group_iri} .")
67
- ctx.obj.echo_info(f"{iri} rdfs:comment '{comment}' .")
67
+ ctx.obj.echo_info(f'{iri} rdfs:comment """{comment}""" .')
68
68
  for parameter in item.params:
69
69
  print_parameter_manual_graph(parameter, ctx=ctx, prefix=f"{prefix}{key}-")
70
70
  else:
@@ -26,12 +26,13 @@ def get_icon_for_command_group(full_name: str) -> str:
26
26
  "admin workspace python": "material/language-python",
27
27
  "config": "material/cog-outline",
28
28
  "dataset": "eccenca/artefact-dataset",
29
- "dataset resource": "eccenca/artefact-file",
30
29
  "project": "eccenca/artefact-project",
30
+ "project file": "eccenca/artefact-file",
31
31
  "project variable": "material/variable-box",
32
32
  "query": "eccenca/application-queries",
33
33
  "graph": "eccenca/artefact-dataset-eccencadataplatform",
34
34
  "graph imports": "material/family-tree",
35
+ "graph insights": "eccenca/graph-insights",
35
36
  "graph validation": "octicons/verified-16",
36
37
  "vocabulary": "eccenca/application-vocabularies",
37
38
  "vocabulary cache": "eccenca/application-vocabularies",
@@ -42,7 +43,6 @@ def get_icon_for_command_group(full_name: str) -> str:
42
43
 
43
44
  def get_tags_for_command_group(full_name: str) -> str:
44
45
  """Get list of tags for a command group name as markdown head section."""
45
- # pylint: disable=consider-using-join
46
46
  tags = {
47
47
  "admin": ["cmemc"],
48
48
  "admin metrics": ["cmemc"],
@@ -54,8 +54,8 @@ def get_tags_for_command_group(full_name: str) -> str:
54
54
  "admin workspace python": ["Python", "cmemc"],
55
55
  "config": ["Configuration", "cmemc"],
56
56
  "dataset": ["cmemc"],
57
- "dataset resource": ["cmemc"],
58
57
  "project": ["Project", "cmemc"],
58
+ "project file": ["Files", "cmemc"],
59
59
  "project variable": ["Variables", "cmemc"],
60
60
  "query": ["SPARQL", "cmemc"],
61
61
  "graph": ["KnowledgeGraph", "cmemc"],
@@ -145,12 +145,10 @@ def get_commands_for_table_recursive(
145
145
  if isinstance(item, Command):
146
146
  command_name = item.name
147
147
  group_link = f"{prefix}/{group_name}/index.md".replace(" ", "/")
148
- if group_link.startswith("/"):
149
- group_link = group_link[1:]
148
+ group_link = group_link.removeprefix("/")
150
149
  command_anchor = f"{prefix}-{group_name}".replace(" ", "-")
151
150
  command_anchor += f"-{command_name}"
152
- if command_anchor.startswith("-"):
153
- command_anchor = command_anchor[1:]
151
+ command_anchor = command_anchor.removeprefix("-")
154
152
  command_link = f"{group_link}#{command_anchor}"
155
153
  new_command = {
156
154
  "command_name": command_name,
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,
cmem_cmemc/placeholder.py CHANGED
@@ -36,7 +36,7 @@ class QueryPlaceholder:
36
36
  """Prepare a list of placeholder values"""
37
37
  result = self.value_query.get_json_results()
38
38
  projection_vars = result["head"]["vars"]
39
- bindings = result["results"]["bindings"]
39
+ bindings: list[dict] = result["results"]["bindings"]
40
40
  if "value" not in projection_vars:
41
41
  return []
42
42
 
@@ -50,7 +50,7 @@ class QueryPlaceholder:
50
50
  values_with_description = []
51
51
  for _ in bindings:
52
52
  value = str(_["value"]["value"])
53
- description = str(_["description"]["value"])
53
+ description = _.get("description", {}).get("value", "")
54
54
  values_with_description.append((value, description))
55
55
  return values_with_description
56
56