clockwork 0.7.1__tar.gz → 0.8.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.
Files changed (24) hide show
  1. {clockwork-0.7.1 → clockwork-0.8.0}/PKG-INFO +2 -1
  2. {clockwork-0.7.1 → clockwork-0.8.0}/pyproject.toml +28 -27
  3. {clockwork-0.7.1 → clockwork-0.8.0/src}/clockwork/__init__.py +4 -3
  4. {clockwork-0.7.1/clockwork/timestamp → clockwork-0.8.0/src/clockwork}/_decorators.py +3 -3
  5. clockwork-0.8.0/src/clockwork/constants.py +6 -0
  6. clockwork-0.7.1/clockwork/timestamp/core.py → clockwork-0.8.0/src/clockwork/date_time.py +203 -89
  7. {clockwork-0.7.1 → clockwork-0.8.0/src}/clockwork/month_end.py +14 -13
  8. {clockwork-0.7.1 → clockwork-0.8.0/src}/clockwork/quarter_end.py +12 -12
  9. {clockwork-0.7.1 → clockwork-0.8.0/src}/clockwork/taskmaster/_task.py +7 -7
  10. {clockwork-0.7.1 → clockwork-0.8.0/src}/clockwork/taskmaster/taskmaster.py +4 -4
  11. {clockwork-0.7.1 → clockwork-0.8.0}/uv.lock +15 -1
  12. clockwork-0.7.1/clockwork/constants.py +0 -1
  13. clockwork-0.7.1/clockwork/timestamp/__init__.py +0 -1
  14. {clockwork-0.7.1 → clockwork-0.8.0}/.gitignore +0 -0
  15. {clockwork-0.7.1 → clockwork-0.8.0}/.python-version +0 -0
  16. {clockwork-0.7.1 → clockwork-0.8.0}/LICENSE +0 -0
  17. {clockwork-0.7.1 → clockwork-0.8.0}/README.md +0 -0
  18. {clockwork-0.7.1 → clockwork-0.8.0/src}/clockwork/decorators.py +0 -0
  19. {clockwork-0.7.1 → clockwork-0.8.0/src}/clockwork/taskmaster/__init__.py +0 -0
  20. {clockwork-0.7.1 → clockwork-0.8.0/src}/clockwork/taskmaster/_scheduler.py +0 -0
  21. {clockwork-0.7.1 → clockwork-0.8.0/src}/clockwork/taskmaster/file_monitor.py +0 -0
  22. {clockwork-0.7.1 → clockwork-0.8.0/src}/clockwork/taskmaster/logger.py +0 -0
  23. {clockwork-0.7.1 → clockwork-0.8.0/src}/clockwork/taskmaster/utils.py +0 -0
  24. {clockwork-0.7.1 → clockwork-0.8.0/src}/clockwork/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: clockwork
3
- Version: 0.7.1
3
+ Version: 0.8.0
4
4
  Summary: Toolkit for time-related operations including scheduling, logging, date manipulation, and more.
5
5
  Project-URL: repository, https://github.com/zteinck/clockwork
6
6
  Project-URL: homepage, https://github.com/zteinck/clockwork
@@ -15,6 +15,7 @@ Requires-Dist: pandas>=3.0.6
15
15
  Requires-Dist: polars>=2.0.0
16
16
  Requires-Dist: python-dateutil>=2.9.0.post0
17
17
  Requires-Dist: schedule>=1.2.2
18
+ Requires-Dist: tzlocal>=5.4.4
18
19
  Description-Content-Type: text/markdown
19
20
 
20
21
  # clockwork
@@ -1,27 +1,28 @@
1
- [project]
2
- name = "clockwork"
3
- version = "0.7.1"
4
- description = "Toolkit for time-related operations including scheduling, logging, date manipulation, and more."
5
- authors = [
6
- { name = "Zachary Einck", email = "zacharyeinck@gmail.com" }
7
- ]
8
- license = { text = "MIT" }
9
- readme = "README.md"
10
- requires-python = ">=3.13"
11
- dependencies = [
12
- "holidays>=0.106",
13
- "numpy>=2.5.4",
14
- "oddments>=0.10.1",
15
- "pandas>=3.0.6",
16
- "polars>=2.0.0",
17
- "python-dateutil>=2.9.0.post0",
18
- "schedule>=1.2.2",
19
- ]
20
-
21
- [project.urls]
22
- repository = "https://github.com/zteinck/clockwork"
23
- homepage = "https://github.com/zteinck/clockwork"
24
-
25
- [build-system]
26
- requires = ["hatchling"]
27
- build-backend = "hatchling.build"
1
+ [project]
2
+ name = "clockwork"
3
+ version = "0.8.0"
4
+ description = "Toolkit for time-related operations including scheduling, logging, date manipulation, and more."
5
+ authors = [
6
+ { name = "Zachary Einck", email = "zacharyeinck@gmail.com" }
7
+ ]
8
+ license = { text = "MIT" }
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ dependencies = [
12
+ "holidays>=0.106",
13
+ "numpy>=2.5.4",
14
+ "oddments>=0.10.1",
15
+ "pandas>=3.0.6",
16
+ "polars>=2.0.0",
17
+ "python-dateutil>=2.9.0.post0",
18
+ "schedule>=1.2.2",
19
+ "tzlocal>=5.4.4",
20
+ ]
21
+
22
+ [project.urls]
23
+ repository = "https://github.com/zteinck/clockwork"
24
+ homepage = "https://github.com/zteinck/clockwork"
25
+
26
+ [build-system]
27
+ requires = ["hatchling"]
28
+ build-backend = "hatchling.build"
@@ -1,12 +1,13 @@
1
- from .timestamp import Timestamp
1
+ from .constants import ISO_DATE_FORMAT, UTC
2
+ from .date_time import Datetime
3
+ from .decorators import print_duration
2
4
  from .month_end import MonthEnd
