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
@@ -1,6 +1,6 @@
1
1
  """Graph Insights command group"""
2
2
 
3
- from typing import TYPE_CHECKING
3
+ import time
4
4
 
5
5
  import click
6
6
  from click import Argument, Context
@@ -11,10 +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
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
15
21
  from cmem_cmemc.exceptions import CmemcError
16
22
  from cmem_cmemc.object_list import (
17
23
  DirectListPropertyFilter,
24
+ DirectMultiValuePropertyFilter,
18
25
  DirectValuePropertyFilter,
19
26
  ObjectList,
20
27
  transform_lower,
@@ -22,11 +29,30 @@ from cmem_cmemc.object_list import (
22
29
  from cmem_cmemc.string_processor import GraphLink, TimeAgo
23
30
  from cmem_cmemc.utils import get_graphs_as_dict, struct_to_table
24
31
 
25
- if TYPE_CHECKING:
26
- from cmem_cmemc.context import ApplicationContext
27
32
 
33
+ def get_api_url(path: str = "") -> str:
34
+ """Get URLs of the graph insights API.
35
+
36
+ Constructs the full URL for accessing graph insights API endpoints by combining
37
+ the DataPlatform API endpoint with the semspect extension base path and an
38
+ optional resource path.
39
+
40
+ Args:
41
+ path: The API resource path to append to the base URL. Defaults to an empty
42
+ string for the root endpoint.
43
+
44
+ Returns:
45
+ The complete URL for the specified graph insights API endpoint.
46
+
47
+ Example:
48
+ >>> get_api_url()
49
+ 'https://example.com/dataplatform/api/ext/semspect'
50
+ >>> get_api_url("/snapshot/status")
51
+ 'https://example.com/dataplatform/api/ext/semspect/snapshot/status'
28
52
 
29
- API_BASE = get_dp_api_endpoint() + "/api/ext/semspect"
53
+ """
54
+ base_url = get_dp_api_endpoint() + "/api/ext/semspect"
55
+ return f"{base_url}{path}"
30
56
 
31
57
 
32
58
  def is_available() -> bool:
@@ -38,7 +64,7 @@ def is_available() -> bool:
38
64
  }
39
65
  """
40
66
  try:
41
- data: dict[str, bool] = get_json(API_BASE)
67
+ data: dict[str, bool] = get_json(get_api_url())
42
68
  except HTTPError:
43
69
  return False
44
70
  return bool(data["isActive"] is True and data["isUserAllowed"] is True)
@@ -46,23 +72,27 @@ def is_available() -> bool:
46
72
 
47
73
  def check_availability(ctx: click.Context) -> None:
48
74
  """Check availability of graph insights endpoints or raise an exception"""
75
+ _ = ctx
49
76
  if is_available():
50
77
  return
51
- app: ApplicationContext = ctx.obj
52
- raise CmemcError(app, "Graph Insights is not available.")
78
+ raise CmemcError("Graph Insights is not available.")
53
79
 
54
80
 
55
81
  def get_snapshots(ctx: click.Context) -> list[dict[str, str | bool | list[str]]]:
56
82
  """Get the snapshot list (all snapshots)"""
57
83
  check_availability(ctx)
58
84
  data: list[dict[str, str | bool | list[str]]] = get_json(
59
- API_BASE + "/snapshot/status", params={"includeManagementOnly": True}
85
+ get_api_url("/snapshot/status"), params={"includeManagementOnly": True}
60
86
  )
61
87
  return data
62
88
 
63
89
 
90
+ @suppress_completion_errors
64
91
  def complete_snapshot_ids(ctx: Context, param: Argument, incomplete: str) -> list[CompletionItem]: # noqa: ARG001
65
92
  """Provide auto-completion for snapshot Ids"""
93
+ ApplicationContext.set_connection_from_params(ctx.find_root().params)
94
+ if not is_available():
95
+ return []
66
96
  snapshots = get_snapshots(ctx)
67
97
  snapshots = sorted(
68
98
  snapshots, key=lambda snapshot: snapshot["updateInfoTimestamp"], reverse=True
@@ -102,6 +132,17 @@ snapshot_list = ObjectList(
102
132
  description="Snapshots with a specific affected graph (main or sub-graphs).",
103
133
  property_key="allGraphsSynced",
104
134
  ),
135
+ DirectValuePropertyFilter(
136
+ name="valid",
137
+ description="Snapshots with a specific validity indicator.",
138
+ property_key="isValid",
139
+ transform=transform_lower,
140
+ ),
141
+ DirectMultiValuePropertyFilter(
142
+ name="ids",
143
+ description="Internal filter for multiple snapshot IDs.",
144
+ property_key="databaseId",
145
+ ),
105
146
  ],
106
147
  )
107
148
 
@@ -147,21 +188,26 @@ def list_command(ctx: Context, filter_: tuple[tuple[str, str]], id_only: bool, r
147
188
  main_graph = _["mainGraphSynced"]
148
189
  updated = _["updateInfoTimestamp"]
149
190
  status = _["status"]
191
+ is_valid = _["isValid"]
150
192
  if main_graph not in graphs:
151
193
  main_graph = rf"\[missing: {main_graph}]"
152
- table.append([id_, main_graph, updated, status])
194
+ table.append([id_, main_graph, updated, status, is_valid])
153
195
 
196
+ filtered = len(filter_) > 0
154
197
  app.echo_info_table(
155
198
  table,
156
- headers=["ID", "Main Graph", "Updated", "Status"],
199
+ headers=["ID", "Main Graph", "Updated", "Status", "Valid"],
157
200
  sort_column=0,
158
201
  cell_processing={1: GraphLink(), 2: TimeAgo()},
159
- 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.",
160
206
  )
161
207
 
162
208
 
163
209
  @click.command(cls=CmemcCommand, name="delete")
164
- @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)
165
211
  @click.option(
166
212
  "--filter",
167
213
  "filter_",
@@ -173,49 +219,87 @@ def list_command(ctx: Context, filter_: tuple[tuple[str, str]], id_only: bool, r
173
219
  @click.option("-a", "--all", "all_", is_flag=True, help="Delete all snapshots.")
174
220
  @click.pass_context
175
221
  def delete_command(
176
- 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
177
223
  ) -> None:
178
- """Delete a graph insight snapshot.
224
+ """Delete graph insight snapshots.
179
225
 
180
226
  Graph Insight Snapshots are identified by an ID.
181
- To get a list of existing snapshots,
182
- 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.
183
231
  """
184
232
  check_availability(ctx)
185
233
  app: ApplicationContext = ctx.obj
186
- if snapshot_id is None and not filter_ and not all_:
187
- 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.")
188
244
 
189
245
  if all_:
190
246
  app.echo_info("Deleting all snapshots ... ", nl=False)
191
- request(method="DELETE", uri=f"{API_BASE}/snapshot")
192
- app.echo_success("done")
247
+ request(method="DELETE", uri=get_api_url("/snapshot"))
248
+ app.echo_success("deleted")
193
249
  return
194
250
 
195
- all_snapshots = get_snapshots(ctx)
196
- all_snapshot_ids = [_["databaseId"] for _ in all_snapshots]
251
+ # Get snapshots to delete based on selection method
197
252
  filter_to_apply = list(filter_) if filter_ else []
198
- if snapshot_id:
199
- 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)))
200
256
  snapshots_to_delete = snapshot_list.apply_filters(ctx=ctx, filter_=filter_to_apply)
