pyoncat 2.0__tar.gz → 2.3__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,8 +1,7 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: pyoncat
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3
|
|
4
4
|
Summary: A Python Client for ONCat (the ORNL Neutron Catalog).
|
|
5
|
-
Home-page: https://oncat.ornl.gov
|
|
6
5
|
Keywords: ornl,neutron,metadata,catalog
|
|
7
6
|
Author: ONCat Support
|
|
8
7
|
Author-email: oncat-support@ornl.gov
|
|
@@ -14,9 +13,10 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
14
13
|
Classifier: Programming Language :: Python :: 3.10
|
|
15
14
|
Classifier: Programming Language :: Python :: 3.11
|
|
16
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
-
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
18
|
Requires-Dist: requests
|
|
19
|
-
|
|
19
|
+
Project-URL: Homepage, https://oncat.ornl.gov
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
21
21
|
|
|
22
22
|
Welcome to PyONCat, a Python client designed to facilitate interaction with the ONCat API.
|
|
@@ -9,29 +9,37 @@ 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
|
|
16
15
|
import os
|
|
17
16
|
import pathlib
|
|
18
|
-
import sys
|
|
19
17
|
import warnings
|
|
20
18
|
import weakref
|
|
21
|
-
from typing import Any, Callable, ClassVar
|
|
19
|
+
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Literal
|
|
20
|
+
from urllib.parse import quote
|
|
22
21
|
|
|
23
22
|
import oauthlib.oauth2
|
|
24
23
|
import requests
|
|
25
24
|
import requests_oauthlib
|
|
26
|
-
|
|
25
|
+
import urllib3
|
|
26
|
+
from oauthlib.oauth2 import rfc6749
|
|
27
|
+
from urllib3.exceptions import InsecureRequestWarning
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
from pyoncat.reduction import ReductionFileBuilder
|
|
30
|
+
|
|
31
|
+
__version__ = "2.3"
|
|
32
|
+
|
|
33
|
+
# Constant for autoreduction user
|
|
34
|
+
AUTOREDUCTION_USER = "auto"
|
|
29
35
|
|
|
30
36
|
__all__ = [
|
|
37
|
+
"AUTOREDUCTION_USER",
|
|
31
38
|
"FileSystemTokenStore",
|
|
32
39
|
"ONCat",
|
|
33
40
|
"ONCatObject",
|
|
34
41
|
"PyONCatError",
|
|
42
|
+
"ReductionFileBuilder",
|
|
35
43
|
"prompt_user_for_login",
|
|
36
44
|
]
|
|
37
45
|
|
|
@@ -41,8 +49,15 @@ __all__ = [
|
|
|
41
49
|
########################################################################
|
|
42
50
|
|
|
43
51
|
|
|
44
|
-
|
|
45
|
-
|
|
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)
|
|
46
61
|
|
|
47
62
|
|
|
48
63
|
class ONCat:
|
|
@@ -102,11 +117,11 @@ class ONCat:
|
|
|
102
117
|
The client secret for the OAuth client, which may be
|
|
103
118
|
required alongside the client ID if you are using one.
|
|
104
119
|
|
|
105
|
-
token_getter: Callable[[],
|
|
120
|
+
token_getter: Callable[[], dict | None] | None
|
|
106
121
|
An optional function that will be used to load the current
|
|
107
122
|
OAuth token if one exists, else `None`.
|
|
108
123
|
|
|
109
|
-
token_setter: Callable[[
|
|
124
|
+
token_setter: Callable[[dict | None], None] | None
|
|
110
125
|
An optional function that will be used to save the current
|
|
111
126
|
OAuth token, to be retrieved later by the `token_getter`.
|
|
112
127
|
|
|
@@ -119,7 +134,7 @@ class ONCat:
|
|
|
119
134
|
An optional API token that can be used by select clients to
|
|
120
135
|
authenticate requests to the API as an alternative to OAuth.
|
|
121
136
|
|
|
122
|
-
flow:
|
|
137
|
+
flow: FlowType | None
|
|
123
138
|
The OAuth "flow" to use for authentication. Required if you
|
|
124
139
|
are using OAuth.
|
|
125
140
|
|
|
@@ -144,18 +159,40 @@ class ONCat:
|
|
|
144
159
|
|
|
145
160
|
"""
|
|
146
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
|
+
|
|
147
186
|
def __init__(
|
|
148
187
|
self,
|
|
149
188
|
url: str,
|
|
150
189
|
client_id: str | None = None,
|
|
151
190
|
client_secret: str | None = None,
|
|
152
|
-
token_getter: Callable[[],
|
|
153
|
-
token_setter: Callable[[
|
|
191
|
+
token_getter: Callable[[], dict | None] | None = None,
|
|
192
|
+
token_setter: Callable[[dict | None], None] | None = None,
|
|
154
193
|
login_prompt: Callable[[], tuple[str, str]] | None = None,
|
|
155
194
|
api_token: str | None = None,
|
|
156
|
-
flow:
|
|
157
|
-
| RESOURCE_OWNER_CREDENTIALS_FLOW
|
|
158
|
-
| None = None,
|
|
195
|
+
flow: FlowType | None = None,
|
|
159
196
|
scopes: list[str] | None = None,
|
|
160
197
|
verify: bool = True,
|
|
161
198
|
timeout: int | None = None,
|
|
@@ -202,7 +239,7 @@ class ONCat:
|
|
|
202
239
|
self._client_secret = client_secret
|
|
203
240
|
self._url = url
|
|
204
241
|
self._flow = flow
|
|
205
|
-
self._scopes = scopes
|
|
242
|
+
self._scopes: list[Any] | None = scopes
|
|
206
243
|
self._verify = verify
|
|
207
244
|
self._timeout = timeout
|
|
208
245
|
|
|
@@ -228,7 +265,7 @@ class ONCat:
|
|
|
228
265
|
self._call_method("delete", url, None, **kwargs)
|
|
229
266
|
|
|
230
267
|
def _call_method(self, method, url, data, **kwargs):
|
|
231
|
-
url =
|
|
268
|
+
url = quote(url)
|
|
232
269
|
full_url = (
|
|
233
270
|
self._url + url if url.startswith("/") else self._url + "/" + url
|
|
234
271
|
)
|
|
@@ -275,7 +312,7 @@ class ONCat:
|
|
|
275
312
|
|
|
276
313
|
try:
|
|
277
314
|
return send_request()
|
|
278
|
-
except
|
|
315
|
+
except rfc6749.errors.InvalidGrantError as error:
|
|
279
316
|
if (
|
|
280
317
|
self._flow == RESOURCE_OWNER_CREDENTIALS_FLOW
|
|
281
318
|
and "(invalid_grant)" in str(error)
|
|
@@ -335,7 +372,7 @@ class ONCat:
|
|
|
335
372
|
):
|
|
336
373
|
# Ignore invalid certs and lack of SSL for OAuth if
|
|
337
374
|
# deploying locally.
|
|
338
|
-
|
|
375
|
+
urllib3.disable_warnings(InsecureRequestWarning)
|
|
339
376
|
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
|
|
340
377
|
|
|
341
378
|
return (
|
|
@@ -390,6 +427,7 @@ class ONCat:
|
|
|
390
427
|
def _login_client_credentials(self):
|
|
391
428
|
with warnings.catch_warnings():
|
|
392
429
|
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
|
430
|
+
assert self._client_id
|
|
393
431
|
self._oauth_client = requests_oauthlib.OAuth2Session(
|
|
394
432
|
client=oauthlib.oauth2.BackendApplicationClient(
|
|
395
433
|
client_id=self._client_id,
|
|
@@ -409,7 +447,7 @@ class ONCat:
|
|
|
409
447
|
timeout=self._timeout,
|
|
410
448
|
)
|
|
411
449
|
|
|
412
|
-
except
|
|
450
|
+
except rfc6749.errors.InvalidClientError as error:
|
|
413
451
|
msg = (
|
|
414
452
|
"You seem to have provided some invalid client "
|
|
415
453
|
"credentials. Are you sure they are correct?"
|
|
@@ -428,6 +466,7 @@ class ONCat:
|
|
|
428
466
|
)
|
|
429
467
|
raise LoginRequiredError(msg)
|
|
430
468
|
else:
|
|
469
|
+
assert self._client_id
|
|
431
470
|
initial_oauth_client = requests_oauthlib.OAuth2Session(
|
|
432
471
|
client=oauthlib.oauth2.LegacyApplicationClient(
|
|
433
472
|
client_id=self._client_id,
|
|
@@ -448,13 +487,13 @@ class ONCat:
|
|
|
448
487
|
timeout=self._timeout,
|
|
449
488
|
)
|
|
450
489
|
self._save_token(token)
|
|
451
|
-
except
|
|
490
|
+
except rfc6749.errors.InvalidClientError as error:
|
|
452
491
|
msg = (
|
|
453
492
|
"You seem to have provided some invalid client "
|
|
454
493
|
"credentials. Are you sure they are correct?"
|
|
455
494
|
)
|
|
456
495
|
raise InvalidClientCredentialsError(msg) from error
|
|
457
|
-
except
|
|
496
|
+
except rfc6749.errors.InvalidGrantError as error:
|
|
458
497
|
if "(invalid_grant)" in str(
|
|
459
498
|
error
|
|
460
499
|
) and "username & password" in str(error):
|
|
@@ -518,8 +557,10 @@ class ONCatObject:
|
|
|
518
557
|
|
|
519
558
|
"""
|
|
520
559
|
|
|
521
|
-
def __init__(self, content: dict):
|
|
522
|
-
|
|
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 {}
|
|
523
564
|
|
|
524
565
|
def __repr__(self):
|
|
525
566
|
# https://stackoverflow.com/a/2626364/778572
|
|
@@ -529,20 +570,16 @@ class ONCatObject:
|
|
|
529
570
|
"id={}".format(self._content.get("id")),
|
|
530
571
|
]
|
|
531
572
|
|
|
532
|
-
|
|
573
|
+
return "<{} at {}> JSON: {}".format(
|
|
533
574
|
" ".join(ident_parts),
|
|
534
575
|
hex(id(self)),
|
|
535
576
|
str(self),
|
|
536
577
|
)
|
|
537
578
|
|
|
538
|
-
if sys.version_info[0] < 3:
|
|
539
|
-
return unicode_repr.encode("utf-8")
|
|
540
|
-
return unicode_repr
|
|
541
|
-
|
|
542
579
|
def __str__(self):
|
|
543
580
|
return json.dumps(self._content, sort_keys=True, indent=4)
|
|
544
581
|
|
|
545
|
-
def __getattr__(self, k):
|
|
582
|
+
def __getattr__(self, k) -> Any:
|
|
546
583
|
if k[0] == "_":
|
|
547
584
|
raise AttributeError(k)
|
|
548
585
|
|
|
@@ -551,7 +588,7 @@ class ONCatObject:
|
|
|
551
588
|
except KeyError as e:
|
|
552
589
|
raise AttributeError(*e.args) from e
|
|
553
590
|
|
|
554
|
-
def __getitem__(self, key):
|
|
591
|
+
def __getitem__(self, key) -> Any:
|
|
555
592
|
elements = key.split(".")
|
|
556
593
|
current = elements[0]
|
|
557
594
|
remainder = elements[1:] if len(elements) > 1 else None
|
|
@@ -653,14 +690,24 @@ class _ONCatMixinMeta(type):
|
|
|
653
690
|
|
|
654
691
|
|
|
655
692
|
class _ONCatResource(metaclass=_ONCatMixinMeta):
|
|
656
|
-
|
|
693
|
+
_NAMESPACE: str
|
|
694
|
+
_RESOURCE_ENDPOINT: str
|
|
695
|
+
|
|
696
|
+
def __init__(self, parent_oncat: ONCat):
|
|
657
697
|
self._parent_oncat_weakref = weakref.ref(parent_oncat)
|
|
658
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
|
+
|
|
659
707
|
def retrieve(self, identifier, **kwargs):
|
|
660
|
-
oncat = self.
|
|
708
|
+
oncat = self._parent
|
|
661
709
|
|
|
662
|
-
|
|
663
|
-
identifier = identifier[1:]
|
|
710
|
+
identifier = identifier.removeprefix("/")
|
|
664
711
|
|
|
665
712
|
path = f"{self._NAMESPACE}/{self._RESOURCE_ENDPOINT}/{identifier}"
|
|
666
713
|
|
|
@@ -669,7 +716,7 @@ class _ONCatResource(metaclass=_ONCatMixinMeta):
|
|
|
669
716
|
|
|
670
717
|
class _ListableONCatMixin(_ONCatResource):
|
|
671
718
|
def list(self, **kwargs):
|
|
672
|
-
oncat = self.
|
|
719
|
+
oncat = self._parent
|
|
673
720
|
|
|
674
721
|
path = f"{self._NAMESPACE}/{self._RESOURCE_ENDPOINT}"
|
|
675
722
|
|
|
@@ -680,11 +727,10 @@ class _ListableONCatMixin(_ONCatResource):
|
|
|
680
727
|
|
|
681
728
|
|
|
682
729
|
class _UpdatableONCatMixin(_ONCatResource):
|
|
683
|
-
def place(self, identifier, data, **kwargs):
|
|
684
|
-
oncat = self.
|
|
730
|
+
def place(self, identifier: str, data: Any, **kwargs):
|
|
731
|
+
oncat = self._parent
|
|
685
732
|
|
|
686
|
-
|
|
687
|
-
identifier = identifier[1:]
|
|
733
|
+
identifier = identifier.removeprefix("/")
|
|
688
734
|
|
|
689
735
|
path = f"{self._NAMESPACE}/{self._RESOURCE_ENDPOINT}/{identifier}"
|
|
690
736
|
|
|
@@ -693,7 +739,7 @@ class _UpdatableONCatMixin(_ONCatResource):
|
|
|
693
739
|
|
|
694
740
|
class _CreatableONCatMixin(_ONCatResource):
|
|
695
741
|
def create(self, data, **kwargs):
|
|
696
|
-
oncat = self.
|
|
742
|
+
oncat = self._parent
|
|
697
743
|
|
|
698
744
|
path = f"{self._NAMESPACE}/{self._RESOURCE_ENDPOINT}"
|
|
699
745
|
|
|
@@ -701,11 +747,10 @@ class _CreatableONCatMixin(_ONCatResource):
|
|
|
701
747
|
|
|
702
748
|
|
|
703
749
|
class _RemovableONCatMixin(_ONCatResource):
|
|
704
|
-
def remove(self, identifier, **kwargs):
|
|
705
|
-
oncat = self.
|
|
750
|
+
def remove(self, identifier: str, **kwargs):
|
|
751
|
+
oncat = self._parent
|
|
706
752
|
|
|
707
|
-
|
|
708
|
-
identifier = identifier[1:]
|
|
753
|
+
identifier = identifier.removeprefix("/")
|
|
709
754
|
|
|
710
755
|
path = f"{self._NAMESPACE}/{self._RESOURCE_ENDPOINT}/{identifier}"
|
|
711
756
|
|
|
@@ -729,7 +774,7 @@ class _ControllerONCatMixin(_ONCatResource):
|
|
|
729
774
|
else:
|
|
730
775
|
identifier = None
|
|
731
776
|
data = args[0]
|
|
732
|
-
|
|
777
|
+
elif len(args) == 2:
|
|
733
778
|
if not isinstance(args[0], str):
|
|
734
779
|
raise TypeError
|
|
735
780
|
identifier = args[0]
|
|
@@ -738,7 +783,7 @@ class _ControllerONCatMixin(_ONCatResource):
|
|
|
738
783
|
if identifier is not None and identifier.startswith("/"):
|
|
739
784
|
identifier = identifier[1:]
|
|
740
785
|
|
|
741
|
-
oncat = self.
|
|
786
|
+
oncat = self._parent
|
|
742
787
|
|
|
743
788
|
if identifier is not None:
|
|
744
789
|
path = (
|
|
@@ -773,7 +818,17 @@ class Experiment(_ListableONCatMixin):
|
|
|
773
818
|
|
|
774
819
|
class Run(_ListableONCatMixin):
|
|
775
820
|
_RESOURCE_ENDPOINT = "runs"
|
|
776
|
-
_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"
|
|
777
832
|
|
|
778
833
|
|
|
779
834
|
class Group(_ListableONCatMixin):
|
|
@@ -783,22 +838,22 @@ class Group(_ListableONCatMixin):
|
|
|
783
838
|
|
|
784
839
|
class Grouping(_ListableONCatMixin):
|
|
785
840
|
_RESOURCE_ENDPOINT = "groupings"
|
|
786
|
-
_NAMESPACE = "
|
|
841
|
+
_NAMESPACE = "api"
|
|
787
842
|
|
|
788
843
|
|
|
789
844
|
class Mapping(_ListableONCatMixin):
|
|
790
845
|
_RESOURCE_ENDPOINT = "mappings"
|
|
791
|
-
_NAMESPACE = "
|
|
846
|
+
_NAMESPACE = "settings"
|
|
792
847
|
|
|
793
848
|
|
|
794
849
|
class Proposal(_ListableONCatMixin):
|
|
795
850
|
_RESOURCE_ENDPOINT = "proposals"
|
|
796
|
-
_NAMESPACE = "
|
|
851
|
+
_NAMESPACE = "api"
|
|
797
852
|
|
|
798
853
|
|
|
799
854
|
class Item(_ListableONCatMixin):
|
|
800
855
|
_RESOURCE_ENDPOINT = "items"
|
|
801
|
-
_NAMESPACE = "
|
|
856
|
+
_NAMESPACE = "api"
|
|
802
857
|
|
|
803
858
|
|
|
804
859
|
class Archive(
|
|
@@ -808,6 +863,11 @@ class Archive(
|
|
|
808
863
|
_NAMESPACE = "api"
|
|
809
864
|
|
|
810
865
|
|
|
866
|
+
class Cycle(_ListableONCatMixin, _ControllerONCatMixin):
|
|
867
|
+
_RESOURCE_ENDPOINT = "cycles"
|
|
868
|
+
_NAMESPACE = "api"
|
|
869
|
+
|
|
870
|
+
|
|
811
871
|
class Datafile(
|
|
812
872
|
_ControllerONCatMixin,
|
|
813
873
|
_CreatableONCatMixin,
|
|
@@ -874,6 +934,51 @@ class Template(
|
|
|
874
934
|
_NAMESPACE = "settings"
|
|
875
935
|
|
|
876
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
|
+
|
|
877
982
|
########################################################################
|
|
878
983
|
# Misc Tools
|
|
879
984
|
########################################################################
|
|
@@ -892,10 +997,10 @@ class _InMemoryTokenStore:
|
|
|
892
997
|
def __init__(self):
|
|
893
998
|
self._token = None
|
|
894
999
|
|
|
895
|
-
def set_token(self, token:
|
|
1000
|
+
def set_token(self, token: dict | None):
|
|
896
1001
|
self._token = token
|
|
897
1002
|
|
|
898
|
-
def get_token(self) ->
|
|
1003
|
+
def get_token(self) -> dict | None:
|
|
899
1004
|
return self._token
|
|
900
1005
|
|
|
901
1006
|
|
|
@@ -922,7 +1027,7 @@ class FileSystemTokenStore:
|
|
|
922
1027
|
def __init__(self, path: str):
|
|
923
1028
|
self.path = path
|
|
924
1029
|
|
|
925
|
-
def read_token(self) ->
|
|
1030
|
+
def read_token(self) -> dict | None:
|
|
926
1031
|
"""Read the token from the file on disk, if it exists.
|
|
927
1032
|
|
|
928
1033
|
Return:
|
|
@@ -936,7 +1041,7 @@ class FileSystemTokenStore:
|
|
|
936
1041
|
with open(self.path) as storage:
|
|
937
1042
|
return json.load(storage)
|
|
938
1043
|
|
|
939
|
-
def write_token(self, token:
|
|
1044
|
+
def write_token(self, token: dict | None):
|
|
940
1045
|
"""Write the token to the file on disk.
|
|
941
1046
|
|
|
942
1047
|
Arguments:
|
|
@@ -1016,14 +1121,6 @@ def _yield_nodes(d, path, include_branches):
|
|
|
1016
1121
|
class PyONCatError(Exception):
|
|
1017
1122
|
"""A base exception for all errors that may be raised by PyONCat."""
|
|
1018
1123
|
|
|
1019
|
-
def __init__(self, message=None, original_error=None):
|
|
1020
|
-
if not message:
|
|
1021
|
-
message = ""
|
|
1022
|
-
with contextlib.suppress(Exception):
|
|
1023
|
-
message += " [" + original_error.response.json()["message"] + "]"
|
|
1024
|
-
super().__init__(message)
|
|
1025
|
-
self.original_error = original_error
|
|
1026
|
-
|
|
1027
1124
|
|
|
1028
1125
|
class UnauthorizedError(PyONCatError):
|
|
1029
1126
|
"""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.3"
|
|
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
|