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.
- {pyoncat-2.6 → pyoncat-2.8}/PKG-INFO +7 -4
- {pyoncat-2.6 → pyoncat-2.8}/pyoncat/README.md +4 -0
- {pyoncat-2.6 → pyoncat-2.8}/pyoncat/__init__.py +117 -63
- {pyoncat-2.6 → pyoncat-2.8}/pyoncat/reduction.py +323 -118
- {pyoncat-2.6 → pyoncat-2.8}/pyproject.toml +2 -2
- {pyoncat-2.6 → pyoncat-2.8}/pyoncat/LICENSE +0 -0
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyoncat
|
|
3
|
-
Version: 2.
|
|
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.
|
|
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.
|
|
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={
|
|
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-
|
|
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-
|
|
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 = "
|
|
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-
|
|
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): #
|
|
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): #
|
|
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): #
|
|
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): #
|
|
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
|
-
|
|
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
|
-
except Exception as e: #
|
|
570
|
-
# Intentionally catch
|
|
571
|
-
#
|
|
572
|
-
#
|
|
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
|
-
|
|
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"]
|
|
@@ -21,7 +21,7 @@ packages = [
|
|
|
21
21
|
]
|
|
22
22
|
|
|
23
23
|
[tool.poetry.dependencies]
|
|
24
|
-
python = ">=3.
|
|
24
|
+
python = ">=3.9, <4"
|
|
25
25
|
requests = "*"
|
|
26
26
|
oauthlib = { version = "*", optional = true }
|
|
27
27
|
requests-oauthlib = { version = "*", optional = true }
|
|
File without changes
|