benchling-sdk 1.10.0a4__py3-none-any.whl → 1.10.0a6__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.
- benchling_sdk/apps/canvas/framework.py +74 -68
- benchling_sdk/apps/canvas/types.py +4 -4
- benchling_sdk/apps/config/__init__.py +0 -3
- benchling_sdk/apps/config/decryption_provider.py +0 -3
- benchling_sdk/apps/config/errors.py +30 -0
- benchling_sdk/apps/config/framework.py +162 -26
- benchling_sdk/apps/{helpers/config_helpers.py → config/helpers.py} +16 -97
- benchling_sdk/apps/config/mock_config.py +68 -69
- benchling_sdk/apps/config/types.py +3 -1
- benchling_sdk/apps/status/framework.py +1 -21
- benchling_sdk/apps/status/helpers.py +20 -0
- benchling_sdk/apps/status/types.py +1 -1
- benchling_sdk/services/v2/stable/assay_result_service.py +18 -0
- {benchling_sdk-1.10.0a4.dist-info → benchling_sdk-1.10.0a6.dist-info}/METADATA +2 -2
- {benchling_sdk-1.10.0a4.dist-info → benchling_sdk-1.10.0a6.dist-info}/RECORD +17 -17
- benchling_sdk/apps/config/scalars.py +0 -190
- {benchling_sdk-1.10.0a4.dist-info → benchling_sdk-1.10.0a6.dist-info}/LICENSE +0 -0
- {benchling_sdk-1.10.0a4.dist-info → benchling_sdk-1.10.0a6.dist-info}/WHEEL +0 -0
@@ -1,13 +1,38 @@
|
|
1
|
-
import
|
2
|
-
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import Dict, List, Optional, OrderedDict, Protocol, Tuple, Union
|
3
4
|
|
4
5
|
from benchling_api_client.v2.extensions import UnknownType
|
5
6
|
from ordered_set import OrderedSet
|
6
7
|
|
7
|
-
from benchling_sdk.apps.config.errors import
|
8
|
+
from benchling_sdk.apps.config.errors import (
|
9
|
+
ConfigItemLinkedResourceError,
|
10
|
+
InaccessibleConfigItemError,
|
11
|
+
MissingRequiredConfigItemError,
|
12
|
+
UnsupportedConfigItemError,
|
13
|
+
)
|
8
14
|
from benchling_sdk.apps.config.types import ConfigItemPath, ConfigurationReference
|
9
15
|
from benchling_sdk.benchling import Benchling
|
10
|
-
from benchling_sdk.models import
|
16
|
+
from benchling_sdk.models import (
|
17
|
+
AppConfigItem,
|
18
|
+
EntitySchemaAppConfigItem,
|
19
|
+
FieldAppConfigItem,
|
20
|
+
GenericApiIdentifiedAppConfigItem,
|
21
|
+
InaccessibleResource,
|
22
|
+
LinkedAppConfigResourceSummary,
|
23
|
+
ListAppConfigurationItemsSort,
|
24
|
+
)
|
25
|
+
|
26
|
+
|
27
|
+
class _DefaultOrderedDict(OrderedDict):
|
28
|
+
_root_path: List[str]
|
29
|
+
|
30
|
+
def __init__(self, root_path: List[str]) -> None:
|
31
|
+
super().__init__()
|
32
|
+
self._root_path = root_path
|
33
|
+
|
34
|
+
def __missing__(self, key):
|
35
|
+
return ConfigItemWrapper(None, [*self._root_path, key])
|
11
36
|
|
12
37
|
|
13
38
|
class ConfigProvider(Protocol):
|
@@ -52,7 +77,8 @@ class BenchlingConfigProvider(ConfigProvider):
|
|
52
77
|
|
53
78
|
# Eager load all config items for now since we don't yet have a way of lazily querying by path
|
54
79
|
all_config_pages = list(app_pages)
|
55
|
-
# Punt on UnknownType for now as apps using manifests with new types +
|
80
|
+
# Punt on UnknownType for now as apps using manifests with new types +
|
81
|
+
# older client could lead to unpredictable results
|
56
82
|
all_config_items = [
|
57
83
|
_supported_config_item(config_item) for page in all_config_pages for config_item in page
|
58
84
|
]
|
@@ -82,17 +108,126 @@ class StaticConfigProvider(ConfigProvider):
|
|
82
108
|
return self._configuration_items
|
83
109
|
|
84
110
|
|
111
|
+
class ConfigItemWrapper:
|
112
|
+
"""
|
113
|
+
Config Item Wrapper.
|
114
|
+
|
115
|
+
A decorator class for AppConfigItem to assist with typesafe access to its values.
|
116
|
+
Access the `item` attribute for the original config item, if present.
|
117
|
+
"""
|
118
|
+
|
119
|
+
item: Optional[ConfigurationReference]
|
120
|
+
path: List[str]
|
121
|
+
|
122
|
+
def __init__(self, item: Optional[ConfigurationReference], path: List[str]) -> None:
|
123
|
+
"""Init Pathed Config Item."""
|
124
|
+
self.item = item
|
125
|
+
self.path = path
|
126
|
+
|
127
|
+
def required(self) -> RequiredConfigItemWrapper:
|
128
|
+
"""Return a `RequiredPathedConfigItem` to enforce that config item is not optional."""
|
129
|
+
if self.item is None:
|
130
|
+
raise MissingRequiredConfigItemError(
|
131
|
+
f"Required config item {self.path} is missing from the config store"
|
132
|
+
)
|
133
|
+
return RequiredConfigItemWrapper(self.item, self.path)
|
134
|
+
|
135
|
+
def linked_resource(self) -> Optional[LinkedAppConfigResourceSummary]:
|
136
|
+
"""
|
137
|
+
Return an optional LinkedAppConfigResourceSummary.
|
138
|
+
|
139
|
+
Raises exceptions if the config item is not of the type that supports linked resources,
|
140
|
+
or the linked resource is inaccessible.
|
141
|
+
"""
|
142
|
+
if self.item is not None:
|
143
|
+
# Python can't compare isinstance for Union types until Python 3.10 so just do this for now
|
144
|
+
# from: https://github.com/python/typing/discussions/1132#discussioncomment-2560441
|
145
|
+
# elif isinstance(self.item, get_args(LinkedResourceConfigurationReference)):
|
146
|
+
if isinstance(
|
147
|
+
self.item, (EntitySchemaAppConfigItem, FieldAppConfigItem, GenericApiIdentifiedAppConfigItem)
|
148
|
+
):
|
149
|
+
if self.item.linked_resource is None:
|
150
|
+
return None
|
151
|
+
elif isinstance(self.item.linked_resource, LinkedAppConfigResourceSummary):
|
152
|
+
return self.item.linked_resource
|
153
|
+
elif isinstance(self.item.linked_resource, InaccessibleResource):
|
154
|
+
raise InaccessibleConfigItemError(
|
155
|
+
f"The linked resource for config item {self.path} was inaccessible. "
|
156
|
+
f"The caller does not have permissions to the resource."
|
157
|
+
)
|
158
|
+
raise UnsupportedConfigItemError(
|
159
|
+
f"Unable to read app configuration with unsupported type: {self.item}"
|
160
|
+
)
|
161
|
+
raise ConfigItemLinkedResourceError(
|
162
|
+
f"Type mismatch: The config item {self.item} type never has a linked resource"
|
163
|
+
)
|
164
|
+
return None
|
165
|
+
|
166
|
+
def value(self) -> Union[str, float, int, bool, None]:
|
167
|
+
"""Return the value of the config item, if present."""
|
168
|
+
return self.item.value if self.item else None
|
169
|
+
|
170
|
+
def value_str(self) -> Optional[str]:
|
171
|
+
"""Return the value of the config item as a string, if present."""
|
172
|
+
return str(self.value()) if self.value() else None
|
173
|
+
|
174
|
+
def __bool__(self) -> bool:
|
175
|
+
return self.value() is not None
|
176
|
+
|
177
|
+
|
178
|
+
class RequiredConfigItemWrapper(ConfigItemWrapper):
|
179
|
+
"""
|
180
|
+
Required Config Item Wrapper.
|
181
|
+
|
182
|
+
A decorator class for AppConfigItem to assist with typesafe access to its values.
|
183
|
+
Enforces that a config item is present, and that it's value is not None.
|
184
|
+
Access the `item` attribute for the original config item.
|
185
|
+
"""
|
186
|
+
|
187
|
+
item: ConfigurationReference
|
188
|
+
|
189
|
+
def __init__(self, item: ConfigurationReference, path: List[str]) -> None:
|
190
|
+
"""Init Required Pathed Config Item."""
|
191
|
+
super().__init__(item, path)
|
192
|
+
|
193
|
+
def linked_resource(self) -> LinkedAppConfigResourceSummary:
|
194
|
+
"""
|
195
|
+
Return a LinkedAppConfigResourceSummary.
|
196
|
+
|
197
|
+
Raises exceptions if the config item is not of the type that supports linked resources,
|
198
|
+
or the linked resource is inaccessible.
|
199
|
+
"""
|
200
|
+
linked_resource = super().linked_resource()
|
201
|
+
if linked_resource is None:
|
202
|
+
raise MissingRequiredConfigItemError(
|
203
|
+
f"Required config item {self.path} is missing a linked resource"
|
204
|
+
)
|
205
|
+
return linked_resource
|
206
|
+
|
207
|
+
def value(self) -> Union[str, float, int, bool]:
|
208
|
+
"""Return the value of the config item."""
|
209
|
+
if self.item.value is None:
|
210
|
+
raise MissingRequiredConfigItemError(
|
211
|
+
f"Required config item {self.path} is missing from the config store"
|
212
|
+
)
|
213
|
+
return self.item.value
|
214
|
+
|
215
|
+
def value_str(self) -> str:
|
216
|
+
"""Return the value of the config item as a string."""
|
217
|
+
return str(self.value())
|
218
|
+
|
219
|
+
|
85
220
|
class ConfigItemStore:
|
86
221
|
"""
|
87
222
|
Dependency Link Store.
|
88
223
|
|
89
|
-
|
224
|
+
Marshals an app configuration from the configuration provider into an indexed structure.
|
90
225
|
Only retrieves app configuration once unless its cache is invalidated.
|
91
226
|
"""
|
92
227
|
|
93
228
|
_configuration_provider: ConfigProvider
|
94
229
|
_configuration: Optional[List[ConfigurationReference]] = None
|
95
|
-
|
230
|
+
_configuration_dict: Optional[Dict[ConfigItemPath, ConfigItemWrapper]] = None
|
96
231
|
_array_path_row_names: Dict[Tuple[str, ...], OrderedSet[str]] = dict()
|
97
232
|
|
98
233
|
def __init__(self, configuration_provider: ConfigProvider):
|
@@ -118,17 +253,19 @@ class ConfigItemStore:
|
|
118
253
|
return self._configuration
|
119
254
|
|
120
255
|
@property
|
121
|
-
def
|
256
|
+
def configuration_path_dict(self) -> Dict[ConfigItemPath, ConfigItemWrapper]:
|
122
257
|
"""
|
123
258
|
Config links.
|
124
259
|
|
125
|
-
Return a
|
260
|
+
Return a dict of configuration item paths to their corresponding configuration items.
|
126
261
|
"""
|
127
|
-
if not self.
|
128
|
-
self.
|
129
|
-
|
262
|
+
if not self._configuration_dict:
|
263
|
+
self._configuration_dict = {
|
264
|
+
tuple(item.path): ConfigItemWrapper(item, item.path) for item in self.configuration
|
265
|
+
}
|
266
|
+
return self._configuration_dict
|
130
267
|
|
131
|
-
def config_by_path(self, path: List[str]) ->
|
268
|
+
def config_by_path(self, path: List[str]) -> ConfigItemWrapper:
|
132
269
|
"""
|
133
270
|
Config by path.
|
134
271
|
|
@@ -136,7 +273,7 @@ class ConfigItemStore:
|
|
136
273
|
"""
|
137
274
|
# Since we eager load all config now, we know that missing path means it's not configured in Benchling
|
138
275
|
# Later if we support lazy loading, we'll need to differentiate what's in our cache versus missing
|
139
|
-
return self.
|
276
|
+
return self.configuration_path_dict.get(tuple(path), ConfigItemWrapper(None, path))
|
140
277
|
|
141
278
|
def config_keys_by_path(self, path: List[str]) -> OrderedSet[str]:
|
142
279
|
"""
|
@@ -160,7 +297,7 @@ class ConfigItemStore:
|
|
160
297
|
self._array_path_row_names[path_tuple] = OrderedSet(
|
161
298
|
[
|
162
299
|
config_item.path[len(path)]
|
163
|
-
# Use the list instead of
|
300
|
+
# Use the list instead of configuration_dict to preserve order
|
164
301
|
for config_item in self.configuration
|
165
302
|
# The +1 is the name of the array row
|
166
303
|
if len(config_item.path) >= len(path) + 1
|
@@ -171,22 +308,21 @@ class ConfigItemStore:
|
|
171
308
|
)
|
172
309
|
return self._array_path_row_names[path_tuple]
|
173
310
|
|
174
|
-
def array_rows_to_dict(self, path: List[str]) -> OrderedDict[str, Dict[str,
|
311
|
+
def array_rows_to_dict(self, path: List[str]) -> OrderedDict[str, Dict[str, ConfigItemWrapper]]:
|
175
312
|
"""Given a path to the root of a config array, return each element as a named dict."""
|
176
|
-
# TODO BNCH-52772 Improve docstring if we keep this method
|
177
313
|
array_keys = self.config_keys_by_path(path)
|
178
314
|
# Although we don't have a way of preserving order when pulling array elements from the API right now
|
179
315
|
# we should intentionally order these to accommodate a potential ordered future
|
180
|
-
array_elements_map =
|
316
|
+
array_elements_map = _DefaultOrderedDict(root_path=path)
|
181
317
|
for key in array_keys:
|
318
|
+
array_elements_map[key] = _DefaultOrderedDict(root_path=[*path, key])
|
182
319
|
# Don't care about order for the keys within a row, only the order of the rows themselves
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
return array_elements_map # type: ignore
|
320
|
+
for array_element_key in self.config_keys_by_path([*path, key]):
|
321
|
+
if self.config_by_path([*path, key, array_element_key]) is not None:
|
322
|
+
array_elements_map[key][array_element_key] = self.config_by_path(
|
323
|
+
[*path, key, array_element_key]
|
324
|
+
)
|
325
|
+
return array_elements_map
|
190
326
|
|
191
327
|
def invalidate_cache(self) -> None:
|
192
328
|
"""
|
@@ -195,7 +331,7 @@ class ConfigItemStore:
|
|
195
331
|
Will force retrieval of configuration from the ConfigProvider the next time the link store is accessed.
|
196
332
|
"""
|
197
333
|
self._configuration = None
|
198
|
-
self.
|
334
|
+
self._configuration_dict = None
|
199
335
|
self._array_path_row_names = dict()
|
200
336
|
|
201
337
|
|
@@ -1,7 +1,6 @@
|
|
1
|
-
from datetime import
|
1
|
+
from datetime import datetime
|
2
2
|
from typing import List, Optional, Union
|
3
3
|
|
4
|
-
from benchling_api_client.v2.beta.models.app_config_field_type import AppConfigFieldType
|
5
4
|
from benchling_api_client.v2.beta.models.base_manifest_config import BaseManifestConfig
|
6
5
|
from benchling_api_client.v2.beta.models.dropdown_dependency import DropdownDependency
|
7
6
|
from benchling_api_client.v2.beta.models.dropdown_dependency_types import DropdownDependencyTypes
|
@@ -29,92 +28,7 @@ from benchling_api_client.v2.beta.models.workflow_task_schema_dependency_type im
|
|
29
28
|
from benchling_api_client.v2.extensions import UnknownType
|
30
29
|
from benchling_api_client.v2.stable.extensions import NotPresentError
|
31
30
|
|
32
|
-
|
33
|
-
from benchling_sdk.models import (
|
34
|
-
AaSequence,
|
35
|
-
AssayResult,
|
36
|
-
AssayRun,
|
37
|
-
Box,
|
38
|
-
Container,
|
39
|
-
CustomEntity,
|
40
|
-
DnaOligo,
|
41
|
-
DnaSequence,
|
42
|
-
Entry,
|
43
|
-
Location,
|
44
|
-
Mixture,
|
45
|
-
Molecule,
|
46
|
-
Plate,
|
47
|
-
Request,
|
48
|
-
RnaOligo,
|
49
|
-
RnaSequence,
|
50
|
-
WorkflowTask,
|
51
|
-
)
|
52
|
-
|
53
|
-
_MODEL_TYPES_FROM_SCHEMA_TYPE = {
|
54
|
-
SchemaDependencyTypes.CONTAINER_SCHEMA: Container,
|
55
|
-
SchemaDependencyTypes.PLATE_SCHEMA: Plate,
|
56
|
-
SchemaDependencyTypes.BOX_SCHEMA: Box,
|
57
|
-
SchemaDependencyTypes.LOCATION_SCHEMA: Location,
|
58
|
-
SchemaDependencyTypes.ENTRY_SCHEMA: Entry,
|
59
|
-
SchemaDependencyTypes.REQUEST_SCHEMA: Request,
|
60
|
-
SchemaDependencyTypes.RESULT_SCHEMA: AssayResult,
|
61
|
-
SchemaDependencyTypes.RUN_SCHEMA: AssayRun,
|
62
|
-
SchemaDependencyTypes.WORKFLOW_TASK_SCHEMA: WorkflowTask,
|
63
|
-
}
|
64
|
-
|
65
|
-
|
66
|
-
_SCALAR_TYPES_FROM_CONFIG = {
|
67
|
-
ScalarConfigTypes.BOOLEAN: bool,
|
68
|
-
ScalarConfigTypes.DATE: date,
|
69
|
-
ScalarConfigTypes.DATETIME: datetime,
|
70
|
-
ScalarConfigTypes.FLOAT: float,
|
71
|
-
ScalarConfigTypes.INTEGER: int,
|
72
|
-
ScalarConfigTypes.JSON: JsonType,
|
73
|
-
ScalarConfigTypes.TEXT: str,
|
74
|
-
}
|
75
|
-
|
76
|
-
|
77
|
-
_FIELD_SCALAR_TYPES_FROM_CONFIG = {
|
78
|
-
AppConfigFieldType.BOOLEAN: bool,
|
79
|
-
AppConfigFieldType.DATE: date,
|
80
|
-
AppConfigFieldType.DATETIME: datetime,
|
81
|
-
AppConfigFieldType.FLOAT: float,
|
82
|
-
AppConfigFieldType.INTEGER: int,
|
83
|
-
AppConfigFieldType.JSON: JsonType,
|
84
|
-
AppConfigFieldType.TEXT: str,
|
85
|
-
}
|
86
|
-
|
87
|
-
|
88
|
-
ModelType = Union[AssayResult, AssayRun, Box, Container, Entry, Location, Plate, Request]
|
89
|
-
|
90
|
-
_INSTANCE_FROM_SCHEMA_SUBTYPE = {
|
91
|
-
SchemaDependencySubtypes.AA_SEQUENCE: AaSequence,
|
92
|
-
SchemaDependencySubtypes.CUSTOM_ENTITY: CustomEntity,
|
93
|
-
SchemaDependencySubtypes.DNA_SEQUENCE: DnaSequence,
|
94
|
-
SchemaDependencySubtypes.DNA_OLIGO: DnaOligo,
|
95
|
-
SchemaDependencySubtypes.MIXTURE: Mixture,
|
96
|
-
SchemaDependencySubtypes.MOLECULE: Molecule,
|
97
|
-
SchemaDependencySubtypes.RNA_OLIGO: RnaOligo,
|
98
|
-
SchemaDependencySubtypes.RNA_SEQUENCE: RnaSequence,
|
99
|
-
}
|
100
|
-
|
101
|
-
EntitySubtype = Union[
|
102
|
-
AaSequence, CustomEntity, DnaOligo, DnaSequence, Mixture, Molecule, RnaOligo, RnaSequence
|
103
|
-
]
|
104
|
-
|
105
|
-
AnyDependency = Union[
|
106
|
-
BaseManifestConfig,
|
107
|
-
DropdownDependency,
|
108
|
-
EntitySchemaDependency,
|
109
|
-
FieldDefinitionsManifest,
|
110
|
-
ManifestArrayConfig,
|
111
|
-
ManifestScalarConfig,
|
112
|
-
ResourceDependency,
|
113
|
-
SchemaDependency,
|
114
|
-
WorkflowTaskSchemaDependency,
|
115
|
-
]
|
116
|
-
|
117
|
-
ArrayElementDependency = Union[
|
31
|
+
_ArrayElementDependency = Union[
|
118
32
|
SchemaDependency,
|
119
33
|
EntitySchemaDependency,
|
120
34
|
WorkflowTaskSchemaDependency,
|
@@ -124,13 +38,13 @@ ArrayElementDependency = Union[
|
|
124
38
|
]
|
125
39
|
|
126
40
|
|
127
|
-
class
|
41
|
+
class _UnsupportedSubTypeError(Exception):
|
128
42
|
"""Error when an unsupported subtype is encountered."""
|
129
43
|
|
130
44
|
pass
|
131
45
|
|
132
46
|
|
133
|
-
def
|
47
|
+
def _field_definitions_from_dependency(
|
134
48
|
dependency: Union[
|
135
49
|
EntitySchemaDependency,
|
136
50
|
SchemaDependency,
|
@@ -148,7 +62,7 @@ def field_definitions_from_dependency(
|
|
148
62
|
return []
|
149
63
|
|
150
64
|
|
151
|
-
def
|
65
|
+
def _element_definition_from_dependency(dependency: ManifestArrayConfig) -> List[_ArrayElementDependency]:
|
152
66
|
"""Safely return an element definition as a list of dependencies from an array dependency or empty list."""
|
153
67
|
try:
|
154
68
|
if hasattr(dependency, "element_definition"):
|
@@ -161,7 +75,7 @@ def element_definition_from_dependency(dependency: ManifestArrayConfig) -> List[
|
|
161
75
|
return []
|
162
76
|
|
163
77
|
|
164
|
-
def
|
78
|
+
def _enum_from_dependency(
|
165
79
|
dependency: Union[
|
166
80
|
ManifestFloatScalarConfig,
|
167
81
|
ManifestIntegerScalarConfig,
|
@@ -180,8 +94,8 @@ def enum_from_dependency(
|
|
180
94
|
|
181
95
|
# TODO BNCH-57036 All element definitions currently deserialize to UnknownType. Hack around this temporarily
|
182
96
|
def _fix_element_definition_deserialization(
|
183
|
-
element: Union[UnknownType,
|
184
|
-
) ->
|
97
|
+
element: Union[UnknownType, _ArrayElementDependency]
|
98
|
+
) -> _ArrayElementDependency:
|
185
99
|
if isinstance(element, UnknownType):
|
186
100
|
if "type" in element.value:
|
187
101
|
element_type = element.value["type"]
|
@@ -201,7 +115,7 @@ def _fix_element_definition_deserialization(
|
|
201
115
|
return element
|
202
116
|
|
203
117
|
|
204
|
-
def
|
118
|
+
def _workflow_task_schema_output_from_dependency(
|
205
119
|
dependency: WorkflowTaskSchemaDependency,
|
206
120
|
) -> Optional[WorkflowTaskSchemaDependencyOutput]:
|
207
121
|
"""Safely return a workflow task schema output from a workflow task schema or None."""
|
@@ -214,7 +128,7 @@ def workflow_task_schema_output_from_dependency(
|
|
214
128
|
return None
|
215
129
|
|
216
130
|
|
217
|
-
def
|
131
|
+
def _options_from_dependency(dependency: DropdownDependency) -> List[BaseManifestConfig]:
|
218
132
|
"""Safely return a list of options from a dropdown dependency or empty list."""
|
219
133
|
try:
|
220
134
|
if hasattr(dependency, "options"):
|
@@ -225,7 +139,7 @@ def options_from_dependency(dependency: DropdownDependency) -> List[BaseManifest
|
|
225
139
|
return []
|
226
140
|
|
227
141
|
|
228
|
-
def
|
142
|
+
def _subtype_from_entity_schema_dependency(
|
229
143
|
dependency: EntitySchemaDependency,
|
230
144
|
) -> Optional[SchemaDependencySubtypes]:
|
231
145
|
"""Safely return an entity schema dependency's subtype, if present."""
|
@@ -236,3 +150,8 @@ def subtype_from_entity_schema_dependency(
|
|
236
150
|
except NotPresentError:
|
237
151
|
pass
|
238
152
|
return None
|
153
|
+
|
154
|
+
|
155
|
+
def datetime_config_value_to_str(value: datetime) -> str:
|
156
|
+
"""Convert a datetime value to a valid string accepted by a datetime app config item."""
|
157
|
+
return value.strftime("%Y-%m-%d %I:%M:%S %p")
|