pyoncat 2.7__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.
- {pyoncat-2.7 → pyoncat-2.8}/PKG-INFO +1 -1
- {pyoncat-2.7 → pyoncat-2.8}/pyoncat/__init__.py +86 -8
- {pyoncat-2.7 → pyoncat-2.8}/pyoncat/reduction.py +322 -117
- {pyoncat-2.7 → pyoncat-2.8}/pyproject.toml +1 -1
- {pyoncat-2.7 → pyoncat-2.8}/pyoncat/LICENSE +0 -0
- {pyoncat-2.7 → pyoncat-2.8}/pyoncat/README.md +0 -0
|
@@ -34,7 +34,7 @@ from pyoncat.reduction import ReductionFileBuilder
|
|
|
34
34
|
if TYPE_CHECKING:
|
|
35
35
|
import threading
|
|
36
36
|
|
|
37
|
-
__version__ = "2.
|
|
37
|
+
__version__ = "2.8"
|
|
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-
|
|
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
|
|
@@ -1138,7 +1143,7 @@ class ONCat:
|
|
|
1138
1143
|
device_auth_body["scope"] = scope_str
|
|
1139
1144
|
|
|
1140
1145
|
# The caller's `device_authorization_timeout` bounds the WHOLE sign-in
|
|
1141
|
-
# (ADR-
|
|
1146
|
+
# (ADR-000097), so the deadline clock starts BEFORE the initial
|
|
1142
1147
|
# device-authorization POST. A slow initial POST therefore counts
|
|
1143
1148
|
# against the overall budget. `http_timeout` still caps each
|
|
1144
1149
|
# individual POST; the caller deadline is the cumulative bound.
|
|
@@ -1798,6 +1803,57 @@ class Reduction(
|
|
|
1798
1803
|
_NAMESPACE = "api"
|
|
1799
1804
|
|
|
1800
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
|
+
|
|
1801
1857
|
class Simulation(_ControllerONCatMixin, _ListableONCatMixin):
|
|
1802
1858
|
_RESOURCE_ENDPOINT = "simulations"
|
|
1803
1859
|
_NAMESPACE = "api"
|
|
@@ -2004,6 +2060,18 @@ def _yield_nodes(d, path, include_branches):
|
|
|
2004
2060
|
yield node_path
|
|
2005
2061
|
|
|
2006
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
|
+
|
|
2007
2075
|
########################################################################
|
|
2008
2076
|
# Exceptions
|
|
2009
2077
|
########################################################################
|
|
@@ -2054,6 +2122,16 @@ class BadRequestError(PyONCatError):
|
|
|
2054
2122
|
"""
|
|
2055
2123
|
|
|
2056
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
|
+
|
|
2057
2135
|
class DeviceAuthorizationError(PyONCatError):
|
|
2058
2136
|
"""An error that is raised when the OAuth 2.0 Device Authorization
|
|
2059
2137
|
Grant (RFC 8628) flow cannot complete (for example, the user did not
|
|
@@ -2072,25 +2150,25 @@ class DeviceAuthorizationError(PyONCatError):
|
|
|
2072
2150
|
# The four outcome subclasses below intentionally omit the `Error` suffix
|
|
2073
2151
|
# (triggering N818): the names are the public 2.6 device-authorization
|
|
2074
2152
|
# API contract
|
|
2075
|
-
# (ADR-
|
|
2153
|
+
# (ADR-000097), spelled to read as outcomes the caller catches by reason --
|
|
2076
2154
|
# Denied / Expired / Cancelled / Timeout. They subclass
|
|
2077
2155
|
# `DeviceAuthorizationError`, so the suffixed base type still catches them.
|
|
2078
|
-
class DeviceAuthorizationDenied(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-
|
|
2156
|
+
class DeviceAuthorizationDenied(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-000097)
|
|
2079
2157
|
"""Raised when the user explicitly denied the sign-in request
|
|
2080
2158
|
(the IdP returned `access_denied`, RFC 8628 §3.5)."""
|
|
2081
2159
|
|
|
2082
2160
|
|
|
2083
|
-
class DeviceAuthorizationExpired(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-
|
|
2161
|
+
class DeviceAuthorizationExpired(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-000097)
|
|
2084
2162
|
"""Raised when the device code expired before the user approved the
|
|
2085
2163
|
sign-in (the IdP returned `expired_token`, RFC 8628 §3.5)."""
|
|
2086
2164
|
|
|
2087
2165
|
|
|
2088
|
-
class DeviceAuthorizationCancelled(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-
|
|
2166
|
+
class DeviceAuthorizationCancelled(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-000097)
|
|
2089
2167
|
"""Raised when the caller cancelled the sign-in in progress via the
|
|
2090
2168
|
`cancel_event` passed to `login()`."""
|
|
2091
2169
|
|
|
2092
2170
|
|
|
2093
|
-
class DeviceAuthorizationTimeout(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-
|
|
2171
|
+
class DeviceAuthorizationTimeout(DeviceAuthorizationError): # ruff: ignore[error-suffix-on-exception-name] - public API name (ADR-000097)
|
|
2094
2172
|
"""Raised when the sign-in did not complete before the overall
|
|
2095
2173
|
caller deadline (`device_authorization_timeout`) or the server's own
|
|
2096
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
|
-
|
|
34
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
455
|
-
|
|
456
|
-
|
|
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
|
|
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.
|
|
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,
|
|
651
|
+
self,
|
|
652
|
+
path: str | os.PathLike[str] | None = None,
|
|
653
|
+
overwrite: bool = True,
|
|
539
654
|
) -> str | None:
|
|
540
|
-
"""Write the reduction file
|
|
655
|
+
"""Write the reduction file like write(), but never raise.
|
|
541
656
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
|
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
|
|
685
|
+
print(f"Reduction file written: {path}")
|
|
563
686
|
else:
|
|
564
|
-
print("Warning:
|
|
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
|
|
571
|
-
#
|
|
572
|
-
#
|
|
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
|
-
|
|
673
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
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.
|
|
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"]
|
|
File without changes
|
|
File without changes
|