SimPyTick 0.0.1__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.
simpytick/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """SimPyTick (Simple Python Tick)."""
2
+
3
+ import logging
4
+
5
+ from simpytick._version import __version__
6
+ from simpytick.sleep import sleep
7
+ from simpytick.sleep_2 import CompensatedSleep as sleep_2
8
+
9
+ __all__ = ["sleep", "sleep_2"]
10
+
11
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
simpytick/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Single version source for package imports and distribution metadata."""
2
+
3
+ __version__ = "0.0.1"
simpytick/_worker.py ADDED
@@ -0,0 +1,13 @@
1
+ """Report background worker completion and failures to its owner."""
2
+
3
+
4
+ def run_worker(target, args, kwargs, status):
5
+ """Run a target and send a bounded, serializable terminal status."""
6
+ try:
7
+ target(*args, **kwargs)
8
+ except BaseException as error: # pylint: disable=broad-exception-caught
9
+ status.send(("error", f"{type(error).__name__}: {str(error)[:1000]}"))
10
+ else:
11
+ status.send(("done", None))
12
+ finally:
13
+ status.close()
simpytick/logger.py ADDED
@@ -0,0 +1,183 @@
1
+ """
2
+ Logging utilities: ``GrpcLogger`` adapter with project-specific levels and
3
+ a ``get_logger`` factory that attaches console + rotating file handlers.
4
+ """
5
+ import logging
6
+ import tempfile
7
+ import warnings
8
+ from datetime import datetime
9
+ from logging.handlers import TimedRotatingFileHandler
10
+ from pathlib import Path
11
+
12
+ import coloredlogs
13
+
14
+ INTERNAL_DEBUG = 5
15
+ INTERNAL_INFO = 7
16
+
17
+
18
+ class GrpcLogger(logging.LoggerAdapter):
19
+ """Adapt a standard logger with custom levels without changing its class."""
20
+
21
+ def __init__(self, logger: logging.Logger | str):
22
+ super().__init__(logging.getLogger(logger) if isinstance(logger, str) else logger, {})
23
+
24
+ @property
25
+ def handlers(self):
26
+ """Return the underlying logger's handlers."""
27
+ return self.logger.handlers
28
+
29
+ @property
30
+ def level(self) -> int:
31
+ """Return the underlying logger's configured level."""
32
+ return self.logger.level
33
+
34
+ def removeHandler(self, handler: logging.Handler) -> None: # pylint: disable=invalid-name
35
+ """Match logging.Logger.removeHandler on the underlying logger."""
36
+ self.logger.removeHandler(handler)
37
+
38
+ def iinfo(self, message, *args, **kwargs):
39
+ """Log at INTERNAL_INFO level (7) --- framework lifecycle events, below DEBUG."""
40
+ self.log(INTERNAL_INFO, message, *args, **kwargs)
41
+
42
+ def idebug(self, message, *args, **kwargs):
43
+ """
44
+ Log at INTERNAL_DEBUG level (5) --- fine-grained framework tracing, below INTERNAL_INFO.
45
+ """
46
+ self.log(INTERNAL_DEBUG, message, *args, **kwargs)
47
+
48
+ def setLevel(self, level) -> None:
49
+ """Set level on the logger and all attached handlers simultaneously."""
50
+ self.logger.setLevel(level)
51
+ for handler in self.handlers:
52
+ handler.setLevel(level)
53
+
54
+
55
+ _ADAPTERS: dict[str, GrpcLogger] = {}
56
+
57
+
58
+ def get_logger(
59
+ name: str = "default",
60
+ *,
61
+ console_log_level: int | None = logging.INFO,
62
+ log_dir: str = None,
63
+ file_log_level: int | None = INTERNAL_DEBUG,
64
+ use_colored_output: bool = True
65
+ ) -> GrpcLogger:
66
+ """
67
+ Get or create a configured logger instance.
68
+
69
+ Parameters
70
+ ----------
71
+ name : str
72
+ Logger name (default: "default")
73
+ console_log_level : int | None
74
+ Console handler level. Set to None to disable console logging.
75
+ log_dir : str
76
+ Directory for log files. If None, uses system temp dir.
77
+ file_log_level : int | None
78
+ File handler level. Set to None to disable file logging.
79
+ use_colored_output : bool
80
+ Whether to use colored console output (default: True)
81
+
82
+ Returns
83
+ -------
84
+ GrpcLogger
85
+ Adapter around a configured standard logger. Importing this module does
86
+ not register a global logger class or custom level names; level names
87
+ are registered only when this factory is explicitly called.
88
+ """
89
+ logger = logging.getLogger(name)
90
+ adapter = _ADAPTERS.setdefault(name, GrpcLogger(logger))
91
+ logging.addLevelName(INTERNAL_DEBUG, "INTERNAL_DEBUG")
92
+ logging.addLevelName(INTERNAL_INFO, "INTERNAL_INFO")
93
+
94
+ # Avoid adding handlers multiple times
95
+ if logger.handlers:
96
+ warnings.warn(
97
+ f"Logger '{name}' already configured; returning cached logger "\
98
+ "and ignoring new settings.",
99
+ stacklevel=2
100
+ )
101
+ return adapter
102
+
103
+ # Logger level must be INTERNAL to allow file handler to capture everything
104
+ # Individual handlers control their own output levels
105
+ logger.setLevel(INTERNAL_DEBUG)
106
+ logger.propagate = False
107
+
108
+ # Determine log directory
109
+ if log_dir is None:
110
+ log_location = Path(tempfile.gettempdir()) / "grpcLogs"
111
+ else:
112
+ log_location = Path(log_dir)
113
+
114
+ # Create file formatter
115
+ file_formatter = logging.Formatter(
116
+ "%(asctime)s.%(msecs)03d - %(name)s - %(levelname)s - %(message)s",
117
+ datefmt="%Y-%m-%d %H:%M:%S"
118
+ )
119
+
120
+ # Console handler with coloredlogs
121
+ if console_log_level is not None:
122
+ if use_colored_output:
123
+ # coloredlogs installs directly on the logger with field-level colors
124
+ coloredlogs.install(
125
+ level=console_log_level,
126
+ logger=logger,
127
+ fmt="%(asctime)s.%(msecs)03d %(hostname)s %(name)s[%(process)d] "\
128
+ "%(levelname)s %(message)s",
129
+ datefmt="%Y-%m-%d %H:%M:%S",
130
+ level_styles={
131
+ "internal_debug": {"color": "blue"},
132
+ "internal_info": {"color": "white"},
133
+ "debug": {"color": "cyan"},
134
+ "info": {"color": "green"},
135
+ "warning": {"color": "yellow", "bold": True},
136
+ "error": {"color": "red", "bold": True},
137
+ "critical": {"color": "red", "bold": True, "background": "white"}
138
+ },
139
+ field_styles={
140
+ "asctime": {"color": "white", "bold": True},
141
+ "hostname": {"color": "magenta"},
142
+ "levelname": {"color": "white", "bold": True},
143
+ "name": {"color": "cyan"},
144
+ "programname": {"color": "cyan"},
145
+ "process": {"color": "magenta"}
146
+ }
147
+ )
148
+ else:
149
+ console_handler = logging.StreamHandler()
150
+ console_handler.setLevel(console_log_level)
151
+ console_handler.setFormatter(file_formatter)
152
+ logger.addHandler(console_handler)
153
+
154
+ # File handler with rotation
155
+ if file_log_level is not None:
156
+ try:
157
+ log_location.mkdir(parents=True, exist_ok=True)
158
+
159
+ # Generate timestamp for log filename
160
+ timestamp = datetime.now().strftime("%Y%m%d")
161
+ log_filename = log_location / f"{name}_{timestamp}.log"
162
+
163
+ # Use TimedRotatingFileHandler for automatic daily rotation
164
+ file_handler = TimedRotatingFileHandler(
165
+ log_filename,
166
+ when="midnight",
167
+ interval=1,
168
+ backupCount=30, # Keep 30 days of logs
169
+ encoding="utf-8"
170
+ )
171
+ file_handler.setLevel(file_log_level)
172
+ file_handler.setFormatter(file_formatter)
173
+ logger.addHandler(file_handler)
174
+ logger.info("Logging files to %s", log_location)
175
+
176
+ except (PermissionError, OSError) as e:
177
+ warnings.warn(
178
+ f"Could not create log directory at {log_location}: {e}. "
179
+ "File logging disabled.",
180
+ stacklevel=2
181
+ )
182
+
183
+ return adapter
simpytick/sleep.py ADDED
@@ -0,0 +1,38 @@
1
+ """Sleep-based waiting with optional cooperative cancellation."""
2
+
3
+ import math
4
+ import threading
5
+ import time
6
+
7
+
8
+ def sleep(
9
+ seconds: float,
10
+ *,
11
+ stop_event: threading.Event | None = None,
12
+ check_interval: float = 0.01,
13
+ ) -> bool:
14
+ """Sleep for seconds; return True if stopped, False if the wait completes.
15
+
16
+ seconds must be finite and nonnegative. check_interval must be finite and
17
+ positive; it limits each sleep chunk when stop_event is supplied. An active
18
+ sleep cannot be interrupted, and OS scheduling can delay cancellation beyond
19
+ check_interval. Without stop_event, use a single time.sleep() call.
20
+
21
+ Monotonic deadlines account for chunk overhead, but do not guarantee exact
22
+ wake-up times or compensate for work between separate calls.
23
+ """
24
+ if not math.isfinite(seconds) or seconds < 0:
25
+ raise ValueError("seconds must be finite and nonnegative")
26
+ if not math.isfinite(check_interval) or check_interval <= 0:
27
+ raise ValueError("check_interval must be finite and positive")
28
+ if stop_event is None:
29
+ time.sleep(seconds)
30
+ return False
31
+
32
+ deadline = time.monotonic() + seconds
33
+ while not stop_event.is_set():
34
+ remaining = deadline - time.monotonic()
35
+ if remaining <= 0:
36
+ return False
37
+ time.sleep(min(remaining, check_interval))
38
+ return True
simpytick/sleep_2.py ADDED
@@ -0,0 +1,76 @@
1
+ """Automatically drift-compensated sleeping without background workers.
2
+
3
+ Exported as simpytick.sleep_2, CompensatedSleep always enables compensation;
4
+ there is no compensation switch. Create one instance before a loop and reuse
5
+ it on every iteration. Creating an instance inside the loop discards its
6
+ schedule and prevents compensation across iterations. For independent,
7
+ uncompensated waits, use simpytick.sleep() instead.
8
+ """
9
+
10
+ import math
11
+ import threading
12
+ import time
13
+
14
+ from simpytick.sleep import sleep
15
+
16
+
17
+ class CompensatedSleep:
18
+ """Callable sleeper with automatic, always-active drift compensation.
19
+
20
+ Reuse the same instance across calls: create it once outside the loop.
21
+ Recreating it each iteration loses the state required for compensation.
22
+ There is no flag to enable compensation; it is always active. Use one
23
+ instance per loop and do not share an instance across threads.
24
+
25
+ The schedule starts on the first call. Each call advances the deadline by
26
+ seconds, accounting for work and oversleep since previous calls. Missed
27
+ deadlines produce zero waits until caught up; exact timing is not guaranteed.
28
+ reset() or cancellation clears the schedule; the next call starts a new one.
29
+
30
+ Example::
31
+
32
+ from simpytick import sleep_2
33
+
34
+ sleep = sleep_2()
35
+ for _ in range(100):
36
+ sleep(0.01)
37
+ """
38
+
39
+ def __init__(self):
40
+ self._deadline: float | None = None
41
+
42
+ def reset(self) -> None:
43
+ """Discard the schedule so the next call starts a new one."""
44
+ self._deadline = None
45
+
46
+ def __call__(
47
+ self,
48
+ seconds: float,
49
+ *,
50
+ stop_event: threading.Event | None = None,
51
+ check_interval: float = 0.01,
52
+ ) -> bool:
53
+ """Wait for the next deadline; return True on cancellation, else False.
54
+
55
+ seconds must be finite and nonnegative; check_interval must be finite
56
+ and positive. Cancellation resets the schedule. Invalid arguments leave
57
+ it unchanged. Stop checks have the same latency limits as simpytick.sleep.
58
+ """
59
+ if not math.isfinite(seconds) or seconds < 0:
60
+ raise ValueError("seconds must be finite and nonnegative")
61
+ if not math.isfinite(check_interval) or check_interval <= 0:
62
+ raise ValueError("check_interval must be finite and positive")
63
+ if stop_event is not None and stop_event.is_set():
64
+ self.reset()
65
+ return True
66
+
67
+ now = time.monotonic()
68
+ self._deadline = (now if self._deadline is None else self._deadline) + seconds
69
+ interrupted = sleep(
70
+ max(self._deadline - now, 0.0),
71
+ stop_event=stop_event,
72
+ check_interval=check_interval,
73
+ )
74
+ if interrupted:
75
+ self.reset()
76
+ return interrupted
simpytick/timer.py ADDED
@@ -0,0 +1,317 @@
1
+ """
2
+ High-precision periodic timer backed by a ``multiprocessing.Process``.
3
+
4
+ Public surface:
5
+ - :func:`timer` --- fire an event *n* times at interval *s*.
6
+ - :class:`TimedEvent` --- context manager that iterates once per tick.
7
+ """
8
+ import itertools
9
+ import logging
10
+ import math
11
+ import multiprocessing
12
+ import threading
13
+ import time
14
+ from multiprocessing import synchronize
15
+ from typing import Literal, Union
16
+
17
+ from simpytick._worker import run_worker
18
+ from simpytick.logger import get_logger
19
+ from simpytick.sleep import sleep
20
+
21
+ # Spawn prevents child code from retaining copied parent state. On POSIX its
22
+ # launcher may still briefly fork, so use TimedEvent(backend="thread") after
23
+ # libraries such as gRPC have started threads or opened poller descriptors.
24
+ _SPAWN_CONTEXT = multiprocessing.get_context("spawn")
25
+
26
+
27
+ def _validate_timer(n: int, s: float) -> None:
28
+ """Reject invalid cycle counts and periods before starting any work."""
29
+ if isinstance(n, bool) or not isinstance(n, int):
30
+ raise TypeError("n must be an integer")
31
+ if n < -1:
32
+ raise ValueError("n must be -1 or nonnegative")
33
+ if isinstance(s, bool) or not isinstance(s, (int, float)):
34
+ raise TypeError("s must be a real number")
35
+ if not math.isfinite(s) or s <= 0:
36
+ raise ValueError("s must be finite and positive")
37
+
38
+
39
+ def _cycles(n: int):
40
+ """Yield cycle indices; runs indefinitely when n == -1."""
41
+ if n == -1:
42
+ yield from itertools.count()
43
+ else:
44
+ yield from range(n)
45
+
46
+
47
+ def _wait_for_next_tick(duration: float, stop_event: threading.Event = None) -> bool:
48
+ """Wait for one interval; return whether thread shutdown was requested.
49
+
50
+ With a stop event, sleep in chunks of at most 10 ms and check for shutdown
51
+ between chunks. An active sleep cannot be interrupted; OS scheduling may
52
+ delay shutdown further. Monotonic deadlines avoid accumulating chunk overhead.
53
+ This avoids potentially coarse Event.wait() timeouts on Windows and other
54
+ platforms, but does not guarantee precise wake-up times.
55
+ """
56
+ return sleep(duration, stop_event=stop_event)
57
+
58
+
59
+ def _tick(period: float, enable_compensation: bool):
60
+ """Yield monotonic deadline waits, or fixed waits without compensation."""
61
+ # Inspired by watsonic's generator-based periodic scheduling:
62
+ # https://stackoverflow.com/questions/8600161/executing-periodic-actions
63
+ # https://stackoverflow.com/users/watsonic
64
+ if not enable_compensation:
65
+ while True:
66
+ yield period
67
+
68
+ next_tick = time.monotonic()
69
+ while True:
70
+ next_tick += period
71
+ yield max(next_tick - time.monotonic(), 0.0)
72
+
73
+
74
+ def timer( # pylint: disable=too-many-arguments
75
+ n: int,
76
+ s: float,
77
+ event: Union[synchronize.Event, threading.Event],
78
+ enable_compensation: bool = True,
79
+ logger_level: int = None,
80
+ *,
81
+ stop_event: threading.Event = None,
82
+ ):
83
+ """
84
+ Timer function that sets an event periodically.
85
+
86
+ Args:
87
+ n: Nonnegative number of cycles, or -1 to run indefinitely.
88
+ s: Finite, positive timing interval in seconds.
89
+ event: multiprocessing.synchronize.Event or threading.Event which will be set periodically
90
+ based on the timing interval. The event is expected to be cleared by the caller
91
+ after each tick.
92
+ enable_compensation: Maintain accumulated monotonic deadlines to correct drift.
93
+ Late cycles shorten the next wait, down to zero while catching up.
94
+ When disabled, each tick is followed by a fixed interval wait.
95
+ stop_event: Optional thread-backend event used to request an early stop.
96
+
97
+ The first tick fires immediately. Prints warning if event is still set
98
+ from the previous cycle (timer overrun). Zero cycles returns without ticks.
99
+ """
100
+ _validate_timer(n, s)
101
+ if logger_level is not None:
102
+ process_logger = get_logger("timer", console_log_level=logger_level, file_log_level=None)
103
+ else:
104
+ process_logger = None
105
+
106
+ ticks = _tick(s, enable_compensation)
107
+ for i in _cycles(n):
108
+ if stop_event is not None and stop_event.is_set():
109
+ break
110
+
111
+ event.set()
112
+
113
+ sleep_duration = next(ticks)
114
+ if _wait_for_next_tick(sleep_duration, stop_event):
115
+ break
116
+
117
+ if event.is_set():
118
+ # not optimal but using log callback here would require passing a logger to the
119
+ # timer function and we want to avoid that for now
120
+ if process_logger:
121
+ process_logger.warning(
122
+ "Warning: Timer overrun at cycle %d. Event is still set.", i + 1
123
+ )
124
+
125
+ class TimedEvent: # pylint: disable=too-many-instance-attributes
126
+ """
127
+ Context manager that drives periodic task execution using the existing
128
+ timer function.
129
+
130
+ Usage::
131
+
132
+ with TimedEvent(s=0.01, n=100) as te:
133
+ for tick in te:
134
+ # executed exactly n times, once per timer tick
135
+
136
+ Parameters
137
+ ----------
138
+ s : float
139
+ Period time in seconds between ticks.
140
+ n : int
141
+ Number of ticks (cycles) to run.
142
+ backend : {"process", "thread"}
143
+ ``"process"`` preserves process-isolated timing. ``"thread"`` avoids
144
+ creating a child after libraries such as gRPC have started threads.
145
+
146
+ Notes
147
+ -----
148
+ The timer runs in either a spawn-isolated daemon process or a daemon thread.
149
+ Workers retain normal OS priority. Exiting the context stops and joins the
150
+ worker. Iteration raises RuntimeError if a background worker fails.
151
+ """
152
+
153
+ def __init__(
154
+ self,
155
+ s: float,
156
+ n: int,
157
+ compensation: bool = True,
158
+ logger: logging.Logger | logging.LoggerAdapter | None = None,
159
+ backend: Literal["process", "thread"] = "process",
160
+ ):
161
+ _validate_timer(n, s)
162
+ if backend not in ("process", "thread"):
163
+ raise ValueError(f"Unsupported timer backend: {backend}")
164
+ self.s = s
165
+ self.n = n
166
+ self.compensation = compensation
167
+ self._event = (
168
+ threading.Event() if backend == "thread" else _SPAWN_CONTEXT.Event()
169
+ )
170
+ self._stop_event = threading.Event()
171
+ self._worker: multiprocessing.Process | threading.Thread | None = None
172
+ self._status_reader = None
173
+ self._status_writer = None
174
+ self._worker_result = None
175
+ self._backend = backend
176
+ self.overrun_count: int = 0 # number of cycles where task exceeded the tick period
177
+ self.logger = logger
178
+
179
+ def __enter__(self) -> "TimedEvent":
180
+ """Start a normal-priority worker, cleaning up if startup fails."""
181
+ timer_args = (
182
+ self.n,
183
+ self.s,
184
+ self._event,
185
+ )
186
+ timer_kwargs = {
187
+ "enable_compensation": self.compensation,
188
+ "logger_level": self.logger.getEffectiveLevel() if self.logger else None,
189
+ "stop_event": self._stop_event if self._backend == "thread" else None,
190
+ }
191
+ self._status_reader, self._status_writer = _SPAWN_CONTEXT.Pipe(duplex=False)
192
+ try:
193
+ worker_type = (
194
+ threading.Thread if self._backend == "thread" else _SPAWN_CONTEXT.Process
195
+ )
196
+ self._worker = worker_type(
197
+ target=run_worker,
198
+ args=(timer, timer_args, timer_kwargs, self._status_writer),
199
+ daemon=True,
200
+ )
201
+ self._worker.start()
202
+ if self._backend == "process":
203
+ self._status_writer.close()
204
+ except BaseException as error:
205
+ try:
206
+ started = self._worker is not None and (
207
+ self._worker.ident is not None if self._backend == "thread"
208
+ else self._worker.pid is not None
209
+ )
210
+ if started:
211
+ self.__exit__(type(error), error, error.__traceback__)
212
+ finally:
213
+ self._close_status()
214
+ raise
215
+ return self
216
+
217
+ def _close_status(self) -> None:
218
+ for connection in (self._status_reader, self._status_writer):
219
+ if connection is not None:
220
+ connection.close()
221
+ self._status_reader = self._status_writer = None
222
+
223
+ def _check_worker_error(self) -> None:
224
+ if self._worker_result is None and self._status_reader is not None:
225
+ try:
226
+ if self._status_reader.poll():
227
+ self._worker_result = self._status_reader.recv()
228
+ except (EOFError, BrokenPipeError):
229
+ pass
230
+ if self._worker_result is not None and self._worker_result[0] == "error":
231
+ raise RuntimeError(f"Timer worker failed: {self._worker_result[1]}")
232
+
233
+ def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
234
+ """Stop and join the worker and close its status connections."""
235
+ try:
236
+ if self._worker is not None:
237
+ if self._backend == "process":
238
+ if self._worker.is_alive():
239
+ self._worker.terminate()
240
+ else:
241
+ self._stop_event.set()
242
+ self._worker.join()
243
+ if exc_type is None:
244
+ self._check_worker_error()
245
+ finally:
246
+ self._close_status()
247
+ return False # do not suppress exceptions
248
+
249
+ def __iter__(self):
250
+ """
251
+ Yield the cycle index (0-based) once per timer tick.
252
+
253
+ Each iteration waits for the timer to set the event, then clears it
254
+ before yielding, so the task code runs synchronously between ticks.
255
+
256
+ After each yield (i.e. after the task body has finished), the event
257
+ is checked again. If it is already set the timer has fired the next
258
+ tick while the task was still running --- a timer overrun. A warning
259
+ is printed and ``self.overrun_count`` is incremented.
260
+
261
+ Deadlock prevention
262
+ -------------------
263
+ ``threading.Event.set()`` is idempotent: if the task overruns by more
264
+ than one tick, several timer fires collapse into a single set signal.
265
+ ``__iter__`` would then wait forever on a tick that the (now-finished)
266
+ timer thread will never fire. To guard against this, every wait uses a
267
+ timeout capped at 100 ms. Normal completion with collapsed ticks stops
268
+ iteration early; worker failures raise RuntimeError, even without a logger.
269
+ """
270
+ try:
271
+ i = 0
272
+ while self.n == -1 or i < self.n:
273
+ self._check_worker_error()
274
+ while not self._event.wait(timeout=min(self.s, 0.1)):
275
+ self._check_worker_error()
276
+ if not self._worker.is_alive():
277
+ self._worker.join()
278
+ self._check_worker_error()
279
+ if self._worker_result != ("done", None):
280
+ exitcode = getattr(self._worker, "exitcode", None)
281
+ raise RuntimeError(
282
+ f"Timer worker exited without completion (exitcode={exitcode})"
283
+ )
284
+ if self._event.is_set():
285
+ break
286
+ if self.logger:
287
+ self.logger.warning(
288
+ "Timer process ended after %d ticks; %d tick(s) missed.",
289
+ i,
290
+ self.n - i,
291
+ )
292
+ return
293
+ self._check_worker_error()
294
+ self._event.clear()
295
+ yield i
296
+ i += 1
297
+ # Stop after n ticks (mirrors the timer process cycle count).
298
+ # Checked here --- after the task body returns --- so the last
299
+ # yield is never skipped even if the process has already exited.
300
+ if self.n != -1 and i >= self.n:
301
+ return
302
+ except KeyboardInterrupt:
303
+ if self.logger:
304
+ self.logger.info("strg+c")
305
+ raise # re-raise so the caller's signal handling is not suppressed
306
+ # cleanup is handled solely by __exit__ (terminate + join)
307
+
308
+
309
+ if __name__ == "__main__":
310
+
311
+ demo_logger = get_logger("timer_demo")
312
+ demo_logger.info("Starting timer demo with compensation enabled...")
313
+
314
+ with TimedEvent(s=0.01, n=10, compensation=True, logger=demo_logger) as te:
315
+ for tick in te:
316
+ demo_logger.info("Tick %d at %s", tick, time.time())
317
+ time.sleep(2.1)
@@ -0,0 +1,274 @@
1
+ Metadata-Version: 2.4
2
+ Name: SimPyTick
3
+ Version: 0.0.1
4
+ Summary: SimPyTick (Simple Python Tick): process- and thread-backed periodic timers.
5
+ Author-Email: Fabian Krumm <ghub_fk@gmx.de>
6
+ License-Expression: BSD-3-Clause
7
+ License-File: LICENSE
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: coloredlogs>=15.0
12
+ Requires-Dist: psutil>=5.0.0
13
+ Description-Content-Type: text/markdown
14
+
15
+ # SimPyTick
16
+
17
+ Simple Python Tick: periodic events using a spawn-isolated process or a thread.
18
+ Requires Python 3.10 or later. Runtime dependencies: `psutil` and `coloredlogs`.
19
+
20
+ ## Release Status
21
+
22
+ Releases before `1.0.0` are testing versions and may introduce breaking changes
23
+ without a major version increment. Semantic versioning will begin with `1.0.0`.
24
+
25
+ ## Installation
26
+
27
+ ```shell
28
+ pip install SimPyTick
29
+ ```
30
+
31
+ From a checkout of this package:
32
+
33
+ ```shell
34
+ uv pip install .
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```python
40
+ from simpytick.timer import TimedEvent
41
+
42
+
43
+ def main():
44
+ with TimedEvent(s=0.01, n=100, backend="thread") as ticks:
45
+ for tick in ticks:
46
+ print(tick)
47
+
48
+
49
+ if __name__ == "__main__":
50
+ main()
51
+ ```
52
+
53
+ `TimedEvent(s, n, compensation=True, logger=None, backend="process")` uses a
54
+ finite, positive period `s` and an integer count `n`. Booleans are not accepted
55
+ as periods or counts. `n=-1` runs indefinitely; `n=0` yields no ticks. Invalid
56
+ arguments raise `TypeError` or `ValueError` before any worker is created. Exiting the
57
+ context terminates and joins the process, or signals and joins the thread.
58
+ The first tick is emitted immediately; subsequent ticks use the given interval.
59
+
60
+ The default process backend uses multiprocessing's `spawn` context. Keep process
61
+ creation behind `if __name__ == "__main__":` in an importable script. Both backends
62
+ retain normal OS priority; the package does not request real-time scheduling or
63
+ require priority-changing privileges. Failed startup cleans up any started worker
64
+ and its status connections. Use `backend="thread"` to
65
+ avoid child creation after libraries such as gRPC start threads or open pollers.
66
+
67
+ Background failures raise `RuntimeError` during iteration or normal context exit,
68
+ even without a logger. Reported exceptions include their type and a bounded message;
69
+ abrupt exits without completion are also errors. Health-check waits are capped at
70
+ 100 ms, subject to OS scheduling. Application exceptions remain primary during
71
+ cleanup. Normal completion with collapsed event signals still ends iteration early.
72
+
73
+ The low-level API keeps its positional arguments and validates `n` and `s` using
74
+ the same rules:
75
+
76
+ ```python
77
+ import threading
78
+ from simpytick.timer import timer
79
+
80
+ event = threading.Event()
81
+ timer(3, 0.01, event, False, None)
82
+ ```
83
+
84
+ `timer(n, s, event, enable_compensation=True, logger_level=None, *,
85
+ stop_event=None)` sets the supplied event. Consumers clear it after each tick;
86
+ multiple unconsumed signals can collapse into one. This low-level call blocks
87
+ until completion. The first tick fires immediately. With compensation enabled,
88
+ a persistent monotonic schedule corrects accumulated drift by shortening waits,
89
+ including zero waits to catch up after missed deadlines. With compensation
90
+ disabled, each tick is followed by a fixed interval wait. This is not a hard
91
+ real-time scheduler, and slow consumers may miss ticks. Compensation no longer
92
+ restricts the number of decimal places in the period; this does not guarantee
93
+ that the operating system can achieve very short intervals.
94
+
95
+ ## Sleep
96
+
97
+ ```python
98
+ import threading
99
+ import simpytick
100
+
101
+ simpytick.sleep(0.01)
102
+
103
+ stop = threading.Event()
104
+ interrupted = simpytick.sleep(1.0, stop_event=stop, check_interval=0.01)
105
+ ```
106
+
107
+ `simpytick.sleep(seconds, *, stop_event=None, check_interval=0.01)` returns
108
+ `True` if cancelled and `False` when the wait completes. Another thread can
109
+ request cancellation with `stop.set()`. Without a stop event it uses a single
110
+ `time.sleep()` call; otherwise it sleeps in chunks, checking the stop flag
111
+ between them. `seconds` must be finite and nonnegative; `check_interval` must
112
+ be finite and positive.
113
+
114
+ An active sleep cannot be interrupted. Cancellation latency depends on the
115
+ chunk size and OS scheduling, so `check_interval` is not a guaranteed bound.
116
+ Monotonic deadlines account for chunk overhead without busy-waiting. Separate
117
+ calls do not share a schedule or compensate for work between calls; use
118
+ `sleep_2` or `TimedEvent` for periodic drift compensation. Exact wake-up times are not
119
+ guaranteed.
120
+
121
+ ### Compensated Sleep
122
+
123
+ ```python
124
+ import time
125
+ from simpytick import sleep_2
126
+
127
+ sleep = sleep_2()
128
+ for _ in range(100):
129
+ sleep(0.01)
130
+ print(time.time())
131
+ ```
132
+
133
+ Create the callable once outside the loop. It runs in the calling thread, with
134
+ no background thread or process. Its monotonic schedule starts on the first
135
+ call; subsequent calls advance the deadline by the supplied seconds, accounting
136
+ for work and oversleep between calls. Overdue deadlines produce zero waits
137
+ until caught up. Use a separate instance for each loop; instances are not
138
+ thread-safe.
139
+
140
+ Calls accept the same `stop_event` and `check_interval` keyword arguments and
141
+ return the same cancellation flag as `simpytick.sleep()`. Cancellation resets the
142
+ schedule; `sleep.reset()` also makes the next call start a new schedule. Exact
143
+ wake-up times and immediate cancellation are not guaranteed.
144
+
145
+ ## Migration Scope
146
+
147
+ The timer module originated in pookiepy. Its digit-based compensation has been
148
+ replaced by persistent monotonic deadlines. The obsolete digit-compensation
149
+ helpers and constants have been removed. `GrpcLogger` is now a
150
+ `logging.LoggerAdapter`, not a `logging.Logger` subclass. `get_logger()` wraps
151
+ standard named loggers, including those created before the package was imported.
152
+ Its custom methods (`iinfo()` and `idebug()`), handler access, level access, and
153
+ adapter caching remain available. Imports do not change the global logger class
154
+ or register level names; explicit `get_logger()` calls register custom levels and
155
+ configure output. The package installs a `NullHandler` for application-controlled
156
+ logging. The factory still defaults to file output under `grpcLogs` in the system
157
+ temporary directory. No dependency on pookiepy, gRPC, or protobuf remains.
158
+
159
+ The timer worker and lifecycle tests and logger tests are copied with namespace
160
+ adjustments. Timer API tests are retained; the gRPC broadcast integration test
161
+ is excluded because it exercises the original framework, not the timer package.
162
+ Spawn-worker tests exercise the actual process without mocking priority setup.
163
+ Regression tests cover validation, startup cleanup, worker failures, logger import
164
+ isolation, and matching runtime/distribution versions.
165
+
166
+ Distribution name: `SimPyTick`. Import namespace: `simpytick`. Initial
167
+ standalone version: `0.0.1`. Original BSD-3-Clause license and attribution remain.
168
+
169
+ ## Development
170
+
171
+ Run commands from this directory:
172
+
173
+ ```shell
174
+ uv pip install -r requirements.txt
175
+ uv run --with-requirements requirements.txt python -m unittest discover -s tests -p "test_*.py"
176
+ uv run --with-requirements requirements-dev.txt python -m pylint --jobs=1 simpytick tests
177
+ uv build
178
+ uvx twine check dist/*
179
+ ```
180
+
181
+ `requirements-dev.txt` includes `-r requirements.txt` and adds development-only
182
+ `pylint==4.0.10` and `coverage>=7.10,<8`; these tools are not published as runtime
183
+ dependencies. CI reports coverage without a minimum. Pylint keeps its default
184
+ fail-under of 10, with no disabled messages or explicit custom threshold.
185
+
186
+ `requirements.txt` contains runtime-only dependencies and mirrors
187
+ `pyproject.toml` project dependencies. The workflows use it before tests, build,
188
+ and publication preflight; the requirements guard test detects drift, so update
189
+ both files together. PDM's declared build backend supplies `uv build` build
190
+ dependencies, while the generated wheel and source-distribution metadata causes
191
+ pip installs to resolve runtime dependencies automatically. The requirements
192
+ file is included in source distributions. `MANIFEST.in` is unnecessary; source
193
+ distributions also include tests and workflows. No protobuf generation or
194
+ parent-project build hook is used.
195
+
196
+ ## CI and Publication
197
+
198
+ The workflows assume this directory is the root of its own GitHub repository.
199
+ While nested in the original repository, GitHub does not discover these workflows.
200
+
201
+ - `test.yml`: mandatory Python 3.10-3.14 tests on `ubuntu-latest`,
202
+ `windows-latest`, `macos-latest`, `ubuntu-22.04-arm`, and `ubuntu-24.04-arm`,
203
+ plus nonblocking `3.x` probes on the same five runners; coverage run/report
204
+ has no minimum.
205
+ - `pylint.yml`: Python 3.14 / Ubuntu, Pylint 3.2.6, `--jobs=1 simpytick tests`.
206
+ - `build.yml`: additional Ubuntu / Python 3.14 preflight, wheel/sdist build,
207
+ Twine checks, isolated installed-artifact smoke tests, and artifact upload.
208
+ - `weekly-latest-python.yml`: Monday at 12:00 UTC and manual runs; nonblocking
209
+ `3.x` unit probes on the same five runners and Ubuntu lint with latest Pylint.
210
+ - `verify-workflows.yml`: reference-equivalent actionlint syntax/expression
211
+ checks and zizmor security audit.
212
+ - `pypi_publish.yml`: pushes to `master`, not GitHub Release publication. A read-only
213
+ build job runs preflight tests, build, Twine and wheel/sdist smoke checks, then
214
+ uploads validated artifacts. Its dependent `pypi-release` environment job
215
+ publishes them using OIDC with `skip-existing: true`, `print-hash: false`, and
216
+ `attestations: true`, without stored API tokens.
217
+
218
+ After publication succeeds, an annotated `v<version>` tag is created from
219
+ `simpytick/_version.py`. An existing tag on the same commit is an idempotent
220
+ no-op; a tag on a different commit is never moved and causes failure. Set the
221
+ version constant manually before a new release. No automatic version bump or
222
+ semantic-version inference from commits or tags occurs; tags are output, not
223
+ the version source. PDM reads the same file for distribution metadata, and
224
+ `simpytick.__version__` imports it at runtime.
225
+
226
+ Before publishing, configure the PyPI project `SimPyTick` with a trusted
227
+ publisher matching your GitHub owner/repository, workflow `pypi_publish.yml`, and
228
+ environment `pypi-release`. Keep the workflow filename to preserve that identity
229
+ and retain the environment URL `https://pypi.org/project/SimPyTick/`. Confirm PyPI
230
+ name availability and select an unpublished version before pushing. Skipping
231
+ existing files supports retries, not replacement of published artifacts.
232
+
233
+ There is no full-matrix mandatory CI `needs` barrier before publishing, matching
234
+ the reference. Require supported-version tests, lint, build and workflow audit
235
+ checks through `master` branch protection; configure environment reviewers and
236
+ allowed branches. Do not require floating `3.x` probes as support guarantees.
237
+ See [.github/CI.md](.github/CI.md) for CI ownership, reference differences,
238
+ and justified deviations.
239
+
240
+ # Todos
241
+
242
+ ## AI Findings
243
+
244
+ Remaining Medium/Low findings from the pre-publication review. High-severity
245
+ findings were addressed separately; these items remain open.
246
+
247
+ - [ ] **Medium - Tick delivery and overrun accounting (review #7):** Define whether
248
+ delivery coalesces, skips, or guarantees ticks. Reconcile the exactly-`n` example
249
+ with missed signals, implement accurate `overrun_count` and sequence accounting,
250
+ and test consumers that span several periods. Avoid an unbounded queue.
251
+ - [ ] **Medium - Context lifecycle (review #8):** Reject repeated/nested entry or
252
+ implement a fully reset lifecycle. Define iteration outside an active context;
253
+ test re-entry, nested entry, and reuse after thread shutdown.
254
+ - [ ] **Medium - Scheduling scalability (review #9):** Define catch-up/skip policy
255
+ and bound catch-up bursts. Benchmark CPU use, jitter, startup, and shutdown over
256
+ increasing timer counts; assess chunked cancellation wakeups and worker-per-timer
257
+ overhead before making high-precision or scalability claims.
258
+ - [ ] **Medium - Log-file path safety (review #10):** Separate logger identity from
259
+ filenames, validate filenames, and use an application-owned directory. Make file
260
+ output opt-in and address path traversal and shared-temporary-directory symlinks.
261
+ - [ ] **Medium - Log retention and writers (review #11):** Use a stable log basename
262
+ so retention works across daily restarts. Give rotation a single owner for
263
+ multiprocess use, such as a `QueueListener`; test retention and concurrent writers.
264
+ - [ ] **Medium - Release gates (review #12):** Make publication depend on the full
265
+ supported OS/Python test matrix and validated distributions for the release
266
+ commit. Check the release tag against the distribution version.
267
+ - [ ] **Medium - Dependency and supply-chain validation (review #13):** Commit a
268
+ development lockfile, lock release tooling, test minimum and current runtime
269
+ dependencies, add scheduled advisory audits, and pin the publishing action to a
270
+ verified commit SHA. Reassess the now-unused `psutil` dependency after removing
271
+ automatic priority changes.
272
+ - [ ] **Low - Packaged development output (review #14):** Exclude or remove
273
+ `simpytick/results.txt` from distributions. Keep reproducible benchmark methods and
274
+ meaningful results outside the import package, and inspect wheel/sdist contents.
@@ -0,0 +1,12 @@
1
+ simpytick-0.0.1.dist-info/METADATA,sha256=E0GpIeW5rPURg2XvWRJggBJXbZcIspqXyqFSVUyEiGs,13127
2
+ simpytick-0.0.1.dist-info/WHEEL,sha256=IuIk3wbaEnFH1cx75iM2R1CmliRI3dFLXTbRakgvgFQ,90
3
+ simpytick-0.0.1.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
4
+ simpytick-0.0.1.dist-info/licenses/LICENSE,sha256=D5tHuFx3CsUmFQTursu3R7LmOAgCWqlytpquqGKWXdY,1499
5
+ simpytick/__init__.py,sha256=uWp0sRDjYNuUwRvBbwhKRXsRxEQYJnhpfe963qud-oc,285
6
+ simpytick/_version.py,sha256=UACWIKnT7L1-Lop1Ja1H0POQVFnhy-lJA8WEvPXZHvg,98
7
+ simpytick/_worker.py,sha256=Uyc88DWA6PIHXquSkempEhg5LTq0XMy_B1f11aA77FY,469
8
+ simpytick/logger.py,sha256=iRsr_fnifsRNXoTsQbxmpQ98WxxiupZer9btwhk3Jo8,6568
9
+ simpytick/sleep.py,sha256=zeOPdx68_aHuDtLTOGEbDvYlDKOLXZgb5MOMxqulunE,1358
10
+ simpytick/sleep_2.py,sha256=hQKoArVqqm28NEYAZbkZOBQHH1jzyxfCuD_Edib0_jM,2795
11
+ simpytick/timer.py,sha256=JDJzR6apu8yVaJ6d5MceTTetZQJ7GeLuZ12K2Q-ZBck,12545
12
+ simpytick-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: pdm-backend (2.5.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+
3
+ [gui_scripts]
4
+
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2024, Fabian Krumm
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.