3
5
  from .quarter_end import QuarterEnd
4
- from .decorators import print_duration
5
6
 
6
7
  from .utils import (
7
8
  format_duration,
8
9
  temporal_format_to_regex,
9
10
  )
10
11
 
11
- __version__ = '0.7.1'
12
+ __version__ = '0.8.0'
12
13
  __author__ = 'Zachary Einck <zacharyeinck@gmail.com>'
@@ -32,7 +32,7 @@ def other_to_delta(func):
32
32
  return delta
33
33
 
34
34
  # Otherwise, 'other' is assumed to be a delta and the resulting
35
- # Timestamp object is returned.
35
+ # Datetime object is returned.
36
36
  delta = self._build_delta(other)
37
37
  dt = func(self, delta)
38
38
  ts = self._make_base(dt)
@@ -84,8 +84,8 @@ def handle_next_last(func):
84
84
 
85
85
  Returns
86
86
  ------------
87
- shifted : Timestamp
88
- Timestamp instance
87
+ shifted : Datetime
88
+ Datetime instance
89
89
  '''
90
90
 
91
91
  (
@@ -0,0 +1,6 @@
1
+ from zoneinfo import ZoneInfo
2
+
3
+
4
+ ISO_DATE_FORMAT = '%Y-%m-%d'
5
+ MONTHS_IN_YEAR = 12
6
+ UTC = ZoneInfo('UTC')
@@ -1,19 +1,31 @@
1
- import datetime
2
1
  import calendar
3
- from copy import deepcopy
2
+ import datetime
3
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
4
4
 
5
- import holidays as hd
6
- import pandas as pd
5
+ from dateutil.relativedelta import relativedelta as relative_delta
6
+ from dateutil.parser import parse as parse_dt_string
7
+ from holidays import UnitedStates
7
8
  import numpy as np
8
9
  from oddments import Validator, UNSET
9
- from dateutil.relativedelta import relativedelta
10
- from dateutil.parser import parse
10
+ import pandas as pd
11
+ from tzlocal import get_localzone_name
12
+
13
+ from .constants import (
14
+ ISO_DATE_FORMAT,
15
+ MONTHS_IN_YEAR,
16
+ UTC,
17
+ )
11
18
 
12
- from ..constants import MONTHS_IN_YEAR
13
- from ._decorators import *
19
+ from ._decorators import (
20
+ other_to_dt,
21
+ other_to_delta,
22
+ handle_next_last,
23
+ to_period_end,
24
+ skip_shift,
25
+ )
14
26
 
15
27
 
16
- class Timestamp:
28
+ class Datetime:
17
29
  '''
18
30
  Description
19
31
  --------------------
@@ -22,7 +34,7 @@ class Timestamp:
22
34
 
23
35
  Class Attributes
24
36
  --------------------
25
- repr_format : str
37
+ display_format : str
26
38
  String of datetime format codes used to render the object's string
27
39
  representation.
28
40
  business_hours : tuple[int]
@@ -47,7 +59,7 @@ class Timestamp:
47
59
  #| Class Attributes |
48
60
  #╰-------------------------------------------------------------------------╯
49
61
 
50
- repr_format = '%Y-%m-%d %I:%M:%S.%f %p'
62
+ display_format = '%Y-%m-%d %I:%M:%S.%f %p'
51
63
  business_hours = (8, 17) # 8am - 5pm
52
64
  weekday_map = None
53
65
  holiday_calendar = None
@@ -145,10 +157,21 @@ class Timestamp:
145
157
  return self.time_zone
146
158
 
147
159
 
