cmem-cmemc 24.2.0rc1__py3-none-any.whl → 24.3.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 (51) hide show
  1. cmem_cmemc/__init__.py +7 -12
  2. cmem_cmemc/command.py +20 -0
  3. cmem_cmemc/command_group.py +70 -0
  4. cmem_cmemc/commands/__init__.py +0 -81
  5. cmem_cmemc/commands/acl.py +118 -62
  6. cmem_cmemc/commands/admin.py +46 -35
  7. cmem_cmemc/commands/client.py +2 -1
  8. cmem_cmemc/commands/config.py +3 -1
  9. cmem_cmemc/commands/dataset.py +27 -24
  10. cmem_cmemc/commands/graph.py +100 -16
  11. cmem_cmemc/commands/metrics.py +195 -79
  12. cmem_cmemc/commands/migration.py +265 -0
  13. cmem_cmemc/commands/project.py +62 -17
  14. cmem_cmemc/commands/python.py +57 -26
  15. cmem_cmemc/commands/query.py +23 -14
  16. cmem_cmemc/commands/resource.py +10 -2
  17. cmem_cmemc/commands/scheduler.py +10 -2
  18. cmem_cmemc/commands/store.py +118 -14
  19. cmem_cmemc/commands/user.py +8 -2
  20. cmem_cmemc/commands/validation.py +304 -113
  21. cmem_cmemc/commands/variable.py +10 -2
  22. cmem_cmemc/commands/vocabulary.py +48 -29
  23. cmem_cmemc/commands/workflow.py +86 -59
  24. cmem_cmemc/commands/workspace.py +27 -8
  25. cmem_cmemc/completion.py +185 -141
  26. cmem_cmemc/constants.py +2 -0
  27. cmem_cmemc/context.py +88 -42
  28. cmem_cmemc/manual_helper/graph.py +1 -0
  29. cmem_cmemc/manual_helper/multi_page.py +3 -1
  30. cmem_cmemc/migrations/__init__.py +1 -0
  31. cmem_cmemc/migrations/abc.py +84 -0
  32. cmem_cmemc/migrations/access_conditions_243.py +118 -0
  33. cmem_cmemc/migrations/bootstrap_data.py +30 -0
  34. cmem_cmemc/migrations/shapes_widget_integrations_243.py +194 -0
  35. cmem_cmemc/migrations/workspace_configurations.py +28 -0
  36. cmem_cmemc/object_list.py +53 -22
  37. cmem_cmemc/parameter_types/__init__.py +1 -0
  38. cmem_cmemc/parameter_types/path.py +69 -0
  39. cmem_cmemc/smart_path/__init__.py +94 -0
  40. cmem_cmemc/smart_path/clients/__init__.py +63 -0
  41. cmem_cmemc/smart_path/clients/http.py +65 -0
  42. cmem_cmemc/string_processor.py +77 -0
  43. cmem_cmemc/title_helper.py +41 -0
  44. cmem_cmemc/utils.py +114 -47
  45. {cmem_cmemc-24.2.0rc1.dist-info → cmem_cmemc-24.3.0rc1.dist-info}/LICENSE +1 -1
  46. cmem_cmemc-24.3.0rc1.dist-info/METADATA +89 -0
  47. cmem_cmemc-24.3.0rc1.dist-info/RECORD +53 -0
  48. {cmem_cmemc-24.2.0rc1.dist-info → cmem_cmemc-24.3.0rc1.dist-info}/WHEEL +1 -1
  49. cmem_cmemc-24.2.0rc1.dist-info/METADATA +0 -69
  50. cmem_cmemc-24.2.0rc1.dist-info/RECORD +0 -37
  51. {cmem_cmemc-24.2.0rc1.dist-info → cmem_cmemc-24.3.0rc1.dist-info}/entry_points.txt +0 -0
@@ -1,20 +1,25 @@
1
1
  """graph validation command group"""
2
+
3
+ import json
4
+ import sys
2
5
  import time
3
- from datetime import UTC, datetime
6
+ from collections import Counter
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
4
9
 
5
10
  import click
