karrio 2023.9.2__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.
@@ -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.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,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):
@@ -8,15 +8,16 @@ import PyPDF2
8
8
  import asyncio
9
9
  import logging
10
10
  import urllib.parse
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()
@@ -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]) -> 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:
@@ -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
- try:
196
- _response = decoder(response)
197
- except Exception as e:
198
- logger.error(e)
199
- _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
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(_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
+ )
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
- 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
+
262
290
  with ThreadPoolExecutor(max_workers=workers) as executor:
263
- requests = {executor.submit(function, item): item for item in sequence}
264
- 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
265
303
 
266
304
 
267
305
  def exec_async(action: Callable, sequence: List[S]) -> List[T]:
268
- 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
269
309
  return await asyncio.gather(
270
- *[loop.run_in_executor(None, lambda: action(args)) for args in sequence]
310
+ *[asyncio.to_thread(action, args) for args in sequence]
271
311
  )
272
312
 
273
- def run_loop():
274
- loop = asyncio.new_event_loop()
275
- result = loop.run_until_complete(run_tasks(loop))
276
- 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()
277
317
 
278
- 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())
279
322
 
280
- result = ThreadPoolExecutor().submit(run_loop).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 re.match(r"/^SW\d{5}$/", self.value or ""):
299
- 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"
300
351
 
301
352
  return None
302
353
 
@@ -1,12 +1,14 @@
1
- from typing import Union, Optional
2
- from decimal import Decimal
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, quant: Optional[float] = None
9
- ) -> Optional[float]:
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
- return float(Decimal(str(value)).quantize(Decimal(str(quant))))
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
- return int(value if base is None else base * round(float(value) / base))
76
+
77
+ return math.ceil(
78
+ float(value) if base is None else base * round(float(value) / base)
79
+ )
@@ -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 = [s for s in values if s not in ["", None]]
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