160
+ @property
161
+ def tz_key(self):
162
+ ''' time zone key '''
163
+ return None if self.tz is None else self.tz.key
164
+
165
+
148
166
  @property
149
167
  def ymd(self):
150
168
  ''' Date string in ISO 8601 format (i.e. YYYY-MM-DD) '''
151
- return self.to_string('%Y-%m-%d')
169
+ return self.to_string(ISO_DATE_FORMAT)
170
+
171
+
172
+ @property
173
+ def display(self):
174
+ return self.to_string(self.display_format)
152
175
 
153
176
 
154
177
  @property
@@ -225,7 +248,7 @@ class Timestamp:
225
248
  @property
226
249
  def is_weekend(self):
227
250
  ''' returns True if the date falls on the weekend '''
228
- return self.weekday_name in {'Saturday','Sunday'}
251
+ return self.weekday_index >= 5
229
252
 
230
253
 
231
254
  @property
@@ -274,9 +297,9 @@ class Timestamp:
274
297
 
275
298
 
276
299
  @property
277
- def is_normalized(self):
278
- ''' returns True if date has no time component (i.e. 00:00:00) '''
279
- return self.time == datetime.time.min
300
+ def has_time_component(self):
301
+ ''' whether the date has a time component (i.e. not 00:00:00) '''
302
+ return self.time != datetime.time.min
280
303
 
281
304
 
282
305
  @property
@@ -348,6 +371,8 @@ class Timestamp:
348
371
 
349
372
 
350
373
  def __getattr__(self, name):
374
+ if name.startswith('_'):
375
+ raise AttributeError(name)
351
376
  return getattr(self.dt, name)
352
377
 
353
378
 
@@ -360,10 +385,10 @@ class Timestamp:
360
385
 
361
386
 
362
387
  def __repr__(self):
363
- return '{0}(dt={1}, tz={2})'.format(
388
+ return '{0}({1!r}, tz_key={2!r})'.format(
364
389
  self.__class__.__name__,
365
- self.to_string(self.repr_format),
366
- self.time_zone,
390
+ self.display,
391
+ self.tz_key,
367
392
  )
368
393
 
369
394
 
@@ -423,11 +448,11 @@ class Timestamp:
423
448
  #| Instance Methods |
424
449
  #╰-------------------------------------------------------------------------╯
425
450
 
426
- def shift(self, *, biz_days=None, **kwargs):
451
+ def shift(self, *, business_days=None, **kwargs):
427
452
  '''
428
453
  Description
429
454
  ------------
430
- Returns a Timestamp object representing self shifted by a delta.
455
+ Returns a Datetime object representing self shifted by a delta.
431
456
 
432
457
  Parameters
433
458
  ------------
@@ -437,16 +462,16 @@ class Timestamp:
437
462
  available:
438
463
 
439
464
  relative : bool
440
- If True, kwargs are passed to 'relative_delta' instead of
465
+ If True, kwargs are passed to 'relativedelta' instead of
441
466
  'datetime.timedelta'.
442
- biz_days : int
467
+ business_days : int
443
468
  Similar to the datetime.timedelta days argument, except
444
469
  weekends and holidays are excluded from the count.
445
470
 
446
471
  Returns
447
472
  ------------
448
- shifted : Timestamp
449
- Timestamp object representing self post-shift.
473
+ shifted : Datetime
474
+ Datetime object representing self post-shift.
450
475
  '''
451
476
 
452
477
  obj = (
@@ -455,13 +480,19 @@ class Timestamp:
455
480
  else self.clone()
456
481
  )
457
482
 
458
- if biz_days is None or biz_days == 0:
483
+ business_days = self._ensure_int(
484
+ value=business_days,
485
+ name='business_days',
486
+ allow_none=True,
487
+ )
488
+
489
+ if business_days is None or business_days == 0:
459
490
  return obj
460
491
 
461
- delta = int(np.sign(biz_days))
492
+ delta = int(np.sign(business_days))
462
493
  counter = 0
463
494
 
464
- while counter < abs(biz_days):
495
+ while counter < abs(business_days):
465
496
  obj = obj._shift(days=delta)
466
497
  if obj.is_business_day:
467
498
  counter += 1
@@ -503,13 +534,17 @@ class Timestamp:
503
534
 
504
535
 
505
536
  def to_utc(self):
506
- return self.to_time_zone('utc')
537
+ return self.to_time_zone(UTC)
507
538
 
508
539
 
509
540
  def to_naive(self):
510
541
  return self.to_time_zone(None)
511
542
 
512
543
 
544
+ def localize(self):
545
+ return self.to_time_zone('Local')
546
+
547
+
513
548
  def clone(self):
514
549
  ''' returns a deep copy of self '''
515
550
  return self._spawn(self)
@@ -522,7 +557,7 @@ class Timestamp:
522
557
 
523
558
  def to_datetime(self):
524
559
  ''' returns a copy of the underlying datetime.datetime object '''
525
- return deepcopy(self.dt)
560
+ return self.dt
526
561
 
527
562
 
528
563
  def to_pandas_timestamp(self):
@@ -576,10 +611,10 @@ class Timestamp:
576
611
  pass
577
612
 
578
613
 
579
- def normalize(self):
614
+ def to_midnight(self):
580
615
  dt = self.to_datetime()
581
- normalized_dt = self._normalize(dt)
582
- return self._spawn(normalized_dt)
616
+ dt = self._to_midnight(dt)
617
+ return self._spawn(dt)
583
618
 
584
619
 
585
620
  def replace(self, **kwargs):
@@ -608,7 +643,7 @@ class Timestamp:
608
643
  )