6
- import requests
7
11
  import timeago
8
12
  from click import Context, UsageError
9
13
  from click.shell_completion import CompletionItem
14
+ from cmem.cmempy.config import get_cmem_base_uri
10
15
  from cmem.cmempy.dp.shacl import validation
11
16
  from junit_xml import TestCase, TestSuite, to_xml_report_string
12
- from requests import HTTPError
13
17
  from rich.progress import Progress, SpinnerColumn, TaskID, TimeElapsedColumn
14
18
 
15
19
  from cmem_cmemc import completion
16
- from cmem_cmemc.commands import CmemcCommand, CmemcGroup
17
- from cmem_cmemc.completion import _finalize_completion
20
+ from cmem_cmemc.command import CmemcCommand
21
+ from cmem_cmemc.command_group import CmemcGroup
22
+ from cmem_cmemc.completion import finalize_completion
18
23
  from cmem_cmemc.context import ApplicationContext
19
24
  from cmem_cmemc.exceptions import ServerError
20
25
  from cmem_cmemc.object_list import (
@@ -24,27 +29,64 @@ from cmem_cmemc.object_list import (
24
29
  compare_int_greater_than,
25
30
  transform_lower,
26
31
  )
27
- from cmem_cmemc.utils import struct_to_table
28
-
29
-
30
- def _report_to_junit(report: dict) -> str:
31
- """Create a jUnit XML document from a report dictionary"""
32
- test_cases: list[TestCase] = []
33
-
34
- for result in report["results"]:
35
- resource_iri = result["resourceIri"]
36
- for _ in result["violations"]:
37
- constraint_name = _["reportEntryConstraintMessageTemplate"]["constraintName"]
38
- message = _["messages"][0]["value"]
39
- new_case = TestCase(
40
- name=f"{constraint_name} @ {resource_iri}",
41
- url=resource_iri,
42
- stdout=message,
43
- category=_["severity"],
32
+ from cmem_cmemc.string_processor import GraphLink, ResourceLink, TimeAgo
33
+ from cmem_cmemc.title_helper import TitleHelper
34
+ from cmem_cmemc.utils import get_query_text, struct_to_table
35
+
36
+
37
+ def _reports_to_junit(reports: list[dict]) -> str:
38
+ """Create a jUnit XML document from a list of report dictionaries"""
39
+ test_suites: list[TestSuite] = []
40
+
41
+ for report in reports:
42
+ test_cases: list[TestCase] = []
43
+ context_graph = report["contextGraphIri"]
44
+ shape_graph = report["shapeGraphIri"]
45
+ time_elapsed = (report["executionFinished"] - report["executionStarted"]) / 1000
46
+ violations: dict[str, list[dict]] = {}
47
+ for resource in sorted(report["resources"]):
48
+ # get a list of all tested resources
49
+ violations[resource] = []
50
+ average_elapsed_sec = time_elapsed / len(violations)
51
+ for result in report["results"]:
52
+ # collection violations per resource
53
+ resource_iri = result["resourceIri"]
54
+ violations[resource_iri] = result["violations"]
55
+ for resource in violations:
56
+ # create on test case per resource
57
+ resource_violations = violations[resource]
58
+ violations_count = len(violations[resource])
59
+ constraints = Counter(
60
+ _["reportEntryConstraintMessageTemplate"]["constraintName"]
61
+ for _ in resource_violations
44
62
  )
45
- test_cases.append(new_case)
46
- test_suite = TestSuite("eccenca Corporate Memory Graph Validation", test_cases)
47
- return str(to_xml_report_string([test_suite], encoding="utf-8"))
63
+ test_case_name = f"{resource}"
64
+ if violations_count == 0:
65
+ test_case_name += " has no violations"
66
+ if violations_count == 1:
67
+ test_case_name += f" has 1 violation ({next(iter(constraints.keys()))})"
68
+ if violations_count > 1:
69
+ constrains_str = ""
70
+ for constraint, constraint_count in constraints.items():
71
+ constrains_str += f", {constraint_count}x{constraint}"
72
+ test_case_name += f" has {violations_count} violations ({constrains_str[2:]})"
73
+
74
+ test_case = TestCase(
75
+ name=test_case_name,
76
+ classname=f"{context_graph} tested with {shape_graph}",
77
+ elapsed_sec=average_elapsed_sec,
78
+ )
79
+ if violations_count > 0:
80
+ test_case.add_failure_info(output=json.dumps(resource_violations, indent=2))
81
+ test_cases.append(test_case)
82
+ test_suite = TestSuite(
83
+ name=f"Testing {context_graph} with shapes from {shape_graph}.",
84
+ test_cases=test_cases,
85
+ id=report["id"],
86
+ timestamp=report["executionFinished"],
87
+ )
88
+ test_suites.append(test_suite)
89
+ return str(to_xml_report_string(test_suites, encoding="utf-8"))
48
90
 
49
91
 
50
92
  def get_sorted_validations_list(ctx: Context) -> list[dict]: # noqa: ARG001
@@ -144,17 +186,22 @@ violations_list = ObjectList(
144
186
  name="severity", description="Filter list by severity.", property_key="severity"
145
187
  ),
146
188
  DirectValuePropertyFilter(
147
- name="resource", description="Filter list by resource IRI.", property_key="resourceIri"
189
+ name="resource",
190
+ description="Filter list by resource IRI.",
191
+ property_key="resourceIri",
192
+ title_helper=TitleHelper(),
148
193
  ),
149
194
  DirectListPropertyFilter(
150
195
  name="node-shape",
151
196
  description="Filter list by node shape IRI.",
152
197
  property_key="nodeShapes",
198
+ title_helper=TitleHelper(),
153
199
  ),
154
200
  DirectValuePropertyFilter(
155
- name="property-shape",
156
- description="Filter list by property shape IRI.",
201
+ name="source",
202
+ description="Filter list by constraint source.",
157
203
  property_key="source",
204
+ title_helper=TitleHelper(),
158
205
  ),
159
206
  ],
160
207
  )
