pyoncat 2.7__tar.gz → 2.9__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyoncat
3
- Version: 2.7
3
+ Version: 2.9
4
4
  Summary: A Python Client for ONCat (the ORNL Neutron Catalog).
5
5
  Keywords: ornl,neutron,metadata,catalog
6
6
  Author: ONCat Support
@@ -30,7 +30,7 @@ The `pyoncat` package serves as a Python client to more easily interact with the
30
30
 
31
31
  ### Requirements
32
32
 
33
- - Python version: `>=3.7, <4`
33
+ - Python version: `>=3.9, <4`
34
34
  - Python packages: `requests`
35
35
  - Optional packages for authenticated API access: `oauthlib`, `requests-oauthlib`
36
36
 
@@ -10,7 +10,7 @@ The `pyoncat` package serves as a Python client to more easily interact with the
10
10
 
11
11
  ### Requirements
12
12
 
13
- - Python version: `>=3.7, <4`
13
+ - Python version: `>=3.9, <4`
14
14
  - Python packages: `requests`
15
15
  - Optional packages for authenticated API access: `oauthlib`, `requests-oauthlib`
16
16
 
@@ -34,7 +34,7 @@ from pyoncat.reduction import ReductionFileBuilder
34
34
  if TYPE_CHECKING:
35
35
  import threading
36
36
 
37
- __version__ = "2.7"
37
+ __version__ = "2.9"
38
38
 
39
39
  # Sent on every request PyONCat makes, API calls and token requests alike,
40
40
  # so server logs can tell PyONCat traffic from other clients.
@@ -58,6 +58,7 @@ __all__ = [
58
58
  "REAUTH_NEVER",
59
59
  "REAUTH_PROMPT",
60
60
  "RESOURCE_OWNER_CREDENTIALS_FLOW",
61
+ "ConflictError",
61
62
  "DeviceAuthorizationCancelled",
62
63
  "DeviceAuthorizationChallenge",
63
64
  "DeviceAuthorizationDenied",
@@ -409,6 +410,7 @@ class ONCat:
409
410
  Cycle: Cycle
410
411
  Datafile: Datafile
411
412
  Reduction: Reduction
413
+ Dataset: Dataset
412
414
  Simulation: Simulation
413
415
  User: User
414
416
  Task: Task
@@ -560,6 +562,9 @@ class ONCat:
560
562
  if error.response.status_code == 404:
561
563
  msg = f'Could not find resource at "{full_url}"'
562
564
  raise NotFoundError(msg) from error
565
+ if error.response.status_code == 409:
566
+ msg = _conflict_message(error.response)
567
+ raise ConflictError(msg) from error
563
568
  msg = f'Error: "{error!s}"'
564
569
  raise PyONCatError(msg) from error
565
570
 
@@ -763,7 +768,7 @@ class ONCat:
763
768
 
764
769
  Revocation uses the configured public `client_id` and no secret
765
770
  (the device-authorization client is a public client, RFC 8628
766
- / ADR-0058).
771
+ / ADR-000058).
767
772
 
768
773
  Per RFC 7009 §2.2 the revocation endpoint returns HTTP 200 even for
769
774
  a token that is already invalid, so a 200 confirms the request was
@@ -932,12 +937,13 @@ class ONCat:
932
937
  device_authorization_timeout: float | None
933
938
  Device Authorization Grant only. An overall caller
934
939
  deadline in seconds for the sign-in, distinct from the
935
- RFC 8628 server-side `expires_in`. When set, polling
936
- stops at the deadline with `DeviceAuthorizationTimeout`.
937
- Polling also stops no later than the server's own
938
- `expires_in` (the effective deadline is the earlier of
939
- the two). `None` (the default) means no overall deadline
940
- -- today's behavior.
940
+ RFC 8628 server-side `expires_in`. Polling stops at the
941
+ earlier of the two. When the caller deadline is reached
942
+ first, the sign-in raises `DeviceAuthorizationTimeout`;
943
+ when the server's `expires_in` is reached first (or at
944
+ the same moment, or no caller deadline is set), it raises
945
+ `DeviceAuthorizationExpired`. `None` (the default) means
946
+ no overall caller deadline.
941
947
 
942
948
  http_timeout: float | None
943
949
  Device Authorization Grant only. The connect/read HTTP
@@ -1138,7 +1144,7 @@ class ONCat:
1138
1144
  device_auth_body["scope"] = scope_str
1139
1145
 
1140
1146
  # The caller's `device_authorization_timeout` bounds the WHOLE sign-in
1141
- # (ADR-0097), so the deadline clock starts BEFORE the initial
1147
+ # (ADR-000097), so the deadline clock starts BEFORE the initial
1142
1148
  # device-authorization POST. A slow initial POST therefore counts
1143
1149
  # against the overall budget. `http_timeout` still caps each
1144
1150
  # individual POST; the caller deadline is the cumulative bound.
@@ -1207,14 +1213,22 @@ class ONCat:
1207
1213
  # The effective deadline is the earlier of the caller's overall
1208
1214
  # `device_authorization_timeout` (clocked from before the
1209
1215
  # initial POST) and the server's own `expires_in` (clocked from
1210
- # now, once known).
1216
+ # now, once known). Which one it is decides the outcome raised
1217
+ # when it passes: the server expiry means the device code is dead
1218
+ # (`DeviceAuthorizationExpired`, the same outcome as a server
1219
+ # `expired_token`), the caller deadline means the caller gave up
1220
+ # (`DeviceAuthorizationTimeout`). On a tie the device code is dead
1221
+ # either way, so the server expiry wins.
1211
1222
  server_expiry = (
1212
1223
  monotonic() + expires_in if expires_in is not None else None
1213
1224
  )
