none-shall-parse 0.3.0__py3-none-any.whl → 0.4.1__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.
@@ -12,4 +12,4 @@ https://www.youtube.com/watch?v=zKhEw7nD9C4
12
12
  """
13
13
 
14
14
  __author__ = "Andries Niemandt, Jan Badenhorst"
15
- __email__ = "andries.niemandt@trintel.co.za, jan@trintel.co.za"
15
+ __email__ = "andries.niemandt@trintel.co.za, jan@trintel.co.za"
@@ -0,0 +1,760 @@
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 | str) -> 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 | str, 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
+ sentinel_pdt = pendulum.instance(sentinel)
263
+ sentinel_doy = sentinel_pdt.day_of_year
264
+ sentinel_year = sentinel_pdt.year
265
+
266
+ if naive:
267
+ this_year = pendulum.naive(sentinel_year, 1, 1)
268
+ last_year = pendulum.naive(sentinel_year - 1, 1, 1)
269
+ else:
270
+ this_year = pendulum.datetime(sentinel_year, 1, 1, tz=sentinel_pdt.timezone)
271
+ last_year = pendulum.datetime(sentinel_year - 1, 1, 1, tz=sentinel_pdt.timezone)
272
+
273
+ def f(ordinal: int) -> datetime:
274
+ dt = this_year if ordinal <= sentinel_doy else last_year
275
+
276
+ # Handle leap year edge case
277
+ if ordinal == 366 and not dt.is_leap_year():
278
+ if naive:
279
+ return pendulum.naive(1970, 1, 1)
280
+ else:
281
+ return pendulum.datetime(1970, 1, 1, tz=sentinel_pdt.timezone)
282
+
283
+ result = dt.add(days=ordinal - 1)
284
+ return result
285
+
286
+ return f
287
+
288
+
289
+ # ------------------------------------------------------------------[ Span Functions ]--
290
+ def day_span(pts: datetime) -> Tuple[datetime, datetime]:
291
+ """
292
+ Returns the beginning and end of the day passed in.
293
+ begin is inclusive and end is exclusive.
294
+
295
+ If the given datetime is timezone aware, the results will be as well, and in the
296
+ same timezone. If the given sentinel is naive, the results will be naive.
297
+
298
+ Examples:
299
+ from datetime import datetime
300
+ dt = datetime(2023, 12, 25, 14, 30, 45)
301
+
302
+ start, end = day_span(dt)
303
+ print(type(start)) # <class 'datetime.datetime'>
304
+
305
+ """
306
+ # Check if input is naive or aware
307
+ naive = pts.tzinfo is None
308
+
309
+ # Convert to Pendulum for easier manipulation
310
+ pdt = pendulum.instance(pts)
311
+
312
+ # Use Pendulum's clean API
313
+ begin = pdt.start_of('day')
314
+ end = pdt.add(days=1).start_of('day')
315
+
316
+ if naive:
317
+ return begin.naive(), end.naive()
318
+ else:
319
+ return begin, end
320
+
321
+
322
+ def week_span(wso: int) -> Callable[[datetime], Tuple[datetime, datetime]]:
323
+ """
324
+ Given an integer between 1 and 7, return a function that will give the
325
+ start and end dates of the week.
326
+
327
+ If the given datetime is timezone aware, the results will be as well, and in the
328
+ same timezone. If the given sentinel is naive, the results will be naive.
329
+
330
+ Examples:
331
+ from datetime import datetime
332
+ dt = datetime(2023, 12, 25, 14, 30, 45)
333
+
334
+ # Week starting on Wednesday (ISO day 3)
335
+ week_func = week_span(3)
336
+ week_start, week_end = week_func(dt)
337
+
338
+ :param wso: ISO weekday integer (1=Monday, 7=Sunday)
339
+ """
340
+ assert_week_start_date_is_valid(wso)
341
+
342
+ def find_dates(pts: datetime) -> Tuple[datetime, datetime]:
343
+ # Check if input is naive or aware
344
+ naive = pts.tzinfo is None
345
+ pdt = pendulum.instance(pts)
346
+
347
+ # Get to the desired week start day
348
+ current_weekday = pdt.weekday() + 1 # Pendulum uses 0-6, we want 1-7
349
+ days_back = (current_weekday - wso) % 7
350
+
351
+ begin = pdt.start_of('day').subtract(days=days_back)
352
+ end = begin.add(days=7)
353
+
354
+ if naive:
355
+ return begin.naive(), end.naive()
356
+ else:
357
+ return begin, end
358
+
359
+ return find_dates
360
+
361
+
362
+ def month_span(mso: int) -> Callable[[datetime], Tuple[datetime, datetime]]:
363
+ """
364
+ Given an integer between 1 and 28, return a function that will give the
365
+ start and end dates of the custom month period.
366
+
367
+ If the given datetime is timezone aware, the results will be as well, and in the
368
+ same timezone. If the given sentinel is naive, the results will be naive.
369
+
370
+ Examples:
371
+ from datetime import datetime
372
+ dt = datetime(2023, 12, 25, 14, 30, 45)
373
+
374
+ # Month starting on 15th
375
+ month_func = month_span(15)
376
+ month_start, month_end = month_func(dt)
377
+
378
+ :param mso: Integer (1-28, the day of month to start periods on)
379
+ """
380
+ assert_month_start_date_is_valid(mso)
381
+
382
+ def find_dates(pts: datetime) -> Tuple[datetime, datetime]:
383
+ # Convert to Pendulum
384
+ naive = pts.tzinfo is None
385
+ pdt = pendulum.instance(pts)
386
+ current_day = pdt.day
387
+
388
+ if current_day >= mso:
389
+ # We're in the current month period
390
+ begin = pdt.start_of('day').replace(day=mso)
391
+ else:
392
+ # We're in the previous month period
393
+ begin = pdt.start_of('day').subtract(months=1).replace(day=mso)
394
+
395
+ # End is mso of next month from `begin`
396
+ end = begin.add(months=1)
397
+
398
+ if naive:
399
+ return begin.naive(), end.naive()
400
+ else:
401
+ return begin, end
402
+
403
+ return find_dates
404
+
405
+
406
+ def arb_span(dates: Sequence[str | datetime], naive: bool = False) -> Callable[
407
+ [Any], Tuple[datetime, datetime]]:
408
+ """
409
+ Parses two given dates and returns a callable function that provides the date range
410
+ as a tuple of datetime objects. The function ensures the date range is valid and
411
+ always returns the earlier date as the start and the later date as the end.
412
+
413
+ Parameters:
414
+ dates (Sequence[str | datetime]): A sequence containing exactly two dates where
415
+ each date is either a string or a datetime object.
416
+ naive (bool): Optional flag. If True, the returned datetime objects will not
417
+ have timezone information (naive datetime). Defaults to False.
418
+
419
+ Returns:
420
+ Callable[[Any], Tuple[datetime, datetime]]: A function that, when invoked,
421
+ returns a tuple of datetime objects (start, end) representing the date range.
422
+
423
+ Raises:
424
+ DateUtilsError: If the provided dates are invalid, identical, or there's an error
425
+ during parsing.
426
+ """
427
+ try:
428
+ parsed_dates = []
429
+
430
+ for date in dates[:2]:
431
+ if isinstance(date, str):
432
+ # Parse string - pendulum.parse returns UTC for date-only strings
433
+ parsed = pendulum.parse(date)
434
+
435
+ # If it's a date-only string (no time/timezone info), treat as naive
436
+ if 'T' not in date and ' ' not in date:
437
+ parsed = parsed.naive()
438
+
439
+ parsed_dates.append(parsed.start_of('day'))
440
+ else:
441
+ # It's already a datetime
442
+ if date.tzinfo is None:
443
+ # Input is naive, keep it naive using pendulum.naive()
444
+ parsed = pendulum.naive(date.year, date.month, date.day,
445
+ date.hour, date.minute, date.second,
446
+ date.microsecond)
447
+ else:
448
+ # Input is timezone-aware, preserve it
449
+ parsed = pendulum.instance(date)
450
+
451
+ parsed_dates.append(parsed.start_of('day'))
452
+
453
+ a, b = parsed_dates
454
+
455
+ # Check if they're comparable (both naive or both aware)
456
+ if (a.tzinfo is None) != (b.tzinfo is None):
457
+ raise DateUtilsError("Cannot compare naive and timezone-aware datetimes")
458
+
459
+ if a == b:
460
+ raise DateUtilsError("Dates may not be the same")
461
+
462
+ # Ensure `begin` is the earlier date and `end` is the later date
463
+ begin = a if a < b else b
464
+ end = b if a < b else a
465
+
466
+ except Exception as ex:
467
+ raise DateUtilsError(f"Error parsing dates: {ex}")
468
+
469
+ def find_dates(*args) -> Tuple[datetime, datetime]:
470
+ """
471
+ :return: tuple of datetime objects (start, end)
472
+ """
473
+ if naive:
474
+ return begin.naive(), end.naive()
475
+ return begin, end
476
+
477
+ return find_dates
478
+
479
+
480
+ def unroll_span_func(
481
+ f: Callable[[datetime], Tuple[datetime, datetime]],
482
+ cover: datetime | None = None,
483
+ ) -> Tuple[List[datetime], List[int], List[str], datetime, datetime]:
484
+ """
485
+ Generate keys for a date range based on a provided function.
486
+
487
+ This function computes a date range using the provided function `f`, which takes a base date and returns
488
+ start and end dates. It generates input and output keys for each day in the range based on ordinal days
489
+ and optionally returns only ordinal day integers.
490
+
491
+ Args:
492
+ f: Function that takes a base date and returns a tuple of start and end dates.
493
+ cover: Base date for computing the range. Defaults to the current date if None.
494
+
495
+ Returns:
496
+ A tuple containing:
497
+ - List of datetime objects.
498
+ - List of ordinal day integers.
499
+ - Start date of the range (as datetime).
500
+ - End date of the range (as datetime).
501
+ If ord_ints_only is True, returns (ordinal_days, start, end, iso_dates).
502
+
503
+ Raises:
504
+ DateUtilsError: If the date range cannot be processed due to invalid dates or formatting.
505
+ """
506
+ cover = pendulum.now() if cover is None else cover
507
+ naive = cover.tzinfo is None
508
+ try:
509
+ start, end = f(cover)
510
+ except Exception as e:
511
+ raise DateUtilsError(f"Function f failed to compute date range: {str(e)}")
512
+
513
+ # Make sure we can use pendulum with these dates
514
+ start = pendulum.instance(start) if isinstance(start, datetime) else start
515
+ start = start.naive() if naive else start
516
+ end = pendulum.instance(end) if isinstance(end, datetime) else end
517
+ end = end.naive() if naive else end
518
+
519
+ try:
520
+ # Generate date range using pendulum.interval
521
+ # The absolute kwarg ensures that we do not have to care about dates passed
522
+ # in the wrong order. We will always range from start to end, inclusive.
523
+ interval = pendulum.interval(start, end.subtract(days=1), absolute=True)
524
+ date_range = []
525
+ ord_days = []
526
+ iso_date_strings = []
527
+ for dt in interval.range(unit="days"):
528
+ date_range.append(dt)
529
+ ord_days.append(dt.day_of_year)
530
+ iso_date_strings.append(dt.format('YYYY-MM-DD'))
531
+
532
+ return date_range, ord_days, iso_date_strings, start, end
533
+
534
+ except (TypeError, ValueError) as e:
535
+ raise DateUtilsError(f"Error processing date range: {str(e)}")
536
+
537
+
538
+ def keys_for_span_func(
539
+ f: Callable[[datetime], Tuple[datetime, datetime]],
540
+ cover: datetime | None = None,
541
+ key_in_format: str = "ODIN_{}",
542
+ key_out_format: str = "ODOUT_{}",
543
+ ):
544
+ """
545
+ Generate keys for a date range based on a provided function.
546
+
547
+ Args:
548
+ f: Function that takes a base date and returns a tuple of start and end dates.
549
+ cover: Base date for computing the range. Defaults to the current date if None.
550
+ key_in_format: Format string for input keys. Defaults to "ODIN_{}".
551
+ key_out_format: Format string for output keys, Defaults to "ODOUT_{}"
552
+
553
+ Returns:
554
+ - List of input keys (empty if key_in_format is None).
555
+ - List of output keys (empty if key_out_format is None).
556
+ - Start date of the range (as datetime).
557
+ - End date of the range (as datetime).
558
+
559
+ Raises:
560
+ DateUtilsError: If the date range cannot be processed.
561
+ """
562
+ date_range, ord_days, iso_date_strings, start, end = unroll_span_func(f=f, cover=cover)
563
+ keys_in = [key_in_format.format(d) for d in ord_days]
564
+ keys_out = [key_out_format.format(d) for d in ord_days]
565
+ return keys_in, keys_out, start, end
566
+
567
+
568
+ def calendar_month_start_end(date_in_month: datetime | None = None) -> Tuple[
569
+ datetime, datetime]:
570
+ naive = date_in_month.tzinfo is None
571
+
572
+ if date_in_month is None:
573
+ date_in_month = za_now()
574
+
575
+ pdt = pendulum.instance(date_in_month)
576
+
577
+ start = pdt.start_of('month')
578
+ end = start.add(months=1)
579
+
580
+ if naive:
581
+ return start.naive(), end.naive()
582
+
583
+ return start, end
584
+
585
+
586
+ # --------------------------------------------------------------------[ Unaware time ]--
587
+ def unix_timestamp() -> int:
588
+ """
589
+ Unix timestamps are, by definition, the number of seconds since the epoch - a
590
+ fixed moment in time, defined as 01-01-1970 UTC.
591
+ :return: Current Unix timestamp
592
+ """
593
+ return round(time.time())
594
+
595
+
596
+ def sentinel_date_and_ordinal_to_date(sentinel_date: datetime | date,
597
+ ordinal: int | float | str) -> date:
598
+ """Convert sentinel date and ordinal day to actual date"""
599
+ year = sentinel_date.year
600
+ int_ordinal = int(ordinal)
601
+
602
+ # If sentinel is Jan 1st and ordinal > 1, use previous year
603
+ if sentinel_date.month == 1 and sentinel_date.day == 1 and int_ordinal > 1:
604
+ year = year - 1
605
+
606
+ # Use Pendulum for date arithmetic
607
+ dt = pendulum.datetime(year, 1, 1).add(days=int_ordinal - 1)
608
+ return dt.date()
609
+
610
+
611
+ def seconds_to_end_of_month() -> int:
612
+ """Calculate seconds remaining until the end of the current month"""
613
+ now = pendulum.now(UTC_TZ)
614
+ end_of_month = now.end_of('month')
615
+ return int((end_of_month - now).total_seconds())
616
+
617
+
618
+ def standard_tz_timestring(ts: int | float, tz: str = ZA_TZ) -> str:
619
+ """
620
+ Format timestamp as: 2022-02-22 15:28:10 (SAST)
621
+ :param ts: Seconds since epoch
622
+ :param tz: Timezone string
623
+ :return: Formatted datetime string
624
+ """
625
+ dt = pendulum.from_timestamp(int(ts), tz=tz)
626
+ return dt.strftime("%Y-%m-%d %H:%M:%S (%Z)")
627
+
628
+
629
+ def get_notice_end_date(given_date: datetime | date | None = None) -> date:
630
+ """
631
+ A notice end date is the end of the month of the given date if the given date
632
+ is before or on the 15th. If the given date is after the 15th, the notice period
633
+ ends at the end of the next month.
634
+ :param given_date: Date to calculate the notice end from
635
+ :return: Notice end date
636
+ """
637
+ if given_date is None:
638
+ given_date = pendulum.now().today()
639
+ elif isinstance(given_date, datetime):
640
+ given_date = given_date.date()
641
+ elif not isinstance(given_date, date):
642
+ raise ValueError(
643
+ "Given date must be a datetime.date or datetime.datetime object")
644
+
645
+ pdt = pendulum.instance(given_date)
646
+ if given_date.day <= 15:
647
+ # End of current month
648
+ end_date = pdt.add(months=1).start_of('month')
649
+ else:
650
+ # End of next month
651
+ end_date = pdt.add(months=2).start_of('month')
652
+
653
+ return end_date
654
+
655
+
656
+ def dt_to_za_time_string(v: datetime) -> str:
657
+ """Convert datetime to South Africa time string"""
658
+ # Convert to Pendulum
659
+ naive = v.tzinfo is None
660
+ if naive:
661
+ pdt = pendulum.instance(v, tz=ZA_TZ)
662
+ else:
663
+ pdt = pendulum.instance(v).in_timezone(ZA_TZ)
664
+ return pdt.strftime("%Y-%m-%d %H:%M:%S")
665
+
666
+
667
+ def months_ago_selection() -> List[Tuple[int, str]]:
668
+ """Generate list of (index, "Month-Year") tuples for last 12 months"""
669
+ today = pendulum.today()
670
+
671
+ return [
672
+ (i, today.subtract(months=i).strftime("%B-%Y"))
673
+ for i in range(12)
674
+ ]
675
+
676
+
677
+ def is_aware(dt: datetime) -> bool:
678
+ """Check if a datetime object is timezone-aware."""
679
+ return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
680
+
681
+
682
+ def make_aware(dt: datetime | None, tz: str = None) -> datetime | None:
683
+ """
684
+ Convert a naive datetime to a timezone-aware datetime using Pendulum.
685
+
686
+ Args:
687
+ dt: The datetime object to convert. If None, returns None.
688
+ tz: The timezone to apply (default: The user's default timezone).).
689
+
690
+ Returns:
691
+ A timezone-aware datetime object.
692
+
693
+ Raises:
694
+ TypeError: If dt is not a datetime object or None.
695
+ ValueError: If dt is already timezone-aware.
696
+ DateUtilsError: If the timezone string is invalid.
697
+ """
698
+ # We force the timezone to the user's local timezone if none was supplied
699
+ # to make this function behave the same as the other functions, where the absense
700
+ # of a timezone is interpreted as the user's local timezone.
701
+ if tz is None:
702
+ tz = local_timezone()
703
+
704
+ if dt is None:
705
+ return None
706
+ if not isinstance(dt, datetime):
707
+ raise TypeError(f"Expected datetime or None, got {type(dt).__name__}")
708
+ if is_aware(dt):
709
+ raise ValueError(f"Datetime is already timezone-aware with {dt.tzinfo}")
710
+
711
+ try:
712
+ return pendulum.instance(dt, tz=tz)
713
+ except InvalidTimezone as e:
714
+ raise DateUtilsError(f"Invalid timezone: {tz}") from e
715
+
716
+
717
+ def unaware_to_utc_aware(dt: datetime | None) -> datetime | None:
718
+ """Convert naive datetime to UTC-aware datetime using Pendulum."""
719
+ if not isinstance(dt, (datetime, type(None))):
720
+ raise TypeError(f"Expected datetime or None, got {type(dt)}")
721
+
722
+ if dt is None or is_aware(dt):
723
+ return dt
724
+
725
+ # Use Pendulum for clean UTC conversion
726
+ pdt = pendulum.instance(dt, tz=UTC_TZ)
727
+ return pdt
728
+
729
+
730
+ def timer_decorator(logger: logging.Logger | None = None):
731
+ """
732
+ Timer decorator that optionally accepts a logger.
733
+
734
+ Args:
735
+ logger: Logger instance to use for timing output. If None, uses print().
736
+
737
+ Returns:
738
+ Decorator function
739
+ """
740
+
741
+ def decorator(func: Callable) -> Callable:
742
+ @functools.wraps(func)
743
+ def wrapper(*args, **kwargs) -> Any:
744
+ start_time = time.perf_counter()
745
+ result = func(*args, **kwargs)
746
+ end_time = time.perf_counter()
747
+ execution_time = end_time - start_time
748
+
749
+ message = f"{func.__name__}:TIME:{execution_time:.6f}s"
750
+
751
+ if logger:
752
+ logger.info(message)
753
+ else:
754
+ print(message) # Fallback to print if no logger provided
755
+
756
+ return result
757
+
758
+ return wrapper
759
+
760
+ return decorator
none_shall_parse/imeis.py CHANGED
@@ -47,28 +47,28 @@ def is_valid_luhn(n: Union[str, int]) -> bool:
47
47
  e
48
48
  for e in n
49
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
- ]
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
72
  ]
73
73
  )
74
74
  chars = [int(ch) for ch in str(n)][::-1] # Reversed Digits
@@ -135,16 +135,16 @@ def get_tac_from_imei(n: Union[str, int]) -> tuple[bool, str]:
135
135
  """
136
136
  Determines the validity of an IMEI number and extracts its TAC if valid.
137
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
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
141
  Code), which corresponds to the first 8 digits of the IMEI.
