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.
- sapiopycommons/ai/__init__.py +0 -0
- sapiopycommons/ai/agent_service_base.py +2051 -0
- sapiopycommons/ai/converter_service_base.py +163 -0
- sapiopycommons/ai/external_credentials.py +131 -0
- sapiopycommons/ai/protoapi/agent/agent_pb2.py +87 -0
- sapiopycommons/ai/protoapi/agent/agent_pb2.pyi +282 -0
- sapiopycommons/ai/protoapi/agent/agent_pb2_grpc.py +154 -0
- sapiopycommons/ai/protoapi/agent/entry_pb2.py +49 -0
- sapiopycommons/ai/protoapi/agent/entry_pb2.pyi +40 -0
- sapiopycommons/ai/protoapi/agent/entry_pb2_grpc.py +24 -0
- sapiopycommons/ai/protoapi/agent/item/item_container_pb2.py +61 -0
- sapiopycommons/ai/protoapi/agent/item/item_container_pb2.pyi +181 -0
- sapiopycommons/ai/protoapi/agent/item/item_container_pb2_grpc.py +24 -0
- sapiopycommons/ai/protoapi/externalcredentials/external_credentials_pb2.py +41 -0
- sapiopycommons/ai/protoapi/externalcredentials/external_credentials_pb2.pyi +36 -0
- sapiopycommons/ai/protoapi/externalcredentials/external_credentials_pb2_grpc.py +24 -0
- sapiopycommons/ai/protoapi/fielddefinitions/fields_pb2.py +51 -0
- sapiopycommons/ai/protoapi/fielddefinitions/fields_pb2.pyi +59 -0
- sapiopycommons/ai/protoapi/fielddefinitions/fields_pb2_grpc.py +24 -0
- sapiopycommons/ai/protoapi/fielddefinitions/velox_field_def_pb2.py +123 -0
- sapiopycommons/ai/protoapi/fielddefinitions/velox_field_def_pb2.pyi +599 -0
- sapiopycommons/ai/protoapi/fielddefinitions/velox_field_def_pb2_grpc.py +24 -0
- sapiopycommons/ai/protoapi/pipeline/converter/converter_pb2.py +59 -0
- sapiopycommons/ai/protoapi/pipeline/converter/converter_pb2.pyi +68 -0
- sapiopycommons/ai/protoapi/pipeline/converter/converter_pb2_grpc.py +149 -0
- sapiopycommons/ai/protoapi/pipeline/script/script_pb2.py +69 -0
- sapiopycommons/ai/protoapi/pipeline/script/script_pb2.pyi +109 -0
- sapiopycommons/ai/protoapi/pipeline/script/script_pb2_grpc.py +153 -0
- sapiopycommons/ai/protoapi/pipeline/step_output_pb2.py +49 -0
- sapiopycommons/ai/protoapi/pipeline/step_output_pb2.pyi +56 -0
- sapiopycommons/ai/protoapi/pipeline/step_output_pb2_grpc.py +24 -0
- sapiopycommons/ai/protoapi/pipeline/step_pb2.py +43 -0
- sapiopycommons/ai/protoapi/pipeline/step_pb2.pyi +44 -0
- sapiopycommons/ai/protoapi/pipeline/step_pb2_grpc.py +24 -0
- sapiopycommons/ai/protoapi/session/sapio_conn_info_pb2.py +39 -0
- sapiopycommons/ai/protoapi/session/sapio_conn_info_pb2.pyi +33 -0
- sapiopycommons/ai/protoapi/session/sapio_conn_info_pb2_grpc.py +24 -0
- sapiopycommons/ai/protobuf_utils.py +583 -0
- sapiopycommons/ai/request_validation.py +561 -0
- sapiopycommons/ai/server.py +152 -0
- sapiopycommons/ai/test_client.py +534 -0
- sapiopycommons/callbacks/callback_util.py +53 -24
- sapiopycommons/eln/experiment_handler.py +12 -5
- sapiopycommons/files/assay_plate_reader.py +93 -0
- sapiopycommons/files/file_text_converter.py +207 -0
- sapiopycommons/files/file_util.py +128 -1
- sapiopycommons/files/temp_files.py +82 -0
- sapiopycommons/flowcyto/flow_cyto.py +2 -24
- sapiopycommons/general/accession_service.py +2 -28
- sapiopycommons/general/aliases.py +4 -1
- sapiopycommons/general/macros.py +172 -0
- sapiopycommons/general/time_util.py +199 -4
- sapiopycommons/multimodal/multimodal.py +2 -24
- sapiopycommons/recordmodel/record_handler.py +200 -111
- sapiopycommons/rules/eln_rule_handler.py +3 -0
- sapiopycommons/rules/on_save_rule_handler.py +3 -0
- sapiopycommons/webhook/webhook_handlers.py +6 -4
- sapiopycommons/webhook/webservice_handlers.py +1 -1
- {sapiopycommons-2025.6.19a564.dist-info → sapiopycommons-2026.1.22a847.dist-info}/METADATA +2 -2
- sapiopycommons-2026.1.22a847.dist-info/RECORD +113 -0
- sapiopycommons-2025.6.19a564.dist-info/RECORD +0 -68
- {sapiopycommons-2025.6.19a564.dist-info → sapiopycommons-2026.1.22a847.dist-info}/WHEEL +0 -0
- {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
|
|
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
|
"""
|