609
644
 
610
645
  delta_cls = (
611
- relativedelta
646
+ relative_delta
612
647
  if relative
613
648
  else datetime.timedelta
614
649
  )
@@ -624,10 +659,17 @@ class Timestamp:
624
659
 
625
660
 
626
661
  def _to_time_zone(self, dt, tz):
627
- tz = self._resolve_time_zone(value=tz, name='tz')
628
- dt = dt.astimezone(tz)
629
- if tz is None:
630
- dt = dt.replace(tzinfo=None)
662
+ tz = self._resolve_time_zone(
663
+ value=tz,
664
+ name='tz',
665
+ )
666
+
667
+ dt = (
668
+ dt.replace(tzinfo=None)
669
+ if tz is None
670
+ else dt.astimezone(tz)
671
+ )
672
+
631
673
  return dt
632
674
 
633
675
 
@@ -654,7 +696,7 @@ class Timestamp:
654
696
  Value to convert to datetime. Supported formats include:
655
697
  • None → returns the current datetime (i.e. now)
656
698
  • pd.Timestamp
657
- • cw.Timestamp or subclass
699
+ • cw.Datetime or subclass
658
700
  • datetime.datetime
659
701
  • datetime.date
660
702
  • int or float (expressed in seconds)
@@ -669,9 +711,9 @@ class Timestamp:
669
711
  source_format : str | None
670
712
  Datetime format code(s) used to parse 'source' when it is a string
671
713
  (e.g. '%Y%m%d %H%M%S').
672
- source_tz : UNSET | None | str | datetime.timezone
714
+ source_tz : UNSET | None | str | datetime.timezone | ZoneInfo
673
715
  Source time zone.
674
- target_tz : UNSET | None | str | datetime.timezone
716
+ target_tz : UNSET | None | str | datetime.timezone | ZoneInfo
675
717
  Desired time zone.
676
718
  offset : int
677
719
  If a day of the week is provided (e.g., 'Monday'), the current week
@@ -702,7 +744,7 @@ class Timestamp:
702
744
  now = datetime.datetime.now(tz=_target_tz)
703
745
  days = weekday - now.weekday()
704
746
  dt = now + datetime.timedelta(days=days, weeks=offset)
705
- dt = self._normalize(dt)
747
+ dt = self._to_midnight(dt)
706
748
  return dt
707
749
 
708
750
 
@@ -733,16 +775,43 @@ class Timestamp:
733
775
  return dt
734
776
 
735
777
 
736
- def ensure_parsed_tz(dt):
778
+ def resolve_tz(dt):
779
+
780
+ def normalize_tz(dt):
781
+ if (
782
+ dt.tzinfo is None
783
+ or isinstance(dt.tzinfo, ZoneInfo)
784
+ ):
785
+ return dt
786
+
787
+ if dt.tzinfo == datetime.timezone.utc:
788
+ return dt.replace(tzinfo=UTC)
789
+
790
+ return dt.astimezone(UTC)
791
+
792
+
793
+ dt = normalize_tz(dt)
794
+
795
+ (
796
+ Validator(
797
+ types=ZoneInfo,
798
+ allow_none=True,
799
+ )
800
+ .validate(
801
+ tzinfo=dt.tzinfo
802
+ )
803
+ )
804
+
737
805
  if source_tz is not UNSET and source_tz != dt.tzinfo:
738
- if dt.tzinfo is None:
739
- dt = dt.replace(tzinfo=source_tz)
740
- else:
806
+
807
+ if dt.tzinfo is not None:
741
808
  raise ValueError(
742
- f"Time zone parsed from string ({dt.tzinfo}) does "
743
- f"not align with 'source_tz' ({source_tz})."
809
+ f"The resolved datetime's time zone ({dt.tzinfo!r}) "
810
+ f"does not align with 'source_tz' ({source_tz!r})."
744
811
  )
745
812
 