@@ -165,8 +212,8 @@ def _get_batch_validation_option(validation_: dict) -> tuple[str, str]:
165
212
  id_ = validation_["id"]
166
213
  state = validation_["state"]
167
214
  graph = validation_["contextGraphIri"]
168
- stamp = datetime.fromtimestamp(validation_["executionStarted"] / 1000, tz=UTC)
169
- time_ago = timeago.format(stamp, datetime.now(tz=UTC))
215
+ stamp = datetime.fromtimestamp(validation_["executionStarted"] / 1000, tz=timezone.utc)
216
+ time_ago = timeago.format(stamp, datetime.now(tz=timezone.utc))
170
217
  resources = _get_resource_count(validation_)
171
218
  violations = _get_violation_count(validation_)
172
219
  return (
@@ -182,7 +229,7 @@ def _complete_all_batch_validations(
182
229
  ) -> list[CompletionItem]:
183
230
  """Provide completion for batch validation"""
184
231
  options = [_get_batch_validation_option(_) for _ in validation.get_all_aggregations()]
185
- return _finalize_completion(candidates=options, incomplete=incomplete)
232
+ return finalize_completion(candidates=options, incomplete=incomplete)
186
233
 
187
234
 
188
235
  def _complete_running_batch_validations(
@@ -196,10 +243,24 @@ def _complete_running_batch_validations(
196
243
  for _ in validation.get_all_aggregations()
197
244
  if _["state"] == validation.STATUS_RUNNING
198
245
  ]
199
- return _finalize_completion(candidates=options, incomplete=incomplete)
246
+ return finalize_completion(candidates=options, incomplete=incomplete)
247
+
248
+
249
+ def _complete_finished_batch_validations(
250
+ ctx: click.Context, # noqa: ARG001
251
+ param: click.Argument, # noqa: ARG001
252
+ incomplete: str,
253
+ ) -> list[CompletionItem]:
254
+ """Provide completion for finished batch validations"""
255
+ options = [
256
+ _get_batch_validation_option(_)
257
+ for _ in validation.get_all_aggregations()
258
+ if _["state"] == validation.STATUS_FINISHED
259
+ ]
260
+ return finalize_completion(candidates=options, incomplete=incomplete)
200
261
 
201
262
 
202
- def show_process_summary(app: ApplicationContext, process_id: str) -> None:
263
+ def _print_process_summary(app: ApplicationContext, process_id: str) -> None:
203
264
  """Show summary of the validation process"""
204
265
  app.echo_info_table(
205
266
  struct_to_table(validation.get_aggregation(batch_id=process_id)),
@@ -276,33 +337,64 @@ def _wait_for_process_completion(
276
337
  return state.status
277
338
 
278
339
 
279
- def _get_violation_table(violations: list[dict]) -> tuple[list, list]:
280
- """Get violation table from batch validation result"""
340
+ def _print_violation_table(
341
+ app: ApplicationContext, data_graph: str, shape_graph: str, violations: list[dict]
342
+ ) -> None:
343
+ """Print violation table from batch validation result"""
344
+ # fetch titles
345
+ resources = []
346
+ for violation in violations:
347
+ resources.append(str(violation.get("resourceIri")))
348
+ resources.extend(violation.get("nodeShapes", []))
349
+ title_helper = TitleHelper()
350
+ title_helper.get(resources)
351
+
352
+ # prepare link helper
353
+ resource_link = ResourceLink(graph_iri=data_graph, title_helper=title_helper)
354
+ shape_link = ResourceLink(graph_iri=shape_graph, title_helper=title_helper)
355
+
281
356
  table = []
282
357
  for violation in violations:
283
- resource_iri: str = str(violation.get("resourceIri"))
358
+ combined_cell = ""
359
+
284
360
  path = violation.get("path", None)
285
- constraint_name = violation.get("constraintName", "UNKNOWN")
361
+ if path is not None:
362
+ combined_cell = f"Path: {path}\n"
363
+
364
+ source = violation.get("source", None)
365
+ if source is not None:
366
+ combined_cell = f"{combined_cell}Source: {shape_link.process(text=source)}"
367
+
286
368
  node_shapes = violation.get("nodeShapes", [])
369
+ if len(node_shapes) == 1:
370
+ combined_cell = f"{combined_cell}\nNodeShape: {shape_link.process(text=node_shapes[0])}"
371
+ if len(node_shapes) > 1:
372
+ combined_cell = f"{combined_cell}\nNodeShapes:"
373
+ for node_shape in node_shapes:
374
+ combined_cell = f"{combined_cell}\n - {shape_link.process(text=node_shape)}"
375
+
287
376
  text = violation["messages"][0]["value"] # default: use the text of the first message
288
377
  for message in violation["messages"]:
289
378
  # look for en non non-lang messages to use
290
379
  if message["lang"] == "" or message["lang"] == "en":
291
380
  text = str(message["value"])
292
381
  break
293
- cell = ""
294
- if path is not None:
295
- cell = f"Path: {path}"
296
- if len(node_shapes) == 1:
297
- cell = f"{cell}\nNodeShape: {node_shapes[0]}"
298
- if len(node_shapes) > 1:
299
- cell = f"{cell}\nNodeShapes:"
300
- for node_shape in node_shapes:
301
- cell = f"{cell}\n - {node_shape}"
302
- cell = f"{cell}\nMessage: {text}"
303
- row = [resource_iri, constraint_name, cell]
382
+ combined_cell = f"{combined_cell}\nMessage: {text}"
383
+
384
+ row = [
385
+ resource_link.process(text=str(violation.get("resourceIri"))),
386
+ violation.get("constraintName", "UNKNOWN"),
387
+ combined_cell,
388
+ ]
304
389
  table.append(row)
305
- return table, ["Resource IRI", "Constraint", "Details"]
390
+
391
+ app.echo_info_table(
392
+ table,
393
+ headers=["Resource", "Constraint", "Details"],
394
+ sort_column=0,
395
+ caption="Violation List",
396
+ empty_table_message="No violations found.",
397
+ )
306
398
 
307
399
 
308
400
  def _get_resource_count(batch_validation: dict) -> str:
@@ -316,7 +408,7 @@ def _get_resource_count(batch_validation: dict) -> str:
316
408
 
317
409
  def _get_violation_count(process_data: dict) -> str:
318
410
  """Get violation count from validation report"""
319
- if process_data.get("executionStarted", None) is None:
411
+ if process_data.get("executionStarted") is None:
320
412
  return "-"
321
413
  resources = str(process_data.get("resourcesWithViolationsCount", "0"))
322
414
  violations = str(process_data.get("violationsCount", "0"))
@@ -327,13 +419,48 @@ def _get_violation_count(process_data: dict) -> str:
327
419
 
328
420
  @click.command(cls=CmemcCommand, name="execute")
329
421
  @click.argument("iri", type=click.STRING, shell_complete=completion.graph_uris)
422
+ @click.option(
423
+ "--wait",
424
+ is_flag=True,
425
+ help="Wait until the process is finished. When using this option without the "
426
+ "`--id-only` flag, it will enable a progress bar and a summary view.",
427
+ )
330
428
  @click.option(
331
429
  "--shape-graph",
332
- shell_complete=completion.graph_uris,
430
+ shell_complete=completion.graph_uris_skip_check,
333
431
  default="https://vocab.eccenca.com/shacl/",
334
432
  show_default=True,
335
433
  help="The shape catalog used for validation.",
336
434
  )
435
+ @click.option(
436
+ "--query",
437
+ shell_complete=completion.remote_queries_and_sparql_files,
438
+ help="SPARQL query to select the resources which you want to validate from "
439
+ "the data graph. "
440
+ "Can be provided as a local file or as a query catalog IRI. "
441
+ "[default: all typed resources]",
442
+ )
443
+ @click.option(
444
+ "--result-graph",
445
+ shell_complete=completion.writable_graph_uris,
446
+ help="(Optionally) write the validation results to a Knowledge Graph. " "[default: None]",
447
+ )
448
+ @click.option(
449
+ "--replace",
450
+ is_flag=True,
451
+ default=False,
452
+ help="Replace the result graph instead of just adding the new results. "
453
+ "This is a dangerous option, so use it with care!",
454
+ )
455
+ @click.option(
456
+ "--ignore-graph",
457
+ shell_complete=completion.ignore_graph_uris,
458
+ type=click.STRING,
459
+ multiple=True,
460
+ help="A set of data graph IRIs which are not queried in the resource selection. "
461
+ "This option is useful for validating only parts of an integration graph "
462
+ "which imports other graphs.",
463
+ )
337
464
  @click.option(
338
465
  "--id-only",
339
466
  is_flag=True,
@@ -341,10 +468,9 @@ def _get_violation_count(process_data: dict) -> str:
341
468
  "This is useful for piping the ID into other commands.",
342
469
  )
343
470
  @click.option(
344
- "--wait",
471
+ "--inspect",
345
472
  is_flag=True,
346
- help="Wait until the process is finished. When using this option without the "
347
- "`--id-only` flag, it will enable a progress bar and a summary view.",
473
+ help="Return the list of violations instead of the summary (includes --wait).",
348
474
  )
349
475
  @click.option(
350
476
  "--polling-interval",
@@ -354,30 +480,53 @@ def _get_violation_count(process_data: dict) -> str:
354
480
  help="How many seconds to wait between status polls. Status polls are"
355
481
  " cheap, so a higher polling interval is most likely not needed.",
356
482
  )
357
- @click.pass_obj
483
+ @click.pass_context
358
484
  def execute_command( # noqa: PLR0913
359
- app: ApplicationContext,
485
+ ctx: Context,
360
486
  iri: str,
361
487
  shape_graph: str,
488
+ query: str,
489
+ result_graph: str,
490
+ replace: bool,
491
+ ignore_graph: list[str],
362
492
  id_only: bool,
363
493
  wait: bool,
494
+ inspect: bool,
364
495
  polling_interval: int,
365
496
  ) -> None:
366
497
  """Start a new validation process.
367
498
 
368
- Validation is performed on all typed resources of a data / context graph (IRI).
369
- Each resource is validated against all applicable node shapes from a
370
- selected shape catalog graph (and its sub-graphs).
499
+ Validation is performed on all typed resources of the data / context graph
500
+ (and its sub-graphs). Each resource is validated against all applicable node
501
+ shapes from the shape catalog.
371
502
  """
372
- process_id = validation.start(context_graph=iri, shape_graph=shape_graph)
373
- if wait:
503
+ app: ApplicationContext = ctx.obj
504
+ if id_only and inspect:
505
+ raise UsageError(
506
+ "Output can be the summary (default), the process ID (--id-only) "
507
+ "or the violation list (--inspect)."
508
+ )
509
+ process_id = validation.start(
510
+ context_graph=iri,
511
+ shape_graph=shape_graph,
512
+ query=get_query_text(query, {"resource"}) if query else None,
513
+ result_graph=result_graph,
514
+ replace=replace,
515
+ ignore_graph=ignore_graph,
516
+ )
517
+ if wait or inspect:
374
518
  _wait_for_process_completion(
375
519
  app=app, process_id=process_id, use_rich=not id_only, polling_interval=polling_interval
376
520
  )
377
521
  if id_only:
378
522
  app.echo_info(process_id)
379
523
  return
380
- show_process_summary(process_id=process_id, app=app)
524
+ if inspect:
525
+ ctx.params["process_id"] = process_id
526
+ data = violations_list.apply_filters(ctx=ctx, filter_=[])
527
+ _print_violation_table(app=app, data_graph=iri, shape_graph=shape_graph, violations=data)
528
+ return
529
+ _print_process_summary(process_id=process_id, app=app)
381
530
 
382
531
 
383
532
  @click.command(cls=CmemcCommand, name="list")
@@ -418,32 +567,25 @@ def list_command(ctx: Context, filter_: tuple[tuple[str, str]], id_only: bool, r
418
567
  app.echo_info(_["id"])
419
568
  return
420
569
 
421
- if len(validations) == 0:
422
- app.echo_warning(
423
- "No validation processes found. "
424
- "Use `graph validation execute` to start a new validation process."
425
- )
426
- return
427
-
428
570
  # output a user table
429
571
  table = []
430
572
  for _ in validations:
431
- if "executionStarted" in _ and _["executionStarted"] is not None:
432
- stamp = datetime.fromtimestamp(_["executionStarted"] / 1000, tz=UTC)
433
- time_ago = timeago.format(stamp, datetime.now(tz=UTC))
434
- else:
435
- time_ago = f"{_['state']}"
436
573
  row = [
437
574
  _["id"],
438
575
  _["state"],
439
- time_ago,
576
+ _.get("executionStarted", None),
440
577
  _["contextGraphIri"],
441
578
  _get_resource_count(_),
442
579
  _get_violation_count(_),
443
580
  ]
444
581
  table.append(row)
445
582
  app.echo_info_table(
446
- table, headers=["ID", "Status", "Started", "Graph", "Resources", "Violations"]
583
+ table,
584
+ headers=["ID", "Status", "Started", "Graph", "Resources", "Violations"],
585
+ caption=f"Validation Processes of {get_cmem_base_uri()}",
586
+ 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.",
447
589
  )
448
590
 
449
591
 
@@ -499,7 +641,7 @@ def inspect_command( # noqa: PLR0913
499
641
  if raw:
500
642
  app.echo_info_json(validation.get_aggregation(batch_id=process_id))
501
643
  else:
502
- show_process_summary(app=app, process_id=process_id)
644
+ _print_process_summary(app=app, process_id=process_id)
503
645
  return
504
646
 
505
647
  data = violations_list.apply_filters(ctx=ctx, filter_=filter_)
@@ -517,12 +659,15 @@ def inspect_command( # noqa: PLR0913
517
659
  "The given validation process does not have any violations - "
518
660
  "I will show the summary instead."
519
661
  )
520
- show_process_summary(app=app, process_id=process_id)
662
+ _print_process_summary(app=app, process_id=process_id)
521
663
  else:
522
- messages_table, messages_header = _get_violation_table(violations=data)
523
- if len(messages_table) > 0:
524
- app.echo_info("")
525
- app.echo_info_table(messages_table, headers=messages_header, sort_column=0)
664
+ process_data = validation.get_aggregation(batch_id=process_id)
665
+ _print_violation_table(
666
+ app=app,
667
+ violations=data,
668
+ shape_graph=process_data["shapeGraphIri"],
669
+ data_graph=process_data["contextGraphIri"],
670
+ )
526
671
 
527
672
 
528
673
  @click.command(cls=CmemcCommand, name="cancel")
@@ -535,58 +680,104 @@ def cancel_command(app: ApplicationContext, process_id: str) -> None:
535
680
  processes, use the `graph validation list` command with the option
536
681
  `--filter status running`, or utilize the tab completion of this command.
537
682
  """
538
- try:
539
- graph_validation = validation.get_aggregation(process_id)
540
- except HTTPError as error:
541
- if error.response is not None and error.response.status_code == requests.codes.not_found:
542
- raise click.UsageError(
543
- f"Graph validation process with ID {process_id} does not exist."
544
- ) from error
545
- else:
546
- if graph_validation["state"] != validation.STATUS_RUNNING:
547
- raise click.UsageError(
548
- f"Graph validation process with ID {process_id} is not a running anymore."
549
- )
550
- app.echo_info(f"Graph validation process with ID {process_id} ... ", nl=False)
683
+ all_status = {_["id"]: _["state"] for _ in validation.get_all_aggregations()}
684
+ if process_id not in all_status:
685
+ raise UsageError(f"Validation process with ID '{process_id}' is not known (anymore).")
686
+ if all_status[process_id] != validation.STATUS_RUNNING:
687
+ raise click.UsageError(
688
+ f"Validation process with ID '{process_id}' is not a running anymore."
689
+ )
690
+ app.echo_info(f"Validation process with ID '{process_id}' ... ", nl=False)
551
691
  validation.cancel(batch_id=process_id)
552
692
  app.echo_success("cancelled")
553
693
 
554
694
 
555
695
  @click.command(cls=CmemcCommand, name="export")
556
- @click.argument("process_id", type=click.STRING, shell_complete=_complete_all_batch_validations)
696
+ @click.argument(
697
+ "process_ids", nargs=-1, type=click.STRING, shell_complete=_complete_finished_batch_validations
698
+ )
699
+ @click.option(
700
+ "--output-file",
701
+ type=click.Path(writable=True, allow_dash=False, dir_okay=False),
702
+ default="report.xml",
703
+ show_default=True,
704
+ help="Export the report to this file. Existing files will be overwritten.",
705
+ )
706
+ @click.option(
707
+ "--exit-1",
708
+ type=click.Choice(["never", "error"]),
709
+ default="error",
710
+ show_default=True,
711
+ help="Specify, when this command returns with exit code 1. Available options are "
712
+ "'never' (exit 0, even if there are violations in the reports), "
713
+ "'error' (exit 1 if there is at least one violation in a report).), ",
714
+ )
557
715
  @click.option(
558
716
  "--format",
559
717
  "format_",
560
718
  type=click.Choice(["JSON", "XML"], case_sensitive=True),
561
- default="JSON",
719
+ default="XML",
562
720
  help="Export either the plain JSON report or a distilled jUnit XML report.",
563
721
  show_default=True,
564
722
  )
565
723
  @click.pass_context
566
- def export_command(ctx: Context, process_id: str, format_: str) -> None:
567
- """Export the report of a finished validation process to a file.
724
+ def export_command(
725
+ ctx: Context, process_ids: tuple[str], output_file: str, exit_1: str, format_: str
726
+ ) -> None:
727
+ """Export a report of finished validations.
568
728
 
569
- This command exports JSON documents or jUnit XML reports in order to process
729
+ This command exports a jUnit XML or JSON report in order to process
570
730
  them somewhere else (e.g. a CI pipeline).
571
731
 
732
+ You can export a single report of multiple validation processes.
733
+
734
+ For jUnit XML: Each validation process result will be transformed to
735
+ a single test suite. All violations of one resource in a result will be
736
+ collected and attached to a single test case in that test suite.
737
+
572
738
  Note: Validation processes IDs can be listed with the `graph validation list`
573
739
  command, or by utilizing the tab completion of this command.
574
740
  """
741
+ if len(process_ids) == 0:
742
+ raise UsageError("This command needs at least one validation process ID.")
575
743
  app: ApplicationContext = ctx.obj
576
- process = None
744
+ process_ids_to_test = {_: True for _ in process_ids}
745
+ overall_violations = 0
746
+ overall_resources = 0
577
747
  for _ in validation.get_all_aggregations():
578
- if _["id"] == process_id:
579
- process = _
580
- if process is None:
581
- raise UsageError(f"Validation process with ID '{process_id}' is not known (anymore).")
582
- if process["state"] != "FINISHED":
583
- raise UsageError(f"Validation process with ID '{process_id}' is still running.")
584
-
585
- report = validation.get(batch_id=process_id)
586
- if format_ == "JSON":
587
- app.echo_info_json(report)
588
- if format_ == "XML":
589
- app.echo_info_xml(_report_to_junit(report))
748
+ if _["id"] in process_ids_to_test:
749
+ if _["state"] != "FINISHED":
750
+ raise UsageError(f"Validation process with ID '{_['id']}' is still running.")
751
+ del process_ids_to_test[_["id"]]
752
+ overall_violations += int(_["violationsCount"])
753
+ overall_resources += int(_["resourceProcessedCount"])
754
+ if len(process_ids_to_test) > 0:
755
+ raise UsageError(
756
+ "Validation processes with the following IDs not known (anymore): "
757
+ + ", ".join(process_ids_to_test)
758
+ )
759
+ reports = []
760
+ for process_id in process_ids:
761
+ report = validation.get(batch_id=process_id)
762
+ reports.append(report)
763
+ app.echo_info(
764
+ f"Export of {len(reports)} validation report(s) with"
765
+ f" {overall_violations} violations in {overall_resources} resources"
766
+ f" to {output_file} ... ",
767
+ nl=False,
768
+ )
769
+ with Path(output_file, mode="w", encoding="utf-8") as file:
770
+ if format_ == "XML":
771
+ file.write_text(_reports_to_junit(reports))
772
+ if format_ == "JSON":
773
+ file.write_text(json.dumps(reports, indent=2))
774
+ app.echo_success("done")
775
+ if exit_1 == "error" and overall_violations > 0:
776
+ app.echo_error(
777
+ "Exit 1 since violations where found in the reports "
778
+ "(can be suppressed with '--exit-1 never')."
779
+ )
780
+ sys.exit(1)
590
781
 
591
782
 
592
783
  @click.group(cls=CmemcGroup, name="validation")
@@ -1,4 +1,5 @@
1
1
  """DataIntegration variable commands for cmemc."""
2
+
2
3
  import re
3
4
 
4
5
  import click
@@ -11,7 +12,8 @@ from cmem.cmempy.workspace.projects.variables import (
11
12
  )
12
13
 
13
14
  from cmem_cmemc import completion
14
- from cmem_cmemc.commands import CmemcCommand, CmemcGroup
15
+ from cmem_cmemc.command import CmemcCommand
16
+ from cmem_cmemc.command_group import CmemcGroup
15
17
  from cmem_cmemc.context import ApplicationContext
16
18
  from cmem_cmemc.utils import check_or_select_project, split_task_id
17
19
 
@@ -102,7 +104,13 @@ def list_command(
102
104
  _.get("description", ""),
103
105
  ]
104
106
  table.append(row)
105
- app.echo_info_table(table, headers=headers, sort_column=0)
107
+ app.echo_info_table(
108
+ table,
109
+ headers=headers,
110
+ sort_column=0,
111
+ empty_table_message="No project variables found. "
112
+ "Use the `project variable create` command to create a new project variable.",
113
+ )
106
114
 
107
115
 
108
116
  @click.command(cls=CmemcCommand, name="get")