datatrail-cli 0.10.3__tar.gz → 0.11.1__tar.gz

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 (46) hide show
  1. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/CHANGELOG.md +14 -0
  2. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/PKG-INFO +2 -2
  3. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/docs/list.md +60 -0
  4. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/docs/ps.md +70 -0
  5. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/dtcli/ls.py +11 -1
  6. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/dtcli/ps.py +32 -3
  7. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/dtcli/src/functions.py +17 -23
  8. datatrail_cli-0.11.1/dtcli/unregistered.py +102 -0
  9. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/dtcli/utilities/cadcclient.py +17 -11
  10. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/dtcli/utilities/utilities.py +3 -0
  11. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/poetry.lock +1153 -653
  12. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/pyproject.toml +2 -2
  13. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/tests/test_cli.py +107 -22
  14. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/tests/test_utils.py +16 -0
  15. datatrail_cli-0.10.3/dtcli/unregistered.py +0 -61
  16. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  17. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  18. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  19. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/.github/workflows/continuous-deployment.yml +0 -0
  20. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/.github/workflows/continuous-integration.yml +0 -0
  21. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/.github/workflows/docs.yml +0 -0
  22. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/.github/workflows/stale.yml +0 -0
  23. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/.gitignore +0 -0
  24. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/.pre-commit-config.yaml +0 -0
  25. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/LICENSE +0 -0
  26. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/README.md +0 -0
  27. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/docs/clear.md +0 -0
  28. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/docs/cli.md +0 -0
  29. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/docs/commands.md +0 -0
  30. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/docs/images/Datatrail-logo.png +0 -0
  31. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/docs/index.md +0 -0
  32. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/docs/initialising.md +0 -0
  33. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/docs/install.md +0 -0
  34. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/docs/pull.md +0 -0
  35. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/docs/scout.md +0 -0
  36. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/docs/user_guide.md +0 -0
  37. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/dtcli/__init__.py +0 -0
  38. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/dtcli/clear.py +0 -0
  39. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/dtcli/cli.py +0 -0
  40. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/dtcli/config.py +0 -0
  41. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/dtcli/config.yaml +0 -0
  42. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/dtcli/pull.py +0 -0
  43. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/dtcli/scout.py +0 -0
  44. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/mkdocs.yml +0 -0
  45. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/tests/test_functions.py +0 -0
  46. {datatrail_cli-0.10.3 → datatrail_cli-0.11.1}/tox.ini +0 -0
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.11.1](https://github.com/CHIMEFRB/datatrail-cli/compare/v0.11.0...v0.11.1) (2026-07-21)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **unregistered:** improve way info displayed ([c5f0fca](https://github.com/CHIMEFRB/datatrail-cli/commit/c5f0fca6f6f53e5004e1f1868517100572d68d1a))
9
+
10
+ ## [0.11.0](https://github.com/CHIMEFRB/datatrail-cli/compare/v0.10.3...v0.11.0) (2026-07-07)
11
+
12
+
13
+ ### Features
14
+
15
+ * **ls & ps:** add --json flag for machine readable output ([c4742a0](https://github.com/CHIMEFRB/datatrail-cli/commit/c4742a0dfe43823ce8e68f21e26e146b90db958c)), closes [#159](https://github.com/CHIMEFRB/datatrail-cli/issues/159)
16
+
3
17
  ## [0.10.3](https://github.com/CHIMEFRB/datatrail-cli/compare/v0.10.2...v0.10.3) (2026-05-19)
4
18
 
5
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datatrail-cli
3
- Version: 0.10.3
3
+ Version: 0.11.1
4
4
  Summary: CHIME/FRB Datatrail CLI
5
5
  Author: CHIME FRB Project Office
6
6
  Author-email: Tarik Zegmott <tarik.zegmott@mcgill.ca>
@@ -13,7 +13,7 @@ Requires-Dist: cadcutils>=1.5.1.1
13
13
  Requires-Dist: click-aliasing>=1.0.0
14
14
  Requires-Dist: click>=8.1.3
15
15
  Requires-Dist: dill>=0.3.6
16
- Requires-Dist: lxml>=4.9.2
16
+ Requires-Dist: lxml>=6.1.0
17
17
  Requires-Dist: mergedeep>=1.3.4
18
18
  Requires-Dist: pyaml>=21.10.1
19
19
  Requires-Dist: requests>=2.29.0
@@ -11,6 +11,7 @@ Options:
11
11
  -v, --verbose Verbosity: v=INFO, vv=DEBUG.
12
12
  -q, --quiet Only errors shown in logs.
13
13
  --write Write the events to file.
14
+ --json Output as JSON.
14
15
  --help Show this message and exit.
15
16
 
16
17
  ```
@@ -100,3 +101,62 @@ Within Datatrail, there are two types of datasets:
100
101
 
101
102
  Please see the CLI reference page for more information on the `list` command:
102
103
  [datatrail list](../cli/#datatrail-list)
104
+
105
+ ## 🤖 Machine-readable JSON output
106
+
107
+ The `--json` flag outputs structured JSON instead of formatted tables, making it easy to parse the output in scripts and pipelines:
108
+
109
+ ```bash
110
+ # Get all scopes as JSON
111
+ $ datatrail ls --json
112
+ {
113
+ "scopes": [
114
+ "chime.event.baseband.raw",
115
+ "chime.event.intensity.raw",
116
+ "kko.acquisition.processed",
117
+ ...
118
+ ]
119
+ }
120
+
121
+ # Get larger datasets as JSON
122
+ $ datatrail ls kko.scheduled.baseband.raw --json
123
+ {
124
+ "scope": "kko.scheduled.baseband.raw",
125
+ "larger_datasets": [
126
+ "20230804095251",
127
+ "scheduled.commissioning.steady"
128
+ ]
129
+ }
130
+
131
+ # Get child datasets as JSON
132
+ $ datatrail ls kko.scheduled.baseband.raw scheduled.commissioning.steady --json
133
+ {
134
+ "datasets": [
135
+ "20230616150511",
136
+ "20230604135840",
137
+ "20230604134842",
138
+ ...
139
+ ]
140
+ }
141
+ ```
142
+
143
+ ### Usage in scripts
144
+
145
+ ```python
146
+ import json
147
+ import subprocess
148
+
149
+ # Get all scopes
150
+ result = subprocess.run(["datatrail", "ls", "--json"], capture_output=True, text=True)
151
+ data = json.loads(result.stdout)
152
+ scopes = data["scopes"]
153
+
154
+ # Get datasets for a scope
155
+ result = subprocess.run(
156
+ ["datatrail", "ls", "chime.event.baseband.raw", "--json"],
157
+ capture_output=True,
158
+ text=True,
159
+ )
160
+ data = json.loads(result.stdout)
161
+ larger_datasets = data["larger_datasets"]
162
+ ```
@@ -11,6 +11,7 @@ Options:
11
11
  -s, --show-files Show file names.
12
12
  -v, --verbose Verbosity: v=INFO, vv=DEBUG.
13
13
  -q, --quiet Set log level to ERROR.
14
+ --json Output as JSON.
14
15
  --help Show this message and exit.
15
16
  ```
16
17
 
@@ -80,3 +81,72 @@ if the `--show-files` or `-s` flag is passed.
80
81
  │ - baseband_308892599_111.h5 │
81
82
  :
82
83
  ```
84
+
85
+ ## 🤖 Machine-readable JSON output
86
+
87
+ The `--json` flag outputs structured JSON instead of formatted tables, making it easy to parse dataset information in scripts and pipelines:
88
+
89
+ ```bash
90
+ $ datatrail ps kko.event.baseband.raw 308892599 --json
91
+ {
92
+ "dataset": "308892599",
93
+ "scope": "kko.event.baseband.raw",
94
+ "files": {
95
+ "contains_datasets": 0,
96
+ "datasets_contained": [],
97
+ "file_replica_locations": {
98
+ "minoc": [
99
+ "data/kko/baseband/raw/2023/08/07/astro_308892599/baseband_308892599_129.h5",
100
+ "data/kko/baseband/raw/2023/08/07/astro_308892599/baseband_308892599_1013.h5",
101
+ ...
102
+ ]
103
+ }
104
+ },
105
+ "policies": {
106
+ "replication_policy": {
107
+ "preferred_storage_elements": ["chime"],
108
+ "priority": "low",
109
+ "default": true
110
+ },
111
+ "deletion_policy": [
112
+ {
113
+ "storage_element": "minoc",
114
+ "priority": "low",
115
+ "default": true,
116
+ "delete_after_days": 36500
117
+ },
118
+ ...
119
+ ],
120
+ "belongs_to": [
121
+ {
122
+ "scope": "kko.event.baseband.raw",
123
+ "name": "B0531+21.commissioning.pulsar.temp"
124
+ }
125
+ ]
126
+ }
127
+ }
128
+ ```
129
+
130
+ ### Usage in scripts
131
+
132
+ ```python
133
+ import json
134
+ import subprocess
135
+
136
+ # Get dataset information
137
+ result = subprocess.run(
138
+ ["datatrail", "ps", "kko.event.baseband.raw", "308892599", "--json"],
139
+ capture_output=True,
140
+ text=True,
141
+ )
142
+ data = json.loads(result.stdout)
143
+
144
+ # Access file locations
145
+ file_locations = data["files"]["file_replica_locations"]
146
+ minoc_files = file_locations.get("minoc", [])
147
+
148
+ # Access policies
149
+ replication_policy = data["policies"]["replication_policy"]
150
+ deletion_policy = data["policies"]["deletion_policy"]
151
+ belongs_to = data["policies"]["belongs_to"]
152
+ ```
@@ -34,14 +34,16 @@ error_console = Console(stderr=True, style="bold red")
34
34
  @click.option("-v", "--verbose", count=True, help="Verbosity: v=INFO, vv=DEBUG.")
35
35
  @click.option("-q", "--quiet", is_flag=True, help="Only errors shown in logs.")
36
36
  @click.option("--write", is_flag=True, help="Write the events to file.")
37
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON.")
37
38
  @click.pass_context
38
- def list(
39
+ def list( # noqa: C901
39
40
  ctx: click.Context,
40
41
  scope: Optional[str] = None,
41
42
  datasets: Optional[str] = None,
42
43
  verbose: int = 0,
43
44
  quiet: bool = False,
44
45
  write: bool = False,
46
+ output_json: bool = False,
45
47
  ):
46
48
  """List Datatrail Scopes & Datasets.
47
49
 
@@ -52,6 +54,7 @@ def list(
52
54
  verbose (int): Verbosity: v=INFO, vv=DEBUG.
53
55
  quiet (bool): Only errors shown in logs.
54
56
  write (bool): Write the events to file.
57
+ output_json (bool): Output as JSON.
55
58
  """
56
59
  # Set logging level.
57
60
  set_log_level(logger, verbose, quiet)
@@ -73,6 +76,13 @@ def list(
73
76
  return None
74
77
  results = functions.list(scope, datasets, verbose, quiet)
75
78
 
79
+ # Output JSON if requested.
80
+ if output_json:
81
+ print(json.dumps(results, indent=2))
82
+ if "error" in results:
83
+ ctx.exit(1)
84
+ return
85
+
76
86
  # Display scopes.
77
87
  if "scopes" in results.keys():
78
88
  table = Table(
@@ -26,14 +26,16 @@ error_console = Console(stderr=True, style="bold red")
26
26
  @click.option("-s", "--show-files", is_flag=True, help="Show file names.")
27
27
  @click.option("-v", "--verbose", count=True, help="Verbosity: v=INFO, vv=DEBUG.")
28
28
  @click.option("-q", "--quiet", is_flag=True, help="Set log level to ERROR.")
29
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON.")
29
30
  @click.pass_context
30
- def ps(
31
+ def ps( # noqa: C901
31
32
  ctx: click.Context,
32
33
  scope: str,
33
34
  dataset: str,
34
35
  show_files: bool,
35
36
  verbose: int,
36
37
  quiet: bool,
38
+ output_json: bool,
37
39
  ):
38
40
  """Detailed status of a dataset.
39
41
 
@@ -44,6 +46,7 @@ def ps(
44
46
  show_files (bool): Show list of files.
45
47
  verbose (int): Verbosity: v=INFO, vv=DUBUG.
46
48
  quiet (bool): Set log level to ERROR.
49
+ output_json (bool): Output as JSON.
47
50
 
48
51
  Returns:
49
52
  None
@@ -73,13 +76,41 @@ def ps(
73
76
  try:
74
77
  files, policies = functions.ps(scope, dataset, verbose, quiet)
75
78
  if isinstance(files, str) or isinstance(policies, str):
79
+ if output_json:
80
+ import json
81
+
82
+ print(
83
+ json.dumps(
84
+ {"error": {"files": str(files), "policies": str(policies)}},
85
+ indent=2,
86
+ )
87
+ )
88
+ ctx.exit(1)
76
89
  error_console.print("Error: files = ", files)
77
90
  error_console.print("Error: policies = ", policies)
78
91
  return None
79
92
  except Exception as e:
93
+ if output_json:
94
+ import json
95
+
96
+ print(json.dumps({"error": str(e)}, indent=2))
97
+ ctx.exit(1)
80
98
  error_console.print(e)
81
99
  return None
82
100
 
101
+ # Handle JSON output
102
+ if output_json:
103
+ import json
104
+
105
+ result = {
106
+ "dataset": dataset,
107
+ "scope": scope,
108
+ "files": files,
109
+ "policies": policies,
110
+ }
111
+ print(json.dumps(result, indent=2))
112
+ return None
113
+
83
114
  if show_files and files:
84
115
  # Files table
85
116
  file_table = create_files_table(dataset, scope, files)
@@ -234,9 +265,7 @@ def create_files_table(dataset: str, scope: str, files: dict):
234
265
  file_table.add_row(f"Storage Element: [magenta]{se}")
235
266
  file_table.add_row(f"Common Path: {common_path}/", style="bold green")
236
267
  file_table.add_row(f"[green]- {fn}")
237
- # file_table.add_row(se, common_path, fn)
238
268
  else:
239
269
  file_table.add_row(f"- {fn}", style="green")
240
- # file_table.add_row("", "", fn)
241
270
  file_table.add_section()
242
271
  return file_table
@@ -4,8 +4,9 @@ import logging
4
4
  import os
5
5
  import re
6
6
  import shutil
7
+ import subprocess
7
8
  import time
8
- from collections import defaultdict
9
+ from collections import Counter
9
10
  from pathlib import Path
10
11
  from typing import Any, Dict, List, Optional, Tuple
11
12
 
@@ -297,8 +298,8 @@ def get_files(
297
298
  if site == "canfar":
298
299
  for folder in folders:
299
300
  os.makedirs(folder, exist_ok=True)
300
- os.system(f"chgrp -R chime-frb-rw {folder}") # nosec
301
- os.system(f"chmod -R g+w {folder}") # nosec
301
+ subprocess.run(["chgrp", "-R", "chime-frb-rw", folder])
302
+ subprocess.run(["chmod", "-R", "g+w", folder])
302
303
  else:
303
304
  for folder in folders:
304
305
  os.makedirs(folder, exist_ok=True)
@@ -485,6 +486,15 @@ def get_unregistered_dataset(dataset: str, scope: str) -> Optional[Dict[str, Any
485
486
  return response[0]
486
487
 
487
488
 
489
+ ATTACH_RE = re.compile(
490
+ r"Could not attach datasets: .+? ERROR: \"?dataset (.+?), (.+?) not found" # noqa: E501
491
+ )
492
+
493
+ CREATE_RE = re.compile(
494
+ r"Could not create dataset: (.+?), scope: (.+?)\. .*UniqueViolation" # noqa: E501
495
+ )
496
+
497
+
488
498
  def signature(msg: str) -> str:
489
499
  """Create a signature for a reason unregistered message.
490
500
 
@@ -494,16 +504,6 @@ def signature(msg: str) -> str:
494
504
  Returns:
495
505
  str: Signature for error message.
496
506
  """
497
- ATTACH_RE = re.compile(
498
- r"Could not attach datasets: .+ ERROR: dataset (.+), (.+) not found" # noqa: E501
499
- )
500
-
501
- CREATE_RE = re.compile(
502
- r"Could not create dataset: (.+), scope: (.+)\. .*UniqueViolation" # noqa: E501
503
- )
504
-
505
- POSTGRES_RE = re.compile(r".*psycopg.*")
506
-
507
507
  msg = msg.strip()
508
508
 
509
509
  # Attach-dataset errors
@@ -516,11 +516,12 @@ def signature(msg: str) -> str:
516
516
  m = CREATE_RE.search(msg)
517
517
  if m:
518
518
  dataset, scope = m.groups()
519
+ dataset = re.sub(r"\d+", "<ID>", dataset)
519
520
  return f"CREATE_DUPLICATE:{dataset}:{scope}"
520
521
 
521
522
  # PostgreSQL violation
522
- m = POSTGRES_RE.search(msg)
523
- if m:
523
+ if "psycopg" in msg:
524
+ msg = re.sub(r"\s+", " ", msg)
524
525
  return f"POSTGRES:{msg[:120]}"
525
526
 
526
527
  # Short status / token messages
@@ -551,11 +552,4 @@ def summarise_unregistered_datasets() -> Dict[str, int]:
551
552
  Dict[str, int]: Dictionary of error message signatures and their counts.
552
553
  """
553
554
  response = get_all_unregistered_datasets()
554
- reason_groups: Dict[str, int] = defaultdict(int)
555
- messages = [str(r["results"]["reason"]) for r in response]
556
-
557
- for msg in messages:
558
- sig = signature(msg)
559
- reason_groups[sig] += 1
560
-
561
- return reason_groups
555
+ return Counter(signature(str(r["results"]["reason"])) for r in response)
@@ -0,0 +1,102 @@
1
+ """Datatrail Unregistered datasets commands."""
2
+
3
+ import logging
4
+ from collections import defaultdict
5
+ from typing import DefaultDict, List, Tuple
6
+
7
+ import click
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+ from rich.text import Text
11
+
12
+ from dtcli.src import functions
13
+ from dtcli.utilities.utilities import set_log_level
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ console = Console()
18
+ error_console = Console(stderr=True, style="bold red")
19
+
20
+ CATEGORY_STYLES = {
21
+ "ATTACH_MISSING": "yellow",
22
+ "CREATE_DUPLICATE": "magenta",
23
+ "POSTGRES": "red",
24
+ "OTHER": "red",
25
+ "STATUS": "cyan",
26
+ }
27
+
28
+
29
+ @click.group(help="Commands related to unregistered datasets.")
30
+ def unregistered():
31
+ """Group of commands related to unregistered datasets."""
32
+ pass
33
+
34
+
35
+ @unregistered.command(help="Summarise the reasons for unregistered datasets.")
36
+ @click.option("-v", "--verbose", count=True, help="Verbosity: v=INFO, vv=DEBUG.")
37
+ @click.option("-q", "--quiet", is_flag=True, help="Only errors shown in logs.")
38
+ @click.pass_context
39
+ def summary(
40
+ ctx: click.Context,
41
+ verbose: int = 0,
42
+ quiet: bool = False,
43
+ ):
44
+ """Show a summary of the unregistered datasets.
45
+
46
+ Args:
47
+ ctx (click.Context): Click context.
48
+ verbose (int): Verbosity: v=INFO, vv=DEBUG.
49
+ quiet (bool): Only errors shown in logs.
50
+ """
51
+ # Set logging level.
52
+ set_log_level(logger, verbose, quiet)
53
+ logger.debug("`summary` called with:")
54
+ logger.debug(f"verbose: {verbose} [{type(verbose)}]")
55
+ logger.debug(f"quiet: {quiet} [{type(quiet)}]")
56
+
57
+ results = functions.summarise_unregistered_datasets()
58
+
59
+ if not results:
60
+ console.print("No unregistered datasets found.")
61
+ return
62
+
63
+ total = sum(results.values())
64
+
65
+ table = Table(
66
+ title=f"Summary of reasons — {total:,} unregistered datasets",
67
+ header_style="magenta",
68
+ title_style="bold magenta",
69
+ row_styles=["none", "dim"],
70
+ )
71
+ table.add_column("Category")
72
+ table.add_column("Detail")
73
+ table.add_column("Count", justify="right")
74
+ table.add_column("%", justify="right")
75
+
76
+ # Group signatures by their category prefix, e.g. "ATTACH_MISSING:...".
77
+ groups: DefaultDict[str, List[Tuple[str, int]]] = defaultdict(list)
78
+ for sig, count in results.items():
79
+ category, _, detail = sig.partition(":")
80
+ groups[category].append((detail, count))
81
+
82
+ ordered = sorted(
83
+ groups.items(),
84
+ key=lambda group: sum(count for _, count in group[1]),
85
+ reverse=True,
86
+ )
87
+ for index, (category, reasons) in enumerate(ordered):
88
+ if index:
89
+ table.add_section()
90
+ style = CATEGORY_STYLES.get(category, "white")
91
+ reasons.sort(key=lambda reason: reason[1], reverse=True)
92
+ for row, (detail, count) in enumerate(reasons):
93
+ if category in ("ATTACH_MISSING", "CREATE_DUPLICATE"):
94
+ detail = detail.replace(":", " → ", 1)
95
+ table.add_row(
96
+ Text(category, style=style) if row == 0 else "",
97
+ Text(detail) if detail else "(no reason recorded)",
98
+ f"{count:,}",
99
+ f"{count / total:.1%}",
100
+ )
101
+
102
+ console.print(table)
@@ -3,6 +3,7 @@
3
3
  import logging
4
4
  import os
5
5
  import sys
6
+ from concurrent.futures import ThreadPoolExecutor
6
7
  from io import StringIO
7
8
  from multiprocessing import Process # Use the standard library only
8
9
  from typing import Any, Dict, List, Optional, Tuple
@@ -69,10 +70,13 @@ def _connect(
69
70
  storage = StorageInventoryClient(cert, resource_id=storage_resource_id)
70
71
  query = CadcTapClient(cert, resource_id=query_resource_id)
71
72
  return cert, storage, query
72
- # TODO: Handle invalid cert
73
73
  except ValueError as error:
74
- logger.error(error)
75
- raise error
74
+ logger.error(
75
+ "Authorization failed: The provided CANFAR certificate is "
76
+ "invalid or expired. Please ensure you have a valid "
77
+ "certificate and try again."
78
+ )
79
+ raise ValueError("Invalid or expired CANFAR certificate.") from error
76
80
 
77
81
 
78
82
  def get(
@@ -410,23 +414,25 @@ def status(
410
414
  ]
411
415
  if not certfile:
412
416
  certfile = procure(key="vospace_certfile")
413
- minoc_status = False
414
- luskan_status = False
415
- for index, url in enumerate(urls):
417
+
418
+ def check_url(url: str) -> bool:
416
419
  response = requests.get(url, cert=certfile, allow_redirects=True)
417
420
  try:
418
421
  response.raise_for_status()
419
422
  authorised = response.headers.get("x-vo-authenticated")
420
423
  if isinstance(authorised, str):
421
- if index == 0:
422
- minoc_status = True
423
- else:
424
- luskan_status = True
424
+ return True
425
425
  else:
426
426
  raise TypeError
427
427
  except HTTPError as error:
428
428
  logger.warning(error)
429
429
  logger.warning(f"{url.split('/')[3]} is down.")
430
+ return False
430
431
  except TypeError:
431
432
  logger.error("Canfar certificate is not valid.")
432
- return minoc_status, luskan_status
433
+ return False
434
+
435
+ with ThreadPoolExecutor(max_workers=len(urls)) as executor:
436
+ results = list(executor.map(check_url, urls))
437
+
438
+ return results[0], results[1]
@@ -70,6 +70,9 @@ def split(data: List[Any], count: int) -> List[List[Any]]:
70
70
  Returns:
71
71
  List[List[Any]]: List of batches.
72
72
  """
73
+ if count <= 0:
74
+ raise ValueError("count must be greater than 0")
75
+
73
76
  batch_size = len(data) // count
74
77
  remainder = len(data) % count
75
78
  batches: List[Any] = []