813
+ dt = dt.replace(tzinfo=source_tz)
814
+
746
815
  if target_tz is not UNSET and target_tz != dt.tzinfo:
747
816
  dt = self._to_time_zone(dt=dt, tz=target_tz)
748
817
 
@@ -754,8 +823,14 @@ class Timestamp:
754
823
  for tz in (source_tz, target_tz)
755
824
  )
756
825
 
757
-
758
826
  if source is None:
827
+
828
+ if source_tz is not UNSET:
829
+ raise ValueError(
830
+ "'source_tz' must be UNSET when 'source' is None, got: "
831
+ f"{source_tz!r}"
832
+ )
833
+
759
834
  dt = datetime.datetime.now(tz=_target_tz)
760
835
  return dt
761
836
 
@@ -763,7 +838,7 @@ class Timestamp:
763
838
 
764
839
  if source_format is not None:
765
840
  dt = datetime.datetime.strptime(source, source_format)
766
- dt = ensure_parsed_tz(dt)
841
+ dt = resolve_tz(dt)
767
842
  return dt
768
843
 
769
844
  for func in (try_weekday, try_quarter_end):
@@ -771,7 +846,9 @@ class Timestamp:
771
846
  if dt is not None:
772
847
  return dt
773
848
 
774
- dt = ensure_parsed_tz(parse(source))
849
+ dt = parse_dt_string(source)
850
+ dt = resolve_tz(dt)
851
+
775
852
  return dt
776
853
 
777
854
  if source_format is not None:
@@ -791,7 +868,7 @@ class Timestamp:
791
868
  tzinfo=_source_tz,
792
869
  )
793
870
 
794
- elif isinstance(source, Timestamp):
871
+ elif isinstance(source, Datetime):
795
872
  dt = source.to_datetime()
796
873
 
797
874
  elif hasattr(source, 'to_pydatetime'):
@@ -804,28 +881,17 @@ class Timestamp:
804
881
  )
805
882
  .validate(
806
883
  type(dt),
807
- f"'dt' type",
884
+ "'dt' type",
808
885
  )
809
886
  )
810
887
 
811
- if source_tz is not UNSET and source_tz != dt.tzinfo:
812
- raise ValueError(
813
- f"The resolved datetime's Time zone ({dt.tzinfo}) does "
814
- f"not align with 'source_tz' ({source_tz})."
815
- )
816
-
817
- if target_tz is not UNSET and target_tz != dt.tzinfo:
818
- dt = self._to_time_zone(dt=dt, tz=target_tz)
819
-
888
+ dt = resolve_tz(dt)
820
889
  return dt
821
890
 
822
891
 
823
892
  # timestamp expressed in seconds
824
893
  elif isinstance(source, (float, int)):
