ga-clock 0.2.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.
ga_clock/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """GA Clock: controllable clocks and internal-time scheduling."""
2
+
3
+ from ._api import CancelJob, Clock, Elapsed, Job
4
+ from ._version import __version__
5
+ from .exceptions import ClockError, GAClockError, GAClockWarning
6
+
7
+ __all__ = [
8
+ "CancelJob",
9
+ "Clock",
10
+ "ClockError",
11
+ "Elapsed",
12
+ "GAClockError",
13
+ "GAClockWarning",
14
+ "Job",
15
+ "__version__",
16
+ ]
ga_clock/_api.py ADDED
@@ -0,0 +1,720 @@
1
+ """Internal implementation for the public GA Clock API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time as time_module
6
+ from collections.abc import Callable, Hashable, Mapping
7
+ from dataclasses import dataclass, field, replace
8
+ from datetime import date, datetime, time, timedelta, tzinfo
9
+ from types import MappingProxyType
10
+ from typing import Any, Final, Literal, TypeAlias, cast
11
+
12
+ from .exceptions import ClockError
13
+
14
+ ClockMode = Literal["realtime", "wrap", "fixed", "scheduled", "manual"]
15
+ DeltaLike: TypeAlias = timedelta | int | float
16
+ DatetimeLike: TypeAlias = datetime | str
17
+ TimeSource = Callable[[], float]
18
+
19
+ SECONDS_PER_DAY = 86_400
20
+ DAYS_PER_GREGORIAN_YEAR = 365.2425
21
+ SECONDS_PER_GREGORIAN_YEAR = SECONDS_PER_DAY * DAYS_PER_GREGORIAN_YEAR
22
+ SECONDS_PER_GREGORIAN_MONTH = SECONDS_PER_GREGORIAN_YEAR / 12
23
+
24
+
25
+ class _CancelJob:
26
+ """Sentinel returned by a job to remove itself from the scheduler."""
27
+
28
+
29
+ CancelJob: Final = _CancelJob()
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Elapsed:
34
+ """Elapsed duration represented with several convenient units."""
35
+
36
+ delta: timedelta
37
+ seconds: float
38
+ minutes: float
39
+ hours: float
40
+ days: float
41
+ months: float
42
+ years: float
43
+
44
+
45
+ @dataclass(eq=False, frozen=True)
46
+ class Job:
47
+ """A scheduled job created by :meth:`Clock.every`.
48
+
49
+ Jobs are usually configured fluently:
50
+
51
+ ```python
52
+ clock.every(5).minutes.do(send_heartbeat).tag("heartbeat")
53
+ ```
54
+ """
55
+
56
+ scheduler: _Scheduler
57
+ interval: int = 1
58
+ unit: str | None = None
59
+ at_time: time | None = None
60
+ start_day: int | None = None
61
+ tags: frozenset[Hashable] = field(default_factory=frozenset)
62
+ job_func: Callable[..., Any] | None = None
63
+ args: tuple[Any, ...] = field(default_factory=tuple)
64
+ kwargs: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({}))
65
+ last_run: datetime | None = None
66
+ next_run: datetime | None = None
67
+ _identity: object = field(default_factory=object, repr=False)
68
+
69
+ @property
70
+ def second(self) -> Job:
71
+ return self.seconds
72
+
73
+ @property
74
+ def seconds(self) -> Job:
75
+ return self._with_unit("seconds")
76
+
77
+ @property
78
+ def minute(self) -> Job:
79
+ return self.minutes
80
+
81
+ @property
82
+ def minutes(self) -> Job:
83
+ return self._with_unit("minutes")
84
+
85
+ @property
86
+ def hour(self) -> Job:
87
+ return self.hours
88
+
89
+ @property
90
+ def hours(self) -> Job:
91
+ return self._with_unit("hours")
92
+
93
+ @property
94
+ def day(self) -> Job:
95
+ return self.days
96
+
97
+ @property
98
+ def days(self) -> Job:
99
+ return self._with_unit("days")
100
+
101
+ @property
102
+ def week(self) -> Job:
103
+ return self.weeks
104
+
105
+ @property
106
+ def weeks(self) -> Job:
107
+ return self._with_unit("weeks")
108
+
109
+ @property
110
+ def month(self) -> Job:
111
+ return self.months
112
+
113
+ @property
114
+ def months(self) -> Job:
115
+ return self._with_unit("months")
116
+
117
+ @property
118
+ def year(self) -> Job:
119
+ return self.years
120
+
121
+ @property
122
+ def years(self) -> Job:
123
+ return self._with_unit("years")
124
+
125
+ @property
126
+ def monday(self) -> Job:
127
+ return self._with_weekday(0)
128
+
129
+ @property
130
+ def tuesday(self) -> Job:
131
+ return self._with_weekday(1)
132
+
133
+ @property
134
+ def wednesday(self) -> Job:
135
+ return self._with_weekday(2)
136
+
137
+ @property
138
+ def thursday(self) -> Job:
139
+ return self._with_weekday(3)
140
+
141
+ @property
142
+ def friday(self) -> Job:
143
+ return self._with_weekday(4)
144
+
145
+ @property
146
+ def saturday(self) -> Job:
147
+ return self._with_weekday(5)
148
+
149
+ @property
150
+ def sunday(self) -> Job:
151
+ return self._with_weekday(6)
152
+
153
+ def at(self, value: str) -> Job:
154
+ """Run at a specific clock time within the selected interval."""
155
+
156
+ self._ensure_not_scheduled()
157
+ if self.unit is None:
158
+ raise ClockError("Choose a time unit before calling at().")
159
+ configured = replace(self, at_time=_parse_at_time(value, self.unit))
160
+ self.scheduler.replace(self, configured)
161
+ return configured
162
+
163
+ def tag(self, *tags: Hashable) -> Job:
164
+ """Attach one or more tags to the job."""
165
+
166
+ configured = replace(self, tags=self.tags.union(tags))
167
+ self.scheduler.replace(self, configured)
168
+ return configured
169
+
170
+ def do(self, job_func: Callable[..., Any], *args: Any, **kwargs: Any) -> Job:
171
+ """Register the job function and add the job to the scheduler."""
172
+
173
+ self._ensure_not_scheduled()
174
+ if self.unit is None:
175
+ raise ClockError("Choose a time unit before calling do().")
176
+ configured = replace(
177
+ self,
178
+ job_func=job_func,
179
+ args=args,
180
+ kwargs=MappingProxyType(dict(kwargs)),
181
+ )
182
+ object.__setattr__(configured, "next_run", configured._next_after(self.scheduler.now()))
183
+ self.scheduler.add(configured)
184
+ return configured
185
+
186
+ def cancel(self) -> Job:
187
+ """Remove this job from its scheduler."""
188
+
189
+ self.scheduler.cancel(self)
190
+ return self
191
+
192
+ def run(self) -> Any:
193
+ """Execute the job function once and schedule the next run."""
194
+
195
+ if self.job_func is None:
196
+ raise ClockError("Cannot run a job before do() has been called.")
197
+
198
+ result = self.job_func(*self.args, **self.kwargs)
199
+ last_run = self.scheduler.now()
200
+ object.__setattr__(self, "last_run", last_run)
201
+
202
+ if result is CancelJob:
203
+ self.scheduler.cancel(self)
204
+ else:
205
+ object.__setattr__(self, "next_run", self._next_after(last_run))
206
+
207
+ return result
208
+
209
+ def _with_unit(self, unit: str) -> Job:
210
+ self._ensure_not_scheduled()
211
+ configured = replace(self, unit=unit)
212
+ self.scheduler.replace(self, configured)
213
+ return configured
214
+
215
+ def _with_weekday(self, weekday: int) -> Job:
216
+ self._ensure_not_scheduled()
217
+ configured = replace(self, unit="weeks", start_day=weekday)
218
+ self.scheduler.replace(self, configured)
219
+ return configured
220
+
221
+ def _ensure_not_scheduled(self) -> None:
222
+ if self.job_func is not None:
223
+ raise ClockError("A scheduled job cannot be reconfigured; create a new job instead.")
224
+
225
+ def _next_after(self, reference: datetime) -> datetime:
226
+ if self.unit is None:
227
+ raise ClockError("A scheduled job needs a time unit.")
228
+
229
+ if self.unit == "seconds":
230
+ return reference + timedelta(seconds=self.interval)
231
+ if self.unit == "minutes":
232
+ return self._next_subdaily_after(reference, "minutes")
233
+ if self.unit == "hours":
234
+ return self._next_subdaily_after(reference, "hours")
235
+ if self.unit == "days":
236
+ return self._next_daily_after(reference)
237
+ if self.unit == "weeks":
238
+ return self._next_weekly_after(reference)
239
+ if self.unit == "months":
240
+ return self._next_calendar_after(reference, months=self.interval)
241
+ if self.unit == "years":
242
+ return self._next_calendar_after(reference, years=self.interval)
243
+
244
+ raise ClockError(f"Unsupported time unit: {self.unit!r}")
245
+
246
+ def _next_subdaily_after(self, reference: datetime, unit: str) -> datetime:
247
+ if self.at_time is None:
248
+ if unit == "minutes":
249
+ return reference + timedelta(minutes=self.interval)
250
+ return reference + timedelta(hours=self.interval)
251
+
252
+ if unit == "minutes":
253
+ candidate = reference.replace(second=self.at_time.second, microsecond=0)
254
+ delta = timedelta(minutes=self.interval)
255
+ else:
256
+ candidate = reference.replace(
257
+ minute=self.at_time.minute,
258
+ second=self.at_time.second,
259
+ microsecond=0,
260
+ )
261
+ delta = timedelta(hours=self.interval)
262
+
263
+ while candidate <= reference:
264
+ candidate += delta
265
+ return candidate
266
+
267
+ def _next_daily_after(self, reference: datetime) -> datetime:
268
+ if self.at_time is None:
269
+ return reference + timedelta(days=self.interval)
270
+
271
+ candidate = datetime.combine(reference.date(), self.at_time, tzinfo=reference.tzinfo)
272
+ while candidate <= reference:
273
+ candidate += timedelta(days=self.interval)
274
+ return candidate
275
+
276
+ def _next_weekly_after(self, reference: datetime) -> datetime:
277
+ if self.start_day is None:
278
+ if self.at_time is None:
279
+ return reference + timedelta(weeks=self.interval)
280
+ candidate = datetime.combine(reference.date(), self.at_time, tzinfo=reference.tzinfo)
281
+ while candidate <= reference:
282
+ candidate += timedelta(weeks=self.interval)
283
+ return candidate
284
+
285
+ days_ahead = (self.start_day - reference.weekday()) % 7
286
+ candidate_date = reference.date() + timedelta(days=days_ahead)
287
+ candidate_time = self.at_time or reference.time().replace(microsecond=0)
288
+ candidate = datetime.combine(candidate_date, candidate_time, tzinfo=reference.tzinfo)
289
+
290
+ while candidate <= reference:
291
+ candidate += timedelta(weeks=self.interval)
292
+ return candidate
293
+
294
+ def _next_calendar_after(
295
+ self,
296
+ reference: datetime,
297
+ months: int = 0,
298
+ years: int = 0,
299
+ ) -> datetime:
300
+ candidate = reference
301
+ if months:
302
+ candidate = _add_months(candidate, months)
303
+ if years:
304
+ candidate = _add_months(candidate, years * 12)
305
+ if self.at_time is not None:
306
+ candidate = candidate.replace(
307
+ hour=self.at_time.hour,
308
+ minute=self.at_time.minute,
309
+ second=self.at_time.second,
310
+ microsecond=0,
311
+ )
312
+ return candidate
313
+
314
+
315
+ class Clock:
316
+ """A controllable datetime source with an internal-time scheduler."""
317
+
318
+ def __init__(
319
+ self,
320
+ mode: ClockMode = "realtime",
321
+ start_at: DatetimeLike | None = None,
322
+ factor: float = 1.0,
323
+ step: DeltaLike = timedelta(seconds=1),
324
+ tz: tzinfo | None = None,
325
+ monotonic: TimeSource = time_module.monotonic,
326
+ auto_run_due: bool = True,
327
+ ) -> None:
328
+ self._mode = _validate_mode(mode)
329
+ self._factor = _validate_factor(factor)
330
+ self._fixed_step = _coerce_delta(step)
331
+ self._start_at = _coerce_datetime(start_at, tz)
332
+ self._current = self._start_at
333
+ self._monotonic = monotonic
334
+ self._real_start = self._monotonic()
335
+ self._scheduler = _Scheduler(self)
336
+ self.auto_run_due = auto_run_due
337
+
338
+ @classmethod
339
+ def realtime(
340
+ cls,
341
+ start_at: DatetimeLike | None = None,
342
+ tz: tzinfo | None = None,
343
+ ) -> Clock:
344
+ """Create a clock that advances at wall-clock speed."""
345
+
346
+ return cls(mode="realtime", start_at=start_at, tz=tz)
347
+
348
+ @classmethod
349
+ def wrap(
350
+ cls,
351
+ factor: float = 1.0,
352
+ start_at: DatetimeLike | None = None,
353
+ tz: tzinfo | None = None,
354
+ ) -> Clock:
355
+ """Create a clock that advances at ``factor * real_time``."""
356
+
357
+ return cls(mode="wrap", start_at=start_at, factor=factor, tz=tz)
358
+
359
+ @classmethod
360
+ def fixed(
361
+ cls,
362
+ step: DeltaLike = timedelta(seconds=1),
363
+ start_at: DatetimeLike | None = None,
364
+ tz: tzinfo | None = None,
365
+ ) -> Clock:
366
+ """Create a clock that advances by a fixed delta on every step."""
367
+
368
+ return cls(mode="fixed", start_at=start_at, step=step, tz=tz)
369
+
370
+ @classmethod
371
+ def scheduled(
372
+ cls,
373
+ start_at: DatetimeLike | None = None,
374
+ tz: tzinfo | None = None,
375
+ ) -> Clock:
376
+ """Create a clock that jumps to the next scheduled job on step."""
377
+
378
+ return cls(mode="scheduled", start_at=start_at, tz=tz)
379
+
380
+ @classmethod
381
+ def manual(
382
+ cls,
383
+ start_at: DatetimeLike | None = None,
384
+ tz: tzinfo | None = None,
385
+ ) -> Clock:
386
+ """Create a clock that advances only by explicit step amounts."""
387
+
388
+ return cls(mode="manual", start_at=start_at, tz=tz)
389
+
390
+ @property
391
+ def mode(self) -> ClockMode:
392
+ """The active clock mode."""
393
+
394
+ return self._mode
395
+
396
+ @property
397
+ def start_at(self) -> datetime:
398
+ """The datetime used as the internal clock origin."""
399
+
400
+ return self._start_at
401
+
402
+ def now(self) -> datetime:
403
+ """Return the current internal datetime."""
404
+
405
+ if self._mode in {"realtime", "wrap"}:
406
+ elapsed_seconds = (self._monotonic() - self._real_start) * self._factor
407
+ return self._start_at + timedelta(seconds=elapsed_seconds)
408
+ return self._current
409
+
410
+ def elapsed(self) -> timedelta:
411
+ """Return the elapsed internal duration since initialization."""
412
+
413
+ return self.now() - self._start_at
414
+
415
+ def elapsed_seconds(self) -> float:
416
+ return self.elapsed().total_seconds()
417
+
418
+ def elapsed_minutes(self) -> float:
419
+ return self.elapsed_seconds() / 60
420
+
421
+ def elapsed_hours(self) -> float:
422
+ return self.elapsed_seconds() / 3_600
423
+
424
+ def elapsed_days(self) -> float:
425
+ return self.elapsed_seconds() / SECONDS_PER_DAY
426
+
427
+ def elapsed_months(self) -> float:
428
+ return self.elapsed_seconds() / SECONDS_PER_GREGORIAN_MONTH
429
+
430
+ def elapsed_years(self) -> float:
431
+ return self.elapsed_seconds() / SECONDS_PER_GREGORIAN_YEAR
432
+
433
+ def elapsed_values(self) -> Elapsed:
434
+ """Return elapsed time in all supported units."""
435
+
436
+ delta = self.elapsed()
437
+ seconds = delta.total_seconds()
438
+ return Elapsed(
439
+ delta=delta,
440
+ seconds=seconds,
441
+ minutes=seconds / 60,
442
+ hours=seconds / 3_600,
443
+ days=seconds / SECONDS_PER_DAY,
444
+ months=seconds / SECONDS_PER_GREGORIAN_MONTH,
445
+ years=seconds / SECONDS_PER_GREGORIAN_YEAR,
446
+ )
447
+
448
+ def step(
449
+ self,
450
+ amount: DeltaLike | None = None,
451
+ *,
452
+ seconds: float = 0,
453
+ minutes: float = 0,
454
+ hours: float = 0,
455
+ days: float = 0,
456
+ weeks: float = 0,
457
+ run_due: bool | None = None,
458
+ ) -> Clock:
459
+ """Advance the clock according to its mode and return ``self``.
460
+
461
+ Fixed clocks ignore wall-clock time and use the configured fixed step.
462
+ Manual clocks require an explicit amount, either as a positional
463
+ `timedelta`/seconds value or as keyword units. Scheduled clocks jump to
464
+ the next scheduled job. Realtime and wrap clocks only run pending jobs.
465
+ """
466
+
467
+ should_run = self.auto_run_due if run_due is None else run_due
468
+
469
+ if self._mode == "fixed":
470
+ if amount is not None or any([seconds, minutes, hours, days, weeks]):
471
+ raise ClockError("fixed clocks use their configured step; pass no step amount.")
472
+ self._current += self._fixed_step
473
+ elif self._mode == "manual":
474
+ delta = _coerce_delta(
475
+ amount,
476
+ seconds=seconds,
477
+ minutes=minutes,
478
+ hours=hours,
479
+ days=days,
480
+ weeks=weeks,
481
+ )
482
+ if delta == timedelta(0):
483
+ raise ClockError("manual clocks require a non-zero step amount.")
484
+ self._current += delta
485
+ elif self._mode == "scheduled":
486
+ if amount is not None or any([seconds, minutes, hours, days, weeks]):
487
+ raise ClockError("scheduled clocks advance to the next job; pass no step amount.")
488
+ next_run = self.next_run()
489
+ if next_run is not None:
490
+ self._current = next_run
491
+ elif self._mode in {"realtime", "wrap"}:
492
+ if amount is not None or any([seconds, minutes, hours, days, weeks]):
493
+ message = f"{self._mode} clocks advance from real time; pass no step amount."
494
+ raise ClockError(message)
495
+ else:
496
+ raise ClockError(f"Unsupported mode: {self._mode!r}")
497
+
498
+ if should_run:
499
+ self.run_pending()
500
+ return self
501
+
502
+ def every(self, interval: int = 1) -> Job:
503
+ """Start building a scheduled job."""
504
+
505
+ if interval < 1:
506
+ raise ClockError("Job interval must be at least 1.")
507
+ return Job(scheduler=self._scheduler, interval=interval)
508
+
509
+ def run_pending(self) -> list[Any]:
510
+ """Run all jobs due at the current internal time."""
511
+
512
+ return self._scheduler.run_pending()
513
+
514
+ def run_all(self) -> list[Any]:
515
+ """Run all scheduled jobs once, regardless of their next run time."""
516
+
517
+ return self._scheduler.run_all()
518
+
519
+ def next_run(self) -> datetime | None:
520
+ """Return the next scheduled internal datetime, if any."""
521
+
522
+ return self._scheduler.next_run()
523
+
524
+ def jobs(self, tag: Hashable | None = None) -> list[Job]:
525
+ """Return scheduled jobs, optionally filtered by tag."""
526
+
527
+ return self._scheduler.jobs(tag)
528
+
529
+ def cancel(self, job: Job) -> Clock:
530
+ """Cancel a scheduled job and return ``self``."""
531
+
532
+ self._scheduler.cancel(job)
533
+ return self
534
+
535
+ def clear(self, *tags: Hashable) -> Clock:
536
+ """Clear all jobs, or only jobs matching one of the provided tags."""
537
+
538
+ self._scheduler.clear(*tags)
539
+ return self
540
+
541
+
542
+ class _Scheduler:
543
+ def __init__(self, clock: Clock) -> None:
544
+ self.clock = clock
545
+ self._jobs: list[Job] = []
546
+
547
+ def now(self) -> datetime:
548
+ return self.clock.now()
549
+
550
+ def add(self, job: Job) -> None:
551
+ for index, existing in enumerate(self._jobs):
552
+ if existing._identity is job._identity:
553
+ self._jobs[index] = job
554
+ break
555
+ else:
556
+ self._jobs.append(job)
557
+ self._sort()
558
+
559
+ def cancel(self, job: Job) -> None:
560
+ self._jobs = [
561
+ existing for existing in self._jobs if existing._identity is not job._identity
562
+ ]
563
+
564
+ def replace(self, old: Job, new: Job) -> None:
565
+ for index, existing in enumerate(self._jobs):
566
+ if existing._identity is old._identity:
567
+ self._jobs[index] = new
568
+ self._sort()
569
+ return
570
+
571
+ def contains(self, job: Job) -> bool:
572
+ return any(existing._identity is job._identity for existing in self._jobs)
573
+
574
+ def clear(self, *tags: Hashable) -> None:
575
+ if not tags:
576
+ self._jobs.clear()
577
+ return
578
+
579
+ tag_set = set(tags)
580
+ self._jobs = [job for job in self._jobs if job.tags.isdisjoint(tag_set)]
581
+
582
+ def jobs(self, tag: Hashable | None = None) -> list[Job]:
583
+ if tag is None:
584
+ return list(self._jobs)
585
+ return [job for job in self._jobs if tag in job.tags]
586
+
587
+ def next_run(self) -> datetime | None:
588
+ scheduled = [job.next_run for job in self._jobs if job.next_run is not None]
589
+ if not scheduled:
590
+ return None
591
+ return min(scheduled)
592
+
593
+ def run_pending(self) -> list[Any]:
594
+ now = self.now()
595
+ due_jobs = [
596
+ job
597
+ for job in sorted(self._jobs, key=lambda item: item.next_run or datetime.max)
598
+ if job.next_run is not None and job.next_run <= now
599
+ ]
600
+
601
+ results = []
602
+ for job in due_jobs:
603
+ if self.contains(job):
604
+ results.append(job.run())
605
+ self._sort()
606
+ return results
607
+
608
+ def run_all(self) -> list[Any]:
609
+ results = []
610
+ for job in list(self._jobs):
611
+ if self.contains(job):
612
+ results.append(job.run())
613
+ self._sort()
614
+ return results
615
+
616
+ def _sort(self) -> None:
617
+ self._jobs.sort(key=lambda job: job.next_run or datetime.max)
618
+
619
+
620
+ def _validate_mode(mode: str) -> ClockMode:
621
+ if mode not in {"realtime", "wrap", "fixed", "scheduled", "manual"}:
622
+ raise ClockError(
623
+ "mode must be one of: 'realtime', 'wrap', 'fixed', 'scheduled', 'manual'."
624
+ )
625
+ return cast(ClockMode, mode)
626
+
627
+
628
+ def _validate_factor(factor: float) -> float:
629
+ if factor <= 0:
630
+ raise ClockError("factor must be greater than zero.")
631
+ return factor
632
+
633
+
634
+ def _coerce_datetime(value: DatetimeLike | None, tz: tzinfo | None) -> datetime:
635
+ if value is None:
636
+ return datetime.now(tz)
637
+ if isinstance(value, str):
638
+ value = datetime.fromisoformat(value)
639
+ if tz is not None and value.tzinfo is None:
640
+ return value.replace(tzinfo=tz)
641
+ return value
642
+
643
+
644
+ def _coerce_delta(
645
+ value: DeltaLike | None = None,
646
+ *,
647
+ seconds: float = 0,
648
+ minutes: float = 0,
649
+ hours: float = 0,
650
+ days: float = 0,
651
+ weeks: float = 0,
652
+ ) -> timedelta:
653
+ if value is None:
654
+ delta = timedelta(0)
655
+ elif isinstance(value, timedelta):
656
+ delta = value
657
+ elif isinstance(value, (int, float)):
658
+ delta = timedelta(seconds=value)
659
+ else:
660
+ raise ClockError("Expected a timedelta or a numeric seconds value.")
661
+
662
+ return delta + timedelta(seconds=seconds, minutes=minutes, hours=hours, days=days, weeks=weeks)
663
+
664
+
665
+ def _parse_at_time(value: str, unit: str) -> time:
666
+ parts = value.split(":")
667
+ if unit == "minutes":
668
+ if len(parts) != 2 or parts[0] != "":
669
+ raise ClockError("Minute jobs use at(':SS').")
670
+ return time(second=_bounded_int(parts[1], 0, 59, "second"))
671
+
672
+ if unit == "hours":
673
+ if len(parts) == 2 and parts[0] == "":
674
+ return time(minute=_bounded_int(parts[1], 0, 59, "minute"))
675
+ if len(parts) == 3 and parts[0] == "":
676
+ return time(
677
+ minute=_bounded_int(parts[1], 0, 59, "minute"),
678
+ second=_bounded_int(parts[2], 0, 59, "second"),
679
+ )
680
+ raise ClockError("Hourly jobs use at(':MM') or at(':MM:SS').")
681
+
682
+ if unit in {"days", "weeks", "months", "years"}:
683
+ if len(parts) == 2:
684
+ return time(
685
+ hour=_bounded_int(parts[0], 0, 23, "hour"),
686
+ minute=_bounded_int(parts[1], 0, 59, "minute"),
687
+ )
688
+ if len(parts) == 3:
689
+ return time(
690
+ hour=_bounded_int(parts[0], 0, 23, "hour"),
691
+ minute=_bounded_int(parts[1], 0, 59, "minute"),
692
+ second=_bounded_int(parts[2], 0, 59, "second"),
693
+ )
694
+ raise ClockError("Daily and larger jobs use at('HH:MM') or at('HH:MM:SS').")
695
+
696
+ raise ClockError(f"at() is not supported for {unit} jobs.")
697
+
698
+
699
+ def _bounded_int(value: str, minimum: int, maximum: int, name: str) -> int:
700
+ try:
701
+ parsed = int(value)
702
+ except ValueError as exc:
703
+ raise ClockError(f"{name} must be an integer.") from exc
704
+
705
+ if parsed < minimum or parsed > maximum:
706
+ raise ClockError(f"{name} must be between {minimum} and {maximum}.")
707
+ return parsed
708
+
709
+
710
+ def _add_months(value: datetime, months: int) -> datetime:
711
+ month_index = value.month - 1 + months
712
+ year = value.year + month_index // 12
713
+ month = month_index % 12 + 1
714
+ day = min(value.day, _days_in_month(year, month))
715
+ return value.replace(year=year, month=month, day=day)
716
+
717
+
718
+ def _days_in_month(year: int, month: int) -> int:
719
+ next_month = date(year + 1, 1, 1) if month == 12 else date(year, month + 1, 1)
720
+ return (next_month - date(year, month, 1)).days
ga_clock/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Package version: the single source of truth for builds and releases."""
2
+
3
+ __version__ = "0.2.0"
ga_clock/exceptions.py ADDED
@@ -0,0 +1,15 @@
1
+ """Public exceptions and warnings raised by GA Clock."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class GAClockError(Exception):
7
+ """Base exception for all GA Clock errors."""
8
+
9
+
10
+ class ClockError(GAClockError, ValueError):
11
+ """Raised when an operation is invalid for the selected clock mode."""
12
+
13
+
14
+ class GAClockWarning(UserWarning):
15
+ """Base warning for non-fatal GA Clock conditions."""
ga_clock/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,241 @@
1
+ Metadata-Version: 2.4
2
+ Name: ga-clock
3
+ Version: 0.2.0
4
+ Summary: GA Clock is a controllable Python clock with realtime, accelerated, fixed, scheduled, and manual time modes.
5
+ Author: GA Clock contributors
6
+ License-Expression: MIT
7
+ Project-URL: Documentation, https://github.com/andreagemma/clock#readme
8
+ Project-URL: Issues, https://github.com/andreagemma/clock/issues
9
+ Project-URL: Source, https://github.com/andreagemma/clock
10
+ Keywords: ga-clock,clock,scheduler,time,testing,simulation
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest>=8.0; extra == "test"
25
+ Requires-Dist: pytest-cov>=5.0; extra == "test"
26
+ Provides-Extra: dev
27
+ Requires-Dist: build>=1.2; extra == "dev"
28
+ Requires-Dist: mypy>=1.10; extra == "dev"
29
+ Requires-Dist: pytest>=8.0; extra == "dev"
30
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
31
+ Requires-Dist: ruff>=0.5; extra == "dev"
32
+ Requires-Dist: twine>=5.1; extra == "dev"
33
+ Dynamic: license-file
34
+
35
+ # GA Clock
36
+
37
+ [![CI](https://github.com/andreagemma/clock/actions/workflows/ci.yml/badge.svg)](https://github.com/andreagemma/clock/actions/workflows/ci.yml)
38
+ [![PyPI](https://img.shields.io/pypi/v/ga-clock.svg)](https://pypi.org/project/ga-clock/)
39
+ [![Python](https://img.shields.io/pypi/pyversions/ga-clock.svg)](https://pypi.org/project/ga-clock/)
40
+
41
+ GA Clock provides a controllable datetime source and an internal-time scheduler for
42
+ applications, simulations, and deterministic tests. A clock can follow wall time,
43
+ accelerate it, advance in fixed or manual steps, or jump directly between scheduled
44
+ events. The scheduler always uses the selected clock's internal time.
45
+
46
+ ## Installation
47
+
48
+ The PyPI distribution is named `ga-clock`; the import package is named `ga_clock`.
49
+
50
+ ```bash
51
+ python -m pip install ga-clock
52
+ ```
53
+
54
+ GA Clock has no runtime dependencies. Development and test tools are available as
55
+ extras:
56
+
57
+ ```bash
58
+ python -m pip install -e ".[test]"
59
+ python -m pip install -e ".[dev]"
60
+ ```
61
+
62
+ ## Quick Start
63
+
64
+ ```python
65
+ from datetime import datetime
66
+
67
+ from ga_clock import Clock
68
+
69
+ clock = Clock.manual(start_at=datetime(2026, 1, 1, 9, 0))
70
+ clock.step(hours=2)
71
+
72
+ assert clock.now() == datetime(2026, 1, 1, 11, 0)
73
+ assert clock.elapsed_hours() == 2
74
+ ```
75
+
76
+ When `start_at` is omitted, the clock starts at the current datetime. It also accepts
77
+ an ISO datetime string and an optional `tzinfo` object.
78
+
79
+ ## Clock Modes
80
+
81
+ | Mode | Internal-time behavior | `step()` behavior |
82
+ | --- | --- | --- |
83
+ | `realtime` | Advances at wall-clock speed | Runs due jobs without moving time directly |
84
+ | `wrap` | Advances at `factor * wall time` | Runs due jobs without moving time directly |
85
+ | `fixed` | Changes only when stepped | Advances by the configured fixed duration |
86
+ | `scheduled` | Changes only when stepped | Jumps to the next scheduled event |
87
+ | `manual` | Changes only when stepped | Advances by the supplied duration |
88
+
89
+ ### Realtime and Wrap
90
+
91
+ ```python
92
+ from ga_clock import Clock
93
+
94
+ realtime = Clock.realtime()
95
+ accelerated = Clock.wrap(factor=60)
96
+ ```
97
+
98
+ A wrap factor of `60` advances internal time by one minute for every elapsed wall-time
99
+ second. Factors must be greater than zero.
100
+
101
+ ### Fixed and Manual
102
+
103
+ ```python
104
+ from datetime import timedelta
105
+
106
+ from ga_clock import Clock
107
+
108
+ fixed = Clock.fixed(step=timedelta(minutes=15))
109
+ fixed.step().step()
110
+ assert fixed.elapsed_minutes() == 30
111
+
112
+ manual = Clock.manual()
113
+ manual.step(minutes=5).step(seconds=30)
114
+ assert manual.elapsed_seconds() == 330
115
+ ```
116
+
117
+ ### Scheduled
118
+
119
+ ```python
120
+ from datetime import datetime
121
+
122
+ from ga_clock import Clock
123
+
124
+ events: list[datetime] = []
125
+ clock = Clock.scheduled(start_at=datetime(2026, 1, 1, 9, 0))
126
+ clock.every().hour.do(lambda: events.append(clock.now()))
127
+
128
+ clock.step()
129
+
130
+ assert events == [datetime(2026, 1, 1, 10, 0)]
131
+ ```
132
+
133
+ Calling `step()` with no jobs scheduled is a no-op.
134
+
135
+ ## Scheduling
136
+
137
+ The fluent API is inspired by `schedule`, but all configuration operations return new
138
+ objects instead of mutating earlier builder values.
139
+
140
+ ```python
141
+ from datetime import datetime
142
+
143
+ from ga_clock import Clock
144
+
145
+ calls: list[str] = []
146
+ clock = Clock.manual(start_at=datetime(2026, 1, 1, 8, 0))
147
+
148
+ heartbeat = clock.every(10).seconds.do(lambda: calls.append("heartbeat"))
149
+ report = clock.every().monday.at("10:00").do(
150
+ lambda: calls.append("report")
151
+ ).tag("reports")
152
+
153
+ clock.step(seconds=10)
154
+ assert calls == ["heartbeat"]
155
+
156
+ clock.cancel(heartbeat)
157
+ clock.clear("reports")
158
+ assert clock.jobs() == []
159
+ ```
160
+
161
+ Supported units are seconds, minutes, hours, days, weeks, months, and years. Weekday
162
+ properties from `monday` through `sunday` are also available. `at()` accepts:
163
+
164
+ - `:SS` for minute jobs;
165
+ - `:MM` or `:MM:SS` for hourly jobs;
166
+ - `HH:MM` or `HH:MM:SS` for daily and larger jobs.
167
+
168
+ Returning `CancelJob` removes a job immediately after it runs:
169
+
170
+ ```python
171
+ from ga_clock import CancelJob
172
+
173
+
174
+ def run_once() -> object:
175
+ return CancelJob
176
+ ```
177
+
178
+ ## Elapsed Time
179
+
180
+ ```python
181
+ clock.elapsed() # datetime.timedelta
182
+ clock.elapsed_seconds() # float
183
+ clock.elapsed_minutes() # float
184
+ clock.elapsed_hours() # float
185
+ clock.elapsed_days() # float
186
+ clock.elapsed_months() # float
187
+ clock.elapsed_years() # float
188
+ clock.elapsed_values() # immutable Elapsed dataclass
189
+ ```
190
+
191
+ Months and years are duration approximations based on the average Gregorian year of
192
+ 365.2425 days; they are not calendar-boundary counts.
193
+
194
+ ## Errors
195
+
196
+ Invalid modes, factors, durations, schedule formats, and mode-specific operations raise
197
+ `ClockError`. It derives from both `GAClockError` and `ValueError`. Non-fatal package
198
+ warnings derive from `GAClockWarning`.
199
+
200
+ Scheduled job callbacks propagate their exceptions to the caller of `step()`,
201
+ `run_pending()`, or `run_all()`; GA Clock does not silently suppress callback failures.
202
+
203
+ ## Security
204
+
205
+ GA Clock does not deserialize data or load executable content. Scheduled callbacks are
206
+ ordinary Python callables and execute with the permissions of the current process. Only
207
+ schedule callbacks from trusted application code.
208
+
209
+ ## Development
210
+
211
+ GA Clock supports Python 3.10 and newer.
212
+
213
+ ```bash
214
+ python -m pip install -e ".[dev]"
215
+ python -m compileall -q src
216
+ python -m pytest --cov=ga_clock --cov-report=term-missing
217
+ ruff check .
218
+ mypy
219
+ python -m pip check
220
+ python -m build
221
+ python -m twine check dist/*
222
+ ```
223
+
224
+ ## Releases
225
+
226
+ `src/ga_clock/_version.py` is the only version source. To publish a release:
227
+
228
+ 1. Update `__version__` in `_version.py` and commit the release changes.
229
+ 2. Push `main` and wait for CI to pass.
230
+ 3. Configure the PyPI Trusted Publisher with project `ga-clock`, owner `andreagemma`,
231
+ repository `ga-clock`, workflow `release.yml`, and environment `pypi`.
232
+ 4. Run the **Create release** GitHub Actions workflow. With no override it creates the
233
+ `v<version>` tag, creates release notes, and explicitly dispatches the build and PyPI
234
+ publication workflow.
235
+
236
+ PyPI versions are immutable. Increment `_version.py` before publishing different
237
+ content.
238
+
239
+ ## License
240
+
241
+ GA Clock is distributed under the MIT License. See [LICENSE](LICENSE).
@@ -0,0 +1,10 @@
1
+ ga_clock/__init__.py,sha256=XPeADzR9LA7yy0Sqa-gS11TYzfFdU5MRFhmV0rnO0dg,365
2
+ ga_clock/_api.py,sha256=gEMxAii-gLUwrKjTP0O8rnzrzZvK4gbfNbofylj9tW4,23066
3
+ ga_clock/_version.py,sha256=TvH7SkPKsvdPulwBwEaEgs2Vf0g_FGjej1jP8oFmiwQ,98
4
+ ga_clock/exceptions.py,sha256=NYGXGpwB0hXAlZSN9O7r0cmr572f9cH7uEI0WMWsH_o,392
5
+ ga_clock/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
6
+ ga_clock-0.2.0.dist-info/licenses/LICENSE,sha256=npXSAB3WakiK5W7m4VZUYNEIzLBP6IxMpi8AeOyVwrI,1078
7
+ ga_clock-0.2.0.dist-info/METADATA,sha256=t2DOlTAgmX2pefneMJ8TgKsf2QFE5orZpoPnwm3oaTQ,7380
8
+ ga_clock-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ ga_clock-0.2.0.dist-info/top_level.txt,sha256=AbFxNAAriBBon7u1lHGtTBGOhTiLif8cN14JGXaW9mw,9
10
+ ga_clock-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GA Clock contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ ga_clock