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.
- cmem_cmemc/cli.py +11 -6
- cmem_cmemc/command.py +1 -1
- cmem_cmemc/command_group.py +59 -31
- cmem_cmemc/commands/acl.py +403 -26
- cmem_cmemc/commands/admin.py +10 -10
- cmem_cmemc/commands/client.py +12 -5
- cmem_cmemc/commands/config.py +106 -12
- cmem_cmemc/commands/dataset.py +163 -172
- cmem_cmemc/commands/file.py +509 -0
- cmem_cmemc/commands/graph.py +200 -72
- cmem_cmemc/commands/graph_imports.py +12 -5
- cmem_cmemc/commands/graph_insights.py +157 -53
- cmem_cmemc/commands/metrics.py +15 -9
- cmem_cmemc/commands/migration.py +12 -4
- cmem_cmemc/commands/package.py +548 -0
- cmem_cmemc/commands/project.py +157 -22
- cmem_cmemc/commands/python.py +9 -5
- cmem_cmemc/commands/query.py +119 -25
- cmem_cmemc/commands/scheduler.py +6 -4
- cmem_cmemc/commands/store.py +2 -1
- cmem_cmemc/commands/user.py +124 -24
- cmem_cmemc/commands/validation.py +15 -10
- cmem_cmemc/commands/variable.py +264 -61
- cmem_cmemc/commands/vocabulary.py +31 -17
- cmem_cmemc/commands/workflow.py +21 -11
- cmem_cmemc/completion.py +126 -109
- cmem_cmemc/context.py +40 -10
- cmem_cmemc/exceptions.py +8 -2
- cmem_cmemc/manual_helper/graph.py +2 -2
- cmem_cmemc/manual_helper/multi_page.py +5 -7
- cmem_cmemc/object_list.py +234 -7
- cmem_cmemc/placeholder.py +2 -2
- cmem_cmemc/string_processor.py +153 -4
- cmem_cmemc/title_helper.py +50 -0
- cmem_cmemc/utils.py +9 -8
- {cmem_cmemc-25.5.0rc1.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/METADATA +7 -6
- cmem_cmemc-26.1.0rc1.dist-info/RECORD +62 -0
- {cmem_cmemc-25.5.0rc1.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/WHEEL +1 -1
- cmem_cmemc/commands/resource.py +0 -220
- cmem_cmemc-25.5.0rc1.dist-info/RECORD +0 -61
- {cmem_cmemc-25.5.0rc1.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/entry_points.txt +0 -0
- {cmem_cmemc-25.5.0rc1.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/licenses/LICENSE +0 -0
cmem_cmemc/commands/acl.py
CHANGED
|
@@ -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.
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
136
|
-
app.echo_info(convert_iri_to_qname(iri=
|
|
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
|
|
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
|
-
|
|
324
|
-
f"
|
|
325
|
-
|
|
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.
|
|
497
|
-
def delete_command(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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)
|
cmem_cmemc/commands/admin.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
225
|
-
if
|
|
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)."
|
cmem_cmemc/commands/client.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Keycloak client management commands"""
|
|
2
2
|
|
|
3
3
|
import click
|
|
4
|
-
from click import
|
|
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(
|
|
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
|
|
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
|
|
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
|
|