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
@@ -14,7 +14,8 @@ from xml.etree.ElementTree import ( # nosec
14
14
  )
15
15
 
16
16
  import click
17
- from click import ClickException, Context, UsageError
17
+ from click import Context, UsageError
18
+ from click.shell_completion import CompletionItem
18
19
  from cmem.cmempy.config import get_cmem_base_uri
19
20
  from cmem.cmempy.dp.authorization import refresh
20
21
  from cmem.cmempy.dp.proxy import graph as graph_api
@@ -30,9 +31,17 @@ from cmem_cmemc.commands.graph_imports import graph_imports_list, imports_group
30
31
  from cmem_cmemc.commands.graph_insights import insights_group
31
32
  from cmem_cmemc.commands.validation import validation_group
32
33
  from cmem_cmemc.constants import UNKNOWN_GRAPH_ERROR
33
- from cmem_cmemc.context import ApplicationContext
34
+ from cmem_cmemc.context import ApplicationContext, build_caption
35
+ from cmem_cmemc.exceptions import CmemcError
36
+ from cmem_cmemc.object_list import (
37
+ DirectMultiValuePropertyFilter,
38
+ DirectValuePropertyFilter,
39
+ Filter,
40
+ ObjectList,
41
+ )
34
42
  from cmem_cmemc.parameter_types.path import ClickSmartPath
35
43
  from cmem_cmemc.smart_path import SmartPath