201
- if not snapshots_to_delete:
202
- raise click.UsageError("No snapshots found to delete.")
203
257
 
204
- for _ in snapshots_to_delete:
205
- id_to_delete = _["databaseId"]
206
- if id_to_delete not in all_snapshot_ids:
207
- raise click.UsageError(f"Snapshot ID '{id_to_delete}' does not exist.")
208
- for _ in snapshots_to_delete:
209
- id_to_delete = _["databaseId"]
210
- app.echo_info(f"Deleting snapshot {id_to_delete} ... ", nl=False)
211
- request(method="DELETE", uri=f"{API_BASE}/snapshot/{id_to_delete}")
212
- app.echo_success("done")
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.")
266
+
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)
270
+
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)
275
+ request(method="DELETE", uri=get_api_url(f"/snapshot/{id_to_delete}"))
276
+ app.echo_success("deleted")
277
+
278
+
279
+ def wait_for_snapshot(snapshot_id: str, polling_interval: int) -> None:
280
+ """Poll until the snapshot reaches 'DONE' status."""
281
+ while True:
282
+ snapshot: dict[str, str | bool | list[str]] = get_json(
283
+ get_api_url(f"/snapshot/status/{snapshot_id}")
284
+ )
285
+ if snapshot.get("status") == "DONE":
286
+ break
287
+ time.sleep(polling_interval)
213
288
 