1214
- deadlines = [
1215
- d for d in (caller_deadline, server_expiry) if d is not None
1216
- ]
1217
- deadline = min(deadlines) if deadlines else None
1225
+ deadline = caller_deadline
1226
+ deadline_is_server_expiry = False
1227
+ if server_expiry is not None and (
1228
+ caller_deadline is None or server_expiry <= caller_deadline
1229
+ ):
1230
+ deadline = server_expiry
1231
+ deadline_is_server_expiry = True
1218
1232
 
1219
1233
  self._dispatch_device_authorization_challenge(
1220
1234
  prompt,
@@ -1236,10 +1250,18 @@ class ONCat:
1236
1250
 
1237
1251
  while True:
1238
1252
  self._raise_if_cancelled(cancel_event)
1239
- self._raise_if_past_deadline(deadline, monotonic)
1253
+ self._raise_if_past_deadline(
1254
+ deadline,
1255
+ monotonic,
1256
+ server_expiry=deadline_is_server_expiry,
1257
+ )
1240
1258
  sleep(self._poll_wait_seconds(interval, deadline, monotonic))
1241
1259
  self._raise_if_cancelled(cancel_event)
1242
- self._raise_if_past_deadline(deadline, monotonic)
1260
+ self._raise_if_past_deadline(
1261
+ deadline,
1262
+ monotonic,
1263
+ server_expiry=deadline_is_server_expiry,
1264
+ )
1243
1265
 
1244
1266
  try:
1245
1267
  token_response = requests.post(
@@ -1372,14 +1394,32 @@ class ONCat:
1372
1394
 
1373
1395
  @staticmethod
1374
1396
  def _raise_if_past_deadline(
1375
- deadline: float | None, monotonic: Callable[[], float]
1397
+ deadline: float | None,
1398
+ monotonic: Callable[[], float],
1399
+ *,
1400
+ server_expiry: bool = False,
1376
1401
  ) -> None:
1377
- if deadline is not None and monotonic() >= deadline:
1402
+ """Raise once `deadline` has passed.
1403
+
1404
+ `server_expiry` says the deadline is the device code's own
1405
+ `expires_in`, so passing it raises `DeviceAuthorizationExpired`;
1406
+ otherwise it is the caller's `device_authorization_timeout` and
1407
+ passing it raises `DeviceAuthorizationTimeout`.
1408
+ """
1409
+ if deadline is None or monotonic() < deadline:
1410
+ return
1411
+ if server_expiry:
1378
1412
  msg = (
1379
- "Device authorization timed out before the user completed "
1380
- "the sign-in."
1413
+ "Device authorization expired: the device code's "
1414
+ "expires_in lapsed before the user completed the sign-in."
1381
1415
  )
1382
- raise DeviceAuthorizationTimeout(msg)
1416
+ raise DeviceAuthorizationExpired(msg)
1417
+ msg = (
1418
+ "Device authorization timed out: the caller's "
1419
+ "device_authorization_timeout lapsed before the user completed "
1420
+ "the sign-in."
1421
+ )
1422
+ raise DeviceAuthorizationTimeout(msg)
1383
1423
 
1384
1424
  def _dispatch_device_authorization_challenge(
1385
1425
  self,
@@ -1798,6 +1838,57 @@ class Reduction(
1798
1838
  _NAMESPACE = "api"
1799
1839
 
1800
1840
 
1841
+ class Dataset(_ListableONCatMixin):
1842
+ """The Datasets of an instrument: groups of reductions that belong
1843
+ together, read-only.
1844
+
1845
+ Every cataloged reduction belongs to exactly one Dataset, and every
1846
+ Dataset to exactly one experiment. ONCat decides a reduction's
1847
+ Dataset when the reduction file is ingested and stores its
1848
+ identifier as the reduction's `indexed.dataset`. The identifier
1849
+ takes one of two forms: `<experiment>/<name>`, such as
1850
+ `IPTS-20841/my-dataset`, for the reductions of an experiment whose
1851
+ reduction files give the same `dataset.name` (see
1852
+ `ReductionFileBuilder.set_dataset_name`); and the reduction file's
1853
+ location, such as
1854
+ `/SNS/REF_M/IPTS-20841/shared/autoreduce/REF_M_32025.json`, for any
1855
+ other reduction, autoreduced or not, which is a Dataset of its own.
1856
+ Datasets exist only on instruments where ONCat builds them; on any
1857
+ other instrument `list` returns an empty list.
1858
+
1859
+ The ONCat website calls a named Dataset a Reduction Set and an
1860
+ unnamed one a Reduction; both are this resource.
1861
+
1862
+ A Dataset record holds `id`, `name` (`None` when unnamed),
1863
+ `facility`, `instrument`, `experiment`, `reductions` and `runs`
1864
+ (each `{ranges, min, max, count}`: `ranges` lists `[first, last]`
1865
+ pairs of reduction keys or run numbers), `earliest` and `latest`
1866
+ (each `{created, ingested, modified}`), and `metadata`, a summary of
1867
+ the reductions' metadata whose leaves hold `min`, `max`, `values`
1868
+ (a list of strings, or the string `"TRUNCATED"` once a path has too
1869
+ many distinct strings to list), and `booleans` (the distinct
1870
+ booleans at the path, sorted: `[False]`, `[True]` or
1871
+ `[False, True]`; present only when a reduction holds a boolean
1872
+ there).
1873
+
1874
+ `retrieve` takes an identifier in either form, and `list`
1875
+ takes the `experiment`, `name`, and `run` filters and paging
1876
+ parameters; `run` keeps the Datasets whose `runs` ranges include
1877
+ that run, whatever their experiment.
1878
+ List a Dataset's reductions with `Reduction.list(..., dataset=...)`:
1879
+
1880
+ dataset = oncat.Dataset.retrieve(
1881
+ "IPTS-20841/my-dataset", facility="SNS", instrument="REF_M"
1882
+ )
1883
+ reductions = oncat.Reduction.list(
1884
+ facility="SNS", instrument="REF_M", dataset=dataset.id
1885
+ )
1886
+ """
1887
+
1888
+ _RESOURCE_ENDPOINT = "datasets"
1889
+ _NAMESPACE = "api"
1890
+
1891
+
1801
1892
  class Simulation(_ControllerONCatMixin, _ListableONCatMixin):
1802
1893
  _RESOURCE_ENDPOINT = "simulations"
1803
1894
  _NAMESPACE = "api"
@@ -2004,6 +2095,18 @@ def _yield_nodes(d, path, include_branches):
2004
2095
  yield node_path
2005
2096
 
2006
2097
 
2098
+ def _conflict_message(response):
2099
+ """Return the message a 409 response body carries, which names the
2100
+ state that refused the request, or the response's status line when
2101
+ the body holds none."""
2102
+ try:
2103
+ body = response.json()
2104
+ except ValueError:
2105
+ body = None
2106
+ message = body.get("message") if isinstance(body, dict) else None
2107
+ return message or f'Error: "{response.status_code} {response.reason}"'
2108
+
2109
+
2007
2110
  ########################################################################
2008
2111
  # Exceptions
2009
2112
  ########################################################################
@@ -2054,6 +2157,16 @@ class BadRequestError(PyONCatError):
2054
2157
  """
2055
2158
 
2056
2159
 
2160
+ class ConflictError(PyONCatError):
2161
+ """An error that is raised when a request conflicts with the current
2162
+ state of the item it acts on, such as submitting a DOI that is no
2163
+ longer drafting.
2164
+
2165
+ The message is the API's explanation. The same request can succeed
2166
+ once the item's state allows it, so check the item before retrying.
2167
+ """
2168
+
2169
+
2057
2170
  class DeviceAuthorizationError(PyONCatError):
2058
2171
  """An error that is raised when the OAuth 2.0 Device Authorization
2059
2172
  Grant (RFC 8628) flow cannot complete (for example, the user did not
@@ -2072,28 +2185,32 @@ class DeviceAuthorizationError(PyONCatError):
2072
2185
  # The four outcome subclasses below intentionally omit the `Error` suffix
2073
2186
  # (triggering N818): the names are the public 2.6 device-authorization
2074
2187
  # API contract
2075
- # (ADR-0097), spelled to read as outcomes the caller catches by reason --
2188
+ # (ADR-000097), spelled to read as outcomes the caller catches by reason --
2076
2189
  # Denied / Expired / Cancelled / Timeout. They subclass
2077
2190
  # `DeviceAuthorizationError`, so the suffixed base type still catches them.
2078
- class DeviceAuthorizationDenied(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-0097)
2191
+ class DeviceAuthorizationDenied(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-000097)
2079
2192
  """Raised when the user explicitly denied the sign-in request
2080
2193
  (the IdP returned `access_denied`, RFC 8628 §3.5)."""
2081
2194
 
2082
2195
 
2083
- class DeviceAuthorizationExpired(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-0097)
2196
+ class DeviceAuthorizationExpired(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-000097)
2084
2197
  """Raised when the device code expired before the user approved the
2085
- sign-in (the IdP returned `expired_token`, RFC 8628 §3.5)."""
2198
+ sign-in: the IdP returned `expired_token` (RFC 8628 §3.5), or the
2199
+ server's `expires_in` lapsed no later than the caller's
2200
+ `device_authorization_timeout`. The code is dead, so a new sign-in
2201
+ must start from a fresh device code."""
2086
2202
 
2087
2203
 
2088
- class DeviceAuthorizationCancelled(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-0097)
2204
+ class DeviceAuthorizationCancelled(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-000097)
2089
2205
  """Raised when the caller cancelled the sign-in in progress via the
2090
2206
  `cancel_event` passed to `login()`."""
2091
2207
 
2092
2208
 
2093
- class DeviceAuthorizationTimeout(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-0097)
2209
+ class DeviceAuthorizationTimeout(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-000097)
2094
2210
  """Raised when the sign-in did not complete before the overall
2095
- caller deadline (`device_authorization_timeout`) or the server's own
2096
- `expires_in`, whichever came first."""
2211
+ caller deadline (`device_authorization_timeout`) and that deadline
2212
+ came strictly before the server's own `expires_in`. A server expiry
2213
+ reached first raises `DeviceAuthorizationExpired` instead."""
2097
2214
 
2098
2215
 
2099
2216
  class DeviceAuthorizationNetworkError(DeviceAuthorizationError):
@@ -10,6 +10,51 @@ import sys
10
10
  import traceback
11
11
  from typing import Any
12
12
 
13
+ # PyONCat is installed without the rest of this repository, so the
14
+ # constants below copy ONCat's rules instead of importing them.
15
+
16
+ # ONCat takes a reduction file's facility and instrument from the first
17
+ # two folders of the file's location.
18
+ _LOCATION_REGEX = re.compile(
19
+ r"^/(?P<facility>HFIR|SNS)/(?P<instrument>[A-Z0-9_]+)/"
20
+ )
21
+
22
+ # ONCat's pattern for an instrument-level shared file: a file under
23
+ # /<FAC>/<INST>/shared/ with no IPTS-<n> folder on the way. ONCat
24
+ # accepts such a file as an input only when <FAC> and <INST> are those
25
+ # of the reduction file's own location.
26
+ _SHARED_INPUT_REGEX = re.compile(
27
+ r"^/(?P<facility>HFIR|SNS)/(?P<instrument>[A-Z0-9_]*)/shared/"
28
+ r"(?:(?!IPTS-\d+/)[^/]+/)*[^/]+$"
29
+ )
30
+
31
+ # A raw input inside an experiment: /<FAC>/<INST>/IPTS-<n>/.../<name>.
32
+ _RAW_INPUT_REGEX = re.compile(
33
+ r"^/(?P<facility>SNS|HFIR)/"
34
+ r"(?P<instrument>[A-Z0-9_]+)/"
35
+ r"(?P<experiment>IPTS-\d+)/"
36
+ r"(?:[^/]+/)*"
37
+ r"(?P<name>[^/]+)$"
38
+ )
39
+
40
+ _TRAILING_RUN_NUMBER_REGEX = re.compile(
41
+ r"_(\d+)(?:_event|_histo)?\.nxs(?:\.h5)?$"
42
+ )
43
+
44
+ # ONCat refuses a Dataset name with any of these segments, the text
45
+ # between two "/": a web address rewrites them, so the Dataset could not
46
+ # be opened by its address.
47
+ _REFUSED_DATASET_NAME_SEGMENTS = ("", ".", "..")
48
+
49
+
50
+ def _fspath(path: str | os.PathLike[str]) -> str:
51
+ """Return os.fspath(path) for a path object, and path itself otherwise.
52
+
53
+ The builder stores, validates, and writes locations as str, so a
54
+ pathlib.Path gives the same result as its str. Any other value,
55
+ bytes included, passes through as the caller gave it."""
56
+ return os.fspath(path) if isinstance(path, os.PathLike) else path
57
+
13
58
 
14
59
  class ReductionFileBuilder:
15
60
  """Builder for creating JSON Reduction Files for ONCat cataloging.
@@ -30,8 +75,9 @@ class ReductionFileBuilder:
30
75
  5. Write the JSON file to disk
31
76
 
32
77
  All mutator methods return `self` for convenient method chaining.
33
- Use `write_safe()` in autoreduction scripts to ensure cataloging
34
- failures do not break the reduction.
78
+ `write_safe()` never raises, so a failed write does not stop the rest
79
+ of the script; its stderr output marks an SNS autoreduction job as
80
+ failed.
35
81
 
36
82
  Example usage:
37
83
 
@@ -75,29 +121,46 @@ class ReductionFileBuilder:
75
121
  """
76
122
  self._user = user
77
123
  self._metadata = metadata if metadata is not None else {}
124
+ # Kept apart from metadata, so set_metadata() and
125
+ # update_metadata() never drop a name set_dataset_name() set.
126
+ self._dataset: dict[str, Any] | None = None
78
127
  self._input_files: list[dict[str, Any]] = []
79
128
  self._output_files: list[dict[str, Any]] = []
80
129
  self._validation_errors: list[str] = []
81
130
 
82
131
  def add_input(
83
132
  self,
84
- location: str,
133
+ location: str | os.PathLike[str],
85
134
  type: str,
86
135
  purpose: str,
87
136
  fields: dict[str, str] | None = None,
88
- relative_to: str | None = None,
137
+ relative_to: str | os.PathLike[str] | None = None,
89
138
  ) -> ReductionFileBuilder:
90
139
  """Add an input file to the reduction.
91
140
 
141
+ An entry identical to one already added (same resolved location,
142
+ type, purpose, and fields) is skipped, so a second reduction pass
143
+ in the same job can add the same files again.
144
+
92
145
  Arguments:
93
146
  ---------
94
- location: str
147
+ location: str | os.PathLike[str]
95
148
  Path to the input file. Must be absolute, or relative if
96
- relative_to is provided.
149
+ relative_to is provided. An input may also be a shared
150
+ file of the reduction's own instrument, under
151
+ /<FAC>/<INST>/shared/ where <FAC> and <INST> are the first
152
+ two folders of the reduction file's location (for example
153
+ a grouping file); ONCat stores such a file by reference
154
+ only, listing it in the reduction without cataloging it as
155
+ a datafile. validate() and write() refuse a shared file of
156
+ another facility or instrument whenever the reduction
157
+ file's location is known: the path passed to write(), or
158
+ the path write() derives from the first raw sample-data
159
+ input.
97
160
 
98
161
  type: str
99
162
  File type. Must be one of: "raw", "processed",
100
- "user-provided", "history".
163
+ "user-provided", "history", "simulation".
101
164
 
102
165
  purpose: str
103
166
  File purpose in kebab-case (e.g., "sample-data",
@@ -109,7 +172,7 @@ class ReductionFileBuilder:
109
172
  "source.path": "dest_path" for extracting metadata.
110
173
  Primarily used for processed files.
111
174
 
112
- relative_to: str | None
175
+ relative_to: str | os.PathLike[str] | None
113
176
  Base directory for relative paths. If provided and
114
177
  location is relative, will be resolved to absolute path.
115
178
 
@@ -142,7 +205,10 @@ class ReductionFileBuilder:
142
205
  )
143
206
 
144
207
  """
145
- resolved_location = self._resolve_path(location, relative_to)
208
+ resolved_location = self._resolve_path(
209
+ _fspath(location),
210
+ None if relative_to is None else _fspath(relative_to),
211
+ )
146
212
 
147
213
  file_dict: dict[str, Any] = {
148
214
  "location": resolved_location,
@@ -154,19 +220,25 @@ class ReductionFileBuilder:
154
220
  if fields:
155
221
  file_dict["fields"] = fields
156
222
 
157
- self._input_files.append(file_dict)
223
+ # The reduction file schema requires unique entries in each list
224
+ if file_dict not in self._input_files:
225
+ self._input_files.append(file_dict)
158
226
  return self
159
227
 
160
228
  def add_output(
161
229
  self,
162
- location: str,
230
+ location: str | os.PathLike[str],
163
231
  type: str,
164
232
  purpose: str,
165
233
  fields: dict[str, str] | None = None,
166
- relative_to: str | None = None,
234
+ relative_to: str | os.PathLike[str] | None = None,
167
235
  ) -> ReductionFileBuilder:
168
236
  """Add an output file to the reduction.
169
237
 
238
+ An entry identical to one already added (same resolved location,
239
+ type, purpose, and fields) is skipped, so a second reduction pass
240
+ in the same job can add the same files again.
241
+
170
242
  The 'fields' parameter is primarily used for output files to extract
171
243
  specific metadata from processed NeXus files, for example:
172
244
 
@@ -177,13 +249,13 @@ class ReductionFileBuilder:
177
249
 
178
250
  Arguments:
179
251
  ---------
180
- location: str
252
+ location: str | os.PathLike[str]
181
253
  Path to the output file. Must be absolute, or relative if
182
254
  relative_to is provided.
183
255
 
184
256
  type: str
185
257
  File type. Must be one of: "raw", "processed",
186
- "user-provided", "history".
258
+ "user-provided", "history", "simulation".
187
259
 
188
260
  purpose: str
189
261
  File purpose in kebab-case (e.g., "reduced-data",
@@ -195,7 +267,7 @@ class ReductionFileBuilder:
195
267
  "source.path": "dest_path" for extracting metadata.
196
268
  Commonly used for processed output files.
197
269
 
198
- relative_to: str | None
270
+ relative_to: str | os.PathLike[str] | None
199
271
  Base directory for relative paths. If provided and
200
272
  location is relative, will be resolved to absolute path.
201
273
 
@@ -221,7 +293,10 @@ class ReductionFileBuilder:
221
293
  )
222
294
 
223
295
  """
224
- resolved_location = self._resolve_path(location, relative_to)
296
+ resolved_location = self._resolve_path(
297
+ _fspath(location),
298
+ None if relative_to is None else _fspath(relative_to),
299
+ )
225
300
 
226
301
  file_dict: dict[str, Any] = {
227
302
  "location": resolved_location,
@@ -233,7 +308,9 @@ class ReductionFileBuilder:
233
308
  if fields:
234
309
  file_dict["fields"] = fields
235
310
 
236
- self._output_files.append(file_dict)
311
+ # The reduction file schema requires unique entries in each list
312
+ if file_dict not in self._output_files:
313
+ self._output_files.append(file_dict)
237
314
  return self
238
315
 
239
316
  def set_metadata(self, metadata: dict[str, Any]) -> ReductionFileBuilder:
@@ -316,6 +393,59 @@ class ReductionFileBuilder:
316
393
  """
317
394
  return self._metadata.copy()
318
395
 
396
+ def set_dataset_name(self, name: str) -> ReductionFileBuilder:
397
+ """Set the dataset name, written as the reduction file's
398
+ top-level "dataset": {"name": name}.
399
+
400
+ Reductions in one experiment that carry the same dataset name
401
+ belong to one Dataset, which the ONCat website calls a Reduction
402
+ Set. Names count exactly as written, so names that differ only
403
+ in case or spacing make two sets. A reduction without a name is
404
+ a Dataset of its own, which the website calls a Reduction.
405
+
406
+ The name is kept apart from the metadata, so set_metadata() and
407
+ update_metadata() leave it in place, whichever order they are
408
+ called in.
409
+
410
+ Arguments:
411
+ ---------
412
+ name: str
413
+ Dataset name. Must be a non-empty string in which no
414
+ part between slashes is empty, "." or "..", since a web
415
+ address rewrites such a part: "a/b", ".a" and "..." are
416
+ names, but "..", "a/../b", "a//b", "/a" and "a/" are not.
417
+
418
+ Returns:
419
+ -------
420
+ Self for method chaining. An invalid name is not set (any
421
+ name set earlier is kept); the error is stored internally
422
+ and surfaced by validate().
423
+
424
+ Example:
425
+ builder.set_dataset_name("my-dataset")
426
+ builder.set_metadata({"ei": 100.0})
427
+ builder.to_dict()["dataset"] # {"name": "my-dataset"}
428
+
429
+ """
430
+ if not isinstance(name, str) or not name:
431
+ msg = f"Dataset name must be a non-empty string: {name!r}"
432
+ self._validation_errors.append(msg)
433
+ return self
434
+
435
+ if any(
436
+ segment in _REFUSED_DATASET_NAME_SEGMENTS
437
+ for segment in name.split("/")
438
+ ):
439
+ msg = (
440
+ "Dataset name must have no empty, '.' or '..' part between "
441
+ f"slashes: {name!r}"
442
+ )
443
+ self._validation_errors.append(msg)
444
+ return self
445
+
446
+ self._dataset = {"name": name}
447
+ return self
448
+
319
449
  def clear_outputs(self) -> ReductionFileBuilder:
320
450
  """Remove all output files from the builder.
321
451
 
@@ -351,11 +481,19 @@ class ReductionFileBuilder:
351
481
  - At least one output file present
352
482
  - User is set and non-empty
353
483
  - Metadata is a dict
354
- - If auto-deriving path: at least one input is type="raw" and
355
- purpose="sample-data"
356
484
  - All paths must be absolute and canonical (`/SNS/...` or
357
485
  `/HFIR/...` for IPTS directories, or `/SNS/...` or `/HFIR/...`
358
486
  for shared directories like /SNS/ARCS/shared/...)
487
+ - An input under an instrument's shared directory,
488
+ /<FAC>/<INST>/shared/, belongs to the facility and instrument
489
+ that are the first two folders of the reduction file's
490
+ location. validate() knows that location only as the path
491
+ write() derives from the first raw sample-data input, and
492
+ leaves this check to ONCat when it cannot derive one.
493
+ write(path) checks against the path it is given.
494
+
495
+ A raw sample-data input is not required: write() needs one only
496
+ to derive a path when it is given none.
359
497
 
360
498
  Does NOT validate:
361
499
 
@@ -378,51 +516,7 @@ class ReductionFileBuilder:
378
516
  builder.write()
379
517
 
380
518
  """
381
- errors = list(self._validation_errors) # Copy accumulated errors
382
-
383
- # Check for inputs and outputs
384
- if not self._input_files:
385
- errors.append("At least one input file is required")
386
-
387
- if not self._output_files:
388
- errors.append("At least one output file is required")
389
-
390
- # Check user
391
- if not self._user:
392
- errors.append("User must be set and non-empty")
393
-
394
- # Check metadata is a dict
395
- if not isinstance(self._metadata, dict):
396
- errors.append("Metadata must be a dictionary")
397
-
398
- # Check for raw sample-data input (needed for auto-derivation)
399
- has_raw_sample = any(
400
- f.get("type") == "raw" and f.get("purpose") == "sample-data"
401
- for f in self._input_files
402
- )
403
- if not has_raw_sample:
404
- errors.append(
405
- "At least one input file with type='raw' and "
406
- "purpose='sample-data' is required for path auto-derivation"
407
- )
408
-
409
- # Validate canonical paths
410
- for file_list, file_type in [
411
- (self._input_files, "input"),
412
- (self._output_files, "output"),
413
- ]:
414
- for file_dict in file_list:
415
- location = file_dict.get("location", "")
416
- if not location.startswith(
417
- "/SNS/"
418
- ) and not location.startswith("/HFIR/"):
419
- msg = (
420
- f"Path must be canonical (start with /SNS/ or "
421
- f"/HFIR/): {location} ({file_type} file)"
422
- )
423
- errors.append(msg)
424
-
425
- return errors
519
+ return self._validate(self._derived_location())
426
520
 
427
521
  def to_dict(self) -> dict[str, Any]:
428
522
  """Preview the JSON structure that will be written (without timestamp).
@@ -431,6 +525,8 @@ class ReductionFileBuilder:
431
525
  -------
432
526
  Dictionary representing the reduction file structure. Does not
433
527
  include the 'created' timestamp which is generated on write().
528
+ Includes "dataset" only after set_dataset_name() has set a
529
+ name, or after load() of a file whose name it accepts.
434
530
 
435
531
  Example:
436
532
  import json
@@ -438,22 +534,32 @@ class ReductionFileBuilder:
438
534
  print(json.dumps(preview, indent=2))
439
535
 
440
536
  """
441
- return {
537
+ content = {
442
538
  "version": "1.0",
443
539
  "input_files": self._input_files,
444
540
  "output_files": self._output_files,
445
541
  "user": self._user,
446
542
  "metadata": self._metadata,
447
543
  }
544
+ if self._dataset is not None:
545
+ content["dataset"] = self._dataset
546
+
547
+ return content
448
548
 
449
- def write(self, path: str | None = None, overwrite: bool = True) -> str:
549
+ def write(
550
+ self,
551
+ path: str | os.PathLike[str] | None = None,
552
+ overwrite: bool = True,
553
+ ) -> str:
450
554
  """Write the reduction file to disk.
451
555
 
452
556
  Behavior:
453
557
 
454
- 1. Validates builder state via validate()
455
- 2. Generates RFC3339 timestamp with timezone
456
- 3. Derives path if not provided (from first raw sample-data input)
558
+ 1. Validates builder state as validate() does, checking shared
559
+ inputs against path, or against the derived path when path
560
+ is None
561
+ 2. Derives path if not provided (from first raw sample-data input)
562
+ 3. Generates RFC3339 timestamp with timezone
457
563
  4. Creates parent directories if needed
458
564
  5. Writes JSON file with 4-space indentation, sorted keys
459
565
  6. Returns absolute path to written file
@@ -462,12 +568,19 @@ class ReductionFileBuilder:
462
568
 
463
569
  /{facility}/{inst}/{experiment}/shared/autoreduce/{inst}_{run}.json
464
570
 
571
+ {facility}, {inst}, and {experiment} are the first three folders
572
+ of the raw sample-data input's path. {run} is the digits after a
573
+ leading "{inst}_" in its file name (REF_M_32025.nxs.h5 is run
574
+ 32025), otherwise the digits before an optional _event or _histo
575
+ and the NeXus extension.
576
+
465
577
  Arguments:
466
578
  ---------
467
- path: str | None
579
+ path: str | os.PathLike[str] | None
468
580
  Optional explicit path to write to. If None, auto-derives
469
581
  path from first input file with type="raw" and
470
- purpose="sample-data".
582
+ purpose="sample-data", and raises ValueError when there is
583
+ no such input.
471
584
 
472
585
  overwrite: bool
473
586
  Whether to overwrite existing file. Default: True
@@ -479,7 +592,9 @@ class ReductionFileBuilder:
479
592
  Raises:
480
593
  ------
481
594
  ValueError
482
- If validation fails or path cannot be auto-derived.
595
+ If validation fails, or if path is None and cannot be
596
+ auto-derived: "no raw sample-data input to derive a path
597
+ from; pass a path" when there is no raw sample-data input.
483
598
 
484
599
  FileExistsError
485
600
  If file exists and overwrite=False.
@@ -499,8 +614,13 @@ class ReductionFileBuilder:
499
614
  path = builder.write(overwrite=False) # Raises if exists
500
615
 
501
616
  """
617
+ if path is not None:
618
+ path = _fspath(path)
619
+
502
620
  # 1. Validate
503
- errors = self.validate()
621
+ errors = self._validate(
622
+ path if path is not None else self._derived_location()
623
+ )
504
624
  if errors:
505
625
  error_list = "\n".join(f" - {e}" for e in errors)
506
626
  msg = f"Validation failed:\n{error_list}"
@@ -516,14 +636,7 @@ class ReductionFileBuilder:
516
636
  raise FileExistsError(msg)
517
637
 
518
638
  # 4. Build JSON structure
519
- content = {
520
- "version": "1.0",
521
- "input_files": self._input_files,
522
- "output_files": self._output_files,
523
- "created": self._generate_timestamp(),
524
- "user": self._user,
525
- "metadata": self._metadata,
526
- }
639
+ content = {**self.to_dict(), "created": self._generate_timestamp()}
527
640
 
528
641
  # 5. Create directories
529
642
  os.makedirs(os.path.dirname(path), exist_ok=True)
@@ -535,17 +648,26 @@ class ReductionFileBuilder:
535
648
  return path
536
649
 
537
650
  def write_safe(
538
- self, path: str | None = None, overwrite: bool = True
651
+ self,
652
+ path: str | os.PathLike[str] | None = None,
653
+ overwrite: bool = True,
539
654
  ) -> str | None:
540
- """Write the reduction file to disk with error handling. Never raises.
655
+ """Write the reduction file like write(), but never raise.
541
656
 
542
- This is a convenience wrapper around write() that catches all
543
- exceptions and prints them to stderr. Use this in autoreduction
544
- scripts where cataloging failures should not break the reduction.
657
+ write_safe() writes the same file to the same path as write(),
658
+ and catches every exception write() raises, so the rest of the
659
+ script keeps running. On failure it prints the error and its
660
+ traceback to stderr and returns None.
661
+
662
+ write_safe() only writes the file; it does not catalog it. At
663
+ SNS, the post-processing agent catalogs the reduction file after
664
+ the autoreduction job succeeds. In an SNS autoreduction job, the
665
+ stderr output of a failed write marks the job as failed: the web
666
+ monitor shows it as failed, and the reduction is not cataloged.
545
667
 
546
668
  Arguments:
547
669
  ---------
548
- path: str | None
670
+ path: str | os.PathLike[str] | None
549
671
  Same as write().
550
672
 
551
673
  overwrite: bool
@@ -556,32 +678,34 @@ class ReductionFileBuilder:
556
678
  Absolute path to written file on success, None on failure.
557
679
 
558
680
  Example:
559
- # In autoreduction script - safe, won't crash reduction
681
+ # In an autoreduction script: a failed write does not stop
682
+ # the script, and its stderr output marks the job as failed
560
683
  path = builder.write_safe()
561
684
  if path:
562
- print(f"Reduction cataloged: {path}")
685
+ print(f"Reduction file written: {path}")
563
686
  else:
564
- print("Warning: Cataloging failed (see stderr)")
687
+ print("Warning: reduction file not written (see stderr)")
565
688
 
566
689
  """
567
690
  try:
568
691
  return self.write(path, overwrite)
569
692
  except Exception as e: # ruff: ignore[blind-except]
570
- # Intentionally catch all exceptions - design requirement for
571
- # autoreduction scripts where cataloging failures should never
572
- # break the reduction workflow
693
+ # Intentionally catch every exception so a failed write never
694
+ # stops the rest of the autoreduction script. The stderr
695
+ # output below is what marks an SNS autoreduction job as
696
+ # failed.
573
697
  msg = f"Warning: Failed to catalog reduction: {e}"
574
698
  print(msg, file=sys.stderr)
575
699
  traceback.print_exc()
576
700
  return None
577
701
 
578
702
  @classmethod
579
- def load(cls, path: str) -> ReductionFileBuilder:
703
+ def load(cls, path: str | os.PathLike[str]) -> ReductionFileBuilder:
580
704
  """Load an existing reduction file for modification.
581
705
 
582
706
  Arguments:
583
707
  ---------
584
- path: str
708
+ path: str | os.PathLike[str]
585
709
  Path to existing JSON reduction file.
586
710
 
587
711
  Returns:
@@ -596,6 +720,10 @@ class ReductionFileBuilder:
596
720
  ValueError
597
721
  If file is not valid JSON or missing required fields.
598
722
 
723
+ The file's dataset name goes through set_dataset_name()'s check.
724
+ A name it refuses is not loaded, validate() reports it, and
725
+ write() raises.
726
+
599
727
  Example:
600
728
  # Load and modify existing file
601
729
  builder = ReductionFileBuilder.load(
@@ -607,6 +735,7 @@ class ReductionFileBuilder:
607
735
  builder.write() # Overwrites original file
608
736
 
609
737
  """
738
+ path = _fspath(path)
610
739
  if not os.path.exists(path):
611
740
  msg = f"Reduction file not found: {path}"
612
741
  raise FileNotFoundError(msg)
@@ -623,6 +752,11 @@ class ReductionFileBuilder:
623
752
 
624
753
  # Create builder
625
754
  builder = cls(user=content["user"], metadata=content["metadata"])
755
+ dataset = content.get("dataset")
756
+ if dataset is not None:
757
+ builder.set_dataset_name(
758
+ dataset.get("name") if isinstance(dataset, dict) else None
759
+ )
626
760
 
627
761
  # Add files (without fields if not present)
628
762
  for f in content["input_files"]:
@@ -666,12 +800,82 @@ class ReductionFileBuilder:
666
800
  self._validation_errors.append(msg)
667
801
  return location
668
802
 
803
+ def _validate(self, reduction_location: str | None) -> list[str]:
804
+ """Return validate()'s errors for a reduction file at
805
+ reduction_location; with None, shared inputs are not checked."""
806
+ errors = list(self._validation_errors) # Copy accumulated errors
807
+
808
+ # Check for inputs and outputs
809
+ if not self._input_files:
810
+ errors.append("At least one input file is required")
811
+
812
+ if not self._output_files:
813
+ errors.append("At least one output file is required")
814
+
815
+ # Check user
816
+ if not self._user:
817
+ errors.append("User must be set and non-empty")
818
+
819
+ # Check metadata is a dict
820
+ if not isinstance(self._metadata, dict):
821
+ errors.append("Metadata must be a dictionary")
822
+
823
+ # Validate canonical paths
824
+ for file_list, file_type in [
825
+ (self._input_files, "input"),
826
+ (self._output_files, "output"),
827
+ ]:
828
+ for file_dict in file_list:
829
+ location = file_dict.get("location", "")
830
+ if not location.startswith(
831
+ "/SNS/"
832
+ ) and not location.startswith("/HFIR/"):
833
+ msg = (
834
+ f"Path must be canonical (start with /SNS/ or "
835
+ f"/HFIR/): {location} ({file_type} file)"
836
+ )
837
+ errors.append(msg)
838
+
839
+ # Check shared inputs belong to the reduction file's instrument
840
+ owner = (
841
+ _LOCATION_REGEX.match(os.fspath(reduction_location))
842
+ if reduction_location is not None
843
+ else None
844
+ )
845
+ if owner:
846
+ facility = owner.group("facility")
847
+ instrument = owner.group("instrument")
848
+ for file_dict in self._input_files:
849
+ shared = _SHARED_INPUT_REGEX.match(
850
+ file_dict.get("location", "")
851
+ )
852
+ if shared and (
853
+ shared.group("facility") != facility
854
+ or shared.group("instrument") != instrument
855
+ ):
856
+ msg = (
857
+ f"Shared input must belong to the reduction "
858
+ f"file's instrument, under "
859
+ f"/{facility}/{instrument}/shared/: "
860
+ f"{file_dict['location']} (input file)"
861
+ )
862
+ errors.append(msg)
863
+
864
+ return errors
865
+
866
+ def _derived_location(self) -> str | None:
867
+ """Return the path write() derives, or None when it cannot
868
+ derive one."""
869
+ try:
870
+ return self._derive_path()
871
+ except ValueError:
872
+ return None
873
+
669
874
  def _derive_path(self) -> str:
670
875
  """Auto-derive JSON file path from first raw sample-data input.
671
876
 
672
- Self-contained: duplicates a minimal regex for raw NeXus paths to
673
- extract facility, instrument, IPTS, and run_number. No imports from
674
- other parts of this repository.
877
+ Raises ValueError when there is no such input, or when its path
878
+ names no facility, instrument, experiment, and run.
675
879
  """
676
880
  # Find first raw sample-data input
677
881
  raw_sample = None
@@ -681,25 +885,16 @@ class ReductionFileBuilder:
681
885
  break
682
886
 
683
887
  if not raw_sample:
684
- msg = (
685
- "Cannot auto-derive path: no input file with type='raw' "
686
- "and purpose='sample-data'. Either add one or provide "
687
- "explicit path."
688
- )
888
+ msg = "no raw sample-data input to derive a path from; pass a path"
689
889
  raise ValueError(msg)
690
890
 
691
- raw_nexus_regex = re.compile(
692
- r"^/(?P<facility>SNS|HFIR)/"
693
- r"(?P<instrument>[A-Z0-9_]+)/"
694
- r"IPTS-(?P<ipts>\d+)/"
695
- r"(?:[^/]+/)*"
696
- r"[^/_]*_(?P<run_number>\d+)"
697
- r"(?:_event|_histo)?"
698
- r"\.(?:nxs(?:\.h5)?)$"
891
+ match = _RAW_INPUT_REGEX.match(raw_sample)
892
+ run_number = (
893
+ self._run_number(match.group("name"), match.group("instrument"))
894
+ if match
895
+ else None
699
896
  )
700
-
701
- match = raw_nexus_regex.search(raw_sample)
702
- if not match:
897
+ if match is None or run_number is None:
703
898
  msg = (
704
899
  f"Cannot parse facility/instrument/experiment from: "
705
900
  f"{raw_sample}"
@@ -708,15 +903,25 @@ class ReductionFileBuilder:
708
903
 
709
904
  facility = match.group("facility")
710
905
  instrument = match.group("instrument")
711
- ipts = match.group("ipts")
712
- run_number = match.group("run_number")
713
- experiment = f"IPTS-{ipts}"
906
+ experiment = match.group("experiment")
714
907
 
715
908
  return (
716
909
  f"/{facility}/{instrument}/{experiment}/shared/autoreduce/"
717
910
  f"{instrument}_{run_number}.json"
718
911
  )
719
912
 
913
+ @staticmethod
914
+ def _run_number(name: str, instrument: str) -> str | None:
915
+ """Return the run in a raw file's name, read as ONCat reads a
916
+ reduction file's name: the digits after a leading
917
+ "<instrument>_" (REF_M_32025_event.nxs is run 32025), otherwise
918
+ the digits before an optional _event or _histo and the NeXus
919
+ extension; None when neither is there."""
920
+ run = re.match(
921
+ rf"{re.escape(instrument)}_(\d+)", name
922
+ ) or _TRAILING_RUN_NUMBER_REGEX.search(name)
923
+ return run.group(1) if run else None
924
+
720
925
  @staticmethod
721
926
  def _generate_timestamp() -> str:
722
927
  """Generate RFC3339 timestamp with timezone."""
@@ -1,13 +1,13 @@
1
1
  [build-system]
2
- requires = ["poetry"]
3
- build-backend = "poetry.masonry.api"
2
+ requires = ["poetry-core"]
3
+ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  # Poetry requires it's own condfiguration because it predates the
6
6
  # "[project]" standard. See:
7
7
  # https://github.com/python-poetry/roadmap/issues/3
8
8
  [tool.poetry]
9
9
  name = "pyoncat"
10
- version = "2.7"
10
+ version = "2.9"
11
11
  description = "A Python Client for ONCat (the ORNL Neutron Catalog)."
12
12
  authors = ["ONCat Support <oncat-support@ornl.gov>"]
13
13
  readme = ["pyoncat/README.md", "pyoncat/LICENSE"]
File without changes