cmem-cmemc 25.4.0__py3-none-any.whl → 25.5.0__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.
@@ -24,48 +24,49 @@ class CmemcGroup(HelpColorsGroup, DYMGroup):
24
24
  kwargs.setdefault(
25
25
  "help_options_custom_colors",
26
26
  {
27
+ "acl": self.color_for_command_groups,
28
+ "admin": self.color_for_command_groups,
27
29
  "bootstrap": self.color_for_writing_commands,
28
- "showcase": self.color_for_writing_commands,
29
- "delete": self.color_for_writing_commands,
30
- "password": self.color_for_writing_commands,
31
- "secret": self.color_for_writing_commands,
32
- "upload": self.color_for_writing_commands,
33
- "import": self.color_for_writing_commands,
30
+ "cache": self.color_for_command_groups,
31
+ "cancel": self.color_for_writing_commands,
32
+ "client": self.color_for_command_groups,
33
+ "config": self.color_for_command_groups,
34
34
  "create": self.color_for_writing_commands,
35
- "enable": self.color_for_writing_commands,
35
+ "dataset": self.color_for_command_groups,
36
+ "delete": self.color_for_writing_commands,
36
37
  "disable": self.color_for_writing_commands,
38
+ "enable": self.color_for_writing_commands,
39
+ "eval": self.color_for_writing_commands,
37
40
  "execute": self.color_for_writing_commands,
38
- "replay": self.color_for_writing_commands,
39
- "io": self.color_for_writing_commands,
41
+ "graph": self.color_for_command_groups,
42
+ "import": self.color_for_writing_commands,
43
+ "imports": self.color_for_command_groups,
44
+ "insights": self.color_for_command_groups,
40
45
  "install": self.color_for_writing_commands,
41
- "uninstall": self.color_for_writing_commands,
42
- "reload": self.color_for_writing_commands,
43
- "update": self.color_for_writing_commands,
44
- "eval": self.color_for_writing_commands,
45
- "cancel": self.color_for_writing_commands,
46
- "admin": self.color_for_command_groups,
47
- "user": self.color_for_command_groups,
48
- "store": self.color_for_command_groups,
46
+ "io": self.color_for_writing_commands,
49
47
  "metrics": self.color_for_command_groups,
50
- "config": self.color_for_command_groups,
51
- "dataset": self.color_for_command_groups,
52
- "graph": self.color_for_command_groups,
48
+ "migrate": self.color_for_writing_commands,
49
+ "migrations": self.color_for_command_groups,
50
+ "password": self.color_for_writing_commands,
53
51
  "project": self.color_for_command_groups,
52
+ "python": self.color_for_command_groups,
54
53
  "query": self.color_for_command_groups,
54
+ "reload": self.color_for_writing_commands,
55
+ "replay": self.color_for_writing_commands,
56
+ "resource": self.color_for_command_groups,
55
57
  "scheduler": self.color_for_command_groups,
58
+ "secret": self.color_for_writing_commands,
59
+ "showcase": self.color_for_writing_commands,
60
+ "store": self.color_for_command_groups,
61
+ "uninstall": self.color_for_writing_commands,
62
+ "update": self.color_for_writing_commands,
63
+ "upload": self.color_for_writing_commands,
64
+ "user": self.color_for_command_groups,
65
+ "validation": self.color_for_command_groups,
66
+ "variable": self.color_for_command_groups,
56
67
  "vocabulary": self.color_for_command_groups,
57
68
  "workflow": self.color_for_command_groups,
58
69
  "workspace": self.color_for_command_groups,
59
- "python": self.color_for_command_groups,
60
- "cache": self.color_for_command_groups,
61
- "resource": self.color_for_command_groups,
62
- "acl": self.color_for_command_groups,
63
- "client": self.color_for_command_groups,
64
- "variable": self.color_for_command_groups,
65
- "validation": self.color_for_command_groups,
66
- "migrate": self.color_for_writing_commands,
67
- "migrations": self.color_for_command_groups,
68
- "imports": self.color_for_command_groups,
69
70
  },
70
71
  )
71
72
  super().__init__(*args, **kwargs)
@@ -35,7 +35,26 @@ 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).",
54
+ "replace": (
55
+ "Replace (overwrite) existing access condition, if present. "
56
+ "Can be used only in combination with '--id'."
57
+ ),
39
58
  }
40
59
 
41
60
  WARNING_UNKNOWN_USER = "Unknown User or no access to get user info."
@@ -194,6 +213,27 @@ def inspect_command(app: ApplicationContext, access_condition_id: str, raw: bool
194
213
  shell_complete=completion.acl_actions,
195
214
  help=HELP_TEXTS["action"],
196
215
  )