214
289
 
215
290
  @click.command(cls=CmemcCommand, name="create")
216
291
  @click.argument("iri", type=click.STRING, shell_complete=graph_uris)
292
+ @click.option("--wait", is_flag=True, help="Wait until snapshot creation is done.")
293
+ @click.option(
294
+ "--polling-interval",
295
+ type=click.IntRange(min=0, max=60),
296
+ show_default=True,
297
+ default=1,
298
+ help="How many seconds to wait between status polls. Status polls are"
299
+ " cheap, so a higher polling interval is most likely not needed.",
300
+ )
217
301
  @click.pass_context
218
- def create_command(ctx: Context, iri: str) -> None:
302
+ def create_command(ctx: Context, iri: str, wait: bool, polling_interval: int) -> None:
219
303
  """Create or update a graph insight snapshot.
220
304
 
221
305
  Create a graph insight snapshot for a given graph.
@@ -225,8 +309,14 @@ def create_command(ctx: Context, iri: str) -> None:
225
309
  check_availability(ctx)
226
310
  app: ApplicationContext = ctx.obj
227
311
  app.echo_info(f"Create / Update graph snapshot for graph {iri} ... ", nl=False)
228
- request(method="POST", uri=f"{API_BASE}/snapshot", params={"contextGraph": iri})
229
- app.echo_success("started")
312
+ snapshot_id = request(
313
+ method="POST", uri=get_api_url("/snapshot"), params={"contextGraph": iri}
314
+ ).text
315
+ app.echo_success("started", nl=not wait)
316
+ if wait:
317
+ app.echo_info(" ... ", nl=False)
318
+ wait_for_snapshot(snapshot_id, polling_interval)
319
+ app.echo_success("created")
230
320
 
231
321
 
232
322
  @click.command(cls=CmemcCommand, name="update")
@@ -240,9 +330,23 @@ def create_command(ctx: Context, iri: str) -> None:
240
330
  multiple=True,
241
331
  )
242
332
  @click.option("-a", "--all", "all_", is_flag=True, help="Delete all snapshots.")
333
+ @click.option("--wait", is_flag=True, help="Wait until snapshot creation is done.")
334
+ @click.option(
335
+ "--polling-interval",
336
+ type=click.IntRange(min=0, max=60),
337
+ show_default=True,
338
+ default=1,
339
+ help="How many seconds to wait between status polls. Status polls are"
340
+ " cheap, so a higher polling interval is most likely not needed.",
341
+ )
243
342
  @click.pass_context
244
- def update_command(
245
- ctx: Context, snapshot_id: str | None, filter_: tuple[tuple[str, str]], all_: bool
343
+ def update_command( # noqa: PLR0913
344
+ ctx: Context,
345
+ snapshot_id: str | None,
346
+ filter_: tuple[tuple[str, str]],
347
+ all_: bool,
348
+ wait: bool,
349
+ polling_interval: int,
246
350
  ) -> None:
247
351
  """Update a graph insight snapshot.
248
352
 
