cmem-cmemc 25.3.0__tar.gz → 25.5.0rc1__tar.gz

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 (60) hide show
  1. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/PKG-INFO +6 -4
  2. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/command_group.py +1 -0
  3. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/acl.py +80 -2
  4. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/admin.py +31 -4
  5. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/graph.py +12 -5
  6. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/graph_imports.py +3 -6
  7. cmem_cmemc-25.5.0rc1/cmem_cmemc/commands/graph_insights.py +314 -0
  8. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/query.py +54 -19
  9. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/store.py +6 -2
  10. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/validation.py +5 -3
  11. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/workflow.py +1 -0
  12. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/completion.py +17 -18
  13. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/context.py +2 -2
  14. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/manual_helper/multi_page.py +2 -0
  15. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/string_processor.py +8 -3
  16. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/pyproject.toml +5 -4
  17. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/LICENSE +0 -0
  18. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/README-public.md +0 -0
  19. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/__init__.py +0 -0
  20. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/_cmemc.zsh +0 -0
  21. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/cli.py +0 -0
  22. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/command.py +0 -0
  23. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/__init__.py +0 -0
  24. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/client.py +0 -0
  25. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/config.py +0 -0
  26. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/dataset.py +0 -0
  27. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/manual.py +0 -0
  28. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/metrics.py +0 -0
  29. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/migration.py +0 -0
  30. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/project.py +0 -0
  31. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/python.py +0 -0
  32. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/resource.py +0 -0
  33. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/scheduler.py +0 -0
  34. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/user.py +0 -0
  35. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/variable.py +0 -0
  36. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/vocabulary.py +0 -0
  37. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/commands/workspace.py +0 -0
  38. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/config_parser.py +0 -0
  39. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/constants.py +0 -0
  40. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/exceptions.py +0 -0
  41. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/manual_helper/__init__.py +0 -0
  42. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/manual_helper/graph.py +0 -0
  43. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/manual_helper/single_page.py +0 -0
  44. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/migrations/__init__.py +0 -0
  45. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/migrations/abc.py +0 -0
  46. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/migrations/access_conditions_243.py +0 -0
  47. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/migrations/bootstrap_data.py +0 -0
  48. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/migrations/remove_noop_triple_251.py +0 -0
  49. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/migrations/shapes_widget_integrations_243.py +0 -0
  50. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/migrations/sparql_query_texts_242.py +0 -0
  51. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/migrations/workspace_configurations.py +0 -0
  52. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/object_list.py +0 -0
  53. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/parameter_types/__init__.py +0 -0
  54. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/parameter_types/path.py +0 -0
  55. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/placeholder.py +0 -0
  56. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/smart_path/__init__.py +0 -0
  57. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/smart_path/clients/__init__.py +0 -0
  58. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/smart_path/clients/http.py +0 -0
  59. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/title_helper.py +0 -0
  60. {cmem_cmemc-25.3.0 → cmem_cmemc-25.5.0rc1}/cmem_cmemc/utils.py +0 -0
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: cmem-cmemc
3
- Version: 25.3.0
3
+ Version: 25.5.0rc1
4
4
  Summary: Command line client for eccenca Corporate Memory
5
5
  License: Apache-2.0
6
+ License-File: LICENSE
6
7
  Author: eccenca
7
8
  Author-email: cmempy-developer@eccenca.com
8
9
  Requires-Python: >=3.10,<4.0
@@ -21,16 +22,17 @@ Classifier: Programming Language :: Python :: 3.10
21
22
  Classifier: Programming Language :: Python :: 3.11
22
23
  Classifier: Programming Language :: Python :: 3.12
23
24
  Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
24
26
  Classifier: Programming Language :: Python :: 3 :: Only
25
27
  Classifier: Topic :: Database
26
28
  Classifier: Topic :: Software Development :: Testing
27
29
  Classifier: Topic :: Utilities
28
30
  Requires-Dist: beautifulsoup4 (>=4.13.3,<5.0.0)
29
31
  Requires-Dist: certifi (>=2024.2.2)
30
- Requires-Dist: click (>=8.1.8,<8.2.0)
32
+ Requires-Dist: click (>=8.3.0,<9.0.0)
31
33
  Requires-Dist: click-didyoumean (>=0.3.1,<0.4.0)
32
34
  Requires-Dist: click-help-colors (>=0.9.4,<0.10.0)
33
- Requires-Dist: cmem-cmempy (==25.2.0)
35
+ Requires-Dist: cmem-cmempy (==25.4.0)
34
36
  Requires-Dist: configparser (>=7.2.0,<8.0.0)
35
37
  Requires-Dist: jinja2 (>=3.1.6,<4.0.0)
36
38
  Requires-Dist: junit-xml (>=1.9,<2.0)
@@ -66,6 +66,7 @@ class CmemcGroup(HelpColorsGroup, DYMGroup):
66
66
  "migrate": self.color_for_writing_commands,
67
67
  "migrations": self.color_for_command_groups,
68
68
  "imports": self.color_for_command_groups,
69
+ "insights": self.color_for_command_groups,
69
70
  },
70
71
  )
71
72
  super().__init__(*args, **kwargs)
@@ -35,6 +35,21 @@ HELP_TEXTS = {
35
35
  "read_graph": "Grants read access to a graph.",
36
36
  "write_graph": "Grants write access to a graph (includes read access).",
37
37
  "action": "Grants usage permissions to an action / functionality.",
38
+ "read_graph_pattern": (
39
+ "Grants management of conditions granting read access on graphs matching the defined "
40
+ "pattern. A pattern consists of a constant string and a wildcard ('*') at the end of "
41
+ "the pattern or the wildcard alone."
42
+ ),
43
+ "write_graph_pattern": (
44
+ "Grants management of conditions granting write access on graphs matching the defined "
45
+ "pattern. A pattern consists of a constant string and a wildcard ('*') at the end of "
46
+ "the pattern or the wildcard alone."
47
+ ),
48
+ "action_pattern": (
49
+ "Grants management of conditions granting action allowance for actions matching the "
50
+ "defined pattern. A pattern consists of a constant string and a wildcard ('*') at the "
51
+ "end of the pattern or the wildcard alone."
52
+ ),
38
53
  "query": "Dynamic access condition query (file or the query catalog IRI).",
39
54
  }
40
55
 
@@ -194,6 +209,27 @@ def inspect_command(app: ApplicationContext, access_condition_id: str, raw: bool
194
209
  shell_complete=completion.acl_actions,
195
210
  help=HELP_TEXTS["action"],
196
211
  )
