Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
951bb35
Upgrade mkdocstrings to 1.x together with mkdocstrings-python, pymdow…
Pythondeveloper6 Oct 28, 2026
a5b84ff
Run the test suite and the stress suite on free-threaded Python 3.14t…
Pythondeveloper6 Oct 28, 2026
e2b234a
Document and test context inheritance in new threads on free-threaded…
Pythondeveloper6 Oct 28, 2026
ee81405
Point every docs link at pythonwrap.readthedocs.io, the real Read the…
Pythondeveloper6 Oct 28, 2026
73de423
Add trace(otel=) spans and timeout(cleanup_grace=)
Pythondeveloper6 Oct 28, 2026
ae5453c
Add a social preview image
Pythondeveloper6 Oct 28, 2026
31d7070
Merge OpenTelemetry spans for trace and timeout cleanup_grace
Pythondeveloper6 Oct 28, 2026
2b52493
Add periodic: background interval loop for sync and async functions
Pythondeveloper6 Oct 28, 2026
45f4842
Merge the periodic decorator
Pythondeveloper6 Oct 28, 2026
c1b8ce4
Check the event catalogue against every emit call instead of a hard-c…
Pythondeveloper6 Oct 28, 2026
1e29e50
Add retry budgets, result-based retries and async retry hooks
Pythondeveloper6 Oct 28, 2026
768881a
Merge retry budget, retry_if_result and async retry hooks
Pythondeveloper6 Oct 28, 2026
9835a6b
Export the new APIs and record them in the changelog
Pythondeveloper6 Oct 28, 2026
ab316ef
List the new features in the README
Pythondeveloper6 Oct 28, 2026
445f12c
Add stale-while-revalidate, pluggable stores and per-instance caches …
Pythondeveloper6 Oct 28, 2026
f2708a3
Merge stale-while-revalidate, cache stores and per-instance method ca…
Pythondeveloper6 Oct 28, 2026
55f930e
Export CacheStore and record the caching additions
Pythondeveloper6 Oct 28, 2026
bc88909
Pickle decorated functions by reference and refuse per-process state
Pythondeveloper6 Oct 28, 2026
8fa503b
Merge pickle support for decorated functions
Pythondeveloper6 Oct 28, 2026
dd2d617
Record pickle support in the changelog
Pythondeveloper6 Oct 28, 2026
7973660
Fix cancellation handling in periodic stop() and timeout cleanup_grace
Pythondeveloper6 Oct 28, 2026
438f0b7
Merge branch 'feat/v0.2-extras' into worktree-agent-a3d92a57b374054db
Pythondeveloper6 Oct 28, 2026
b8cee22
Merge fixes from the extras review
Pythondeveloper6 Oct 28, 2026
e5bbb33
Record the extras review fixes in the changelog
Pythondeveloper6 Oct 28, 2026
af2b9c8
Check periodic drift against scheduled ticks so slow runners cannot f…
Pythondeveloper6 Oct 28, 2026
19518bf
Verify TestPyPI before publishing final releases and check distributions
Pythondeveloper6 Oct 29, 2026
a0d589b
Run checks on main and consolidate publishing action updates
Pythondeveloper6 Oct 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ contact_links:
url: https://github.com/LitePy/PythonWrap/security/advisories/new
about: Please report security issues privately, not in a public issue.
- name: Documentation
url: https://python-wrap.readthedocs.io
url: https://pythonwrap.readthedocs.io
about: Check the docs first; most questions are answered there.
53 changes: 45 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Continuous integration.
# Continuous integration after changes land on main.
#
# Four jobs: lint (seconds), the test matrix, mypy, and a packaging check.
# Five jobs: lint (seconds), the test matrix, free-threaded Python with the stress
# suite, mypy, and a packaging check.
#
# python-wrap is pure Python with no dependencies, so the matrix is wide rather than
# deep: every interpreter the classifiers name, on all three operating systems. Time and
Expand All @@ -12,8 +13,6 @@ name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:

# A force-push should not leave the previous run burning minutes on a dead commit.
concurrency:
Expand Down Expand Up @@ -43,10 +42,10 @@ jobs:
- run: uv sync --group dev --frozen

- name: ruff check
run: uv run --no-sync ruff check src tests
run: uv run --no-sync ruff check src tests scripts/check_installed.py

