karrio 2023.5.1__py3-none-any.whl → 2025.5rc1__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.
@@ -7,7 +7,7 @@ from karrio.core.utils.datetime import DATEFORMAT as DF
7
7
  from karrio.core.utils.xml import XMLPARSER as XP, Element
8
8
  from karrio.core.utils.serializable import Serializable, Deserializable
9
9
  from karrio.core.utils.pipeline import Pipeline, Job
10
- from karrio.core.utils.enum import Enum, Flag, OptionEnum, svcEnum
10
+ from karrio.core.utils.enum import Enum, Flag, StrEnum, OptionEnum, svcEnum
11
11
  from karrio.core.utils.tracing import Tracer, Record, Trace
12
12
  from karrio.core.utils.transformer import to_multi_piece_rates, to_multi_piece_shipment
13
13
  from karrio.core.utils.caching import Cache
@@ -1,14 +1,14 @@
1
- from typing import Union, List
2
- from datetime import datetime
1
+ import typing
2
+ from datetime import datetime, timedelta, timezone
3
3
 
4
4
 
5
5
  class DATEFORMAT:
6
6
  @staticmethod
7
7
  def date(
8
- date_value: Union[str, int, datetime] = None,
8
+ date_value: typing.Union[str, int, datetime] = None,
9
9
  current_format: str = "%Y-%m-%d",
10
- try_formats: List[str] = None,
11
- ) -> datetime:
10
+ try_formats: typing.List[str] = None,
11
+ ) -> typing.Optional[datetime]:
12
12
  if date_value is None:
13
13
  return None
14
14
 
@@ -30,11 +30,58 @@ 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
- date_str: Union[str, int, datetime] = None,
82
+ date_str: typing.Union[str, int, datetime] = None,
36
83
  current_format: str = "%Y-%m-%d",
37
- try_formats: List[str] = None,
84
+ try_formats: typing.List[str] = None,
38
85
  ):
39
86
  date = DATEFORMAT.date(
40
87
  date_str, current_format=current_format, try_formats=try_formats
@@ -45,13 +92,15 @@ class DATEFORMAT:
45
92
 
46
93
  @staticmethod
47
94
  def fdatetime(
48
- date_str: Union[str, int, datetime] = None,
95
+ date_str: typing.Union[str, int, datetime] = None,
49
96
  current_format: str = "%Y-%m-%d %H:%M:%S",
50
97
  output_format: str = "%Y-%m-%d %H:%M:%S",
51
- try_formats: List[str] = None,
98
+ try_formats: typing.List[str] = None,
52
99
  ):
53
100
  date = DATEFORMAT.date(
54
- date_str, current_format=current_format, try_formats=try_formats
101
+ date_str,
102
+ current_format=current_format,
103
+ try_formats=try_formats,
55
104
  )
56
105
  if date is None:
57
106
  return None
@@ -62,7 +111,7 @@ class DATEFORMAT:
62
111
  time_str: str,
63
112
  current_format: str = "%H:%M:%S",
64
113
  output_format: str = "%H:%M",
65
- try_formats: List[str] = None,
114
+ try_formats: typing.List[str] = None,
66
115
  ):
