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 +16 -0
- ga_clock/_api.py +720 -0
- ga_clock/_version.py +3 -0
- ga_clock/exceptions.py +15 -0
- ga_clock/py.typed +1 -0
- ga_clock-0.2.0.dist-info/METADATA +241 -0
- ga_clock-0.2.0.dist-info/RECORD +10 -0
- ga_clock-0.2.0.dist-info/WHEEL +5 -0
- ga_clock-0.2.0.dist-info/licenses/LICENSE +21 -0
- ga_clock-0.2.0.dist-info/top_level.txt +1 -0
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
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
|
+
[](https://github.com/andreagemma/clock/actions/workflows/ci.yml)
|
|
38
|
+
[](https://pypi.org/project/ga-clock/)
|
|
39
|
+
[](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,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
|