cmem-cmemc 25.5.0rc1__py3-none-any.whl → 26.1.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. cmem_cmemc/cli.py +11 -6
  2. cmem_cmemc/command.py +1 -1
  3. cmem_cmemc/command_group.py +59 -31
  4. cmem_cmemc/commands/acl.py +403 -26
  5. cmem_cmemc/commands/admin.py +10 -10
  6. cmem_cmemc/commands/client.py +12 -5
  7. cmem_cmemc/commands/config.py +106 -12
  8. cmem_cmemc/commands/dataset.py +163 -172
  9. cmem_cmemc/commands/file.py +509 -0
  10. cmem_cmemc/commands/graph.py +200 -72
  11. cmem_cmemc/commands/graph_imports.py +12 -5
  12. cmem_cmemc/commands/graph_insights.py +157 -53
  13. cmem_cmemc/commands/metrics.py +15 -9
  14. cmem_cmemc/commands/migration.py +12 -4
  15. cmem_cmemc/commands/package.py +548 -0
  16. cmem_cmemc/commands/project.py +157 -22
  17. cmem_cmemc/commands/python.py +9 -5
  18. cmem_cmemc/commands/query.py +119 -25
  19. cmem_cmemc/commands/scheduler.py +6 -4
  20. cmem_cmemc/commands/store.py +2 -1
  21. cmem_cmemc/commands/user.py +124 -24
  22. cmem_cmemc/commands/validation.py +15 -10
  23. cmem_cmemc/commands/variable.py +264 -61
  24. cmem_cmemc/commands/vocabulary.py +31 -17
  25. cmem_cmemc/commands/workflow.py +21 -11
  26. cmem_cmemc/completion.py +126 -109
  27. cmem_cmemc/context.py +40 -10
  28. cmem_cmemc/exceptions.py +8 -2
  29. cmem_cmemc/manual_helper/graph.py +2 -2
  30. cmem_cmemc/manual_helper/multi_page.py +5 -7
  31. cmem_cmemc/object_list.py +234 -7
  32. cmem_cmemc/placeholder.py +2 -2
  33. cmem_cmemc/string_processor.py +153 -4
  34. cmem_cmemc/title_helper.py +50 -0
  35. cmem_cmemc/utils.py +9 -8
  36. {cmem_cmemc-25.5.0rc1.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/METADATA +7 -6
  37. cmem_cmemc-26.1.0rc1.dist-info/RECORD +62 -0
  38. {cmem_cmemc-25.5.0rc1.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/WHEEL +1 -1
  39. cmem_cmemc/commands/resource.py +0 -220
  40. cmem_cmemc-25.5.0rc1.dist-info/RECORD +0 -61
  41. {cmem_cmemc-25.5.0rc1.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/entry_points.txt +0 -0
  42. {cmem_cmemc-25.5.0rc1.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/licenses/LICENSE +0 -0
@@ -1,8 +1,11 @@
1
1
  """access control"""
2
2
 
3
+ import json
4
+ import os
5
+
3
6
  import click
4
7
  import requests.exceptions
5
- from click import Option
8
+ from click import Context, Option
6
9
  from cmem.cmempy.dp.authorization.conditions import (
7
10
  create_access_condition,
8
11
  delete_access_condition,
@@ -12,12 +15,21 @@ from cmem.cmempy.dp.authorization.conditions import (
12
15
  update_access_condition,
13
16
  )
14
17
  from cmem.cmempy.keycloak.user import get_user_by_username, user_groups
18
+ from jinja2 import Template
15
19
 
16
20
  from cmem_cmemc import completion
17
21
  from cmem_cmemc.command import CmemcCommand
18
22
  from cmem_cmemc.command_group import CmemcGroup
19
23
  from cmem_cmemc.constants import NS_ACL, NS_ACTION, NS_GROUP, NS_USER
20
- from cmem_cmemc.context import ApplicationContext
24
+ from cmem_cmemc.context import ApplicationContext, build_caption
25
+ from cmem_cmemc.object_list import (
26
+ DirectListPropertyFilter,
27
+ DirectMultiValuePropertyFilter,
28
+ DirectValuePropertyFilter,
29
+ ObjectList,
30
+ )
31
+ from cmem_cmemc.parameter_types.path import ClickSmartPath
32
+ from cmem_cmemc.smart_path import SmartPath as Path
21
33
  from cmem_cmemc.utils import (
22
34
  convert_iri_to_qname,
23
35
  convert_qname_to_iri,
@@ -51,6 +63,10 @@ HELP_TEXTS = {
51
63
  "end of the pattern or the wildcard alone."
52
64
  ),
53
65
  "query": "Dynamic access condition query (file or the query catalog IRI).",
66
+ "replace": (
67
+ "Replace (overwrite) existing access condition, if present. "
68
+ "Can be used only in combination with '--id'."
69
+ ),
54
70
  }
55
71
 
56
72
  WARNING_UNKNOWN_USER = "Unknown User or no access to get user info."
@@ -113,6 +129,96 @@ def generate_acl_name(user: str | None, groups: list[str], query: str | None) ->
113
129
  return "Condition for ALL users"
114
130
 
115
131
 
132
+ def get_acls(ctx: Context) -> list[dict]: # noqa: ARG001
133
+ """Get access conditions for object list"""
134
+ return fetch_all_acls() # type: ignore[no-any-return]
135
+
136
+
137
+ acl_list = ObjectList(
138
+ name="access conditions",
139
+ get_objects=get_acls,
140
+ filters=[
141
+ DirectMultiValuePropertyFilter(
142
+ name="ids",
143
+ description="Access conditions with a specific ID.",
144
+ property_key="iri",
145
+ ),
146
+ DirectValuePropertyFilter(
147
+ name="name",
148
+ description="List only access conditions with a specific name.",
149
+ property_key="name",
150
+ ),
151
+ DirectValuePropertyFilter(
152
+ name="user",
153
+ description="List only access conditions that require a specific user account.",
154
+ property_key="requiresAccount",
155
+ ),
156
+ DirectListPropertyFilter(
157
+ name="group",
158
+ description="List only access conditions that require membership in a specific group.",
159
+ property_key="requiresGroup",
160
+ ),
161
+ DirectListPropertyFilter(
162
+ name="read-graph",
163
+ description="List only access conditions that grant read access to a specific graph.",
164
+ property_key="readableGraphs",
165
+ ),
166
+ DirectListPropertyFilter(
167
+ name="write-graph",
168
+ description="List only access conditions that grant write access to a specific graph.",
169
+ property_key="writableGraphs",
170
+ ),
171
+ ],
172
+ )
173
+
174
+
175
+ def _validate_acl_ids(access_condition_ids: tuple[str, ...]) -> None:
176
+ """Validate that all provided access condition IDs exist."""
177
+ if not access_condition_ids:
178
+ return
179
+ all_acls = fetch_all_acls()
180
+ all_iris = {acl["iri"] for acl in all_acls}
181
+ for acl_id in access_condition_ids:
182
+ iri = convert_qname_to_iri(qname=acl_id, default_ns=NS_ACL)
183
+ if iri not in all_iris:
184
+ raise click.ClickException(
185
+ f"Access condition {acl_id} not available. Use the 'admin acl list' "
186
+ "command to get a list of existing access conditions."
187
+ )
188
+
189
+
190
+ def _get_acls_to_delete(
191
+ ctx: Context,
192
+ access_condition_ids: tuple[str, ...],
193
+ all_: bool,
194
+ filter_: tuple[tuple[str, str], ...],
195
+ ) -> list[dict]:
196
+ """Get the list of access conditions to delete based on selection method."""
197
+ if all_:
198
+ # Get all access conditions
199
+ return fetch_all_acls() # type: ignore[no-any-return]
200
+
201
+ # Validate provided IDs exist before proceeding
202
+ _validate_acl_ids(access_condition_ids)
203
+
204
+ # Build filter list
205
+ filter_to_apply = list(filter_) if filter_ else []
206
+
207
+ # Add IDs if provided (using internal multi-value filter)
208
+ if access_condition_ids:
209
+ iris = [convert_qname_to_iri(qname=_, default_ns=NS_ACL) for _ in access_condition_ids]
210
+ filter_to_apply.append(("ids", ",".join(iris)))
211
+
212
+ # Apply filters
213
+ acls = acl_list.apply_filters(ctx=ctx, filter_=filter_to_apply)
214
+
215
+ # Validation: ensure we found access conditions
216
+ if not acls and not access_condition_ids:
217
+ raise click.ClickException("No access conditions found matching the provided filters.")
218
+
219
+ return acls
220
+
221
+
116
222
  @click.command(cls=CmemcCommand, name="list")
117
223
  @click.option("--raw", is_flag=True, help="Outputs raw JSON.")
118
224
  @click.option(
@@ -120,20 +226,29 @@ def generate_acl_name(user: str | None, groups: list[str], query: str | None) ->
120
226
  is_flag=True,
121
227
  help="Lists only URIs. This is useful for piping the IDs into other commands.",
122
228
  )
123
- @click.pass_obj
124
- def list_command(app: ApplicationContext, raw: bool, id_only: bool) -> None:
229
+ @click.option(
230
+ "--filter",
231
+ "filter_",
232
+ type=(str, str),
233
+ multiple=True,
234
+ help=acl_list.get_filter_help_text(),
235
+ shell_complete=acl_list.complete_values,
236
+ )
237
+ @click.pass_context
238
+ def list_command(ctx: Context, raw: bool, id_only: bool, filter_: tuple[tuple[str, str]]) -> None:
125
239
  """List access conditions.
126
240
 
127
241
  This command retrieves and lists all access conditions, which are manageable
128
242
  by the current account.
129
243
  """
130
- acls = fetch_all_acls()
244
+ app: ApplicationContext = ctx.obj
245
+ acls = acl_list.apply_filters(ctx=ctx, filter_=filter_)
131
246
  if raw:
132
247
  app.echo_info_json(acls)
133
248
  return
134
249
  if id_only:
135
- for graph in acls:
136
- app.echo_info(convert_iri_to_qname(iri=graph.get("iri"), default_ns=NS_ACL))
250
+ for _ in acls:
251
+ app.echo_info(convert_iri_to_qname(iri=_.get("iri"), default_ns=NS_ACL))
137
252
  return
138
253
  table = [
139
254
  (convert_iri_to_qname(iri=_.get("iri"), default_ns=NS_ACL), _.get("name", "-"))
@@ -143,6 +258,7 @@ def list_command(app: ApplicationContext, raw: bool, id_only: bool) -> None:
143
258
  table,
144
259
  headers=["URI", "Name"],
145
260
  sort_column=0,
261
+ caption=build_caption(len(table), "access condition"),
146
262
  empty_table_message="No access conditions found. "
147
263
  "Use the `admin acl create` command to create a new access condition.",
148
264
  )
@@ -255,8 +371,8 @@ def inspect_command(app: ApplicationContext, access_condition_id: str, raw: bool
255
371
  type=click.STRING,
256
372
  help=HELP_TEXTS["description"],
257
373
  )
374
+ @click.option("--replace", is_flag=True, help=HELP_TEXTS["replace"])
258
375
  @click.pass_obj
259
- # pylint: disable-msg=too-many-arguments
260
376
  def create_command( # noqa: PLR0913
261
377
  app: ApplicationContext,
262
378
  name: str,
@@ -271,6 +387,7 @@ def create_command( # noqa: PLR0913
271
387
  write_graph_patterns: tuple[str],
272
388
  action_patterns: tuple[str],
273
389
  query: str,
390
+ replace: bool,
274
391
  ) -> None:
275
392
  """Create an access condition.
276
393
 
@@ -293,6 +410,9 @@ def create_command( # noqa: PLR0913
293
410
 
294
411
  Example: cmemc admin acl create --group local-users --write-graph https://example.org/
295
412
  """
413
+ if replace and not id_:
414
+ raise click.UsageError("To replace an access condition, you must specify an ID.")
415
+
296
416
  if (
297
417
  not read_graphs
298
418
  and not write_graphs
@@ -312,7 +432,7 @@ def create_command( # noqa: PLR0913
312
432
  query_str = get_query_text(query, {"user", "group", "readGraph", "writeGraph"})
313
433
 
314
434
  if not user and not groups and not query:
315
- app.echo_warning("Access conditions without a user or group assignment affect ALL users.")
435
+ app.echo_warning("Access conditions without a user or group assignment affects ALL users.")
316
436
 
317
437
  if not name:
318
438
  name = generate_acl_name(user=user, groups=groups, query=query)
@@ -320,11 +440,11 @@ def create_command( # noqa: PLR0913
320
440
  if not description:
321
441
  description = "This access condition was created with cmemc."
322
442
 
323
- app.echo_info(
324
- f"Creating access condition '{name}' ... ",
325
- nl=False,
326
- )
327
-
443
+ if replace and NS_ACL + id_ in [_["iri"] for _ in fetch_all_acls()]:
444
+ app.echo_info(f"Replacing access condition '{id_}' ... ", nl=False)
445
+ delete_access_condition(iri=NS_ACL + id_)
446
+ else:
447
+ app.echo_info(f"Creating access condition '{name}' ... ", nl=False)
328
448
  create_access_condition(
329
449
  name=name,
330
450
  static_id=id_,
@@ -431,7 +551,6 @@ def create_command( # noqa: PLR0913
431
551
  help=HELP_TEXTS["query"],
432
552
  )
433
553
  @click.pass_obj
434
- # pylint: disable-msg=too-many-arguments
435
554
  def update_command( # noqa: PLR0913
436
555
  app: ApplicationContext,
437
556
  access_condition_id: str,
@@ -480,6 +599,14 @@ def update_command( # noqa: PLR0913
480
599
 
481
600
 
482
601
  @click.command(cls=CmemcCommand, name="delete")
602
+ @click.option(
603
+ "--filter",
604
+ "filter_",
605
+ type=(str, str),
606
+ help=acl_list.get_filter_help_text(),
607
+ shell_complete=acl_list.complete_values,
608
+ multiple=True,
609
+ )
483
610
  @click.option(
484
611
  "-a",
485
612
  "--all",
@@ -493,27 +620,275 @@ def update_command( # noqa: PLR0913
493
620
  type=click.STRING,
494
621
  shell_complete=completion.acl_ids,
495
622
  )
496
- @click.pass_obj
497
- def delete_command(app: ApplicationContext, all_: bool, access_condition_ids: list[str]) -> None:
623
+ @click.pass_context
624
+ def delete_command(
625
+ ctx: Context, access_condition_ids: tuple[str], filter_: tuple[tuple[str, str]], all_: bool
626
+ ) -> None:
498
627
  """Delete access conditions.
499
628
 
500
629
  This command deletes existing access conditions from the account.
501
630
 
502
- Note: Access conditions can be listed by using the `cmemc admin acs list` command.
631
+ Warning: Access conditions will be deleted without prompting.
632
+
633
+ Note: Access conditions can be listed by using the `admin acl list` command.
503
634
  """
504
- if access_condition_ids == () and not all_:
635
+ app: ApplicationContext = ctx.obj
636
+
637
+ # Validation: require at least one selection method
638
+ if not access_condition_ids and not filter_ and not all_:
505
639
  raise click.UsageError(
506
- "Either specify at least one access condition ID,"
507
- " or use the --all option to delete all access conditions."
640
+ "Either provide at least one access condition ID, a filter, or use the --all flag."
508
641
  )
642
+
643
+ if access_condition_ids and (all_ or filter_):
644
+ raise click.UsageError(
645
+ "Either specify access condition IDs OR use a --filter or the --all option."
646
+ )
647
+
648
+ # Get access conditions to delete based on selection method
649
+ acls_to_delete = _get_acls_to_delete(ctx, access_condition_ids, all_, filter_)
650
+
651
+ # Avoid double removal as well as sort IRIs
652
+ iris_to_delete = sorted({acl["iri"] for acl in acls_to_delete}, key=lambda v: v.lower())
653
+ count = len(iris_to_delete)
654
+
655
+ # Delete each access condition
656
+ for current, iri in enumerate(iris_to_delete, start=1):
657
+ current_string = str(current).zfill(len(str(count)))
658
+ app.echo_info(f"Delete access condition {current_string}/{count}: {iri} ... ", nl=False)
659
+ delete_access_condition(iri=iri)
660
+ app.echo_success("deleted")
661
+
662
+
663
+ @click.command(cls=CmemcCommand, name="export")
664
+ @click.option(
665
+ "-a",
666
+ "--all",
667
+ "all_",
668
+ is_flag=True,
669
+ help="Export all access conditions.",
670
+ )
671
+ @click.option(
672
+ "--filter",
673
+ "filter_",
674
+ type=(str, str),
675
+ help=acl_list.get_filter_help_text(),
676
+ shell_complete=acl_list.complete_values,
677
+ multiple=True,
678
+ )
679
+ @click.option(
680
+ "--output-file",
681
+ type=ClickSmartPath(writable=True, allow_dash=True, dir_okay=False),
682
+ help="Export to this file. Use '-' for stdout. "
683
+ "If specified, overrides --output-dir and --filename-template.",
684
+ )
685
+ @click.option(
686
+ "--output-dir",
687
+ default=".",
688
+ show_default=True,
689
+ type=ClickSmartPath(writable=True, file_okay=False),
690
+ help="The base directory, where the ACL files will be created. "
691
+ "If this directory does not exist, it will be silently created. "
692
+ "Ignored if --output-file is specified.",
693
+ )
694
+ @click.option(
695
+ "--filename-template",
696
+ "-t",
697
+ "template",
698
+ default="{{date}}-{{connection}}.acls.json",
699
+ show_default=True,
700
+ type=click.STRING,
701
+ help="Template for the export file name(s). Possible placeholders are (Jinja2): "
702
+ "{{connection}} (from the --connection option) and "
703
+ "{{date}} (the current date as YYYY-MM-DD). "
704
+ "Needed directories will be created. "
705
+ "Ignored if --output-file is specified.",
706
+ )
707
+ @click.option(
708
+ "--replace",
709
+ is_flag=True,
710
+ help="Replace existing files. This is a dangerous option, so use it with care.",
711
+ )
712
+ @click.argument(
713
+ "access_condition_ids",
714
+ nargs=-1,
715
+ type=click.STRING,
716
+ shell_complete=completion.acl_ids,
717
+ )
718
+ @click.pass_context
719
+ def export_command( # noqa: PLR0913
720
+ ctx: Context,
721
+ all_: bool,
722
+ filter_: tuple[tuple[str, str]],
723
+ output_file: str | None,
724
+ output_dir: str,
725
+ template: str,
726
+ replace: bool,
727
+ access_condition_ids: tuple[str],
728
+ ) -> None:
729
+ """Export access conditions to a JSON file.
730
+
731
+ Access conditions can be exported based on IDs, filters, or all at once.
732
+ The exported JSON can be imported back using the `acl import` command.
733
+
734
+ By default, uses template-based file naming with the current date and connection name.
735
+ You can override this by specifying an explicit output file path with --output-file.
736
+
737
+ Example: cmemc admin acl export --all
738
+
739
+ Example: cmemc admin acl export --all --output-file acls.json
740
+
741
+ Example: cmemc admin acl export --filter group local-users
742
+
743
+ Example: cmemc admin acl export :my-acl-iri
744
+ """
745
+ app: ApplicationContext = ctx.obj
746
+
747
+ if not access_condition_ids and not filter_ and not all_:
748
+ raise click.UsageError(
749
+ "Either provide at least one access condition ID, a filter, or use the --all flag."
750
+ )
751
+
509
752
  if all_:
510
- access_condition_ids = [_["iri"] for _ in fetch_all_acls()]
753
+ acls_to_export = fetch_all_acls()
754
+ else:
755
+ # Apply user-provided filters first
756
+ filter_to_apply = list(filter_) if filter_ else []
757
+
758
+ # If IDs provided, add internal list-of-ids filter (with OR logic)
759
+ if access_condition_ids:
760
+ iris = [convert_qname_to_iri(qname=_, default_ns=NS_ACL) for _ in access_condition_ids]
761
+ filter_to_apply.append(("ids", ",".join(iris)))
762
+
763
+ acls_to_export = acl_list.apply_filters(ctx=ctx, filter_=filter_to_apply)
511
764
 
512
- count = len(access_condition_ids)
513
- for index, _ in enumerate(access_condition_ids, 1):
514
- app.echo_info(f"Delete access condition {index}/{count}: {_} ... ", nl=False)
515
- delete_access_condition(iri=convert_qname_to_iri(qname=_, default_ns=NS_ACL))
765
+ if not acls_to_export:
766
+ raise click.ClickException("No access conditions found to export.")
767
+
768
+ count = len(acls_to_export)
769
+ output = json.dumps(acls_to_export, indent=2)
770
+
771
+ # Handle stdout special case early
772
+ if output_file == "-":
773
+ click.echo(output)
774
+ return
775
+
776
+ # Determine output path based on mode
777
+ if output_file:
778
+ # Mode 1: Explicit output file (overrides template parameters)
779
+ export_path = output_file
780
+ else:
781
+ # Mode 2: Template-based output (default)
782
+ template_data = app.get_template_data()
783
+ local_name = Template(template).render(template_data)
784
+ export_path = os.path.normpath(str(Path(output_dir) / local_name))
785
+ # Create parent directory if needed (only in template mode)
786
+ Path(export_path).parent.mkdir(exist_ok=True, parents=True)
787
+
788
+ # Write to file
789
+ app.echo_info(f"Exporting {count} access condition(s) to {export_path} ... ", nl=False)
790
+ if Path(export_path).exists() and replace is not True:
791
+ app.echo_error("file exists")
792
+ return
793
+
794
+ with click.open_file(export_path, "w") as f:
795
+ f.write(output)
796
+ app.echo_success("done")
797
+
798
+
799
+ @click.command(cls=CmemcCommand, name="import")
800
+ @click.option(
801
+ "--replace",
802
+ is_flag=True,
803
+ help="Replace existing access conditions with the same IRI. "
804
+ "By default, import will fail if an access condition already exists.",
805
+ )
806
+ @click.argument(
807
+ "input_file",
808
+ required=True,
809
+ type=ClickSmartPath(readable=True, allow_dash=False, dir_okay=False),
810
+ shell_complete=completion.acl_files,
811
+ )
812
+ @click.pass_context
813
+ def import_command(ctx: Context, replace: bool, input_file: str) -> None:
814
+ """Import access conditions from a JSON file.
815
+
816
+ This command imports access conditions from a JSON file that was created
817
+ using the `acl export` command.
818
+
819
+ If --replace is specified, existing access conditions with matching IRIs
820
+ will be deleted before importing. Otherwise, the import will skip if an
821
+ access condition with the same IRI already exists.
822
+
823
+ Example: cmemc admin acl import acls.json
824
+
825
+ Example: cmemc admin acl import --replace acls.json
826
+ """
827
+ app: ApplicationContext = ctx.obj
828
+
829
+ # Read and parse JSON file
830
+ try:
831
+ with click.open_file(input_file, "r") as f:
832
+ acls_to_import = json.load(f)
833
+ except json.JSONDecodeError as e:
834
+ raise click.ClickException(f"Invalid JSON file: {e}") from e
835
+
836
+ if not isinstance(acls_to_import, list):
837
+ raise click.ClickException("JSON file must contain a list of access conditions.")
838
+
839
+ if not acls_to_import:
840
+ app.echo_warning("No access conditions found in file.")
841
+ return
842
+
843
+ existing_acls = {acl["iri"]: acl for acl in fetch_all_acls()}
844
+ count = len(acls_to_import)
845
+ imported = 0
846
+ skipped = 0
847
+
848
+ for current, acl_data in enumerate(acls_to_import, start=1):
849
+ iri = acl_data.get("iri")
850
+ name = acl_data.get("name", "Unnamed")
851
+
852
+ if not iri:
853
+ app.echo_warning(f"Skipping ACL {current}/{count}: missing IRI")
854
+ skipped += 1
855
+ continue
856
+
857
+ # Extract ID from IRI
858
+ acl_id = iri.split("/")[-1]
859
+
860
+ app.echo_info(f"Import ACL {current}/{count}: {name} ({acl_id}) ... ", nl=False)
861
+
862
+ # Check if already exists
863
+ if iri in existing_acls:
864
+ if replace:
865
+ app.echo_info("replacing ... ", nl=False)
866
+ delete_access_condition(iri=iri)
867
+ else:
868
+ app.echo_warning("skipped (already exists)")
869
+ skipped += 1
870
+ continue
871
+
872
+ # Create the access condition
873
+ create_access_condition(
874
+ name=acl_data.get("name", "Imported ACL"),
875
+ static_id=acl_id,
876
+ description=acl_data.get("comment", "Created with cmemc"),
877
+ user=acl_data.get("requiresAccount"),
878
+ groups=acl_data.get("requiresGroup", []),
879
+ read_graphs=acl_data.get("readableGraphs", []),
880
+ write_graphs=acl_data.get("writableGraphs", []),
881
+ actions=acl_data.get("allowedActions", []),
882
+ read_graph_patterns=acl_data.get("grantReadPatterns", []),
883
+ write_graph_patterns=acl_data.get("grantWritePatterns", []),
884
+ action_patterns=acl_data.get("grantAllowedActions", []),
885
+ query=None, # Queries not supported in import
886
+ )
516
887
  app.echo_success("done")
888
+ imported += 1
889
+
890
+ # Summary
891
+ app.echo_info(f"Import complete: {imported} imported, {skipped} skipped")
517
892
 
518
893
 
519
894
  @click.command(name="review")
@@ -581,4 +956,6 @@ acl.add_command(inspect_command)
581
956
  acl.add_command(create_command)
582
957
  acl.add_command(update_command)
583
958
  acl.add_command(delete_command)
959
+ acl.add_command(export_command)
960
+ acl.add_command(import_command)
584
961
  acl.add_command(review_command)
@@ -4,12 +4,11 @@ from datetime import datetime, timezone
4
4
 
5
5
  import click
6
6
  import jwt
7
- import timeago
8
- from click import ClickException
9
7
  from cmem.cmempy.api import get_access_token, get_token
10
8
  from cmem.cmempy.config import get_cmem_base_uri
11
9
  from cmem.cmempy.health import get_complete_status_info
12
10
  from dateutil.relativedelta import relativedelta
11
+ from humanize import naturaltime
13
12
 
14
13
  from cmem_cmemc import completion
15
14
  from cmem_cmemc.command import CmemcCommand
@@ -22,6 +21,7 @@ from cmem_cmemc.commands.store import store
22
21
  from cmem_cmemc.commands.user import user
23
22
  from cmem_cmemc.commands.workspace import workspace
24
23
  from cmem_cmemc.context import ApplicationContext
24
+ from cmem_cmemc.exceptions import CmemcError
25
25
  from cmem_cmemc.utils import struct_to_table
26
26
 
27
27
  WARNING_MIGRATION = (
@@ -45,7 +45,7 @@ def _check_cmem_license(app: ApplicationContext, data: dict, exit_1: str) -> Non
45
45
  cmem_license_end = license_["validDate"]
46
46
  output = f"Your Corporate Memory license expired on {cmem_license_end}."
47
47
  if exit_1 in ("error", "always"):
48
- raise ClickException(output)
48
+ raise CmemcError(output)
49
49
  app.echo_error(output)
50
50
 
51
51
 
@@ -61,7 +61,7 @@ def _check_graphdb_license(app: ApplicationContext, data: dict, months: int, exi
61
61
  graphdb_license_end = data["explore"]["info"]["store"]["licenseExpiration"]
62
62
  output = f"Your GraphDB license expires on {graphdb_license_end}."
63
63
  if exit_1 == "always":
64
- raise ClickException(output)
64
+ raise CmemcError(output)
65
65
  app.echo_warning(output)
66
66
 
67
67
 
@@ -125,7 +125,7 @@ def status_command( # noqa: C901, PLR0912
125
125
  app.echo_debug(_["explore"]["error"])
126
126
 
127
127
  if exit_1 in ("always", "error") and (_["overall"]["healthy"] != "UP"):
128
- raise ClickException(
128
+ raise CmemcError(
129
129
  f"One or more major status flags are DOWN or UNKNOWN: {_!r}",
130
130
  )
131
131
  if raw:
@@ -137,19 +137,19 @@ def status_command( # noqa: C901, PLR0912
137
137
  app.echo_info(table[0][1])
138
138
  return
139
139
  if len(table) == 0:
140
- raise ClickException(f"No values for key(s): {key}")
140
+ raise CmemcError(f"No values for key(s): {key}")
141
141
  app.echo_info_table(table, headers=["Key", "Value"], sort_column=0)
142
142
  return
143
143
  app.check_versions()
144
144
  _workspace_config = _["explore"]["info"].get("workspaceConfiguration", {})
145
145
  if _workspace_config.get("workspacesToMigrate"):
146
146
  if exit_1 == "always":
147
- raise ClickException(WARNING_MIGRATION)
147
+ raise CmemcError(WARNING_MIGRATION)
148
148
  app.echo_warning(WARNING_MIGRATION)
149
149
 
150
150
  if _["shapes"]["version"] not in (_["explore"]["version"], "UNKNOWN"):
151
151
  if exit_1 == "always":
152
- raise ClickException(WARNING_SHAPES)
152
+ raise CmemcError(WARNING_SHAPES)
153
153
  app.echo_warning(WARNING_SHAPES)
154
154
 
155
155
  _check_cmem_license(app=app, data=_, exit_1=exit_1)
@@ -221,8 +221,8 @@ def token_command(app: ApplicationContext, raw: bool, decode: bool, ttl: bool) -
221
221
  return
222
222
  exp_time = datetime.fromtimestamp(exp_ts, tz=timezone.utc)
223
223
  now_time = datetime.now(tz=timezone.utc)
224
- ttl_delta = timeago.format(exp_time, now_time)
225
- if ttl_delta.startswith("in"):
224
+ ttl_delta = naturaltime(exp_time, when=now_time)
225
+ if exp_time > now_time:
226
226
  app.echo_info(
227
227
  f"The provided access token will expire {ttl_delta} "
228
228
  f"(TTL is {ttl_in_seconds} seconds)."
@@ -1,7 +1,7 @@
1
1
  """Keycloak client management commands"""
2
2
 
3
3
  import click
4
- from click import ClickException, UsageError
4
+ from click import UsageError
5
5
  from cmem.cmempy.config import get_keycloak_base_uri, get_keycloak_realm_id
6
6
  from cmem.cmempy.keycloak.client import (
7
7
  generate_client_secret,
@@ -13,7 +13,8 @@ from cmem.cmempy.keycloak.client import (
13
13
  from cmem_cmemc import completion
14
14
  from cmem_cmemc.command import CmemcCommand
15
15
  from cmem_cmemc.command_group import CmemcGroup
16
- from cmem_cmemc.context import ApplicationContext
16
+ from cmem_cmemc.context import ApplicationContext, build_caption
17
+ from cmem_cmemc.exceptions import CmemcError
17
18
 
18
19
  NO_CLIENT_ERROR = (
19
20
  "{} is not a valid client account. "
@@ -47,7 +48,13 @@ def list_command(app: ApplicationContext, raw: bool, id_only: bool) -> None:
47
48
  app.echo_info(cnt["clientId"])
48
49
  return
49
50
  table = [(_["clientId"], _.get("description", "-")) for _ in clients]
50
- app.echo_info_table(table, headers=["Client ID", "Description"], sort_column=0)
51
+ app.echo_info_table(
52
+ table,
53
+ headers=["Client ID", "Description"],
54
+ sort_column=0,
55
+ caption=build_caption(len(table), "client"),
56
+ empty_table_message="No client accounts found.",
57
+ )
51
58
 
52
59
 
53
60
  @click.command(cls=CmemcCommand, name="secret")
@@ -66,7 +73,7 @@ def secret_command(app: ApplicationContext, client_id: str, generate: bool, outp
66
73
 
67
74
  clients = get_client_by_client_id(client_id)
68
75
  if not clients:
69
- raise ClickException(NO_CLIENT_ERROR.format(client_id))
76
+ raise CmemcError(NO_CLIENT_ERROR.format(client_id))
70
77
 
71
78
  if generate:
72
79
  if not output:
@@ -106,7 +113,7 @@ def open_command(app: ApplicationContext, client_ids: tuple[str]) -> None:
106
113
  client_id_map = {c["clientId"]: c["id"] for c in clients}
107
114
  for _ in client_ids:
108
115
  if _ not in client_id_map:
109
- raise ClickException(NO_CLIENT_ERROR.format(_))
116
+ raise CmemcError(NO_CLIENT_ERROR.format(_))
110
117
  client_id = client_id_map[_]
111
118
  open_user_uri = f"{open_client_base_uri}/{client_id}/settings"
112
119