interruptible-threading 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- interruptible_threading-0.0.1/MANIFEST.in +4 -0
- interruptible_threading-0.0.1/PKG-INFO +191 -0
- interruptible_threading-0.0.1/README.md +145 -0
- interruptible_threading-0.0.1/docs/LICENSE.txt +29 -0
- interruptible_threading-0.0.1/interruptible_threading/__init__.py +859 -0
- interruptible_threading-0.0.1/interruptible_threading/_version.py +683 -0
- interruptible_threading-0.0.1/interruptible_threading/version.py +23 -0
- interruptible_threading-0.0.1/interruptible_threading.egg-info/PKG-INFO +191 -0
- interruptible_threading-0.0.1/interruptible_threading.egg-info/SOURCES.txt +16 -0
- interruptible_threading-0.0.1/interruptible_threading.egg-info/dependency_links.txt +1 -0
- interruptible_threading-0.0.1/interruptible_threading.egg-info/not-zip-safe +1 -0
- interruptible_threading-0.0.1/interruptible_threading.egg-info/requires.txt +22 -0
- interruptible_threading-0.0.1/interruptible_threading.egg-info/top_level.txt +1 -0
- interruptible_threading-0.0.1/pyproject.toml +45 -0
- interruptible_threading-0.0.1/setup.cfg +65 -0
- interruptible_threading-0.0.1/setup.py +6 -0
- interruptible_threading-0.0.1/versioneer.py +2277 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: interruptible-threading
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Cooperative, prompt thread interruption for CPython (Linux / Darwin)
|
|
5
|
+
Home-page: https://github.com/smacke/python-interruptible-threads
|
|
6
|
+
Author: Stephen Macke
|
|
7
|
+
Author-email: stephen.macke@gmail.com
|
|
8
|
+
License: BSD-3-Clause
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
12
|
+
Classifier: Natural Language :: English
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown; charset=UTF-8
|
|
24
|
+
License-File: docs/LICENSE.txt
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: black<24; extra == "test"
|
|
27
|
+
Requires-Dist: isort; extra == "test"
|
|
28
|
+
Requires-Dist: mypy; extra == "test"
|
|
29
|
+
Requires-Dist: pytest; extra == "test"
|
|
30
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
31
|
+
Requires-Dist: pytest-timeout; extra == "test"
|
|
32
|
+
Requires-Dist: ruff; extra == "test"
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: build; extra == "dev"
|
|
35
|
+
Requires-Dist: pycln; extra == "dev"
|
|
36
|
+
Requires-Dist: twine; extra == "dev"
|
|
37
|
+
Requires-Dist: versioneer; extra == "dev"
|
|
38
|
+
Requires-Dist: black<24; extra == "dev"
|
|
39
|
+
Requires-Dist: isort; extra == "dev"
|
|
40
|
+
Requires-Dist: mypy; extra == "dev"
|
|
41
|
+
Requires-Dist: pytest; extra == "dev"
|
|
42
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
43
|
+
Requires-Dist: pytest-timeout; extra == "dev"
|
|
44
|
+
Requires-Dist: ruff; extra == "dev"
|
|
45
|
+
Dynamic: license-file
|
|
46
|
+
|
|
47
|
+
# Python Interruptible Threads
|
|
48
|
+
|
|
49
|
+
Cooperative, prompt thread interruption for CPython. Call `thread.interrupt()` and a
|
|
50
|
+
target thread raises an exception (`ThreadInterrupted` by default) — even when it is
|
|
51
|
+
parked in `time.sleep`, `select`, `asyncio.sleep`, an `Event`/`Queue`/`Condition` wait,
|
|
52
|
+
or a (helper-wrapped) blocking socket read.
|
|
53
|
+
|
|
54
|
+
It uses `ctypes` to reach `PyThreadState_SetAsyncExc` and patches a curated set of
|
|
55
|
+
stdlib blocking primitives, so it works only on **CPython** and **POSIX (Linux / Darwin)**.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from interruptible_threading import InterruptibleThread, ThreadInterrupted
|
|
59
|
+
import time
|
|
60
|
+
|
|
61
|
+
InterruptibleThread.install_patches()
|
|
62
|
+
|
|
63
|
+
def sleep_forever():
|
|
64
|
+
try:
|
|
65
|
+
while True:
|
|
66
|
+
time.sleep(10)
|
|
67
|
+
except ThreadInterrupted:
|
|
68
|
+
print("interrupted")
|
|
69
|
+
|
|
70
|
+
t = InterruptibleThread(target=sleep_forever, daemon=True)
|
|
71
|
+
t.start()
|
|
72
|
+
...
|
|
73
|
+
t.interrupt() # output: "interrupted"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
> **Back-compat:** earlier versions raised `KeyboardInterrupt`. To keep that behavior,
|
|
77
|
+
> `InterruptibleThread.install_patches(interrupt_exc=KeyboardInterrupt)`, or
|
|
78
|
+
> `install_patches(legacy_keyboardinterrupt=True)` to deliver an exception caught by
|
|
79
|
+
> *both* `ThreadInterrupted` and `KeyboardInterrupt` handlers.
|
|
80
|
+
|
|
81
|
+
## How it works
|
|
82
|
+
|
|
83
|
+
Pure-Python code is interrupted with `PyThreadState_SetAsyncExc`, an async exception
|
|
84
|
+
that fires at the next bytecode boundary. That cannot break a thread sitting in a
|
|
85
|
+
C-level blocking call, so `install_patches()` replaces `time.sleep`,
|
|
86
|
+
`selectors.DefaultSelector`, `select.select`, and `threading.Condition.wait` (which
|
|
87
|
+
also covers `Event.wait` and `queue.Queue`) with versions that wake promptly via a
|
|
88
|
+
per-thread self-pipe / chunked polling.
|
|
89
|
+
|
|
90
|
+
The design rests on a single **durable per-thread "interrupt pending" flag**.
|
|
91
|
+
`interrupt()` sets the flag first (under one lock), then issues a wakeup nudge; every
|
|
92
|
+
blocking primitive checks the flag *before* parking and *again* after waking. This
|
|
93
|
+
removes the time-of-check/time-of-use races inherent in choosing a delivery path from
|
|
94
|
+
transient state, and makes interrupts maskable and pollable.
|
|
95
|
+
|
|
96
|
+
### Why not `signal.pthread_kill`?
|
|
97
|
+
|
|
98
|
+
`pthread_kill` can unblock a syscall but cannot deliver an *exception* to a worker
|
|
99
|
+
thread: CPython runs Python-level signal handlers only on the main thread, and PEP 475's
|
|
100
|
+
EINTR auto-retry loops call `PyErr_CheckSignals()` — a no-op off the main thread — without
|
|
101
|
+
consulting `tstate->async_exc`, so the syscall is transparently retried. The self-pipe +
|
|
102
|
+
cooperative-primitive approach is the only way to get prompt, exception-bearing
|
|
103
|
+
interruption of worker threads on CPython.
|
|
104
|
+
|
|
105
|
+
## API
|
|
106
|
+
|
|
107
|
+
| Name | Purpose |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| `InterruptibleThread(...)` | `threading.Thread` subclass with `.interrupt(recursive=False)`. |
|
|
110
|
+
| `InterruptibleThread.install_patches(interrupt_exc=ThreadInterrupted, legacy_keyboardinterrupt=False, monkeypatch_socket=False)` | Install the stdlib patches (process-wide). |
|
|
111
|
+
| `InterruptibleThread.uninstall_patches()` | Restore the originals. |
|
|
112
|
+
| `InterruptibleThread.run_interruptible(coro)` | Run a coroutine via `asyncio.run` with clean, cancellation-based interruption. |
|
|
113
|
+
| `ThreadInterrupted` | Default interrupt exception (subclass of `BaseException`). |
|
|
114
|
+
| `is_interrupted(thread=None)` | Whether an interrupt is pending (non-consuming). |
|
|
115
|
+
| `clear_interrupt()` | Consume a pending interrupt without raising; returns whether one was pending. |
|
|
116
|
+
| `check_interrupt()` / `interruptible_checkpoint()` | Raise if pending and not masked — for CPU-bound loops. |
|
|
117
|
+
| `periodic_checkpoint(every=N)` | Context manager yielding a `.tick()` that checkpoints every N calls. |
|
|
118
|
+
| `critical_section()` / `interrupts_disabled()` | Defer delivery during cleanup; deliver on exit. |
|
|
119
|
+
| `interruptible_recv/send/accept(sock, ...)` | Interruptible blocking socket ops. |
|
|
120
|
+
| `set_poll_interval(seconds)` | Tune the chunked-poll wakeup latency (default 50 ms). |
|
|
121
|
+
|
|
122
|
+
### Masking critical sections
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from interruptible_threading import critical_section
|
|
126
|
+
|
|
127
|
+
with critical_section():
|
|
128
|
+
commit() # interrupts that arrive here are deferred...
|
|
129
|
+
release_resources() # ...and delivered exactly once when the block exits
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### CPU-bound loops
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
from interruptible_threading import periodic_checkpoint
|
|
136
|
+
|
|
137
|
+
with periodic_checkpoint(every=1000) as ck:
|
|
138
|
+
for row in huge_iterable:
|
|
139
|
+
ck.tick() # raises ThreadInterrupted promptly once interrupted
|
|
140
|
+
crunch(row)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### asyncio
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
import asyncio
|
|
147
|
+
from interruptible_threading import InterruptibleThread
|
|
148
|
+
|
|
149
|
+
def worker():
|
|
150
|
+
try:
|
|
151
|
+
InterruptibleThread.run_interruptible(main_coro())
|
|
152
|
+
except ThreadInterrupted:
|
|
153
|
+
print("cancelled cleanly")
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`run_interruptible` cancels the loop's tasks via `call_soon_threadsafe` (proper
|
|
157
|
+
`finally` / `async with` unwind) instead of injecting an exception into the selector.
|
|
158
|
+
|
|
159
|
+
## Limitations
|
|
160
|
+
|
|
161
|
+
- **CPython + POSIX only.** Relies on `PyThreadState_SetAsyncExc`, `os.pipe`, and `select`.
|
|
162
|
+
- **Uncovered blocking calls** stay blocked until they return: synchronous regular-file
|
|
163
|
+
disk I/O (`open().read()`, `os.read` on files), raw blocking `socket.recv` (use the
|
|
164
|
+
`interruptible_recv` helpers or `monkeypatch_socket=True`), `os.waitpid`, and
|
|
165
|
+
`Lock.acquire` on a builtin lock. The pending flag is honored at the next patched
|
|
166
|
+
primitive / checkpoint, but the in-progress call is not broken.
|
|
167
|
+
- **C extensions that release the GIL and never re-enter Python** (heavy NumPy kernels,
|
|
168
|
+
compiled crypto) cannot be preempted; only cooperative `check_interrupt()` helps.
|
|
169
|
+
- **Chunked-poll primitives** (`Event`/`Queue`/`Condition`) have up to `_POLL_INTERVAL`
|
|
170
|
+
(default 50 ms) latency, tunable via `set_poll_interval`.
|
|
171
|
+
- **Async injection lands at an arbitrary bytecode boundary** and is un-recallable, so
|
|
172
|
+
`critical_section()` is airtight only for the flag-driven paths; prefer
|
|
173
|
+
checkpoints/blocking primitives inside code that must not be interrupted mid-cleanup.
|
|
174
|
+
- **Catch-and-continue clears the flag explicitly.** The interrupt-pending flag is
|
|
175
|
+
durable (so an interrupt is never lost if the target parks in a blocking call before
|
|
176
|
+
async injection can fire). Consequently, if you *catch* `ThreadInterrupted` and want to
|
|
177
|
+
keep running, call `clear_interrupt()` — otherwise the next `check_interrupt()` /
|
|
178
|
+
blocking primitive re-raises. This mirrors Java's `Thread.interrupted()`.
|
|
179
|
+
- **`install_patches()` mutates global stdlib state.** Code that captured references
|
|
180
|
+
before install (e.g. `from time import sleep`) keeps the originals. Not for libraries
|
|
181
|
+
to call implicitly.
|
|
182
|
+
- **The main thread is intentionally not interruptible** by this mechanism, preserving
|
|
183
|
+
real Ctrl-C / `KeyboardInterrupt` semantics.
|
|
184
|
+
|
|
185
|
+
## Development
|
|
186
|
+
|
|
187
|
+
```sh
|
|
188
|
+
pip install -e .[test] # or: make devdeps
|
|
189
|
+
make check # blackcheck + ruff + mypy + pytest (with coverage)
|
|
190
|
+
pytest # just the tests
|
|
191
|
+
```
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Python Interruptible Threads
|
|
2
|
+
|
|
3
|
+
Cooperative, prompt thread interruption for CPython. Call `thread.interrupt()` and a
|
|
4
|
+
target thread raises an exception (`ThreadInterrupted` by default) — even when it is
|
|
5
|
+
parked in `time.sleep`, `select`, `asyncio.sleep`, an `Event`/`Queue`/`Condition` wait,
|
|
6
|
+
or a (helper-wrapped) blocking socket read.
|
|
7
|
+
|
|
8
|
+
It uses `ctypes` to reach `PyThreadState_SetAsyncExc` and patches a curated set of
|
|
9
|
+
stdlib blocking primitives, so it works only on **CPython** and **POSIX (Linux / Darwin)**.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from interruptible_threading import InterruptibleThread, ThreadInterrupted
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
InterruptibleThread.install_patches()
|
|
16
|
+
|
|
17
|
+
def sleep_forever():
|
|
18
|
+
try:
|
|
19
|
+
while True:
|
|
20
|
+
time.sleep(10)
|
|
21
|
+
except ThreadInterrupted:
|
|
22
|
+
print("interrupted")
|
|
23
|
+
|
|
24
|
+
t = InterruptibleThread(target=sleep_forever, daemon=True)
|
|
25
|
+
t.start()
|
|
26
|
+
...
|
|
27
|
+
t.interrupt() # output: "interrupted"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
> **Back-compat:** earlier versions raised `KeyboardInterrupt`. To keep that behavior,
|
|
31
|
+
> `InterruptibleThread.install_patches(interrupt_exc=KeyboardInterrupt)`, or
|
|
32
|
+
> `install_patches(legacy_keyboardinterrupt=True)` to deliver an exception caught by
|
|
33
|
+
> *both* `ThreadInterrupted` and `KeyboardInterrupt` handlers.
|
|
34
|
+
|
|
35
|
+
## How it works
|
|
36
|
+
|
|
37
|
+
Pure-Python code is interrupted with `PyThreadState_SetAsyncExc`, an async exception
|
|
38
|
+
that fires at the next bytecode boundary. That cannot break a thread sitting in a
|
|
39
|
+
C-level blocking call, so `install_patches()` replaces `time.sleep`,
|
|
40
|
+
`selectors.DefaultSelector`, `select.select`, and `threading.Condition.wait` (which
|
|
41
|
+
also covers `Event.wait` and `queue.Queue`) with versions that wake promptly via a
|
|
42
|
+
per-thread self-pipe / chunked polling.
|
|
43
|
+
|
|
44
|
+
The design rests on a single **durable per-thread "interrupt pending" flag**.
|
|
45
|
+
`interrupt()` sets the flag first (under one lock), then issues a wakeup nudge; every
|
|
46
|
+
blocking primitive checks the flag *before* parking and *again* after waking. This
|
|
47
|
+
removes the time-of-check/time-of-use races inherent in choosing a delivery path from
|
|
48
|
+
transient state, and makes interrupts maskable and pollable.
|
|
49
|
+
|
|
50
|
+
### Why not `signal.pthread_kill`?
|
|
51
|
+
|
|
52
|
+
`pthread_kill` can unblock a syscall but cannot deliver an *exception* to a worker
|
|
53
|
+
thread: CPython runs Python-level signal handlers only on the main thread, and PEP 475's
|
|
54
|
+
EINTR auto-retry loops call `PyErr_CheckSignals()` — a no-op off the main thread — without
|
|
55
|
+
consulting `tstate->async_exc`, so the syscall is transparently retried. The self-pipe +
|
|
56
|
+
cooperative-primitive approach is the only way to get prompt, exception-bearing
|
|
57
|
+
interruption of worker threads on CPython.
|
|
58
|
+
|
|
59
|
+
## API
|
|
60
|
+
|
|
61
|
+
| Name | Purpose |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| `InterruptibleThread(...)` | `threading.Thread` subclass with `.interrupt(recursive=False)`. |
|
|
64
|
+
| `InterruptibleThread.install_patches(interrupt_exc=ThreadInterrupted, legacy_keyboardinterrupt=False, monkeypatch_socket=False)` | Install the stdlib patches (process-wide). |
|
|
65
|
+
| `InterruptibleThread.uninstall_patches()` | Restore the originals. |
|
|
66
|
+
| `InterruptibleThread.run_interruptible(coro)` | Run a coroutine via `asyncio.run` with clean, cancellation-based interruption. |
|
|
67
|
+
| `ThreadInterrupted` | Default interrupt exception (subclass of `BaseException`). |
|
|
68
|
+
| `is_interrupted(thread=None)` | Whether an interrupt is pending (non-consuming). |
|
|
69
|
+
| `clear_interrupt()` | Consume a pending interrupt without raising; returns whether one was pending. |
|
|
70
|
+
| `check_interrupt()` / `interruptible_checkpoint()` | Raise if pending and not masked — for CPU-bound loops. |
|
|
71
|
+
| `periodic_checkpoint(every=N)` | Context manager yielding a `.tick()` that checkpoints every N calls. |
|
|
72
|
+
| `critical_section()` / `interrupts_disabled()` | Defer delivery during cleanup; deliver on exit. |
|
|
73
|
+
| `interruptible_recv/send/accept(sock, ...)` | Interruptible blocking socket ops. |
|
|
74
|
+
| `set_poll_interval(seconds)` | Tune the chunked-poll wakeup latency (default 50 ms). |
|
|
75
|
+
|
|
76
|
+
### Masking critical sections
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from interruptible_threading import critical_section
|
|
80
|
+
|
|
81
|
+
with critical_section():
|
|
82
|
+
commit() # interrupts that arrive here are deferred...
|
|
83
|
+
release_resources() # ...and delivered exactly once when the block exits
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### CPU-bound loops
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from interruptible_threading import periodic_checkpoint
|
|
90
|
+
|
|
91
|
+
with periodic_checkpoint(every=1000) as ck:
|
|
92
|
+
for row in huge_iterable:
|
|
93
|
+
ck.tick() # raises ThreadInterrupted promptly once interrupted
|
|
94
|
+
crunch(row)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### asyncio
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
import asyncio
|
|
101
|
+
from interruptible_threading import InterruptibleThread
|
|
102
|
+
|
|
103
|
+
def worker():
|
|
104
|
+
try:
|
|
105
|
+
InterruptibleThread.run_interruptible(main_coro())
|
|
106
|
+
except ThreadInterrupted:
|
|
107
|
+
print("cancelled cleanly")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`run_interruptible` cancels the loop's tasks via `call_soon_threadsafe` (proper
|
|
111
|
+
`finally` / `async with` unwind) instead of injecting an exception into the selector.
|
|
112
|
+
|
|
113
|
+
## Limitations
|
|
114
|
+
|
|
115
|
+
- **CPython + POSIX only.** Relies on `PyThreadState_SetAsyncExc`, `os.pipe`, and `select`.
|
|
116
|
+
- **Uncovered blocking calls** stay blocked until they return: synchronous regular-file
|
|
117
|
+
disk I/O (`open().read()`, `os.read` on files), raw blocking `socket.recv` (use the
|
|
118
|
+
`interruptible_recv` helpers or `monkeypatch_socket=True`), `os.waitpid`, and
|
|
119
|
+
`Lock.acquire` on a builtin lock. The pending flag is honored at the next patched
|
|
120
|
+
primitive / checkpoint, but the in-progress call is not broken.
|
|
121
|
+
- **C extensions that release the GIL and never re-enter Python** (heavy NumPy kernels,
|
|
122
|
+
compiled crypto) cannot be preempted; only cooperative `check_interrupt()` helps.
|
|
123
|
+
- **Chunked-poll primitives** (`Event`/`Queue`/`Condition`) have up to `_POLL_INTERVAL`
|
|
124
|
+
(default 50 ms) latency, tunable via `set_poll_interval`.
|
|
125
|
+
- **Async injection lands at an arbitrary bytecode boundary** and is un-recallable, so
|
|
126
|
+
`critical_section()` is airtight only for the flag-driven paths; prefer
|
|
127
|
+
checkpoints/blocking primitives inside code that must not be interrupted mid-cleanup.
|
|
128
|
+
- **Catch-and-continue clears the flag explicitly.** The interrupt-pending flag is
|
|
129
|
+
durable (so an interrupt is never lost if the target parks in a blocking call before
|
|
130
|
+
async injection can fire). Consequently, if you *catch* `ThreadInterrupted` and want to
|
|
131
|
+
keep running, call `clear_interrupt()` — otherwise the next `check_interrupt()` /
|
|
132
|
+
blocking primitive re-raises. This mirrors Java's `Thread.interrupted()`.
|
|
133
|
+
- **`install_patches()` mutates global stdlib state.** Code that captured references
|
|
134
|
+
before install (e.g. `from time import sleep`) keeps the originals. Not for libraries
|
|
135
|
+
to call implicitly.
|
|
136
|
+
- **The main thread is intentionally not interruptible** by this mechanism, preserving
|
|
137
|
+
real Ctrl-C / `KeyboardInterrupt` semantics.
|
|
138
|
+
|
|
139
|
+
## Development
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
pip install -e .[test] # or: make devdeps
|
|
143
|
+
make check # blackcheck + ruff + mypy + pytest (with coverage)
|
|
144
|
+
pytest # just the tests
|
|
145
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Stephen Macke
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|