none-shall-parse 0.2.2__py3-none-any.whl → 0.4.0__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.
- none_shall_parse/__init__.py +1 -1
- none_shall_parse/dates.py +682 -0
- none_shall_parse/imeis.py +212 -0
- none_shall_parse/lists.py +2 -3
- none_shall_parse/parse.py +8 -9
- none_shall_parse/strings.py +138 -21
- none_shall_parse/types.py +32 -0
- {none_shall_parse-0.2.2.dist-info → none_shall_parse-0.4.0.dist-info}/METADATA +3 -2
- none_shall_parse-0.4.0.dist-info/RECORD +10 -0
- none_shall_parse-0.2.2.dist-info/RECORD +0 -7
- {none_shall_parse-0.2.2.dist-info → none_shall_parse-0.4.0.dist-info}/WHEEL +0 -0
none_shall_parse/__init__.py
CHANGED
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
from typing import Callable, Any, Tuple, Sequence, List
|
|
6
|
+
|
|
7
|
+
import pendulum
|
|
8
|
+
from pendulum import local_timezone
|
|
9
|
+
from pendulum.tz.exceptions import InvalidTimezone
|
|
10
|
+
|
|
11
|
+
ZA_TZ = 'Africa/Johannesburg'
|
|
12
|
+
UTC_TZ = 'UTC'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DateUtilsError(Exception):
|
|
16
|
+
"""
|
|
17
|
+
Raised when some date calc gets crap input.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
message = None
|
|
21
|
+
|
|
22
|
+
def __init__(self, message):
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.message = message
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def assert_week_start_date_is_valid(wso):
|
|
28
|
+
if wso < 1 or wso > 7:
|
|
29
|
+
raise DateUtilsError("Weeks can only start on days between 1 and 7")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def assert_month_start_date_is_valid(mso):
|
|
33
|
+
if mso > 28 or mso < 1:
|
|
34
|
+
raise DateUtilsError("Months can only start on days between 1 and 28")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_datetime_now(naive: bool = False, tz: str | None = None) -> datetime:
|
|
38
|
+
"""
|
|
39
|
+
Get the current date and time.
|
|
40
|
+
|
|
41
|
+
This function retrieves the current date and time using the pendulum library. It can
|
|
42
|
+
return the datetime in either a naive or timezone-aware format, depending on the
|
|
43
|
+
parameters provided.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
naive (bool): If True, returns a naive datetime object without timezone information.
|
|
47
|
+
Defaults to True.
|
|
48
|
+
tz (Optional[str]): The timezone to use if a timezone-aware datetime is requested.
|
|
49
|
+
If not provided and naive is False, the default system timezone
|
|
50
|
+
is used.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
datetime: The current date and time based on the specified parameters.
|
|
54
|
+
"""
|
|
55
|
+
return pendulum.now().naive() if naive else pendulum.now(tz=tz)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def za_now() -> datetime:
|
|
59
|
+
"""
|
|
60
|
+
Returns the current date and time in the South African timezone.
|
|
61
|
+
|
|
62
|
+
This function retrieves the current date and time, ensuring it is aware of
|
|
63
|
+
time zone settings. It uses the South African timezone (ZA_TZ) for proper
|
|
64
|
+
time localization.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
datetime: The current date and time in the South African timezone.
|
|
68
|
+
"""
|
|
69
|
+
return get_datetime_now(naive=False, tz=ZA_TZ)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def za_ordinal_year_day_now() -> int:
|
|
73
|
+
"""
|
|
74
|
+
Returns the current ordinal day of the year for the ZA_TZ timezone.
|
|
75
|
+
|
|
76
|
+
This function calculates and returns the day of the year based on the current
|
|
77
|
+
date and time in the ZA (South Africa) timezone.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
int: The current ordinal day of the year in ZA_TZ timezone.
|
|
81
|
+
"""
|
|
82
|
+
return pendulum.now(ZA_TZ).day_of_year
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def za_ordinal_year_day_tomorrow() -> int:
|
|
86
|
+
"""
|
|
87
|
+
Returns the ordinal day of the year for tomorrow in the ZA_TZ timezone.
|
|
88
|
+
|
|
89
|
+
This function calculates the ordinal day of the year (1 to 366) for the date
|
|
90
|
+
that is one day ahead of today, as per the ZA_TZ timezone.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
int: The ordinal day of the year for tomorrow in the ZA_TZ timezone.
|
|
94
|
+
"""
|
|
95
|
+
return pendulum.now(ZA_TZ).add(days=1).day_of_year
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def utc_epoch_start() -> datetime:
|
|
99
|
+
"""
|
|
100
|
+
Gets the UTC epoch start time as a datetime object.
|
|
101
|
+
|
|
102
|
+
This function calculates the start of the UNIX epoch (January 1, 1970)
|
|
103
|
+
in UTC as a datetime object. It leverages the `pendulum` library for
|
|
104
|
+
handling the time calculation with the specified UTC timezone.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
datetime: A datetime object representing the start of the UTC epoch.
|
|
108
|
+
"""
|
|
109
|
+
return pendulum.from_timestamp(0)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _now_offset_n_units(n: int, units: str, naive: bool = False,
|
|
113
|
+
tz: str | None = None) -> datetime:
|
|
114
|
+
"""
|
|
115
|
+
Calculate a datetime object offset by a specified number of time units.
|
|
116
|
+
|
|
117
|
+
This function allows you to calculate a datetime offset by a given number of units
|
|
118
|
+
(minutes, hours, days, etc.) from the current time. You can also specify the timezone
|
|
119
|
+
and whether the returned datetime should be naive or timezone-aware.
|
|
120
|
+
|
|
121
|
+
Parameters:
|
|
122
|
+
n (int): The number of units to offset the current time by.
|
|
123
|
+
units (str): The type of time unit to offset by.
|
|
124
|
+
naive (bool): Whether to return a naive datetime (without timezone information).
|
|
125
|
+
Defaults to False.
|
|
126
|
+
tz (Optional[str]): The timezone of the resulting datetime. If not provided,
|
|
127
|
+
the system's local timezone is used.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
datetime: The calculated datetime object, optionally timezone-aware or naive.
|
|
131
|
+
"""
|
|
132
|
+
kwargs = {units: n}
|
|
133
|
+
return pendulum.now().add(**kwargs).naive() if naive else pendulum.now(tz).add(
|
|
134
|
+
**kwargs)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def now_offset_n_minutes(n: int, naive: bool = False,
|
|
138
|
+
tz: str | None = None) -> datetime:
|
|
139
|
+
return _now_offset_n_units(n, units="minutes", naive=naive, tz=tz)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def now_offset_n_hours(n: int, naive: bool = False,
|
|
143
|
+
tz: str | None = None) -> datetime:
|
|
144
|
+
return _now_offset_n_units(n, units="hours", naive=naive, tz=tz)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def now_offset_n_days(n: int, naive: bool = False,
|
|
148
|
+
tz: str | None = None) -> datetime:
|
|
149
|
+
return _now_offset_n_units(n, units="days", naive=naive, tz=tz)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_datetime_tomorrow(naive: bool = False,
|
|
153
|
+
tz: str | None = None) -> datetime:
|
|
154
|
+
"""Get tomorrow's datetime"""
|
|
155
|
+
return now_offset_n_days(1, naive=naive, tz=tz)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def get_datetime_yesterday(naive: bool = False,
|
|
159
|
+
tz: str | None = None) -> datetime:
|
|
160
|
+
"""Get yesterday's datetime"""
|
|
161
|
+
return now_offset_n_days(-1, naive=naive, tz=tz)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def get_utc_datetime_offset_n_days(n: int = 0) -> datetime:
|
|
165
|
+
"""Get UTC datetime n with an offset of n days"""
|
|
166
|
+
return pendulum.now(UTC_TZ).add(days=n)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def epoch_to_datetime(epoch_int: int | float, naive: bool = False,
|
|
170
|
+
tz: str | None = None) -> datetime:
|
|
171
|
+
"""
|
|
172
|
+
Converts an epoch timestamp to a datetime object.
|
|
173
|
+
|
|
174
|
+
This function takes an input epoch timestamp and converts it into a
|
|
175
|
+
datetime object using the Pendulum library. It supports conversion
|
|
176
|
+
to either naive or timezone-aware datetime objects based on the
|
|
177
|
+
parameters provided.
|
|
178
|
+
|
|
179
|
+
Parameters:
|
|
180
|
+
epoch_int (int | float): The epoch timestamp to convert to a datetime
|
|
181
|
+
object. It can be provided as an integer or float value.
|
|
182
|
+
naive (bool): If True, the resulting datetime object will be naive
|
|
183
|
+
(without timezone information). Defaults to False.
|
|
184
|
+
tz (Optional[str]): The timezone in which the resulting datetime object
|
|
185
|
+
should be created. If not provided, the system's default timezone
|
|
186
|
+
will be used.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
datetime: A datetime object representing the converted epoch timestamp.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
# We force the timezone to the user's local timezone if none was supplied
|
|
193
|
+
# to make this function behave the same as the other functions, where the absense
|
|
194
|
+
# of a timezone is interpreted as the user's local timezone.'
|
|
195
|
+
if tz is None:
|
|
196
|
+
tz = local_timezone()
|
|
197
|
+
|
|
198
|
+
if naive:
|
|
199
|
+
return pendulum.from_timestamp(int(epoch_int), tz=tz).naive()
|
|
200
|
+
return pendulum.from_timestamp(int(epoch_int), tz=tz)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def epoch_to_utc_datetime(epoch_int: int | float) -> datetime:
|
|
204
|
+
"""
|
|
205
|
+
Converts an epoch timestamp to a UTC datetime object.
|
|
206
|
+
|
|
207
|
+
This function takes an integer or float representing an epoch timestamp,
|
|
208
|
+
and converts it to a datetime object in UTC timezone using Pendulum.
|
|
209
|
+
|
|
210
|
+
Parameters:
|
|
211
|
+
epoch_int: An epoch timestamp represented as an integer or float.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
A datetime object in UTC corresponding to the provided epoch timestamp.
|
|
215
|
+
"""
|
|
216
|
+
return pendulum.from_timestamp(int(epoch_int), tz=UTC_TZ)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def is_office_hours_in_timezone(epoch_int: int | float, tz: str | None = None) -> bool:
|
|
220
|
+
"""
|
|
221
|
+
Determines if a given epoch timestamp falls within office hours for a
|
|
222
|
+
specific timezone.
|
|
223
|
+
|
|
224
|
+
Office hours are considered to be between 08:00 and 17:00 (8 AM to 5 PM) in
|
|
225
|
+
the specified timezone. The function converts the provided epoch timestamp into
|
|
226
|
+
a datetime object according to the given timezone and checks whether the time falls
|
|
227
|
+
within the defined office hours.
|
|
228
|
+
|
|
229
|
+
Parameters:
|
|
230
|
+
epoch_int: int | float
|
|
231
|
+
Epoch timestamp to be checked. It must represent the number of seconds
|
|
232
|
+
(or fraction of seconds, if float) since the UNIX epoch.
|
|
233
|
+
tz: str, optional
|
|
234
|
+
Timezone expressed as a string. Defaults to local system timezone.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
bool
|
|
238
|
+
True if the epoch timestamp occurs within office hours for the specified
|
|
239
|
+
timezone otherwise, False.
|
|
240
|
+
"""
|
|
241
|
+
dt = pendulum.from_timestamp(int(epoch_int), tz=tz)
|
|
242
|
+
|
|
243
|
+
# Create office hours boundaries for the same date
|
|
244
|
+
oh_begin = dt.replace(hour=8, minute=0, second=0, microsecond=0)
|
|
245
|
+
oh_end = dt.replace(hour=17, minute=0, second=0, microsecond=0)
|
|
246
|
+
|
|
247
|
+
return oh_begin < dt < oh_end
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def get_datetime_from_ordinal_and_sentinel(
|
|
251
|
+
sentinel: datetime | None = None) -> Callable[[int], datetime]:
|
|
252
|
+
"""
|
|
253
|
+
Given an ordinal year day, and a sentinel datetime, get the closest past
|
|
254
|
+
datetime to the sentinel that had the given ordinal year day.
|
|
255
|
+
|
|
256
|
+
If the given sentinel is timezone-aware, the results will be as well, and in the
|
|
257
|
+
same timezone. If the given sentinel is naive, the results will be naive.
|
|
258
|
+
"""
|
|
259
|
+
# Check timezone awareness
|
|
260
|
+
naive = sentinel.tzinfo is None
|
|
261
|
+
|
|
262
|
+
# Convert to Pendulum for easier manipulation
|
|
263
|
+
sentinel_pdt = pendulum.instance(sentinel)
|
|
264
|
+
sentinel_doy = sentinel_pdt.day_of_year
|
|
265
|
+
sentinel_year = sentinel_pdt.year
|
|
266
|
+
|
|
267
|
+
# Create start of years with CORRECT timezone handling
|
|
268
|
+
if naive:
|
|
269
|
+
# Use pendulum.naive() to create naive datetimes
|
|
270
|
+
this_year = pendulum.naive(sentinel_year, 1, 1)
|
|
271
|
+
last_year = pendulum.naive(sentinel_year - 1, 1, 1)
|
|
272
|
+
else:
|
|
273
|
+
# Create in the same timezone as sentinel (not UTC!)
|
|
274
|
+
this_year = pendulum.datetime(sentinel_year, 1, 1, tz=sentinel_pdt.timezone)
|
|
275
|
+
last_year = pendulum.datetime(sentinel_year - 1, 1, 1, tz=sentinel_pdt.timezone)
|
|
276
|
+
|
|
277
|
+
def f(ordinal: int) -> datetime:
|
|
278
|
+
# Choose year based on whether ordinal is before or after sentinel's day of year
|
|
279
|
+
dt = this_year if ordinal <= sentinel_doy else last_year
|
|
280
|
+
|
|
281
|
+
# Handle leap year edge case
|
|
282
|
+
if ordinal == 366 and not dt.is_leap_year():
|
|
283
|
+
if naive:
|
|
284
|
+
return pendulum.naive(1970, 1, 1)
|
|
285
|
+
else:
|
|
286
|
+
return pendulum.datetime(1970, 1, 1, tz=sentinel_pdt.timezone)
|
|
287
|
+
|
|
288
|
+
# Add (ordinal - 1) days to start of year
|
|
289
|
+
result = dt.add(days=ordinal - 1)
|
|
290
|
+
|
|
291
|
+
# Return as-is (Pendulum datetime preserves timezone awareness)
|
|
292
|
+
return result
|
|
293
|
+
|
|
294
|
+
return f
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ------------------------------------------------------------------[ Span Functions ]--
|
|
298
|
+
def day_span(pts: datetime) -> Tuple[datetime, datetime]:
|
|
299
|
+
"""
|
|
300
|
+
Returns the beginning and end of the day passed in.
|
|
301
|
+
begin is inclusive and end is exclusive.
|
|
302
|
+
|
|
303
|
+
If the given datetime is timezone aware, the results will be as well, and in the
|
|
304
|
+
same timezone. If the given sentinel is naive, the results will be naive.
|
|
305
|
+
|
|
306
|
+
Examples:
|
|
307
|
+
from datetime import datetime
|
|
308
|
+
dt = datetime(2023, 12, 25, 14, 30, 45)
|
|
309
|
+
|
|
310
|
+
start, end = day_span(dt)
|
|
311
|
+
print(type(start)) # <class 'datetime.datetime'>
|
|
312
|
+
|
|
313
|
+
"""
|
|
314
|
+
# Check if input is naive or aware
|
|
315
|
+
naive = pts.tzinfo is None
|
|
316
|
+
|
|
317
|
+
# Convert to Pendulum for easier manipulation
|
|
318
|
+
pdt = pendulum.instance(pts)
|
|
319
|
+
|
|
320
|
+
# Use Pendulum's clean API
|
|
321
|
+
begin = pdt.start_of('day')
|
|
322
|
+
end = pdt.add(days=1).start_of('day')
|
|
323
|
+
|
|
324
|
+
if naive:
|
|
325
|
+
return begin.naive(), end.naive()
|
|
326
|
+
else:
|
|
327
|
+
return begin, end
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def week_span(wso: int) -> Callable[[datetime], Tuple[datetime, datetime]]:
|
|
331
|
+
"""
|
|
332
|
+
Given an integer between 1 and 7, return a function that will give the
|
|
333
|
+
start and end dates of the week.
|
|
334
|
+
|
|
335
|
+
If the given datetime is timezone aware, the results will be as well, and in the
|
|
336
|
+
same timezone. If the given sentinel is naive, the results will be naive.
|
|
337
|
+
|
|
338
|
+
Examples:
|
|
339
|
+
from datetime import datetime
|
|
340
|
+
dt = datetime(2023, 12, 25, 14, 30, 45)
|
|
341
|
+
|
|
342
|
+
# Week starting on Wednesday (ISO day 3)
|
|
343
|
+
week_func = week_span(3)
|
|
344
|
+
week_start, week_end = week_func(dt)
|
|
345
|
+
|
|
346
|
+
:param wso: ISO weekday integer (1=Monday, 7=Sunday)
|
|
347
|
+
"""
|
|
348
|
+
assert_week_start_date_is_valid(wso)
|
|
349
|
+
|
|
350
|
+
def find_dates(pts: datetime) -> Tuple[datetime, datetime]:
|
|
351
|
+
# Check if input is naive or aware
|
|
352
|
+
naive = pts.tzinfo is None
|
|
353
|
+
pdt = pendulum.instance(pts)
|
|
354
|
+
|
|
355
|
+
# Get to the desired week start day
|
|
356
|
+
current_weekday = pdt.weekday() + 1 # Pendulum uses 0-6, we want 1-7
|
|
357
|
+
days_back = (current_weekday - wso) % 7
|
|
358
|
+
|
|
359
|
+
begin = pdt.start_of('day').subtract(days=days_back)
|
|
360
|
+
end = begin.add(days=7)
|
|
361
|
+
|
|
362
|
+
if naive:
|
|
363
|
+
return begin.naive(), end.naive()
|
|
364
|
+
else:
|
|
365
|
+
return begin, end
|
|
366
|
+
|
|
367
|
+
return find_dates
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def month_span(mso: int) -> Callable[[datetime], Tuple[datetime, datetime]]:
|
|
371
|
+
"""
|
|
372
|
+
Given an integer between 1 and 28, return a function that will give the
|
|
373
|
+
start and end dates of the custom month period.
|
|
374
|
+
|
|
375
|
+
If the given datetime is timezone aware, the results will be as well, and in the
|
|
376
|
+
same timezone. If the given sentinel is naive, the results will be naive.
|
|
377
|
+
|
|
378
|
+
Examples:
|
|
379
|
+
from datetime import datetime
|
|
380
|
+
dt = datetime(2023, 12, 25, 14, 30, 45)
|
|
381
|
+
|
|
382
|
+
# Month starting on 15th
|
|
383
|
+
month_func = month_span(15)
|
|
384
|
+
month_start, month_end = month_func(dt)
|
|
385
|
+
|
|
386
|
+
:param mso: Integer (1-28, the day of month to start periods on)
|
|
387
|
+
"""
|
|
388
|
+
assert_month_start_date_is_valid(mso)
|
|
389
|
+
|
|
390
|
+
def find_dates(pts: datetime) -> Tuple[datetime, datetime]:
|
|
391
|
+
# Convert to Pendulum
|
|
392
|
+
naive = pts.tzinfo is None
|
|
393
|
+
pdt = pendulum.instance(pts)
|
|
394
|
+
current_day = pdt.day
|
|
395
|
+
|
|
396
|
+
if current_day >= mso:
|
|
397
|
+
# We're in the current month period
|
|
398
|
+
begin = pdt.start_of('day').replace(day=mso)
|
|
399
|
+
else:
|
|
400
|
+
# We're in the previous month period
|
|
401
|
+
begin = pdt.start_of('day').subtract(months=1).replace(day=mso)
|
|
402
|
+
|
|
403
|
+
# End is mso of next month from `begin`
|
|
404
|
+
end = begin.add(months=1)
|
|
405
|
+
|
|
406
|
+
if naive:
|
|
407
|
+
return begin.naive(), end.naive()
|
|
408
|
+
else:
|
|
409
|
+
return begin, end
|
|
410
|
+
|
|
411
|
+
return find_dates
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def arb_span(dates: Sequence[str | datetime], naive: bool = False) -> Callable[
|
|
415
|
+
[], Tuple[datetime, datetime]]:
|
|
416
|
+
"""
|
|
417
|
+
Parses two given dates and returns a callable function that provides the date range
|
|
418
|
+
as a tuple of datetime objects. The function ensures the date range is valid and
|
|
419
|
+
always returns the earlier date as the start and the later date as the end.
|
|
420
|
+
|
|
421
|
+
Parameters:
|
|
422
|
+
dates (Sequence[str | datetime]): A sequence containing exactly two dates where each
|
|
423
|
+
date is either a string or a datetime object.
|
|
424
|
+
naive (bool): Optional flag. If True, the returned datetime objects will not
|
|
425
|
+
have timezone information (naive datetime). Defaults to False.
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
Callable[[], Tuple[datetime, datetime]]: A function that, when invoked, returns a
|
|
429
|
+
tuple of datetime objects (start, end)
|
|
430
|
+
representing the date range.
|
|
431
|
+
|
|
432
|
+
Raises:
|
|
433
|
+
DateUtilsError: If the provided dates are invalid, identical, or there's an error
|
|
434
|
+
during parsing.
|
|
435
|
+
"""
|
|
436
|
+
try:
|
|
437
|
+
parsed_dates = []
|
|
438
|
+
|
|
439
|
+
for date in dates[:2]:
|
|
440
|
+
if isinstance(date, str):
|
|
441
|
+
# Parse string - pendulum.parse returns UTC for date-only strings
|
|
442
|
+
parsed = pendulum.parse(date)
|
|
443
|
+
|
|
444
|
+
# If it's a date-only string (no time/timezone info), treat as naive
|
|
445
|
+
if 'T' not in date and ' ' not in date:
|
|
446
|
+
parsed = parsed.naive()
|
|
447
|
+
|
|
448
|
+
parsed_dates.append(parsed.start_of('day'))
|
|
449
|
+
else:
|
|
450
|
+
# It's already a datetime
|
|
451
|
+
if date.tzinfo is None:
|
|
452
|
+
# Input is naive, keep it naive using pendulum.naive()
|
|
453
|
+
parsed = pendulum.naive(date.year, date.month, date.day,
|
|
454
|
+
date.hour, date.minute, date.second,
|
|
455
|
+
date.microsecond)
|
|
456
|
+
else:
|
|
457
|
+
# Input is timezone-aware, preserve it
|
|
458
|
+
parsed = pendulum.instance(date)
|
|
459
|
+
|
|
460
|
+
parsed_dates.append(parsed.start_of('day'))
|
|
461
|
+
|
|
462
|
+
a, b = parsed_dates
|
|
463
|
+
|
|
464
|
+
# Check if they're comparable (both naive or both aware)
|
|
465
|
+
if (a.tzinfo is None) != (b.tzinfo is None):
|
|
466
|
+
raise DateUtilsError("Cannot compare naive and timezone-aware datetimes")
|
|
467
|
+
|
|
468
|
+
if a == b:
|
|
469
|
+
raise DateUtilsError("Dates may not be the same")
|
|
470
|
+
|
|
471
|
+
# Ensure `begin` is the earlier date and `end` is the later date
|
|
472
|
+
begin = a if a < b else b
|
|
473
|
+
end = b if a < b else a
|
|
474
|
+
|
|
475
|
+
except Exception as ex:
|
|
476
|
+
raise DateUtilsError(f"Error parsing dates: {ex}")
|
|
477
|
+
|
|
478
|
+
def find_dates() -> Tuple[datetime, datetime]:
|
|
479
|
+
"""
|
|
480
|
+
:return: tuple of datetime objects (start, end)
|
|
481
|
+
"""
|
|
482
|
+
if naive:
|
|
483
|
+
return begin.naive(), end.naive()
|
|
484
|
+
return begin, end
|
|
485
|
+
|
|
486
|
+
return find_dates
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def calendar_month_start_end(date_in_month: datetime | None = None) -> Tuple[
|
|
490
|
+
datetime, datetime]:
|
|
491
|
+
naive = date_in_month.tzinfo is None
|
|
492
|
+
|
|
493
|
+
if date_in_month is None:
|
|
494
|
+
date_in_month = za_now()
|
|
495
|
+
|
|
496
|
+
pdt = pendulum.instance(date_in_month)
|
|
497
|
+
|
|
498
|
+
# One-liner for both values
|
|
499
|
+
start = pdt.start_of('month')
|
|
500
|
+
end = start.add(months=1)
|
|
501
|
+
|
|
502
|
+
if naive:
|
|
503
|
+
return start.naive(), end.naive()
|
|
504
|
+
|
|
505
|
+
return start, end
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# --------------------------------------------------------------------[ Unaware time ]--
|
|
509
|
+
def unix_timestamp() -> int:
|
|
510
|
+
"""
|
|
511
|
+
Unix timestamps are, by definition, the number of seconds since the epoch - a
|
|
512
|
+
fixed moment in time, defined as 01-01-1970 UTC.
|
|
513
|
+
:return: Current Unix timestamp
|
|
514
|
+
"""
|
|
515
|
+
return round(time.time())
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def sentinel_date_and_ordinal_to_date(sentinel_date: datetime,
|
|
519
|
+
ordinal: int | float) -> date:
|
|
520
|
+
"""Convert sentinel date and ordinal day to actual date"""
|
|
521
|
+
year = sentinel_date.year
|
|
522
|
+
int_ordinal = int(ordinal)
|
|
523
|
+
|
|
524
|
+
# If sentinel is Jan 1st and ordinal > 1, use previous year
|
|
525
|
+
if sentinel_date.month == 1 and sentinel_date.day == 1 and int_ordinal > 1:
|
|
526
|
+
year = year - 1
|
|
527
|
+
|
|
528
|
+
# Use Pendulum for date arithmetic
|
|
529
|
+
dt = pendulum.datetime(year, 1, 1).add(days=int_ordinal - 1)
|
|
530
|
+
return dt.date()
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def seconds_to_end_of_month() -> int:
|
|
534
|
+
"""Calculate seconds remaining until the end of the current month"""
|
|
535
|
+
now = pendulum.now(UTC_TZ)
|
|
536
|
+
end_of_month = now.end_of('month')
|
|
537
|
+
return int((end_of_month - now).total_seconds())
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def standard_tz_timestring(ts: int | float, tz: str = ZA_TZ) -> str:
|
|
541
|
+
"""
|
|
542
|
+
Format timestamp as: 2022-02-22 15:28:10 (SAST)
|
|
543
|
+
:param ts: Seconds since epoch
|
|
544
|
+
:param tz: Timezone string
|
|
545
|
+
:return: Formatted datetime string
|
|
546
|
+
"""
|
|
547
|
+
dt = pendulum.from_timestamp(int(ts), tz=tz)
|
|
548
|
+
return dt.strftime("%Y-%m-%d %H:%M:%S (%Z)")
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def get_notice_end_date(given_date: datetime | date | None = None) -> date:
|
|
552
|
+
"""
|
|
553
|
+
A notice end date is the end of the month of the given date if the given date
|
|
554
|
+
is before or on the 15th. If the given date is after the 15th, the notice period
|
|
555
|
+
ends at the end of the next month.
|
|
556
|
+
:param given_date: Date to calculate the notice end from
|
|
557
|
+
:return: Notice end date
|
|
558
|
+
"""
|
|
559
|
+
if given_date is None:
|
|
560
|
+
given_date = pendulum.now().today()
|
|
561
|
+
elif isinstance(given_date, datetime):
|
|
562
|
+
given_date = given_date.date()
|
|
563
|
+
elif not isinstance(given_date, date):
|
|
564
|
+
raise ValueError(
|
|
565
|
+
"Given date must be a datetime.date or datetime.datetime object")
|
|
566
|
+
|
|
567
|
+
pdt = pendulum.instance(given_date)
|
|
568
|
+
if given_date.day <= 15:
|
|
569
|
+
# End of current month
|
|
570
|
+
end_date = pdt.add(months=1).start_of('month')
|
|
571
|
+
else:
|
|
572
|
+
# End of next month
|
|
573
|
+
end_date = pdt.add(months=2).start_of('month')
|
|
574
|
+
|
|
575
|
+
return end_date
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def dt_to_za_time_string(v: datetime) -> str:
|
|
579
|
+
"""Convert datetime to South Africa time string"""
|
|
580
|
+
# Convert to Pendulum
|
|
581
|
+
naive = v.tzinfo is None
|
|
582
|
+
if naive:
|
|
583
|
+
pdt = pendulum.instance(v, tz=ZA_TZ)
|
|
584
|
+
else:
|
|
585
|
+
pdt = pendulum.instance(v).in_timezone(ZA_TZ)
|
|
586
|
+
return pdt.strftime("%Y-%m-%d %H:%M:%S")
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def months_ago_selection() -> List[Tuple[int, str]]:
|
|
590
|
+
"""Generate list of (index, "Month-Year") tuples for last 12 months"""
|
|
591
|
+
today = pendulum.today()
|
|
592
|
+
|
|
593
|
+
return [
|
|
594
|
+
(i, today.subtract(months=i).strftime("%B-%Y"))
|
|
595
|
+
for i in range(12)
|
|
596
|
+
]
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def is_aware(dt: datetime) -> bool:
|
|
600
|
+
"""Check if a datetime object is timezone-aware."""
|
|
601
|
+
return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def make_aware(dt: datetime | None, tz: str = None) -> datetime | None:
|
|
605
|
+
"""
|
|
606
|
+
Convert a naive datetime to a timezone-aware datetime using Pendulum.
|
|
607
|
+
|
|
608
|
+
Args:
|
|
609
|
+
dt: The datetime object to convert. If None, returns None.
|
|
610
|
+
tz: The timezone to apply (default: The user's default timezone).).
|
|
611
|
+
|
|
612
|
+
Returns:
|
|
613
|
+
A timezone-aware datetime object.
|
|
614
|
+
|
|
615
|
+
Raises:
|
|
616
|
+
TypeError: If dt is not a datetime object or None.
|
|
617
|
+
ValueError: If dt is already timezone-aware.
|
|
618
|
+
DateUtilsError: If the timezone string is invalid.
|
|
619
|
+
"""
|
|
620
|
+
# We force the timezone to the user's local timezone if none was supplied
|
|
621
|
+
# to make this function behave the same as the other functions, where the absense
|
|
622
|
+
# of a timezone is interpreted as the user's local timezone.
|
|
623
|
+
if tz is None:
|
|
624
|
+
tz = local_timezone()
|
|
625
|
+
|
|
626
|
+
if dt is None:
|
|
627
|
+
return None
|
|
628
|
+
if not isinstance(dt, datetime):
|
|
629
|
+
raise TypeError(f"Expected datetime or None, got {type(dt).__name__}")
|
|
630
|
+
if is_aware(dt):
|
|
631
|
+
raise ValueError(f"Datetime is already timezone-aware with {dt.tzinfo}")
|
|
632
|
+
|
|
633
|
+
try:
|
|
634
|
+
return pendulum.instance(dt, tz=tz)
|
|
635
|
+
except InvalidTimezone as e:
|
|
636
|
+
raise DateUtilsError(f"Invalid timezone: {tz}") from e
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def unaware_to_utc_aware(dt: datetime | None) -> datetime | None:
|
|
640
|
+
"""Convert naive datetime to UTC-aware datetime using Pendulum."""
|
|
641
|
+
if not isinstance(dt, (datetime, type(None))):
|
|
642
|
+
raise TypeError(f"Expected datetime or None, got {type(dt)}")
|
|
643
|
+
|
|
644
|
+
if dt is None or is_aware(dt):
|
|
645
|
+
return dt
|
|
646
|
+
|
|
647
|
+
# Use Pendulum for clean UTC conversion
|
|
648
|
+
pdt = pendulum.instance(dt, tz=UTC_TZ)
|
|
649
|
+
return pdt
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def timer_decorator(logger: logging.Logger | None = None):
|
|
653
|
+
"""
|
|
654
|
+
Timer decorator that optionally accepts a logger.
|
|
655
|
+
|
|
656
|
+
Args:
|
|
657
|
+
logger: Logger instance to use for timing output. If None, uses print().
|
|
658
|
+
|
|
659
|
+
Returns:
|
|
660
|
+
Decorator function
|
|
661
|
+
"""
|
|
662
|
+
|
|
663
|
+
def decorator(func: Callable) -> Callable:
|
|
664
|
+
@functools.wraps(func)
|
|
665
|
+
def wrapper(*args, **kwargs) -> Any:
|
|
666
|
+
start_time = time.perf_counter()
|
|
667
|
+
result = func(*args, **kwargs)
|
|
668
|
+
end_time = time.perf_counter()
|
|
669
|
+
execution_time = end_time - start_time
|
|
670
|
+
|
|
671
|
+
message = f"{func.__name__}:TIME:{execution_time:.6f}s"
|
|
672
|
+
|
|
673
|
+
if logger:
|
|
674
|
+
logger.info(message)
|
|
675
|
+
else:
|
|
676
|
+
print(message) # Fallback to print if no logger provided
|
|
677
|
+
|
|
678
|
+
return result
|
|
679
|
+
|
|
680
|
+
return wrapper
|
|
681
|
+
|
|
682
|
+
return decorator
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from typing import Union
|
|
2
|
+
|
|
3
|
+
LUHN_DOUBLES = [0, 2, 4, 6, 8, 1, 3, 5, 7, 9]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_luhn_digit(n: Union[str, int]) -> int:
|
|
7
|
+
"""
|
|
8
|
+
Calculates the Luhn checksum digit for a given number.
|
|
9
|
+
|
|
10
|
+
The function processes a number using the Luhn algorithm. It computes
|
|
11
|
+
the Luhn checksum digit based on the provided digits, ensuring that the
|
|
12
|
+
resulting number adheres to the Luhn standard. The method is useful for
|
|
13
|
+
validating numerical identifiers like credit card numbers or IMEIs.
|
|
14
|
+
|
|
15
|
+
Parameters:
|
|
16
|
+
n (str | int): A number represented as a string or integer that
|
|
17
|
+
requires Luhn checksum digit calculation.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
int: The Luhn checksum digit as an integer.
|
|
21
|
+
"""
|
|
22
|
+
chars = [int(ch) for ch in str(n)]
|
|
23
|
+
firsts = [ch for ch in chars[0::2]]
|
|
24
|
+
doubles = [LUHN_DOUBLES[ch] for ch in chars[1::2]]
|
|
25
|
+
check = 10 - divmod(sum((sum(firsts), sum(doubles))), 10)[1]
|
|
26
|
+
return divmod(check, 10)[1]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_valid_luhn(n: Union[str, int]) -> bool:
|
|
30
|
+
"""
|
|
31
|
+
Determines if a given number, represented as a string or integer, adheres
|
|
32
|
+
to the Luhn algorithm.
|
|
33
|
+
|
|
34
|
+
The Luhn algorithm, also known as the mod 10 algorithm, is a simple checksum
|
|
35
|
+
formula used to validate identification numbers such as credit card numbers.
|
|
36
|
+
|
|
37
|
+
Parameters:
|
|
38
|
+
n : Union[str, int]
|
|
39
|
+
The input number to be validated. It can be provided as a string or an integer.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
bool
|
|
43
|
+
Returns True if the input number satisfies the Luhn algorithm; otherwise, False.
|
|
44
|
+
"""
|
|
45
|
+
n = "".join(
|
|
46
|
+
[
|
|
47
|
+
e
|
|
48
|
+
for e in n
|
|
49
|
+
if e
|
|
50
|
+
in [
|
|
51
|
+
0,
|
|
52
|
+
1,
|
|
53
|
+
2,
|
|
54
|
+
3,
|
|
55
|
+
4,
|
|
56
|
+
5,
|
|
57
|
+
6,
|
|
58
|
+
7,
|
|
59
|
+
8,
|
|
60
|
+
9,
|
|
61
|
+
"0",
|
|
62
|
+
"1",
|
|
63
|
+
"2",
|
|
64
|
+
"3",
|
|
65
|
+
"4",
|
|
66
|
+
"5",
|
|
67
|
+
"6",
|
|
68
|
+
"7",
|
|
69
|
+
"8",
|
|
70
|
+
"9",
|
|
71
|
+
]
|
|
72
|
+
]
|
|
73
|
+
)
|
|
74
|
+
chars = [int(ch) for ch in str(n)][::-1] # Reversed Digits
|
|
75
|
+
firsts = [ch for ch in chars[0::2]]
|
|
76
|
+
doubles = [LUHN_DOUBLES[ch] for ch in chars[1::2]]
|
|
77
|
+
final = sum((sum(firsts), sum(doubles)))
|
|
78
|
+
return divmod(final, 10)[1] == 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_valid_imei(n: Union[str, int]) -> bool:
|
|
82
|
+
"""
|
|
83
|
+
Determines whether the given number is a valid IMEI (International Mobile
|
|
84
|
+
Equipment Identity) number.
|
|
85
|
+
|
|
86
|
+
An IMEI number is a 15-digit unique identifier for a mobile device. This function
|
|
87
|
+
first checks that the length of the input is 15 characters and then validates
|
|
88
|
+
it using the Luhn algorithm.
|
|
89
|
+
|
|
90
|
+
Parameters:
|
|
91
|
+
n: Union[str, int]
|
|
92
|
+
The number to be checked, represented as a string or integer.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
bool
|
|
96
|
+
True if the given number is a valid IMEI, otherwise False.
|
|
97
|
+
"""
|
|
98
|
+
return len(str(n)) == 15 and is_valid_luhn(n)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def normalize_imei(c: Union[str, int]) -> str:
|
|
102
|
+
"""
|
|
103
|
+
Normalizes the given IMEI number by extracting the first 14 digits and appending
|
|
104
|
+
the calculated Luhn check digit to make it a valid IMEI.
|
|
105
|
+
|
|
106
|
+
The IMEI (International Mobile Equipment Identity) is a unique identifier
|
|
107
|
+
typically consisting of 15 digits. This function ensures that the provided
|
|
108
|
+
IMEI-like input is converted into a valid IMEI format by calculating and appending
|
|
109
|
+
the appropriate check digit.
|
|
110
|
+
|
|
111
|
+
Parameters:
|
|
112
|
+
c: Union[str, int]
|
|
113
|
+
The input IMEI or a value resembling an IMEI. It can be provided as a string
|
|
114
|
+
or an integer.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
str
|
|
118
|
+
A 15-digit valid IMEI as a string.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
Exception
|
|
122
|
+
Raises any exceptions occurring internally within the `get_luhn_digit` function
|
|
123
|
+
if the calculation of the check digit fails.
|
|
124
|
+
|
|
125
|
+
Notes:
|
|
126
|
+
This function assumes the presence of the `get_luhn_digit` function for Luhn
|
|
127
|
+
digit calculation.
|
|
128
|
+
"""
|
|
129
|
+
t = str(c)[:14]
|
|
130
|
+
check_digit = get_luhn_digit(t)
|
|
131
|
+
return "%s%s" % (t, check_digit)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def get_tac_from_imei(n: Union[str, int]) -> tuple[bool, str]:
|
|
135
|
+
"""
|
|
136
|
+
Determines the validity of an IMEI number and extracts its TAC if valid.
|
|
137
|
+
|
|
138
|
+
This function checks whether a provided IMEI (International Mobile Equipment
|
|
139
|
+
Identity) number is valid based on IMEI validation rules. If the given IMEI
|
|
140
|
+
is valid, the function also extracts and returns the TAC (Type Allocation
|
|
141
|
+
Code), which corresponds to the first 8 digits of the IMEI.
|
|
142
|
+
|
|
143
|
+
Parameters:
|
|
144
|
+
n (str): The IMEI number to be validated and processed.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
tuple: A tuple containing a boolean indicating whether the IMEI is valid
|
|
148
|
+
and a string representing the TAC if valid or a placeholder if invalid.
|
|
149
|
+
"""
|
|
150
|
+
tac = "Not a Valid IMEI"
|
|
151
|
+
is_valid = is_valid_imei(n)
|
|
152
|
+
if not is_valid:
|
|
153
|
+
return False, tac
|
|
154
|
+
else:
|
|
155
|
+
tac = str(n)[:8]
|
|
156
|
+
return True, tac
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def decrement_imei(n: Union[str, int]) -> tuple[bool, str]:
|
|
160
|
+
"""
|
|
161
|
+
Decrements the given IMEI number by one and normalizes it.
|
|
162
|
+
|
|
163
|
+
This function validates the provided IMEI number. If it is a valid IMEI, the
|
|
164
|
+
function decrements the first 14 digits by one and computes the new IMEI
|
|
165
|
+
checksum to generate a normalized IMEI. If the provided IMEI is not valid,
|
|
166
|
+
it returns a failure status and an error message.
|
|
167
|
+
|
|
168
|
+
Parameters:
|
|
169
|
+
n: int
|
|
170
|
+
The IMEI number to be validated and decremented.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
tuple[bool, str]
|
|
174
|
+
A tuple where the first element is a boolean indicating whether the
|
|
175
|
+
operation was successful, and the second element is the resulting IMEI
|
|
176
|
+
or an error message if the input was invalid.
|
|
177
|
+
"""
|
|
178
|
+
result = "Not a Valid IMEI"
|
|
179
|
+
is_valid = is_valid_imei(n)
|
|
180
|
+
if not is_valid:
|
|
181
|
+
return False, result
|
|
182
|
+
else:
|
|
183
|
+
result = normalize_imei(int(str(n)[:14]) - 1)
|
|
184
|
+
return True, result
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def increment_imei(n: Union[str, int]) -> tuple[bool, str]:
|
|
188
|
+
"""
|
|
189
|
+
Determines if a given IMEI number is valid and increments it by 1 if valid.
|
|
190
|
+
|
|
191
|
+
This function first checks if the provided IMEI number is valid using the
|
|
192
|
+
is_valid_imei function. If the input is a valid IMEI, it increments the IMEI
|
|
193
|
+
value by 1 while retaining only the first 14 digits. If the input is not valid,
|
|
194
|
+
it returns a predefined invalid result.
|
|
195
|
+
|
|
196
|
+
Parameters:
|
|
197
|
+
n: int
|
|
198
|
+
IMEI number to be validated and potentially incremented.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
tuple[bool, str]
|
|
202
|
+
A tuple where the first element is a boolean indicating whether the operation
|
|
203
|
+
was successful, and the second element is a string containing the incremented
|
|
204
|
+
IMEI number if valid or an error message if not valid.
|
|
205
|
+
"""
|
|
206
|
+
result = "Not a Valid IMEI"
|
|
207
|
+
is_valid = is_valid_imei(n)
|
|
208
|
+
if not is_valid:
|
|
209
|
+
return False, result
|
|
210
|
+
else:
|
|
211
|
+
result = normalize_imei(int(str(n)[:14]) + 1)
|
|
212
|
+
return True, result
|
none_shall_parse/lists.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import collections
|
|
2
|
-
from typing import Iterable, Generator, Any,
|
|
2
|
+
from typing import Iterable, Generator, Any, List
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
from .types import T
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
def flatten(some_list: Iterable) -> Generator[Any, None, None]:
|
|
@@ -46,4 +46,3 @@ def safe_list_get(lst: List[T], idx: int, default: T) -> T:
|
|
|
46
46
|
return lst[idx]
|
|
47
47
|
except IndexError:
|
|
48
48
|
return default
|
|
49
|
-
|
none_shall_parse/parse.py
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
from typing import
|
|
1
|
+
from typing import Callable, Any
|
|
2
2
|
from typing import Union
|
|
3
3
|
|
|
4
4
|
from .strings import slugify
|
|
5
|
+
from .types import ChoicesType, StringLike
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
_true_set = {'yes', 'true', 't', 'y', '1'}
|
|
9
|
-
_false_set = {'no', 'false', 'f', 'n', '0'}
|
|
7
|
+
_true_set = {"yes", "true", "t", "y", "1"}
|
|
8
|
+
_false_set = {"no", "false", "f", "n", "0"}
|
|
10
9
|
|
|
11
10
|
|
|
12
11
|
def str_to_bool(v: Any, raise_exc: bool = False) -> bool | None:
|
|
@@ -124,8 +123,8 @@ def int_or_none(s: int | float | str | None) -> int | None:
|
|
|
124
123
|
|
|
125
124
|
|
|
126
125
|
def choices_code_to_string(
|
|
127
|
-
|
|
128
|
-
|
|
126
|
+
choices: ChoicesType, default: str | None = None, to_slug: bool = False
|
|
127
|
+
) -> Callable[[Union[int, StringLike]], StringLike | None]:
|
|
129
128
|
"""
|
|
130
129
|
Converts a code to a corresponding string representation based on provided choices.
|
|
131
130
|
The function allows optional fallback to a default value and can slugify the resulting string
|
|
@@ -158,8 +157,8 @@ def choices_code_to_string(
|
|
|
158
157
|
|
|
159
158
|
|
|
160
159
|
def choices_string_to_code(
|
|
161
|
-
|
|
162
|
-
|
|
160
|
+
choices: ChoicesType, default: Any = None, to_lower: bool = False
|
|
161
|
+
) -> Callable[[str], Union[int, str, None]]:
|
|
163
162
|
"""
|
|
164
163
|
Converts a dictionary of choices into a callable function that maps input strings
|
|
165
164
|
to their corresponding codes. This helper function is particularly useful for handling
|
none_shall_parse/strings.py
CHANGED
|
@@ -6,14 +6,16 @@ import re
|
|
|
6
6
|
import secrets
|
|
7
7
|
import string
|
|
8
8
|
import unicodedata
|
|
9
|
+
from typing import Any
|
|
9
10
|
|
|
10
11
|
_control_chars = "".join(
|
|
11
|
-
map(chr, itertools.chain(range(0x00, 0x20), range(0x7F, 0xA0)))
|
|
12
|
+
map(chr, itertools.chain(range(0x00, 0x20), range(0x7F, 0xA0)))
|
|
13
|
+
)
|
|
12
14
|
_re_control_char = re.compile("[%s]" % re.escape(_control_chars))
|
|
13
15
|
_re_combine_whitespace = re.compile(r"\s+")
|
|
14
16
|
|
|
15
17
|
|
|
16
|
-
def slugify(value, allow_unicode=False):
|
|
18
|
+
def slugify(value: object, allow_unicode: bool = False) -> str:
|
|
17
19
|
"""
|
|
18
20
|
Maps directly to Django's slugify function.
|
|
19
21
|
Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated
|
|
@@ -38,10 +40,26 @@ def random_16():
|
|
|
38
40
|
return "".join(random.choices(string.ascii_letters + string.digits, k=16))
|
|
39
41
|
|
|
40
42
|
|
|
41
|
-
def to_human_string(s):
|
|
43
|
+
def to_human_string(s: Any) -> tuple[Any | str, bool]:
|
|
42
44
|
"""
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
Cleans up a string by removing extra whitespace and control characters.
|
|
46
|
+
|
|
47
|
+
Removes unnecessary whitespace and control characters from the input string.
|
|
48
|
+
This function is designed to validate and clean user-provided string input
|
|
49
|
+
while preserving input that does not require modification. It returns the
|
|
50
|
+
cleaned string along with a boolean indicating whether changes were made
|
|
51
|
+
to the original string.
|
|
52
|
+
|
|
53
|
+
Parameters:
|
|
54
|
+
s : any
|
|
55
|
+
The input value to clean and validate. If it is not a string, it is
|
|
56
|
+
returned unchanged with a modification flag of False.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
tuple
|
|
60
|
+
A tuple where the first element is the cleaned string (or the original
|
|
61
|
+
input if it is not a string), and the second element is a boolean
|
|
62
|
+
indicating whether the string was modified.
|
|
45
63
|
"""
|
|
46
64
|
if not isinstance(s, str):
|
|
47
65
|
return s, False
|
|
@@ -53,7 +71,28 @@ def to_human_string(s):
|
|
|
53
71
|
return clean_string, True
|
|
54
72
|
|
|
55
73
|
|
|
56
|
-
def is_quoted_string(s, strip=False):
|
|
74
|
+
def is_quoted_string(s: str, strip: bool = False) -> tuple[bool, str]:
|
|
75
|
+
"""
|
|
76
|
+
Checks if a given string is enclosed in quotes and optionally strips the quotes.
|
|
77
|
+
|
|
78
|
+
The function determines whether a given string starts and ends with matching quotes,
|
|
79
|
+
either single quotes (') or double quotes ("). If the string is quoted and
|
|
80
|
+
the `strip` parameter is set to True, it removes the enclosing quotes and returns
|
|
81
|
+
the unquoted string.
|
|
82
|
+
|
|
83
|
+
Parameters:
|
|
84
|
+
s : str
|
|
85
|
+
The input string to check and possibly process.
|
|
86
|
+
strip : bool, optional
|
|
87
|
+
Indicates whether to remove the enclosing quotes if the string is quoted.
|
|
88
|
+
Defaults to False.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
tuple[bool, str]
|
|
92
|
+
A tuple where the first element is a boolean indicating whether the string
|
|
93
|
+
is quoted, and the second element is the original string or the stripped
|
|
94
|
+
version if `strip` is True.
|
|
95
|
+
"""
|
|
57
96
|
is_quoted = False
|
|
58
97
|
result = s
|
|
59
98
|
if not isinstance(s, str):
|
|
@@ -70,7 +109,32 @@ def is_quoted_string(s, strip=False):
|
|
|
70
109
|
return is_quoted, result
|
|
71
110
|
|
|
72
111
|
|
|
73
|
-
def is_numeric_string(s, convert=False):
|
|
112
|
+
def is_numeric_string(s: str, convert: bool = False) -> tuple[bool, str | int | float]:
|
|
113
|
+
"""
|
|
114
|
+
Checks if the given string represents a numeric value and optionally converts it.
|
|
115
|
+
|
|
116
|
+
This function determines if the provided string represents a numeric value.
|
|
117
|
+
If the input is numeric and the `convert` flag is set to True, it returns
|
|
118
|
+
the numeric value converted to either an integer (if the float represents
|
|
119
|
+
an integer) or a float. If the input is not numeric, it returns the original
|
|
120
|
+
input string.
|
|
121
|
+
|
|
122
|
+
Parameters
|
|
123
|
+
----------
|
|
124
|
+
s : str
|
|
125
|
+
The input string to check.
|
|
126
|
+
convert : bool, optional
|
|
127
|
+
A flag indicating whether to convert the numeric string to a numeric
|
|
128
|
+
type (default is False).
|
|
129
|
+
|
|
130
|
+
Returns
|
|
131
|
+
-------
|
|
132
|
+
tuple
|
|
133
|
+
A tuple containing a boolean and the result:
|
|
134
|
+
- A boolean indicating whether the input string is numeric or not.
|
|
135
|
+
- The numeric value if `convert` is True and the string is numeric;
|
|
136
|
+
otherwise, the original string.
|
|
137
|
+
"""
|
|
74
138
|
is_numeric = False
|
|
75
139
|
result = s
|
|
76
140
|
f = None
|
|
@@ -88,7 +152,7 @@ def is_numeric_string(s, convert=False):
|
|
|
88
152
|
return is_numeric, result
|
|
89
153
|
|
|
90
154
|
|
|
91
|
-
def custom_slug(s):
|
|
155
|
+
def custom_slug(s: str) -> str:
|
|
92
156
|
# Remove all non-word characters (everything except numbers and letters)
|
|
93
157
|
s = re.sub(r"[^\w\s]", "", s)
|
|
94
158
|
|
|
@@ -98,34 +162,87 @@ def custom_slug(s):
|
|
|
98
162
|
return s
|
|
99
163
|
|
|
100
164
|
|
|
101
|
-
def b64_encode(s):
|
|
102
|
-
|
|
165
|
+
def b64_encode(s: str | bytes) -> str:
|
|
166
|
+
"""
|
|
167
|
+
Encodes a string or bytes into its Base64 representation.
|
|
168
|
+
|
|
169
|
+
This function takes an input, either a string or bytes, and encodes it
|
|
170
|
+
into its Base64 representation. If the input is a string, it is first
|
|
171
|
+
encoded into bytes using UTF-8 encoding. The resulting Base64 encoded
|
|
172
|
+
value is returned as a string.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
s: The input to encode, which can be either a string or bytes.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The Base64 encoded representation of the input as a string.
|
|
179
|
+
"""
|
|
180
|
+
if isinstance(s, str):
|
|
181
|
+
s = s.encode("utf-8")
|
|
182
|
+
return base64.b64encode(s).decode("utf-8")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def b64_decode(s: str) -> bytes:
|
|
186
|
+
"""
|
|
187
|
+
Decodes a Base64 encoded string to its original binary format.
|
|
188
|
+
|
|
189
|
+
This function takes a Base64 encoded string and decodes it to its
|
|
190
|
+
original bytes form. Base64 encoding may omit padding characters, so
|
|
191
|
+
the function ensures the input is properly padded before decoding.
|
|
192
|
+
|
|
193
|
+
Parameters:
|
|
194
|
+
s: str
|
|
195
|
+
A Base64 encoded string that needs to be decoded.
|
|
103
196
|
|
|
197
|
+
Returns:
|
|
198
|
+
bytes
|
|
199
|
+
The decoded binary data.
|
|
104
200
|
|
|
105
|
-
|
|
201
|
+
Raises:
|
|
202
|
+
ValueError
|
|
203
|
+
If the input string contains invalid Base64 characters.
|
|
204
|
+
"""
|
|
106
205
|
pad = "=" * (-len(s) % 4)
|
|
107
206
|
return base64.b64decode(s + pad)
|
|
108
207
|
|
|
109
208
|
|
|
110
|
-
def calc_hash(*args):
|
|
209
|
+
def calc_hash(*args: Any) -> str:
|
|
111
210
|
"""
|
|
112
|
-
Calculate a hash
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
the
|
|
211
|
+
Calculate and return a SHA-1 hash for the given arguments.
|
|
212
|
+
|
|
213
|
+
This function joins the provided arguments into a single string, encodes it
|
|
214
|
+
using UTF-16, and calculates the SHA-1 hash of the resulting bytes.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
*args: A variable number of arguments to include in the hash.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
str: The computed SHA-1 hash as a hexadecimal string.
|
|
116
221
|
"""
|
|
117
|
-
s =
|
|
222
|
+
s = "_".join(map(str, args))
|
|
118
223
|
return hashlib.sha1(s.encode("utf-16")).hexdigest()
|
|
119
224
|
|
|
120
225
|
|
|
121
|
-
def generate_random_password(n=10):
|
|
226
|
+
def generate_random_password(n: int = 10) -> str:
|
|
227
|
+
"""
|
|
228
|
+
Generates a random password meeting specific criteria for complexity. The
|
|
229
|
+
function ensures the password contains at least one lowercase letter, one
|
|
230
|
+
uppercase letter, and at least three numeric digits. The length of the
|
|
231
|
+
password can be customized using the 'n' parameter.
|
|
232
|
+
|
|
233
|
+
Parameters:
|
|
234
|
+
n (int): Length of the password to be generated. Default is 10.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
str: A randomly generated password that meets the specified criteria.
|
|
238
|
+
"""
|
|
122
239
|
alphabet = string.ascii_letters + string.digits
|
|
123
240
|
while True:
|
|
124
241
|
password = "".join(secrets.choice(alphabet) for i in range(n))
|
|
125
242
|
if (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
243
|
+
any(c.islower() for c in password)
|
|
244
|
+
and any(c.isupper() for c in password)
|
|
245
|
+
and sum(c.isdigit() for c in password) >= 3
|
|
129
246
|
):
|
|
130
247
|
break
|
|
131
248
|
return password
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from typing import Protocol, Sequence, Tuple, Union, TypeVar
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class StringLike(Protocol):
|
|
5
|
+
"""
|
|
6
|
+
Protocol that defines the expected behavior for string-like objects.
|
|
7
|
+
|
|
8
|
+
This protocol specifies the methods and properties that an object
|
|
9
|
+
must implement to be considered string-like. It defines basic
|
|
10
|
+
string operations such as getting the string representation and
|
|
11
|
+
length, string concatenation, containment checks, and several
|
|
12
|
+
commonly used string manipulation methods. Objects adhering to
|
|
13
|
+
this protocol can mimic the behavior of standard Python strings.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __str__(self) -> str: ...
|
|
17
|
+
def __len__(self) -> int: ...
|
|
18
|
+
def __add__(self, other: str) -> str: ...
|
|
19
|
+
def __contains__(self, item: str) -> bool: ...
|
|
20
|
+
|
|
21
|
+
# Most commonly used string methods
|
|
22
|
+
def upper(self) -> str: ...
|
|
23
|
+
def lower(self) -> str: ...
|
|
24
|
+
def strip(self) -> str: ...
|
|
25
|
+
def startswith(self, prefix: str) -> bool: ...
|
|
26
|
+
def endswith(self, suffix: str) -> bool: ...
|
|
27
|
+
def replace(self, old: str, new: str) -> str: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
ChoicesType = Sequence[Tuple[Union[int, str], StringLike]]
|
|
31
|
+
|
|
32
|
+
T = TypeVar("T")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: none-shall-parse
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Trinity Shared Python utilities.
|
|
5
5
|
Author: Andries Niemandt, Jan Badenhorst
|
|
6
6
|
Author-email: Andries Niemandt <andries.niemandt@trintel.co.za>, Jan Badenhorst <jan@trintel.co.za>
|
|
@@ -8,6 +8,7 @@ License: MIT
|
|
|
8
8
|
Classifier: Programming Language :: Python :: 3
|
|
9
9
|
Classifier: License :: OSI Approved :: MIT License
|
|
10
10
|
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Dist: pendulum
|
|
11
12
|
Requires-Dist: pytest>=8.0.0 ; extra == 'dev'
|
|
12
13
|
Requires-Dist: ruff>=0.12.3 ; extra == 'dev'
|
|
13
14
|
Requires-Dist: isort ; extra == 'dev'
|
|
@@ -53,7 +54,7 @@ pip install none-shall-parse
|
|
|
53
54
|
|
|
54
55
|
## Development Quick Start
|
|
55
56
|
|
|
56
|
-
#### To build
|
|
57
|
+
#### To build and publish to pypi:
|
|
57
58
|
|
|
58
59
|
Update the version in the `pyproject.toml` file, then:
|
|
59
60
|
```bash
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
none_shall_parse/__init__.py,sha256=3uhWV40LVbfVnCtRGCDLdTKyBBcoH56zgIApAQWWN0Q,549
|
|
2
|
+
none_shall_parse/dates.py,sha256=njpvrozrbAqWsfdXTl5crR9SiQWONSv8W27QE-CCV94,23430
|
|
3
|
+
none_shall_parse/imeis.py,sha256=o-TgbWDU4tmv1MHnAxfLCeZUpCZ8lt7VKOHrIeBmUFE,6837
|
|
4
|
+
none_shall_parse/lists.py,sha256=buAahex2iOYXZIcGOFfE9y9BYgBgvo3RilIiv1BALJ8,1706
|
|
5
|
+
none_shall_parse/parse.py,sha256=77bXZAtwFksRwuZ9Ax0lPxEjFpyjkQBqRa5mBc1WkF4,6843
|
|
6
|
+
none_shall_parse/strings.py,sha256=Eqrl8Sb-wOzjTu1_bbO-ALljlDKVa-1LcpcACqOmZuE,7931
|
|
7
|
+
none_shall_parse/types.py,sha256=PsljcR1UyyZZizm50wgyZPRNaeYvInSQoPS3i0RLgKo,1123
|
|
8
|
+
none_shall_parse-0.4.0.dist-info/WHEEL,sha256=4n27za1eEkOnA7dNjN6C5-O2rUiw6iapszm14Uj-Qmk,79
|
|
9
|
+
none_shall_parse-0.4.0.dist-info/METADATA,sha256=x4drnmVl1J-P9Mp8NQ3R-8EKC5VTJgZ0jZ8n9IcvzK8,1701
|
|
10
|
+
none_shall_parse-0.4.0.dist-info/RECORD,,
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
none_shall_parse/__init__.py,sha256=ElNUb98vffLm3_IvHJWLklIPWvr0p84SdpVLHof2n1o,548
|
|
2
|
-
none_shall_parse/lists.py,sha256=IndbwxaxvByFtW88AtHyNOTDDp-E1EWLz_KY7OCcBIU,1712
|
|
3
|
-
none_shall_parse/parse.py,sha256=99A_Xo3n2I2zGsgJcobjRPhgNOO1HytpZHs_hmr7t2c,6878
|
|
4
|
-
none_shall_parse/strings.py,sha256=_fvsQtUyjqmPj_c4NjeOsAPrd5LOzNb4SX16-ZyZIEA,3511
|
|
5
|
-
none_shall_parse-0.2.2.dist-info/WHEEL,sha256=4n27za1eEkOnA7dNjN6C5-O2rUiw6iapszm14Uj-Qmk,79
|
|
6
|
-
none_shall_parse-0.2.2.dist-info/METADATA,sha256=iCpKl9FrsBF2jCeC9aciK7Wc0oMWciAxfgx-obdobvg,1676
|
|
7
|
-
none_shall_parse-0.2.2.dist-info/RECORD,,
|
|
File without changes
|