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.
@@ -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
@@ -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, TypeVar, List
2
+ from typing import Iterable, Generator, Any, List
3
3
 
4
- T = TypeVar('T')
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 Any, Sequence, Tuple, Callable
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
- ChoicesType = Sequence[Tuple[Union[int, str], str]]
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
- choices: ChoicesType, default: str | None = None,
128
- to_slug: bool = False) -> Callable[[Union[int, str]], str | None]:
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
- choices: ChoicesType, default: Any = None,
162
- 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]]:
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
@@ -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
- This replaces all tabs and newlines with spaces and removes all non-printing
44
- control characters.
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
- return base64.b64encode(s).decode("utf-8").strip("=")
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
- def b64_decode(s):
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 over the set of arguments.
113
- This is useful to compare models against each other for equality
114
- if the assumption is that if some combination of fields are equal, then
115
- the models represent equal ideas.
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 = '_'.join(map(str, args))
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
- any(c.islower() for c in password)
127
- and any(c.isupper() for c in password)
128
- 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
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.2.2
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
@@ -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,,