44
+ from cmem_cmemc.string_processor import GraphLink
36
45
  from cmem_cmemc.utils import (
37
46
  RdfGraphData,
38
47
  convert_uri_to_filename,
@@ -44,6 +53,70 @@ from cmem_cmemc.utils import (
44
53
  )
45
54
 
46
55
 
56
+ def compare_access_value(ctx: Filter, object_value: str, filter_value: str) -> bool: # noqa: ARG001
57
+ """Compare access values - writeable property is boolean string."""
58
+ if filter_value == "writeable":
59
+ return object_value == "True"
60
+ if filter_value == "readonly":
61
+ return object_value == "False"
62
+ raise UsageError(f"Invalid access filter value: {filter_value}")
63
+
64
+
65
+ def compare_imported_by(ctx: Filter, object_iri: str, importing_graph_iri: str) -> bool: # noqa: ARG001
66
+ """Check if object_iri is imported by importing_graph_iri."""
67
+ if importing_graph_iri not in get_graphs_as_dict():
68
+ raise CmemcError(UNKNOWN_GRAPH_ERROR.format(importing_graph_iri))
69
+ imported_graphs = get_graph_imports(importing_graph_iri)
70
+ return object_iri in imported_graphs
71
+
72
+
73
+ def get_graphs_for_list(ctx: Context) -> list[dict]: # noqa: ARG001
74
+ """Get graphs for object list."""
75
+ return get_graphs()
76
+
77
+
78
+ # Common filters used by both list and delete commands
79
+ _graph_common_filters = [
80
+ DirectValuePropertyFilter(
81
+ name="imported-by",
82
+ description="Filter graphs imported by the specified graph IRI.",
83
+ property_key="iri",
84
+ compare=compare_imported_by,
85
+ completion_method="values",
86
+ ),
87
+ DirectMultiValuePropertyFilter(
88
+ name="iris",
89
+ description="Internal filter for multiple graph IRIs.",
90
+ property_key="iri",
91
+ ),
92
+ ]
93
+
94
+ # Access filter only for list command (not applicable for delete)
95
+ _graph_access_filter = DirectValuePropertyFilter(
96
+ name="access",
97
+ description="Filter graphs by access condition (readonly or writeable).",
98
+ property_key="writeable",
99
+ compare=compare_access_value,
100
+ fixed_completion=[
101
+ CompletionItem("readonly", help="Graphs which are NOT writeable by current user."),
102
+ CompletionItem("writeable", help="Graphs which ARE writeable by current user."),
103
+ ],
104
+ fixed_completion_only=True,
105
+ )
106
+
107
+ graph_list_obj = ObjectList(
108
+ name="graphs",
109
+ get_objects=get_graphs_for_list,
110
+ filters=[_graph_access_filter, *_graph_common_filters],
111
+ )
112
+
113
+ graph_delete_obj = ObjectList(
114
+ name="graphs",
115
+ get_objects=get_graphs_for_list,
116
+ filters=[*_graph_common_filters],
117
+ )
118
+
119
+
47
120
  def count_graph(graph_iri: str) -> int:
48
121
  """Count triples in a graph and return integer."""
49
122
  query = "SELECT (COUNT(*) AS ?triples) " + " FROM <" + graph_iri + "> WHERE { ?s ?p ?o }" # noqa: S608
@@ -129,45 +202,13 @@ def _get_export_names(
129
202
  _name_created = f"{Template(template).render(template_data)}{file_extension}"
130
203
  _names[iri] = _name_created
131
204
  if len(_names.values()) != len(set(_names.values())):
132
- raise ClickException(
205
+ raise CmemcError(
133
206
  "The given template string produces a naming clash. "
134
207
  "Please use a different template to produce unique names."
135
208
  )
136
209
  return _names
137
210
 
138
211
 
139
- def _get_graphs_filtered(filter_name: str, filter_value: str) -> list[dict]:
140
- """Get graphs but filtered according to name and value."""
141
- # not filtered means all graphs
142
- graphs: list[dict]
143
- if filter_name is None:
144
- return get_graphs()
145
- # check for correct filter names
146
- possible_filter_names = ("access", "imported-by")
147
- if filter_name not in possible_filter_names:
148
- raise ClickException(
149
- f"{filter_name} is an unknown filter name. " f"Use one of {possible_filter_names}."
150
- )
151
- # filter by access condition
152
- if filter_name == "access":
153
- if filter_value == "writeable":
154
- graphs = get_graphs(writeable=True, readonly=False)
155
- elif filter_value == "readonly":
156
- graphs = get_graphs(writeable=False, readonly=True)
157
- else:
158
- raise ClickException("Filter access is either 'readonly' or 'writeable'.")
159
- else:
160
- # default is all graphs
161
- graphs = get_graphs()
162
- # filter by imported-by
163
- if filter_name == "imported-by":
164
- if filter_value not in get_graphs_as_dict():
165
- raise ClickException(UNKNOWN_GRAPH_ERROR.format(filter_value))
166
- imported_graphs = get_graph_imports(filter_value)
167
- graphs = [_ for _ in graphs if _["iri"] in imported_graphs]
168
- return graphs
169
-
170
-
171
212
  def _add_imported_graphs(iris: list[str], all_graphs: dict) -> list[str]:
172
213
  """Get a list of graph IRIs extended with the imported graphs.
173
214
 
@@ -190,6 +231,49 @@ def _add_imported_graphs(iris: list[str], all_graphs: dict) -> list[str]:
190
231
  return list(set(extended_list))
191
232
 
192
233
 
234
+ def _validate_graph_iris(iris: tuple[str, ...]) -> None:
235
+ """Validate that all provided graph IRIs exist."""
236
+ if not iris:
237
+ return
238
+ all_graphs = get_graphs_as_dict()
239
+ for iri in iris:
240
+ if iri not in all_graphs:
241
+ raise CmemcError(UNKNOWN_GRAPH_ERROR.format(iri))
242
+
243
+
244
+ def _get_graphs_to_delete(
245
+ ctx: Context,
246
+ iris: tuple[str, ...],
247
+ all_: bool,
248
+ filter_: tuple[tuple[str, str], ...],
249
+ ) -> list[dict]:
250
+ """Get the list of graphs to delete based on selection method."""
251
+ if all_:
252
+ return get_graphs(writeable=True, readonly=False)
253
+
254
+ # Validate provided IRIs exist before proceeding
255
+ _validate_graph_iris(iris)
256
+
257
+ # Build filter list
258
+ filter_to_apply = list(filter_) if filter_ else []
259
+
260
+ # Add IRIs if provided (using internal multi-value filter)
261
+ if iris:
262
+ filter_to_apply.append(("iris", ",".join(iris)))
263
+
264
+ # Apply filters to writeable graphs only
265
+ writeable_graphs = get_graphs(writeable=True, readonly=False)
266
+ graphs = graph_delete_obj.apply_filters(
267
+ ctx=ctx, filter_=filter_to_apply, objects=writeable_graphs
268
+ )
269
+
270
+ # Validation: ensure we found graphs
271
+ if not graphs:
272
+ raise CmemcError("No graphs found matching the provided criteria.")
273
+
274
+ return graphs
275
+
276
+
193
277
  def _check_and_extend_exported_graphs(
194
278
  iris: list[str], all_flag: bool, imported_flag: bool, all_graphs: dict
195
279
  ) -> list[str]:
@@ -220,7 +304,7 @@ def _check_and_extend_exported_graphs(
220
304
  )
221
305
  for iri in iris:
222
306
  if iri not in all_graphs:
223
- raise ClickException(UNKNOWN_GRAPH_ERROR.format(iri))
307
+ raise CmemcError(UNKNOWN_GRAPH_ERROR.format(iri))
224
308
  if all_flag:
225
309
  # in case --all is given,
226
310
  # list of graphs is filled with all available graph IRIs
@@ -301,22 +385,16 @@ def tree_command(ctx: Context, all_: bool, raw: bool, id_only: bool, iris: list[
301
385
  @click.option(
302
386
  "--filter",
303
387
  "filter_",
304
- type=click.Tuple([click.Choice(["access", "imported-by"]), str]),
305
- shell_complete=completion.graph_list_filter,
306
- default=[None] * 2,
307
- help="Filter graphs based on effective access conditions or import "
308
- "closure. "
309
- "First parameter CHOICE can be 'access' or 'imported-by'. "
310
- "The second parameter can be 'readonly' or 'writeable' in case "
311
- "of 'access' or any readable graph in case of 'imported-by'.",
388
+ multiple=True,
389
+ type=(str, str),
390
+ shell_complete=graph_list_obj.complete_values,
391
+ help=graph_list_obj.get_filter_help_text(),
312
392
  )
313
- @click.pass_obj
314
- def list_command(
315
- app: ApplicationContext, raw: bool, id_only: bool, filter_: tuple[str, str]
316
- ) -> None:
393
+ @click.pass_context
394
+ def list_command(ctx: Context, raw: bool, id_only: bool, filter_: tuple[tuple[str, str]]) -> None:
317
395
  """List accessible graphs."""
318
- filter_name, filter_value = filter_
319
- graphs = _get_graphs_filtered(filter_name, filter_value)
396
+ app: ApplicationContext = ctx.obj
397
+ graphs = graph_list_obj.apply_filters(ctx=ctx, filter_=filter_)
320
398
 
321
399
  if raw:
322
400
  app.echo_info_json(graphs)
@@ -336,15 +414,19 @@ def list_command(
336
414
  row = [
337
415
  _["iri"],
338
416
  graph_class,
339
- _["label"]["title"],
417
+ _["iri"],
340
418
  ]
341
419
  table.append(row)
420
+ filtered = len(filter_) > 0
342
421
  app.echo_info_table(
343
422
  table,
344
423
  headers=["Graph IRI", "Type", "Label"],
345
424
  sort_column=2,
346
- empty_table_message="No graphs found. "
347
- "Use the `graph import` command to import a graph from a file, or "
425
+ cell_processing={2: GraphLink()},
426
+ caption=build_caption(len(table), "graph", filtered=filtered),
427
+ empty_table_message="No graphs found for these filters."
428
+ if filtered
429
+ else "No graphs found. Use the `graph import` command to import a graph from a file, or "
348
430
  "use the `admin store bootstrap` command to import the default graphs.",
349
431
  )
350
432
 
@@ -371,7 +453,6 @@ def _write_graph_imports(ctx: Context, filename: str, iri: str) -> None:
371
453
  imports_file.close()
372
454
 
373
455
 
374
- # pylint: disable=too-many-arguments,too-many-locals
375
456
  @click.command(cls=CmemcCommand, name="export")
376
457
  @click.option("-a", "--all", "all_", is_flag=True, help="Export all readable graphs.")
377
458
  @click.option(
@@ -475,7 +556,7 @@ def export_command( # noqa: C901, PLR0913
475
556
  app.echo_debug("output is directory")
476
557
  # pre-calculate all filenames with the template,
477
558
  # in order to output errors on naming clashes as early as possible
478
- extension = mimetypes.guess_extension(mime_type)
559
+ extension = _get_file_extension_for_mime_type(mime_type)
479
560
  _names = _get_export_names(
480
561
  app, iris, template, f"{extension}.gz" if compress else f"{extension}"
481
562
  )
@@ -551,7 +632,7 @@ def validate_input_path(input_path: str) -> None:
551
632
  conflicting_files = [f for f in gz_files if f in files]
552
633
 
553
634
  if conflicting_files:
554
- raise ClickException(
635
+ raise CmemcError(
555
636
  f"The following RDF files (.ttl/.nt) have corresponding '.gz' files,"
556
637
  f" which is not allowed: {', '.join(conflicting_files)}"
557
638
  )
@@ -566,6 +647,26 @@ def _get_graph_supported_formats() -> dict[str, str]:
566
647
  }
567
648
 
568
649
 
650
+ def _get_file_extension_for_mime_type(mime_type: str) -> str:
651
+ """Get file extension for a MIME type with fallback mappings.
652
+
653
+ mimetypes.guess_extension() can return None on some systems (especially Windows)
654
+ for certain RDF MIME types. This function provides fallback extensions.
655
+ """
656
+ # Try to use the system's mimetypes registry first
657
+ extension = mimetypes.guess_extension(mime_type, strict=False)
658
+ if extension is not None:
659
+ return extension
660
+
661
+ # Fallback mappings for RDF MIME types
662
+ mime_to_extension = {
663
+ "application/n-triples": ".nt",
664
+ "text/turtle": ".ttl",
665
+ "application/rdf+xml": ".rdf",
666
+ }
667
+ return mime_to_extension.get(mime_type, ".ttl")
668
+
669
+
569
670
  def _get_buffer_and_content_type(
570
671
  triple_file: str, app: ApplicationContext
571
672
  ) -> tuple[io.BytesIO, str, None | str]:
@@ -633,7 +734,7 @@ def _process_input_directory(input_path: str, iri: str) -> list[RdfGraphData]:
633
734
  # in case a directory is the source AND IRI is given
634
735
  graphs = []
635
736
  for _ in _get_graph_supported_formats():
636
- extension = mimetypes.guess_extension(_)
737
+ extension = _get_file_extension_for_mime_type(_)
637
738
  graphs += [
638
739
  RdfGraphData(str(file), iri, [])
639
740
  for file in SmartPath(input_path).glob(f"*{extension}")
@@ -775,6 +876,12 @@ def import_command( # noqa: PLR0913
775
876
 
776
877
 
777
878
  @click.command(cls=CmemcCommand, name="delete")
879
+ @click.argument(
880
+ "iris",
881
+ nargs=-1,
882
+ type=click.STRING,
883
+ shell_complete=completion.writable_graph_uris,
884
+ )
778
885
  @click.option("-a", "--all", "all_", is_flag=True, help="Delete all writeable graphs.")
779
886
  @click.option(
780
887
  "--include-imports",
@@ -785,35 +892,56 @@ def import_command( # noqa: PLR0913
785
892
  @click.option(
786
893
  "--include-import-statements", is_flag=True, help="Delete import reference of deleted graphs"
787
894
  )
788
- @click.argument(
789
- "iris",
790
- nargs=-1,
791
- type=click.STRING,
792
- shell_complete=completion.writable_graph_uris,
793
- callback=tuple_to_list,
895
+ @click.option(
896
+ "--filter",
897
+ "filter_",
898
+ multiple=True,
899
+ type=(str, str),
900
+ shell_complete=graph_delete_obj.complete_values,
901
+ help=graph_delete_obj.get_filter_help_text(),
794
902
  )
795
903
  @click.pass_context
796
- def delete_command(
904
+ def delete_command( # noqa: PLR0913
797
905
  ctx: Context,
906
+ iris: tuple[str, ...],
798
907
  all_: bool,
799
908
  include_imports: bool,
800
909
  include_import_statements: bool,
801
- iris: list[str],
910
+ filter_: tuple[tuple[str, str], ...],
802
911
  ) -> None:
803
912
  """Delete graph(s) from the store."""
804
913
  app: ApplicationContext = ctx.obj
805
- graphs = get_graphs_as_dict(writeable=True, readonly=False)
806
- iris = _check_and_extend_exported_graphs(iris, all_, include_imports, graphs)
914
+
915
+ # Validation: require at least one selection method
916
+ if not iris and not all_ and not filter_:
917
+ raise UsageError(
918
+ "Either specify at least one graph IRI or use the --all or "
919
+ "--filter options to specify graphs for deletion."
920
+ )
921
+
922
+ # Get base list of graphs to delete using ObjectList filtering
923
+ graphs_to_delete = _get_graphs_to_delete(ctx, iris, all_, filter_)
924
+ iris_to_delete = [g["iri"] for g in graphs_to_delete]
925
+
926
+ # Handle --include-imports flag
927
+ if include_imports:
928
+ all_graphs = get_graphs_as_dict(writeable=True, readonly=False)
929
+ iris_to_delete = _add_imported_graphs(iris_to_delete, all_graphs)
930
+
931
+ # Remove duplicates and sort
932
+ iris_to_delete = sorted(set(iris_to_delete))
933
+
807
934
  imports_to_be_deleted = []
808
- count: int = len(iris)
809
- for current, iri in enumerate(iris, start=1):
935
+ count: int = len(iris_to_delete)
936
+ for current, iri in enumerate(iris_to_delete, start=1):
937
+ current_string = str(current).zfill(len(str(count)))
810
938
  imports_to_be_deleted += graph_imports_list.apply_filters(
811
939
  ctx=ctx, filter_=[("to-graph", iri)]
812
940
  )
813
941
 
814
- app.echo_info(f"Drop graph {current}/{count}: {iri} ... ", nl=False)
942
+ app.echo_info(f"Delete graph {current_string}/{count}: {iri} ... ", nl=False)
815
943
  graph_api.delete(iri)
816
- app.echo_success("done")
944
+ app.echo_success("deleted")
817
945
  # refresh access conditions in case of dropped AC graph
818
946
  if iri == refresh.AUTHORIZATION_GRAPH_URI:
819
947
  refresh.get()
@@ -823,7 +951,7 @@ def delete_command(
823
951
  imports_cmd = graph.commands["imports"]
824
952
  delete_cmd = imports_cmd.commands["delete"] # type: ignore[attr-defined]
825
953
  for _ in imports_to_be_deleted:
826
- if _["from_graph"] not in iris:
954
+ if _["from_graph"] not in iris_to_delete:
827
955
  ctx.invoke(delete_cmd, from_graph=_["from_graph"], to_graph=_["to_graph"])
828
956
 
829
957
 
@@ -3,7 +3,7 @@
3
3
  import os
4
4
 
5
5
  import click
6
- from click import Argument, ClickException, Context, UsageError
6
+ from click import Argument, Context, UsageError
7
7
  from click.shell_completion import CompletionItem
8
8
  from cmem.cmempy.dp.proxy.graph import get_graph_import_tree
9
9
  from cmem.cmempy.queries import SparqlQuery
@@ -12,8 +12,10 @@ from treelib import Tree
12
12
  from cmem_cmemc import completion
13
13
  from cmem_cmemc.command import CmemcCommand
14
14
  from cmem_cmemc.command_group import CmemcGroup
15
+ from cmem_cmemc.completion import suppress_completion_errors
15
16
  from cmem_cmemc.constants import UNKNOWN_GRAPH_ERROR
16
- from cmem_cmemc.context import ApplicationContext
17
+ from cmem_cmemc.context import ApplicationContext, build_caption
18
+ from cmem_cmemc.exceptions import CmemcError
17
19
  from cmem_cmemc.object_list import DirectValuePropertyFilter, ObjectList
18
20
  from cmem_cmemc.string_processor import GraphLink
19
21
  from cmem_cmemc.title_helper import TitleHelper
@@ -183,7 +185,7 @@ def tree_command(
183
185
 
184
186
  for iri in iris:
185
187
  if iri not in graphs:
186
- raise ClickException(UNKNOWN_GRAPH_ERROR.format(iri))
188
+ raise CmemcError(UNKNOWN_GRAPH_ERROR.format(iri))
187
189
 
188
190
  iris = sorted(iris, key=lambda x: graphs[x]["label"]["title"].lower())
189
191
 
@@ -276,13 +278,16 @@ def list_command(ctx: Context, raw: bool, filter_: tuple[str, str]) -> None:
276
278
  to_graph = rf"\[missing: {to_graph}]"
277
279
  table.append([from_graph, to_graph])
278
280
 
281
+ filtered = len(filters_to_apply) > 0
279
282
  app.echo_info_table(
280
283
  table,
281
284
  headers=["From graph", "To graph"],
282
285
  sort_column=0,
286
+ caption=build_caption(len(table), "import", filtered=filtered),
283
287
  cell_processing={0: GraphLink(), 1: GraphLink()},
284
- empty_table_message="No imports found. "
285
- "You can use the `graph imports create` command to create a graph import.",
288
+ empty_table_message="No imports found for these filters."
289
+ if filtered
290
+ else "No imports found. Use the `graph imports create` command to create a graph import.",
286
291
  )
287
292
 
288
293
 
@@ -295,6 +300,7 @@ def _validate_graphs(from_graph: str | None, to_graph: str | None) -> None:
295
300
  raise click.UsageError(f"To graph {to_graph} not found.")
296
301
 
297
302
 
303
+ @suppress_completion_errors
298
304
  def _from_graph_uris(ctx: Context, param: Argument, incomplete: str) -> list[CompletionItem]:
299
305
  """Provide auto completion items for delete command from-graph argument"""
300
306
  imports = get_imports_list(ctx)
@@ -306,6 +312,7 @@ def _from_graph_uris(ctx: Context, param: Argument, incomplete: str) -> list[Com
306
312
  ]
307
313
 
308
314
 
315
+ @suppress_completion_errors
309
316
  def _to_graph_uris(ctx: Context, param: Argument, incomplete: str) -> list[CompletionItem]:
310
317
  """Provide auto completion items for create/delete command to-graph argument"""
311
318
  from_graph = ctx.params["from_graph"]
@@ -11,11 +11,17 @@ from requests import HTTPError
11
11
 
12
12
  from cmem_cmemc.command import CmemcCommand
13
13
  from cmem_cmemc.command_group import CmemcGroup
14
- from cmem_cmemc.completion import NOT_SORTED, finalize_completion, graph_uris
15
- from cmem_cmemc.context import ApplicationContext
14
+ from cmem_cmemc.completion import (
15
+ NOT_SORTED,
16
+ finalize_completion,
17
+ graph_uris,
18
+ suppress_completion_errors,
19
+ )
20
+ from cmem_cmemc.context import ApplicationContext, build_caption
16
21
  from cmem_cmemc.exceptions import CmemcError
17
22
  from cmem_cmemc.object_list import (
18
23
  DirectListPropertyFilter,
24
+ DirectMultiValuePropertyFilter,
19
25
  DirectValuePropertyFilter,
20
26
  ObjectList,
21
27
  transform_lower,
@@ -66,10 +72,10 @@ def is_available() -> bool:
66
72
 
67
73
  def check_availability(ctx: click.Context) -> None:
68
74
  """Check availability of graph insights endpoints or raise an exception"""
75
+ _ = ctx
69
76
  if is_available():
70
77
  return
71
- app: ApplicationContext = ctx.obj
72
- raise CmemcError(app, "Graph Insights is not available.")
78
+ raise CmemcError("Graph Insights is not available.")
73
79
 
74
80
 
75
81
  def get_snapshots(ctx: click.Context) -> list[dict[str, str | bool | list[str]]]:
@@ -81,6 +87,7 @@ def get_snapshots(ctx: click.Context) -> list[dict[str, str | bool | list[str]]]
81
87
  return data
82
88
 
83
89
 
90
+ @suppress_completion_errors
84
91
  def complete_snapshot_ids(ctx: Context, param: Argument, incomplete: str) -> list[CompletionItem]: # noqa: ARG001
85
92
  """Provide auto-completion for snapshot Ids"""
86
93
  ApplicationContext.set_connection_from_params(ctx.find_root().params)
@@ -131,6 +138,11 @@ snapshot_list = ObjectList(
131
138
  property_key="isValid",
132
139
  transform=transform_lower,
133
140
  ),
141
+ DirectMultiValuePropertyFilter(
142
+ name="ids",
143
+ description="Internal filter for multiple snapshot IDs.",
144
+ property_key="databaseId",
145
+ ),
134
146
  ],
135
147
  )
136
148
 
@@ -181,17 +193,21 @@ def list_command(ctx: Context, filter_: tuple[tuple[str, str]], id_only: bool, r
181
193
  main_graph = rf"\[missing: {main_graph}]"
182
194
  table.append([id_, main_graph, updated, status, is_valid])
183
195
 
196
+ filtered = len(filter_) > 0
184
197
  app.echo_info_table(
185
198
  table,
186
199
  headers=["ID", "Main Graph", "Updated", "Status", "Valid"],
187
200
  sort_column=0,
188
201
  cell_processing={1: GraphLink(), 2: TimeAgo()},
189
- empty_table_message="No graph insight snapshots found.",
202
+ caption=build_caption(len(table), "graph insight snapshot", filtered=filtered),
203
+ empty_table_message="No graph insight snapshots found for these filters."
204
+ if filtered
205
+ else "No graph insight snapshots found.",
190
206
  )
191
207
 
192
208
 
193
209
  @click.command(cls=CmemcCommand, name="delete")
194
- @click.argument("SNAPSHOT_ID", type=str, shell_complete=complete_snapshot_ids, required=False)
210
+ @click.argument("snapshot_ids", nargs=-1, type=click.STRING, shell_complete=complete_snapshot_ids)
195
211
  @click.option(
196
212
  "--filter",
197
213
  "filter_",
@@ -203,41 +219,61 @@ def list_command(ctx: Context, filter_: tuple[tuple[str, str]], id_only: bool, r
203
219
  @click.option("-a", "--all", "all_", is_flag=True, help="Delete all snapshots.")
204
220
  @click.pass_context
205
221
  def delete_command(
206
- ctx: Context, snapshot_id: str | None, filter_: tuple[tuple[str, str]], all_: bool
222
+ ctx: Context, snapshot_ids: tuple[str], filter_: tuple[tuple[str, str]], all_: bool
207
223
  ) -> None:
208
- """Delete a graph insight snapshot.
224
+ """Delete graph insight snapshots.
209
225
 
210
226
  Graph Insight Snapshots are identified by an ID.
211
- To get a list of existing snapshots,
212
- execute the `graph insights list` command or use tab-completion.
227
+
228
+ Warning: Snapshots will be deleted without prompting.
229
+
230
+ Note: Snapshots can be listed by using the `graph insights list` command.
213
231
  """
214
232
  check_availability(ctx)
215
233
  app: ApplicationContext = ctx.obj
216
- if snapshot_id is None and not filter_ and not all_:
217
- raise click.UsageError("Either provide a snapshot ID or a filter, or use the --all flag.")
234
+
235
+ # Validation: require at least one selection method
236
+ if not snapshot_ids and not filter_ and not all_:
237
+ raise click.UsageError(
238
+ "Either provide at least one snapshot ID, "
239
+ "use a --filter option, or use the --all flag."
240
+ )
241
+
242
+ if snapshot_ids and (all_ or filter_):
243
+ raise click.UsageError("Either specify snapshot IDs OR use a --filter or the --all option.")
218
244
 
219
245
  if all_:
220
246
  app.echo_info("Deleting all snapshots ... ", nl=False)
221
247
  request(method="DELETE", uri=get_api_url("/snapshot"))
222
- app.echo_success("done")
248
+ app.echo_success("deleted")
223
249
  return
224
250
 
251
+ # Get snapshots to delete based on selection method
225
252
  filter_to_apply = list(filter_) if filter_ else []
226
- if snapshot_id:
227
- filter_to_apply.append(("id", snapshot_id))
253
+ if snapshot_ids:
254
+ # Use internal multi-value filter for multiple IDs
255
+ filter_to_apply.append(("ids", ",".join(snapshot_ids)))
228
256
  snapshots_to_delete = snapshot_list.apply_filters(ctx=ctx, filter_=filter_to_apply)
229
257
 
230
- if not snapshots_to_delete and snapshot_id:
231
- raise click.ClickException(f"Snapshot ID '{snapshot_id}' does not exist.")
258
+ if not snapshots_to_delete and snapshot_ids:
259
+ raise CmemcError(
260
+ f"Snapshot ID(s) {', '.join(snapshot_ids)} not found. "
261
+ "Use the 'graph insights list' command to get a list of existing snapshots."
262
+ )
263
+
264
+ if not snapshots_to_delete and not snapshot_ids:
265
+ raise CmemcError("No snapshots found to delete.")
232
266
 
233
- if not snapshots_to_delete and not snapshot_id:
234
- raise click.ClickException("No snapshots found to delete.")
267
+ # Avoid double removal as well as sort IDs
268
+ ids_to_delete = sorted({_["databaseId"] for _ in snapshots_to_delete}, key=lambda v: v.lower())
269
+ count = len(ids_to_delete)
235
270
 
236
- for _ in snapshots_to_delete:
237
- id_to_delete = _["databaseId"]
238
- app.echo_info(f"Deleting snapshot {id_to_delete} ... ", nl=False)
271
+ # Delete each snapshot
272
+ for current, id_to_delete in enumerate(ids_to_delete, start=1):
273
+ current_string = str(current).zfill(len(str(count)))
274
+ app.echo_info(f"Delete snapshot {current_string}/{count}: {id_to_delete} ... ", nl=False)
239
275
  request(method="DELETE", uri=get_api_url(f"/snapshot/{id_to_delete}"))
240
- app.echo_success("done")
276
+ app.echo_success("deleted")
241
277
 
242
278
 
243
279
  def wait_for_snapshot(snapshot_id: str, polling_interval: int) -> None:
@@ -331,10 +367,10 @@ def update_command( # noqa: PLR0913
331
367
  snapshots_to_update = get_snapshots(ctx)
332
368
 
333
369
  if not snapshots_to_update and snapshot_id:
334
- raise click.ClickException(f"Snapshot ID '{snapshot_id}' does not exist.")
370
+ raise CmemcError(f"Snapshot ID '{snapshot_id}' does not exist.")
335
371
 
336
372
  if not snapshots_to_update and not snapshot_id:
337
- raise click.ClickException("No snapshots found to update.")
373
+ raise CmemcError("No snapshots found to update.")
338
374
 
339
375
  for _ in snapshots_to_update:
340
376
  id_to_update = _["databaseId"]