212
+ @click.option(
213
+ "--read-graph-pattern",
214
+ "read_graph_patterns",
215
+ type=click.STRING,
216
+ multiple=True,
217
+ help=HELP_TEXTS["read_graph_pattern"],
218
+ )
219
+ @click.option(
220
+ "--write-graph-pattern",
221
+ "write_graph_patterns",
222
+ type=click.STRING,
223
+ multiple=True,
224
+ help=HELP_TEXTS["write_graph_pattern"],
225
+ )
226
+ @click.option(
227
+ "--action-pattern",
228
+ "action_patterns",
229
+ type=click.STRING,
230
+ multiple=True,
231
+ help=HELP_TEXTS["action_pattern"],
232
+ )
197
233
  @click.option(
198
234
  "--query",
199
235
  "query",
@@ -231,6 +267,9 @@ def create_command( # noqa: PLR0913
231
267
  read_graphs: tuple[str],
232
268
  write_graphs: tuple[str],
233
269
  actions: tuple[str],
270
+ read_graph_patterns: tuple[str],
271
+ write_graph_patterns: tuple[str],
272
+ action_patterns: tuple[str],
234
273
  query: str,
235
274
  ) -> None:
236
275
  """Create an access condition.
@@ -254,10 +293,19 @@ def create_command( # noqa: PLR0913
254
293
 
255
294
  Example: cmemc admin acl create --group local-users --write-graph https://example.org/
256
295
  """
257
- if not read_graphs and not write_graphs and not actions and not query:
296
+ if (
297
+ not read_graphs
298
+ and not write_graphs
299
+ and not actions
300
+ and not read_graph_patterns
301
+ and not write_graph_patterns
302
+ and not action_patterns
303
+ and not query
304
+ ):
258
305
  raise click.UsageError(
259
306
  "Missing access / usage grant. Use at least one of the following options: "
260
- "--read-graph, --write-graph, --action or --query."
307
+ "--read-graph, --write-graph, --action, --read-graph-pattern, "
308
+ "--write-graph-pattern, --action-pattern or --query."
261
309
  )
262
310
  query_str = None
263
311
  if query:
@@ -286,6 +334,9 @@ def create_command( # noqa: PLR0913
286
334
  read_graphs=list(read_graphs),
287
335
  write_graphs=list(write_graphs),
288
336
  actions=[convert_qname_to_iri(qname=_, default_ns=NS_ACTION) for _ in actions],
337
+ read_graph_patterns=list(read_graph_patterns),
338
+ write_graph_patterns=list(write_graph_patterns),
339
+ action_patterns=list(action_patterns),
289
340
  query=query_str,
290
341
  )
291
342
  app.echo_success("done")
@@ -351,6 +402,27 @@ def create_command( # noqa: PLR0913
351
402
  shell_complete=completion.acl_actions,
352
403
  help=HELP_TEXTS["action"],
353
404
  )
405
+ @click.option(
406
+ "--read-graph-pattern",
407
+ "read_graph_patterns",
408
+ type=click.STRING,
409
+ multiple=True,
410
+ help=HELP_TEXTS["read_graph_pattern"],
411
+ )
412
+ @click.option(
413
+ "--write-graph-pattern",
414
+ "write_graph_patterns",
415
+ type=click.STRING,
416
+ multiple=True,
417
+ help=HELP_TEXTS["write_graph_pattern"],
418
+ )
419
+ @click.option(
420
+ "--action-pattern",
421
+ "action_patterns",
422
+ type=click.STRING,
423
+ multiple=True,
424
+ help=HELP_TEXTS["action_pattern"],
425
+ )
354
426
  @click.option(
355
427
  "--query",
356
428
  "query",
@@ -370,6 +442,9 @@ def update_command( # noqa: PLR0913
370
442
  read_graphs: tuple[str],
371
443
  write_graphs: tuple[str],
372
444
  actions: tuple[str],
445
+ read_graph_patterns: tuple[str],
446
+ write_graph_patterns: tuple[str],
447
+ action_patterns: tuple[str],
373
448
  query: str,
374
449
  ) -> None:
375
450
  """Update an access condition.
@@ -396,6 +471,9 @@ def update_command( # noqa: PLR0913
396
471
  read_graphs=read_graphs,
397
472
  write_graphs=write_graphs,
398
473
  actions=[convert_qname_to_iri(qname=_, default_ns=NS_ACTION) for _ in actions],
474
+ read_graph_patterns=read_graph_patterns,
475
+ write_graph_patterns=write_graph_patterns,
476
+ action_patterns=action_patterns,
399
477
  query=query_str,
400
478
  )
401
479
  app.echo_success("done")
@@ -4,6 +4,7 @@ from datetime import datetime, timezone
4
4
 
5
5
  import click
6
6
  import jwt
7
+ import timeago
7
8
  from click import ClickException
8
9
  from cmem.cmempy.api import get_access_token, get_token
9
10
  from cmem.cmempy.config import get_cmem_base_uri
@@ -185,8 +186,14 @@ def status_command( # noqa: C901, PLR0912
185
186
  help="Decode the access token and outputs the raw JSON. Note that the "
186
187
  "access token is only decoded and esp. not validated.",
187
188
  )
189
+ @click.option(
190
+ "--ttl",
191
+ is_flag=True,
192
+ help="Output information about the lifetime of the access token. "
193
+ "In combination with --raw, it outputs the TTL in seconds.",
194
+ )
188
195
  @click.pass_obj
189
- def token_command(app: ApplicationContext, raw: bool, decode: bool) -> None:
196
+ def token_command(app: ApplicationContext, raw: bool, decode: bool, ttl: bool) -> None:
190
197
  """Fetch and output an access token.
191
198
 
192
199
  This command can be used to check for correct authentication as well as
@@ -202,9 +209,30 @@ def token_command(app: ApplicationContext, raw: bool, decode: bool) -> None:
202
209
  # - get_access_token returns the token string which is maybe from conf
203
210
  # - get_token fetches a new token incl. envelope from keycloak
204
211
 
212
+ token = get_access_token()
213
+ decoded_token = jwt.decode(token, options={"verify_signature": False})
214
+ if ttl:
215
+ app.echo_debug(token)
216
+ iat_ts = decoded_token["iat"]
217
+ exp_ts = decoded_token["exp"]
218
+ ttl_in_seconds = exp_ts - iat_ts
219
+ if raw:
220
+ app.echo_info_json(ttl_in_seconds)
221
+ return
222
+ exp_time = datetime.fromtimestamp(exp_ts, tz=timezone.utc)
223
+ now_time = datetime.now(tz=timezone.utc)
224
+ ttl_delta = timeago.format(exp_time, now_time)
225
+ if ttl_delta.startswith("in"):
226
+ app.echo_info(
227
+ f"The provided access token will expire {ttl_delta} "
228
+ f"(TTL is {ttl_in_seconds} seconds)."
229
+ )
230
+ else:
231
+ app.echo_info(
232
+ f"The provided access token expired {ttl_delta} (TTL was {ttl_in_seconds} seconds)."
233
+ )
234
+ return
205
235
  if decode:
206
- token = get_access_token()
207
- decoded_token = jwt.decode(token, options={"verify_signature": False})
208
236
  if raw:
209
237
  app.echo_info_json(decoded_token)
210
238
  return
@@ -214,7 +242,6 @@ def token_command(app: ApplicationContext, raw: bool, decode: bool) -> None:
214
242
  if raw:
215
243
  app.echo_info_json(get_token())
216
244
  return
217
- token = get_access_token()
218
245
  app.echo_info(token)
219
246
 
220
247
 
@@ -27,6 +27,7 @@ from cmem_cmemc import completion
27
27
  from cmem_cmemc.command import CmemcCommand
28
28
  from cmem_cmemc.command_group import CmemcGroup
29
29
  from cmem_cmemc.commands.graph_imports import graph_imports_list, imports_group
30
+ from cmem_cmemc.commands.graph_insights import insights_group
30
31
  from cmem_cmemc.commands.validation import validation_group
31
32
  from cmem_cmemc.constants import UNKNOWN_GRAPH_ERROR
32
33
  from cmem_cmemc.context import ApplicationContext
@@ -567,10 +568,11 @@ def _get_graph_supported_formats() -> dict[str, str]:
567
568
 
568
569
  def _get_buffer_and_content_type(
569
570
  triple_file: str, app: ApplicationContext
570
- ) -> tuple[io.BytesIO, str]:
571
- """Get the io.BytesIO buffer and the content type of triple_file"""
571
+ ) -> tuple[io.BytesIO, str, None | str]:
572
+ """Get the io.BytesIO buffer, the content type and the content encoding of a triple_file"""
572
573
  smart_file = SmartPath(triple_file)
573
574
  content_type, encoding = mimetypes.guess_type(triple_file)
575
+ content_encoding = "gzip" if smart_file.name.endswith(".gz") else None
574
576
  if content_type is None:
575
577
  content_type = "text/turtle"
576
578
  for supported_type, supported_suffix in _get_graph_supported_formats().items():
@@ -596,7 +598,7 @@ def _get_buffer_and_content_type(
596
598
  with ClickSmartPath.open(triple_file, transport_params=transport_params) as file_obj:
597
599
  buffer.write(file_obj.read())
598
600
  buffer.seek(0)
599
- return buffer, content_type
601
+ return buffer, content_type, content_encoding
600
602
 
601
603
 
602
604
  def _create_graph_imports(ctx: Context, graphs: list[RdfGraphData]) -> None:
@@ -748,9 +750,13 @@ def import_command( # noqa: PLR0913
748
750
  continue
749
751
  # prevents re-replacing of graphs in a single run
750
752
  _replace = False if graph_iri in processed_graphs else replace
751
- _buffer, content_type = _get_buffer_and_content_type(triple_file, app)
753
+ _buffer, content_type, content_encoding = _get_buffer_and_content_type(triple_file, app)
752
754
  response = graph_api.post_streamed(
753
- graph_iri, _buffer, replace=_replace, content_type=content_type
755
+ graph_iri,
756
+ _buffer,
757
+ replace=_replace,
758
+ content_type=content_type,
759
+ content_encoding=content_encoding,
754
760
  )
755
761
  request_headers = response.request.headers
756
762
  request_headers.pop("Authorization")
@@ -886,3 +892,4 @@ graph.add_command(import_command)
886
892
  graph.add_command(open_command)
887
893
  graph.add_command(validation_group)
888
894
  graph.add_command(imports_group)
895
+ graph.add_command(insights_group)
@@ -12,7 +12,6 @@ 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 escape_colon
16
15
  from cmem_cmemc.constants import UNKNOWN_GRAPH_ERROR
17
16
  from cmem_cmemc.context import ApplicationContext
18
17
  from cmem_cmemc.object_list import DirectValuePropertyFilter, ObjectList
@@ -299,7 +298,7 @@ def _validate_graphs(from_graph: str | None, to_graph: str | None) -> None:
299
298
  def _from_graph_uris(ctx: Context, param: Argument, incomplete: str) -> list[CompletionItem]:
300
299
  """Provide auto completion items for delete command from-graph argument"""
301
300
  imports = get_imports_list(ctx)
302
- from_graphs = {escape_colon(_["from_graph"]) for _ in imports}
301
+ from_graphs = {_["from_graph"] for _ in imports}
303
302
  return [
304
303
  _
305
304
  for _ in completion.graph_uris(ctx=ctx, param=param, incomplete=incomplete)
@@ -311,15 +310,13 @@ def _to_graph_uris(ctx: Context, param: Argument, incomplete: str) -> list[Compl
311
310
  """Provide auto completion items for create/delete command to-graph argument"""
312
311
  from_graph = ctx.params["from_graph"]
313
312
  imports = graph_imports_list.apply_filters(ctx=ctx, filter_=[("from-graph", from_graph)])
314
- to_graphs = {escape_colon(_["to_graph"]) for _ in imports}
313
+ to_graphs = {_["to_graph"] for _ in imports}
315
314
  command = ctx.command.name
316
315
  return [
317
316
  _
318
317
  for _ in completion.graph_uris(ctx=ctx, param=param, incomplete=incomplete)
319
318
  if (command == "delete" and _.value in to_graphs)
320
- or (
321
- command == "create" and _.value not in to_graphs and _.value != escape_colon(from_graph)
322
- )
319
+ or (command == "create" and _.value not in to_graphs and _.value != from_graph)
323
320
  ]
324
321
 
325
322
 
@@ -0,0 +1,314 @@
1
+ """Graph Insights command group"""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import click
6
+ from click import Argument, Context
7
+ from click.shell_completion import CompletionItem
8
+ from cmem.cmempy.api import get_json, request
9
+ from cmem.cmempy.config import get_dp_api_endpoint
10
+ from requests import HTTPError
11
+
12
+ from cmem_cmemc.command import CmemcCommand
13
+ from cmem_cmemc.command_group import CmemcGroup
14
+ from cmem_cmemc.completion import NOT_SORTED, finalize_completion, graph_uris
15
+ from cmem_cmemc.exceptions import CmemcError
16
+ from cmem_cmemc.object_list import (
17
+ DirectListPropertyFilter,
18
+ DirectValuePropertyFilter,
19
+ ObjectList,
20
+ transform_lower,
21
+ )
22
+ from cmem_cmemc.string_processor import GraphLink, TimeAgo
23
+ from cmem_cmemc.utils import get_graphs_as_dict, struct_to_table
24
+
25
+ if TYPE_CHECKING:
26
+ from cmem_cmemc.context import ApplicationContext
27
+
28
+
29
+ API_BASE = get_dp_api_endpoint() + "/api/ext/semspect"
30
+
31
+
32
+ def is_available() -> bool:
33
+ """Check availability of graph insights endpoints
34
+
35
+ {
36
+ "isActive": true,
37
+ "isUserAllowed": true
38
+ }
39
+ """
40
+ try:
41
+ data: dict[str, bool] = get_json(API_BASE)
42
+ except HTTPError:
43
+ return False
44
+ return bool(data["isActive"] is True and data["isUserAllowed"] is True)
45
+
46
+
47
+ def check_availability(ctx: click.Context) -> None:
48
+ """Check availability of graph insights endpoints or raise an exception"""
49
+ if is_available():
50
+ return
51
+ app: ApplicationContext = ctx.obj
52
+ raise CmemcError(app, "Graph Insights is not available.")
53
+
54
+
55
+ def get_snapshots(ctx: click.Context) -> list[dict[str, str | bool | list[str]]]:
56
+ """Get the snapshot list (all snapshots)"""
57
+ check_availability(ctx)
58
+ data: list[dict[str, str | bool | list[str]]] = get_json(
59
+ API_BASE + "/snapshot/status", params={"includeManagementOnly": True}
60
+ )
61
+ return data
62
+
63
+
64
+ def complete_snapshot_ids(ctx: Context, param: Argument, incomplete: str) -> list[CompletionItem]: # noqa: ARG001
65
+ """Provide auto-completion for snapshot Ids"""
66
+ snapshots = get_snapshots(ctx)
67
+ snapshots = sorted(
68
+ snapshots, key=lambda snapshot: snapshot["updateInfoTimestamp"], reverse=True
69
+ )
70
+ options = [
71
+ (
72
+ snapshot["databaseId"],
73
+ f"{snapshot['mainGraphSynced']} ({snapshot['updateInfoTimestamp']})",
74
+ )
75
+ for snapshot in snapshots
76
+ ]
77
+ return finalize_completion(candidates=options, incomplete=incomplete, sort_by=NOT_SORTED)
78
+
79
+
80
+ snapshot_list = ObjectList(
81
+ name="insight snapshots",
82
+ get_objects=get_snapshots,
83
+ filters=[
84
+ DirectValuePropertyFilter(
85
+ name="id",
86
+ description="Snapshots with a specific id.",
87
+ property_key="databaseId",
88
+ transform=transform_lower,
89
+ ),
90
+ DirectValuePropertyFilter(
91
+ name="main-graph",
92
+ description="Snapshots with a specific main graph.",
93
+ property_key="mainGraphSynced",
94
+ ),
95
+ DirectValuePropertyFilter(
96
+ name="status",
97
+ description="Snapshots with a specific status.",
98
+ property_key="status",
99
+ ),
100
+ DirectListPropertyFilter(
101
+ name="affected-graph",
102
+ description="Snapshots with a specific affected graph (main or sub-graphs).",
103
+ property_key="allGraphsSynced",
104
+ ),
105
+ ],
106
+ )
107
+
108
+
109
+ @click.command(cls=CmemcCommand, name="list")
110
+ @click.option(
111
+ "--filter",
112
+ "filter_",
113
+ type=(str, str),
114
+ help=snapshot_list.get_filter_help_text(),
115
+ shell_complete=snapshot_list.complete_values,
116
+ multiple=True,
117
+ )
118
+ @click.option("--raw", is_flag=True, help="Outputs raw JSON response.")
119
+ @click.option(
120
+ "--id-only",
121
+ is_flag=True,
122
+ help="Return the snapshot IDs only. This is useful for piping the IDs into other commands.",
123
+ )
124
+ @click.pass_context
125
+ def list_command(ctx: Context, filter_: tuple[tuple[str, str]], id_only: bool, raw: bool) -> None:
126
+ """List graph insight snapshots.
127
+
128
+ Graph Insights Snapshots are identified by an ID.
129
+ """
130
+ check_availability(ctx)
131
+ app: ApplicationContext = ctx.obj
132
+ snapshots = snapshot_list.apply_filters(ctx=ctx, filter_=filter_)
133
+
134
+ if id_only:
135
+ for _ in snapshots:
136
+ click.echo(_["databaseId"])
137
+ return
138
+
139
+ if raw:
140
+ app.echo_info_json(snapshots)
141
+ return
142
+
143
+ graphs = get_graphs_as_dict()
144
+ table = []
145
+ for _ in snapshots:
146
+ id_ = _["databaseId"]
147
+ main_graph = _["mainGraphSynced"]
148
+ updated = _["updateInfoTimestamp"]
149
+ status = _["status"]
150
+ if main_graph not in graphs:
151
+ main_graph = rf"\[missing: {main_graph}]"
152
+ table.append([id_, main_graph, updated, status])
153
+
154
+ app.echo_info_table(
155
+ table,
156
+ headers=["ID", "Main Graph", "Updated", "Status"],
157
+ sort_column=0,
158
+ cell_processing={1: GraphLink(), 2: TimeAgo()},
159
+ empty_table_message="No graph insight snapshots found.",
160
+ )
161
+
162
+
163
+ @click.command(cls=CmemcCommand, name="delete")
164
+ @click.argument("SNAPSHOT_ID", type=str, shell_complete=complete_snapshot_ids, required=False)
165
+ @click.option(
166
+ "--filter",
167
+ "filter_",
168
+ type=(str, str),
169
+ help=snapshot_list.get_filter_help_text(),
170
+ shell_complete=snapshot_list.complete_values,
171
+ multiple=True,
172
+ )
173
+ @click.option("-a", "--all", "all_", is_flag=True, help="Delete all snapshots.")
174
+ @click.pass_context
175
+ def delete_command(
176
+ ctx: Context, snapshot_id: str | None, filter_: tuple[tuple[str, str]], all_: bool
177
+ ) -> None:
178
+ """Delete a graph insight snapshot.
179
+
180
+ 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.
183
+ """
184
+ check_availability(ctx)
185
+ 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.")
188
+
189
+ if all_:
190
+ app.echo_info("Deleting all snapshots ... ", nl=False)
191
+ request(method="DELETE", uri=f"{API_BASE}/snapshot")
192
+ app.echo_success("done")
193
+ return
194
+
195
+ all_snapshots = get_snapshots(ctx)
196
+ all_snapshot_ids = [_["databaseId"] for _ in all_snapshots]
197
+ filter_to_apply = list(filter_) if filter_ else []
198
+ if snapshot_id:
199
+ filter_to_apply.append(("id", snapshot_id))
200
+ 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
+
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")
213
+
214
+
215
+ @click.command(cls=CmemcCommand, name="create")
216
+ @click.argument("iri", type=click.STRING, shell_complete=graph_uris)
217
+ @click.pass_context
218
+ def create_command(ctx: Context, iri: str) -> None:
219
+ """Create or update a graph insight snapshot.
220
+
221
+ Create a graph insight snapshot for a given graph.
222
+ If the snapshot already exists, it is hot-swapped after re-creation.
223
+ The snapshot contains only the (imported) graphs the requesting user can read.
224
+ """
225
+ check_availability(ctx)
226
+ app: ApplicationContext = ctx.obj
227
+ 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")
230
+
231
+
232
+ @click.command(cls=CmemcCommand, name="update")
233
+ @click.argument("SNAPSHOT_ID", type=str, shell_complete=complete_snapshot_ids, required=False)
234
+ @click.option(
235
+ "--filter",
236
+ "filter_",
237
+ type=(str, str),
238
+ help=snapshot_list.get_filter_help_text(),
239
+ shell_complete=snapshot_list.complete_values,
240
+ multiple=True,
241
+ )
242
+ @click.option("-a", "--all", "all_", is_flag=True, help="Delete all snapshots.")
243
+ @click.pass_context
244
+ def update_command(
245
+ ctx: Context, snapshot_id: str | None, filter_: tuple[tuple[str, str]], all_: bool
246
+ ) -> None:
247
+ """Update a graph insight snapshot.
248
+
249
+ Update a graph insight snapshot.
250
+ After the update, the snapshot is hot-swapped.
251
+ """
252
+ check_availability(ctx)
253
+ app: ApplicationContext = ctx.obj
254
+ if snapshot_id is None and not filter_ and not all_:
255
+ raise click.UsageError("Either provide a snapshot ID or a filter, or use the --all flag.")
256
+
257
+ filter_to_apply = list(filter_) if filter_ else []
258
+ if snapshot_id:
259
+ filter_to_apply.append(("id", snapshot_id))
260
+ snapshots_to_update = snapshot_list.apply_filters(ctx=ctx, filter_=filter_to_apply)
261
+
262
+ all_snapshots = get_snapshots(ctx)
263
+ all_snapshot_ids = [_["databaseId"] for _ in all_snapshots]
264
+
265
+ if all_:
266
+ snapshots_to_update = get_snapshots(ctx)
267
+
268
+ if not snapshots_to_update:
269
+ raise click.UsageError("No snapshots found to delete.")
270
+
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
+ for _ in snapshots_to_update:
276
+ id_to_update = _["databaseId"]
277
+ 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")
280
+
281
+
282
+ @click.command(cls=CmemcCommand, name="inspect")
283
+ @click.argument("SNAPSHOT_ID", type=str, shell_complete=complete_snapshot_ids)
284
+ @click.option("--raw", is_flag=True, help="Outputs raw JSON.")
285
+ @click.pass_context
286
+ def inspect_command(ctx: Context, snapshot_id: str, raw: bool) -> None:
287
+ """Inspect the metadata of a graph insight snapshot."""
288
+ check_availability(ctx)
289
+ app: ApplicationContext = ctx.obj
290
+ snapshot: dict[str, str | bool | list[str]] = get_json(
291
+ f"{API_BASE}/snapshot/status/{snapshot_id}"
292
+ )
293
+ if raw:
294
+ app.echo_info_json(snapshot)
295
+ else:
296
+ table = struct_to_table(snapshot)
297
+ app.echo_info_table(table, headers=["Key", "Value"], sort_column=0)
298
+
299
+
300
+ @click.group(cls=CmemcGroup, name="insights")
301
+ def insights_group() -> CmemcGroup: # type: ignore[empty-body]
302
+ """List, create, delete and inspect graph insight snapshots.
303
+
304
+ Graph Insight Snapshots are identified by an ID.
305
+ To get a list of existing snapshots,
306
+ execute the `graph insights list` command or use tab-completion.
307
+ """
308
+
309
+
310
+ insights_group.add_command(list_command)
311
+ insights_group.add_command(delete_command)
312
+ insights_group.add_command(create_command)
313
+ insights_group.add_command(update_command)
314
+ insights_group.add_command(inspect_command)
@@ -10,8 +10,8 @@ from time import sleep, time
10
10
  from uuid import uuid4
11
11
 
12
12
  import click
13
- from click import ClickException, UsageError
14
13
  from click.shell_completion import CompletionItem
14
+ from cmem.cmempy.config import get_cmem_base_uri
15
15
  from cmem.cmempy.queries import (
16
16
  QueryCatalog,
17
17
  SparqlQuery,
@@ -91,12 +91,12 @@ class ReplayStatistics:
91
91
  iri = query_["iri"]
92
92
  catalog_entry = self.catalog.get_query(iri)
93
93
  if catalog_entry is None:
94
- raise ClickException(f"measure_query - query {iri} is not in catalog.")
94
+ raise click.ClickException(f"measure_query - query {iri} is not in catalog.")
95
95
  return catalog_entry
96
96
  query_string = query_["queryString"]
97
97
  return SparqlQuery(text=query_string)
98
98
  except KeyError as error:
99
- raise ClickException(
99
+ raise click.ClickException(
100
100
  "measure_query - given input dict has no queryString key."
101
101
  ) from error
102
102
 
@@ -289,15 +289,15 @@ query_status_list = ObjectList(
289
289
  compare=compare_regex,
290
290
  fixed_completion=[
291
291
  CompletionItem(
292
- r"http\:\/\/schema.org",
292
+ r"http://schema.org",
293
293
  help="List only queries which somehow use the schema.org namespace.",
294
294
  ),
295
295
  CompletionItem(
296
- r"http\:\/\/www.w3.org\/2000\/01\/rdf-schema\#",
296
+ r"http://www.w3.org/2000/01/rdf-schema#",
297
297
  help="List only queries which somehow use the RDF schema namespace.",
298
298
  ),
299
299
  CompletionItem(
300
- r"\\\?value",
300
+ r"?value",
301
301
  help="List only queries which are using the ?value projection variable.",
302
302
  ),
303
303
  CompletionItem(
@@ -330,6 +330,13 @@ def _output_query_status_details(app: ApplicationContext, status_dict: dict) ->
330
330
 
331
331
 
332
332
  @click.command(cls=CmemcCommand, name="list")
333
+ @click.option(
334
+ "--catalog-graph",
335
+ default="https://ns.eccenca.com/data/queries/",
336
+ show_default=True,
337
+ shell_complete=completion.graph_uris,
338
+ help="The used query catalog graph.",
339
+ )
333
340
  @click.option(
334
341
  "--id-only",
335
342
  is_flag=True,
@@ -337,13 +344,13 @@ def _output_query_status_details(app: ApplicationContext, status_dict: dict) ->
337
344
  "This is useful for piping the ids into other cmemc commands.",
338
345
  )
339
346
  @click.pass_obj
340
- def list_command(app: ApplicationContext, id_only: bool) -> None:
347
+ def list_command(app: ApplicationContext, catalog_graph: str, id_only: bool) -> None:
341
348
  """List available queries from the catalog.
342
349
 
343
350
  Outputs a list of query URIs which can be used as reference for
344
351
  the query execute command.
345
352
  """
346
- queries = QueryCatalog().get_queries().items()
353
+ queries = QueryCatalog(graph=catalog_graph).get_queries().items()
347
354
  if id_only:
348
355
  # sort dict by short_url - https://docs.python.org/3/howto/sorting.html
349
356
  for _, sparql_query in sorted(queries, key=lambda k: k[1].short_url.lower()):
@@ -359,7 +366,12 @@ def list_command(app: ApplicationContext, id_only: bool) -> None:
359
366
  ]
360
367
  table.append(row)
361
368
  app.echo_info_table(
362
- table, headers=["Query URI", "Type", "Placeholder", "Label"], sort_column=3
369
+ table,
370
+ headers=["Query URI", "Type", "Placeholder", "Label"],
371
+ sort_column=3,
372
+ empty_table_message="There are no query available in the "
373
+ f"selected catalog ({catalog_graph}).",
374
+ caption=f"Queries from {catalog_graph} ({get_cmem_base_uri()})",
363
375
  )
364
376
 
365
377
 
@@ -367,6 +379,13 @@ def list_command(app: ApplicationContext, id_only: bool) -> None:
367
379
  @click.argument(
368
380
  "QUERIES", nargs=-1, required=True, shell_complete=completion.remote_queries_and_sparql_files
369
381
  )
382
+ @click.option(
383
+ "--catalog-graph",
384
+ default="https://ns.eccenca.com/data/queries/",
385
+ show_default=True,
386
+ shell_complete=completion.graph_uris,
387
+ help="The used query catalog graph.",
388
+ )
370
389
  @click.option(
371
390
  "--accept",
372
391
  default="default",
@@ -420,6 +439,7 @@ def list_command(app: ApplicationContext, id_only: bool) -> None:
420
439
  def execute_command( # noqa: PLR0913
421
440
  app: ApplicationContext,
422
441
  queries: tuple[str, ...],
442
+ catalog_graph: str,
423
443
  accept: str,
424
444
  no_imports: bool,
425
445
  base64: bool,
@@ -453,9 +473,14 @@ def execute_command( # noqa: PLR0913
453
473
  app.echo_debug("Parameter: " + str(placeholder))
454
474
  for file_or_uri in queries:
455
475
  app.echo_debug(f"Start of execution: {file_or_uri} with " f"placeholder {placeholder}")
456
- executed_query: SparqlQuery = QueryCatalog().get_query(file_or_uri, placeholder=placeholder)
476
+ executed_query: SparqlQuery = QueryCatalog(graph=catalog_graph).get_query(
477
+ file_or_uri, placeholder=placeholder
478
+ )
457
479
  if executed_query is None:
458
- raise ClickException(f"{file_or_uri} is neither a (readable) file nor a query URI.")
480
+ raise click.UsageError(
481
+ f"{file_or_uri} is neither a (readable) file nor "
482
+ f"a query URI in the catalog graph {catalog_graph}"
483
+ )
459
484
  app.echo_debug(
460
485
  f"Execute ({executed_query.query_type}): "
461
486
  f"{executed_query.label} < {executed_query.url}"
@@ -499,8 +524,15 @@ def execute_command( # noqa: PLR0913
499
524
  @click.argument(
500
525
  "QUERIES", nargs=-1, required=True, shell_complete=completion.remote_queries_and_sparql_files
501
526
  )
527
+ @click.option(
528
+ "--catalog-graph",
529
+ default="https://ns.eccenca.com/data/queries/",
530
+ show_default=True,
531
+ shell_complete=completion.graph_uris,
532
+ help="The used query catalog graph.",
533
+ )
502
534
  @click.pass_obj
503
- def open_command(app: ApplicationContext, queries: tuple[str, ...]) -> None:
535
+ def open_command(app: ApplicationContext, queries: tuple[str, ...], catalog_graph: str) -> None:
504
536
  """Open queries in the editor of the query catalog in your browser.
505
537
 
506
538
  With this command, you can open (remote) queries from the query catalog in
@@ -512,10 +544,13 @@ def open_command(app: ApplicationContext, queries: tuple[str, ...]) -> None:
512
544
  opening multiple browser tabs.
513
545
  """
514
546
  for file_or_uri in queries:
515
- opened_query = QueryCatalog().get_query(file_or_uri)
547
+ opened_query = QueryCatalog(graph=catalog_graph).get_query(file_or_uri)
516
548
  if opened_query is None:
517
- raise ClickException(f"{file_or_uri} is neither a (readable) file nor a query URI.")
518
- open_query_uri = opened_query.get_editor_url()
549
+ raise click.UsageError(
550
+ f"{file_or_uri} is neither a (readable) file nor "
551
+ f"a query URI in the catalog graph {catalog_graph}"
552
+ )
553
+ open_query_uri = opened_query.get_editor_url(graph=catalog_graph)
519
554
  app.echo_debug(f"Open {file_or_uri}: {open_query_uri}")
520
555
  click.launch(open_query_uri)
521
556
 
@@ -571,7 +606,7 @@ def status_command(
571
606
  queries = query_status_list.apply_filters(ctx=ctx, filter_=filter_)
572
607
 
573
608
  if query_id and len(queries) == 0:
574
- raise UsageError(f"Query with ID '{query_id}' does not exist (anymore).")
609
+ raise click.UsageError(f"Query with ID '{query_id}' does not exist (anymore).")
575
610
 
576
611
  if raw:
577
612
  app.echo_info_json(queries)
@@ -666,14 +701,14 @@ def replay_command( # noqa: PLR0913
666
701
  other data.
667
702
  """
668
703
  if loops <= 0:
669
- raise UsageError("Please set a positive loops integer value (>=1).")
704
+ raise click.UsageError("Please set a positive loops integer value (>=1).")
670
705
  try:
671
706
  with Path(replay_file).open(encoding="utf8") as _:
672
707
  input_queries = load(_)
673
708
  except JSONDecodeError as error:
674
- raise ClickException(f"File {replay_file} is not a valid JSON document.") from error
709
+ raise click.ClickException(f"File {replay_file} is not a valid JSON document.") from error
675
710
  if len(input_queries) == 0:
676
- raise ClickException(f"File {replay_file} contains no queries.")
711
+ raise click.ClickException(f"File {replay_file} contains no queries.")
677
712
  app.echo_debug(f"File {replay_file} contains {len(input_queries)} queries.")
678
713
 
679
714
  statistic = ReplayStatistics(app=app, label=run_label)
@@ -10,6 +10,7 @@ from cmem.cmempy.dp.admin import create_showcase_data, delete_bootstrap_data, im
10
10
  from cmem.cmempy.dp.admin.backup import get_zip, post_zip
11
11
  from cmem.cmempy.dp.workspace import migrate_workspaces
12
12
  from cmem.cmempy.health import get_dp_info
13
+ from cmem.cmempy.workspace import reload_workspace
13
14
  from jinja2 import Template
14
15
 
15
16
  from cmem_cmemc.command import CmemcCommand
@@ -53,7 +54,7 @@ def bootstrap_command(app: ApplicationContext, import_: bool, remove: bool) -> N
53
54
 
54
55
  Note: The import part of this command is equivalent to the 'bootstrap-data' migration recipe
55
56
  """
56
- if import_ and remove or not import_ and not remove:
57
+ if (import_ and remove) or (not import_ and not remove):
57
58
  raise UsageError("Either use the --import or the --remove option.")
58
59
  if import_:
59
60
  app.echo_info("Update or import bootstrap data ... ", nl=False)
@@ -99,12 +100,15 @@ def showcase_command(app: ApplicationContext, scale: int, create: bool, delete:
99
100
  raise UsageError("Either use the --create or the --delete flag.")
100
101
  if delete:
101
102
  raise NotImplementedError(
102
- "This feature is not implemented yet. " "Please delete the graphs manually."
103
+ "This feature is not implemented yet. Please delete the graphs manually."
103
104
  )
104
105
  if create:
105
106
  app.echo_info(f"Create showcase data with scale factor {scale} ... ", nl=False)
106
107
  create_showcase_data(scale_factor=scale)
107
108
  app.echo_success("done")
109
+ app.echo_info("Reload workspace ... ", nl=False)
110
+ reload_workspace()
111
+ app.echo_success("done")
108
112
 
109
113
 
110
114
  @click.command(cls=CmemcCommand, name="export")
@@ -766,11 +766,13 @@ def export_command(
766
766
  f" to {output_file} ... ",
767
767
  nl=False,
768
768
  )
769
- with Path(output_file, mode="w", encoding="utf-8") as file:
769
+ output_path = Path(output_file)
770
+
771
+ with output_path.open("w", encoding="utf-8") as file:
770
772
  if format_ == "XML":
771
- file.write_text(_reports_to_junit(reports))
773
+ file.write(_reports_to_junit(reports))
772
774
  if format_ == "JSON":
773
- file.write_text(json.dumps(reports, indent=2))
775
+ json.dump(reports, file, indent=2)
774
776
  app.echo_success("done")
775
777
  if exit_1 == "error" and overall_violations > 0:
776
778
  app.echo_error(
@@ -53,6 +53,7 @@ FILE_EXTENSIONS_TO_PLUGIN_ID = {
53
53
  ".txt": "text",
54
54
  ".md": "text",
55
55
  ".xlsx": "excel",
56
+ ".xls": "excel",
56
57
  ".zip": "multiCsv",
57
58
  ".pdf": "binaryFile",
58
59
  ".png": "binaryFile",
@@ -53,11 +53,6 @@ SORT_BY_KEY = 0
53
53
  SORT_BY_DESC = 1
54
54
 
55
55
 
56
- def escape_colon(value: str) -> str:
57
- """Escape colons in the input string by prefixing them with a backslash."""
58
- return value.replace(":", r"\:")
59
-
60
-
61
56
  def finalize_completion(
62
57
  candidates: list,
63
58
  incomplete: str = "",
@@ -130,10 +125,7 @@ def finalize_completion(
130
125
  key=lambda x: (str(x[sort_by]).casefold(), str(x[sort_by])),
131
126
  reverse=reverse,
132
127
  )
133
- return [
134
- CompletionItem(value=escape_colon(element[0]), help=element[1])
135
- for element in sorted_list
136
- ]
128
+ return [CompletionItem(value=element[0], help=element[1]) for element in sorted_list]
137
129
 
138
130
  raise ClickException(
139
131
  "Candidates should be a list of strings or a list of tuples." f" Got {candidates}"
@@ -199,7 +191,10 @@ def acl_actions(ctx: Context, param: Argument, incomplete: str) -> list[Completi
199
191
  if check_option_in_params(iri, ctx.params.get(str(param.name))):
200
192
  continue
201
193
  options.append((qname, name))
202
- options.append(("urn:elds-backend-all-actions", "All Actions (until 24.2.x, now deprecated)"))
194
+ if not check_option_in_params("urn:elds-backend-all-actions", ctx.params.get(str(param.name))):
195
+ options.append(
196
+ ("urn:elds-backend-all-actions", "All Actions (until 24.2.x, now deprecated)")
197
+ )
203
198
  return finalize_completion(candidates=options, incomplete=incomplete, sort_by=SORT_BY_DESC)
204
199
 
205
200
 
@@ -359,10 +354,10 @@ def dataset_list_filter(ctx: Context, param: Argument, incomplete: str) -> list[
359
354
  ("type", "Filter by dataset type."),
360
355
  ]
361
356
  filter_regex = [
362
- (r"^Final\:", "Example: Dataset label starts with 'Final:'."),
357
+ ("^Final:", "Example: Dataset label starts with 'Final:'."),
363
358
  (
364
359
  r"[12][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]",
365
- "Example: Dataset label contains a data-like string.",
360
+ "Example: Dataset label contains a date-like string.",
366
361
  ),
367
362
  ]
368
363
  options = []
@@ -526,6 +521,7 @@ def get_dataset_file_mapping() -> dict[str, dict[str, str]]:
526
521
  return {
527
522
  ".csv": {"description": "CSV Dataset resource", "type": "csv"},
528
523
  ".csv.zip": {"description": "CSV Dataset resource (zipped)", "type": "csv"},
524
+ ".xls": {"description": "Excel Dataset resource", "type": "excel"},
529
525
  ".xlsx": {"description": "Excel Dataset resource", "type": "excel"},
530
526
  ".xml": {"description": "XML Dataset resource", "type": "xml"},
531
527
  ".xml.zip": {"description": "XML Dataset resource (zipped)", "type": "xml"},
@@ -643,10 +639,11 @@ def placeholder(ctx: Context, param: Argument, incomplete: str) -> list[Completi
643
639
  args = get_completion_args(incomplete)
644
640
  # setup configuration
645
641
  ApplicationContext.set_connection_from_params(ctx.find_root().params)
642
+ catalog_graph = ctx.params.get("catalog_graph")
646
643
  # extract placeholder from given queries in the command line
647
644
  options = []
648
645
  placeholders: dict[str, QueryPlaceholder] = {}
649
- catalog = QueryCatalog() # fetch all queries
646
+ catalog = QueryCatalog(graph=catalog_graph) if catalog_graph else QueryCatalog()
650
647
  for _, arg in enumerate(args):
651
648
  query = catalog.get_query(arg)
652
649
  if query is not None:
@@ -654,7 +651,7 @@ def placeholder(ctx: Context, param: Argument, incomplete: str) -> list[Completi
654
651
  placeholders = placeholders | get_placeholders_for_query(iri=query.url)
655
652
  # collect all placeholder keys
656
653
  options.extend(list(query.get_placeholder_keys()))
657
- # look if cursor is in value position of the -p option and
654
+ # look if the cursor is in value position of the -p option and
658
655
  # use placeholder value completion, in case it is
659
656
  if args[len(args) - 2] in ("-p", "--parameter"):
660
657
  key = args[len(args) - 1]
@@ -676,8 +673,10 @@ def placeholder(ctx: Context, param: Argument, incomplete: str) -> list[Completi
676
673
  def remote_queries(ctx: Context, param: Argument, incomplete: str) -> list[CompletionItem]:
677
674
  """Prepare a list of query URIs."""
678
675
  ApplicationContext.set_connection_from_params(ctx.find_root().params)
676
+ catalog_graph = ctx.params.get("catalog_graph")
677
+ catalog = QueryCatalog(graph=catalog_graph) if catalog_graph else QueryCatalog()
679
678
  options = []
680
- for query in QueryCatalog().get_queries().values():
679
+ for query in catalog.get_queries().values():
681
680
  url = query.short_url
682
681
  label = query.label
683
682
  options.append((url, label))
@@ -798,11 +797,11 @@ def graph_uris_with_all_graph_uri(
798
797
  options = graph_uris(ctx, param, incomplete, writeable=True, readonly=True)
799
798
  options.append(
800
799
  CompletionItem(
801
- value=r"urn\:elds-backend-all-graphs", help="All Graphs (until 24.2.x, now deprecated)"
800
+ value=r"urn:elds-backend-all-graphs", help="All Graphs (until 24.2.x, now deprecated)"
802
801
  )
803
802
  )
804
803
  options.append(
805
- CompletionItem(value=r"https\://vocab.eccenca.com/auth/AllGraphs", help="All Graphs")
804
+ CompletionItem(value=r"https://vocab.eccenca.com/auth/AllGraphs", help="All Graphs")
806
805
  )
807
806
  return options
808
807
 
@@ -958,7 +957,7 @@ def workflow_list_filter(ctx: Context, param: Argument, incomplete: str) -> list
958
957
  ("input-output", "List only workflows with a variable input and output dataset."),
959
958
  ]
960
959
  filter_regex = [
961
- (r"^Final\:", "Example: Workflow label starts with 'Final:'."),
960
+ (r"^Final:", "Example: Workflow label starts with 'Final:'."),
962
961
  (
963
962
  r"[12][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]",
964
963
  "Example: Workflow label contains a data-like string.",
@@ -29,9 +29,9 @@ from cmem_cmemc.exceptions import InvalidConfigurationError
29
29
  from cmem_cmemc.string_processor import StringProcessor, process_row
30
30
  from cmem_cmemc.utils import is_enabled, str_to_bool
31
31
 
32
- DI_TARGET_VERSION = "v25.1.0"
32
+ DI_TARGET_VERSION = "v25.2.0"
33
33
 
34
- EXPLORE_TARGET_VERSION = "v25.1.0"
34
+ EXPLORE_TARGET_VERSION = "v25.2.0"
35
35
 
36
36
  KNOWN_CONFIG_KEYS = {
37
37
  "CMEM_BASE_URI": cmempy_config.get_cmem_base_uri,
@@ -31,6 +31,7 @@ def get_icon_for_command_group(full_name: str) -> str:
31
31
  "project variable": "material/variable-box",
32
32
  "query": "eccenca/application-queries",
33
33
  "graph": "eccenca/artefact-dataset-eccencadataplatform",
34
+ "graph imports": "material/family-tree",
34
35
  "graph validation": "octicons/verified-16",
35
36
  "vocabulary": "eccenca/application-vocabularies",
36
37
  "vocabulary cache": "eccenca/application-vocabularies",
@@ -58,6 +59,7 @@ def get_tags_for_command_group(full_name: str) -> str:
58
59
  "project variable": ["Variables", "cmemc"],
59
60
  "query": ["SPARQL", "cmemc"],
60
61
  "graph": ["KnowledgeGraph", "cmemc"],
62
+ "graph imports": ["KnowledgeGraph", "cmemc"],
61
63
  "graph validation": ["KnowledgeGraph", "Validation", "cmemc"],
62
64
  "vocabulary": ["Vocabulary", "cmemc"],
63
65
  "vocabulary cache": ["Vocabulary", "cmemc"],
@@ -20,18 +20,23 @@ class StringProcessor(ABC):
20
20
 
21
21
 
22
22
  class TimeAgo(StringProcessor):
23
- """Create a string similar to 'x minutes ago' from a timestamp"""
23
+ """Create a string similar to 'x minutes ago' from a timestamp or iso-formated string."""
24
24
 
25
25
  def process(self, text: str) -> str:
26
26
  """Process a single string content and output the processed string."""
27
27
  if text is None:
28
28
  return ""
29
+ try:
30
+ stamp = datetime.fromisoformat(str(text))
31
+ return str(timeago.format(stamp, datetime.now(tz=timezone.utc)))
32
+ except (ValueError, TypeError):
33
+ pass
29
34
  try:
30
35
  text_as_int = int(text)
36
+ stamp = datetime.fromtimestamp(text_as_int / 1000, tz=timezone.utc)
37
+ return str(timeago.format(stamp, datetime.now(tz=timezone.utc)))
31
38
  except ValueError:
32
39
  return text
33
- stamp = datetime.fromtimestamp(text_as_int / 1000, tz=timezone.utc)
34
- return str(timeago.format(stamp, datetime.now(tz=timezone.utc)))
35
40
 
36
41
 
37
42
  class GraphLink(StringProcessor):
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "cmem-cmemc"
3
- version = "25.3.0"
3
+ version = "25.5.0rc1"
4
4
  description = "Command line client for eccenca Corporate Memory"
5
5
  license = "Apache-2.0"
6
6
  classifiers = [
@@ -31,11 +31,10 @@ readme = "README-public.md"
31
31
  python = "^3.10"
32
32
  beautifulsoup4 = "^4.13.3"
33
33
  certifi = ">=2024.2.2"
34
- # https://github.com/pallets/click/issues/2906
35
- click = "^8.1.8, <8.2.0"
34
+ click = "^8.3.0"
36
35
  click-didyoumean = "^0.3.1"
37
36
  click-help-colors = "^0.9.4"
38
- cmem-cmempy = "==25.2.0"
37
+ cmem-cmempy = "==25.4.0"
39
38
  # cmem-cmempy = { path = "cmempy", develop = true }
40
39
  configparser = "^7.2.0"
41
40
  jinja2 = "^3.1.6"
@@ -66,9 +65,11 @@ pytest-cov = "^6.0.0"
66
65
  pytest-dotenv = "^0.5.2"
67
66
  pytest-html = "^4.1.1"
68
67
  pytest-memray = { version = "^1.7.0", markers = "platform_system != 'Windows'" }
68
+ pytest-mock = "^3.14.1"
69
69
  requests-mock = "^1.12.1"
70
70
  ruff = "^0.7.4"
71
71
  safety = "^1.10.3"
72
+ semver = "^3.0.4"
72
73
  types-python-dateutil = "^2.9.0.20240316"
73
74
  types-requests = "^2.32.0.20241016"
74
75
  types-six = "^1.16.21.20240425"
File without changes