pyoncat 2.6__tar.gz → 2.8__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,20 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyoncat
3
- Version: 2.6
3
+ Version: 2.8
4
4
  Summary: A Python Client for ONCat (the ORNL Neutron Catalog).
5
5
  Keywords: ornl,neutron,metadata,catalog
6
6
  Author: ONCat Support
7
7
  Author-email: oncat-support@ornl.gov
8
- Requires-Python: >=3.7,<4
8
+ Requires-Python: >=3.9,<4
9
9
  Classifier: Programming Language :: Python :: 3
10
- Classifier: Programming Language :: Python :: 3.7
11
- Classifier: Programming Language :: Python :: 3.8
12
10
  Classifier: Programming Language :: Python :: 3.9
13
11
  Classifier: Programming Language :: Python :: 3.10
14
12
  Classifier: Programming Language :: Python :: 3.11
15
13
  Classifier: Programming Language :: Python :: 3.12
16
14
  Classifier: Programming Language :: Python :: 3.13
17
15
  Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Programming Language :: Python :: 3.15
18
17
  Requires-Dist: requests
19
18
  Project-URL: Homepage, https://oncat.ornl.gov
20
19
  Description-Content-Type: text/markdown
@@ -59,6 +58,10 @@ Alternatively, a Conda package is available up on [Anaconda](https://anaconda.or
59
58
 
60
59
  To use the authenticated version of the API, you need to obtain credentials from an ONCat administrator. Please contact ONCat Support at [oncat-support@ornl.gov](mailto:oncat-support@ornl.gov) to request credentials.
61
60
 
61
+ ### Request headers
62
+
63
+ Every request PyONCat makes carries two headers: `User-Agent: pyoncat/<version> requests/<version> Python/<major.minor>`, naming PyONCat, the `requests` library, and the Python major.minor version, and `API-Client: v1 pyoncat/<version>`. They let server logs tell PyONCat traffic from other clients.
64
+
62
65
  ### Examples
63
66
 
64
67
  For usage examples, please refer to the API documentation at [oncat.ornl.gov](https://oncat.ornl.gov).
@@ -38,6 +38,10 @@ Alternatively, a Conda package is available up on [Anaconda](https://anaconda.or
38
38
 
39
39
  To use the authenticated version of the API, you need to obtain credentials from an ONCat administrator. Please contact ONCat Support at [oncat-support@ornl.gov](mailto:oncat-support@ornl.gov) to request credentials.
40
40
 
41
+ ### Request headers
42
+
43
+ Every request PyONCat makes carries two headers: `User-Agent: pyoncat/<version> requests/<version> Python/<major.minor>`, naming PyONCat, the `requests` library, and the Python major.minor version, and `API-Client: v1 pyoncat/<version>`. They let server logs tell PyONCat traffic from other clients.
44
+
41
45
  ### Examples
42
46
 
43
47
  For usage examples, please refer to the API documentation at [oncat.ornl.gov](https://oncat.ornl.gov).
@@ -34,7 +34,18 @@ from pyoncat.reduction import ReductionFileBuilder
34
34
  if TYPE_CHECKING:
35
35
  import threading
36
36
 
37
- __version__ = "2.6"
37
+ __version__ = "2.8"
38
+
39
+ # Sent on every request PyONCat makes, API calls and token requests alike,
40
+ # so server logs can tell PyONCat traffic from other clients.
41
+ _USER_AGENT = (
42
+ f"pyoncat/{__version__} requests/{requests.__version__} "
43
+ f"Python/{sys.version_info.major}.{sys.version_info.minor}"
44
+ )
45
+ _CLIENT_HEADERS = {
46
+ "User-Agent": _USER_AGENT,
47
+ "API-Client": f"v1 pyoncat/{__version__}",
48
+ }
38
49
 
39
50
  # Constant for autoreduction user
40
51
  AUTOREDUCTION_USER = "auto"
@@ -47,6 +58,7 @@ __all__ = [
47
58
  "REAUTH_NEVER",
48
59
  "REAUTH_PROMPT",
49
60
  "RESOURCE_OWNER_CREDENTIALS_FLOW",
61
+ "ConflictError",
50
62
  "DeviceAuthorizationCancelled",
51
63
  "DeviceAuthorizationChallenge",
52
64
  "DeviceAuthorizationDenied",
@@ -398,12 +410,12 @@ class ONCat:
398
410
  Cycle: Cycle
399
411
  Datafile: Datafile
400
412
  Reduction: Reduction
413
+ Dataset: Dataset
401
414
  Simulation: Simulation
402
415
  User: User
403
416
  Task: Task
404
417
  Info: Info
405
418
  Cache: Cache
406
- Usage: Usage
407
419
  Template: Template
408
420
 
409
421
  def __init__(
@@ -525,14 +537,17 @@ class ONCat:
525
537
  timeout=self._timeout,
526
538
  )
527
539
  else:
540
+ auth_headers = (
541
+ {"Authorization": "Bearer " + self._api_token}
542
+ if self._api_token
543
+ else {}
544
+ )
528
545
  response = getattr(requests, method)(
529
546
  full_url,
530
547
  params=kwargs,
531
548
  json=data,
532
549
  verify=self._should_verify(),
533
- headers={"Authorization": "Bearer " + self._api_token}
534
- if self._api_token
535
- else None,
550
+ headers={**_CLIENT_HEADERS, **auth_headers},
536
551
  timeout=self._timeout,
537
552
  )
538
553
  try:
@@ -547,6 +562,9 @@ class ONCat:
547
562
  if error.response.status_code == 404:
548
563
  msg = f'Could not find resource at "{full_url}"'
549
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
550
568
  msg = f'Error: "{error!s}"'
551
569
  raise PyONCatError(msg) from error
552
570
 
@@ -750,7 +768,7 @@ class ONCat:
750
768
 
751
769
  Revocation uses the configured public `client_id` and no secret
752
770
  (the device-authorization client is a public client, RFC 8628
753
- / ADR-0058).
771
+ / ADR-000058).
754
772
 
755
773
  Per RFC 7009 §2.2 the revocation endpoint returns HTTP 200 even for
756
774
  a token that is already invalid, so a 200 confirms the request was
@@ -799,6 +817,7 @@ class ONCat:
799
817
  verify=self._should_verify(),
800
818
  timeout=post_timeout,
801
819
  headers={
820
+ **_CLIENT_HEADERS,
802
821
  "Content-Type": "application/x-www-form-urlencoded",
803
822
  "Accept": "application/json",
804
823
  },
@@ -975,6 +994,7 @@ class ONCat:
975
994
  ),
976
995
  scope=self._scopes,
977
996
  )
997
+ self._oauth_client.headers.update(_CLIENT_HEADERS)
978
998
  try:
979
999
  token = self._oauth_client.fetch_token(
980
1000
  self._url + "/oauth/token",
@@ -1012,6 +1032,7 @@ class ONCat:
1012
1032
  client_secret=self._client_secret,
1013
1033
  )
1014
1034
  )
1035
+ initial_oauth_client.headers.update(_CLIENT_HEADERS)
1015
1036
 
1016
1037
  try:
1017
1038
  token = initial_oauth_client.fetch_token(
@@ -1060,6 +1081,7 @@ class ONCat:
1060
1081
  auto_refresh_kwargs=auto_refresh_kwargs,
1061
1082
  token_updater=self._save_token,
1062
1083
  )
1084
+ self._oauth_client.headers.update(_CLIENT_HEADERS)
1063
1085
 
1064
1086
  def _login_device_authorization(
1065
1087
  self,
@@ -1121,7 +1143,7 @@ class ONCat:
1121
1143
  device_auth_body["scope"] = scope_str
1122
1144
 
1123
1145
  # The caller's `device_authorization_timeout` bounds the WHOLE sign-in
1124
- # (ADR-0097), so the deadline clock starts BEFORE the initial
1146
+ # (ADR-000097), so the deadline clock starts BEFORE the initial
1125
1147
  # device-authorization POST. A slow initial POST therefore counts
1126
1148
  # against the overall budget. `http_timeout` still caps each
1127
1149
  # individual POST; the caller deadline is the cumulative bound.
@@ -1142,6 +1164,7 @@ class ONCat:
1142
1164
  verify=self._should_verify(),
1143
1165
  timeout=post_timeout,
1144
1166
  headers={
1167
+ **_CLIENT_HEADERS,
1145
1168
  "Content-Type": "application/x-www-form-urlencoded",
1146
1169
  "Accept": "application/json",
1147
1170
  },
@@ -1230,6 +1253,7 @@ class ONCat:
1230
1253
  verify=self._should_verify(),
1231
1254
  timeout=post_timeout,
1232
1255
  headers={
1256
+ **_CLIENT_HEADERS,
1233
1257
  "Content-Type": "application/x-www-form-urlencoded",
1234
1258
  "Accept": "application/json",
1235
1259
  },
@@ -1397,6 +1421,7 @@ class ONCat:
1397
1421
  },
1398
1422
  token_updater=self._save_token,
1399
1423
  )