@@ -259,24 +363,24 @@ def update_command(
259
363
  filter_to_apply.append(("id", snapshot_id))
260
364
  snapshots_to_update = snapshot_list.apply_filters(ctx=ctx, filter_=filter_to_apply)
261
365
 
262
- all_snapshots = get_snapshots(ctx)
263
- all_snapshot_ids = [_["databaseId"] for _ in all_snapshots]
264
-
265
366
  if all_:
266
367
  snapshots_to_update = get_snapshots(ctx)
267
368
 
268
- if not snapshots_to_update:
269
- raise click.UsageError("No snapshots found to delete.")
369
+ if not snapshots_to_update and snapshot_id:
370
+ raise CmemcError(f"Snapshot ID '{snapshot_id}' does not exist.")
371
+
372
+ if not snapshots_to_update and not snapshot_id:
373
+ raise CmemcError("No snapshots found to update.")
270
374
 
271
- for _ in snapshots_to_update:
272
- id_to_update = _["databaseId"]
273
- if id_to_update not in all_snapshot_ids:
274
- raise click.UsageError(f"Snapshot ID '{id_to_update}' does not exist.")
275
375
  for _ in snapshots_to_update:
276
376
  id_to_update = _["databaseId"]
277
377
  app.echo_info(f"Update snapshot {id_to_update} ... ", nl=False)
278
- request(method="PUT", uri=f"{API_BASE}/snapshot/{snapshot_id}")
279
- app.echo_success("started")
378
+ request(method="PUT", uri=get_api_url(f"/snapshot/{id_to_update}"))
379
+ app.echo_success("started", nl=not wait)
380
+ if wait:
381
+ app.echo_info(" ... ", nl=False)
382
+ wait_for_snapshot(id_to_update, polling_interval)
383
+ app.echo_success("updated")
280
384
 
281
385
 
282
386
  @click.command(cls=CmemcCommand, name="inspect")
@@ -288,7 +392,7 @@ def inspect_command(ctx: Context, snapshot_id: str, raw: bool) -> None:
288
392
  check_availability(ctx)
289
393
  app: ApplicationContext = ctx.obj
290
394
  snapshot: dict[str, str | bool | list[str]] = get_json(
291
- f"{API_BASE}/snapshot/status/{snapshot_id}"
395
+ get_api_url(f"/snapshot/status/{snapshot_id}")
292
396
  )
293
397
  if raw:
294
398
  app.echo_info_json(snapshot)
@@ -1,17 +1,19 @@
1
1
  """metrics commands for cmem command line interface."""
2
2
 
3
3
  import click
4
- from click import Argument, ClickException, Context, UsageError
4
+ from click import Argument, Context, UsageError
5
5
  from click.shell_completion import CompletionItem
6
6
  from cmem.cmempy.api import request
7
- from cmem.cmempy.config import get_cmem_base_uri, get_di_api_endpoint, get_dp_api_endpoint
7
+ from cmem.cmempy.config import get_di_api_endpoint, get_dp_api_endpoint
8
8
  from prometheus_client.parser import text_string_to_metric_families
9
9
  from requests import HTTPError
10
10
 
11
11
  from cmem_cmemc import completion
12
12
  from cmem_cmemc.command import CmemcCommand
13
13
  from cmem_cmemc.command_group import CmemcGroup
14
- from cmem_cmemc.context import ApplicationContext
14
+ from cmem_cmemc.completion import suppress_completion_errors
15
+ from cmem_cmemc.context import ApplicationContext, build_caption
16
+ from cmem_cmemc.exceptions import CmemcError
15
17
  from cmem_cmemc.object_list import (
16
18
  DirectValuePropertyFilter,
17
19
  ObjectList,
@@ -94,6 +96,7 @@ metrics_list = ObjectList(
94
96
  )
95
97
 
96
98
 
99
+ @suppress_completion_errors
97
100
  def _complete_metrics_id(ctx: Context, param: Argument, incomplete: str) -> list[CompletionItem]: # noqa: ARG001
98
101
  """Prepare a list of metric identifier."""
99
102
  ApplicationContext.set_connection_from_params(ctx.find_root().params)
@@ -101,6 +104,7 @@ def _complete_metrics_id(ctx: Context, param: Argument, incomplete: str) -> list
101
104
  return completion.finalize_completion(candidates=candidates, incomplete=incomplete)
102
105
 
103
106
 
107
+ @suppress_completion_errors
104
108
  def _complete_metric_label_filter(
105
109
  ctx: Context,
106
110
  param: Argument, # noqa: ARG001
@@ -141,11 +145,11 @@ def _filter_samples(family: dict, label_filter: tuple[tuple[str, str], ...]) ->
141
145
  sample_labels = sample[1]
142
146
  for name, value in label_filter:
143
147
  if name not in labels:
144
- raise ClickException(
148
+ raise CmemcError(
145
149
  f"The metric '{family_name}' does " f"not have a label named '{name}'."
146
150
  )
147
151
  if value not in labels[name]:
148
- raise ClickException(
152
+ raise CmemcError(
149
153
  f"The metric '{family_name}' does "
150
154
  f"not have a label '{name}' with the value '{value}'."
151
155
  )
@@ -157,7 +161,6 @@ def _filter_samples(family: dict, label_filter: tuple[tuple[str, str], ...]) ->
157
161
  return samples
158
162
 
159
163
 
160
- # pylint: disable-msg=too-many-arguments
161
164
  @click.command(cls=CmemcCommand, name="get")
162
165
  @click.argument("metric_id", required=True, type=click.STRING, shell_complete=_complete_metrics_id)
163
166
  @click.option(
@@ -214,7 +217,7 @@ def get_command(
214
217
  return
215
218
 
216
219
  if len(samples) == 0:
217
- raise ClickException(
220
+ raise CmemcError(
218
221
  "No data - the given label combination filtered out "
219
222
  f"all available samples of the metric {metric_id}."
220
223
  )
@@ -308,12 +311,15 @@ def list_command(
308
311
  ]
309
312
  for _ in data
310
313
  ]
314
+ filtered = len(filter_) > 0
311
315
  app.echo_info_table(
312
316
  table,
313
317
  headers=["ID", "Type", "L", "S", "Documentation"],
314
318
  sort_column=0,
315
- caption=f"{len(table)} metrics families of {get_cmem_base_uri()}",
316
- empty_table_message="No metrics families available.",
319
+ caption=build_caption(len(table), "metric", filtered=filtered),
320
+ empty_table_message="No metrics found for these filters."
321
+ if filtered
322
+ else "No metrics available.",
317
323
  )
318
324
 
319
325
 
@@ -8,8 +8,12 @@ from click.shell_completion import CompletionItem
8
8
 
9
9
  from cmem_cmemc.command import CmemcCommand
10
10
  from cmem_cmemc.command_group import CmemcGroup
11
- from cmem_cmemc.completion import check_option_in_params, finalize_completion
12
- from cmem_cmemc.context import ApplicationContext
11
+ from cmem_cmemc.completion import (
12
+ check_option_in_params,
13
+ finalize_completion,
14
+ suppress_completion_errors,
15
+ )
16
+ from cmem_cmemc.context import ApplicationContext, build_caption
13
17
  from cmem_cmemc.migrations.access_conditions_243 import (
14
18
  MoveAccessConditionsToNewGraph,
15
19
  RenameAuthVocabularyResources,
@@ -65,6 +69,7 @@ def get_migrations(ctx: click.Context) -> list[dict]: # noqa: ARG001
65
69
  return data
66
70
 
67
71
 
72
+ @suppress_completion_errors
68
73
  def complete_migration_ids(ctx: Context, param: Argument, incomplete: str) -> list[CompletionItem]:
69
74
  """Prepare a list of migration recipe IDs"""
70
75
  ApplicationContext.set_connection_from_params(ctx.find_root().params)
@@ -153,12 +158,15 @@ def list_command(
153
158
  ]
154
159
  for _ in data
155
160
  ]
161
+ filtered = len(filter_) > 0
156
162
  app.echo_info_table(
157
163
  table,
158
164
  headers=["ID", "Description", "Tags", "First Version"],
159
165
  sort_column=3,
160
- caption=f"{len(table)} migration(s)",
161
- empty_table_message="No migrations available.",
166
+ caption=build_caption(len(table), "migration", filtered=filtered),
167
+ empty_table_message="No migrations found for these filters."
168
+ if filtered
169
+ else "No migrations available.",
162
170
  )
163
171
 
164
172