none-shall-parse 0.3.0__tar.gz → 0.4.0__tar.gz

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,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: none-shall-parse
3
- Version: 0.3.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 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
@@ -32,7 +32,7 @@ pip install none-shall-parse
32
32
 
33
33
  ## Development Quick Start
34
34
 
35
- #### To build an publish to pypi:
35
+ #### To build and publish to pypi:
36
36
 
37
37
  Update the version in the `pyproject.toml` file, then:
38
38
  ```bash
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "none-shall-parse"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Trinity Shared Python utilities."
9
9
  readme = "README.md"
10
10
  authors = [
@@ -18,7 +18,9 @@ classifiers = [
18
18
  "Operating System :: OS Independent",
19
19
  ]
20
20
  requires-python = ">=3.12"
21
- dependencies = []
21
+ dependencies = [
22
+ "pendulum",
23
+ ]
22
24
 
23
25
  [project.optional-dependencies]
24
26
  dev = [
@@ -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,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
@@ -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"
@@ -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
-
@@ -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
@@ -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")