datatrail-cli 1.0.0__tar.gz → 1.0.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 (71) hide show
  1. datatrail_cli-1.0.1/.release-please-manifest.json +3 -0
  2. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/CHANGELOG.md +13 -0
  3. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/PKG-INFO +1 -1
  4. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/pull.py +20 -2
  5. datatrail_cli-1.0.1/dtcli/scout.py +323 -0
  6. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/src/functions.py +66 -3
  7. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/pyproject.toml +1 -1
  8. datatrail_cli-1.0.1/tests/test_canfar_permissions.py +206 -0
  9. datatrail_cli-1.0.1/tests/test_pull_size.py +164 -0
  10. datatrail_cli-1.0.1/tests/test_scout.py +419 -0
  11. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/uv.lock +1 -1
  12. datatrail_cli-1.0.0/.release-please-manifest.json +0 -3
  13. datatrail_cli-1.0.0/dtcli/scout.py +0 -233
  14. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  15. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  16. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  17. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/.github/workflows/codeql.yml +0 -0
  18. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/.github/workflows/continuous-deployment.yml +0 -0
  19. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/.github/workflows/continuous-integration.yml +0 -0
  20. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/.github/workflows/docs.yml +0 -0
  21. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/.github/workflows/stale.yml +0 -0
  22. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/.gitignore +0 -0
  23. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/.pre-commit-config.yaml +0 -0
  24. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/LICENSE +0 -0
  25. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/README.md +0 -0
  26. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/clear.md +0 -0
  27. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/cli.md +0 -0
  28. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/commands.md +0 -0
  29. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/images/Datatrail-logo.png +0 -0
  30. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/index.md +0 -0
  31. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/initialising.md +0 -0
  32. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/install.md +0 -0
  33. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/inventory.md +0 -0
  34. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/list.md +0 -0
  35. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/ps.md +0 -0
  36. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/pull-manifest.md +0 -0
  37. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/pull.md +0 -0
  38. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/scout.md +0 -0
  39. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/unregistered.md +0 -0
  40. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/docs/user_guide.md +0 -0
  41. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/__init__.py +0 -0
  42. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/clear.py +0 -0
  43. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/cli.py +0 -0
  44. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/config.py +0 -0
  45. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/config.yaml +0 -0
  46. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/doctor.py +0 -0
  47. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/inventory.py +0 -0
  48. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/locking.py +0 -0
  49. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/ls.py +0 -0
  50. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/ps.py +0 -0
  51. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/pull_manifest.py +0 -0
  52. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/unregistered.py +0 -0
  53. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/utilities/cadcclient.py +0 -0
  54. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/utilities/results.py +0 -0
  55. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/utilities/utilities.py +0 -0
  56. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/dtcli/verify.py +0 -0
  57. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/mkdocs.yml +0 -0
  58. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/release-please-config.json +0 -0
  59. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/conftest.py +0 -0
  60. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_cadc_summary.py +0 -0
  61. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_cli.py +0 -0
  62. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_config.py +0 -0
  63. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_doctor.py +0 -0
  64. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_functions.py +0 -0
  65. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_inventory.py +0 -0
  66. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_locking.py +0 -0
  67. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_pull_manifest.py +0 -0
  68. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_pull_reliability.py +0 -0
  69. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_utils.py +0 -0
  70. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tests/test_verify.py +0 -0
  71. {datatrail_cli-1.0.0 → datatrail_cli-1.0.1}/tox.ini +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "1.0.1"