142
142
 
143
143
  Parameters:
144
144
  n (str): The IMEI number to be validated and processed.
145
145
 
146
146
  Returns:
147
- tuple: A tuple containing a boolean indicating whether the IMEI is valid
147
+ tuple: A tuple containing a boolean indicating whether the IMEI is valid
148
148
  and a string representing the TAC if valid or a placeholder if invalid.
149
149
  """
150
150
  tac = "Not a Valid IMEI"
@@ -188,9 +188,9 @@ def increment_imei(n: Union[str, int]) -> tuple[bool, str]:
188
188
  """
189
189
  Determines if a given IMEI number is valid and increments it by 1 if valid.
190
190
 
191
- This function first checks if the provided IMEI number is valid using the
191
+ This function first checks if the provided IMEI number is valid using the
192
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,
193
+ value by 1 while retaining only the first 14 digits. If the input is not valid,
194
194
  it returns a predefined invalid result.
195
195
 
196
196
  Parameters:
@@ -199,8 +199,8 @@ def increment_imei(n: Union[str, int]) -> tuple[bool, str]:
199
199
 
200
200
  Returns:
201
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
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
204
  IMEI number if valid or an error message if not valid.
205
205
  """
206
206
  result = "Not a Valid IMEI"
none_shall_parse/lists.py CHANGED
@@ -1,7 +1,9 @@
1
1
  import collections
2
2
  from typing import Iterable, Generator, Any, List
3
+
3
4
  from .types import T
4
5
 
6
+
5
7
  def flatten(some_list: Iterable) -> Generator[Any, None, None]:
6
8
  """
