karrio 2023.9.2__py3-none-any.whl → 2025.5rc3__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.
- karrio/__init__.py +0 -100
- karrio/addons/renderer.py +1 -1
- karrio/api/gateway.py +58 -35
- karrio/api/interface.py +41 -4
- karrio/api/mapper.py +39 -0
- karrio/api/proxy.py +18 -5
- karrio/core/__init__.py +5 -1
- karrio/core/metadata.py +113 -20
- karrio/core/models.py +64 -5
- karrio/core/plugins.py +606 -0
- karrio/core/settings.py +39 -2
- karrio/core/units.py +574 -29
- karrio/core/utils/datetime.py +62 -2
- karrio/core/utils/dict.py +5 -0
- karrio/core/utils/enum.py +98 -13
- karrio/core/utils/helpers.py +83 -32
- karrio/core/utils/number.py +52 -8
- karrio/core/utils/string.py +52 -1
- karrio/core/utils/transformer.py +9 -4
- karrio/core/validators.py +88 -0
- karrio/lib.py +147 -2
- karrio/plugins/__init__.py +6 -0
- karrio/references.py +652 -67
- karrio/sdk.py +102 -0
- karrio/universal/mappers/rating_proxy.py +35 -9
- karrio/validators/__init__.py +6 -0
- {karrio-2023.9.2.dist-info → karrio-2025.5rc3.dist-info}/METADATA +9 -8
- karrio-2025.5rc3.dist-info/RECORD +57 -0
- {karrio-2023.9.2.dist-info → karrio-2025.5rc3.dist-info}/WHEEL +1 -1
- {karrio-2023.9.2.dist-info → karrio-2025.5rc3.dist-info}/top_level.txt +1 -0
- karrio-2023.9.2.dist-info/RECORD +0 -52
karrio/core/utils/datetime.py
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
import typing
|
2
|
-
from datetime import datetime
|
2
|
+
from datetime import datetime, timedelta, timezone
|
3
3
|
|
4
4
|
|
5
5
|
class DATEFORMAT:
|
@@ -30,6 +30,53 @@ class DATEFORMAT:
|
|
30
30
|
|
31
31
|
return datetime.strptime(str(date_value), current_format)
|
32
32
|
|
33
|
+
@staticmethod
|
34
|
+
def next_business_datetime(
|
35
|
+
date_value: typing.Union[str, datetime] = None,
|
36
|
+
current_format: str = "%Y-%m-%d %H:%M:%S",
|
37
|
+
try_formats: typing.List[str] = None,
|
38
|
+
start_hour: int = 10,
|
39
|
+
end_hour: int = 17,
|
40
|
+
) -> typing.Optional[datetime]:
|
41
|
+
date = DATEFORMAT.date(
|
42
|
+
date_value, current_format=current_format, try_formats=try_formats
|
43
|
+
)
|
44
|
+
if date is None:
|
45
|
+
return None
|
46
|
+
|
47
|
+
# Define business hours
|
48
|
+
_start_hour = start_hour
|
49
|
+
_end_hour = end_hour
|
50
|
+
|
51
|
+
# If date has no time component, set it to current time
|
52
|
+
if date.hour == 0 and date.minute == 0 and date.second == 0:
|
53
|
+
now = datetime.now()
|
54
|
+
date = date.replace(hour=now.hour, minute=now.minute, second=now.second)
|
55
|
+
|
56
|
+
# If the given datetime is within business hours, return it
|
57
|
+
if DATEFORMAT.is_business_hour(date):
|
58
|
+
return date
|
59
|
+
|
60
|
+
# If it's outside business hours, calculate the next business datetime
|
61
|
+
if date.weekday() >= 5: # If it's Saturday or Sunday
|
62
|
+
# Move to the next Monday
|
63
|
+
days_to_add = 7 - date.weekday()
|
64
|
+
next_business_day = date + timedelta(days=days_to_add)
|
65
|
+
return next_business_day.replace(
|
66
|
+
hour=_start_hour, minute=0, second=0, microsecond=0
|
67
|
+
)
|
68
|
+
elif date.hour >= _end_hour: # If it's after business hours
|
69
|
+
# Move to the next business day
|
70
|
+
next_business_day = date + timedelta(days=1)
|
71
|
+
if next_business_day.weekday() >= 5: # If it's Saturday or Sunday
|
72
|
+
days_to_add = 7 - next_business_day.weekday()
|
73
|
+
next_business_day += timedelta(days=days_to_add)
|
74
|
+
return next_business_day.replace(
|
75
|
+
hour=_start_hour, minute=0, second=0, microsecond=0
|
76
|
+
)
|
77
|
+
else: # If it's before business hours
|
78
|
+
return date.replace(hour=_start_hour, minute=0, second=0, microsecond=0)
|
79
|
+
|
33
80
|
@staticmethod
|
34
81
|
def fdate(
|
35
82
|
date_str: typing.Union[str, int, datetime] = None,
|
@@ -77,4 +124,17 @@ class DATEFORMAT:
|
|
77
124
|
def ftimestamp(timestamp: typing.Union[str, int] = None):
|
78
125
|
if timestamp is None:
|
79
126
|
return None
|
80
|
-
return datetime.
|
127
|
+
return datetime.fromtimestamp(float(timestamp), timezone.utc).strftime("%H:%M")
|
128
|
+
|
129
|
+
@staticmethod
|
130
|
+
def is_business_hour(dt: datetime):
|
131
|
+
# Define business hours
|
132
|
+
start_hour = 9
|
133
|
+
end_hour = 17
|
134
|
+
|
135
|
+
# Check if the given datetime is within business hours
|
136
|
+
if dt.weekday() >= 5: # 5 and 6 correspond to Saturday and Sunday
|
137
|
+
return False
|
138
|
+
if dt.hour < start_hour or dt.hour >= end_hour:
|
139
|
+
return False
|
140
|
+
return True
|
karrio/core/utils/dict.py
CHANGED
@@ -1,3 +1,4 @@
|
|
1
|
+
import re
|
1
2
|
import enum
|
2
3
|
import attr
|
3
4
|
import json
|
@@ -50,6 +51,10 @@ class DICTPARSE:
|
|
50
51
|
:return: a dictionary.
|
51
52
|
"""
|
52
53
|
_clear_empty = clear_empty is not False
|
54
|
+
if isinstance(entity, str):
|
55
|
+
entity = re.sub(",[ \t\r\n]+}", "}", entity)
|
56
|
+
entity = re.sub(r",[ \t\r\n]+\]", "]", entity)
|
57
|
+
|
53
58
|
return json.loads(
|
54
59
|
(
|
55
60
|
DICTPARSE.jsonify(entity)
|
karrio/core/utils/enum.py
CHANGED
@@ -1,7 +1,6 @@
|
|
1
1
|
import attr
|
2
2
|
import enum
|
3
3
|
import typing
|
4
|
-
import karrio.lib as lib
|
5
4
|
|
6
5
|
BaseStrEnum = getattr(enum, "StrEnum", enum.Flag)
|
7
6
|
|
@@ -78,87 +77,173 @@ class EnumWrapper:
|
|
78
77
|
|
79
78
|
@attr.s(auto_attribs=True)
|
80
79
|
class OptionEnum:
|
80
|
+
"""An option enumeration class for handling typed options.
|
81
|
+
|
82
|
+
Attributes:
|
83
|
+
code: The option code or identifier
|
84
|
+
type: The type converter function or enum type
|
85
|
+
state: The current state value
|
86
|
+
default: The default value to use when none is provided
|
87
|
+
"""
|
81
88
|
code: str
|
82
|
-
type: typing.Callable = str
|
89
|
+
type: typing.Union[typing.Callable, MetaEnum] = str
|
83
90
|
state: typing.Any = None
|
91
|
+
default: typing.Any = None
|
84
92
|
|
85
93
|
def __getitem__(self, type: typing.Callable = None) -> "OptionEnum":
|
86
|
-
return OptionEnum("", type or self.type, self.state)
|
94
|
+
return OptionEnum("", type or self.type, self.state, self.default)
|
87
95
|
|
88
96
|
def __call__(self, value: typing.Any = None) -> "OptionEnum":
|
97
|
+
"""Create a new OptionEnum instance with the specified value.
|
98
|
+
|
99
|
+
Args:
|
100
|
+
value: The value to set. If None and default is provided, default will be used.
|
101
|
+
|
102
|
+
Returns:
|
103
|
+
A new OptionEnum instance with the appropriate state.
|
104
|
+
"""
|
89
105
|
state = self.state
|
90
106
|
|
107
|
+
# if value is None and default is provided, use default
|
108
|
+
if value is None and self.default is not None:
|
109
|
+
value = self.default
|
110
|
+
|
91
111
|
# if type is bool we have an option defined as Flag.
|
92
112
|
if self.type is bool:
|
93
113
|
state = value is not False
|
94
114
|
|
115
|
+
elif "enum" in str(self.type):
|
116
|
+
state = (
|
117
|
+
(
|
118
|
+
self.type.map(value).name_or_key # type: ignore
|
119
|
+
if hasattr(value, "map")
|
120
|
+
else self.type[value].name # type: ignore
|
121
|
+
)
|
122
|
+
if value is not None and value != ""
|
123
|
+
else None
|
124
|
+
)
|
125
|
+
|
95
126
|
else:
|
96
127
|
state = self.type(value) if value is not None else None
|
97
128
|
|
98
|
-
return OptionEnum(self.code, self.type, state)
|
129
|
+
return OptionEnum(self.code, self.type, state, self.default)
|
99
130
|
|
100
131
|
|
101
132
|
@attr.s(auto_attribs=True)
|
102
133
|
class Spec:
|
134
|
+
"""A specification class for handling typed values with computation logic.
|
135
|
+
|
136
|
+
Attributes:
|
137
|
+
key: The specification key or identifier
|
138
|
+
type: The type of the specification value
|
139
|
+
compute: The computation function to apply
|
140
|
+
value: The current value
|
141
|
+
default: The default value to use when none is provided
|
142
|
+
"""
|
103
143
|
key: str
|
104
144
|
type: typing.Type
|
105
145
|
compute: typing.Callable
|
106
146
|
value: typing.Any = None
|
147
|
+
default: typing.Any = None
|
107
148
|
|
108
149
|
def apply(self, *args, **kwargs):
|
150
|
+
"""Apply the computation function to the arguments."""
|
109
151
|
return self.compute(*args, **kwargs)
|
110
152
|
|
111
153
|
"""Spec initialization modes"""
|
112
154
|
|
113
155
|
@staticmethod
|
114
|
-
def asFlag(key: str) -> "Spec":
|
156
|
+
def asFlag(key: str, default: typing.Optional[bool] = None) -> "Spec":
|
115
157
|
"""A Spec defined as "Flag" means that when it is specified in the payload,
|
116
158
|
a boolean flag will be returned as value.
|
159
|
+
|
160
|
+
Args:
|
161
|
+
key: The specification key
|
162
|
+
default: Default value to use when none is provided
|
163
|
+
|
164
|
+
Returns:
|
165
|
+
A Spec instance configured as a flag
|
117
166
|
"""
|
118
167
|
|
119
168
|
def compute(value: typing.Optional[bool]) -> bool:
|
169
|
+
# Use default if value is None
|
170
|
+
if value is None and default is not None:
|
171
|
+
value = default
|
120
172
|
return value is not False
|
121
173
|
|
122
|
-
return Spec(key, bool, compute)
|
174
|
+
return Spec(key, bool, compute, default=default)
|
123
175
|
|
124
176
|
@staticmethod
|
125
|
-
def asKey(key: str) -> "Spec":
|
177
|
+
def asKey(key: str, default: typing.Optional[bool] = None) -> "Spec":
|
126
178
|
"""A Spec defined as "Key" means that when it is specified in a payload and not flagged as False,
|
127
179
|
the spec code will be returned as value.
|
180
|
+
|
181
|
+
Args:
|
182
|
+
key: The specification key
|
183
|
+
default: Default value to use when none is provided
|
184
|
+
|
185
|
+
Returns:
|
186
|
+
A Spec instance configured to return its key
|
128
187
|
"""
|
129
188
|
|
130
189
|
def compute(value: typing.Optional[bool]) -> str:
|
190
|
+
# Use default if value is None
|
191
|
+
if value is None and default is not None:
|
192
|
+
value = default
|
131
193
|
return key if (value is not False) else None
|
132
194
|
|
133
|
-
return Spec(key, bool, compute)
|
195
|
+
return Spec(key, bool, compute, default=default)
|
134
196
|
|
135
197
|
@staticmethod
|
136
|
-
def asValue(key: str, type: typing.Type = str) -> "Spec":
|
198
|
+
def asValue(key: str, type: typing.Type = str, default: typing.Any = None) -> "Spec":
|
137
199
|
"""A Spec defined as "typing.Type" means that when it is specified in a payload,
|
138
200
|
the value passed by the user will be returned.
|
201
|
+
|
202
|
+
Args:
|
203
|
+
key: The specification key
|
204
|
+
type: The type to convert the value to
|
205
|
+
default: Default value to use when none is provided
|
206
|
+
|
207
|
+
Returns:
|
208
|
+
A Spec instance configured to return the typed value
|
139
209
|
"""
|
140
210
|
|
141
211
|
def compute(value: typing.Optional[type]) -> type: # type: ignore
|
212
|
+
# Use default if value is None
|
213
|
+
if value is None and default is not None:
|
214
|
+
value = default
|
142
215
|
return type(value) if value is not None else None
|
143
216
|
|
144
|
-
return Spec(key, type, compute)
|
217
|
+
return Spec(key, type, compute, default=default)
|
145
218
|
|
146
219
|
@staticmethod
|
147
|
-
def asKeyVal(key: str, type: typing.Type = str) -> "Spec":
|
220
|
+
def asKeyVal(key: str, type: typing.Type = str, default: typing.Any = None) -> "Spec":
|
148
221
|
"""A Spec defined as "Value" means that when it is specified in a payload,
|
149
222
|
the a new spec defined as type is returned.
|
223
|
+
|
224
|
+
Args:
|
225
|
+
key: The specification key
|
226
|
+
type: The type to convert the value to
|
227
|
+
default: Default value to use when none is provided
|
228
|
+
|
229
|
+
Returns:
|
230
|
+
A Spec instance configured to return a new Spec with the typed value
|
150
231
|
"""
|
151
232
|
|
152
233
|
def compute_inner_spec(value: typing.Optional[type]) -> Spec: # type: ignore
|
234
|
+
# Use default if value is None
|
235
|
+
if value is None and default is not None:
|
236
|
+
value = default
|
237
|
+
|
153
238
|
computed_value = (
|
154
239
|
getattr(value, "value", None)
|
155
240
|
if hasattr(value, "value")
|
156
241
|
else (type(value) if value is not None else None)
|
157
242
|
)
|
158
243
|
|
159
|
-
return Spec(key, type, lambda *_: computed_value, computed_value)
|
244
|
+
return Spec(key, type, lambda *_: computed_value, computed_value, default=default)
|
160
245
|
|
161
|
-
return Spec(key, type, compute_inner_spec)
|
246
|
+
return Spec(key, type, compute_inner_spec, default=default)
|
162
247
|
|
163
248
|
|
164
249
|
class svcEnum(str):
|
karrio/core/utils/helpers.py
CHANGED
@@ -8,15 +8,16 @@ import PyPDF2
|
|
8
8
|
import asyncio
|
9
9
|
import logging
|
10
10
|
import urllib.parse
|
11
|
-
|
11
|
+
import PIL.Image
|
12
|
+
import PIL.ImageFile
|
12
13
|
from urllib.error import HTTPError
|
13
|
-
from urllib.request import urlopen, Request
|
14
|
+
from urllib.request import urlopen, Request, ProxyHandler, build_opener, install_opener
|
14
15
|
from typing import List, TypeVar, Callable, Optional, Any, cast
|
15
16
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
16
17
|
|
17
18
|
logger = logging.getLogger(__name__)
|
18
19
|
ssl._create_default_https_context = ssl._create_unverified_context
|
19
|
-
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
20
|
+
PIL.ImageFile.LOAD_TRUNCATED_IMAGES = True
|
20
21
|
T = TypeVar("T")
|
21
22
|
S = TypeVar("S")
|
22
23
|
NEW_LINE = """
|
@@ -46,10 +47,10 @@ def to_buffer(encoded_file: str, **kwargs) -> io.BytesIO:
|
|
46
47
|
|
47
48
|
def image_to_pdf(image_str: str, rotate: int = None, resize: dict = None) -> str:
|
48
49
|
buffer = to_buffer(image_str)
|
49
|
-
_image = Image.open(buffer)
|
50
|
+
_image = PIL.Image.open(buffer)
|
50
51
|
|
51
52
|
image = (
|
52
|
-
_image.rotate(rotate, Image.NEAREST, expand=True)
|
53
|
+
_image.rotate(rotate, PIL.Image.Resampling.NEAREST, expand=True)
|
53
54
|
if rotate is not None
|
54
55
|
else _image
|
55
56
|
)
|
@@ -58,12 +59,12 @@ def image_to_pdf(image_str: str, rotate: int = None, resize: dict = None) -> str
|
|
58
59
|
img = image.copy()
|
59
60
|
wpercent = resize["width"] / float(img.size[0])
|
60
61
|
hsize = int((float(img.size[1]) * float(wpercent)))
|
61
|
-
image = img.resize((resize["width"], hsize), Image.Resampling.LANCZOS)
|
62
|
+
image = img.resize((resize["width"], hsize), PIL.Image.Resampling.LANCZOS)
|
62
63
|
|
63
64
|
if resize is not None:
|
64
65
|
img = image.copy()
|
65
66
|
image = img.resize(
|
66
|
-
(resize["width"], resize["height"]), Image.Resampling.LANCZOS
|
67
|
+
(resize["width"], resize["height"]), PIL.Image.Resampling.LANCZOS
|
67
68
|
)
|
68
69
|
|
69
70
|
new_buffer = io.BytesIO()
|
@@ -82,17 +83,17 @@ def bundle_pdfs(base64_strings: List[str]) -> PyPDF2.PdfMerger:
|
|
82
83
|
return merger
|
83
84
|
|
84
85
|
|
85
|
-
def bundle_imgs(base64_strings: List[str])
|
86
|
+
def bundle_imgs(base64_strings: List[str]):
|
86
87
|
image_buffers = [
|
87
88
|
io.BytesIO(base64.b64decode(b64_str)) for b64_str in base64_strings
|
88
89
|
]
|
89
|
-
images = [Image.open(buffer) for buffer in image_buffers]
|
90
|
+
images = [PIL.Image.open(buffer) for buffer in image_buffers]
|
90
91
|
widths, heights = zip(*(i.size for i in images))
|
91
92
|
|
92
93
|
max_width = max(widths)
|
93
94
|
total_height = sum(heights)
|
94
95
|
|
95
|
-
image = Image.new("RGB", (max_width, total_height))
|
96
|
+
image = PIL.Image.new("RGB", (max_width, total_height))
|
96
97
|
|
97
98
|
x_offset = 0
|
98
99
|
for im in images:
|
@@ -161,6 +162,7 @@ def decode_bytes(byte):
|
|
161
162
|
def process_request(
|
162
163
|
request_id: str,
|
163
164
|
trace: Callable[[Any, str], Any] = None,
|
165
|
+
proxy: str = None,
|
164
166
|
**kwargs,
|
165
167
|
) -> Request:
|
166
168
|
payload = (
|
@@ -181,6 +183,21 @@ def process_request(
|
|
181
183
|
|
182
184
|
_request = Request(**{**kwargs, **payload})
|
183
185
|
|
186
|
+
# Apply proxy settings if provided: Proxy Example` 'username:password@IP_Address:Port'
|
187
|
+
if proxy:
|
188
|
+
proxy_info = proxy.split("@")
|
189
|
+
auth_info, host_port = proxy_info[0], proxy_info[1]
|
190
|
+
auth_info = urllib.parse.unquote(auth_info)
|
191
|
+
auth_encoded = base64.b64encode(auth_info.encode()).decode()
|
192
|
+
proxy_url = f"http://{host_port}"
|
193
|
+
|
194
|
+
# Create a ProxyHandler
|
195
|
+
proxy_handler = ProxyHandler({"http": proxy_url, "https": proxy_url})
|
196
|
+
opener = build_opener(proxy_handler)
|
197
|
+
opener.addheaders = [("Proxy-Authorization", f"Basic {auth_encoded}")]
|
198
|
+
install_opener(opener)
|
199
|
+
logger.info(f"Proxy set to: {proxy_url} with credentials")
|
200
|
+
|
184
201
|
logger.info(f"Request URL:: {_request.full_url}")
|
185
202
|
|
186
203
|
return _request
|
@@ -188,15 +205,16 @@ def process_request(
|
|
188
205
|
|
189
206
|
def process_response(
|
190
207
|
request_id: str,
|
191
|
-
response,
|
208
|
+
response: Any,
|
192
209
|
decoder: Callable,
|
210
|
+
on_ok: Callable[[Any], str] = None,
|
193
211
|
trace: Callable[[Any, str], Any] = None,
|
194
212
|
) -> str:
|
195
|
-
|
196
|
-
_response =
|
197
|
-
|
198
|
-
|
199
|
-
_response =
|
213
|
+
if on_ok is not None:
|
214
|
+
_response = on_ok(response)
|
215
|
+
else:
|
216
|
+
_data = response.read()
|
217
|
+
_response = failsafe(lambda: decoder(_data)) or _data
|
200
218
|
|
201
219
|
if trace:
|
202
220
|
_content = _response if isinstance(_response, str) else "undecoded bytes..."
|
@@ -230,23 +248,29 @@ def process_error(
|
|
230
248
|
|
231
249
|
def request(
|
232
250
|
decoder: Callable = decode_bytes,
|
251
|
+
on_ok: Callable[[Any], str] = None,
|
233
252
|
on_error: Callable[[HTTPError], str] = None,
|
234
253
|
trace: Callable[[Any, str], Any] = None,
|
254
|
+
proxy: str = None,
|
255
|
+
timeout: Optional[int] = None,
|
235
256
|
**kwargs,
|
236
257
|
) -> str:
|
237
258
|
"""Return an HTTP response body.
|
238
259
|
|
239
260
|
make a http request (wrapper around Request method from built in urllib)
|
261
|
+
Proxy example: 'Username:Password@IP_Address:Port'
|
240
262
|
"""
|
241
263
|
|
242
264
|
_request_id = str(uuid.uuid4())
|
243
265
|
logger.debug(f"sending request ({_request_id})...")
|
244
266
|
|
245
267
|
try:
|
246
|
-
_request = process_request(_request_id, trace, **kwargs)
|
268
|
+
_request = process_request(_request_id, trace, proxy, **kwargs)
|
247
269
|
|
248
|
-
with urlopen(_request) as f:
|
249
|
-
_response = process_response(
|
270
|
+
with urlopen(_request, timeout=timeout) as f:
|
271
|
+
_response = process_response(
|
272
|
+
_request_id, f, decoder, on_ok=on_ok, trace=trace
|
273
|
+
)
|
250
274
|
|
251
275
|
except HTTPError as e:
|
252
276
|
_response = process_error(_request_id, e, on_error=on_error, trace=trace)
|
@@ -258,26 +282,45 @@ def exec_parrallel(
|
|
258
282
|
function: Callable, sequence: List[S], max_workers: int = None
|
259
283
|
) -> List[T]:
|
260
284
|
"""Return a list of result for function execution on each element of the sequence."""
|
261
|
-
|
285
|
+
if not sequence:
|
286
|
+
return [] # No work to do
|
287
|
+
|
288
|
+
workers = min(len(sequence), max_workers or len(sequence))
|
289
|
+
|
262
290
|
with ThreadPoolExecutor(max_workers=workers) as executor:
|
263
|
-
|
264
|
-
|
291
|
+
# Submit tasks
|
292
|
+
futures = [executor.submit(function, item) for item in sequence]
|
293
|
+
|
294
|
+
# Collect results as tasks complete
|
295
|
+
results = []
|
296
|
+
for future in as_completed(futures):
|
297
|
+
try:
|
298
|
+
results.append(future.result()) # Append result of the completed task
|
299
|
+
except Exception as e:
|
300
|
+
results.append(e) # Optionally handle or log exceptions here
|
301
|
+
|
302
|
+
return results
|
265
303
|
|
266
304
|
|
267
305
|
def exec_async(action: Callable, sequence: List[S]) -> List[T]:
|
268
|
-
async def run_tasks(
|
306
|
+
async def run_tasks():
|
307
|
+
# Use asyncio.to_thread instead of loop.run_in_executor
|
308
|
+
# This ensures proper task scheduling and prevents potential task dropping
|
269
309
|
return await asyncio.gather(
|
270
|
-
*[
|
310
|
+
*[asyncio.to_thread(action, args) for args in sequence]
|
271
311
|
)
|
272
312
|
|
273
|
-
def run_loop():
|
274
|
-
|
275
|
-
|
276
|
-
|
313
|
+
async def run_loop():
|
314
|
+
# Simplified to just return the result of run_tasks
|
315
|
+
# No need for manual loop creation and closing
|
316
|
+
return await run_tasks()
|
277
317
|
|
278
|
-
|
318
|
+
# Use asyncio.run instead of ThreadPoolExecutor
|
319
|
+
# This properly sets up and tears down the event loop
|
320
|
+
# Preventing issues with loop closure and task cleanup
|
321
|
+
result = asyncio.run(run_loop())
|
279
322
|
|
280
|
-
result
|
323
|
+
# Cast the result to the expected type
|
281
324
|
return cast(List[T], result)
|
282
325
|
|
283
326
|
|
@@ -295,8 +338,16 @@ class Location:
|
|
295
338
|
|
296
339
|
@property
|
297
340
|
def as_zip5(self) -> Optional[str]:
|
298
|
-
if not
|
299
|
-
return
|
341
|
+
if not self.value:
|
342
|
+
return None
|
343
|
+
|
344
|
+
# Try to extract exactly 5 digits
|
345
|
+
if match := re.search(r"\d{5}", self.value):
|
346
|
+
return match.group(0)
|
347
|
+
|
348
|
+
# If 4 digits, pad with 0
|
349
|
+
if match := re.search(r"\d{4}", self.value):
|
350
|
+
return f"{match.group(0)}0"
|
300
351
|
|
301
352
|
return None
|
302
353
|
|
karrio/core/utils/number.py
CHANGED
@@ -1,12 +1,14 @@
|
|
1
|
-
|
2
|
-
|
1
|
+
import math
|
2
|
+
import typing
|
3
|
+
import decimal
|
3
4
|
|
4
5
|
|
5
6
|
class NUMBERFORMAT:
|
6
7
|
@staticmethod
|
7
8
|
def decimal(
|
8
|
-
value: Union[str, float, bytes] = None,
|
9
|
-
|
9
|
+
value: typing.Union[str, float, bytes] = None,
|
10
|
+
quant: typing.Optional[float] = None,
|
11
|
+
) -> typing.Optional[float]:
|
10
12
|
"""Parse a value into a valid decimal number.
|
11
13
|
|
12
14
|
:param value: a value that can be parsed to float.
|
@@ -16,14 +18,53 @@ class NUMBERFORMAT:
|
|
16
18
|
if value is None or isinstance(value, bool):
|
17
19
|
return None
|
18
20
|
if quant is not None:
|
19
|
-
|
21
|
+
_result = float(
|
22
|
+
decimal.Decimal(str(value)).quantize(decimal.Decimal(str(quant)))
|
23
|
+
)
|
24
|
+
return _result if _result != 0 else float(decimal.Decimal(str(value)))
|
20
25
|
|
21
26
|
return round(float(value), 2)
|
22
27
|
|
28
|
+
@staticmethod
|
29
|
+
def numeric_decimal(
|
30
|
+
value: typing.Union[str, float, bytes] = None,
|
31
|
+
total_digits: int = 3,
|
32
|
+
decimal_digits: int = 3,
|
33
|
+
) -> str:
|
34
|
+
"""Convert a float to a zero-padded string with customizable total length and decimal places.
|
35
|
+
|
36
|
+
Args:
|
37
|
+
value (float): A floating point number to be formatted.
|
38
|
+
total_digits (int): The total length of the output string (including both numeric and decimal parts).
|
39
|
+
decimal_digits (int): The number of decimal digits (d) in the final output.
|
40
|
+
|
41
|
+
Returns:
|
42
|
+
str: A zero-padded string of total_digits length, with the last decimal_digits as decimals.
|
43
|
+
|
44
|
+
Examples:
|
45
|
+
>>> format_to_custom_numeric_decimal(1.0, 7, 3) # NNNNddd
|
46
|
+
'0001000'
|
47
|
+
|
48
|
+
>>> format_to_custom_numeric_decimal(1.0, 8, 3) # NNNNNddd
|
49
|
+
'00001000'
|
50
|
+
|
51
|
+
>>> format_to_custom_numeric_decimal(1.0, 6, 3) # NNNddd
|
52
|
+
'001000'
|
53
|
+
"""
|
54
|
+
if value is None or isinstance(value, bool):
|
55
|
+
return None
|
56
|
+
|
57
|
+
# Multiply the input float by 10^decimal_digits to scale the decimal part
|
58
|
+
scaling_factor = 10**decimal_digits
|
59
|
+
scaled_value = int(value * scaling_factor)
|
60
|
+
|
61
|
+
# Format as a zero-padded string with the total number of digits
|
62
|
+
return f"{scaled_value:0{total_digits}d}"
|
63
|
+
|
23
64
|
@staticmethod
|
24
65
|
def integer(
|
25
|
-
value: Union[str, int, bytes] = None, base: int = None
|
26
|
-
) -> Optional[int]:
|
66
|
+
value: typing.Union[str, int, bytes] = None, base: int = None
|
67
|
+
) -> typing.Optional[int]:
|
27
68
|
"""Parse a value into a valid integer number.
|
28
69
|
|
29
70
|
:param value: a value that can be parsed into integer.
|
@@ -32,4 +73,7 @@ class NUMBERFORMAT:
|
|
32
73
|
"""
|
33
74
|
if value is None or isinstance(value, bool):
|
34
75
|
return None
|
35
|
-
|
76
|
+
|
77
|
+
return math.ceil(
|
78
|
+
float(value) if base is None else base * round(float(value) / base)
|
79
|
+
)
|
karrio/core/utils/string.py
CHANGED
@@ -7,15 +7,19 @@ class STRINGFORMAT:
|
|
7
7
|
*values,
|
8
8
|
join: bool = False,
|
9
9
|
separator: str = " ",
|
10
|
+
trim: bool = False,
|
10
11
|
) -> typing.Optional[typing.Union[str, typing.List[str]]]:
|
11
12
|
"""Concatenate a set of string values into a list of string or a single joined text.
|
12
13
|
|
13
14
|
:param values: a set of string values.
|
14
15
|
:param join: indicate whether to join into a single string.
|
15
16
|
:param separator: the text separator if joined into a single string.
|
17
|
+
:param trim: indicate whether to trim the string values.
|
16
18
|
:return: a string, list of string or None.
|
17
19
|
"""
|
18
|
-
strings = [
|
20
|
+
strings = [
|
21
|
+
"".join(s.split(" ")) if trim else s for s in values if s not in ["", None]
|
22
|
+
]
|
19
23
|
|
20
24
|
if len(strings) == 0:
|
21
25
|
return None
|
@@ -24,3 +28,50 @@ class STRINGFORMAT:
|
|
24
28
|
return separator.join(strings)
|
25
29
|
|
26
30
|
return strings
|
31
|
+
|
32
|
+
@staticmethod
|
33
|
+
def to_snake_case(input_string: typing.Optional[str]) -> typing.Optional[str]:
|
34
|
+
"""Convert any string format to snake case."""
|
35
|
+
if input_string is None:
|
36
|
+
return None
|
37
|
+
|
38
|
+
# Handle camelCase, PascalCase, and consecutive uppercase letters
|
39
|
+
s = ""
|
40
|
+
for i, char in enumerate(input_string):
|
41
|
+
if char.isupper():
|
42
|
+
if i > 0 and not input_string[i - 1].isupper():
|
43
|
+
s += "_"
|
44
|
+
s += char.lower()
|
45
|
+
else:
|
46
|
+
s += char
|
47
|
+
|
48
|
+
# Handle spaces, hyphens, and other separators
|
49
|
+
s = "".join([c.lower() if c.isalnum() else "_" for c in s])
|
50
|
+
|
51
|
+
# Remove leading/trailing underscores and collapse multiple underscores
|
52
|
+
return "_".join(filter(None, s.split("_")))
|
53
|
+
|
54
|
+
@staticmethod
|
55
|
+
def to_slug(
|
56
|
+
*values,
|
57
|
+
separator: str = "_",
|
58
|
+
) -> typing.Optional[str]:
|
59
|
+
"""Convert a set of string values into a slug string."""
|
60
|
+
|
61
|
+
processed_values = []
|
62
|
+
for value in values:
|
63
|
+
if value not in ["", None]:
|
64
|
+
# Convert to lowercase and replace spaces with separator
|
65
|
+
processed = value.lower().replace(" ", separator)
|
66
|
+
# Replace other non-alphanumeric characters with separator
|
67
|
+
processed = "".join(
|
68
|
+
c if c.isalnum() or c == separator else separator for c in processed
|
69
|
+
)
|
70
|
+
# Remove consecutive separators
|
71
|
+
while separator * 2 in processed:
|
72
|
+
processed = processed.replace(separator * 2, separator)
|
73
|
+
# Remove leading and trailing separators
|
74
|
+
processed = processed.strip(separator)
|
75
|
+
processed_values.append(processed)
|
76
|
+
|
77
|
+
return separator.join(processed_values) if processed_values else None
|