3
+ }
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.1](https://github.com/CHIMEFRB/datatrail-cli/compare/datatrail-cli-v1.0.0...datatrail-cli-v1.0.1) (2026-09-29)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **pull:** continue when optional size query is unavailable ([#195](https://github.com/CHIMEFRB/datatrail-cli/issues/195)) ([b36bbbf](https://github.com/CHIMEFRB/datatrail-cli/commit/b36bbbf90d47eb22f20eaa7a12cfea8f4ae703bb))
9
+ * **scout:** restore discrepancy detection and confirmed repairs ([#197](https://github.com/CHIMEFRB/datatrail-cli/issues/197)) ([54dfbf4](https://github.com/CHIMEFRB/datatrail-cli/commit/54dfbf4b3abf8ff0f7f9db13e5eb7ccdb28ccce1))
10
+
11
+
12
+ ### Performance Improvements
13
+
14
+ * **pull:** apply CANFAR permissions without subprocesses ([#157](https://github.com/CHIMEFRB/datatrail-cli/issues/157)) ([b8c2230](https://github.com/CHIMEFRB/datatrail-cli/commit/b8c22306432a6b24b435b5ddb421ac5cdcd9e1eb))
15
+
3
16
  ## [1.0.0](https://github.com/CHIMEFRB/datatrail-cli/compare/datatrail-cli-v0.13.0...datatrail-cli-v1.0.0) (2026-09-28)
4
17
 
5
18
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: datatrail-cli
3
- Version: 1.0.0
3
+ Version: 1.0.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>
@@ -4,7 +4,13 @@ import logging
4
4
  from os import cpu_count, path
5
5
 
6
6
  import click
7
- from requests.exceptions import ConnectionError, SSLError
7
+ from cadcutils.exceptions import (
8
+ HttpException,
9
+ InternalServerException,
10
+ TransferException,
11
+ UnexpectedException,
12
+ )
13
+ from requests.exceptions import ConnectionError, SSLError, Timeout
8
14
  from rich.console import Console
9
15
  from rich.prompt import Confirm
10
16
 
@@ -149,6 +155,18 @@ Create one using 'cadc-get-cert -u <USERNAME>'.
149
155
  """
150
156
  )
151
157
  return None
158
+ except (ConnectionError, Timeout, HttpException) as error:
159
+ # CADC also uses the base HttpException for failed connections/retries.
160
+ # Preserve its authentication, certificate, and invalid-request errors.
161
+ if isinstance(error, HttpException) and type(error) not in (
162
+ HttpException,
163
+ InternalServerException,
164
+ TransferException,
165
+ UnexpectedException,
166
+ ):
167
+ raise
168
+ logger.debug("Unable to query Luskan for download size", exc_info=True)
169
+ to_download_size = -1
152
170
  elif not luskan_up:
153
171
  to_download_size = -1
154
172
  else:
@@ -161,7 +179,7 @@ Create one using 'cadc-get-cert -u <USERNAME>'.
161
179
  f" - {len(files['missing'])} files can be downloaded from minoc.",
162
180
  style="yellow",
163
181
  )
164
- if luskan_up:
182
+ if to_download_size >= 0:
165
183
  console.print(
166
184
  f" - Size to download: {to_download_size:.2f} GB.\n",
167
185
  style="yellow",
@@ -0,0 +1,323 @@
1
+ """Datatrail Scout Command."""
2
+
3
+ import logging
4
+ import re
5
+ from pathlib import PurePosixPath
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ import click
9
+ import requests
10
+ from rich.console import Console
11
+ from rich.prompt import Confirm
12
+ from rich.table import Table
13
+
14
+ from dtcli.config import procure
15
+ from dtcli.ls import list as ls
16
+ from dtcli.utilities import cadcclient
17
+ from dtcli.utilities.utilities import (
18
+ REQUEST_TIMEOUT,
19
+ check_canfar_status,
20
+ set_log_level,
21
+ validate_scope,
22
+ )
23
+
24
+ logger = logging.getLogger("scout")
25
+
26
+ console = Console()
27
+ error_console = Console(stderr=True, style="bold red")
28
+
29
+ # Remote roots used by the Datatrail server's scout proxy. These are independent
30
+ # of the client's local root_mounts configuration.
31
+ SCOUT_ROOT_MOUNTS = {
32
+ "chime": "/",
33
+ "baseband_buffer": "/data/baseband_buffer/",
34
+ "kko": "/",
35
+ "gbo": "/",
36
+ "hco": "/",
37
+ }
38
+
39
+
40
+ @click.command(name="scout", help="Scout a dataset.")
41
+ @click.argument("scopes", required=False, type=click.STRING, nargs=-1)
42
+ @click.argument("dataset", required=True, type=click.STRING, nargs=1)
43
+ @click.option("-v", "--verbose", count=True, help="Verbosity: v=INFO, vv=DEBUG.")
44
+ @click.option("-q", "--quiet", is_flag=True, help="Set log level to ERROR.")
45
+ @click.pass_context
46
+ def scout( # noqa: C901
47
+ ctx: click.Context,
48
+ scopes: List[str],
49
+ dataset: str,
50
+ verbose: int,
51
+ quiet: bool,
52
+ ):
53
+ """Scout a dataset.
54
+
55
+ Args:
56
+ ctx (click.Context): Click context.
57
+ scopes (List[str]): Scopes of dataset.
58
+ dataset (str): Name of dataset.
59
+ verbose (int): Verbosity: v=INFO, vv=DUBUG.
60
+ quiet (bool): Set log level to ERROR.
61
+
62
+ Returns:
63
+ None
64
+ """
65
+ # Set logging level.
66
+ set_log_level(logger, verbose, quiet)
67
+ logger.debug("`scout` called with:")
68
+ logger.debug(f"scopes: {scopes} [{type(scopes)}]")
69
+ logger.debug(f"dataset: {dataset} [{type(dataset)}]")
70
+ logger.debug(f"verbose: {verbose} [{type(verbose)}]")
71
+ logger.debug(f"quiet: {quiet} [{type(quiet)}]")
72
+
73
+ # Check if scopes are valid.
74
+ if scopes:
75
+ logger.debug(f"Scopes limited to: {list(scopes)}")
76
+ try:
77
+ valid_scopes = all(validate_scope(scope) for scope in scopes)
78
+ except Exception:
79
+ error_console.print("Unable to validate scopes.")
80
+ ctx.exit(1)
81
+ if not valid_scopes:
82
+ error_console.print("A scope is invalid.")
83
+ console.print("Valid scopes are:")
84
+ ctx.invoke(ls)
85
+ ctx.exit(1)
86
+
87
+ # Load configuration.
88
+ try:
89
+ config = procure()
90
+ server = config["server"]
91
+ logger.debug("Configuration loaded successfully.")
92
+ except Exception:
93
+ logger.error(
94
+ "No configuration file found. Create one with `datatrail config init`."
95
+ )
96
+ ctx.exit(1)
97
+
98
+ # Check Canfar status.
99
+ check_canfar_status(error_console)
100
+
101
+ # Scout dataset.
102
+ url = server.rstrip("/") + "/query/dataset/scout"
103
+ params: Dict[str, Any] = {"name": dataset}
104
+ if scopes:
105
+ params["scopes"] = scopes
106
+ logger.debug(f"URL: {url}")
107
+ try:
108
+ response = requests.get(url, params=params, timeout=REQUEST_TIMEOUT)
109
+ except requests.exceptions.Timeout:
110
+ error_console.print("Error: Datatrail server timed out.")
111
+ ctx.exit(1)
112
+ except requests.RequestException:
113
+ error_console.print("Error: Datatrail scout request failed.")
114
+ ctx.exit(1)
115
+ if not 200 <= response.status_code < 300:
116
+ error_console.print(
117
+ f"Error: Datatrail server returned HTTP {response.status_code}."
118
+ )
119
+ ctx.exit(1)
120
+ try:
121
+ data = response.json()
122
+ logger.debug(f"Data: {data}")
123
+ except ValueError:
124
+ error_console.print("Error: Datatrail server returned invalid JSON.")
125
+ ctx.exit(1)
126
+ if not _valid_scout_data(data):
127
+ error_console.print("Error: Datatrail returned no valid scout results.")
128
+ ctx.exit(1)
129
+
130
+ file_discrepancies: List[List] = []
131
+ failed = False
132
+
133
+ for scope in data:
134
+ basepath = data[scope]["basepath"].replace("'", "''")
135
+ query = f"select count(*) from inventory.Artifact where uri like 'cadc:CHIMEFRB/{basepath}%'" # noqa: E501
136
+ try:
137
+ count = int(cadcclient.query(query)[0][0])
138
+ if count < 0:
139
+ raise ValueError("Invalid file count.")
140
+ except Exception:
141
+ error_console.print(f"{scope} - Minoc count query failed.")
142
+ count = -1
143
+ failed = True
144
+ data[scope]["observed"]["minoc"] = count
145
+ storage_elements = dict.fromkeys(
146
+ [*data[scope]["observed"], *data[scope]["expected"]]
147
+ )
148
+ for se in storage_elements:
149
+ data[scope]["observed"].setdefault(se, 0)
150
+ data[scope]["expected"].setdefault(se, 0)
151
+ if data[scope]["observed"][se] > data[scope]["expected"][se]:
152
+ file_discrepancies.append([scope, se])
153
+
154
+ show_scout_results(dataset, data)
155
+
156
+ if file_discrepancies:
157
+ error_console.print("File discrepancies:")
158
+ for scope, se in file_discrepancies:
159
+ error_console.print(f" - {se}: {scope}")
160
+ if Confirm.ask("\nWould you like to attempt to heal this discrepancy?"):
161
+ if not _heal(server, dataset, scope, se, data[scope]):
162
+ failed = True
163
+ if failed:
164
+ ctx.exit(1)
165
+
166
+
167
+ def _valid_scout_data(data: Any) -> bool:
168
+ """Require the report fields used to display and select repairs."""
169
+ if not isinstance(data, dict) or not data or "error" in data:
170
+ return False
171
+ for scope, info in data.items():
172
+ if not isinstance(scope, str) or not scope or not isinstance(info, dict):
173
+ return False
174
+ if not isinstance(info.get("basepath"), str) or not info["basepath"]:
175
+ return False
176
+ if not isinstance(info.get("filetype"), str):
177
+ return False
178
+ for field in ("observed", "expected"):
179
+ counts = info.get(field)
180
+ if not isinstance(counts, dict) or not all(
181
+ isinstance(site, str)
182
+ and site
183
+ and isinstance(count, int)
184
+ and not isinstance(count, bool)
185
+ and count >= (0 if field == "expected" else -1)
186
+ for site, count in counts.items()
187
+ ):
188
+ return False
189
+ return True
190
+
191
+
192
+ def _validated_checksums(data: Any) -> Optional[Dict[str, str]]:
193
+ """Accept filename-to-MD5 mappings and normalize the optional MD5 prefix."""
194
+ if not isinstance(data, dict) or not data or "error" in data:
195
+ return None
196
+ checksums = {}
197
+ for filename, value in data.items():
198
+ if not isinstance(filename, str) or not filename.strip():
199
+ return None
200
+ if not isinstance(value, str):
201
+ return None
202
+ checksum = value.strip().lower()
203
+ if checksum.startswith("md5:"):
204
+ checksum = checksum[4:]
205
+ if re.fullmatch(r"[0-9a-f]{32}", checksum) is None:
206
+ return None
207
+ checksums[filename] = checksum
208
+ return checksums
209
+
210
+
211
+ def _site_checksums(
212
+ checksums: Dict[str, str], site: str, basepath: str
213
+ ) -> Optional[Dict[str, str]]:
214
+ """Convert scout filenames to the dataset-relative names used by sync."""
215
+ if site not in SCOUT_ROOT_MOUNTS:
216
+ return None
217
+ root = PurePosixPath(SCOUT_ROOT_MOUNTS[site])
218
+ base = PurePosixPath(basepath)
219
+ if base.is_absolute() or ".." in base.parts:
220
+ return None
221
+ normalized = {}
222
+ for filename, checksum in checksums.items():
223
+ path = PurePosixPath(filename)
224
+ if ".." in path.parts:
225
+ return None
226
+ try:
227
+ relative = path.relative_to(root) if path.is_absolute() else path
228
+ suffix = relative.relative_to(base)
229
+ except ValueError:
230
+ return None
231
+ name = relative.as_posix()
232
+ if not suffix.parts or name in normalized:
233
+ return None
234
+ normalized[name] = checksum
235
+ return normalized
236
+
237
+
238
+ def _heal(server: str, dataset: str, scope: str, site: str, info: Dict) -> bool:
239
+ """Fetch validated checksums and submit one explicitly confirmed repair."""
240
+ try:
241
+ if site == "minoc":
242
+ data = cadcclient.dataset_md5s(info["basepath"])
243
+ else:
244
+ response = requests.get(
245
+ server.rstrip("/") + "/query/dataset/scout/md5sums",
246
+ params={
247
+ "basepath": info["basepath"],
248
+ "site": site,
249
+ "filetype": info["filetype"],
250
+ },
251
+ timeout=REQUEST_TIMEOUT,
252
+ )
253
+ if not 200 <= response.status_code < 300:
254
+ raise ValueError("Checksum request failed.")
255
+ data = response.json()
256
+ except Exception:
257
+ error_console.print(f"{scope} ({site}) - Checksum query failed; skipping.")
258
+ return False
259
+ checksums = _validated_checksums(data)
260
+ if checksums is not None and site != "minoc":
261
+ checksums = _site_checksums(checksums, site, info["basepath"])
262
+ if checksums is None:
263
+ error_console.print(f"{scope} ({site}) - Invalid checksum response; skipping.")
264
+ return False
265
+ try:
266
+ response = requests.post(
267
+ server.rstrip("/") + "/commit/dataset/scout/sync",
268
+ params={"name": dataset, "scope": scope, "replicate_to": site},
269
+ json=checksums,
270
+ timeout=REQUEST_TIMEOUT,
271
+ )
272
+ except requests.RequestException:
273
+ error_console.print(f"{scope} ({site}) - Healing failed.")
274
+ return False
275
+ if not 200 <= response.status_code < 300:
276
+ error_console.print(f"{scope} ({site}) - Healing failed.")
277
+ return False
278
+ console.print(f"{scope} ({site}) - Healing successful.")
279
+ return True
280
+
281
+
282
+ def show_scout_results(dataset: str, data: dict):
283
+ """Create and display a table with scout results.
284
+
285
+ Args:
286
+ dataset: Name of dataset.
287
+ data: Data to display.
288
+ """
289
+ # Display results.
290
+ scopes = list(data.keys())
291
+ storage_elements = dict.fromkeys(
292
+ site for info in data.values() for site in [*info["observed"], *info["expected"]]
293
+ )
294
+ table = Table(
295
+ title=f"Scout Results for {dataset}",
296
+ header_style="magenta",
297
+ title_style="bold magenta",
298
+ )
299
+ table.add_column("Scope", style="bold")
300
+ for se in storage_elements:
301
+ table.add_column(se, style="bold")
302
+
303
+ for scope in scopes:
304
+ # Observed
305
+ row = [scope]
306
+ for se in storage_elements:
307
+ row.append(str(data[scope]["observed"].get(se, 0)))
308
+ table.add_row(*row, style="blue")
309
+
310
+ # Expected
311
+ row = [scope]
312
+ for se in storage_elements:
313
+ row.append(str(data[scope]["expected"].get(se, 0)))
314
+ table.add_row(*row, style="yellow", end_section=True)
315
+
316
+ console.print(table)
317
+ console.print("Legend: [blue]Observed[/blue], [yellow]Expected[/yellow]")
318
+ console.print(
319
+ "NOTE: In the case where more files are expected at a site other than \
320
+ minoc, that this may be due to the file type filtering when querying each site. This \
321
+ is a known limitation of the current implementation.",
322
+ )
323
+ console.print()
@@ -4,7 +4,7 @@ import logging
4
4
  import os
5
5
  import re
6
6
  import shutil
7
- import subprocess
7
+ import stat
8
8
  import time
9
9
  from collections import Counter
10
10
  from collections.abc import Sequence
@@ -461,6 +461,63 @@ def find_missing_dataset_files(
461
461
  return {"missing": missing_files, "existing": existing_files}
462
462
 
463
463
 
464
+ def _apply_file_group_permissions(
465
+ filename: str, directory_fd: int, group_id: Optional[int]
466
+ ) -> None:
467
+ """Update a regular file relative to its directory without following links."""
468
+ try:
469
+ metadata = os.stat(filename, dir_fd=directory_fd, follow_symlinks=False)
470
+ if not stat.S_ISREG(metadata.st_mode):
471
+ return
472
+ if group_id is not None:
473
+ try:
474
+ os.chown(
475
+ filename, -1, group_id, dir_fd=directory_fd, follow_symlinks=False
476
+ )
477
+ except OSError as error:
478
+ logger.warning("Could not update CANFAR ownership: %s", error)
479
+ metadata = os.stat(filename, dir_fd=directory_fd, follow_symlinks=False)
480
+ if stat.S_ISREG(metadata.st_mode):
481
+ os.chmod(
482
+ filename,
483
+ stat.S_IMODE(metadata.st_mode) | stat.S_IWGRP,
484
+ dir_fd=directory_fd,
485
+ follow_symlinks=False,
486
+ )
487
+ except (OSError, ValueError, NotImplementedError) as error:
488
+ # If a symlink replaces the file, do not follow it. Some platforms
489
+ # reject no-follow chmod rather than applying it.
490
+ logger.warning("Could not update CANFAR permissions: %s", error)
491
+
492
+
493
+ def _apply_chime_frb_rw_permissions(folder: str, group_id: Optional[int]) -> None:
494
+ """Set group ownership and write permission without following symlinks."""
495
+
496
+ def report_error(error: OSError) -> None:
497
+ logger.warning("Could not update CANFAR permissions: %s", error)
498
+
499
+ try:
500
+ # fwalk keeps directory access relative to open descriptors and skips
501
+ # symlinked directories, including a symlink passed as the root folder.
502
+ for _, _, filenames, directory_fd in os.fwalk(
503
+ folder, follow_symlinks=False, onerror=report_error
504
+ ):
505
+ if group_id is not None:
506
+ try:
507
+ os.fchown(directory_fd, -1, group_id)
508
+ except OSError as error:
509
+ report_error(error)
510
+ try:
511
+ mode = stat.S_IMODE(os.fstat(directory_fd).st_mode)
512
+ os.fchmod(directory_fd, mode | stat.S_IWGRP)
513
+ except OSError as error:
514
+ report_error(error)
515
+ for filename in filenames:
516
+ _apply_file_group_permissions(filename, directory_fd, group_id)
517
+ except OSError as error:
518
+ report_error(error)
519
+
520
+
464
521
  def get_files(
465
522
  files: List[str],
466
523
  site: str,
@@ -499,10 +556,16 @@ def get_files(
499
556
  # make directory structure if it does not exist.
500
557
  folders = {os.path.dirname(path) for path in destinations}
501
558
  if site == "canfar":
559
+ import grp
560
+
561
+ try:
562
+ group_id = grp.getgrnam("chime-frb-rw").gr_gid
563
+ except (KeyError, OSError) as error:
564
+ logger.warning("Could not resolve CANFAR group: %s", error)
565
+ group_id = None
502
566
  for folder in folders:
503
567
  os.makedirs(folder, exist_ok=True)
504
- subprocess.run(["chgrp", "-R", "chime-frb-rw", folder])
505
- subprocess.run(["chmod", "-R", "g+w", folder])
568
+ _apply_chime_frb_rw_permissions(folder, group_id)
506
569
  else:
507
570
  for folder in folders:
508
571
  os.makedirs(folder, exist_ok=True)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "datatrail-cli"
3
- version = "1.0.0"
3
+ version = "1.0.1"
4
4
  description = "CHIME/FRB Datatrail CLI"
5
5
  authors = [
6
6
  { name = "CHIME FRB Project Office" },