7
9
  Flattens a nested iterable into a one-dimensional generator.
@@ -44,4 +46,3 @@ def safe_list_get(lst: List[T], idx: int, default: T) -> T:
44
46
  return lst[idx]
45
47
  except IndexError:
46
48
  return default
47
-
none_shall_parse/parse.py CHANGED
@@ -1,11 +1,11 @@
1
1
  from typing import Callable, Any
2
2
  from typing import Union
3
3
 
4
- from src.none_shall_parse.strings import slugify
5
- from src.none_shall_parse.types import ChoicesType, StringLike
4
+ from .strings import slugify
5
+ from .types import ChoicesType, StringLike
6
6
 
7
- _true_set = {'yes', 'true', 't', 'y', '1'}
8
- _false_set = {'no', 'false', 'f', 'n', '0'}
7
+ _true_set = {"yes", "true", "t", "y", "1"}
8
+ _false_set = {"no", "false", "f", "n", "0"}
9
9
 
10
10
 
11
11
  def str_to_bool(v: Any, raise_exc: bool = False) -> bool | None:
@@ -123,8 +123,8 @@ def int_or_none(s: int | float | str | None) -> int | None:
123
123
 
124
124
 
125
125
  def choices_code_to_string(
126
- choices: ChoicesType, default: str | None = None,
127
- to_slug: bool = False) -> Callable[[Union[int, StringLike]], StringLike | None]:
126
+ choices: ChoicesType, default: str | None = None, to_slug: bool = False
127
+ ) -> Callable[[Union[int, StringLike]], StringLike | None]:
128
128
  """
129
129
  Converts a code to a corresponding string representation based on provided choices.
130
130
  The function allows optional fallback to a default value and can slugify the resulting string
@@ -157,8 +157,8 @@ def choices_code_to_string(
157
157
 
158
158
 
159
159
  def choices_string_to_code(
160
- choices: ChoicesType, default: Any = None,
161
- to_lower: bool = False) -> Callable[[str], Union[int, str, None]]:
160
+ choices: ChoicesType, default: Any = None, to_lower: bool = False
161
+ ) -> Callable[[str], Union[int, str, None]]:
162
162
  """