- name: ruff format
run: uv run --no-sync ruff format --check --diff src tests
run: uv run --no-sync ruff format --check --diff src tests scripts/check_installed.py

test:
name: py${{ matrix.python-version }} · ${{ matrix.os }}
Expand Down Expand Up @@ -84,6 +83,36 @@ jobs:
path: coverage.xml
if-no-files-found: warn

free-threaded:
# CPython 3.14t with the GIL off, plus the thread-safety stress suite, which only
# runs when PW_STRESS=1. This is where real data races show up.
name: py3.14t (free-threaded) · ubuntu-latest
runs-on: ubuntu-latest
env:
PYTHON_GIL: "0"
steps:
- uses: actions/checkout@v7

- name: Install uv
uses: astral-sh/setup-uv@v10.3.2
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
python-version: "3.14t"

- run: uv sync --group dev --frozen

- name: The GIL must really be off
run: uv run --no-sync python -c "import sys; assert not sys._is_gil_enabled()"

- name: pytest
run: uv run --no-sync pytest -q

- name: Thread-safety stress suite
run: uv run --no-sync pytest -q tests/stress
env:
PW_STRESS: "1"

typecheck:
name: mypy
runs-on: ubuntu-latest
Expand Down Expand Up @@ -136,8 +165,16 @@ jobs:
run: |
uv venv "$RUNNER_TEMP/smoke" --python 3.13
uv pip install --python "$RUNNER_TEMP/smoke" dist/*.whl
cd "$RUNNER_TEMP"
"$RUNNER_TEMP/smoke/bin/python" -c "import python_wrap; print(python_wrap.__version__)"
version=$(sed -nE 's/^__version__ *= *"([^"]+)".*/\1/p' src/python_wrap/__init__.py)
"$RUNNER_TEMP/smoke/bin/python" -I scripts/check_installed.py "$version"