1424
+ self._oauth_client.headers.update(_CLIENT_HEADERS)
1400
1425
 
1401
1426
 
1402
1427
  class ONCatObject:
@@ -1556,6 +1581,12 @@ ONCatRepresentation = ONCatObject
1556
1581
  ########################################################################
1557
1582
 
1558
1583
 
1584
+ # TODO: The REST documentation page derives the PyONCat class and method
1585
+ # in each code example from the resource's namespace and path, without
1586
+ # consulting this module. Where a resource has no class here, or the
1587
+ # class lacks the method, the example does not run. Decide how the
1588
+ # documentation should learn what this module provides or otherwise
1589
+ # resolve this mismatch.
1559
1590
  class _ONCatMixinMeta(type):
1560
1591
  REGISTERED_RESOURCES: ClassVar[list] = []
1561
1592
 
@@ -1719,7 +1750,7 @@ class Grouping(_ListableONCatMixin):
1719
1750
 
1720
1751
  class Mapping(_ListableONCatMixin):
1721
1752
  _RESOURCE_ENDPOINT = "mappings"
1722
- _NAMESPACE = "settings"
1753
+ _NAMESPACE = "option"
1723
1754
 
1724
1755
 
1725
1756
  class Proposal(_ListableONCatMixin):
@@ -1772,6 +1803,57 @@ class Reduction(
1772
1803
  _NAMESPACE = "api"
1773
1804
 
1774
1805
 
1806
+ class Dataset(_ListableONCatMixin):
1807
+ """The Datasets of an instrument: groups of reductions that belong
1808
+ together, read-only.
1809
+
1810
+ Every cataloged reduction belongs to exactly one Dataset, and every
1811
+ Dataset to exactly one experiment. ONCat decides a reduction's
1812
+ Dataset when the reduction file is ingested and stores its
1813
+ identifier as the reduction's `indexed.dataset`. The identifier
1814
+ takes one of two forms: `<experiment>/<name>`, such as
1815
+ `IPTS-20841/my-dataset`, for the reductions of an experiment whose
1816
+ reduction files give the same `dataset.name` (see
1817
+ `ReductionFileBuilder.set_dataset_name`); and the reduction file's
1818
+ location, such as
1819
+ `/SNS/REF_M/IPTS-20841/shared/autoreduce/REF_M_32025.json`, for any
1820
+ other reduction, autoreduced or not, which is a Dataset of its own.
1821
+ Datasets exist only on instruments where ONCat builds them; on any
1822
+ other instrument `list` returns an empty list.
1823
+
1824
+ The ONCat website calls a named Dataset a Reduction Set and an
1825
+ unnamed one a Reduction; both are this resource.
1826
+
1827
+ A Dataset record holds `id`, `name` (`None` when unnamed),
1828
+ `facility`, `instrument`, `experiment`, `reductions` and `runs`
1829
+ (each `{ranges, min, max, count}`: `ranges` lists `[first, last]`
1830
+ pairs of reduction keys or run numbers), `earliest` and `latest`
1831
+ (each `{created, ingested, modified}`), and `metadata`, a summary of
1832
+ the reductions' metadata whose leaves hold `min`, `max`, `values`
1833
+ (a list of strings, or the string `"TRUNCATED"` once a path has too
1834
+ many distinct strings to list), and `booleans` (the distinct
1835
+ booleans at the path, sorted: `[False]`, `[True]` or
1836
+ `[False, True]`; present only when a reduction holds a boolean
1837
+ there).
1838
+
1839
+ `retrieve` takes an identifier in either form, and `list`
1840
+ takes the `experiment`, `name`, and `run` filters and paging
1841
+ parameters; `run` keeps the Datasets whose `runs` ranges include
1842
+ that run, whatever their experiment.
1843
+ List a Dataset's reductions with `Reduction.list(..., dataset=...)`:
1844
+
1845
+ dataset = oncat.Dataset.retrieve(
1846
+ "IPTS-20841/my-dataset", facility="SNS", instrument="REF_M"
1847
+ )
1848
+ reductions = oncat.Reduction.list(
1849
+ facility="SNS", instrument="REF_M", dataset=dataset.id
1850
+ )
1851
+ """
1852
+
1853
+ _RESOURCE_ENDPOINT = "datasets"
1854
+ _NAMESPACE = "api"
1855
+
1856
+
1775
1857
  class Simulation(_ControllerONCatMixin, _ListableONCatMixin):
1776
1858
  _RESOURCE_ENDPOINT = "simulations"
1777
1859
  _NAMESPACE = "api"
@@ -1803,11 +1885,6 @@ class Cache(_UpdatableONCatMixin):
1803
1885
  _NAMESPACE = "admin"
1804
1886
 
1805
1887
 
1806
- class Usage(_ListableONCatMixin):
1807
- _RESOURCE_ENDPOINT = "usage"
1808
- _NAMESPACE = "admin"
1809
-
1810
-
1811
1888
  ########################################################################
1812
1889
 
1813
1890
 
@@ -1818,51 +1895,6 @@ class Template(
1818
1895
  _NAMESPACE = "settings"
1819
1896
 
1820
1897
 
1821
- ########################################################################
1822
- # Summary Resources
1823
- ########################################################################
1824
-
1825
-
1826
- class ExperimentSummary(_ONCatResource):
1827
- _RESOURCE_ENDPOINT = "experiments"
1828
- _NAMESPACE = "summary"
1829
-
1830
-
1831
- class GroupingSummary(_ONCatResource):
1832
- _RESOURCE_ENDPOINT = "groupings"
1833
- _NAMESPACE = "summary"
1834
-
1835
-
1836
- class InstrumentSummary(_ONCatResource):
1837
- _RESOURCE_ENDPOINT = "instruments"
1838
- _NAMESPACE = "summary"
1839
-
1840
-
1841
- class ReductionSummary(_ONCatResource):
1842
- _RESOURCE_ENDPOINT = "reductions"
1843
- _NAMESPACE = "summary"
1844
-
1845
-
1846
- class RunSummary(_ONCatResource):
1847
- _RESOURCE_ENDPOINT = "runs"
1848
- _NAMESPACE = "summary"
1849
-
1850
-
1851
- class SampleSummary(_ONCatResource):
1852
- _RESOURCE_ENDPOINT = "samples"
1853
- _NAMESPACE = "summary"
1854
-
1855
-
1856
- class ScanSummary(_ONCatResource):
1857
- _RESOURCE_ENDPOINT = "scans"
1858
- _NAMESPACE = "summary"
1859
-
1860
-
1861
- class SessionSummary(_ONCatResource):
1862
- _RESOURCE_ENDPOINT = "sessions"
1863
- _NAMESPACE = "summary"
1864
-
1865
-
1866
1898
  ########################################################################
1867
1899
  # Misc Tools
1868
1900
  ########################################################################
@@ -2028,6 +2060,18 @@ def _yield_nodes(d, path, include_branches):
2028
2060
  yield node_path
2029
2061
 
2030
2062
 
2063
+ def _conflict_message(response):
2064
+ """Return the message a 409 response body carries, which names the
2065
+ state that refused the request, or the response's status line when
2066
+ the body holds none."""
2067
+ try:
2068
+ body = response.json()
2069
+ except ValueError:
2070
+ body = None
2071
+ message = body.get("message") if isinstance(body, dict) else None
2072
+ return message or f'Error: "{response.status_code} {response.reason}"'
2073
+
2074
+
2031
2075
  ########################################################################
2032
2076
  # Exceptions
2033
2077
  ########################################################################
@@ -2078,6 +2122,16 @@ class BadRequestError(PyONCatError):
2078
2122
  """
2079
2123
 
2080
2124
 
2125
+ class ConflictError(PyONCatError):
2126
+ """An error that is raised when a request conflicts with the current
2127
+ state of the item it acts on, such as submitting a DOI that is no
2128
+ longer drafting.
2129
+
2130
+ The message is the API's explanation. The same request can succeed
2131
+ once the item's state allows it, so check the item before retrying.
2132
+ """
2133
+
2134
+
2081
2135
  class DeviceAuthorizationError(PyONCatError):
2082
2136
  """An error that is raised when the OAuth 2.0 Device Authorization
2083
2137
  Grant (RFC 8628) flow cannot complete (for example, the user did not
@@ -2096,25 +2150,25 @@ class DeviceAuthorizationError(PyONCatError):
2096
2150
  # The four outcome subclasses below intentionally omit the `Error` suffix
2097
2151
  # (triggering N818): the names are the public 2.6 device-authorization
2098
2152
  # API contract
2099
- # (ADR-0097), spelled to read as outcomes the caller catches by reason --
2153
+ # (ADR-000097), spelled to read as outcomes the caller catches by reason --
2100
2154
  # Denied / Expired / Cancelled / Timeout. They subclass
2101
2155
  # `DeviceAuthorizationError`, so the suffixed base type still catches them.
2102
- class DeviceAuthorizationDenied(DeviceAuthorizationError): # noqa: N818 - public API name (ADR-0097)
2156
+ class DeviceAuthorizationDenied(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-000097)
2103
2157
  """Raised when the user explicitly denied the sign-in request
2104
2158
  (the IdP returned `access_denied`, RFC 8628 §3.5)."""
2105
2159
 
2106
2160
 
2107
- class DeviceAuthorizationExpired(DeviceAuthorizationError): # noqa: N818 - public API name (ADR-0097)
2161
+ class DeviceAuthorizationExpired(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-000097)
2108
2162
  """Raised when the device code expired before the user approved the
2109
2163
  sign-in (the IdP returned `expired_token`, RFC 8628 §3.5)."""
2110
2164
 
2111
2165
 
2112
- class DeviceAuthorizationCancelled(DeviceAuthorizationError): # noqa: N818 - public API name (ADR-0097)
2166
+ class DeviceAuthorizationCancelled(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-000097)
2113
2167
  """Raised when the caller cancelled the sign-in in progress via the
2114
2168
  `cancel_event` passed to `login()`."""
2115
2169
 
2116
2170
 
2117
- class DeviceAuthorizationTimeout(DeviceAuthorizationError): # noqa: N818 - public API name (ADR-0097)
2171
+ class DeviceAuthorizationTimeout(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-000097)
2118
2172
  """Raised when the sign-in did not complete before the overall
2119
2173
  caller deadline (`device_authorization_timeout`) or the server's own
2120
2174
  `expires_in`, whichever came first."""
@@ -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
- except Exception as e: # noqa: BLE001
570
- # Intentionally catch all exceptions - design requirement for
571
- # autoreduction scripts where cataloging failures should never
572
- # break the reduction workflow
692
+ except Exception as e: # ruff: ignore[blind-except]
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."""
@@ -7,7 +7,7 @@ build-backend = "poetry.masonry.api"
7
7
  # https://github.com/python-poetry/roadmap/issues/3
8
8
  [tool.poetry]
9
9
  name = "pyoncat"
10
- version = "2.6"
10
+ version = "2.8"
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"]
@@ -21,7 +21,7 @@ packages = [
21
21
  ]
22
22
 
23
23
  [tool.poetry.dependencies]
24
- python = ">=3.7, <4"
24
+ python = ">=3.9, <4"
25
25
  requests = "*"
26
26
  oauthlib = { version = "*", optional = true }
27
27
  requests-oauthlib = { version = "*", optional = true }
File without changes