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 +11 -0
- simpytick/_version.py +3 -0
- simpytick/_worker.py +13 -0
- simpytick/logger.py +183 -0
- simpytick/sleep.py +38 -0
- simpytick/sleep_2.py +76 -0
- simpytick/timer.py +317 -0
- simpytick-0.0.1.dist-info/METADATA +274 -0
- simpytick-0.0.1.dist-info/RECORD +12 -0
- simpytick-0.0.1.dist-info/WHEEL +4 -0
- simpytick-0.0.1.dist-info/entry_points.txt +4 -0
- simpytick-0.0.1.dist-info/licenses/LICENSE +28 -0
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
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,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.
|