cmem-cmemc 25.6.0__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 +27 -0
- cmem_cmemc/commands/acl.py +388 -20
- 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 +162 -118
- cmem_cmemc/commands/file.py +117 -73
- cmem_cmemc/commands/graph.py +200 -72
- cmem_cmemc/commands/graph_imports.py +12 -5
- cmem_cmemc/commands/graph_insights.py +61 -25
- 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 +155 -22
- cmem_cmemc/commands/python.py +8 -4
- 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 +18 -13
- cmem_cmemc/commands/workflow.py +21 -11
- cmem_cmemc/completion.py +105 -105
- cmem_cmemc/context.py +38 -8
- cmem_cmemc/exceptions.py +8 -2
- cmem_cmemc/manual_helper/multi_page.py +0 -1
- cmem_cmemc/object_list.py +234 -7
- cmem_cmemc/string_processor.py +142 -5
- cmem_cmemc/title_helper.py +50 -0
- cmem_cmemc/utils.py +8 -7
- {cmem_cmemc-25.6.0.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/METADATA +6 -6
- cmem_cmemc-26.1.0rc1.dist-info/RECORD +62 -0
- {cmem_cmemc-25.6.0.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/WHEEL +1 -1
- cmem_cmemc-25.6.0.dist-info/RECORD +0 -61
- {cmem_cmemc-25.6.0.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/entry_points.txt +0 -0
- {cmem_cmemc-25.6.0.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/licenses/LICENSE +0 -0
cmem_cmemc/cli.py
CHANGED
|
@@ -7,6 +7,8 @@ from importlib.resources import open_text
|
|
|
7
7
|
from os import environ as env
|
|
8
8
|
|
|
9
9
|
import click
|
|
10
|
+
from cmem_client.exceptions import BaseError as ClientBaseError
|
|
11
|
+
from eccenca_marketplace_client.exceptions import BaseError as MarketplaceClientBaseError
|
|
10
12
|
|
|
11
13
|
from cmem_cmemc import completion
|
|
12
14
|
from cmem_cmemc.command_group import CmemcGroup
|
|
@@ -16,6 +18,7 @@ from cmem_cmemc.commands import (
|
|
|
16
18
|
dataset,
|
|
17
19
|
graph,
|
|
18
20
|
manual,
|
|
21
|
+
package,
|
|
19
22
|
project,
|
|
20
23
|
query,
|
|
21
24
|
vocabulary,
|
|
@@ -106,7 +109,7 @@ def cli( # noqa: PLR0913
|
|
|
106
109
|
|
|
107
110
|
https://eccenca.com/go/cmemc
|
|
108
111
|
|
|
109
|
-
cmemc is ©
|
|
112
|
+
cmemc is © 2026 eccenca GmbH, licensed under the Apache License 2.0.
|
|
110
113
|
"""
|
|
111
114
|
_ = connection, debug, quiet, config_file, external_http_timeout
|
|
112
115
|
if " ".join(sys.argv).find("config edit") != -1:
|
|
@@ -120,6 +123,7 @@ cli.add_command(admin.admin)
|
|
|
120
123
|
cli.add_command(config.config)
|
|
121
124
|
cli.add_command(dataset.dataset)
|
|
122
125
|
cli.add_command(graph.graph)
|
|
126
|
+
cli.add_command(package.package_group)
|
|
123
127
|
cli.add_command(project.project)
|
|
124
128
|
cli.add_command(query.query)
|
|
125
129
|
cli.add_command(vocabulary.vocabulary)
|
|
@@ -130,9 +134,10 @@ cli.add_command(manual.manual_command)
|
|
|
130
134
|
def main() -> None:
|
|
131
135
|
"""Start the command line interface."""
|
|
132
136
|
try:
|
|
133
|
-
cli()
|
|
134
|
-
except CmemcError as error:
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
137
|
+
cli()
|
|
138
|
+
except (CmemcError, ClientBaseError, MarketplaceClientBaseError) as error:
|
|
139
|
+
if "--debug" in sys.argv or "-d" in sys.argv:
|
|
140
|
+
ApplicationContext.echo_debug_string(traceback.format_exc())
|
|
141
|
+
message = extract_error_message(error).removeprefix("CmemcError: ")
|
|
142
|
+
ApplicationContext.echo_error(message)
|
|
138
143
|
sys.exit(1)
|
cmem_cmemc/command.py
CHANGED
cmem_cmemc/command_group.py
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
"""cmemc Click Command Group"""
|
|
2
2
|
|
|
3
|
+
import shutil
|
|
4
|
+
|
|
5
|
+
from click import Command, Context
|
|
6
|
+
from click.core import _complete_visible_commands
|
|
7
|
+
from click.shell_completion import CompletionItem
|
|
3
8
|
from click_didyoumean import DYMGroup
|
|
4
9
|
from click_help_colors import HelpColorsGroup
|
|
5
10
|
|
|
@@ -49,6 +54,7 @@ class CmemcGroup(HelpColorsGroup, DYMGroup):
|
|
|
49
54
|
"migrate": self.color_for_writing_commands,
|
|
50
55
|
"migrations": self.color_for_command_groups,
|
|
51
56
|
"password": self.color_for_writing_commands,
|
|
57
|
+
"package": self.color_for_command_groups,
|
|
52
58
|
"project": self.color_for_command_groups,
|
|
53
59
|
"python": self.color_for_command_groups,
|
|
54
60
|
"query": self.color_for_command_groups,
|
|
@@ -71,3 +77,24 @@ class CmemcGroup(HelpColorsGroup, DYMGroup):
|
|
|
71
77
|
},
|
|
72
78
|
)
|
|
73
79
|
super().__init__(*args, **kwargs)
|
|
80
|
+
|
|
81
|
+
def shell_complete(self, ctx: Context, incomplete: str) -> list[CompletionItem]:
|
|
82
|
+
"""Override shell completion to use full terminal width for help text.
|
|
83
|
+
|
|
84
|
+
This method extends the default Click Group shell completion by using
|
|
85
|
+
the full terminal width for command descriptions instead of the default
|
|
86
|
+
45-character limit.
|
|
87
|
+
"""
|
|
88
|
+
# Get terminal width, default to a large number if not available
|
|
89
|
+
terminal_width = shutil.get_terminal_size(fallback=(200, 24)).columns
|
|
90
|
+
|
|
91
|
+
# Get completions for subcommands with full-width help text
|
|
92
|
+
results = [
|
|
93
|
+
CompletionItem(name, help=command.get_short_help_str(limit=terminal_width))
|
|
94
|
+
for name, command in _complete_visible_commands(ctx, incomplete)
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
# Call Command.shell_complete (not Group.shell_complete) to get options, etc.
|
|
98
|
+
# This avoids duplicate subcommand completions from the parent Group class
|
|
99
|
+
results.extend(Command.shell_complete(self, ctx, incomplete))
|
|
100
|
+
return results
|
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,
|
|
@@ -117,6 +129,96 @@ def generate_acl_name(user: str | None, groups: list[str], query: str | None) ->
|
|
|
117
129
|
return "Condition for ALL users"
|
|
118
130
|
|
|
119
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
|
+
|
|
120
222
|
@click.command(cls=CmemcCommand, name="list")
|
|
121
223
|
@click.option("--raw", is_flag=True, help="Outputs raw JSON.")
|
|
122
224
|
@click.option(
|
|
@@ -124,20 +226,29 @@ def generate_acl_name(user: str | None, groups: list[str], query: str | None) ->
|
|
|
124
226
|
is_flag=True,
|
|
125
227
|
help="Lists only URIs. This is useful for piping the IDs into other commands.",
|
|
126
228
|
)
|
|
127
|
-
@click.
|
|
128
|
-
|
|
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:
|
|
129
239
|
"""List access conditions.
|
|
130
240
|
|
|
131
241
|
This command retrieves and lists all access conditions, which are manageable
|
|
132
242
|
by the current account.
|
|
133
243
|
"""
|
|
134
|
-
|
|
244
|
+
app: ApplicationContext = ctx.obj
|
|
245
|
+
acls = acl_list.apply_filters(ctx=ctx, filter_=filter_)
|
|
135
246
|
if raw:
|
|
136
247
|
app.echo_info_json(acls)
|
|
137
248
|
return
|
|
138
249
|
if id_only:
|
|
139
|
-
for
|
|
140
|
-
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))
|
|
141
252
|
return
|
|
142
253
|
table = [
|
|
143
254
|
(convert_iri_to_qname(iri=_.get("iri"), default_ns=NS_ACL), _.get("name", "-"))
|
|
@@ -147,6 +258,7 @@ def list_command(app: ApplicationContext, raw: bool, id_only: bool) -> None:
|
|
|
147
258
|
table,
|
|
148
259
|
headers=["URI", "Name"],
|
|
149
260
|
sort_column=0,
|
|
261
|
+
caption=build_caption(len(table), "access condition"),
|
|
150
262
|
empty_table_message="No access conditions found. "
|
|
151
263
|
"Use the `admin acl create` command to create a new access condition.",
|
|
152
264
|
)
|
|
@@ -261,7 +373,6 @@ def inspect_command(app: ApplicationContext, access_condition_id: str, raw: bool
|
|
|
261
373
|
)
|
|
262
374
|
@click.option("--replace", is_flag=True, help=HELP_TEXTS["replace"])
|
|
263
375
|
@click.pass_obj
|
|
264
|
-
# pylint: disable-msg=too-many-arguments
|
|
265
376
|
def create_command( # noqa: PLR0913
|
|
266
377
|
app: ApplicationContext,
|
|
267
378
|
name: str,
|
|
@@ -440,7 +551,6 @@ def create_command( # noqa: PLR0913
|
|
|
440
551
|
help=HELP_TEXTS["query"],
|
|
441
552
|
)
|
|
442
553
|
@click.pass_obj
|
|
443
|
-
# pylint: disable-msg=too-many-arguments
|
|
444
554
|
def update_command( # noqa: PLR0913
|
|
445
555
|
app: ApplicationContext,
|
|
446
556
|
access_condition_id: str,
|
|
@@ -489,6 +599,14 @@ def update_command( # noqa: PLR0913
|
|
|
489
599
|
|
|
490
600
|
|
|
491
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
|
+
)
|
|
492
610
|
@click.option(
|
|
493
611
|
"-a",
|
|
494
612
|
"--all",
|
|
@@ -502,27 +620,275 @@ def update_command( # noqa: PLR0913
|
|
|
502
620
|
type=click.STRING,
|
|
503
621
|
shell_complete=completion.acl_ids,
|
|
504
622
|
)
|
|
505
|
-
@click.
|
|
506
|
-
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:
|
|
507
627
|
"""Delete access conditions.
|
|
508
628
|
|
|
509
629
|
This command deletes existing access conditions from the account.
|
|
510
630
|
|
|
511
|
-
|
|
631
|
+
Warning: Access conditions will be deleted without prompting.
|
|
632
|
+
|
|
633
|
+
Note: Access conditions can be listed by using the `admin acl list` command.
|
|
634
|
+
"""
|
|
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_:
|
|
639
|
+
raise click.UsageError(
|
|
640
|
+
"Either provide at least one access condition ID, a filter, or use the --all flag."
|
|
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
|
|
512
744
|
"""
|
|
513
|
-
|
|
745
|
+
app: ApplicationContext = ctx.obj
|
|
746
|
+
|
|
747
|
+
if not access_condition_ids and not filter_ and not all_:
|
|
514
748
|
raise click.UsageError(
|
|
515
|
-
"Either
|
|
516
|
-
" or use the --all option to delete all access conditions."
|
|
749
|
+
"Either provide at least one access condition ID, a filter, or use the --all flag."
|
|
517
750
|
)
|
|
751
|
+
|
|
518
752
|
if all_:
|
|
519
|
-
|
|
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)
|
|
764
|
+
|
|
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)
|
|
520
770
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
+
)
|
|
525
887
|
app.echo_success("done")
|
|
888
|
+
imported += 1
|
|
889
|
+
|
|
890
|
+
# Summary
|
|
891
|
+
app.echo_info(f"Import complete: {imported} imported, {skipped} skipped")
|
|
526
892
|
|
|
527
893
|
|
|
528
894
|
@click.command(name="review")
|
|
@@ -590,4 +956,6 @@ acl.add_command(inspect_command)
|
|
|
590
956
|
acl.add_command(create_command)
|
|
591
957
|
acl.add_command(update_command)
|
|
592
958
|
acl.add_command(delete_command)
|
|
959
|
+
acl.add_command(export_command)
|
|
960
|
+
acl.add_command(import_command)
|
|
593
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)."
|