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.3
1
+ Metadata-Version: 2.4
2
2
  Name: pyoncat
3
- Version: 2.1
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
- Requires-Dist: oauthlib
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
- from requests.packages.urllib3.exceptions import InsecureRequestWarning
25
+ import urllib3
26
+ from oauthlib.oauth2 import rfc6749
27
+ from urllib3.exceptions import InsecureRequestWarning
26
28
 
27
- __version__ = "2.1"
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
- CLIENT_CREDENTIALS_FLOW = "client"
44
- RESOURCE_OWNER_CREDENTIALS_FLOW = "resource_owner"
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[[], str | None] | None
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[[str | None], None] | None
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: CLIENT_CREDENTIALS_FLOW | RESOURCE_OWNER_CREDENTIALS_FLOW | None
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[[], str | None] | None = None,
152
- token_setter: Callable[[str | None], None] | None = None,
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: CLIENT_CREDENTIALS_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 = requests.utils.quote(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 oauthlib.oauth2.rfc6749.errors.InvalidGrantError as error:
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
- requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
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 oauthlib.oauth2.rfc6749.errors.InvalidClientError as error:
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 oauthlib.oauth2.rfc6749.errors.InvalidClientError as error:
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 oauthlib.oauth2.rfc6749.errors.InvalidGrantError as error:
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
- self._content = content
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
- def __init__(self, parent_oncat):
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._parent_oncat_weakref()
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._parent_oncat_weakref()
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._parent_oncat_weakref()
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._parent_oncat_weakref()
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._parent_oncat_weakref()
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
- else:
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._parent_oncat_weakref()
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 = "experimental"
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 = "experimental"
841
+ _NAMESPACE = "api"
779
842
 
780
843
 
781
844
  class Mapping(_ListableONCatMixin):
782
845
  _RESOURCE_ENDPOINT = "mappings"
783
- _NAMESPACE = "experimental"
846
+ _NAMESPACE = "settings"
784
847
 
785
848
 
786
849
  class Proposal(_ListableONCatMixin):
787
850
  _RESOURCE_ENDPOINT = "proposals"
788
- _NAMESPACE = "experimental"
851
+ _NAMESPACE = "api"
789
852
 
790
853
 
791
854
  class Item(_ListableONCatMixin):
792
855
  _RESOURCE_ENDPOINT = "items"
793
- _NAMESPACE = "experimental"
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: str | None):
1000
+ def set_token(self, token: dict | None):
893
1001
  self._token = token
894
1002
 
895
- def get_token(self) -> str | None:
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, however the parent
903
- directory must exist and you must have write permissions else an
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) -> str | None:
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: str | None):
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.1"
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