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.
Files changed (39) hide show
  1. cmem_cmemc/cli.py +11 -6
  2. cmem_cmemc/command.py +1 -1
  3. cmem_cmemc/command_group.py +27 -0
  4. cmem_cmemc/commands/acl.py +388 -20
  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 +162 -118
  9. cmem_cmemc/commands/file.py +117 -73
  10. cmem_cmemc/commands/graph.py +200 -72
  11. cmem_cmemc/commands/graph_imports.py +12 -5
  12. cmem_cmemc/commands/graph_insights.py +61 -25
  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 +155 -22
  17. cmem_cmemc/commands/python.py +8 -4
  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 +18 -13
  25. cmem_cmemc/commands/workflow.py +21 -11
  26. cmem_cmemc/completion.py +105 -105
  27. cmem_cmemc/context.py +38 -8
  28. cmem_cmemc/exceptions.py +8 -2
  29. cmem_cmemc/manual_helper/multi_page.py +0 -1
  30. cmem_cmemc/object_list.py +234 -7
  31. cmem_cmemc/string_processor.py +142 -5
  32. cmem_cmemc/title_helper.py +50 -0
  33. cmem_cmemc/utils.py +8 -7
  34. {cmem_cmemc-25.6.0.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/METADATA +6 -6
  35. cmem_cmemc-26.1.0rc1.dist-info/RECORD +62 -0
  36. {cmem_cmemc-25.6.0.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/WHEEL +1 -1
  37. cmem_cmemc-25.6.0.dist-info/RECORD +0 -61
  38. {cmem_cmemc-25.6.0.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/entry_points.txt +0 -0
  39. {cmem_cmemc-25.6.0.dist-info → cmem_cmemc-26.1.0rc1.dist-info}/licenses/LICENSE +0 -0
@@ -4,7 +4,6 @@ import sys
4
4
  from getpass import getpass
5
5
 
6
6
  import click
7
- from click import ClickException
8
7
  from cmem.cmempy.config import get_keycloak_base_uri, get_keycloak_realm_id
9
8
  from cmem.cmempy.keycloak.group import list_groups
10
9
  from cmem.cmempy.keycloak.user import (
@@ -23,8 +22,10 @@ from cmem.cmempy.keycloak.user import (
23
22
  from cmem_cmemc import completion
24
23
  from cmem_cmemc.command import CmemcCommand
25
24
  from cmem_cmemc.command_group import CmemcGroup
26
- from cmem_cmemc.context import ApplicationContext
25
+ from cmem_cmemc.context import ApplicationContext, build_caption
26
+ from cmem_cmemc.exceptions import CmemcError
27
27
  from cmem_cmemc.object_list import (
28
+ DirectMultiValuePropertyFilter,
28
29
  DirectValuePropertyFilter,
29
30
  ObjectList,
30
31
  compare_regex,
@@ -71,10 +72,61 @@ user_list = ObjectList(
71
72
  compare=compare_regex,
72
73
  fixed_completion=[],
73
74
  ),
75
+ DirectMultiValuePropertyFilter(
76
+ name="usernames",
77
+ description="Internal filter for multiple usernames.",
78
+ property_key="username",
79
+ ),
74
80
  ],
75
81
  )
76
82
 
77
83
 
84
+ def _validate_usernames(usernames: tuple[str, ...]) -> None:
85
+ """Validate that all provided usernames exist."""
86
+ if not usernames:
87
+ return
88
+ all_users = list_users()
89
+ all_usernames = [user["username"] for user in all_users]
90
+ for username in usernames:
91
+ if username not in all_usernames:
92
+ raise CmemcError(NO_USER_ERROR.format(username))
93
+
94
+
95
+ def _get_users_to_delete(
96
+ ctx: click.Context,
97
+ usernames: tuple[str, ...],
98
+ all_: bool,
99
+ filter_: tuple[tuple[str, str], ...],
100
+ ) -> list[str]:
101
+ """Get the list of usernames to delete based on selection method."""
102
+ if all_:
103
+ # Get all users
104
+ users = list_users()
105
+ return [user["username"] for user in users]
106
+
107
+ # Validate provided usernames exist before proceeding
108
+ _validate_usernames(usernames)
109
+
110
+ # Build filter list
111
+ filter_to_apply = list(filter_) if filter_ else []
112
+
113
+ # Add usernames if provided (using internal multi-value filter)
114
+ if usernames:
115
+ filter_to_apply.append(("usernames", ",".join(usernames)))
116
+
117
+ # Apply filters
118
+ users = user_list.apply_filters(ctx=ctx, filter_=filter_to_apply)
119
+
120
+ # Build list of usernames
121
+ result = [user["username"] for user in users]
122
+
123
+ # Validation: ensure we found users
124
+ if not result and not usernames:
125
+ raise CmemcError("No user accounts found matching the provided filters.")
126
+
127
+ return result
128
+
129
+
78
130
  @click.command(cls=CmemcCommand, name="list")
79
131
  @click.option("--raw", is_flag=True, help="Outputs raw JSON.")
80
132
  @click.option(
@@ -117,33 +169,81 @@ def list_command(
117
169
  )
118
170
  for usr in users
119
171
  ]
172
+ filtered = len(filter_) > 0
120
173
  app.echo_info_table(
121
174
  table,
122
175
  headers=["Username", "First Name", "Last Name", "Email"],
123
176
  sort_column=0,
124
- empty_table_message="No user accounts found. "
125
- "Use the `admin user create` command to create an account.",
177
+ caption=build_caption(len(table), "user", filtered=filtered),
178
+ empty_table_message="No user accounts found for these filters."
179
+ if filtered
180
+ else "No user accounts found. Use the `admin user create` command to create an account.",
126
181
  )
127
182
 
128
183
 
129
184
  @click.command(cls=CmemcCommand, name="delete")
130
- @click.argument("username", shell_complete=completion.user_ids)
131
- @click.pass_obj
132
- def delete_command(app: ApplicationContext, username: str) -> None:
133
- """Delete a user account.
185
+ @click.option(
186
+ "-a",
187
+ "--all",
188
+ "all_",
189
+ is_flag=True,
190
+ help="Delete all user accounts. This is a dangerous option, so use it with care.",
191
+ )
192
+ @click.option(
193
+ "--filter",
194
+ "filter_",
195
+ type=(str, str),
196
+ multiple=True,
197
+ shell_complete=user_list.complete_values,
198
+ help=user_list.get_filter_help_text(),
199
+ )
200
+ @click.argument("usernames", nargs=-1, type=click.STRING, shell_complete=completion.user_ids)
201
+ @click.pass_context
202
+ def delete_command(
203
+ ctx: click.Context,
204
+ all_: bool,
205
+ filter_: tuple[tuple[str, str]],
206
+ usernames: tuple[str],
207
+ ) -> None:
208
+ """Delete user accounts.
209
+
210
+ This command deletes user accounts from a realm.
134
211
 
135
- This command deletes a user account from a realm.
212
+ Warning: User accounts will be deleted without prompting.
136
213
 
137
- Note: The deletion of a user account does not delete the assigned groups of
138
- this account, only the assignments to these groups.
214
+ Note: The deletion of user accounts does not delete the assigned groups,
215
+ only the assignments to these groups. User accounts can be listed by using
216
+ the `admin user list` command.
139
217
  """
140
- app.echo_info(f"Deleting user {username} ... ", nl=False)
141
- users = get_user_by_username(username)
142
- if not users:
143
- raise ClickException(NO_USER_ERROR.format(username))
218
+ app = ctx.obj
219
+
220
+ # Validation: require at least one selection method
221
+ if not usernames and not all_ and not filter_:
222
+ raise click.UsageError(
223
+ "Either specify at least one username"
224
+ " or use a --filter option,"
225
+ " or use the --all option to delete all user accounts."
226
+ )
227
+
228
+ if usernames and (all_ or filter_):
229
+ raise click.UsageError("Either specify a username OR use a --filter or the --all option.")
230
+
231
+ # Get users to delete based on selection method
232
+ users_to_delete = _get_users_to_delete(ctx, usernames, all_, filter_)
233
+
234
+ # Avoid double removal as well as sort usernames
235
+ processed_usernames = sorted(set(users_to_delete), key=lambda v: v.lower())
236
+ count = len(processed_usernames)
144
237
 
145
- delete_user(users[0]["id"])
146
- app.echo_success("deleted")
238
+ # Delete each user
239
+ for current, username in enumerate(processed_usernames, start=1):
240
+ current_string = str(current).zfill(len(str(count)))
241
+ app.echo_info(f"Delete user {current_string}/{count}: {username} ... ", nl=False)
242
+ users = get_user_by_username(username)
243
+ if not users:
244
+ raise CmemcError(NO_USER_ERROR.format(username))
245
+ delete_user(users[0]["id"])
246
+ app.echo_success("deleted")
147
247
 
148
248
 
149
249
  @click.command(cls=CmemcCommand, name="create")
@@ -161,7 +261,7 @@ def create_command(app: ApplicationContext, username: str) -> None:
161
261
  app.echo_info(f"Creating user {username} ... ", nl=False)
162
262
  users = get_user_by_username(username)
163
263
  if users:
164
- raise ClickException(EXISTING_USER_ERROR.format(username))
264
+ raise CmemcError(EXISTING_USER_ERROR.format(username))
165
265
 
166
266
  create_user(username=username)
167
267
  app.echo_success("done")
@@ -215,14 +315,14 @@ def update_command( # noqa: PLR0913
215
315
  app.echo_info(f"Updating user {username} ... ", nl=False)
216
316
  users = get_user_by_username(username)
217
317
  if not users:
218
- raise ClickException(NO_USER_ERROR.format(username))
318
+ raise CmemcError(NO_USER_ERROR.format(username))
219
319
  user_id = users[0]["id"]
220
320
  all_groups = {group["name"]: group["id"] for group in list_groups()}
221
321
  invalid_groups = [group for group in assign_group if group not in all_groups]
222
322
  existing_user_groups = {group["name"] for group in user_groups(user_id)}
223
323
 
224
324
  if invalid_groups:
225
- raise ClickException(
325
+ raise CmemcError(
226
326
  NO_GROUP_ERROR.format(
227
327
  ", ".join(invalid_groups), ", ".join(all_groups.keys() - set(existing_user_groups))
228
328
  )
@@ -232,7 +332,7 @@ def update_command( # noqa: PLR0913
232
332
  group for group in unassign_group if group not in existing_user_groups
233
333
  ]
234
334
  if invalid_unassign_groups:
235
- raise ClickException(
335
+ raise CmemcError(
236
336
  INVALID_UNASSIGN_GROUP_ERROR.format(
237
337
  ", ".join(invalid_unassign_groups), ", ".join(existing_user_groups)
238
338
  )
@@ -287,7 +387,7 @@ def password_command(
287
387
  app.echo_info(f"Changing password for account {username} ... ", nl=False)
288
388
  users = get_user_by_username(username)
289
389
  if not users:
290
- raise ClickException(NO_USER_ERROR.format(username))
390
+ raise CmemcError(NO_USER_ERROR.format(username))
291
391
  if not value and not request_change:
292
392
  app.echo_info("\nNew password: ", nl=False)
293
393
  value = getpass(prompt="")
@@ -300,7 +400,7 @@ def password_command(
300
400
  if value:
301
401
  reset_password(user_id=users[0]["id"], value=value, temporary=temporary)
302
402
  if request_change and not users[0].get("email", None):
303
- raise ClickException(NO_EMAIL_ERROR.format(username))
403
+ raise CmemcError(NO_EMAIL_ERROR.format(username))
304
404
  if request_change:
305
405
  request_password_change(users[0]["id"])
306
406
  app.echo_success("done")
@@ -331,7 +431,7 @@ def open_command(app: ApplicationContext, usernames: str) -> None:
331
431
  user_name_id_map = {u["username"]: u["id"] for u in users}
332
432
  for _ in usernames:
333
433
  if _ not in user_name_id_map:
334
- raise ClickException(NO_USER_ERROR.format(_))
434
+ raise CmemcError(NO_USER_ERROR.format(_))
335
435
  user_id = user_name_id_map[_]
336
436
  open_user_uri = f"{open_user_base_uri}/{user_id}/settings"
337
437
 
@@ -8,19 +8,18 @@ from datetime import datetime, timezone
8
8
  from pathlib import Path
9
9
 
10
10
  import click
11
- import timeago
12
11
  from click import Context, UsageError
13
12
  from click.shell_completion import CompletionItem
14
- from cmem.cmempy.config import get_cmem_base_uri
15
13
  from cmem.cmempy.dp.shacl import validation
14
+ from humanize import naturaltime
16
15
  from junit_xml import TestCase, TestSuite, to_xml_report_string
17
16
  from rich.progress import Progress, SpinnerColumn, TaskID, TimeElapsedColumn
18
17
 
19
18
  from cmem_cmemc import completion
20
19
  from cmem_cmemc.command import CmemcCommand
21
20
  from cmem_cmemc.command_group import CmemcGroup
22
- from cmem_cmemc.completion import finalize_completion
23
- from cmem_cmemc.context import ApplicationContext
21
+ from cmem_cmemc.completion import finalize_completion, suppress_completion_errors
22
+ from cmem_cmemc.context import ApplicationContext, build_caption
24
23
  from cmem_cmemc.exceptions import ServerError
25
24
  from cmem_cmemc.object_list import (
26
25
  DirectListPropertyFilter,
@@ -213,7 +212,7 @@ def _get_batch_validation_option(validation_: dict) -> tuple[str, str]:
213
212
  state = validation_["state"]
214
213
  graph = validation_["contextGraphIri"]
215
214
  stamp = datetime.fromtimestamp(validation_["executionStarted"] / 1000, tz=timezone.utc)
216
- time_ago = timeago.format(stamp, datetime.now(tz=timezone.utc))
215
+ time_ago = naturaltime(stamp, when=datetime.now(tz=timezone.utc))
217
216
  resources = _get_resource_count(validation_)
218
217
  violations = _get_violation_count(validation_)
219
218
  return (
@@ -222,6 +221,7 @@ def _get_batch_validation_option(validation_: dict) -> tuple[str, str]:
222
221
  )
223
222
 
224
223
 
224
+ @suppress_completion_errors
225
225
  def _complete_all_batch_validations(
226
226
  ctx: click.Context, # noqa: ARG001
227
227
  param: click.Argument, # noqa: ARG001
@@ -232,6 +232,7 @@ def _complete_all_batch_validations(
232
232
  return finalize_completion(candidates=options, incomplete=incomplete)
233
233
 
234
234
 
235
+ @suppress_completion_errors
235
236
  def _complete_running_batch_validations(
236
237
  ctx: click.Context, # noqa: ARG001
237
238
  param: click.Argument, # noqa: ARG001
@@ -246,6 +247,7 @@ def _complete_running_batch_validations(
246
247
  return finalize_completion(candidates=options, incomplete=incomplete)
247
248
 
248
249
 
250
+ @suppress_completion_errors
249
251
  def _complete_finished_batch_validations(
250
252
  ctx: click.Context, # noqa: ARG001
251
253
  param: click.Argument, # noqa: ARG001
@@ -330,10 +332,10 @@ def _wait_for_process_completion(
330
332
  progress.stop()
331
333
  progress.__exit__(None, None, None)
332
334
  if state.status == validation.STATUS_CANCELLED:
333
- raise ServerError(app, "Process was cancelled.")
335
+ raise ServerError("Process was cancelled.")
334
336
  if state.status == validation.STATUS_ERROR:
335
337
  error_message = state.data.get("error", "")
336
- raise ServerError(app, f"Process ended with error: {error_message}")
338
+ raise ServerError(f"Process ended with error: {error_message}")
337
339
  return state.status
338
340
 
339
341
 
@@ -579,13 +581,16 @@ def list_command(ctx: Context, filter_: tuple[tuple[str, str]], id_only: bool, r
579
581
  _get_violation_count(_),
580
582
  ]
581
583
  table.append(row)
584
+ filtered = len(filter_) > 0
582
585
  app.echo_info_table(
583
586
  table,
584
587
  headers=["ID", "Status", "Started", "Graph", "Resources", "Violations"],
585
- caption=f"Validation Processes of {get_cmem_base_uri()}",
588
+ caption=build_caption(len(table), "validation process", filtered=filtered),
586
589
  cell_processing={2: TimeAgo(), 3: GraphLink()},
587
- empty_table_message="No validation processes found. "
588
- "Use `graph validation execute` to start a new validation process.",
590
+ empty_table_message="No validation processes found for these filters."
591
+ if filtered
592
+ else "No validation processes found."
593
+ " Use `graph validation execute` to start a new validation process.",
589
594
  )
590
595
 
591
596