825
- if not (
826
- source_tz is UNSET
827
- or source_tz == datetime.timezone.utc
828
- ):
894
+ if source_tz not in (UNSET, UTC):
829
895
  raise ValueError(
830
896
  "'source_tz' must be UNSET or UTC when 'source' is a "
831
897
  f"timestamp, got: {source_tz!r}"
@@ -874,11 +940,11 @@ class Timestamp:
874
940
 
875
941
  @staticmethod
876
942
  def _make_base(*args, **kwargs):
877
- return Timestamp(*args, **kwargs)
943
+ return Datetime(*args, **kwargs)
878
944
 
879
945
 
880
946
  @staticmethod
881
- def _normalize(value):
947
+ def _to_midnight(value):
882
948
  ''' Sets the time component to zero (midnight) '''
883
949
  return value.replace(hour=0, minute=0, second=0, microsecond=0)
884
950
 
@@ -887,8 +953,8 @@ class Timestamp:
887
953
  def _validate_month(month):
888
954
  if not (1 <= month <= MONTHS_IN_YEAR):
889
955
  raise ValueError(
890
- "'month' must be between 1 and "
891
- f"{MONTHS_IN_YEAR}, got: {month}."
956
+ f"'month' must be between 1 and {MONTHS_IN_YEAR}, got: "
957
+ f"{month}."
892
958
  )
893
959
 
894
960
 
@@ -899,29 +965,64 @@ class Timestamp:
899
965
 
900
966
 
901
967
  @staticmethod
902
- def _ensure_int(value, name=None):
903
- Validator(types=(int, str)).validate(value, name)
968
+ def _ensure_int(
969
+ value,
970
+ name=None,
971
+ whitelist=None,
972
+ allow_none=False,
973
+ positive_only=False,
974
+ ):
904
975
 
905
- if isinstance(value, str):
906
- if value.isdigit():
907
- return int(value)
976
+ if allow_none and value is None:
977
+ return value
978
+
979
+ if isinstance(value, bool):
908
980
  raise TypeError(
909
- f"Failed to convert 'value' to integer: {value!r}"
981
+ f'{name!r} must be an int or str, got bool: {value!r}'
910
982
  )
911
983
 
984
+ if isinstance(value, str):
985
+ try:
986
+ return int(value)
987
+ except ValueError as exc:
988
+ raise ValueError(
989
+ f'Integer conversion failed for {name!r}: {value!r}'
990
+ ) from exc
991
+
992
+ params = {}
993
+
994
+ if positive_only:
995
+ params.update({
996
+ 'min_value': 0,
997
+ 'min_inclusive': False,
998
+ })
999
+
1000
+ (
1001
+ Validator(
1002
+ types=int,
1003
+ whitelist=whitelist,
1004
+ **params,
1005
+ )
1006
+ .validate(value, name)
1007
+ )
1008
+
912
1009
  return value
913
1010
 
914
1011
 
915
1012
  @staticmethod
916
1013
  def _resolve_time_zone(value, name):
917
1014
 
918
- cls = datetime.timezone
919
-
920
1015
  (
921
1016
  Validator(
922
- types=(str, cls),
1017
+ types=(
1018
+ str,
1019
+ ZoneInfo,
1020
+ datetime.timezone,
1021
+ ),
923
1022
  allow_none=True,
924
1023
  allow_unset=True,
1024
+ allow_blank=False,
1025
+ allow_whitespace=False,
925
1026
  )
926
1027
  .validate(value, name)
927
1028
  )
@@ -930,17 +1031,30 @@ class Timestamp:
930
1031
  return value
931
1032
 
932
1033
  if isinstance(value, str):
933
- if value.lower() == 'utc':
934
- return cls.utc
935
- else:
1034
+ if value.upper() == UTC.key:
1035
+ return UTC
1036
+ if value.lower() == 'local':
1037
+ value = get_localzone_name()
1038
+ try:
1039
+ return ZoneInfo(value)
1040
+ except ZoneInfoNotFoundError:
936
1041
  raise ValueError(
937
- f'Unsupported string value for {name!r}: {value!r}.'
938
- )
1042
+ 'String values must be valid ZoneInfo keys. Invalid '
1043
+ f'value for {name!r}: {value!r}.'
1044
+ ) from None
939
1045
 
940
- if isinstance(value, cls):
1046
+ if isinstance(value, ZoneInfo):
941
1047
  return value
942
1048
 
943
- raise AssertionError
1049
+ utc = datetime.timezone.utc
1050
+
1051
+ if value == utc:
1052
+ return UTC
1053
+
1054
+ raise ValueError(
1055
+ f'Expected {utc!r} or a valid ZoneInfo key; fixed offsets are '
1056
+ f'not supported. Unsupported value for {name!r}: {value!r}.'
1057
+ )
944
1058
 
945
1059
 
946
1060
  #╭-------------------------------------------------------------------------╮
@@ -960,7 +1074,7 @@ class Timestamp:
960
1074
  @classmethod
961
1075
  def _get_holiday_calendar(cls):
962
1076
  if cls.holiday_calendar is None:
963
- cls.holiday_calendar = hd.UnitedStates()
1077
+ cls.holiday_calendar = UnitedStates()
964
1078
  return cls.holiday_calendar
965
1079
 
966
1080
 
@@ -982,7 +1096,7 @@ class Timestamp:
982
1096
  @classmethod
983
1097
  def _get_month_end_cls(cls):
984
1098
  if cls._month_end_cls is None:
985
- from ..month_end import MonthEnd
1099
+ from .month_end import MonthEnd
986
1100
  cls._month_end_cls = MonthEnd
987
1101
  return cls._month_end_cls
988
1102
 
@@ -990,6 +1104,6 @@ class Timestamp:
990
1104
  @classmethod
991
1105
  def _get_quarter_end_cls(cls):
992
1106
  if cls._quarter_end_cls is None:
993
- from ..quarter_end import QuarterEnd
1107
+ from .quarter_end import QuarterEnd
994
1108
  cls._quarter_end_cls = QuarterEnd
995
1109
  return cls._quarter_end_cls
@@ -2,11 +2,11 @@ import datetime
2
2
 
3
3
  from oddments import Validator, UNSET
4
4
 
5
- from .timestamp import Timestamp
6
- from .constants import MONTHS_IN_YEAR
5
+ from .constants import ISO_DATE_FORMAT, MONTHS_IN_YEAR
6
+ from .date_time import Datetime
7
7
 
8
8
 
