pyoncat 2.1__tar.gz → 2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: pyoncat
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4
|
|
4
4
|
Summary: A Python Client for ONCat (the ORNL Neutron Catalog).
|
|
5
5
|
Keywords: ornl,neutron,metadata,catalog
|
|
6
6
|
Author: ONCat Support
|
|
@@ -14,9 +14,8 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
-
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
18
|
Requires-Dist: requests
|
|
19
|
-
Requires-Dist: requests-oauthlib
|
|
20
19
|
Project-URL: Homepage, https://oncat.ornl.gov
|
|
21
20
|
Description-Content-Type: text/markdown
|
|
22
21
|
|
|
@@ -9,7 +9,6 @@ hosted at https://oncat.ornl.gov.
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
-
import contextlib
|
|
13
12
|
import functools
|
|
14
13
|
import getpass
|
|
15
14
|
import json
|
|
@@ -17,20 +16,30 @@ import os
|
|
|
17
16
|
import pathlib
|
|
18
17
|
import warnings
|
|
19
18
|
import weakref
|
|
20
|
-
from typing import Any, Callable, ClassVar
|
|
19
|
+
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal
|
|
20
|
+
from urllib.parse import quote
|
|
21
21
|
|
|
22
22
|
import oauthlib.oauth2
|
|
23
23
|
import requests
|
|
24
24
|
import requests_oauthlib
|
|
25
|
-
|
|
25
|
+
import urllib3
|
|
26
|
+
from oauthlib.oauth2 import rfc6749
|
|
27
|
+
from urllib3.exceptions import InsecureRequestWarning
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
from pyoncat.reduction import ReductionFileBuilder
|
|
30
|
+
|
|
31
|
+
__version__ = "2.4"
|
|
32
|
+
|
|
33
|
+
# Constant for autoreduction user
|
|
34
|
+
AUTOREDUCTION_USER = "auto"
|
|
28
35
|
|
|
29
36
|
__all__ = [
|
|
37
|
+
"AUTOREDUCTION_USER",
|
|
30
38
|
"FileSystemTokenStore",
|
|
31
39
|
"ONCat",
|
|
32
40
|
"ONCatObject",
|
|
33
41
|
"PyONCatError",
|
|
42
|
+
"ReductionFileBuilder",
|
|
34
43
|
"prompt_user_for_login",
|
|
35
44
|
]
|
|
36
45
|
|
|
@@ -40,8 +49,15 @@ __all__ = [
|
|
|
40
49
|
########################################################################
|
|
41
50
|
|
|
42
51
|
|
|
43
|
-
|
|
44
|
-
|
|
52
|
+
FlowType = Literal[
|
|
53
|
+
"client_credentials",
|
|
54
|
+
"resource_owner",
|
|
55
|
+
]
|
|
56
|
+
CLIENT_CREDENTIALS_FLOW: FlowType = "client_credentials"
|
|
57
|
+
RESOURCE_OWNER_CREDENTIALS_FLOW: FlowType = "resource_owner"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
assert isinstance(CLIENT_CREDENTIALS_FLOW, str)
|
|
45
61
|
|
|
46
62
|
|
|
47
63
|
class ONCat:
|
|
@@ -101,11 +117,11 @@ class ONCat:
|
|
|
101
117
|
The client secret for the OAuth client, which may be
|
|
102
118
|
required alongside the client ID if you are using one.
|
|
103
119
|
|
|
104
|
-
token_getter: Callable[[],
|
|
120
|
+
token_getter: Callable[[], dict | None] | None
|
|
105
121
|
An optional function that will be used to load the current
|
|
106
122
|
OAuth token if one exists, else `None`.
|
|
107
123
|
|
|
108
|
-
token_setter: Callable[[
|
|
124
|
+
token_setter: Callable[[dict | None], None] | None
|
|
109
125
|
An optional function that will be used to save the current
|
|
110
126
|
OAuth token, to be retrieved later by the `token_getter`.
|
|
111
127
|
|
|
@@ -118,7 +134,7 @@ class ONCat:
|
|
|
118
134
|
An optional API token that can be used by select clients to
|
|
119
135
|
authenticate requests to the API as an alternative to OAuth.
|
|
120
136
|
|
|
121
|
-
flow:
|
|
137
|
+
flow: FlowType | None
|
|
122
138
|
The OAuth "flow" to use for authentication. Required if you
|
|
123
139
|
are using OAuth.
|
|
124
140
|
|
|
@@ -143,18 +159,40 @@ class ONCat:
|
|
|
143
159
|
|
|
144
160
|
"""
|
|
145
161
|
|
|
162
|
+
if TYPE_CHECKING:
|
|
163
|
+
Facility: Facility
|
|
164
|
+
Instrument: Instrument
|
|
165
|
+
Experiment: Experiment
|
|
166
|
+
Run: Run
|
|
167
|
+
Scan: Scan
|
|
168
|
+
Sample: Sample
|
|
169
|
+
Group: Group
|
|
170
|
+
Grouping: Grouping
|
|
171
|
+
Mapping: Mapping
|
|
172
|
+
Proposal: Proposal
|
|
173
|
+
Item: Item
|
|
174
|
+
Archive: Archive
|
|
175
|
+
Cycle: Cycle
|
|
176
|
+
Datafile: Datafile
|
|
177
|
+
Reduction: Reduction
|
|
178
|
+
Simulation: Simulation
|
|
179
|
+
User: User
|
|
180
|
+
Task: Task
|
|
181
|
+
Info: Info
|
|
182
|
+
CacheEntry: CacheEntry
|
|
183
|
+
UsageEntry: UsageEntry
|
|
184
|
+
Template: Template
|
|
185
|
+
|
|
146
186
|
def __init__(
|
|
147
187
|
self,
|
|
148
188
|
url: str,
|
|
149
189
|
client_id: str | None = None,
|
|
150
190
|
client_secret: str | None = None,
|
|
151
|
-
token_getter: Callable[[],
|
|
152
|
-
token_setter: Callable[[
|
|
191
|
+
token_getter: Callable[[], dict | None] | None = None,
|
|
192
|
+
token_setter: Callable[[dict | None], None] | None = None,
|
|
153
193
|
login_prompt: Callable[[], tuple[str, str]] | None = None,
|
|
154
194
|
api_token: str | None = None,
|
|
155
|
-
flow:
|
|
156
|
-
| RESOURCE_OWNER_CREDENTIALS_FLOW
|
|
157
|
-
| None = None,
|
|
195
|
+
flow: FlowType | None = None,
|
|
158
196
|
scopes: list[str] | None = None,
|
|
159
197
|
verify: bool = True,
|
|
160
198
|
timeout: int | None = None,
|
|
@@ -201,7 +239,7 @@ class ONCat:
|
|
|
201
239
|
self._client_secret = client_secret
|
|
202
240
|
self._url = url
|
|
203
241
|
self._flow = flow
|
|
204
|
-
self._scopes = scopes
|
|
242
|
+
self._scopes: list[Any] | None = scopes
|
|
205
243
|
self._verify = verify
|
|
206
244
|
self._timeout = timeout
|
|
207
245
|
|
|
@@ -227,7 +265,7 @@ class ONCat:
|
|
|
227
265
|
self._call_method("delete", url, None, **kwargs)
|
|
228
266
|
|
|
229
267
|
def _call_method(self, method, url, data, **kwargs):
|
|
230
|
-
url =
|
|
268
|
+
url = quote(url)
|
|
231
269
|
full_url = (
|
|
232
270
|
self._url + url if url.startswith("/") else self._url + "/" + url
|
|
233
271
|
)
|
|
@@ -274,7 +312,7 @@ class ONCat:
|
|
|
274
312
|
|
|
275
313
|
try:
|
|
276
314
|
return send_request()
|
|
277
|
-
except
|
|
315
|
+
except rfc6749.errors.InvalidGrantError as error:
|
|
278
316
|
if (
|
|
279
317
|
self._flow == RESOURCE_OWNER_CREDENTIALS_FLOW
|
|
280
318
|
and "(invalid_grant)" in str(error)
|
|
@@ -334,7 +372,7 @@ class ONCat:
|
|
|
334
372
|
):
|
|
335
373
|
# Ignore invalid certs and lack of SSL for OAuth if
|
|
336
374
|
# deploying locally.
|
|
337
|
-
|
|
375
|
+
urllib3.disable_warnings(InsecureRequestWarning)
|
|
338
376
|
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
|
|
339
377
|
|
|
340
378
|
return (
|
|
@@ -389,6 +427,7 @@ class ONCat:
|
|
|
389
427
|
def _login_client_credentials(self):
|
|
390
428
|
with warnings.catch_warnings():
|
|
391
429
|
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
|
430
|
+
assert self._client_id
|
|
392
431
|
self._oauth_client = requests_oauthlib.OAuth2Session(
|
|
393
432
|
client=oauthlib.oauth2.BackendApplicationClient(
|
|
394
433
|
client_id=self._client_id,
|
|
@@ -408,7 +447,7 @@ class ONCat:
|
|
|
408
447
|
timeout=self._timeout,
|
|
409
448
|
)
|
|
410
449
|
|
|
411
|
-
except
|
|
450
|
+
except rfc6749.errors.InvalidClientError as error:
|
|
412
451
|
msg = (
|
|
413
452
|
"You seem to have provided some invalid client "
|
|
414
453
|
"credentials. Are you sure they are correct?"
|
|
@@ -427,6 +466,7 @@ class ONCat:
|
|
|
427
466
|
)
|
|
428
467
|
raise LoginRequiredError(msg)
|
|
429
468
|
else:
|
|
469
|
+
assert self._client_id
|
|
430
470
|
initial_oauth_client = requests_oauthlib.OAuth2Session(
|
|
431
471
|
client=oauthlib.oauth2.LegacyApplicationClient(
|
|
432
472
|
client_id=self._client_id,
|
|
@@ -447,13 +487,13 @@ class ONCat:
|
|
|
447
487
|
timeout=self._timeout,
|
|
448
488
|
)
|
|
449
489
|
self._save_token(token)
|
|
450
|
-
except
|
|
490
|
+
except rfc6749.errors.InvalidClientError as error:
|
|
451
491
|
msg = (
|
|
452
492
|
"You seem to have provided some invalid client "
|
|
453
493
|
"credentials. Are you sure they are correct?"
|
|
454
494
|
)
|
|
455
495
|
raise InvalidClientCredentialsError(msg) from error
|
|
456
|
-
except
|
|
496
|
+
except rfc6749.errors.InvalidGrantError as error:
|
|
457
497
|
if "(invalid_grant)" in str(
|
|
458
498
|
error
|
|
459
499
|
) and "username & password" in str(error):
|
|
@@ -517,8 +557,10 @@ class ONCatObject:
|
|
|
517
557
|
|
|
518
558
|
"""
|
|
519
559
|
|
|
520
|
-
def __init__(self, content: dict):
|
|
521
|
-
|
|
560
|
+
def __init__(self, content: dict | None = None):
|
|
561
|
+
# TODO: Consider a better way to represent a `None` ONCat object
|
|
562
|
+
# because not all responses will return a representation.
|
|
563
|
+
self._content = content or {}
|
|
522
564
|
|
|
523
565
|
def __repr__(self):
|
|
524
566
|
# https://stackoverflow.com/a/2626364/778572
|
|
@@ -537,7 +579,7 @@ class ONCatObject:
|
|
|
537
579
|
def __str__(self):
|
|
538
580
|
return json.dumps(self._content, sort_keys=True, indent=4)
|
|
539
581
|
|
|
540
|
-
def __getattr__(self, k):
|
|
582
|
+
def __getattr__(self, k) -> Any:
|
|
541
583
|
if k[0] == "_":
|
|
542
584
|
raise AttributeError(k)
|
|
543
585
|
|
|
@@ -546,7 +588,7 @@ class ONCatObject:
|
|
|
546
588
|
except KeyError as e:
|
|
547
589
|
raise AttributeError(*e.args) from e
|
|
548
590
|
|
|
549
|
-
def __getitem__(self, key):
|
|
591
|
+
def __getitem__(self, key) -> Any:
|
|
550
592
|
elements = key.split(".")
|
|
551
593
|
current = elements[0]
|
|
552
594
|
remainder = elements[1:] if len(elements) > 1 else None
|
|
@@ -648,11 +690,22 @@ class _ONCatMixinMeta(type):
|
|
|
648
690
|
|
|
649
691
|
|
|
650
692
|
class _ONCatResource(metaclass=_ONCatMixinMeta):
|
|
651
|
-
|
|
693
|
+
_NAMESPACE: str
|
|
694
|
+
_RESOURCE_ENDPOINT: str
|
|
695
|
+
|
|
696
|
+
def __init__(self, parent_oncat: ONCat):
|
|
652
697
|
self._parent_oncat_weakref = weakref.ref(parent_oncat)
|
|
653
698
|
|
|
699
|
+
@property
|
|
700
|
+
def _parent(self) -> ONCat:
|
|
701
|
+
parent = self._parent_oncat_weakref()
|
|
702
|
+
if parent is None:
|
|
703
|
+
msg = "ONCat client was garbage-collected"
|
|
704
|
+
raise ReferenceError(msg)
|
|
705
|
+
return parent
|
|
706
|
+
|
|
654
707
|
def retrieve(self, identifier, **kwargs):
|
|
655
|
-
oncat = self.
|
|
708
|
+
oncat = self._parent
|
|
656
709
|
|
|
657
710
|
identifier = identifier.removeprefix("/")
|
|
658
711
|
|
|
@@ -663,7 +716,7 @@ class _ONCatResource(metaclass=_ONCatMixinMeta):
|
|
|
663
716
|
|
|
664
717
|
class _ListableONCatMixin(_ONCatResource):
|
|
665
718
|
def list(self, **kwargs):
|
|
666
|
-
oncat = self.
|
|
719
|
+
oncat = self._parent
|
|
667
720
|
|
|
668
721
|
path = f"{self._NAMESPACE}/{self._RESOURCE_ENDPOINT}"
|
|
669
722
|
|
|
@@ -674,8 +727,8 @@ class _ListableONCatMixin(_ONCatResource):
|
|
|
674
727
|
|
|
675
728
|
|
|
676
729
|
class _UpdatableONCatMixin(_ONCatResource):
|
|
677
|
-
def place(self, identifier, data, **kwargs):
|
|
678
|
-
oncat = self.
|
|
730
|
+
def place(self, identifier: str, data: Any, **kwargs):
|
|
731
|
+
oncat = self._parent
|
|
679
732
|
|
|
680
733
|
identifier = identifier.removeprefix("/")
|
|
681
734
|
|
|
@@ -686,7 +739,7 @@ class _UpdatableONCatMixin(_ONCatResource):
|
|
|
686
739
|
|
|
687
740
|
class _CreatableONCatMixin(_ONCatResource):
|
|
688
741
|
def create(self, data, **kwargs):
|
|
689
|
-
oncat = self.
|
|
742
|
+
oncat = self._parent
|
|
690
743
|
|
|
691
744
|
path = f"{self._NAMESPACE}/{self._RESOURCE_ENDPOINT}"
|
|
692
745
|
|
|
@@ -694,8 +747,8 @@ class _CreatableONCatMixin(_ONCatResource):
|
|
|
694
747
|
|
|
695
748
|
|
|
696
749
|
class _RemovableONCatMixin(_ONCatResource):
|
|
697
|
-
def remove(self, identifier, **kwargs):
|
|
698
|
-
oncat = self.
|
|
750
|
+
def remove(self, identifier: str, **kwargs):
|
|
751
|
+
oncat = self._parent
|
|
699
752
|
|
|
700
753
|
identifier = identifier.removeprefix("/")
|
|
701
754
|
|
|
@@ -721,7 +774,7 @@ class _ControllerONCatMixin(_ONCatResource):
|
|
|
721
774
|
else:
|
|
722
775
|
identifier = None
|
|
723
776
|
data = args[0]
|
|
724
|
-
|
|
777
|
+
elif len(args) == 2:
|
|
725
778
|
if not isinstance(args[0], str):
|
|
726
779
|
raise TypeError
|
|
727
780
|
identifier = args[0]
|
|
@@ -730,7 +783,7 @@ class _ControllerONCatMixin(_ONCatResource):
|
|
|
730
783
|
if identifier is not None and identifier.startswith("/"):
|
|
731
784
|
identifier = identifier[1:]
|
|
732
785
|
|
|
733
|
-
oncat = self.
|
|
786
|
+
oncat = self._parent
|
|
734
787
|
|
|
735
788
|
if identifier is not None:
|
|
736
789
|
path = (
|
|
@@ -765,7 +818,17 @@ class Experiment(_ListableONCatMixin):
|
|
|
765
818
|
|
|
766
819
|
class Run(_ListableONCatMixin):
|
|
767
820
|
_RESOURCE_ENDPOINT = "runs"
|
|
768
|
-
_NAMESPACE = "
|
|
821
|
+
_NAMESPACE = "api"
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
class Scan(_ListableONCatMixin):
|
|
825
|
+
_RESOURCE_ENDPOINT = "scans"
|
|
826
|
+
_NAMESPACE = "api"
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
class Sample(_ListableONCatMixin):
|
|
830
|
+
_RESOURCE_ENDPOINT = "samples"
|
|
831
|
+
_NAMESPACE = "api"
|
|
769
832
|
|
|
770
833
|
|
|
771
834
|
class Group(_ListableONCatMixin):
|
|
@@ -775,22 +838,22 @@ class Group(_ListableONCatMixin):
|
|
|
775
838
|
|
|
776
839
|
class Grouping(_ListableONCatMixin):
|
|
777
840
|
_RESOURCE_ENDPOINT = "groupings"
|
|
778
|
-
_NAMESPACE = "
|
|
841
|
+
_NAMESPACE = "api"
|
|
779
842
|
|
|
780
843
|
|
|
781
844
|
class Mapping(_ListableONCatMixin):
|
|
782
845
|
_RESOURCE_ENDPOINT = "mappings"
|
|
783
|
-
_NAMESPACE = "
|
|
846
|
+
_NAMESPACE = "settings"
|
|
784
847
|
|
|
785
848
|
|
|
786
849
|
class Proposal(_ListableONCatMixin):
|
|
787
850
|
_RESOURCE_ENDPOINT = "proposals"
|
|
788
|
-
_NAMESPACE = "
|
|
851
|
+
_NAMESPACE = "api"
|
|
789
852
|
|
|
790
853
|
|
|
791
854
|
class Item(_ListableONCatMixin):
|
|
792
855
|
_RESOURCE_ENDPOINT = "items"
|
|
793
|
-
_NAMESPACE = "
|
|
856
|
+
_NAMESPACE = "api"
|
|
794
857
|
|
|
795
858
|
|
|
796
859
|
class Archive(
|
|
@@ -871,6 +934,51 @@ class Template(
|
|
|
871
934
|
_NAMESPACE = "settings"
|
|
872
935
|
|
|
873
936
|
|
|
937
|
+
########################################################################
|
|
938
|
+
# Summary Resources
|
|
939
|
+
########################################################################
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
class ExperimentSummary(_ONCatResource):
|
|
943
|
+
_RESOURCE_ENDPOINT = "experiment"
|
|
944
|
+
_NAMESPACE = "summary"
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
class GroupingSummary(_ONCatResource):
|
|
948
|
+
_RESOURCE_ENDPOINT = "grouping"
|
|
949
|
+
_NAMESPACE = "summary"
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
class InstrumentSummary(_ONCatResource):
|
|
953
|
+
_RESOURCE_ENDPOINT = "instrument"
|
|
954
|
+
_NAMESPACE = "summary"
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
class ReductionSummary(_ONCatResource):
|
|
958
|
+
_RESOURCE_ENDPOINT = "reduction"
|
|
959
|
+
_NAMESPACE = "summary"
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
class RunSummary(_ONCatResource):
|
|
963
|
+
_RESOURCE_ENDPOINT = "run"
|
|
964
|
+
_NAMESPACE = "summary"
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
class SampleSummary(_ONCatResource):
|
|
968
|
+
_RESOURCE_ENDPOINT = "sample"
|
|
969
|
+
_NAMESPACE = "summary"
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
class ScanSummary(_ONCatResource):
|
|
973
|
+
_RESOURCE_ENDPOINT = "scan"
|
|
974
|
+
_NAMESPACE = "summary"
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
class SessionSummary(_ONCatResource):
|
|
978
|
+
_RESOURCE_ENDPOINT = "session"
|
|
979
|
+
_NAMESPACE = "summary"
|
|
980
|
+
|
|
981
|
+
|
|
874
982
|
########################################################################
|
|
875
983
|
# Misc Tools
|
|
876
984
|
########################################################################
|
|
@@ -889,19 +997,19 @@ class _InMemoryTokenStore:
|
|
|
889
997
|
def __init__(self):
|
|
890
998
|
self._token = None
|
|
891
999
|
|
|
892
|
-
def set_token(self, token:
|
|
1000
|
+
def set_token(self, token: dict | None):
|
|
893
1001
|
self._token = token
|
|
894
1002
|
|
|
895
|
-
def get_token(self) ->
|
|
1003
|
+
def get_token(self) -> dict | None:
|
|
896
1004
|
return self._token
|
|
897
1005
|
|
|
898
1006
|
|
|
899
1007
|
class FileSystemTokenStore:
|
|
900
1008
|
"""A token store that persists OAuth tokens to a file on disk.
|
|
901
1009
|
|
|
902
|
-
The file is created if it doesn't already exist,
|
|
903
|
-
|
|
904
|
-
exception will be raised.
|
|
1010
|
+
The file is created if it doesn't already exist, including any
|
|
1011
|
+
necessary parent directories. You must have write permissions to
|
|
1012
|
+
the parent directory else an exception will be raised.
|
|
905
1013
|
|
|
906
1014
|
This kind of store may be useful for long-lived applications where
|
|
907
1015
|
you need to persist the token across uses of the client, and it is
|
|
@@ -919,7 +1027,7 @@ class FileSystemTokenStore:
|
|
|
919
1027
|
def __init__(self, path: str):
|
|
920
1028
|
self.path = path
|
|
921
1029
|
|
|
922
|
-
def read_token(self) ->
|
|
1030
|
+
def read_token(self) -> dict | None:
|
|
923
1031
|
"""Read the token from the file on disk, if it exists.
|
|
924
1032
|
|
|
925
1033
|
Return:
|
|
@@ -933,7 +1041,7 @@ class FileSystemTokenStore:
|
|
|
933
1041
|
with open(self.path) as storage:
|
|
934
1042
|
return json.load(storage)
|
|
935
1043
|
|
|
936
|
-
def write_token(self, token:
|
|
1044
|
+
def write_token(self, token: dict | None):
|
|
937
1045
|
"""Write the token to the file on disk.
|
|
938
1046
|
|
|
939
1047
|
Arguments:
|
|
@@ -943,6 +1051,11 @@ class FileSystemTokenStore:
|
|
|
943
1051
|
token.
|
|
944
1052
|
|
|
945
1053
|
"""
|
|
1054
|
+
# Create parent directory if it doesn't exist
|
|
1055
|
+
parent_dir = os.path.dirname(self.path)
|
|
1056
|
+
if parent_dir:
|
|
1057
|
+
os.makedirs(parent_dir, exist_ok=True)
|
|
1058
|
+
|
|
946
1059
|
with open(self.path, "w") as storage:
|
|
947
1060
|
json.dump(token, storage)
|
|
948
1061
|
|
|
@@ -1013,14 +1126,6 @@ def _yield_nodes(d, path, include_branches):
|
|
|
1013
1126
|
class PyONCatError(Exception):
|
|
1014
1127
|
"""A base exception for all errors that may be raised by PyONCat."""
|
|
1015
1128
|
|
|
1016
|
-
def __init__(self, message=None, original_error=None):
|
|
1017
|
-
if not message:
|
|
1018
|
-
message = ""
|
|
1019
|
-
with contextlib.suppress(Exception):
|
|
1020
|
-
message += " [" + original_error.response.json()["message"] + "]"
|
|
1021
|
-
super().__init__(message)
|
|
1022
|
-
self.original_error = original_error
|
|
1023
|
-
|
|
1024
1129
|
|
|
1025
1130
|
class UnauthorizedError(PyONCatError):
|
|
1026
1131
|
"""An error that is raised when the client is not authorized to
|
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
"""Reduction file builder for creating JSON reduction files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import traceback
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ReductionFileBuilder:
|
|
15
|
+
"""Builder for creating JSON Reduction Files for ONCat cataloging.
|
|
16
|
+
|
|
17
|
+
Reduction files document the relationship between raw data and reduced
|
|
18
|
+
data, including all input files (raw data, calibration files, etc.)
|
|
19
|
+
and all output files (processed data, plots, etc.).
|
|
20
|
+
|
|
21
|
+
This class does NOT communicate with the ONCat API. It only creates
|
|
22
|
+
JSON files on disk that can later be cataloged via the API.
|
|
23
|
+
|
|
24
|
+
The typical workflow is:
|
|
25
|
+
|
|
26
|
+
1. Create builder with user identifier
|
|
27
|
+
2. Add input files (raw data, calibrations, etc.)
|
|
28
|
+
3. Add output files (reduced data, plots, etc.)
|
|
29
|
+
4. Set metadata about the reduction
|
|
30
|
+
5. Write the JSON file to disk
|
|
31
|
+
|
|
32
|
+
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.
|
|
35
|
+
|
|
36
|
+
Example usage:
|
|
37
|
+
|
|
38
|
+
from pyoncat import ReductionFileBuilder, AUTOREDUCTION_USER
|
|
39
|
+
|
|
40
|
+
builder = ReductionFileBuilder(user=AUTOREDUCTION_USER)
|
|
41
|
+
builder.add_input(
|
|
42
|
+
"/SNS/ARCS/IPTS-12345/nexus/ARCS_123456.nxs.h5",
|
|
43
|
+
type="raw",
|
|
44
|
+
purpose="sample-data"
|
|
45
|
+
)
|
|
46
|
+
builder.add_output(
|
|
47
|
+
"/SNS/ARCS/IPTS-12345/shared/autoreduce/ARCS_123456.nxs",
|
|
48
|
+
type="processed",
|
|
49
|
+
purpose="reduced-data"
|
|
50
|
+
)
|
|
51
|
+
builder.set_metadata({"ei": 100.0, "t0": 50.0})
|
|
52
|
+
path = builder.write_safe() # Auto-derives path from inputs
|
|
53
|
+
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, user: str, metadata: dict[str, Any] | None = None):
|
|
57
|
+
"""Create a new reduction file builder.
|
|
58
|
+
|
|
59
|
+
Arguments:
|
|
60
|
+
---------
|
|
61
|
+
user: str
|
|
62
|
+
XCAMS/UCAMS username or "auto" for autoreduction. Must be
|
|
63
|
+
lowercase alphanumeric only. A value must always be
|
|
64
|
+
provided by the caller; there is no default.
|
|
65
|
+
|
|
66
|
+
metadata: dict[str, Any] | None
|
|
67
|
+
Optional initial metadata dictionary. Default: {}
|
|
68
|
+
|
|
69
|
+
Example:
|
|
70
|
+
from pyoncat import ReductionFileBuilder, AUTOREDUCTION_USER
|
|
71
|
+
|
|
72
|
+
builder = ReductionFileBuilder(user=AUTOREDUCTION_USER)
|
|
73
|
+
builder = ReductionFileBuilder(user="pp4", metadata={"ei": 100.0})
|
|
74
|
+
|
|
75
|
+
"""
|
|
76
|
+
self._user = user
|
|
77
|
+
self._metadata = metadata if metadata is not None else {}
|
|
78
|
+
self._input_files: list[dict[str, Any]] = []
|
|
79
|
+
self._output_files: list[dict[str, Any]] = []
|
|
80
|
+
self._validation_errors: list[str] = []
|
|
81
|
+
|
|
82
|
+
def add_input(
|
|
83
|
+
self,
|
|
84
|
+
location: str,
|
|
85
|
+
type: str,
|
|
86
|
+
purpose: str,
|
|
87
|
+
fields: dict[str, str] | None = None,
|
|
88
|
+
relative_to: str | None = None,
|
|
89
|
+
) -> ReductionFileBuilder:
|
|
90
|
+
"""Add an input file to the reduction.
|
|
91
|
+
|
|
92
|
+
Arguments:
|
|
93
|
+
---------
|
|
94
|
+
location: str
|
|
95
|
+
Path to the input file. Must be absolute, or relative if
|
|
96
|
+
relative_to is provided.
|
|
97
|
+
|
|
98
|
+
type: str
|
|
99
|
+
File type. Must be one of: "raw", "processed",
|
|
100
|
+
"user-provided", "history".
|
|
101
|
+
|
|
102
|
+
purpose: str
|
|
103
|
+
File purpose in kebab-case (e.g., "sample-data",
|
|
104
|
+
"vanadium", "detector-calibration", "mask", "ub-matrix").
|
|
105
|
+
Server validates format but accepts any kebab-case string.
|
|
106
|
+
|
|
107
|
+
fields: dict[str, str] | None
|
|
108
|
+
Optional metadata field mappings for this file. Dict of
|
|
109
|
+
"source.path": "dest_path" for extracting metadata.
|
|
110
|
+
Primarily used for processed files.
|
|
111
|
+
|
|
112
|
+
relative_to: str | None
|
|
113
|
+
Base directory for relative paths. If provided and
|
|
114
|
+
location is relative, will be resolved to absolute path.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
-------
|
|
118
|
+
Self for method chaining. Never raises; validation errors are
|
|
119
|
+
stored internally and surfaced by validate().
|
|
120
|
+
|
|
121
|
+
Example:
|
|
122
|
+
# Absolute path
|
|
123
|
+
builder.add_input(
|
|
124
|
+
"/SNS/ARCS/IPTS-12345/nexus/ARCS_123456.nxs.h5",
|
|
125
|
+
type="raw",
|
|
126
|
+
purpose="sample-data"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Relative path with relative_to
|
|
130
|
+
builder.add_input(
|
|
131
|
+
"./van.nxs",
|
|
132
|
+
type="processed",
|
|
133
|
+
purpose="detector-calibration",
|
|
134
|
+
relative_to="/SNS/ARCS/IPTS-12345/shared/autoreduce"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Method chaining (use keyword args for clarity)
|
|
138
|
+
builder.add_input(
|
|
139
|
+
file1, type="raw", purpose="sample-data"
|
|
140
|
+
).add_input(
|
|
141
|
+
file2, type="raw", purpose="vanadium"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
"""
|
|
145
|
+
resolved_location = self._resolve_path(location, relative_to)
|
|
146
|
+
|
|
147
|
+
file_dict: dict[str, Any] = {
|
|
148
|
+
"location": resolved_location,
|
|
149
|
+
"type": type,
|
|
150
|
+
"purpose": purpose,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# Only include fields if provided and non-empty
|
|
154
|
+
if fields:
|
|
155
|
+
file_dict["fields"] = fields
|
|
156
|
+
|
|
157
|
+
self._input_files.append(file_dict)
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
def add_output(
|
|
161
|
+
self,
|
|
162
|
+
location: str,
|
|
163
|
+
type: str,
|
|
164
|
+
purpose: str,
|
|
165
|
+
fields: dict[str, str] | None = None,
|
|
166
|
+
relative_to: str | None = None,
|
|
167
|
+
) -> ReductionFileBuilder:
|
|
168
|
+
"""Add an output file to the reduction.
|
|
169
|
+
|
|
170
|
+
The 'fields' parameter is primarily used for output files to extract
|
|
171
|
+
specific metadata from processed NeXus files, for example:
|
|
172
|
+
|
|
173
|
+
fields={
|
|
174
|
+
"mantid_workspace_1.sample.geom_thickness": "thickness",
|
|
175
|
+
"mantid_workspace_1.title": "title"
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
Arguments:
|
|
179
|
+
---------
|
|
180
|
+
location: str
|
|
181
|
+
Path to the output file. Must be absolute, or relative if
|
|
182
|
+
relative_to is provided.
|
|
183
|
+
|
|
184
|
+
type: str
|
|
185
|
+
File type. Must be one of: "raw", "processed",
|
|
186
|
+
"user-provided", "history".
|
|
187
|
+
|
|
188
|
+
purpose: str
|
|
189
|
+
File purpose in kebab-case (e.g., "reduced-data",
|
|
190
|
+
"plot", "log"). Server validates format but accepts any
|
|
191
|
+
kebab-case string.
|
|
192
|
+
|
|
193
|
+
fields: dict[str, str] | None
|
|
194
|
+
Optional metadata field mappings for this file. Dict of
|
|
195
|
+
"source.path": "dest_path" for extracting metadata.
|
|
196
|
+
Commonly used for processed output files.
|
|
197
|
+
|
|
198
|
+
relative_to: str | None
|
|
199
|
+
Base directory for relative paths. If provided and
|
|
200
|
+
location is relative, will be resolved to absolute path.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
-------
|
|
204
|
+
Self for method chaining. Never raises; validation errors are
|
|
205
|
+
stored internally and surfaced by validate().
|
|
206
|
+
|
|
207
|
+
Example:
|
|
208
|
+
builder.add_output(
|
|
209
|
+
"/SNS/ARCS/IPTS-12345/shared/autoreduce/ARCS_123456.nxs",
|
|
210
|
+
type="processed",
|
|
211
|
+
purpose="reduced-data",
|
|
212
|
+
fields={"MDHistoWorkspace.experiment0.logs.ei.value": "ei"}
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# With relative path
|
|
216
|
+
builder.add_output(
|
|
217
|
+
"ARCS_123456.nxs",
|
|
218
|
+
type="processed",
|
|
219
|
+
purpose="reduced-data",
|
|
220
|
+
relative_to=outdir
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
"""
|
|
224
|
+
resolved_location = self._resolve_path(location, relative_to)
|
|
225
|
+
|
|
226
|
+
file_dict: dict[str, Any] = {
|
|
227
|
+
"location": resolved_location,
|
|
228
|
+
"type": type,
|
|
229
|
+
"purpose": purpose,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
# Only include fields if provided and non-empty
|
|
233
|
+
if fields:
|
|
234
|
+
file_dict["fields"] = fields
|
|
235
|
+
|
|
236
|
+
self._output_files.append(file_dict)
|
|
237
|
+
return self
|
|
238
|
+
|
|
239
|
+
def set_metadata(self, metadata: dict[str, Any]) -> ReductionFileBuilder:
|
|
240
|
+
"""Set the reduction metadata, replacing any existing metadata.
|
|
241
|
+
|
|
242
|
+
No validation is performed on structure or content. This is a
|
|
243
|
+
"wild west" field where any valid JSON is accepted. While no
|
|
244
|
+
validation is enforced, consistency across instruments is
|
|
245
|
+
encouraged. See documentation for recommended metadata fields.
|
|
246
|
+
|
|
247
|
+
Arguments:
|
|
248
|
+
---------
|
|
249
|
+
metadata: dict[str, Any]
|
|
250
|
+
Dictionary of metadata fields. Can be empty.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
-------
|
|
254
|
+
Self for method chaining.
|
|
255
|
+
|
|
256
|
+
Example:
|
|
257
|
+
builder.set_metadata({
|
|
258
|
+
"ei": 100.0,
|
|
259
|
+
"t0": 50.0,
|
|
260
|
+
"duration": 3600.0,
|
|
261
|
+
"sample": {
|
|
262
|
+
"name": "MySample",
|
|
263
|
+
"temperature": 300.0
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
"""
|
|
268
|
+
self._metadata = metadata
|
|
269
|
+
return self
|
|
270
|
+
|
|
271
|
+
def update_metadata(
|
|
272
|
+
self, metadata: dict[str, Any]
|
|
273
|
+
) -> ReductionFileBuilder:
|
|
274
|
+
"""Update the reduction metadata by merging with existing metadata.
|
|
275
|
+
|
|
276
|
+
Performs a shallow merge: top-level keys from metadata are merged
|
|
277
|
+
into existing metadata, but nested dictionaries are replaced, not
|
|
278
|
+
merged.
|
|
279
|
+
|
|
280
|
+
Arguments:
|
|
281
|
+
---------
|
|
282
|
+
metadata: dict[str, Any]
|
|
283
|
+
Dictionary of metadata fields to merge.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
-------
|
|
287
|
+
Self for method chaining.
|
|
288
|
+
|
|
289
|
+
Example:
|
|
290
|
+
builder.set_metadata({"ei": 100.0})
|
|
291
|
+
builder.update_metadata({"t0": 50.0})
|
|
292
|
+
builder.get_metadata() # {"ei": 100.0, "t0": 50.0}
|
|
293
|
+
|
|
294
|
+
# Nested dicts are replaced, not merged
|
|
295
|
+
builder.set_metadata({"sample": {"name": "A"}})
|
|
296
|
+
builder.update_metadata({"sample": {"temp": 300}})
|
|
297
|
+
builder.get_metadata() # {"sample": {"temp": 300}}
|
|
298
|
+
|
|
299
|
+
"""
|
|
300
|
+
self._metadata.update(metadata)
|
|
301
|
+
return self
|
|
302
|
+
|
|
303
|
+
def get_metadata(self) -> dict[str, Any]:
|
|
304
|
+
"""Get a copy of the current metadata dictionary.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
-------
|
|
308
|
+
Copy of current metadata. Modifications to the returned dict
|
|
309
|
+
won't affect builder state.
|
|
310
|
+
|
|
311
|
+
Example:
|
|
312
|
+
metadata = builder.get_metadata()
|
|
313
|
+
metadata["new_field"] = "value" # Doesn't affect builder
|
|
314
|
+
builder.set_metadata(metadata) # Explicit update required
|
|
315
|
+
|
|
316
|
+
"""
|
|
317
|
+
return self._metadata.copy()
|
|
318
|
+
|
|
319
|
+
def clear_outputs(self) -> ReductionFileBuilder:
|
|
320
|
+
"""Remove all output files from the builder.
|
|
321
|
+
|
|
322
|
+
Useful when re-running a reduction: keep the same inputs but start
|
|
323
|
+
fresh with outputs.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
-------
|
|
327
|
+
Self for method chaining.
|
|
328
|
+
|
|
329
|
+
Example:
|
|
330
|
+
builder = ReductionFileBuilder(user="auto")
|
|
331
|
+
builder.add_input(input_file, "raw", "sample-data")
|
|
332
|
+
builder.add_output(output_file, "processed", "reduced-data")
|
|
333
|
+
|
|
334
|
+
# Reduction failed, try again
|
|
335
|
+
builder.clear_outputs()
|
|
336
|
+
builder.add_output(new_output_file, "processed", "reduced-data")
|
|
337
|
+
builder.write_safe()
|
|
338
|
+
|
|
339
|
+
"""
|
|
340
|
+
self._output_files = []
|
|
341
|
+
return self
|
|
342
|
+
|
|
343
|
+
def validate(self) -> list[str]:
|
|
344
|
+
"""Validate the builder state and return any errors.
|
|
345
|
+
|
|
346
|
+
Performs minimal validation:
|
|
347
|
+
|
|
348
|
+
- Returns any errors accumulated from mutators (e.g., relative
|
|
349
|
+
paths without relative_to)
|
|
350
|
+
- At least one input file present
|
|
351
|
+
- At least one output file present
|
|
352
|
+
- User is set and non-empty
|
|
353
|
+
- Metadata is a dict
|
|
354
|
+
- If auto-deriving path: at least one input is type="raw" and
|
|
355
|
+
purpose="sample-data"
|
|
356
|
+
- All paths must be absolute and canonical (`/SNS/...` or
|
|
357
|
+
`/HFIR/...` for IPTS directories, or `/SNS/...` or `/HFIR/...`
|
|
358
|
+
for shared directories like /SNS/ARCS/shared/...)
|
|
359
|
+
|
|
360
|
+
Does NOT validate:
|
|
361
|
+
|
|
362
|
+
- Type/purpose enum values (server validates)
|
|
363
|
+
- Detailed file path patterns beyond canonical roots
|
|
364
|
+
- Fields mapping format (server validates)
|
|
365
|
+
- Metadata structure (intentionally unrestricted)
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
-------
|
|
369
|
+
List of error messages. Empty list if valid.
|
|
370
|
+
|
|
371
|
+
Example:
|
|
372
|
+
errors = builder.validate()
|
|
373
|
+
if errors:
|
|
374
|
+
print("Validation failed:")
|
|
375
|
+
for error in errors:
|
|
376
|
+
print(f" - {error}")
|
|
377
|
+
else:
|
|
378
|
+
builder.write()
|
|
379
|
+
|
|
380
|
+
"""
|
|
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
|
|
426
|
+
|
|
427
|
+
def to_dict(self) -> dict[str, Any]:
|
|
428
|
+
"""Preview the JSON structure that will be written (without timestamp).
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
-------
|
|
432
|
+
Dictionary representing the reduction file structure. Does not
|
|
433
|
+
include the 'created' timestamp which is generated on write().
|
|
434
|
+
|
|
435
|
+
Example:
|
|
436
|
+
import json
|
|
437
|
+
preview = builder.to_dict()
|
|
438
|
+
print(json.dumps(preview, indent=2))
|
|
439
|
+
|
|
440
|
+
"""
|
|
441
|
+
return {
|
|
442
|
+
"version": "1.0",
|
|
443
|
+
"input_files": self._input_files,
|
|
444
|
+
"output_files": self._output_files,
|
|
445
|
+
"user": self._user,
|
|
446
|
+
"metadata": self._metadata,
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
def write(self, path: str | None = None, overwrite: bool = True) -> str:
|
|
450
|
+
"""Write the reduction file to disk.
|
|
451
|
+
|
|
452
|
+
Behavior:
|
|
453
|
+
|
|
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)
|
|
457
|
+
4. Creates parent directories if needed
|
|
458
|
+
5. Writes JSON file with 4-space indentation, sorted keys
|
|
459
|
+
6. Returns absolute path to written file
|
|
460
|
+
|
|
461
|
+
Auto-derived path format:
|
|
462
|
+
|
|
463
|
+
/{facility}/{inst}/{experiment}/shared/autoreduce/{inst}_{run}.json
|
|
464
|
+
|
|
465
|
+
Arguments:
|
|
466
|
+
---------
|
|
467
|
+
path: str | None
|
|
468
|
+
Optional explicit path to write to. If None, auto-derives
|
|
469
|
+
path from first input file with type="raw" and
|
|
470
|
+
purpose="sample-data".
|
|
471
|
+
|
|
472
|
+
overwrite: bool
|
|
473
|
+
Whether to overwrite existing file. Default: True
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
-------
|
|
477
|
+
Absolute path to the written file.
|
|
478
|
+
|
|
479
|
+
Raises:
|
|
480
|
+
------
|
|
481
|
+
ValueError
|
|
482
|
+
If validation fails or path cannot be auto-derived.
|
|
483
|
+
|
|
484
|
+
FileExistsError
|
|
485
|
+
If file exists and overwrite=False.
|
|
486
|
+
|
|
487
|
+
IOError
|
|
488
|
+
If write fails.
|
|
489
|
+
|
|
490
|
+
Example:
|
|
491
|
+
# Auto-derive path
|
|
492
|
+
path = builder.write()
|
|
493
|
+
# /SNS/ARCS/IPTS-12345/shared/autoreduce/ARCS_123456.json
|
|
494
|
+
|
|
495
|
+
# Explicit path
|
|
496
|
+
path = builder.write("/custom/path/reduction.json")
|
|
497
|
+
|
|
498
|
+
# Don't overwrite
|
|
499
|
+
path = builder.write(overwrite=False) # Raises if exists
|
|
500
|
+
|
|
501
|
+
"""
|
|
502
|
+
# 1. Validate
|
|
503
|
+
errors = self.validate()
|
|
504
|
+
if errors:
|
|
505
|
+
error_list = "\n".join(f" - {e}" for e in errors)
|
|
506
|
+
msg = f"Validation failed:\n{error_list}"
|
|
507
|
+
raise ValueError(msg)
|
|
508
|
+
|
|
509
|
+
# 2. Derive path if needed
|
|
510
|
+
if path is None:
|
|
511
|
+
path = self._derive_path()
|
|
512
|
+
|
|
513
|
+
# 3. Check overwrite
|
|
514
|
+
if not overwrite and os.path.exists(path):
|
|
515
|
+
msg = f"File already exists: {path}"
|
|
516
|
+
raise FileExistsError(msg)
|
|
517
|
+
|
|
518
|
+
# 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
|
+
}
|
|
527
|
+
|
|
528
|
+
# 5. Create directories
|
|
529
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
530
|
+
|
|
531
|
+
# 6. Write file
|
|
532
|
+
with open(path, "w") as f:
|
|
533
|
+
json.dump(content, f, indent=4, sort_keys=True)
|
|
534
|
+
|
|
535
|
+
return path
|
|
536
|
+
|
|
537
|
+
def write_safe(
|
|
538
|
+
self, path: str | None = None, overwrite: bool = True
|
|
539
|
+
) -> str | None:
|
|
540
|
+
"""Write the reduction file to disk with error handling. Never raises.
|
|
541
|
+
|
|
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.
|
|
545
|
+
|
|
546
|
+
Arguments:
|
|
547
|
+
---------
|
|
548
|
+
path: str | None
|
|
549
|
+
Same as write().
|
|
550
|
+
|
|
551
|
+
overwrite: bool
|
|
552
|
+
Same as write().
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
-------
|
|
556
|
+
Absolute path to written file on success, None on failure.
|
|
557
|
+
|
|
558
|
+
Example:
|
|
559
|
+
# In autoreduction script - safe, won't crash reduction
|
|
560
|
+
path = builder.write_safe()
|
|
561
|
+
if path:
|
|
562
|
+
print(f"Reduction cataloged: {path}")
|
|
563
|
+
else:
|
|
564
|
+
print("Warning: Cataloging failed (see stderr)")
|
|
565
|
+
|
|
566
|
+
"""
|
|
567
|
+
try:
|
|
568
|
+
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
|
|
573
|
+
msg = f"Warning: Failed to catalog reduction: {e}"
|
|
574
|
+
print(msg, file=sys.stderr)
|
|
575
|
+
traceback.print_exc()
|
|
576
|
+
return None
|
|
577
|
+
|
|
578
|
+
@classmethod
|
|
579
|
+
def load(cls, path: str) -> ReductionFileBuilder:
|
|
580
|
+
"""Load an existing reduction file for modification.
|
|
581
|
+
|
|
582
|
+
Arguments:
|
|
583
|
+
---------
|
|
584
|
+
path: str
|
|
585
|
+
Path to existing JSON reduction file.
|
|
586
|
+
|
|
587
|
+
Returns:
|
|
588
|
+
-------
|
|
589
|
+
New ReductionFileBuilder instance populated with file contents.
|
|
590
|
+
|
|
591
|
+
Raises:
|
|
592
|
+
------
|
|
593
|
+
FileNotFoundError
|
|
594
|
+
If file doesn't exist.
|
|
595
|
+
|
|
596
|
+
ValueError
|
|
597
|
+
If file is not valid JSON or missing required fields.
|
|
598
|
+
|
|
599
|
+
Example:
|
|
600
|
+
# Load and modify existing file
|
|
601
|
+
builder = ReductionFileBuilder.load(
|
|
602
|
+
"/SNS/ARCS/IPTS-12345/shared/autoreduce/ARCS_123456.json"
|
|
603
|
+
)
|
|
604
|
+
builder.add_output(
|
|
605
|
+
new_output_file, type="processed", purpose="reduced-data"
|
|
606
|
+
)
|
|
607
|
+
builder.write() # Overwrites original file
|
|
608
|
+
|
|
609
|
+
"""
|
|
610
|
+
if not os.path.exists(path):
|
|
611
|
+
msg = f"Reduction file not found: {path}"
|
|
612
|
+
raise FileNotFoundError(msg)
|
|
613
|
+
|
|
614
|
+
with open(path) as f:
|
|
615
|
+
content = json.load(f)
|
|
616
|
+
|
|
617
|
+
# Validate required fields
|
|
618
|
+
required = ["user", "metadata", "input_files", "output_files"]
|
|
619
|
+
missing = [f for f in required if f not in content]
|
|
620
|
+
if missing:
|
|
621
|
+
msg = f"Invalid reduction file, missing fields: {missing}"
|
|
622
|
+
raise ValueError(msg)
|
|
623
|
+
|
|
624
|
+
# Create builder
|
|
625
|
+
builder = cls(user=content["user"], metadata=content["metadata"])
|
|
626
|
+
|
|
627
|
+
# Add files (without fields if not present)
|
|
628
|
+
for f in content["input_files"]:
|
|
629
|
+
builder.add_input(
|
|
630
|
+
f["location"],
|
|
631
|
+
f["type"],
|
|
632
|
+
f["purpose"],
|
|
633
|
+
fields=f.get("fields"), # May be None
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
for f in content["output_files"]:
|
|
637
|
+
builder.add_output(
|
|
638
|
+
f["location"],
|
|
639
|
+
f["type"],
|
|
640
|
+
f["purpose"],
|
|
641
|
+
fields=f.get("fields"),
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
return builder
|
|
645
|
+
|
|
646
|
+
# Private helper methods
|
|
647
|
+
|
|
648
|
+
def _resolve_path(self, location: str, relative_to: str | None) -> str:
|
|
649
|
+
"""Resolve location to absolute path.
|
|
650
|
+
|
|
651
|
+
Stores validation errors internally; never raises.
|
|
652
|
+
"""
|
|
653
|
+
# If already absolute, use as-is
|
|
654
|
+
# (validation will enforce canonical roots)
|
|
655
|
+
if os.path.isabs(location):
|
|
656
|
+
return location
|
|
657
|
+
|
|
658
|
+
# If relative and relative_to provided, resolve
|
|
659
|
+
if relative_to:
|
|
660
|
+
return os.path.abspath(os.path.join(relative_to, location))
|
|
661
|
+
|
|
662
|
+
# Relative without relative_to — store error and return as-is
|
|
663
|
+
msg = (
|
|
664
|
+
f"Path must be absolute or have relative_to specified: {location}"
|
|
665
|
+
)
|
|
666
|
+
self._validation_errors.append(msg)
|
|
667
|
+
return location
|
|
668
|
+
|
|
669
|
+
def _derive_path(self) -> str:
|
|
670
|
+
"""Auto-derive JSON file path from first raw sample-data input.
|
|
671
|
+
|
|
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.
|
|
675
|
+
"""
|
|
676
|
+
# Find first raw sample-data input
|
|
677
|
+
raw_sample = None
|
|
678
|
+
for f in self._input_files:
|
|
679
|
+
if f.get("type") == "raw" and f.get("purpose") == "sample-data":
|
|
680
|
+
raw_sample = f["location"]
|
|
681
|
+
break
|
|
682
|
+
|
|
683
|
+
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
|
+
)
|
|
689
|
+
raise ValueError(msg)
|
|
690
|
+
|
|
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)?)$"
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
match = raw_nexus_regex.search(raw_sample)
|
|
702
|
+
if not match:
|
|
703
|
+
msg = (
|
|
704
|
+
f"Cannot parse facility/instrument/experiment from: "
|
|
705
|
+
f"{raw_sample}"
|
|
706
|
+
)
|
|
707
|
+
raise ValueError(msg)
|
|
708
|
+
|
|
709
|
+
facility = match.group("facility")
|
|
710
|
+
instrument = match.group("instrument")
|
|
711
|
+
ipts = match.group("ipts")
|
|
712
|
+
run_number = match.group("run_number")
|
|
713
|
+
experiment = f"IPTS-{ipts}"
|
|
714
|
+
|
|
715
|
+
return (
|
|
716
|
+
f"/{facility}/{instrument}/{experiment}/shared/autoreduce/"
|
|
717
|
+
f"{instrument}_{run_number}.json"
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
@staticmethod
|
|
721
|
+
def _generate_timestamp() -> str:
|
|
722
|
+
"""Generate RFC3339 timestamp with timezone."""
|
|
723
|
+
now = datetime.datetime.now(datetime.timezone.utc).astimezone()
|
|
724
|
+
return now.isoformat()
|
|
@@ -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.4"
|
|
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
|