sapiopycommons 2025.6.19a564__py3-none-any.whl → 2026.1.22a847__py3-none-any.whl

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.
Files changed (63) hide show
  1. sapiopycommons/ai/__init__.py +0 -0
  2. sapiopycommons/ai/agent_service_base.py +2051 -0
  3. sapiopycommons/ai/converter_service_base.py +163 -0
  4. sapiopycommons/ai/external_credentials.py +131 -0
  5. sapiopycommons/ai/protoapi/agent/agent_pb2.py +87 -0
  6. sapiopycommons/ai/protoapi/agent/agent_pb2.pyi +282 -0
  7. sapiopycommons/ai/protoapi/agent/agent_pb2_grpc.py +154 -0
  8. sapiopycommons/ai/protoapi/agent/entry_pb2.py +49 -0
  9. sapiopycommons/ai/protoapi/agent/entry_pb2.pyi +40 -0
  10. sapiopycommons/ai/protoapi/agent/entry_pb2_grpc.py +24 -0
  11. sapiopycommons/ai/protoapi/agent/item/item_container_pb2.py +61 -0
  12. sapiopycommons/ai/protoapi/agent/item/item_container_pb2.pyi +181 -0
  13. sapiopycommons/ai/protoapi/agent/item/item_container_pb2_grpc.py +24 -0
  14. sapiopycommons/ai/protoapi/externalcredentials/external_credentials_pb2.py +41 -0
  15. sapiopycommons/ai/protoapi/externalcredentials/external_credentials_pb2.pyi +36 -0
  16. sapiopycommons/ai/protoapi/externalcredentials/external_credentials_pb2_grpc.py +24 -0
  17. sapiopycommons/ai/protoapi/fielddefinitions/fields_pb2.py +51 -0
  18. sapiopycommons/ai/protoapi/fielddefinitions/fields_pb2.pyi +59 -0
  19. sapiopycommons/ai/protoapi/fielddefinitions/fields_pb2_grpc.py +24 -0
  20. sapiopycommons/ai/protoapi/fielddefinitions/velox_field_def_pb2.py +123 -0
  21. sapiopycommons/ai/protoapi/fielddefinitions/velox_field_def_pb2.pyi +599 -0
  22. sapiopycommons/ai/protoapi/fielddefinitions/velox_field_def_pb2_grpc.py +24 -0
  23. sapiopycommons/ai/protoapi/pipeline/converter/converter_pb2.py +59 -0
  24. sapiopycommons/ai/protoapi/pipeline/converter/converter_pb2.pyi +68 -0
  25. sapiopycommons/ai/protoapi/pipeline/converter/converter_pb2_grpc.py +149 -0
  26. sapiopycommons/ai/protoapi/pipeline/script/script_pb2.py +69 -0
  27. sapiopycommons/ai/protoapi/pipeline/script/script_pb2.pyi +109 -0
  28. sapiopycommons/ai/protoapi/pipeline/script/script_pb2_grpc.py +153 -0
  29. sapiopycommons/ai/protoapi/pipeline/step_output_pb2.py +49 -0
  30. sapiopycommons/ai/protoapi/pipeline/step_output_pb2.pyi +56 -0
  31. sapiopycommons/ai/protoapi/pipeline/step_output_pb2_grpc.py +24 -0
  32. sapiopycommons/ai/protoapi/pipeline/step_pb2.py +43 -0
  33. sapiopycommons/ai/protoapi/pipeline/step_pb2.pyi +44 -0
  34. sapiopycommons/ai/protoapi/pipeline/step_pb2_grpc.py +24 -0
  35. sapiopycommons/ai/protoapi/session/sapio_conn_info_pb2.py +39 -0
  36. sapiopycommons/ai/protoapi/session/sapio_conn_info_pb2.pyi +33 -0
  37. sapiopycommons/ai/protoapi/session/sapio_conn_info_pb2_grpc.py +24 -0
  38. sapiopycommons/ai/protobuf_utils.py +583 -0
  39. sapiopycommons/ai/request_validation.py +561 -0
  40. sapiopycommons/ai/server.py +152 -0
  41. sapiopycommons/ai/test_client.py +534 -0
  42. sapiopycommons/callbacks/callback_util.py +53 -24
  43. sapiopycommons/eln/experiment_handler.py +12 -5
  44. sapiopycommons/files/assay_plate_reader.py +93 -0
  45. sapiopycommons/files/file_text_converter.py +207 -0
  46. sapiopycommons/files/file_util.py +128 -1
  47. sapiopycommons/files/temp_files.py +82 -0
  48. sapiopycommons/flowcyto/flow_cyto.py +2 -24
  49. sapiopycommons/general/accession_service.py +2 -28
  50. sapiopycommons/general/aliases.py +4 -1
  51. sapiopycommons/general/macros.py +172 -0
  52. sapiopycommons/general/time_util.py +199 -4
  53. sapiopycommons/multimodal/multimodal.py +2 -24
  54. sapiopycommons/recordmodel/record_handler.py +200 -111
  55. sapiopycommons/rules/eln_rule_handler.py +3 -0
  56. sapiopycommons/rules/on_save_rule_handler.py +3 -0
  57. sapiopycommons/webhook/webhook_handlers.py +6 -4
  58. sapiopycommons/webhook/webservice_handlers.py +1 -1
  59. {sapiopycommons-2025.6.19a564.dist-info → sapiopycommons-2026.1.22a847.dist-info}/METADATA +2 -2
  60. sapiopycommons-2026.1.22a847.dist-info/RECORD +113 -0
  61. sapiopycommons-2025.6.19a564.dist-info/RECORD +0 -68
  62. {sapiopycommons-2025.6.19a564.dist-info → sapiopycommons-2026.1.22a847.dist-info}/WHEEL +0 -0
  63. {sapiopycommons-2025.6.19a564.dist-info → sapiopycommons-2026.1.22a847.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,82 @@
1
+ import os
2
+ import shutil
3
+ import tempfile
4
+ from typing import Callable, Any
5
+
6
+
7
+ # FR-47422: Created class.
8
+ class TempFileHandler:
9
+ """
10
+ A utility class to manage temporary files and directories.
11
+ """
12
+ directories: list[str]
13
+ files: list[str]
14
+
15
+ def __init__(self):
16
+ self.directories = []
17
+ self.files = []
18
+
19
+ def create_temp_directory(self) -> str:
20
+ """
21
+ Create a temporary directory.
22
+
23
+ :return: The path to a newly created temporary directory.
24
+ """
25
+ directory: str = tempfile.mkdtemp()
26
+ self.directories.append(directory)
27
+ return directory
28
+
29
+ def create_temp_file(self, data: str | bytes, suffix: str = "") -> str:
30
+ """
31
+ Create a temporary file with the specified data and optional suffix.
32
+
33
+ :param data: The data to write to the temporary file.
34
+ :param suffix: An optional suffix for the temporary file.
35
+ :return: The path to a newly created temporary file containing the provided data.
36
+ """
37
+ mode: str = 'w' if isinstance(data, str) else 'wb'
38
+ with tempfile.NamedTemporaryFile(mode=mode, suffix=suffix, delete=False) as tmp_file:
39
+ tmp_file.write(data)
40
+ file_path: str = tmp_file.name
41
+ self.files.append(file_path)
42
+ return file_path
43
+
44
+ def create_temp_file_from_func(self, func: Callable, params: dict[str, Any], suffix: str = "",
45
+ is_binary: bool = True) -> str:
46
+ """
47
+ Create a temporary file and populate it using the provided function. The function should accept parameters as
48
+ specified in the `params` dictionary.
49
+
50
+ :param func: The function to call with the temporary file path that will populate the file.
51
+ :param params: Keyword arguments to pass to the function. If "<NEW_FILE>" is used as a value, it will be
52
+ replaced with the temporary file object. If "<NEW_FILE_PATH>" is used as a value, it will be replaced with
53
+ the temporary file path.
54
+ :param suffix: An optional suffix for the temporary file.
55
+ :param is_binary: Whether to open the temporary file in binary mode.
56
+ :return: The path to the newly created temporary file.
57
+ """
58
+ mode: str = 'wb' if is_binary else 'w'
59
+ with tempfile.NamedTemporaryFile(mode, suffix=suffix, delete=False) as tmp_file:
60
+ for key, value in params.items():
61
+ if value == "<NEW_FILE>":
62
+ params[key] = tmp_file
63
+ elif value == "<NEW_FILE_PATH>":
64
+ params[key] = tmp_file.name
65
+ func(**params)
66
+ file_path: str = tmp_file.name
67
+ self.files.append(file_path)
68
+ return file_path
69
+
70
+ def cleanup(self) -> None:
71
+ """
72
+ Delete all temporary files and directories created by this handler.
73
+ """
74
+ for directory in self.directories:
75
+ if os.path.exists(directory):
76
+ shutil.rmtree(directory)
77
+ self.directories.clear()
78
+
79
+ for file_path in self.files:
80
+ if os.path.exists(file_path):
81
+ os.remove(file_path)
82
+ self.files.clear()
@@ -4,38 +4,16 @@ from weakref import WeakValueDictionary
4
4
 
5
5
  from databind.json import dumps
6
6
  from sapiopylib.rest.User import SapioUser
7
+ from sapiopylib.rest.utils.singletons import SapioContextManager
7
8
 
8
9
  from sapiopycommons.flowcyto.flowcyto_data import FlowJoWorkspaceInputJson, UploadFCSInputJson, \
9
10
  ComputeFlowStatisticsInputJson
10
11
 
11
12
 
12
- class FlowCytoManager:
13
+ class FlowCytoManager(SapioContextManager):
13
14
  """
14
15
  This manager includes flow cytometry analysis tools that would require FlowCyto license to use.
15
16
  """
16
- _user: SapioUser
17
-
18
- __instances: WeakValueDictionary[SapioUser, FlowCytoManager] = WeakValueDictionary()
19
- __initialized: bool
20
-
21
- def __new__(cls, user: SapioUser):
22
- """
23
- Observes singleton pattern per record model manager object.
24
-
25
- :param user: The user that will make the webservice request to the application.
26
- """
27
- obj = cls.__instances.get(user)
28
- if not obj:
29
- obj = object.__new__(cls)
30
- obj.__initialized = False
31
- cls.__instances[user] = obj
32
- return obj
33
-
34
- def __init__(self, user: SapioUser):
35
- if self.__initialized:
36
- return
37
- self._user = user
38
- self.__initialized = True
39
17
 
40
18
  def create_flowjo_workspace(self, workspace_input: FlowJoWorkspaceInputJson) -> int:
41
19
  """
@@ -5,6 +5,7 @@ from typing import Any
5
5
  from weakref import WeakValueDictionary
6
6
 
7
7
  from sapiopylib.rest.User import SapioUser
8
+ from sapiopylib.rest.utils.singletons import SapioContextManager
8
9
 
9
10
  _STR_JAVA_TYPE = "java.lang.String"
10
11
  _INT_JAVA_TYPE = "java.lang.Integer"
@@ -274,37 +275,10 @@ class AccessionServiceDescriptor:
274
275
  }
275
276
 
276
277
 
277
- class AccessionService:
278
+ class AccessionService(SapioContextManager):
278
279
  """
279
280
  Provides Sapio Foundations Accession Service functionalities.
280
281
  """
281
- _user: SapioUser
282
-
283
- __instances: WeakValueDictionary[SapioUser, AccessionService] = WeakValueDictionary()
284
- __initialized: bool
285
-
286
- @property
287
- def user(self) -> SapioUser:
288
- return self._user
289
-
290
- def __new__(cls, user: SapioUser):
291
- """
292
- Observes singleton pattern per record model manager object.
293
-
294
- :param user: The user that will make the webservice request to the application.
295
- """
296
- obj = cls.__instances.get(user)
297
- if not obj:
298
- obj = object.__new__(cls)
299
- obj.__initialized = False
300
- cls.__instances[user] = obj
301
- return obj
302
-
303
- def __init__(self, user: SapioUser):
304
- if self.__initialized:
305
- return
306
- self._user = user
307
- self.__initialized = True
308
282
 
309
283
  def accession_with_config(self, data_type_name: str, data_field_name: str, num_ids: int) -> list[str]:
310
284
  """
@@ -223,9 +223,12 @@ class AliasUtil:
223
223
  # macros get translated to valid field values.
224
224
  fields: FieldMap = {f: record.fields.get(f) for f in record.fields}
225
225
  # PR-47457: Only include the record ID if the caller requests it, since including the record ID can break
226
- # callbacks in certain circumstances if the record ID is negative.
226
+ # callbacks in certain circumstances.
227
+ # PR-47894: Also remove the RecordId key if it exists and the caller doesn't want it included.
227
228
  if include_record_id:
228
229
  fields["RecordId"] = AliasUtil.to_record_id(record)
230
+ elif "RecordId" in fields:
231
+ del fields["RecordId"]
229
232
  return fields
230
233
 
231
234
  @staticmethod
@@ -0,0 +1,172 @@
1
+
2
+ import re
3
+ from datetime import datetime, timedelta, timezone
4
+
5
+ from sapiopycommons.general.exceptions import SapioException
6
+
7
+ date_macro_pattern: str = (
8
+ r"@(?:today|yesterday|thisweek|"
9
+ r"nextmonth|thismonth|lastmonth|"
10
+ r"nextyear|thisyear|lastyear|"
11
+ r"month(?:january|february|march|april|may|june|july|august|september|october|november|december)|"
12
+ r"last\d+days|next\d+days)"
13
+ )
14
+ """A regular expression that can be used to determine whether a given value matches one of the supported date macros."""
15
+
16
+ date_macro_values: list[str] = [
17
+ "@today", "@yesterday", "@thisweek",
18
+ "@nextmonth", "@thismonth", "@lastmonth",
19
+ "@nextyear", "@thisyear", "@lastyear",
20
+ "@monthjanuary", "@monthfebruary", "@monthmarch", "@monthapril", "@monthmay", "@monthjune", "@monthjuly",
21
+ "@monthaugust", "@monthseptember", "@monthoctober", "@monthnovember", "@monthdecember",
22
+ "@next_days", "@last_days"
23
+ ]
24
+ """A list of the supported date macros. For @next_days and @last_days, the underscore is expected to be replaced with
25
+ an integer."""
26
+
27
+
28
+ class MacroParser:
29
+ """
30
+ A utility class for parsing macros used in the Sapio platform.
31
+ """
32
+ _reg_month = re.compile(r"@\w*month\w*")
33
+ _reg_digits = re.compile(r"@\w*(\d+)\w*")
34
+ _reg_last_days = re.compile(r"@last(\d+)days")
35
+ _reg_next_days = re.compile(r"@next(\d+)days")
36
+
37
+ @staticmethod
38
+ def _now() -> datetime:
39
+ """
40
+ :return: A datetime object for the current time in UTC.
41
+ """
42
+ return datetime.now(timezone.utc)
43
+
44
+ @staticmethod
45
+ def _dates_to_timestamps(a: datetime, b: datetime) -> tuple[int, int]:
46
+ """
47
+ Convert the given datetimes to epoch-millisecond timestamps on the start of the first date
48
+ and the end of the second date.
49
+
50
+ :param a: A datetime object.
51
+ :param b: A datetime object.
52
+ :return: A tuple containing the start and end timestamps in milliseconds since the epoch.
53
+ """
54
+ # The start of the date on the first datetime.
55
+ a = a.replace(hour=0, minute=0, second=0, microsecond=0)
56
+ # The end of the date on the second datetime.
57
+ b = b.replace(hour=23, minute=59, second=59, microsecond=999000)
58
+ return int(a.timestamp() * 1000), int(b.timestamp() * 1000)
59
+
60
+ @classmethod
61
+ def parse_date_macro(cls, macro: str) -> tuple[int, int]:
62
+ """
63
+ Convert a date macro string into a range of epoch millisecond timestamps.
64
+ All macros are considered from the current time in UTC.
65
+
66
+ :param macro: A valid date macro string. If an invalid macro is provided, an exception will be raised.
67
+ :return: A tuple containing the start and end timestamps in milliseconds since the epoch for the given macro.
68
+ The returned range is inclusive; the first value is the first millisecond of the starting date
69
+ (00:00:00.000) and the last value is the final millisecond of the ending date (23:59:59.999).
70
+ """
71
+ if macro is None or not macro.strip():
72
+ raise SapioException(f"Invalid macro. None or empty/blank string provided.")
73
+ macro = macro.strip().lower()
74
+
75
+ now: datetime = cls._now()
76
+
77
+ # --- @today: 00:00:00.000 to 23:59:59.999 today ---
78
+ if macro == "@today":
79
+ return cls._dates_to_timestamps(now, now)
80
+
81
+ # --- @yesterday: 00:00:00.000 to 23:59:59.999 yesterday ---
82
+ if macro == "@yesterday":
83
+ yesterday: datetime = now - timedelta(days=1)
84
+ return cls._dates_to_timestamps(yesterday, yesterday)
85
+
86
+ # --- @thisweek: Sunday -> Saturday (inclusive) ---
87
+ if macro == "@thisweek":
88
+ weekday: int = now.weekday() # Monday=0 ... Sunday=6
89
+ # TODO: Some way to control what the first day of the week is considered?
90
+ # +2 = Saturday
91
+ # +1 = Sunday
92
+ # +0 = Monday
93
+ days_since_sunday: int = (weekday + 1) % 7
94
+ sunday: datetime = now - timedelta(days=days_since_sunday)
95
+ saturday: datetime = sunday + timedelta(days=6)
96
+ return cls._dates_to_timestamps(sunday, saturday)
97
+
98
+ # --- last/next N days ---
99
+ if cls._reg_digits.fullmatch(macro):
100
+ # --- @lastNdays ---
101
+ if m := cls._reg_last_days.fullmatch(macro):
102
+ days = int(m.group(1))
103
+ return cls._dates_to_timestamps(now - timedelta(days=days), now)
104
+
105
+ # --- @nextNdays ---
106
+ if m := cls._reg_next_days.fullmatch(macro):
107
+ days = int(m.group(1))
108
+ return cls._dates_to_timestamps(now, now + timedelta(days=days))
109
+
110
+ raise SapioException(f"Invalid macro: {macro}")
111
+
112
+ # --- Month macros ---
113
+ if cls._reg_month.fullmatch(macro):
114
+ year: int = now.year
115
+ month: int = now.month
116
+
117
+ if macro == "@lastmonth":
118
+ month -= 1
119
+ if month == 0:
120
+ year -= 1
121
+ month = 12
122
+ elif macro == "@nextmonth":
123
+ month += 1
124
+ if month == 13:
125
+ year += 1
126
+ month = 1
127
+ # @thismonth uses the current month and year, so no replacement needed.
128
+ elif macro != "@thismonth":
129
+ month_map: dict[str, int] = {
130
+ "@monthjanuary": 1,
131
+ "@monthfebruary": 2,
132
+ "@monthmarch": 3,
133
+ "@monthapril": 4,
134
+ "@monthmay": 5,
135
+ "@monthjune": 6,
136
+ "@monthjuly": 7,
137
+ "@monthaugust": 8,
138
+ "@monthseptember": 9,
139
+ "@monthoctober": 10,
140
+ "@monthnovember": 11,
141
+ "@monthdecember": 12,
142
+ }
143
+ if macro in month_map:
144
+ month = month_map[macro]
145
+ else:
146
+ raise SapioException(f"Invalid macro: {macro}")
147
+
148
+ month_start: datetime = now.replace(year=year, month=month, day=1)
149
+ # Find the first day of next month.
150
+ if month == 12:
151
+ next_month = datetime(year + 1, 1, 1, tzinfo=timezone.utc)
152
+ else:
153
+ next_month = datetime(year, month + 1, 1, tzinfo=timezone.utc)
154
+ # Then subtract one day to find the last day of the start month.
155
+ month_end: datetime = next_month - timedelta(days=1)
156
+
157
+ return cls._dates_to_timestamps(month_start, month_end)
158
+
159
+ # --- Year macros ---
160
+ if macro in ("@thisyear", "@lastyear", "@nextyear"):
161
+ year: int = now.year
162
+ if macro == "@lastyear":
163
+ year -= 1
164
+ elif macro == "@nextyear":
165
+ year += 1
166
+ # No change in year needed for @thisyear.
167
+
168
+ year_start: datetime = now.replace(year=year, month=1, day=1)
169
+ year_end: datetime = year_start.replace(year=year_start.year + 1) - timedelta(days=1)
170
+ return cls._dates_to_timestamps(year_start, year_end)
171
+
172
+ raise SapioException(f"Invalid macro: {macro}")
@@ -133,9 +133,10 @@ class TimeUtil:
133
133
  return datetime.fromtimestamp(millis / 1000, tz).strftime(time_format)
134
134
 
135
135
  @staticmethod
136
- def format_to_millis(time_point: str, time_format: str, timezone: str | int = None) -> int:
136
+ def format_to_millis(time_point: str, time_format: str, timezone: str | int = None) -> int | None:
137
137
  """
138
- Convert the input time from the provided format to milliseconds.
138
+ Convert the input time from the provided format to milliseconds. If None is passed to the time_point parameter,
139
+ None will be returned.
139
140
 
140
141
  :param time_point: The time in some date/time format to convert from.
141
142
  :param time_format: The format that the time_point is in. Documentation for how the time formatting works
@@ -144,6 +145,9 @@ class TimeUtil:
144
145
  timezone variable set by the TimeUtil. A list of valid timezones can be found at
145
146
  https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. May also accept a UTC offset in seconds.
146
147
  """
148
+ if time_point is None:
149
+ return None
150
+
147
151
  tz = TimeUtil.__to_tz(timezone)
148
152
  return int(datetime.strptime(time_point, time_format).replace(tzinfo=tz).timestamp() * 1000)
149
153
 
@@ -166,11 +170,12 @@ class TimeUtil:
166
170
  return TimeUtil.shift_millis(millis, to_timezone, from_timezone)
167
171
 
168
172
  @staticmethod
169
- def shift_millis(millis: int, to_timezone: str = "UTC", from_timezone: str | None = None) -> int:
173
+ def shift_millis(millis: int, to_timezone: str = "UTC", from_timezone: str | None = None) -> int | None:
170
174
  """
171
175
  Take a number of milliseconds for a time in from_timezone and output the epoch timestamp that would display that
172
176
  same time in to_timezone. A use case for this is when dealing with static date fields to convert a provided
173
177
  timestamp to the value necessary to display that timestamp in the same way when viewed in the static date field.
178
+ If None is passed to the millis parameter, None will be returned.
174
179
 
175
180
  :param millis: The time in milliseconds to convert from.
176
181
  :param to_timezone: The timezone to shift to. If not provided, uses UTC.
@@ -180,17 +185,21 @@ class TimeUtil:
180
185
  :return: The epoch timestamp that would display as the same time in to_timezone as the given time in
181
186
  from_timezone.
182
187
  """
188
+ if millis is None:
189
+ return None
190
+
183
191
  to_offset: int = TimeUtil.__get_timezone_offset(to_timezone) * 1000
184
192
  from_offset: int = TimeUtil.__get_timezone_offset(from_timezone) * 1000
185
193
  return millis + from_offset - to_offset
186
194
 
187
195
  @staticmethod
188
196
  def shift_format(time_point: str, time_format: str, to_timezone: str = "UTC", from_timezone: str | None = None) \
189
- -> int:
197
+ -> int | None:
190
198
  """
191
199
  Take a timestamp for a time in from_timezone and output the epoch timestamp that would display that same time
192
200
  in to_timezone. A use case for this is when dealing with static date fields to convert a provided timestamp to
193
201
  the value necessary to display that timestamp in the same way when viewed in the static date field.
202
+ If None is passed to the time_point parameter, None will be returned.
194
203
 
195
204
  :param time_point: The time in some date/time format to convert from.
196
205
  :param time_format: The format that the time_point is in. Documentation for how the time formatting works
@@ -202,6 +211,9 @@ class TimeUtil:
202
211
  :return: The epoch timestamp that would display as the same time in to_timezone as the given time in
203
212
  from_timezone.
204
213
  """
214
+ if time_point is None:
215
+ return None
216
+
205
217
  millis: int = TimeUtil.format_to_millis(time_point, time_format, from_timezone)
206
218
  return TimeUtil.shift_millis(millis, to_timezone, from_timezone)
207
219
 
@@ -215,9 +227,192 @@ class TimeUtil:
215
227
  :param time_format: The format that the time_point should be in. Documentation for how the time formatting works
216
228
  can be found at https://docs.python.org/3.10/library/datetime.html#strftime-and-strptime-behavior
217
229
  """
230
+ if time_point is None:
231
+ return False
218
232
  try:
219
233
  # If this function successfully runs, then the time_point matches the time_format.
220
234
  TimeUtil.format_to_millis(time_point, time_format, "UTC")
221
235
  return True
222
236
  except Exception:
223
237
  return False
238
+
239
+
240
+ MILLISECONDS_IN_A_SECOND: int = 1000
241
+ MILLISECONDS_IN_A_MINUTE: int = 60 * MILLISECONDS_IN_A_SECOND
242
+ MILLISECONDS_IN_AN_HOUR: int = 60 * MILLISECONDS_IN_A_MINUTE
243
+ MILLISECONDS_IN_A_DAY: int = 24 * MILLISECONDS_IN_AN_HOUR
244
+
245
+ class ElapsedTime:
246
+ _start: int
247
+ _end: int
248
+
249
+ _total_days: float
250
+ _total_hours: float
251
+ _total_minutes: float
252
+ _total_seconds: float
253
+ _total_milliseconds: int
254
+
255
+ _days: int
256
+ _hours: int
257
+ _minutes: int
258
+ _seconds: int
259
+ _milliseconds: int
260
+
261
+ def __init__(self, start: int | float, end: int | float | None = None):
262
+ """
263
+ :param start: The start timestamp in milliseconds (int) or seconds (float).
264
+ :param end: The end timestamp in milliseconds (int) or seconds (float). If None, uses the current epoch time in
265
+ milliseconds.
266
+ """
267
+ if isinstance(start, float):
268
+ start = int(start * 1000)
269
+
270
+ if end is None:
271
+ end = TimeUtil.now_in_millis()
272
+ elif isinstance(end, float):
273
+ end = int(end * 1000)
274
+
275
+ self._start = start
276
+ self._end = end
277
+
278
+ self._total_milliseconds = end - start
279
+ self._total_seconds = self._total_milliseconds / MILLISECONDS_IN_A_SECOND
280
+ self._total_minutes = self._total_milliseconds / MILLISECONDS_IN_A_MINUTE
281
+ self._total_hours = self._total_milliseconds / MILLISECONDS_IN_AN_HOUR
282
+ self._total_days = self._total_milliseconds / MILLISECONDS_IN_A_DAY
283
+
284
+ elapsed: int = end - start
285
+ self._days: int = elapsed // MILLISECONDS_IN_A_DAY
286
+ elapsed -= self._days * MILLISECONDS_IN_A_DAY
287
+ self._hours: int = elapsed // MILLISECONDS_IN_AN_HOUR
288
+ elapsed -= self._hours * MILLISECONDS_IN_AN_HOUR
289
+ self._minutes: int = elapsed // MILLISECONDS_IN_A_MINUTE
290
+ elapsed -= self._minutes * MILLISECONDS_IN_A_MINUTE
291
+ self._seconds: int = elapsed // MILLISECONDS_IN_A_SECOND
292
+ elapsed -= self._seconds * MILLISECONDS_IN_A_SECOND
293
+ self._milliseconds = elapsed
294
+
295
+ @staticmethod
296
+ def as_eta(total: int, progress: int, start: int | float, now: int | float | None = None) -> ElapsedTime:
297
+ """
298
+ Calculate the estimated time remaining for a task based on how much time has passed so far and how many items
299
+ have been completed. The estimated time remaining is calculated by determining the average time per item
300
+ completed so far and multiplying that by the number of items remaining.
301
+
302
+ :param total: The total number of items that need to be completed for the task.
303
+ :param progress: The number of items that have been completed so far.
304
+ :param start: The start time of a task in milliseconds (int) or seconds (float).
305
+ :param now: The amount of time that has passed so far while performing the task in milliseconds (int)
306
+ or seconds (float). If None, uses the current epoch time in milliseconds.
307
+ :return: An ElapsedTime object representing the estimated time remaining.
308
+ """
309
+ if now is None:
310
+ now = TimeUtil.now_in_millis()
311
+ is_int: bool = isinstance(start, int) and isinstance(now, int)
312
+ # How much time has elapsed so far.
313
+ elapsed: int | float = now - start
314
+ # The average time it has taken to complete each record so far.
315
+ per_record: int | float = (elapsed // progress) if is_int else (elapsed / progress)
316
+ # The estimated time remaining based on the average time per record.
317
+ remaining: int | float = (total - progress) * per_record
318
+ return ElapsedTime(now, now + remaining)
319
+
320
+ def __str__(self) -> str:
321
+ time_str: str = f"{self._hours:02d}:{self._minutes:02d}:{self._seconds:02d}.{self._milliseconds:03d}"
322
+ if self._days:
323
+ return f"{self._days}d {time_str}"
324
+ return time_str
325
+
326
+ @property
327
+ def start(self) -> int:
328
+ """
329
+ The start timestamp in milliseconds.
330
+ """
331
+ return self._start
332
+
333
+ @property
334
+ def end(self) -> int:
335
+ """
336
+ The end timestamp in milliseconds.
337
+ """
338
+ return self._end
339
+
340
+ @property
341
+ def total_days(self) -> float:
342
+ """
343
+ The total number of days in the elapsed time. For example, an elapsed time of 1.5 days would
344
+ return 1.5.
345
+ """
346
+ return self._total_days
347
+
348
+ @property
349
+ def total_hours(self) -> float:
350
+ """
351
+ The total number of hours in the elapsed time. For example, an elapsed time of 1.5 days would
352
+ return 36.0.
353
+ """
354
+ return self._total_hours
355
+
356
+ @property
357
+ def total_minutes(self) -> float:
358
+ """
359
+ The total number of minutes in the elapsed time. For example, an elapsed time of 1.5 days would
360
+ return 2,160.0.
361
+ """
362
+ return self._total_minutes
363
+
364
+ @property
365
+ def total_seconds(self) -> float:
366
+ """
367
+ The total number of seconds in the elapsed time. For example, an elapsed time of 1.5 days would
368
+ return 129,600.0.
369
+ """
370
+ return self._total_seconds
371
+
372
+ @property
373
+ def total_milliseconds(self) -> int:
374
+ """
375
+ The total number of milliseconds in the elapsed time. For example, an elapsed time of 1.5 days would
376
+ return 129,600,000.
377
+ """
378
+ return self._total_milliseconds
379
+
380
+ @property
381
+ def days(self) -> int:
382
+ """
383
+ The number of full days in the elapsed time. For example, an elapsed time of 1.5 days would
384
+ return 1.
385
+ """
386
+ return self._days
387
+
388
+ @property
389
+ def hours(self) -> int:
390
+ """
391
+ The number of full hours in the elapsed time, not counting full days. For example, an elapsed time of 1.5 days
392
+ would return 12.
393
+ """
394
+ return self._hours
395
+
396
+ @property
397
+ def minutes(self) -> int:
398
+ """
399
+ The number of full minutes in the elapsed time, not counting full hours. For example, an elapsed time of
400
+ 1.5 hours would return 30.
401
+ """
402
+ return self._minutes
403
+
404
+ @property
405
+ def seconds(self) -> int:
406
+ """
407
+ The number of full seconds in the elapsed time, not counting full minutes. For example, an elapsed time of 1
408
+ minute and 45 seconds would return 45.
409
+ """
410
+ return self._seconds
411
+
412
+ @property
413
+ def milliseconds(self) -> int:
414
+ """
415
+ The number of milliseconds in the elapsed time, not counting full seconds. For example, an elapsed time of
416
+ 1.25 seconds would return 250.
417
+ """
418
+ return self._milliseconds
@@ -7,35 +7,13 @@ from weakref import WeakValueDictionary
7
7
  from databind.json import dumps, loads
8
8
  from sapiopylib.rest.User import SapioUser
9
9
  from sapiopylib.rest.pojo.DataRecord import DataRecord
10
+ from sapiopylib.rest.utils.singletons import SapioContextManager
10
11
 
11
12
  from sapiopycommons.general.exceptions import SapioException
12
13
  from sapiopycommons.multimodal.multimodal_data import *
13
14
 
14
15
 
15
- class MultiModalManager:
16
- _user: SapioUser
17
-
18
- __instances: WeakValueDictionary[SapioUser, MultiModalManager] = WeakValueDictionary()
19
- __initialized: bool
20
-
21
- def __new__(cls, user: SapioUser):
22
- """
23
- Observes singleton pattern per record model manager object.
24
-
25
- :param user: The user that will make the webservice request to the application.
26
- """
27
- obj = cls.__instances.get(user)
28
- if not obj:
29
- obj = object.__new__(cls)
30
- obj.__initialized = False
31
- cls.__instances[user] = obj
32
- return obj
33
-
34
- def __init__(self, user:SapioUser):
35
- if self.__initialized:
36
- return
37
- self._user = user
38
- self.__initialized = True
16
+ class MultiModalManager(SapioContextManager):
39
17
 
40
18
  def load_image_data(self, request: ImageDataRequestPojo) -> list[str]:
41
19
  """