9
- class MonthEnd(Timestamp):
9
+ class MonthEnd(Datetime):
10
10
  '''
11
11
  Description
12
12
  --------------------
@@ -26,7 +26,7 @@ class MonthEnd(Timestamp):
26
26
  #| Class Attributes |
27
27
  #╰-------------------------------------------------------------------------╯
28
28
 
29
- repr_format = '%Y-%m-%d'
29
+ repr_format = ISO_DATE_FORMAT
30
30
  _increment = 1
31
31
 
32
32
 
@@ -44,7 +44,7 @@ class MonthEnd(Timestamp):
44
44
 
45
45
  @property
46
46
  def long_label(self):
47
- return self.to_string('%Y-%m-%d')
47
+ return self.to_string(ISO_DATE_FORMAT)
48
48
 
49
49
 
50
50
  @property
@@ -147,11 +147,8 @@ class MonthEnd(Timestamp):
147
147
  f'of {self.month_name} {self.year}'
148
148
  )
149
149
 
150
- if not self.is_normalized:
151
- return (
152
- 'the time component must be normalized (i.e. all zoroes), '
153
- f'got: {self.time}'
154
- )
150
+ if self.has_time_component:
151
+ return f'it includes a time component: {self.time}'
155
152
 
156
153
 
157
154
  def _init_dt(
@@ -179,7 +176,7 @@ class MonthEnd(Timestamp):
179
176
  parameters are provided. Use positive values to move forward in
180
177
  time and negative values to move backward in time.
181
178
  kwargs : dict
182
- Additional keyword arguments are forwarded to the Timestamp
179
+ Additional keyword arguments are forwarded to the Datetime
183
180
  constructor.
184
181
  '''
185
182
 
@@ -208,7 +205,11 @@ class MonthEnd(Timestamp):
208
205
 
209
206
  if has_year_or_month:
