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
1
+ Metadata-Version: 2.4
2
2
  Name: pyoncat
3
- Version: 2.0
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
- Requires-Dist: oauthlib
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
18
  Requires-Dist: requests
19
- Requires-Dist: requests-oauthlib
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
- from requests.packages.urllib3.exceptions import InsecureRequestWarning
25
+ import urllib3
26
+ from oauthlib.oauth2 import rfc6749
27
+ from urllib3.exceptions import InsecureRequestWarning
27
28
 
28
- __version__ = "2.0"
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
- CLIENT_CREDENTIALS_FLOW = "client"
45
- 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)
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[[], str | None] | None
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[[str | None], None] | None
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: CLIENT_CREDENTIALS_FLOW | RESOURCE_OWNER_CREDENTIALS_FLOW | None
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[[], str | None] | None = None,
153
- 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,
154
193
  login_prompt: Callable[[], tuple[str, str]] | None = None,
155
194
  api_token: str | None = None,
156
- flow: CLIENT_CREDENTIALS_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 = requests.utils.quote(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 oauthlib.oauth2.rfc6749.errors.InvalidGrantError as error:
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
- requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
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 oauthlib.oauth2.rfc6749.errors.InvalidClientError as error:
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 oauthlib.oauth2.rfc6749.errors.InvalidClientError as error:
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 oauthlib.oauth2.rfc6749.errors.InvalidGrantError as error:
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
- 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 {}
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
- unicode_repr = "<{} at {}> JSON: {}".format(
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
- def __init__(self, parent_oncat):
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._parent_oncat_weakref()
708
+ oncat = self._parent
661
709
 
662
- if identifier.startswith("/"):
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._parent_oncat_weakref()
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._parent_oncat_weakref()
730
+ def place(self, identifier: str, data: Any, **kwargs):
731
+ oncat = self._parent
685
732
 
686
- if identifier.startswith("/"):
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._parent_oncat_weakref()
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._parent_oncat_weakref()
750
+ def remove(self, identifier: str, **kwargs):
751
+ oncat = self._parent
706
752
 
707
- if identifier.startswith("/"):
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
- else:
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._parent_oncat_weakref()
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 = "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"
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 = "experimental"
841
+ _NAMESPACE = "api"
787
842
 
788
843
 
789
844
  class Mapping(_ListableONCatMixin):
790
845
  _RESOURCE_ENDPOINT = "mappings"
791
- _NAMESPACE = "experimental"
846
+ _NAMESPACE = "settings"
792
847
 
793
848
 
794
849
  class Proposal(_ListableONCatMixin):
795
850
  _RESOURCE_ENDPOINT = "proposals"
796
- _NAMESPACE = "experimental"
851
+ _NAMESPACE = "api"
797
852
 
798
853
 
799
854
  class Item(_ListableONCatMixin):
800
855
  _RESOURCE_ENDPOINT = "items"
801
- _NAMESPACE = "experimental"
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: str | None):
1000
+ def set_token(self, token: dict | None):
896
1001
  self._token = token
897
1002
 
898
- def get_token(self) -> str | None:
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) -> str | None:
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: str | None):
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.0"
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