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
@@ -0,0 +1,509 @@
1
+ """Build project file commands for cmemc."""
2
+
3
+ import click
4
+ from click import Context, UsageError
5
+ from click.shell_completion import CompletionItem
6
+ from cmem.cmempy.workspace.projects.resources import get_all_resources
7
+ from cmem.cmempy.workspace.projects.resources.resource import (
8
+ create_resource,
9
+ delete_resource,
10
+ get_resource_metadata,
11
+ get_resource_response,
12
+ get_resource_usage_data,
13
+ resource_exist,
14
+ )
15
+
16
+ from cmem_cmemc import completion
17
+ from cmem_cmemc.command import CmemcCommand
18
+ from cmem_cmemc.command_group import CmemcGroup
19
+ from cmem_cmemc.context import ApplicationContext, build_caption
20
+ from cmem_cmemc.exceptions import CmemcError
21
+ from cmem_cmemc.object_list import (
22
+ DirectMultiValuePropertyFilter,
23
+ DirectValuePropertyFilter,
24
+ ObjectList,
25
+ compare_regex,
26
+ )
27
+ from cmem_cmemc.parameter_types.path import ClickSmartPath
28
+ from cmem_cmemc.smart_path import SmartPath as Path
29
+ from cmem_cmemc.string_processor import FileSize, TimeAgo
30
+ from cmem_cmemc.utils import check_or_select_project, split_task_id, struct_to_table
31
+
32
+
33
+ def get_resources(ctx: Context) -> list[dict]: # noqa: ARG001
34
+ """Get file resources for object list."""
35
+ _: list[dict] = get_all_resources()
36
+ return _
37
+
38
+
39
+ resource_list = ObjectList(
40
+ name="file resources",
41
+ get_objects=get_resources,
42
+ filters=[
43
+ DirectValuePropertyFilter(
44
+ name="project",
45
+ description="Filter file resources by project ID.",
46
+ property_key="project",
47
+ completion_method="values",
48
+ ),
49
+ DirectValuePropertyFilter(
50
+ name="regex",
51
+ description="Filter by regex matching the resource name.",
52
+ property_key="name",
53
+ compare=compare_regex,
54
+ fixed_completion=[
55
+ CompletionItem("csv$", help="File resources which name ends with .csv"),
56
+ CompletionItem(
57
+ "2021-10-[0-9][0-9]",
58
+ help="File resources which name has a date from 2021-10 in it",
59
+ ),
60
+ ],
61
+ fixed_completion_only=False,
62
+ ),
63
+ DirectMultiValuePropertyFilter(
64
+ name="ids",
65
+ description="Internal filter for multiple resource IDs.",
66
+ property_key="id",
67
+ ),
68
+ ],
69
+ )
70
+
71
+
72
+ def _upload_file_resource(
73
+ app: ApplicationContext,
74
+ project_id: str,
75
+ local_file_name: str,
76
+ remote_file_name: str,
77
+ replace: bool,
78
+ ) -> None:
79
+ """Upload a local file as a dataset resource to a project.
80
+
81
+ Args:
82
+ ----
83
+ app: the click cli app context.
84
+ project_id: The project ID in the workspace.
85
+ local_file_name: The path to the local file name
86
+ remote_file_name: The remote file name
87
+ replace: Replace resource if needed.
88
+
89
+ Raises:
90
+ ------
91
+ ValueError: if resource exists and no replace
92
+
93
+ """
94
+ exist = resource_exist(project_name=project_id, resource_name=remote_file_name)
95
+ if exist and not replace:
96
+ raise CmemcError(
97
+ f"A file resource with the name '{remote_file_name}' already "
98
+ "exists in this project. \n"
99
+ "Please rename the file or use the '--replace' "
100
+ "parameter in order to overwrite the remote file.",
101
+ )
102
+ if exist:
103
+ app.echo_info(
104
+ f"Replace content of {remote_file_name} with content from "
105
+ f"{local_file_name} in project {project_id} ... ",
106
+ nl=False,
107
+ )
108
+ else:
109
+ app.echo_info(
110
+ f"Upload {local_file_name} as a file resource "
111
+ f"{remote_file_name} to project {project_id} ... ",
112
+ nl=False,
113
+ )
114
+ create_resource(
115
+ project_name=project_id,
116
+ resource_name=remote_file_name,
117
+ file_resource=ClickSmartPath.open(local_file_name),
118
+ replace=replace,
119
+ )
120
+ app.echo_success("done")
121
+
122
+
123
+ def _validate_resource_ids(resource_ids: tuple[str, ...]) -> None:
124
+ """Validate that all provided resource IDs exist."""
125
+ if not resource_ids:
126
+ return
127
+ all_resources = get_all_resources()
128
+ all_resource_ids = [_["id"] for _ in all_resources]
129
+ for resource_id in resource_ids:
130
+ if resource_id not in all_resource_ids:
131
+ raise CmemcError(f"Resource {resource_id} not available.")
132
+
133
+
134
+ def _get_resources_to_delete(
135
+ ctx: Context,
136
+ resource_ids: tuple[str, ...],
137
+ all_: bool,
138
+ filter_: tuple[tuple[str, str], ...],
139
+ ) -> list[dict]:
140
+ """Get the list of resources to delete based on selection method."""
141
+ if all_:
142
+ _: list[dict] = get_all_resources()
143
+ return _
144
+
145
+ # Validate provided IDs exist before proceeding
146
+ _validate_resource_ids(resource_ids)
147
+
148
+ # Build filter list
149
+ filter_to_apply = list(filter_) if filter_ else []
150
+
151
+ # Add IDs if provided (using internal multi-value filter)
152
+ if resource_ids:
153
+ filter_to_apply.append(("ids", ",".join(resource_ids)))
154
+
155
+ # Apply filters
156
+ resources = resource_list.apply_filters(ctx=ctx, filter_=filter_to_apply)
157
+
158
+ # Validation: ensure we found resources
159
+ if not resources and not resource_ids:
160
+ raise CmemcError("No resources found matching the provided filters.")
161
+
162
+ return resources
163
+
164
+
165
+ @click.command(cls=CmemcCommand, name="list")
166
+ @click.option("--raw", is_flag=True, help="Outputs raw JSON.")
167
+ @click.option(
168
+ "--id-only",
169
+ is_flag=True,
170
+ help="Lists only resource IDs and no other metadata. "
171
+ "This is useful for piping the IDs into other commands.",
172
+ )
173
+ @click.option(
174
+ "--filter",
175
+ "filter_",
176
+ multiple=True,
177
+ type=(str, str),
178
+ shell_complete=resource_list.complete_values,
179
+ help=resource_list.get_filter_help_text(),
180
+ )
181
+ @click.pass_context
182
+ def list_command(ctx: Context, raw: bool, id_only: bool, filter_: tuple[tuple[str, str]]) -> None:
183
+ """List available file resources.
184
+
185
+ Outputs a table or a list of file resources.
186
+ """
187
+ app: ApplicationContext = ctx.obj
188
+ resources = resource_list.apply_filters(ctx=ctx, filter_=filter_)
189
+
190
+ if raw:
191
+ app.echo_info_json(resources)
192
+ return
193
+ if id_only:
194
+ for _ in sorted(_["id"] for _ in resources):
195
+ app.echo_result(_)
196
+ return
197
+ # output a user table
198
+ table = []
199
+ headers = ["ID", "Modified", "Size"]
200
+ for _ in resources:
201
+ row = [
202
+ _["id"],
203
+ _["modified"],
204
+ _["size"],
205
+ ]
206
+ table.append(row)
207
+
208
+ filtered = len(filter_) > 0
209
+ app.echo_info_table(
210
+ table,
211
+ headers=headers,
212
+ sort_column=0,
213
+ cell_processing={1: TimeAgo(), 2: FileSize()},
214
+ caption=build_caption(len(table), "file resource", filtered=filtered),
215
+ empty_table_message="No resources found for these filters."
216
+ if filtered
217
+ else "No resources found. Use the `dataset create` command to create a new "
218
+ "file-based dataset, or the `project file upload` command to create only a file resource.",
219
+ )
220
+
221
+
222
+ @click.command(cls=CmemcCommand, name="delete")
223
+ @click.argument("resource_ids", nargs=-1, type=click.STRING, shell_complete=completion.resource_ids)
224
+ @click.option("--force", is_flag=True, help="Delete resource even if in use by a task.")
225
+ @click.option(
226
+ "-a",
227
+ "--all",
228
+ "all_",
229
+ is_flag=True,
230
+ help="Delete all resources. This is a dangerous option, so use it with care.",
231
+ )
232
+ @click.option(
233
+ "--filter",
234
+ "filter_",
235
+ multiple=True,
236
+ type=(str, str),
237
+ shell_complete=resource_list.complete_values,
238
+ help=resource_list.get_filter_help_text(),
239
+ )
240
+ @click.pass_context
241
+ def delete_command(
242
+ ctx: Context,
243
+ resource_ids: tuple[str, ...],
244
+ force: bool,
245
+ all_: bool,
246
+ filter_: tuple[tuple[str, str], ...],
247
+ ) -> None:
248
+ """Delete file resources.
249
+
250
+ There are three selection mechanisms: with specific IDs - only those
251
+ specified resources will be deleted; by using --filter - resources based
252
+ on the filter type and value will be deleted; by using --all, which will
253
+ delete all resources.
254
+ """
255
+ app: ApplicationContext = ctx.obj
256
+
257
+ # Validation: require at least one selection method
258
+ if not resource_ids and not all_ and not filter_:
259
+ raise UsageError(
260
+ "Either specify at least one resource ID or use the --all or "
261
+ "--filter options to specify resources for deletion."
262
+ )
263
+
264
+ # Get resources to delete based on selection method
265
+ resources_to_delete = _get_resources_to_delete(ctx, resource_ids, all_, filter_)
266
+
267
+ # Avoid double removal as well as sort IDs
268
+ processed_ids = sorted({_["id"] for _ in resources_to_delete}, key=lambda v: v.lower())
269
+ count = len(processed_ids)
270
+
271
+ # Delete each resource
272
+ for current, resource_id in enumerate(processed_ids, start=1):
273
+ current_string = str(current).zfill(len(str(count)))
274
+ app.echo_info(f"Delete resource {current_string}/{count}: {resource_id} ... ", nl=False)
275
+ project_id, resource_local_id = split_task_id(resource_id)
276
+ usage = get_resource_usage_data(project_id, resource_local_id)
277
+ if len(usage) > 0:
278
+ app.echo_error(f"in use by {len(usage)} task(s)", nl=False)
279
+ if force:
280
+ app.echo_info(" ... ", nl=False)
281
+ else:
282
+ app.echo_info("")
283
+ continue
284
+ delete_resource(project_name=project_id, resource_name=resource_local_id)
285
+ app.echo_success("deleted")
286
+
287
+
288
+ @click.command(cls=CmemcCommand, name="download")
289
+ @click.argument("resource_ids", nargs=-1, type=click.STRING, shell_complete=completion.resource_ids)
290
+ @click.option(
291
+ "--output-dir",
292
+ default=".",
293
+ show_default=True,
294
+ type=ClickSmartPath(writable=True, file_okay=False),
295
+ help="The directory where the downloaded files will be saved. "
296
+ "If this directory does not exist, it will be created.",
297
+ )
298
+ @click.option(
299
+ "--replace",
300
+ is_flag=True,
301
+ help="Replace existing files. This is a dangerous option, " "so use it with care!",
302
+ )
303
+ @click.pass_obj
304
+ def download_command(
305
+ app: ApplicationContext, resource_ids: tuple[str], output_dir: str, replace: bool
306
+ ) -> None:
307
+ """Download file resources to the local file system.
308
+
309
+ This command downloads one or more file resources from projects to your local
310
+ file system. Files are saved with their resource names in the output directory.
311
+
312
+ Resources are identified by their IDs in the format PROJECT_ID:RESOURCE_NAME.
313
+
314
+ Example: cmemc project file download my-proj:my-file.csv
315
+
316
+ Example: cmemc project file download my-proj:file1.csv my-proj:file2.csv --output-dir /tmp
317
+ """
318
+ import os
319
+
320
+ if not resource_ids:
321
+ raise UsageError(
322
+ "At least one resource ID must be specified. "
323
+ "Use 'project file list' to see available resources."
324
+ )
325
+
326
+ count = len(resource_ids)
327
+ for current, resource_id in enumerate(resource_ids, start=1):
328
+ try:
329
+ project_id, resource_name = split_task_id(resource_id)
330
+ except ValueError:
331
+ app.echo_error(f"Invalid resource ID format: {resource_id}")
332
+ continue
333
+
334
+ # Build output path
335
+ output_path = os.path.normpath(str(Path(output_dir) / resource_name))
336
+
337
+ app.echo_info(
338
+ f"Download resource {current}/{count}: {resource_id} to {output_path} ... ",
339
+ nl=False,
340
+ )
341
+
342
+ if Path(output_path).exists() and replace is not True:
343
+ app.echo_error("target file exists")
344
+ continue
345
+
346
+ # Create parent directory if it doesn't exist
347
+ Path(output_path).parent.mkdir(exist_ok=True, parents=True)
348
+
349
+ try:
350
+ with (
351
+ get_resource_response(project_id, resource_name) as response,
352
+ click.open_file(output_path, "wb") as resource_file,
353
+ ):
354
+ for chunk in response.iter_content(chunk_size=8192):
355
+ resource_file.write(chunk)
356
+ app.echo_success("done")
357
+ except (OSError, CmemcError) as error:
358
+ app.echo_error(f"failed: {error!s}")
359
+ continue
360
+
361
+
362
+ @click.command(cls=CmemcCommand, name="upload")
363
+ @click.argument(
364
+ "input_path",
365
+ required=True,
366
+ type=ClickSmartPath(
367
+ allow_dash=False, dir_okay=False, readable=True, exists=True, remote_okay=True
368
+ ),
369
+ )
370
+ @click.option(
371
+ "--project",
372
+ "project_id",
373
+ type=click.STRING,
374
+ shell_complete=completion.project_ids,
375
+ help="The project where you want to upload the file. If there is "
376
+ "only one project in the workspace, this option can be omitted.",
377
+ )
378
+ @click.option(
379
+ "--path",
380
+ "remote_name",
381
+ type=click.STRING,
382
+ shell_complete=completion.resource_paths,
383
+ help="The path/name of the file resource in the project (e.g., 'data/file.csv'). "
384
+ "If not specified, the local file name will be used.",
385
+ )
386
+ @click.option(
387
+ "--replace",
388
+ is_flag=True,
389
+ help="Replace existing file resource. This is a dangerous option, " "so use it with care!",
390
+ )
391
+ @click.pass_obj
392
+ def upload_command(
393
+ app: ApplicationContext, input_path: str, project_id: str, remote_name: str, replace: bool
394
+ ) -> None:
395
+ """Upload a file to a project.
396
+
397
+ This command uploads a file to a project as a file resource.
398
+
399
+ Note: If you want to create a dataset from your file, the `dataset create`
400
+ command is maybe the better option.
401
+
402
+ Example: cmemc project file upload my-file.csv --project my-project
403
+ """
404
+ project_id = check_or_select_project(app, project_id)
405
+ local_file_name = Path(input_path).name
406
+
407
+ if remote_name and remote_name.endswith("/"):
408
+ app.echo_warning(
409
+ f"Remote path ends with a slash, so the local file name is appended: {local_file_name}."
410
+ )
411
+ remote_name = remote_name + local_file_name
412
+
413
+ # Use local filename if remote name not specified
414
+ if not remote_name:
415
+ remote_name = local_file_name
416
+
417
+ _upload_file_resource(
418
+ app=app,
419
+ remote_file_name=remote_name,
420
+ project_id=project_id,
421
+ local_file_name=input_path,
422
+ replace=replace,
423
+ )
424
+
425
+
426
+ @click.command(cls=CmemcCommand, name="inspect")
427
+ @click.argument("resource_id", type=click.STRING, shell_complete=completion.resource_ids)
428
+ @click.option("--raw", is_flag=True, help="Outputs raw JSON.")
429
+ @click.pass_obj
430
+ def inspect_command(app: ApplicationContext, resource_id: str, raw: bool) -> None:
431
+ """Display all metadata of a file resource."""
432
+ project_id, resource_id = split_task_id(resource_id)
433
+ resource_data = get_resource_metadata(project_id, resource_id)
434
+ if raw:
435
+ app.echo_info_json(resource_data)
436
+ else:
437
+ table = struct_to_table(resource_data)
438
+ app.echo_info_table(table, headers=["Key", "Value"], sort_column=0)
439
+
440
+
441
+ @click.command(cls=CmemcCommand, name="usage")
442
+ @click.argument("resource_id", type=click.STRING, shell_complete=completion.resource_ids)
443
+ @click.option("--raw", is_flag=True, help="Outputs raw JSON.")
444
+ @click.pass_obj
445
+ def usage_command(app: ApplicationContext, resource_id: str, raw: bool) -> None:
446
+ """Display all usage data of a file resource."""
447
+ project_id, resource_id = split_task_id(resource_id)
448
+ usage = get_resource_usage_data(project_id, resource_id)
449
+ if raw:
450
+ app.echo_info_json(usage)
451
+ return
452
+ # output a user table
453
+ table = []
454
+ headers = ["Task ID", "Type", "Label"]
455
+ for _ in usage:
456
+ row = [project_id + ":" + _["id"], _["taskType"], _["label"]]
457
+ table.append(row)
458
+ app.echo_info_table(
459
+ table,
460
+ empty_table_message=f"The file resource '{resource_id}' is not used in "
461
+ f"any task in project '{project_id}'.",
462
+ headers=headers,
463
+ sort_column=2,
464
+ )
465
+
466
+
467
+ @click.group(
468
+ cls=CmemcGroup,
469
+ hidden=True,
470
+ )
471
+ @click.pass_context
472
+ def resource(ctx: Context) -> None:
473
+ """List, inspect or delete dataset file resources.
474
+
475
+ File resources are identified by their paths and project IDs.
476
+
477
+ Warning: This command group is deprecated and will be removed with the next major release.
478
+ Please use the `project file` command group instead.
479
+ """
480
+ app: ApplicationContext = ctx.obj
481
+ app.echo_warning(
482
+ "The 'dataset resource' command group is deprecated and will be removed with the next"
483
+ " major release. Please use the 'project file' command group instead.",
484
+ )
485
+
486
+
487
+ @click.group(cls=CmemcGroup)
488
+ def file() -> CmemcGroup: # type: ignore[empty-body]
489
+ """List, inspect, up-/download or delete project file resources.
490
+
491
+ File resources are identified with a RESOURCE_ID which is a concatenation
492
+ of its project ID and its relative path, e.g. `my-project:path-to/table.csv`.
493
+
494
+ Note: To get a list of existing file resources, execute the `project file list` command
495
+ or use tab-completion.
496
+ """
497
+
498
+
499
+ resource.add_command(list_command)
500
+ resource.add_command(delete_command)
501
+ resource.add_command(inspect_command)
502
+ resource.add_command(usage_command)
503
+
504
+ file.add_command(list_command)
505
+ file.add_command(delete_command)
506
+ file.add_command(download_command)
507
+ file.add_command(upload_command)
508
+ file.add_command(inspect_command)
509
+ file.add_command(usage_command)