210
207
  year, month = (
211
- self._ensure_int(value=v, name=k)
208
+ self._ensure_int(
209
+ value=v,
210
+ name=k,
211
+ positive_only=True,
212
+ )
212
213
  for k, v in {
213
214
  'year': year,
214
215
  'month': month,
@@ -239,5 +240,5 @@ class MonthEnd(Timestamp):
239
240
  tzinfo=_target_tz,
240
241
  )
241
242
 
242
- self._dt = self._normalize(self._dt)
243
+ self._dt = self._to_midnight(self._dt)
243
244
  self._validate()
@@ -102,7 +102,7 @@ class QuarterEnd(MonthEnd):
102
102
  when no other parameters are provided. Use positive values to move
103
103
  forward in time and negative values to move backward in time.
104
104
  kwargs : dict
105
- Additional keyword arguments forwarded to the Timestamp
105
+ Additional keyword arguments forwarded to the Datetime
106
106
  constructor.
107
107
  '''
108
108
 
@@ -132,23 +132,17 @@ class QuarterEnd(MonthEnd):
132
132
  "simultaneously."
133
133
  )
134
134
 
135
- month = self._ensure_int(month)
136
-
137
- # validate month
138
- (
139
- Validator(
140
- types=int,
135
+ month = self._ensure_int(
136
+ value=month,
137
+ name='month',
141
138
  whitelist=self.scheme,
142
139
  )
143
- .validate(
144
- month=month
145
- )
146
- )
147
140
 
148
141
  if has_quarter:
149
142
  quarter = self._ensure_int(
150
143
  value=quarter,
151
144
  name='quarter',
145
+ whitelist=[1, 2, 3, 4],
152
146
  )
153
147
  month = self.scheme[quarter - 1]
154
148
 
@@ -304,7 +298,13 @@ class QuarterEnd(MonthEnd):
304
298
  if parsed is None:
305
299
  return None
306
300
 
307
- year, quarter = (cls._ensure_int(x) for x in parsed)
301
+ year, quarter = (
302
+ cls._ensure_int(
303
+ value=value,
304
+ positive_only=True,
305
+ )
306
+ for value in parsed
307
+ )
308
308
 
309
309
  if not (1 <= quarter <= 4):
310
310
  raise ValueError(
@@ -4,7 +4,7 @@ from contextlib import redirect_stdout
4
4
 
5
5
  from schedule import CancelJob
6
6
 
7
- from ..timestamp import Timestamp
7
+ from ..date_time import Datetime
8
8
  from ..utils import format_duration
9
9
 
10
10
  from .utils import (
@@ -38,7 +38,7 @@ class Task:
38
38
  name of job
39
39
  at : str
40
40
  at time string
41
- expiry : Timestamp
41
+ expiry : Datetime
42
42
  If not None, job will be set inactive and stop running after this
43
43
  datetime
44
44
  func : func
@@ -173,12 +173,12 @@ class Task:
173
173
 
174
174
  self.update_table(active=0 if set_inactive else 1)
175
175
 
176
- if self.expiry and self.expiry < Timestamp():
176
+ if self.expiry and self.expiry < Datetime():
177
177
  update_status('cancelled on expiration', set_inactive=True)
178
178
  if self.verbose:
179
179
  print('Killing', end=' ')
180
180
  print(self.name, end='')
181
- print(f' @ {Timestamp()} ->', end=' ')
181
+ print(f' @ {Datetime()} ->', end=' ')
182
182
  print('Job Cancelled')
183
183
 
184
184
  # cancel job if it was cancelled on cascasde or
@@ -195,16 +195,16 @@ class Task:
195
195
  if self.verbose:
196
196
  print('Running', end=' ')
197
197
  print(self.name, end='')
198
- print(f' @ {Timestamp()} ->', end=' ')
198
+ print(f' @ {Datetime()} ->', end=' ')
199
199
 
200
200
  if self.restrict_to_business_hours \
201
- and not Timestamp().is_business_hours:
201
+ and not Datetime().is_business_hours:
202
202
  raise PrerequisiteError(
203
203
  'cannot execute outside of business hours'
204
204
  )
205
205
 
206
206
  if self.restrict_to_business_days \
207
- and not Timestamp().is_business_day:
207
+ and not Datetime().is_business_day:
208
208
  raise PrerequisiteError(
209
209
  'cannot execute during the weekend'
210
210
  )
@@ -4,7 +4,7 @@ import uuid
4
4
 
5
5
  import oddments as odd
6
6
 
7
- from ..timestamp import Timestamp
7
+ from ..date_time import Datetime
8
8
  from ._scheduler import TaskScheduler
9
9
  from ._task import Task
10
10
 
@@ -147,9 +147,9 @@ class TaskMaster:
147
147
  constituent time.
148
148
  interval : int
149
149
  schedule.Scheduler.every interval argument
150
- start : Timestamp
150
+ start : Datetime
151
151
  If not None, job will be not be added until this datetime
152
- expiry : Timestamp
152
+ expiry : Datetime
153
153
  If not None, job will be set inactive and stop running after this
154
154
  datetime
155
155
  kwargs : keyword arguments
@@ -168,7 +168,7 @@ class TaskMaster:
168
168
  if not hasattr(cls, 'scheduler'):
169
169
  cls.scheduler = TaskScheduler()
170
170
 
171
- now = Timestamp()
171
+ now = Datetime()
172
172
 
173
173
  if (start and start > now) or (expiry and expiry < now):
174
174
  return
@@ -12,7 +12,7 @@ resolution-markers = [
12
12
 
13
13
  [[package]]
14
14
  name = "clockwork"
15
- version = "0.7.1"
15
+ version = "0.8.0"
16
16
  source = { editable = "." }
17
17
  dependencies = [
18
18
  { name = "holidays" },
@@ -22,6 +22,7 @@ dependencies = [
22
22
  { name = "polars" },
23
23
  { name = "python-dateutil" },
24
24
  { name = "schedule" },
25
+ { name = "tzlocal" },
25
26
  ]
26
27
 
27
28
  [package.metadata]
@@ -33,6 +34,7 @@ requires-dist = [
33
34
  { name = "polars", specifier = ">=2.0.0" },
34
35
  { name = "python-dateutil", specifier = ">=2.9.0.post0" },
35
36
  { name = "schedule", specifier = ">=1.2.2" },
37
+ { name = "tzlocal", specifier = ">=5.4.4" },
36
38
  ]
37
39
 
38
40
  [[package]]
@@ -286,3 +288,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce7
286
288
  wheels = [
287
289
  { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" },
288
290
  ]
291
+
292
+ [[package]]
293
+ name = "tzlocal"
294
+ version = "5.4.5"
295
+ source = { registry = "https://pypi.org/simple" }
296
+ dependencies = [
297
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
298
+ ]
299
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/89/2bd035df331499589fc92e59375c17c6cea6a500453b2c5f6a6ec9572825/tzlocal-5.4.5.tar.gz", hash = "sha256:ba976898bad27945178c9ca25c222c19e322b30c10342b9f40b46714f70d2d5f", size = 31483, upload-time = "2026-10-22T09:09:26.38Z" }
300
+ wheels = [
301
+ { url = "https://files.pythonhosted.org/packages/7b/c1/9be4e1a8a30db8729a86628073910e56f85aaf8a659e053f376a2e6ea5a8/tzlocal-5.4.5-py3-none-any.whl", hash = "sha256:ecab534f6d65d9deb4c4ea52c121ab6dd5958e9b878e71dcb102578269752454", size = 18215, upload-time = "2026-10-22T09:09:24.447Z" },
302
+ ]
@@ -1 +0,0 @@
1
- MONTHS_IN_YEAR = 12
@@ -1 +0,0 @@
1
- from .core import Timestamp
File without changes
File without changes
File without changes
File without changes