163
163
  Converts a dictionary of choices into a callable function that maps input strings
164
164
  to their corresponding codes. This helper function is particularly useful for handling
@@ -9,7 +9,8 @@ import unicodedata
9
9
  from typing import Any
10
10
 
11
11
  _control_chars = "".join(
12
- map(chr, itertools.chain(range(0x00, 0x20), range(0x7F, 0xA0))))
12
+ map(chr, itertools.chain(range(0x00, 0x20), range(0x7F, 0xA0)))
13
+ )
13
14
  _re_control_char = re.compile("[%s]" % re.escape(_control_chars))
14
15
  _re_combine_whitespace = re.compile(r"\s+")
15
16
 
@@ -74,8 +75,8 @@ def is_quoted_string(s: str, strip: bool = False) -> tuple[bool, str]:
74
75
  """
75
76
  Checks if a given string is enclosed in quotes and optionally strips the quotes.
76
77
 
77
- The function determines whether a given string starts and ends with matching quotes,
78
- either single quotes (') or double quotes ("). If the string is quoted and
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
79
80
  the `strip` parameter is set to True, it removes the enclosing quotes and returns
80
81
  the unquoted string.
81
82
 
@@ -88,8 +89,8 @@ def is_quoted_string(s: str, strip: bool = False) -> tuple[bool, str]:
88
89
 
89
90
  Returns:
90
91
  tuple[bool, str]
91
- A tuple where the first element is a boolean indicating whether the string
92
- is quoted, and the second element is the original string or the stripped
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
93
94
  version if `strip` is True.
94
95
  """