- name: The source distribution includes its test fixtures
run: |
mkdir "$RUNNER_TEMP/sdist"
tar -xzf dist/*.tar.gz -C "$RUNNER_TEMP/sdist" --strip-components=1
cd "$RUNNER_TEMP/sdist"
uv sync --group dev
uv run --no-sync pytest tests/test_examples.py tests/test_benchmarks_smoke.py

- name: Upload the distributions
uses: actions/upload-artifact@v7
Expand Down
6 changes: 2 additions & 4 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
# Build check for the documentation site.
#
# Read the Docs does the hosting (see .readthedocs.yaml). This workflow only catches
# breakage before it gets there: `--strict` turns a broken link, a page missing from the
# nav or a docstring mkdocstrings cannot render into a failed check on the pull request.
# breakage after changes land on main: `--strict` turns a broken link, a page missing
# from the nav or a docstring mkdocstrings cannot render into a failed check.
name: Docs

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
Expand Down
73 changes: 61 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@
# pushed against an unregistered publisher fails at the upload, and a version number on
# PyPI can never be reused.
#
# Routing: a final version (0.2.0, 0.2.0.post1) goes to PyPI. Anything else (a, b, rc,
# .dev) goes to TestPyPI only, so a release candidate can be rehearsed without burning a
# real version number.
# Every tagged version goes to TestPyPI and is installed back from that index first.
# Final versions then go to PyPI; pre-releases remain on TestPyPI only.
#
# No API token is involved anywhere in this file, and none should be added. Trusted
# Publishing mints a short-lived credential scoped to this workflow and environment.
Expand Down Expand Up @@ -78,8 +77,7 @@ jobs:
python3 -m zipfile -l dist/*.whl | grep -q 'python_wrap/py.typed'
uv venv "$RUNNER_TEMP/smoke" --python 3.13
uv pip install --python "$RUNNER_TEMP/smoke" dist/*.whl
cd "$RUNNER_TEMP"
"$RUNNER_TEMP/smoke/bin/python" -c "import python_wrap; print(python_wrap.__version__)"
"$RUNNER_TEMP/smoke/bin/python" -I scripts/check_installed.py "${{ steps.meta.outputs.version }}"

- uses: actions/upload-artifact@v7
with:
Expand All @@ -90,7 +88,7 @@ jobs:
publish-testpypi:
name: Publish to TestPyPI
needs: build
if: github.ref_type == 'tag' && needs.build.outputs.prerelease == 'true'
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
environment:
name: testpypi
Expand All @@ -103,16 +101,41 @@ jobs:
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@v1.14.2
- uses: pypa/gh-action-pypi-publish@v1.14.3
with:
repository-url: https://test.pypi.org/legacy/
# Re-running after a failed attempt should not be blocked by files that
# already landed.
skip-existing: true

verify-testpypi:
name: Install and verify from TestPyPI
needs: [build, publish-testpypi]
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10.3.2
with:
python-version: "3.13"
- name: Install the exact published version without fallback indexes
env:
VERSION: ${{ needs.build.outputs.version }}
run: |
uv venv "$RUNNER_TEMP/testpypi-smoke" --python 3.13
for attempt in 1 2 3 4 5 6; do
if uv pip install --python "$RUNNER_TEMP/testpypi-smoke" --no-deps --no-cache \
--index-url https://test.pypi.org/simple/ "python-wrap==$VERSION"; then
break
fi
if [ "$attempt" = 6 ]; then exit 1; fi
sleep 10
done
"$RUNNER_TEMP/testpypi-smoke/bin/python" -I scripts/check_installed.py "$VERSION"

publish-pypi:
name: Publish to PyPI
needs: build
needs: [build, verify-testpypi]
if: github.ref_type == 'tag' && needs.build.outputs.prerelease == 'false'
runs-on: ubuntu-latest
environment:
Expand All @@ -127,19 +150,45 @@ jobs:
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@v1.14.2
- uses: pypa/gh-action-pypi-publish@v1.14.3
with:
# PEP 740 attestations, signed by the same OIDC identity.
attestations: true

verify-pypi:
name: Install and verify from PyPI
needs: [build, publish-pypi]
if: github.ref_type == 'tag' && needs.build.outputs.prerelease == 'false'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10.3.2
with:
python-version: "3.13"
- name: Install the exact published version
env:
VERSION: ${{ needs.build.outputs.version }}
run: |
uv venv "$RUNNER_TEMP/pypi-smoke" --python 3.13
for attempt in 1 2 3 4 5 6; do
if uv pip install --python "$RUNNER_TEMP/pypi-smoke" --no-deps --no-cache \
--index-url https://pypi.org/simple/ "python-wrap==$VERSION"; then
break
fi
if [ "$attempt" = 6 ]; then exit 1; fi
sleep 10
done
"$RUNNER_TEMP/pypi-smoke/bin/python" -I scripts/check_installed.py "$VERSION"

github-release:
name: Create the GitHub release
needs: [build, publish-testpypi, publish-pypi]
needs: [build, verify-testpypi, publish-pypi, verify-pypi]
# Whichever publish job applied must have succeeded; the other is skipped.
if: >-
!cancelled() && github.ref_type == 'tag' && needs.build.result == 'success'
&& contains(fromJSON('["success", "skipped"]'), needs.publish-testpypi.result)
&& contains(fromJSON('["success", "skipped"]'), needs.publish-pypi.result)
&& needs.verify-testpypi.result == 'success'
&& ((needs.build.outputs.prerelease == 'true' && needs.publish-pypi.result == 'skipped')
|| (needs.publish-pypi.result == 'success' && needs.verify-pypi.result == 'success'))
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down
66 changes: 60 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ A ground-up rework and a much larger toolkit. Every decorator works on plain fun
configures logging. Several
v0.1 decorators that did not do what their names promised (`transactional`, `revert_on_failure`,
async `timing`) now work. See the
[migration guide](https://python-wrap.readthedocs.io/en/latest/migration/) for the full upgrade
[migration guide](https://pythonwrap.readthedocs.io/en/latest/project/migration/) for the full upgrade
path.

### Highlights
Expand Down Expand Up @@ -70,6 +70,16 @@ path.

Resilience:

- `RetryBudget` (with `RetryBudgetStats`): one Finagle/Envoy-style retry allowance shared
across functions, threads and event loops via `retry(budget=...)`, `retrying(budget=...)` or
`configure(retry={"budget": ...})`. When it is spent, retrying stops at once and emits
`retry.budget_exhausted`, which prevents retry storms against a failing dependency.
- `retry_if_result=` on `retry`/`retrying` retries when the function returns a matching value;
once retries run out the last value is returned. `retrying` blocks set `attempt.result`.
- For `async def` functions, `retry_if`, `retry_if_result` and `on_retry` may be async and are
awaited; `async with attempt:` is supported in `retrying`.
- `timeout(..., cleanup_grace=)` bounds async cleanup after the limit: the call is cancelled,
given the grace to finish its cleanup, then `TimeoutExpired` is raised.
- `retry`: `delay_from=` (with the `retry_after_header` helper for `Retry-After`), `max_time=`,
`log_level=` and `log_exc_info=`; `retry_stats(func)` and `RetryStats`; `retrying(...)` /
`Retrying` for retrying an inline block (`for` / `async for` with `with attempt`); retries
Expand All @@ -85,6 +95,11 @@ Resilience:

Async toolkit and concurrency:

- `periodic(interval, *, jitter=, initial_delay=, max_runs=, on_error=)` runs a sync function in
a daemon thread, or an async function as a task, every N seconds without drift (missed ticks
are skipped). The function stays callable and gains `start()`, `stop()`, `running`, `runs`,
`last_error` and `running_in_background()` for clean shutdown. Events `periodic.run` and
`periodic.failed`.
- `python_wrap.aio`: `gather_limit`, `map_async` and `amap_iter` (bounded concurrency, lazy
input, ordered results; everything is cancelled and awaited on error, cancellation or early
exit), `race` (first success wins; `RaceFailed`) and `run_sync` (refuses to run inside a running
Expand All @@ -98,12 +113,24 @@ Async toolkit and concurrency:

Caching:

- `memoize(ttl=..., stale_ttl=...)`: stale-while-revalidate. After `ttl` a stale value is served
at once while one background refresh runs (a task for async functions, a daemon thread for
sync ones); a failed refresh keeps the stale value and is logged. Events `cache.stale` and
`cache.refresh_failed`.
- `memoize(store=...)` and the `CacheStore` protocol: keep entries in any store (a dict, Redis,
...); the `cache_*` helpers work against it.
- `memoize(per_instance=True)`: per-instance method caches held through weak references, so
instances stay collectable and need not be hashable.
- `cached_property(ttl=)` (thread-safe, `async def` getters, `invalidate()`).
- `memoize`: `cache_exceptions=` / `exception_ttl=`, a per-item `ttl=` callable, and
`cache_contains()`, `cache_get()` and `cache_parameters()`.

Observability, configuration and testing:

- `trace(otel=True, span_name=...)` wraps calls in an OpenTelemetry span (sync and async) with
`code.function`, `code.namespace` and `python_wrap.context.*` attributes and exception
recording. `opentelemetry-api` is imported lazily and only when asked for; python-wrap still
has no runtime dependencies.
- `count_calls`: calls, errors, in-flight, mean, p50, p95 and p99 per function.
- `python_wrap.events`: `subscribe()`, `listening()`, `filter_events()` and `EVENT_NAMES`, one hook
for retry, cache, circuit, limiter, bulkhead and timeout events (Prometheus, OpenTelemetry and
Expand Down Expand Up @@ -131,6 +158,9 @@ Observability, configuration and testing:

Core:

- Decorated functions, including `periodic` objects and `wrap_methods` classes, pickle by
reference, so they work with `multiprocessing` and `ProcessPoolExecutor` under the "spawn"
start method. Cache, limiter and breaker state is per worker process.
- `retry`: `backoff`, `max_delay`, `jitter`, `on=` exception filter, `retry_if=` predicate,
`on_retry=` hook receiving a `RetryState`, unlimited attempts (`max_attempts=None`), and bare
`@retry` usage.
Expand All @@ -153,14 +183,22 @@ Core:
- `revert_on_failure(attrs=, deep=, target=)`.
- `deprecate(since=, removed_in=, alternative=, category=)`, which also works on classes;
`mock_data(enabled=bool | callable)`; `compose(*decorators)`.
- Exceptions in `python_wrap.errors`, each subclassing the closest builtin: `PythonWrapError`,
`TimeoutExpired` (`TimeoutError`), `RateLimitExceeded`, `CircuitOpenError`, `ValidationError`
(`ValueError`) and `ArgumentTypeError` (`TypeError`).
- Exceptions in `python_wrap.errors`, all subclassing `PythonWrapError`: `TimeoutExpired`
(also a `TimeoutError`), `RateLimitExceeded`, `CircuitOpenError`, `ValidationError` (also a
`ValueError`) and `ArgumentTypeError` (also a `TypeError`).
- `once` gained `.reset()` and `.called`.
- A `py.typed` marker, and a `NullHandler` on the `python_wrap` logger.

### Changed

- `CircuitBreaker`, `RateLimiter`, `Bulkhead`, `RetryBudget`, `Transaction`, `Policy` and
`testing.FakeClock` raise a clear `TypeError` when pickled or copied (their state belongs to
one process), instead of an error about a thread lock or a copy that silently shares a lock.
- `RetryState.exception` can be `None` (when a returned value was rejected by
`retry_if_result`), and `RetryState` has a new `result` field. A sync function given an async
retry hook raises `TypeError` when decorated.
- On free-threaded Python 3.14t new threads inherit the caller's context, so `log_context`
fields carry over into them (`sys.flags.thread_inherit_context`).
- The project uses a `src/` layout and Hatchling, and requires Python 3.10 or newer.
- The runtime dependencies `wrapt` and `typing-extensions` were unused and have been removed.
- `retry` waits with `asyncio.sleep` for `async def` functions, and re-raises the final
Expand Down Expand Up @@ -190,6 +228,20 @@ Core:

### Fixed

- Final releases now pass through TestPyPI and an installation check before PyPI.
Both indexes are verified after upload before creating the GitHub release.
- Source distributions include the examples and benchmark script used by their tests.
- Release smoke checks validate metadata, public imports, `py.typed`, retry, memoization
and async behavior in an isolated installed environment.

- `AsyncPeriodic.stop()` no longer swallows a cancellation aimed at the task awaiting it, and
ends a loop whose run swallows the cancel; `stop()` called inside a run ends the loop as soon
as that run returns.
- `timeout(..., cleanup_grace=...)` lets an outside cancellation propagate as `CancelledError`
(matching `asyncio.timeout`), and waits for and retrieves the child task when the caller is
cancelled instead of logging "Task exception was never retrieved".
- `periodic(on_error="raise")` logs an error that `stop()` never collected when `start()` is
called again, instead of dropping it silently.
- `revert_on_failure` never restored anything because its snapshot was always empty.
- `transactional` was a no-op; it now commits and rolls back through a `Transaction`.
- `memoize` and `cache` built keys from `str(args)`, which collided and ignored keyword
Expand All @@ -215,13 +267,14 @@ Core:

### Performance

- Async `memoize` and `cache` hits are about 30-35% faster (one lock instead of two).
- The `memoize` hit path is about 9x faster.
- `validate_args`, `check_type`, `enforce_types`, `log_args` and `audit` skip signature binding on
plain positional calls (5x to 40x faster).
- `audit` skips building a record when nobody would receive it, and `timing` skips formatting
when its log level is disabled.
- Numbers and a benchmark script are on the
[performance page](https://python-wrap.readthedocs.io/en/latest/performance/).
[performance page](https://pythonwrap.readthedocs.io/en/latest/project/performance/).

### Deprecated

Expand All @@ -240,7 +293,8 @@ Core:
### Infrastructure

- CI runs ruff, `mypy --strict`, the test suite on Linux, macOS and Windows across Python 3.10 to
3.14, and a wheel check.
3.14, the test and thread-safety stress suites on free-threaded Python 3.14t, and a wheel
check.
- Tag-driven releases publish to PyPI through Trusted Publishing, with no stored token.
- The documentation build is checked in CI and hosted on Read the Docs.
- Added Dependabot, issue and pull request templates, `CONTRIBUTING.md`, `SECURITY.md`,
Expand Down
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ There is no Makefile; the commands below are the whole workflow.

## Run the checks

CI runs exactly these, so if they pass locally the pull request will be green.
Run these checks before opening or merging a pull request. CI and docs run after
changes land on `main`; pull requests and feature-branch pushes do not start them.
Release publishing remains tied to version tags.

```bash
uv run pytest # tests
Expand Down
Loading