216
+ @click.option(
217
+ "--read-graph-pattern",
218
+ "read_graph_patterns",
219
+ type=click.STRING,
220
+ multiple=True,
221
+ help=HELP_TEXTS["read_graph_pattern"],
222
+ )
223
+ @click.option(
224
+ "--write-graph-pattern",
225
+ "write_graph_patterns",
226
+ type=click.STRING,
227
+ multiple=True,
228
+ help=HELP_TEXTS["write_graph_pattern"],
229
+ )
230
+ @click.option(
231
+ "--action-pattern",
232
+ "action_patterns",
233
+ type=click.STRING,
234
+ multiple=True,
235
+ help=HELP_TEXTS["action_pattern"],
236
+ )
197
237
  @click.option(
198
238
  "--query",
199
239
  "query",
@@ -219,6 +259,7 @@ def inspect_command(app: ApplicationContext, access_condition_id: str, raw: bool
219
259
  type=click.STRING,
220
260
  help=HELP_TEXTS["description"],
221
261
  )
262
+ @click.option("--replace", is_flag=True, help=HELP_TEXTS["replace"])
222
263
  @click.pass_obj
223
264
  # pylint: disable-msg=too-many-arguments
224
265
  def create_command( # noqa: PLR0913
@@ -231,7 +272,11 @@ def create_command( # noqa: PLR0913
231
272
  read_graphs: tuple[str],
232
273
  write_graphs: tuple[str],
233
274
  actions: tuple[str],
275
+ read_graph_patterns: tuple[str],
276
+ write_graph_patterns: tuple[str],
277
+ action_patterns: tuple[str],
234
278
  query: str,
279
+ replace: bool,
235
280
  ) -> None:
236
281
  """Create an access condition.
237
282
 
@@ -254,17 +299,29 @@ def create_command( # noqa: PLR0913
254
299
 
255
300
  Example: cmemc admin acl create --group local-users --write-graph https://example.org/
256
301
  """
257
- if not read_graphs and not write_graphs and not actions and not query:
302
+ if replace and not id_:
303
+ raise click.UsageError("To replace an access condition, you must specify an ID.")
304
+
305
+ if (
306
+ not read_graphs
307
+ and not write_graphs
308
+ and not actions
309
+ and not read_graph_patterns
310
+ and not write_graph_patterns
311
+ and not action_patterns
312
+ and not query
313
+ ):
258
314
  raise click.UsageError(
259
315
  "Missing access / usage grant. Use at least one of the following options: "
260
- "--read-graph, --write-graph, --action or --query."
316
+ "--read-graph, --write-graph, --action, --read-graph-pattern, "
317
+ "--write-graph-pattern, --action-pattern or --query."
261
318
  )
262
319
  query_str = None
263
320
  if query:
264
321
  query_str = get_query_text(query, {"user", "group", "readGraph", "writeGraph"})
265
322
 
266
323
  if not user and not groups and not query:
267
- app.echo_warning("Access conditions without a user or group assignment affect ALL users.")
324
+ app.echo_warning("Access conditions without a user or group assignment affects ALL users.")
268
325
 
269
326
  if not name:
270
327
  name = generate_acl_name(user=user, groups=groups, query=query)
@@ -272,11 +329,11 @@ def create_command( # noqa: PLR0913
272
329
  if not description:
273
330
  description = "This access condition was created with cmemc."
274
331
 
275
- app.echo_info(
276
- f"Creating access condition '{name}' ... ",
277
- nl=False,
278
- )
279
-
332
+ if replace and NS_ACL + id_ in [_["iri"] for _ in fetch_all_acls()]:
333
+ app.echo_info(f"Replacing access condition '{id_}' ... ", nl=False)
334
+ delete_access_condition(iri=NS_ACL + id_)
335
+ else:
336
+ app.echo_info(f"Creating access condition '{name}' ... ", nl=False)
280
337
  create_access_condition(
281
338
  name=name,
282
339
  static_id=id_,
@@ -286,6 +343,9 @@ def create_command( # noqa: PLR0913
286
343
  read_graphs=list(read_graphs),
287
344
  write_graphs=list(write_graphs),
288
345
  actions=[convert_qname_to_iri(qname=_, default_ns=NS_ACTION) for _ in actions],
346
+ read_graph_patterns=list(read_graph_patterns),
347
+ write_graph_patterns=list(write_graph_patterns),
348
+ action_patterns=list(action_patterns),
289
349
  query=query_str,
290
350
  )
291
351
  app.echo_success("done")
@@ -351,6 +411,27 @@ def create_command( # noqa: PLR0913
351
411
  shell_complete=completion.acl_actions,
352
412
  help=HELP_TEXTS["action"],
353
413
  )
414
+ @click.option(
415
+ "--read-graph-pattern",
416
+ "read_graph_patterns",
417
+ type=click.STRING,
418
+ multiple=True,
419
+ help=HELP_TEXTS["read_graph_pattern"],
420
+ )
421
+ @click.option(
422
+ "--write-graph-pattern",
423
+ "write_graph_patterns",
424
+ type=click.STRING,
425
+ multiple=True,
426
+ help=HELP_TEXTS["write_graph_pattern"],
427
+ )
428
+ @click.option(
429
+ "--action-pattern",
430
+ "action_patterns",
431
+ type=click.STRING,
432
+ multiple=True,
433
+ help=HELP_TEXTS["action_pattern"],
434
+ )
354
435
  @click.option(
355
436
  "--query",
356
437
  "query",
@@ -370,6 +451,9 @@ def update_command( # noqa: PLR0913
370
451
  read_graphs: tuple[str],
371
452
  write_graphs: tuple[str],
372
453
  actions: tuple[str],
454
+ read_graph_patterns: tuple[str],
455
+ write_graph_patterns: tuple[str],
456
+ action_patterns: tuple[str],
373
457
  query: str,
374
458
  ) -> None:
375
459
  """Update an access condition.
@@ -396,6 +480,9 @@ def update_command( # noqa: PLR0913
396
480
  read_graphs=read_graphs,
397
481
  write_graphs=write_graphs,
398
482
  actions=[convert_qname_to_iri(qname=_, default_ns=NS_ACTION) for _ in actions],
483
+ read_graph_patterns=read_graph_patterns,
484
+ write_graph_patterns=write_graph_patterns,
485
+ action_patterns=action_patterns,
399
486
  query=query_str,
400
487
  )
401
488
  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,382 @@
1
+ """Graph Insights command group"""
2
+
3
+ import time
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.context import ApplicationContext
16
+ from cmem_cmemc.exceptions import CmemcError
17
+ from cmem_cmemc.object_list import (
18
+ DirectListPropertyFilter,
19
+ DirectValuePropertyFilter,
20
+ ObjectList,
21
+ transform_lower,
22
+ )
23
+ from cmem_cmemc.string_processor import GraphLink, TimeAgo
24
+ from cmem_cmemc.utils import get_graphs_as_dict, struct_to_table
25
+
26
+
27
+ def get_api_url(path: str = "") -> str:
28
+ """Get URLs of the graph insights API.
29
+
30
+ Constructs the full URL for accessing graph insights API endpoints by combining
31
+ the DataPlatform API endpoint with the semspect extension base path and an
32
+ optional resource path.
33
+
34
+ Args:
35
+ path: The API resource path to append to the base URL. Defaults to an empty
36
+ string for the root endpoint.
37
+
38
+ Returns:
39
+ The complete URL for the specified graph insights API endpoint.
40
+
41
+ Example:
42
+ >>> get_api_url()
43
+ 'https://example.com/dataplatform/api/ext/semspect'
44
+ >>> get_api_url("/snapshot/status")
45
+ 'https://example.com/dataplatform/api/ext/semspect/snapshot/status'
46
+
47
+ """
48
+ base_url = get_dp_api_endpoint() + "/api/ext/semspect"
49
+ return f"{base_url}{path}"
50
+
51
+
52
+ def is_available() -> bool:
53
+ """Check availability of graph insights endpoints
54
+
55
+ {
56
+ "isActive": true,
57
+ "isUserAllowed": true
58
+ }
59
+ """
60
+ try:
61
+ data: dict[str, bool] = get_json(get_api_url())
62
+ except HTTPError:
63
+ return False
64
+ return bool(data["isActive"] is True and data["isUserAllowed"] is True)
65
+
66
+
67
+ def check_availability(ctx: click.Context) -> None:
68
+ """Check availability of graph insights endpoints or raise an exception"""
69
+ if is_available():
70
+ return
71
+ app: ApplicationContext = ctx.obj
72
+ raise CmemcError(app, "Graph Insights is not available.")
73
+
74
+
75
+ def get_snapshots(ctx: click.Context) -> list[dict[str, str | bool | list[str]]]:
76
+ """Get the snapshot list (all snapshots)"""
77
+ check_availability(ctx)
78
+ data: list[dict[str, str | bool | list[str]]] = get_json(
79
+ get_api_url("/snapshot/status"), params={"includeManagementOnly": True}
80
+ )
81
+ return data
82
+
83
+
84
+ def complete_snapshot_ids(ctx: Context, param: Argument, incomplete: str) -> list[CompletionItem]: # noqa: ARG001
85
+ """Provide auto-completion for snapshot Ids"""
86
+ ApplicationContext.set_connection_from_params(ctx.find_root().params)
87
+ if not is_available():
88
+ return []
89
+ snapshots = get_snapshots(ctx)
90
+ snapshots = sorted(
91
+ snapshots, key=lambda snapshot: snapshot["updateInfoTimestamp"], reverse=True
92
+ )
93
+ options = [
94
+ (
95
+ snapshot["databaseId"],
96
+ f"{snapshot['mainGraphSynced']} ({snapshot['updateInfoTimestamp']})",
97
+ )
98
+ for snapshot in snapshots
99
+ ]
100
+ return finalize_completion(candidates=options, incomplete=incomplete, sort_by=NOT_SORTED)
101
+
102
+
103
+ snapshot_list = ObjectList(
104
+ name="insight snapshots",
105
+ get_objects=get_snapshots,
106
+ filters=[
107
+ DirectValuePropertyFilter(
108
+ name="id",
109
+ description="Snapshots with a specific id.",
110
+ property_key="databaseId",
111
+ transform=transform_lower,
112
+ ),
113
+ DirectValuePropertyFilter(
114
+ name="main-graph",
115
+ description="Snapshots with a specific main graph.",
116
+ property_key="mainGraphSynced",
117
+ ),
118
+ DirectValuePropertyFilter(
119
+ name="status",
120
+ description="Snapshots with a specific status.",
121
+ property_key="status",
122
+ ),
123
+ DirectListPropertyFilter(
124
+ name="affected-graph",
125
+ description="Snapshots with a specific affected graph (main or sub-graphs).",
126
+ property_key="allGraphsSynced",
127
+ ),
128
+ DirectValuePropertyFilter(
129
+ name="valid",
130
+ description="Snapshots with a specific validity indicator.",
131
+ property_key="isValid",
132
+ transform=transform_lower,
133
+ ),
134
+ ],
135
+ )
136
+
137
+
138
+ @click.command(cls=CmemcCommand, name="list")
139
+ @click.option(
140
+ "--filter",
141
+ "filter_",
142
+ type=(str, str),
143
+ help=snapshot_list.get_filter_help_text(),
144
+ shell_complete=snapshot_list.complete_values,
145
+ multiple=True,
146
+ )
147
+ @click.option("--raw", is_flag=True, help="Outputs raw JSON response.")
148
+ @click.option(
149
+ "--id-only",
150
+ is_flag=True,
151
+ help="Return the snapshot IDs only. This is useful for piping the IDs into other commands.",
152
+ )
153
+ @click.pass_context
154
+ def list_command(ctx: Context, filter_: tuple[tuple[str, str]], id_only: bool, raw: bool) -> None:
155
+ """List graph insight snapshots.
156
+
157
+ Graph Insights Snapshots are identified by an ID.
158
+ """
159
+ check_availability(ctx)
160
+ app: ApplicationContext = ctx.obj
161
+ snapshots = snapshot_list.apply_filters(ctx=ctx, filter_=filter_)
162
+
163
+ if id_only:
164
+ for _ in snapshots:
165
+ click.echo(_["databaseId"])
166
+ return
167
+
168
+ if raw:
169
+ app.echo_info_json(snapshots)
170
+ return
171
+
172
+ graphs = get_graphs_as_dict()
173
+ table = []
174
+ for _ in snapshots:
175
+ id_ = _["databaseId"]
176
+ main_graph = _["mainGraphSynced"]
177
+ updated = _["updateInfoTimestamp"]
178
+ status = _["status"]
179
+ is_valid = _["isValid"]
180
+ if main_graph not in graphs:
181
+ main_graph = rf"\[missing: {main_graph}]"
182
+ table.append([id_, main_graph, updated, status, is_valid])
183
+
184
+ app.echo_info_table(
185
+ table,
186
+ headers=["ID", "Main Graph", "Updated", "Status", "Valid"],
187
+ sort_column=0,
188
+ cell_processing={1: GraphLink(), 2: TimeAgo()},
189
+ empty_table_message="No graph insight snapshots found.",
190
+ )
191
+
192
+
193
+ @click.command(cls=CmemcCommand, name="delete")
194
+ @click.argument("SNAPSHOT_ID", type=str, shell_complete=complete_snapshot_ids, required=False)
195
+ @click.option(
196
+ "--filter",
197
+ "filter_",
198
+ type=(str, str),
199
+ help=snapshot_list.get_filter_help_text(),
200
+ shell_complete=snapshot_list.complete_values,
201
+ multiple=True,
202
+ )
203
+ @click.option("-a", "--all", "all_", is_flag=True, help="Delete all snapshots.")
204
+ @click.pass_context
205
+ def delete_command(
206
+ ctx: Context, snapshot_id: str | None, filter_: tuple[tuple[str, str]], all_: bool
207
+ ) -> None:
208
+ """Delete a graph insight snapshot.
209
+
210
+ 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.
213
+ """
214
+ check_availability(ctx)
215
+ 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.")
218
+
219
+ if all_:
220
+ app.echo_info("Deleting all snapshots ... ", nl=False)
221
+ request(method="DELETE", uri=get_api_url("/snapshot"))
222
+ app.echo_success("done")
223
+ return
224
+
225
+ filter_to_apply = list(filter_) if filter_ else []
226
+ if snapshot_id:
227
+ filter_to_apply.append(("id", snapshot_id))
228
+ snapshots_to_delete = snapshot_list.apply_filters(ctx=ctx, filter_=filter_to_apply)
229
+
230
+ if not snapshots_to_delete and snapshot_id:
231
+ raise click.ClickException(f"Snapshot ID '{snapshot_id}' does not exist.")
232
+
233
+ if not snapshots_to_delete and not snapshot_id:
234
+ raise click.ClickException("No snapshots found to delete.")
235
+
236
+ for _ in snapshots_to_delete:
237
+ id_to_delete = _["databaseId"]
238
+ app.echo_info(f"Deleting snapshot {id_to_delete} ... ", nl=False)
239
+ request(method="DELETE", uri=get_api_url(f"/snapshot/{id_to_delete}"))
240
+ app.echo_success("done")
241
+
242
+
243
+ def wait_for_snapshot(snapshot_id: str, polling_interval: int) -> None:
244
+ """Poll until the snapshot reaches 'DONE' status."""
245
+ while True:
246
+ snapshot: dict[str, str | bool | list[str]] = get_json(
247
+ get_api_url(f"/snapshot/status/{snapshot_id}")
248
+ )
249
+ if snapshot.get("status") == "DONE":
250
+ break
251
+ time.sleep(polling_interval)
252
+
253
+
254
+ @click.command(cls=CmemcCommand, name="create")
255
+ @click.argument("iri", type=click.STRING, shell_complete=graph_uris)
256
+ @click.option("--wait", is_flag=True, help="Wait until snapshot creation is done.")
257
+ @click.option(
258
+ "--polling-interval",
259
+ type=click.IntRange(min=0, max=60),
260
+ show_default=True,
261
+ default=1,
262
+ help="How many seconds to wait between status polls. Status polls are"
263
+ " cheap, so a higher polling interval is most likely not needed.",
264
+ )
265
+ @click.pass_context
266
+ def create_command(ctx: Context, iri: str, wait: bool, polling_interval: int) -> None:
267
+ """Create or update a graph insight snapshot.
268
+
269
+ Create a graph insight snapshot for a given graph.
270
+ If the snapshot already exists, it is hot-swapped after re-creation.
271
+ The snapshot contains only the (imported) graphs the requesting user can read.
272
+ """
273
+ check_availability(ctx)
274
+ app: ApplicationContext = ctx.obj
275
+ app.echo_info(f"Create / Update graph snapshot for graph {iri} ... ", nl=False)
276
+ snapshot_id = request(
277
+ method="POST", uri=get_api_url("/snapshot"), params={"contextGraph": iri}
278
+ ).text
279
+ app.echo_success("started", nl=not wait)
280
+ if wait:
281
+ app.echo_info(" ... ", nl=False)
282
+ wait_for_snapshot(snapshot_id, polling_interval)
283
+ app.echo_success("created")
284
+
285
+
286
+ @click.command(cls=CmemcCommand, name="update")
287
+ @click.argument("SNAPSHOT_ID", type=str, shell_complete=complete_snapshot_ids, required=False)
288
+ @click.option(
289
+ "--filter",
290
+ "filter_",
291
+ type=(str, str),
292
+ help=snapshot_list.get_filter_help_text(),
293
+ shell_complete=snapshot_list.complete_values,
294
+ multiple=True,
295
+ )
296
+ @click.option("-a", "--all", "all_", is_flag=True, help="Delete all snapshots.")
297
+ @click.option("--wait", is_flag=True, help="Wait until snapshot creation is done.")
298
+ @click.option(
299
+ "--polling-interval",
300
+ type=click.IntRange(min=0, max=60),
301
+ show_default=True,
302
+ default=1,
303
+ help="How many seconds to wait between status polls. Status polls are"
304
+ " cheap, so a higher polling interval is most likely not needed.",
305
+ )
306
+ @click.pass_context
307
+ def update_command( # noqa: PLR0913
308
+ ctx: Context,
309
+ snapshot_id: str | None,
310
+ filter_: tuple[tuple[str, str]],
311
+ all_: bool,
312
+ wait: bool,
313
+ polling_interval: int,
314
+ ) -> None:
315
+ """Update a graph insight snapshot.
316
+
317
+ Update a graph insight snapshot.
318
+ After the update, the snapshot is hot-swapped.
319
+ """
320
+ check_availability(ctx)
321
+ app: ApplicationContext = ctx.obj
322
+ if snapshot_id is None and not filter_ and not all_:
323
+ raise click.UsageError("Either provide a snapshot ID or a filter, or use the --all flag.")
324
+
325
+ filter_to_apply = list(filter_) if filter_ else []
326
+ if snapshot_id:
327
+ filter_to_apply.append(("id", snapshot_id))
328
+ snapshots_to_update = snapshot_list.apply_filters(ctx=ctx, filter_=filter_to_apply)
329
+
330
+ if all_:
331
+ snapshots_to_update = get_snapshots(ctx)
332
+
333
+ if not snapshots_to_update and snapshot_id:
334
+ raise click.ClickException(f"Snapshot ID '{snapshot_id}' does not exist.")
335
+
336
+ if not snapshots_to_update and not snapshot_id:
337
+ raise click.ClickException("No snapshots found to update.")
338
+
339
+ for _ in snapshots_to_update:
340
+ id_to_update = _["databaseId"]
341
+ app.echo_info(f"Update snapshot {id_to_update} ... ", nl=False)
342
+ request(method="PUT", uri=get_api_url(f"/snapshot/{id_to_update}"))
343
+ app.echo_success("started", nl=not wait)
344
+ if wait:
345
+ app.echo_info(" ... ", nl=False)
346
+ wait_for_snapshot(id_to_update, polling_interval)
347
+ app.echo_success("updated")
348
+
349
+
350
+ @click.command(cls=CmemcCommand, name="inspect")
351
+ @click.argument("SNAPSHOT_ID", type=str, shell_complete=complete_snapshot_ids)
352
+ @click.option("--raw", is_flag=True, help="Outputs raw JSON.")
353
+ @click.pass_context
354
+ def inspect_command(ctx: Context, snapshot_id: str, raw: bool) -> None:
355
+ """Inspect the metadata of a graph insight snapshot."""
356
+ check_availability(ctx)
357
+ app: ApplicationContext = ctx.obj
358
+ snapshot: dict[str, str | bool | list[str]] = get_json(
359
+ get_api_url(f"/snapshot/status/{snapshot_id}")
360
+ )
361
+ if raw:
362
+ app.echo_info_json(snapshot)
363
+ else:
364
+ table = struct_to_table(snapshot)
365
+ app.echo_info_table(table, headers=["Key", "Value"], sort_column=0)
366
+
367
+
368
+ @click.group(cls=CmemcGroup, name="insights")
369
+ def insights_group() -> CmemcGroup: # type: ignore[empty-body]
370
+ """List, create, delete and inspect graph insight snapshots.
371
+
372
+ Graph Insight Snapshots are identified by an ID.
373
+ To get a list of existing snapshots,
374
+ execute the `graph insights list` command or use tab-completion.
375
+ """
376
+
377
+
378
+ insights_group.add_command(list_command)
379
+ insights_group.add_command(delete_command)
380
+ insights_group.add_command(create_command)
381
+ insights_group.add_command(update_command)
382
+ insights_group.add_command(inspect_command)
@@ -138,7 +138,7 @@ def list_command(app: ApplicationContext, raw: bool, id_only: bool) -> None:
138
138
  for _ in projects:
139
139
  row = [
140
140
  _["name"],
141
- _["metaData"]["label"],
141
+ _["metaData"].get("label", ""),
142
142
  ]
143
143
  table.append(row)
144
144
  app.echo_info_table(
@@ -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(
@@ -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(
@@ -69,7 +69,9 @@ WHERE {{}}
69
69
  """
70
70
 
71
71
 
72
- def _validate_vocabs_to_process(iris: tuple[str], filter_: str, all_flag: bool) -> list[str]:
72
+ def _validate_vocabs_to_process(
73
+ iris: tuple[str], filter_: str, all_flag: bool, replace: bool = False
74
+ ) -> list[str]:
73
75
  """Return a list of vocabulary IRTs which will be processed.
74
76
 
75
77
  list is without duplicates, and validated if they exist
@@ -85,6 +87,8 @@ def _validate_vocabs_to_process(iris: tuple[str], filter_: str, all_flag: bool)
85
87
  if filter_ == "installed": # uninstall command
86
88
  return [_ for _ in all_vocabs if all_vocabs[_]["installed"]]
87
89
  # install command
90
+ if replace:
91
+ return list(all_vocabs)
88
92
  return [_ for _ in all_vocabs if not all_vocabs[_]["installed"]]
89
93
 
90
94
  vocabs_to_process = list(set(iris)) # avoid double removal / installation
@@ -96,7 +100,7 @@ def _validate_vocabs_to_process(iris: tuple[str], filter_: str, all_flag: bool)
96
100
  if filter_ == "installable": # install command
97
101
  if _ not in all_vocabs:
98
102
  raise click.UsageError(f"Vocabulary {_} does not exist.")
99
- if all_vocabs[_]["installed"]:
103
+ if all_vocabs[_]["installed"] and not replace:
100
104
  raise click.UsageError(f"Vocabulary {_} already installed.")
101
105
  return vocabs_to_process
102
106
 
@@ -290,15 +294,20 @@ def list_command(app: ApplicationContext, id_only: bool, filter_: str, raw: bool
290
294
  @click.option(
291
295
  "-a", "--all", "all_", is_flag=True, help="Install all vocabularies from the catalog."
292
296
  )
297
+ @click.option(
298
+ "--replace", is_flag=True, help="Replace (overwrite) existing vocabulary, if present."
299
+ )
293
300
  @click.pass_obj
294
- def install_command(app: ApplicationContext, iris: tuple[str], all_: bool) -> None:
301
+ def install_command(app: ApplicationContext, iris: tuple[str], all_: bool, replace: bool) -> None:
295
302
  """Install one or more vocabularies from the catalog.
296
303
 
297
304
  Vocabularies are identified by their graph IRI.
298
305
  Installable vocabularies can be listed with the
299
306
  vocabulary list command.
300
307
  """
301
- vocabs_to_install = _validate_vocabs_to_process(iris=iris, filter_="installable", all_flag=all_)
308
+ vocabs_to_install = _validate_vocabs_to_process(
309
+ iris=iris, filter_="installable", all_flag=all_, replace=replace
310
+ )
302
311
  count: int = len(vocabs_to_install)
303
312
  for current, vocab in enumerate(vocabs_to_install, start=1):
304
313
  app.echo_info(f"Install vocabulary {current}/{count}: {vocab} ... ", nl=False)
@@ -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",
cmem_cmemc/completion.py CHANGED
@@ -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"},
@@ -732,7 +728,7 @@ def project_ids(ctx: Context, param: Argument, incomplete: str) -> list[Completi
732
728
  options = []
733
729
  for _ in projects:
734
730
  project_id = _["name"]
735
- label = _["metaData"]["label"]
731
+ label = _["metaData"].get("label", "")
736
732
  # do not add project if already in the command line
737
733
  if check_option_in_params(project_id, ctx.params.get(str(param.name))):
738
734
  continue
@@ -801,11 +797,11 @@ def graph_uris_with_all_graph_uri(
801
797
  options = graph_uris(ctx, param, incomplete, writeable=True, readonly=True)
802
798
  options.append(
803
799
  CompletionItem(
804
- 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)"
805
801
  )
806
802
  )
807
803
  options.append(
808
- 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")
809
805
  )
810
806
  return options
811
807
 
@@ -938,9 +934,7 @@ def resource_list_filter(ctx: Context, param: Argument, incomplete: str) -> list
938
934
  if args[len(args) - 1] == "--filter":
939
935
  return [CompletionItem(value=f[0], help=f[1]) for f in filter_names]
940
936
  if args[len(args) - 1] == "project":
941
- return finalize_completion(
942
- candidates=project_ids(ctx, param, incomplete), incomplete=incomplete
943
- )
937
+ return project_ids(ctx, param, incomplete)
944
938
  if args[len(args) - 1] == "regex":
945
939
  return finalize_completion(candidates=filter_values_regex, incomplete=incomplete)
946
940
  return []
@@ -961,7 +955,7 @@ def workflow_list_filter(ctx: Context, param: Argument, incomplete: str) -> list
961
955
  ("input-output", "List only workflows with a variable input and output dataset."),
962
956
  ]
963
957
  filter_regex = [
964
- (r"^Final\:", "Example: Workflow label starts with 'Final:'."),
958
+ (r"^Final:", "Example: Workflow label starts with 'Final:'."),
965
959
  (
966
960
  r"[12][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]",
967
961
  "Example: Workflow label contains a data-like string.",
cmem_cmemc/context.py CHANGED
@@ -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.2.0"
32
+ DI_TARGET_VERSION = "v25.3.0"
33
33
 
34
- EXPLORE_TARGET_VERSION = "v25.2.0"
34
+ EXPLORE_TARGET_VERSION = "v25.3.0"
35
35
 
36
36
  KNOWN_CONFIG_KEYS = {
37
37
  "CMEM_BASE_URI": cmempy_config.get_cmem_base_uri,
@@ -32,6 +32,7 @@ def get_icon_for_command_group(full_name: str) -> str:
32
32
  "query": "eccenca/application-queries",
33
33
  "graph": "eccenca/artefact-dataset-eccencadataplatform",
34
34
  "graph imports": "material/family-tree",
35
+ "graph insights": "eccenca/graph-insights",
35
36
  "graph validation": "octicons/verified-16",
36
37
  "vocabulary": "eccenca/application-vocabularies",
37
38
  "vocabulary cache": "eccenca/application-vocabularies",
@@ -145,12 +146,10 @@ def get_commands_for_table_recursive(
145
146
  if isinstance(item, Command):
146
147
  command_name = item.name
147
148
  group_link = f"{prefix}/{group_name}/index.md".replace(" ", "/")
148
- if group_link.startswith("/"):
149
- group_link = group_link[1:]
149
+ group_link = group_link.removeprefix("/")
150
150
  command_anchor = f"{prefix}-{group_name}".replace(" ", "-")
151
151
  command_anchor += f"-{command_name}"
152
- if command_anchor.startswith("-"):
153
- command_anchor = command_anchor[1:]
152
+ command_anchor = command_anchor.removeprefix("-")
154
153
  command_link = f"{group_link}#{command_anchor}"
155
154
  new_command = {
156
155
  "command_name": command_name,
@@ -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):
cmem_cmemc/utils.py CHANGED
@@ -35,7 +35,7 @@ def check_python_version(ctx: type["ApplicationContext"]) -> None:
35
35
  """Check the runtime python version and warn or error."""
36
36
  version = sys.version_info
37
37
  major_expected = [3]
38
- minor_expected = [10, 11, 12, 13]
38
+ minor_expected = [13]
39
39
  if version.major not in major_expected:
40
40
  ctx.echo_error(f"Error: cmemc can not be executed with Python {version.major}.")
41
41
  sys.exit(1)
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: cmem-cmemc
3
- Version: 25.4.0
3
+ Version: 25.5.0
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.3.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)
@@ -2,38 +2,39 @@ cmem_cmemc/__init__.py,sha256=-RPEVweA-fcmEAynszDDMKwArJgxZpGW61UBiV7O4Og,24
2
2
  cmem_cmemc/_cmemc.zsh,sha256=fmkrBHIQxus8cp2AgO1tzZ5mNZdGL_83cYz3a9uAdsg,1326
3
3
  cmem_cmemc/cli.py,sha256=vDdSHFmXUstC3T7OlbPSd0hXxyigJE4VVgRMcsNz5cc,4538
4
4
  cmem_cmemc/command.py,sha256=nBtrwPKFJLRpD3IPk5hKyn2LOMl-1ae7SV9iRhgky8k,1958
5
- cmem_cmemc/command_group.py,sha256=DCHQzt2JXRLZWXbnKd5-ZpIp5GTN0SKX90Me8R8naKQ,3429
5
+ cmem_cmemc/command_group.py,sha256=LSCQfvRQm8R-HsBZfUs-5lzEGEf_LMaleFwZ1AKpZRE,3488
6
6
  cmem_cmemc/commands/__init__.py,sha256=NaGM5jOzf0S_-4UIAwlVDOf2AZ3mliGPoRLXQJfTyZs,22
7
- cmem_cmemc/commands/acl.py,sha256=zy27D_GeEaYEn3P6EEHtQ55sbWkjgwTgspCeuBWPL14,16006
8
- cmem_cmemc/commands/admin.py,sha256=wq9QE-5hfxUM1zQbzicjgQ-T_D9Sa2hlRNVyIHMbzNk,9234
7
+ cmem_cmemc/commands/acl.py,sha256=vJ3H5eeWiVCtGk1ZpEF2OqXeHurZgPwakD8YT09lnVM,19030
8
+ cmem_cmemc/commands/admin.py,sha256=F-393oXTVYV7HxK5NxuhONlBIEg7wffxE7DAKDuasG4,10192
9
9
  cmem_cmemc/commands/client.py,sha256=nBs7MoF2wF45AteTCeIQrXcOwKmHHCd8_lG_SM2mQSA,5127
10
10
  cmem_cmemc/commands/config.py,sha256=VHiVkW6NFuz-tpKXRPl7dO1gIXQLOuEhlGVxb422qwA,5803
11
11
  cmem_cmemc/commands/dataset.py,sha256=zylVsfBD92LzHgQ96edNKaQNsf0zKoPro0VQdX--Mu8,30488
12
- cmem_cmemc/commands/graph.py,sha256=_VtNpCwZF-Cdrv_WAQ5x1pYUB-FnWOxq4-n5qIzu81E,32335
13
- cmem_cmemc/commands/graph_imports.py,sha256=gUcBSBLk7TXyvMth1jdLTb1L20iWidzjXNgb9E1fZUg,14157
12
+ cmem_cmemc/commands/graph.py,sha256=uAI7dEDRjwLSGl6QsJ0PsAycJf2-EZNNn_lIFliDE9Q,32662
13
+ cmem_cmemc/commands/graph_imports.py,sha256=CYgTUSj8giHoWzz0Qjtup0H1V5GKZEI3xXdA_9w_btI,14046
14
+ cmem_cmemc/commands/graph_insights.py,sha256=ARgaYeKBH1b2OIKVjgOPwgAa8mVzo3CLCstcw4Z53z4,13193
14
15
  cmem_cmemc/commands/manual.py,sha256=-sZWeFL92Kj8gL3VYsbpKh2ZaVTyM3LgKaUcpNn9u3A,2179
15
16
  cmem_cmemc/commands/metrics.py,sha256=pIBRTq90f7MEI99HLdFLN3D1xQ2Z2u6VKUeTIz0X7DY,12205
16
17
  cmem_cmemc/commands/migration.py,sha256=y9v4Be7WELGjDGDBZrfLBeqU_G_JH1fnP5UVG9qjB3g,9638
17
- cmem_cmemc/commands/project.py,sha256=zHqs7XBsRjzTO6EGxaN_TgZ_rsqyIPF59aObuMhsfmA,20593
18
+ cmem_cmemc/commands/project.py,sha256=0cnThtaRZOc04EXR31JFEUSONKbh3ANQG4vZANGwfWA,20601
18
19
  cmem_cmemc/commands/python.py,sha256=7ExdKr7nTED5ZZGjeBk5UngNI3F_KNcavwUgwaxApV0,11965
19
- cmem_cmemc/commands/query.py,sha256=etDC1Vg9W1eu5qYuWABozzRZFBW4i0N0Gmiu_rNPgWo,28801
20
+ cmem_cmemc/commands/query.py,sha256=Dra4BHshfec1nvVJbDvLT9VTjfesWJGSQCbyNmnnMWI,28788
20
21
  cmem_cmemc/commands/resource.py,sha256=74cn_yqMv3a6xOQAPpNCuluTWEH-_2PGENJnl7y8qz4,7778
21
22
  cmem_cmemc/commands/scheduler.py,sha256=zYeO1-Hlxh9D-I9JIweQ-SEA0la0wv0EicY_UY7rNCg,8751
22
23
  cmem_cmemc/commands/store.py,sha256=W_6LXq98If50-X-XYZUQsYodVwUjOSm3_jyMp62pFuA,10599
23
24
  cmem_cmemc/commands/user.py,sha256=ANZpeOBA46xiqOcNPrueComsCV0gEBbav-vOL9VgyX4,12535
24
- cmem_cmemc/commands/validation.py,sha256=Fv5yBIXzqy8FyjIzo-wJkv_7H5bhtcQhMxFIlJEVoBw,29495
25
+ cmem_cmemc/commands/validation.py,sha256=ebolVeKpTTQ-tNjsmGWnfIlvv77lDwiWZRtd-qLbrHM,29509
25
26
  cmem_cmemc/commands/variable.py,sha256=aLRH_rFe0h7JBpKIqzcevbk26vczgUGokIDY8g6LPxA,11576
26
- cmem_cmemc/commands/vocabulary.py,sha256=fdXsG7gspA6HeOasXis1ky9UIZG-qRYP-NiFcvzCTKM,17840
27
- cmem_cmemc/commands/workflow.py,sha256=HSgEMbK_anYZpme4yiQ4-pxGfQcL4YWa29lrm0aT8Nk,25777
27
+ cmem_cmemc/commands/vocabulary.py,sha256=erf3zqSRqCVrN_OlCZj5Z5w4L6MRwSaUmC6V11v2vHc,18095
28
+ cmem_cmemc/commands/workflow.py,sha256=BINC-P5RsDvKTkHUbKZpzkfV5M12Cl7EPD4RLmygDOQ,25798
28
29
  cmem_cmemc/commands/workspace.py,sha256=IcZgBsvtulLRFofS70qpln6oKQIZunrVLfSAUeiFhCA,4579
29
- cmem_cmemc/completion.py,sha256=W43pH-WbHmj2-vMcu1kNkJbFhcPXr4OFIHZyHdw6PSM,45313
30
+ cmem_cmemc/completion.py,sha256=JchfZKbgpOIxN8rPyu7ZBPerIzNKXLo0tOgz8xJa5uw,45237
30
31
  cmem_cmemc/config_parser.py,sha256=NduwOT-BB_uAk3pz1Y-ex18RQJW-jjHzkQKCEUUK6Hc,1276
31
32
  cmem_cmemc/constants.py,sha256=pzZYbSaTDUiWmE-VOAHB20oivHew5_FP9UTejySsVK4,550
32
- cmem_cmemc/context.py,sha256=giSw-5BYQtf_Tw0x33JlpzoZY5wP4IMaaoSJMvYzMSc,22247
33
+ cmem_cmemc/context.py,sha256=Axk5zTbhAMPBSMPnk9jIaOQr4GvS3Ih5Jb6ZZc-c5O0,22247
33
34
  cmem_cmemc/exceptions.py,sha256=0lsGOfXhciNGJloJGERMbbPuBbs5IwIIJ_5YnY9qQ-8,546
34
35
  cmem_cmemc/manual_helper/__init__.py,sha256=G3Lqw2aPxo8x63Tg7L0aa5VD9BMaRzZDmhrog7IuEPg,43
35
36
  cmem_cmemc/manual_helper/graph.py,sha256=HU04NYWeJ6LmW4UC7qHr1v1qsm2Md61pJ-pgWUHFmHY,3647
36
- cmem_cmemc/manual_helper/multi_page.py,sha256=_nw5yFVo7K1e9n5qETJcbkq12ru_Fq9TH0u8EzyyRz4,12338
37
+ cmem_cmemc/manual_helper/multi_page.py,sha256=v24CZpirD-ThVYJI34axQhC-IB9APQ_VyjMrl6WNQQk,12320
37
38
  cmem_cmemc/manual_helper/single_page.py,sha256=0mMn_IJwFCe-WPKAmxGEStb8IINLpQRxAx_F1pIxg1E,1526
38
39
  cmem_cmemc/migrations/__init__.py,sha256=i6Ri7qN58ou_MwOzm2KibPkXOD7u-1ELky-nUE5LjAA,24
39
40
  cmem_cmemc/migrations/abc.py,sha256=UGJzrvMzUFdp2-sosp49ObRI-SrUSzLJqLEhvB4QTzg,3564
@@ -50,11 +51,11 @@ cmem_cmemc/placeholder.py,sha256=PzZWpVBa7WPP5_5f-HlSoaZaOnto-1Dr9XuCTSpPnNM,238
50
51
  cmem_cmemc/smart_path/__init__.py,sha256=zDgm1kDrzLyCuIcNb8VXSdnb_CcVNjGkjgiIDVlsh74,3023
51
52
  cmem_cmemc/smart_path/clients/__init__.py,sha256=YFOm69BfTCRvAcJjN_CoUmCv3kzEciyYOPUG337p_pA,1696
52
53
  cmem_cmemc/smart_path/clients/http.py,sha256=3clZu2v4uuOvPY4MY_8SVSy7hIXJDNooahFRBRpy0ok,2347
53
- cmem_cmemc/string_processor.py,sha256=kSVePdgFmf2ekurKj6TbDJn6ur82VGLwCsTJ9ODfBEU,2879
54
+ cmem_cmemc/string_processor.py,sha256=0EZVl3UeVgV5EEYGLPvCGHrJDMd0Ezekkwg6mDQVBZI,3112
54
55
  cmem_cmemc/title_helper.py,sha256=7frjAR54_Xc1gszOWXfzSmKFTawNJQ7kkXhZcHmQLyw,1250
55
- cmem_cmemc/utils.py,sha256=PkDFDISz7uemJCmyIWmtCcjfR_gRnRBL8ao76Ex-py8,14669
56
- cmem_cmemc-25.4.0.dist-info/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
57
- cmem_cmemc-25.4.0.dist-info/METADATA,sha256=SXMr4rUBE2nGtL-eKPcpwPtWTI-FJafD8EcDJwdi6l8,5642
58
- cmem_cmemc-25.4.0.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
59
- cmem_cmemc-25.4.0.dist-info/entry_points.txt,sha256=2G0AWAyz501EHpFTjIxccdlCTsHt80NT0pdUGP1QkPA,45
60
- cmem_cmemc-25.4.0.dist-info/RECORD,,
56
+ cmem_cmemc/utils.py,sha256=LlvAMHxciY9ge-REdwHQhRetJGrYghRqBZADxqE0yL4,14657
57
+ cmem_cmemc-25.5.0.dist-info/METADATA,sha256=F3j2Ueql34apDjUfI0IH2TgvOpRzq-Ki-QGvd37Kqq8,5715
58
+ cmem_cmemc-25.5.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
59
+ cmem_cmemc-25.5.0.dist-info/entry_points.txt,sha256=2G0AWAyz501EHpFTjIxccdlCTsHt80NT0pdUGP1QkPA,45
60
+ cmem_cmemc-25.5.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
61
+ cmem_cmemc-25.5.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.0.1
2
+ Generator: poetry-core 2.2.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any