95
96
  is_quoted = False
@@ -131,7 +132,7 @@ def is_numeric_string(s: str, convert: bool = False) -> tuple[bool, str | int |
131
132
  tuple
132
133
  A tuple containing a boolean and the result:
133
134
  - A boolean indicating whether the input string is numeric or not.
134
- - The numeric value if `convert` is True and the string is numeric;
135
+ - The numeric value if `convert` is True and the string is numeric;
135
136
  otherwise, the original string.
136
137
  """
137
138
  is_numeric = False
@@ -218,15 +219,15 @@ def calc_hash(*args: Any) -> str:
218
219
  Returns:
219
220
  str: The computed SHA-1 hash as a hexadecimal string.
220
221
  """
221
- s = '_'.join(map(str, args))
222
+ s = "_".join(map(str, args))
222
223
  return hashlib.sha1(s.encode("utf-16")).hexdigest()
223
224
 
224
225
 
225
226
  def generate_random_password(n: int = 10) -> str:
226
227
  """
227
- Generates a random password meeting specific criteria for complexity. The
228
- function ensures the password contains at least one lowercase letter, one
229
- uppercase letter, and at least three numeric digits. The length of the
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
230
231
  password can be customized using the 'n' parameter.
231
232
 
232
233
  Parameters:
@@ -239,9 +240,9 @@ def generate_random_password(n: int = 10) -> str:
239
240
  while True:
240
241
  password = "".join(secrets.choice(alphabet) for i in range(n))
241
242
  if (
242
- any(c.islower() for c in password)
243
- and any(c.isupper() for c in password)
244
- and sum(c.isdigit() for c in password) >= 3
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
245
246
  ):
246
247
  break
247
248
  return password
none_shall_parse/types.py CHANGED
@@ -12,6 +12,7 @@ class StringLike(Protocol):
12
12
  commonly used string manipulation methods. Objects adhering to
13
13
  this protocol can mimic the behavior of standard Python strings.
14
14
  """
15
+
15
16
  def __str__(self) -> str: ...
16
17
  def __len__(self) -> int: ...
17
18
  def __add__(self, other: str) -> str: ...
@@ -28,4 +29,4 @@ class StringLike(Protocol):
28
29
 
29
30
  ChoicesType = Sequence[Tuple[Union[int, str], StringLike]]
30
31
 
31
- T = TypeVar('T')
32
+ T = TypeVar("T")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: none-shall-parse
3
- Version: 0.3.0
3
+ Version: 0.4.1
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 an publish to pypi:
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=aoOlpzHynnO8HpMQxQteY1XunHPR6KzbZQgq9121JDE,26461
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.1.dist-info/WHEEL,sha256=4n27za1eEkOnA7dNjN6C5-O2rUiw6iapszm14Uj-Qmk,79
9
+ none_shall_parse-0.4.1.dist-info/METADATA,sha256=pW01F0kYVnFckUU2KTEtETYYgwJLBT_MGsrafjtfwxY,1701
10
+ none_shall_parse-0.4.1.dist-info/RECORD,,
@@ -1,9 +0,0 @@
1
- none_shall_parse/__init__.py,sha256=ElNUb98vffLm3_IvHJWLklIPWvr0p84SdpVLHof2n1o,548
2
- none_shall_parse/imeis.py,sha256=sNLGeeGU0K4SvTb4xp-yuV0P3c-JjYDanble9f09uBc,6911
3
- none_shall_parse/lists.py,sha256=3s9Oi0-aUVcfzhIdW6N9-z7ZI56VxTa3WRDj1zAiJQI,1705
4
- none_shall_parse/parse.py,sha256=U4FUuQqtqgEKEC-3blUd78EFYyQHbBgbWW194VXdcYw,6905
5
- none_shall_parse/strings.py,sha256=HAcaDOHkrUZ_pDfNjfh89GQ5EJeBo6WjH5of1B11vos,7950
6
- none_shall_parse/types.py,sha256=SGHhzzIxC9_89STRa9eAQt18_cZVuklpj2cUnyrW8l0,1121
7
- none_shall_parse-0.3.0.dist-info/WHEEL,sha256=4n27za1eEkOnA7dNjN6C5-O2rUiw6iapszm14Uj-Qmk,79
8
- none_shall_parse-0.3.0.dist-info/METADATA,sha256=d-W1OJJiftIMIVXi3EzRYfgAWLu_74FYrchoBHmROOs,1676
9
- none_shall_parse-0.3.0.dist-info/RECORD,,