67
116
  time = DATEFORMAT.date(
68
117
  time_str, current_format=current_format, try_formats=try_formats
@@ -72,7 +121,20 @@ class DATEFORMAT:
72
121
  return time.strftime(output_format)
73
122
 
74
123
  @staticmethod
75
- def ftimestamp(timestamp: Union[str, int] = None):
124
+ def ftimestamp(timestamp: typing.Union[str, int] = None):
76
125
  if timestamp is None:
77
126
  return None
78
- return datetime.utcfromtimestamp(float(timestamp)).strftime("%H:%M")
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,9 +1,11 @@
1
1
  import attr
2
- from typing import Optional, Type, Any, Callable, cast
3
- from enum import Enum as BaseEnum, Flag as BaseFlag, EnumMeta
2
+ import enum
3
+ import typing
4
4
 
5
+ BaseStrEnum = getattr(enum, "StrEnum", enum.Flag)
5
6
 
6
- class MetaEnum(EnumMeta):
7
+
8
+ class MetaEnum(enum.EnumMeta):
7
9
  def __contains__(cls, item):
8
10
  if item is None:
9
11
  return False
@@ -12,15 +14,21 @@ class MetaEnum(EnumMeta):
12
14
 
13
15
  return super().__contains__(item)
14
16
 
15
- def map(cls, key: Any):
17
+ def map(cls, key: typing.Any):
16
18
  if key in cls:
17
19
  return EnumWrapper(key, cls[key])
18
- elif key in cast(Any, cls)._value2member_map_:
20
+ elif key in typing.cast(typing.Any, cls)._value2member_map_:
19
21
  return EnumWrapper(key, cls(key))
20
- elif key in [str(v.value) for v in cast(Any, cls).__members__.values()]:
22
+ elif key in [
23
+ str(v.value) for v in typing.cast(typing.Any, cls).__members__.values()
24
+ ]:
21
25
  return EnumWrapper(
22
26
  key,
23
- next(v for v in cast(Any, cls).__members__.values() if v.value == key),
27
+ next(
28
+ v
29
+ for v in typing.cast(typing.Any, cls).__members__.values()
30
+ if v.value == key
31
+ ),
24
32
  )
25
33
 
26
34
  return EnumWrapper(key)
@@ -29,18 +37,22 @@ class MetaEnum(EnumMeta):
29
37
  return {name: enum.value for name, enum in self.__members__.items()}
30
38
 
31
39
 
32
- class Enum(BaseEnum, metaclass=MetaEnum):
40
+ class Enum(enum.Enum, metaclass=MetaEnum):
33
41
  pass
34
42
 
35
43
 
36
- class Flag(BaseFlag, metaclass=MetaEnum):
44
+ class Flag(enum.Flag, metaclass=MetaEnum):
45
+ pass
46
+
47
+
48
+ class StrEnum(BaseStrEnum, metaclass=MetaEnum): # type: ignore
37
49
  pass
38
50
 
39
51
 
40
52
  @attr.s(auto_attribs=True)
41
53
  class EnumWrapper:
42
- key: Any
43
- enum: Optional[Enum] = None
54
+ key: typing.Any
55
+ enum: typing.Optional[Enum] = None
44
56
 
45
57
  @property
46
58
  def name(self):
@@ -65,87 +77,173 @@ class EnumWrapper:
65
77
 
66
78
  @attr.s(auto_attribs=True)
67
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
+ """
68
88
  code: str
69
- type: Callable = str
70
- state: Any = None
89
+ type: typing.Union[typing.Callable, MetaEnum] = str
90
+ state: typing.Any = None
91
+ default: typing.Any = None
92
+
93
+ def __getitem__(self, type: typing.Callable = None) -> "OptionEnum":
94
+ return OptionEnum("", type or self.type, self.state, self.default)
71
95
 
72
- def __getitem__(self, type: Callable = None) -> "OptionEnum":
73
- return OptionEnum("", type or self.type, self.state)
96
+ def __call__(self, value: typing.Any = None) -> "OptionEnum":
97
+ """Create a new OptionEnum instance with the specified value.
74
98
 
75
- def __call__(self, value: Any = None) -> "OptionEnum":
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
+ """
76
105
  state = self.state
77
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
+
78
111
  # if type is bool we have an option defined as Flag.
79
112
  if self.type is bool:
80
113
  state = value is not False
81
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
+
82
126
  else:
83
127
  state = self.type(value) if value is not None else None
84
128
 
85
- return OptionEnum(self.code, self.type, state)
129
+ return OptionEnum(self.code, self.type, state, self.default)
86
130
 
87
131
 
88
132
  @attr.s(auto_attribs=True)
89
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
+ """
90
143
  key: str
91
- type: Type
92
- compute: Callable
93
- value: Any = None
144
+ type: typing.Type
145
+ compute: typing.Callable
146
+ value: typing.Any = None
147
+ default: typing.Any = None
94
148
 
95
149
  def apply(self, *args, **kwargs):
150
+ """Apply the computation function to the arguments."""
96
151
  return self.compute(*args, **kwargs)
97
152
 
98
153
  """Spec initialization modes"""
99
154
 
100
155
  @staticmethod
101
- def asFlag(key: str) -> "Spec":
156
+ def asFlag(key: str, default: typing.Optional[bool] = None) -> "Spec":
102
157
  """A Spec defined as "Flag" means that when it is specified in the payload,
103
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
104
166
  """
105
167
 
106
- def compute(value: Optional[bool]) -> bool:
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
107
172
  return value is not False
108
173
 
109
- return Spec(key, bool, compute)
174
+ return Spec(key, bool, compute, default=default)
110
175
 
111
176
  @staticmethod
112
- def asKey(key: str) -> "Spec":
177
+ def asKey(key: str, default: typing.Optional[bool] = None) -> "Spec":
113
178
  """A Spec defined as "Key" means that when it is specified in a payload and not flagged as False,
114
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
115
187
  """
116
188
 
117
- def compute(value: Optional[bool]) -> str:
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
118
193
  return key if (value is not False) else None
119
194
 
120
- return Spec(key, bool, compute)
195
+ return Spec(key, bool, compute, default=default)
121
196
 
122
197
  @staticmethod
123
- def asValue(key: str, type: Type = str) -> "Spec":
124
- """A Spec defined as "Type" means that when it is specified in a payload,
198
+ def asValue(key: str, type: typing.Type = str, default: typing.Any = None) -> "Spec":
199
+ """A Spec defined as "typing.Type" means that when it is specified in a payload,
125
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
126
209
  """
127
210
 
128
- def compute(value: Optional[type]) -> type: # type: ignore
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
129
215
  return type(value) if value is not None else None
130
216
 
131
- return Spec(key, type, compute)
217
+ return Spec(key, type, compute, default=default)
132
218
 
133
219
  @staticmethod
134
- def asKeyVal(key: str, type: Type = str) -> "Spec":
220
+ def asKeyVal(key: str, type: typing.Type = str, default: typing.Any = None) -> "Spec":
135
221
  """A Spec defined as "Value" means that when it is specified in a payload,
136
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
137
231
  """
138
232
 
139
- def compute_inner_spec(value: Optional[type]) -> Spec: # type: ignore
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
+
140
238
  computed_value = (
141
239
  getattr(value, "value", None)
142
240
  if hasattr(value, "value")
143
241
  else (type(value) if value is not None else None)
144
242
  )
145
243
 
146
- return Spec(key, type, lambda *_: computed_value, computed_value)
244
+ return Spec(key, type, lambda *_: computed_value, computed_value, default=default)
147
245
 
148
- return Spec(key, type, compute_inner_spec)
246
+ return Spec(key, type, compute_inner_spec, default=default)
149
247
 
150
248
 
151
249
  class svcEnum(str):
@@ -4,19 +4,20 @@ import ssl
4
4
  import uuid
5
5
  import string
6
6
  import base64
7
+ import PyPDF2
7
8
  import asyncio
8
9
  import logging
9
10
  import urllib.parse
10
- from PyPDF2 import PdfMerger
11
- from PIL import Image, ImageFile
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()
@@ -72,8 +73,8 @@ def image_to_pdf(image_str: str, rotate: int = None, resize: dict = None) -> str
72
73
  return base64.b64encode(new_buffer.getvalue()).decode("utf-8")
73
74
 
74
75
 
75
- def bundle_pdfs(base64_strings: List[str]) -> PdfMerger:
76
- merger = PdfMerger(strict=False)
76
+ def bundle_pdfs(base64_strings: List[str]) -> PyPDF2.PdfMerger:
77
+ merger = PyPDF2.PdfMerger(strict=False)
77
78
 
78
79
  for b64_str in base64_strings:
79
80
  buffer = to_buffer(b64_str)
@@ -82,17 +83,17 @@ def bundle_pdfs(base64_strings: List[str]) -> PdfMerger:
82
83
  return merger
83
84
 
84
85
 
85
- def bundle_imgs(base64_strings: List[str]) -> Image:
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:
@@ -144,6 +145,12 @@ def zpl_to_pdf(zpl_str: str, width: int, height: int, dpmm: int = 12) -> str:
144
145
  return doc
145
146
 
146
147
 
148
+ def binary_to_base64(binary_str: str) -> str:
149
+ buffer = to_buffer(binary_str)
150
+
151
+ return base64.b64encode(buffer.getvalue()).decode("utf-8")
152
+
153
+
147
154
  def decode_bytes(byte):
148
155
  return (
149
156
  failsafe(lambda: byte.decode("utf-8"))
@@ -155,6 +162,7 @@ def decode_bytes(byte):
155
162
  def process_request(
156
163
  request_id: str,
157
164
  trace: Callable[[Any, str], Any] = None,
165
+ proxy: str = None,
158
166
  **kwargs,
159
167
  ) -> Request:
160
168
  payload = (
@@ -175,6 +183,21 @@ def process_request(
175
183
 
176
184
  _request = Request(**{**kwargs, **payload})
177
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
+
178
201
  logger.info(f"Request URL:: {_request.full_url}")
179
202
 
180
203
  return _request
@@ -182,15 +205,16 @@ def process_request(
182
205
 
183
206
  def process_response(
184
207
  request_id: str,
185
- response,
208
+ response: Any,
186
209
  decoder: Callable,
210
+ on_ok: Callable[[Any], str] = None,
187
211
  trace: Callable[[Any, str], Any] = None,
188
212
  ) -> str:
189
- try:
190
- _response = decoder(response)
191
- except Exception as e:
192
- logger.error(e)
193
- _response = 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
194
218
 
195
219
  if trace:
196
220
  _content = _response if isinstance(_response, str) else "undecoded bytes..."
@@ -224,23 +248,29 @@ def process_error(
224
248
 
225
249
  def request(
226
250
  decoder: Callable = decode_bytes,
251
+ on_ok: Callable[[Any], str] = None,
227
252
  on_error: Callable[[HTTPError], str] = None,
228
253
  trace: Callable[[Any, str], Any] = None,
254
+ proxy: str = None,
255
+ timeout: Optional[int] = None,
229
256
  **kwargs,
230
257
  ) -> str:
231
258
  """Return an HTTP response body.
232
259
 
233
260
  make a http request (wrapper around Request method from built in urllib)
261
+ Proxy example: 'Username:Password@IP_Address:Port'
234
262
  """
235
263
 
236
264
  _request_id = str(uuid.uuid4())
237
265
  logger.debug(f"sending request ({_request_id})...")
238
266
 
239
267
  try:
240
- _request = process_request(_request_id, trace, **kwargs)
268
+ _request = process_request(_request_id, trace, proxy, **kwargs)
241
269
 
242
- with urlopen(_request) as f:
243
- _response = process_response(_request_id, f.read(), decoder, trace=trace)
270
+ with urlopen(_request, timeout=timeout) as f:
271
+ _response = process_response(
272
+ _request_id, f, decoder, on_ok=on_ok, trace=trace
273
+ )
244
274
 
245
275
  except HTTPError as e:
246
276
  _response = process_error(_request_id, e, on_error=on_error, trace=trace)
@@ -252,26 +282,45 @@ def exec_parrallel(
252
282
  function: Callable, sequence: List[S], max_workers: int = None
253
283
  ) -> List[T]:
254
284
  """Return a list of result for function execution on each element of the sequence."""
255
- workers = len(sequence) or max_workers or 2
285
+ if not sequence:
286
+ return [] # No work to do
287
+
288
+ workers = min(len(sequence), max_workers or len(sequence))
289
+
256
290
  with ThreadPoolExecutor(max_workers=workers) as executor:
257
- requests = {executor.submit(function, item): item for item in sequence}
258
- return [response.result() for response in as_completed(requests)]
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
259
303
 
260
304
 
261
305
  def exec_async(action: Callable, sequence: List[S]) -> List[T]:
262
- async def run_tasks(loop):
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
263
309
  return await asyncio.gather(
264
- *[loop.run_in_executor(None, lambda: action(args)) for args in sequence]
310
+ *[asyncio.to_thread(action, args) for args in sequence]
265
311
  )
266
312
 
267
- def run_loop():
268
- loop = asyncio.new_event_loop()
269
- result = loop.run_until_complete(run_tasks(loop))
270
- loop.close()
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()
271
317
 
272
- return result
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())
273
322
 
274
- result = ThreadPoolExecutor().submit(run_loop).result()
323
+ # Cast the result to the expected type
275
324
  return cast(List[T], result)
276
325
 
277
326
 
@@ -289,8 +338,16 @@ class Location:
289
338
 
290
339
  @property
291
340
  def as_zip5(self) -> Optional[str]:
292
- if not re.match(r"/^SW\d{5}$/", self.value or ""):
293
- return self.value
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"
294
351
 
295
352
  return None
296
353