Repository navigation
Release 0.2.0 - #1
Merged
Merged
Conversation
Move to src/python_wrap, replace Poetry with hatchling + uv dependency groups, require Python 3.10+, and add the shared foundation the v0.2 modules build on: _core (logger resolution, async detection, patchable clock), errors (typed exceptions that keep built-in base classes), and a fake_clock test fixture. Importing the package no longer configures logging.
…sactions) modules
… debounce, singleflight
…tructure Replace the token-based publish workflow with a tag-driven release using PyPI Trusted Publishing, add a 3 OS x 5 Python test matrix, mypy and packaging checks, switch Read the Docs to MkDocs, and add dependabot, issue/PR templates, pre-commit config and contributor documents.
Re-export every module's public names from python_wrap, add a python_wrap.decorators compatibility module for v0.1 imports, bump the version to 0.2.0, and test the public surface and v0.1 call forms. Fix enforce_types on Python 3.10, where isinstance(list[int], type) is True.
…t__, and logging hook safety
…nd-method typing, orphan errors, once.reset race
Replace the Sphinx docs with a Material for MkDocs site: choosing guide, one guide per area, async, stacking, testing, limitations, migration from 0.1 and an API reference generated by mkdocstrings. scripts/check_docs_examples.py runs every python block in docs/ so examples cannot drift from the API.
- timeout: on async functions only convert the decorator's own deadline to TimeoutExpired (asyncio.timeout + expired() on 3.11+, task + asyncio.wait on 3.10); a TimeoutError raised by the function propagates unchanged, and the expiry is chained instead of hidden. - is_async: detect callable objects with an async __call__ (classes are never async), so every decorator picks the async wrapper for them. - singleflight: retrieve the shared task's outcome even if every caller was cancelled (no 'exception in shielded future' / 'never retrieved' logs), and run re-entrant same-key calls directly instead of deadlocking (sync and async). - Document that a broad except in user code swallows signal-based timeouts, and that throttle returns the last successful result after a raising run.
Hammers every stateful decorator from 16-64 threads behind barriers, plus several event loops in threads for the async paths. Known races are reproduced deterministically and marked xfail(strict=True).
…Clock start, module-based docs example runner, bulkhead starvation note
…nce once per process Task creation now happens outside the lock with a placeholder future registered first, so eagerly completing tasks no longer deadlock memoize or leave a stale finished task in flight. Async once shares one run across event loops through a thread-level future; a cancelled or stranded run lets another loop take over.
… full 0.2 feature set
…stress races - circuit_breaker / CircuitBreaker: the sync wrapper calls the sync path directly instead of re-detecting async on every call (bulkhead and RateLimiter already decided at decoration time). - timeout thread fallback: pooled idle daemon workers (at most 8 idle, exit after 5 s idle; a worker whose call timed out is abandoned, never reused; contextvars copied per call; reset after fork). A result that arrives after the limit is a timeout. Signal path: one setitimer call replaces getitimer+setitimer, and the TimeoutExpired is only built when the alarm fires. - singleflight (async): the run resolves the callers' futures itself (no relay callback) and starts eagerly on 3.12+ without a custom task factory; a cancelled caller still only detaches. Fixes the KeyError/stale entry under asyncio.eager_task_factory. - batch_calls: a call that fills a batch it alone waits for runs it in the calling task; sync loaders warn once (RuntimeWarning) when called from a running loop. - RateLimiter.reset() and limiter_of(func) for rate_limit-decorated functions. - Races: Bulkhead.release decrements active before freeing the permit; the rate limiter reads the clock under its lock; debounce(async).cancel() from another thread hands the cancellation to the owning loop.
…key edge cases Async bulkhead and race timeouts, hedge delays and the batch_calls and debounce windows waited through _core.async_sleep, so fast_mode() made them instant and fake_clock() never fired them. They now use real-time timers like timeout() and the sync versions. The timeout signal and thread paths compare real time as well, so a call that advances a fake clock is not reported late and nested alarms still fire. A blocking rate limiter no longer busy-spins under fast_mode(): a call that would wait is let through at once and still recorded; under fake_clock() the wait advances the fake clock. transactional rejects a positional-only, *tx or **tx parameter when decorating. The singleflight default key and the memoize fallback key (no inspectable signature) keep keyword arguments apart from look-alike positional ones.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
python-wrap 0.2.0: async support everywhere, new resilience/async/caching/testing toolkits, typed stacking, docs site, CI and Trusted Publishing release workflow. Full notes from CHANGELOG.md below; upgrade notes in docs/migration.md.
A ground-up rework and a much larger toolkit. Every decorator works on plain functions,
async deffunctions and methods, keeps the wrapped signature for type checkers, and neverconfigures logging. Several
v0.1 decorators that did not do what their names promised (
transactional,revert_on_failure,async
timing) now work. See themigration guide for the full upgrade
path.
Highlights
wraptandtyping-extensionsare gone; signatures arepreserved with
ParamSpec, the package shipspy.typed, andmypy --strictis clean.async defwrappers stay coroutine functions and never blockthe event loop.
retrywith backoff, jitter and hooks;CircuitBreaker;fallback;suppress;timeoutthat actually interrupts.memoizewith TTL and LRU,rate_limitwith a sliding window,a shared
RateLimiter,Bulkhead,throttle,debounceandsingleflight.retry(delay_from=retry_after_header)followsRetry-After,max_time=anddeadline()cap the total time, and cancellation is never retried.gather_limit,map_async,amap_iter,raceandrun_sync, plusbatch_calls(the DataLoader pattern) andhedge.policy(...)applies protections in the recommended order, andconfigure()sets project-wide defaults.count_calls, onepython_wrap.eventshook for every component,log_context, andpython_wrap.testing(fast_mode,FakeClock,spy, pytest fixtures).memoizehit path is about 9x faster and the validation and logging decoratorsskip signature binding on plain calls.
log_args,auditandtraceredact secrets.Breaking changes
run_in_threadreturns aconcurrent.futures.Futureinstead of aThread. Migrate:replace
.join()with.result()(which also returns the value and re-raises errors).benchmarkis a plain function,benchmark(func, *, args=(), kwargs=None, runs=5, warmup=1) -> BenchmarkResult, instead of a decorator that silently ran the function three times percall. Migrate: call
benchmark(func, args=(...)); usebenchmark_asyncfor coroutines.retry_on_exceptionis deprecated and now stops after 3 attempts by default (v0.1 retriedforever with no delay). Migrate: use
retry(on=...), and passmax_attempts=Noneto keep theold unlimited behaviour.
rate_limitraisesRateLimitExceededinstead of a bareException. Migrate: catchRateLimitExceeded(useexc.retry_after), or passblock=Trueto wait instead.log_args,log_returnandtracelog atDEBUGby default (log_argsusedINFO).Migrate: pass
level=logging.INFOto keep the old level.no_debugis a deprecated no-op and will be removed in 0.3. Migrate: delete the decorator.validate_argsandcheck_typeraiseTypeErrorat decoration time for a name that is nota parameter of the function. Migrate: fix the misspelled or stale parameter name.
stay on 0.1.0.
logging.basicConfig. Migrate: configure logging inyour application if you relied on python-wrap's root-logger setup.
Added
Resilience:
retry:delay_from=(with theretry_after_headerhelper forRetry-After),max_time=,log_level=andlog_exc_info=;retry_stats(func)andRetryStats;retrying(...)/Retryingfor retrying an inline block (for/async forwithwith attempt); retrieshonour
deadline(). Cancellation,KeyboardInterrupt,SystemExitandGeneratorExitarenever retried.
CircuitBreaker:success_threshold,exclude=,on_state_changeand.stats(
CircuitStats).timeoutis also a context manager (with timeout(5):/async with timeout(5):) and returnsa
Timeoutobject.python_wrap.deadline(deadline(),remaining(),check()) gives code ashared time budget, and
timeoutclamps to it.hedge(delay, max_extra=, on=): hedged requests for idempotent calls; the first success winsand async losers are cancelled.
Async toolkit and concurrency:
python_wrap.aio:gather_limit,map_asyncandamap_iter(bounded concurrency, lazyinput, ordered results; everything is cancelled and awaited on error, cancellation or early
exit),
race(first success wins;RaceFailed) andrun_sync(refuses to run inside a runningloop).
Bulkhead(max_concurrent, max_waiting=, timeout=, name=)for sync and async (decorator,with,async with,.active,.waiting),limit_concurrency(n)andBulkheadFull.RateLimiter: one budget shared across functions (with,async with,acquire,try_acquire), and several windows at once withrate_limit(limits=[(5, 1), (600, 900)]).batch_calls(max_size=, max_wait=, key=): the DataLoader pattern for sync and async code, withde-duplication,
.flush()/.aflush()/.pending, andBatchErroron contract violations.Caching:
cached_property(ttl=)(thread-safe,async defgetters,invalidate()).memoize:cache_exceptions=/exception_ttl=, a per-itemttl=callable, andcache_contains(),cache_get()andcache_parameters().Observability, configuration and testing:
count_calls: calls, errors, in-flight, mean, p50, p95 and p99 per function.python_wrap.events:subscribe(),listening(),filter_events()andEVENT_NAMES, one hookfor retry, cache, circuit, limiter, bulkhead and timeout events (Prometheus, OpenTelemetry and
statsd recipes in the docs).
log_context()/current_log_context(): contextvar fields inlog_args,log_return,audit,traceandtimingmessages, onLogRecord.contextand onAuditRecord.context.log_argsandauditacceptredact_extra=.policy(...)/Policy: reusable, immutable decorator bundles placed by role (observe,validate,fallback,cache,limiter,bulkhead,breaker,retry,timeout,guard,outer,inner) in the recommended order, with.with_();wrap_methods(*decorators, include=, exclude=, include_private=)decorates a class.python_wrap.config:configure(),configured(),get_config()andreset_config()forproject-wide defaults (logger, retry options, redaction), read at decoration time; explicit
arguments win.
python_wrap.testing:fast_mode(),disable_retries(),disable_rate_limits(),disable_caching(),overrides()(context managers and decorators),FakeClock/fake_clock(),spy()/spy_on(),eventually()/eventually_sync(), and the pytestfixtures
pw_fake_clockandpw_fast_mode(pytest_plugins = ["python_wrap.testing"]).@retryover@memoizekeeps.cache_info()), andMemoized,Once,CircuitProtected,LimitedandCountedbindcorrectly under methods and
@classmethod. Typing regression tests runmypyin CI.examples/, a performance page withbenchmarks/run.py, and a thread-safety stress suite (PW_STRESS=1, also run on free-threaded3.13t and 3.14t).
Core:
retry:backoff,max_delay,jitter,on=exception filter,retry_if=predicate,on_retry=hook receiving aRetryState, unlimited attempts (max_attempts=None), and bare@retryusage.fallback(value)/fallback(handler=...)andsuppress(*exceptions)for failing soft.CircuitBreaker(shareable across functions), thecircuit_breakerdecorator (breaker exposedas
.breaker) andCircuitState.memoize:maxsize(LRU),ttl,typed,key=,cache_info(),cache_clear(),cache_delete(), and in-flight de-duplication for async functions.throttle(interval),debounce(wait)with.cancel()and.flush(),singleflight(key=),and
rate_limit(block=, key=).timing(threshold=, callback=)withTimingRecord,measure_memory(callback=)withMemoryRecord,profile(sort=, limit=, callback=), andbenchmark_async.log_argssecret redaction (redact=,DEFAULT_REDACT) andmax_length=;auditnow yieldsa structured
AuditRecordwithsink=andinclude_result=;trace(limit=). Every loggingdecorator accepts
logger=andlevel=.enforce_types: runtime checks from annotations (generics, unions,Literal,Annotated,NewTypeand more) for arguments and return values.Transactionwithon_commit/on_rollback,tx=injection,current_transaction(), andsync and async context-manager support.
revert_on_failure(attrs=, deep=, target=).deprecate(since=, removed_in=, alternative=, category=), which also works on classes;mock_data(enabled=bool | callable);compose(*decorators).python_wrap.errors, each subclassing the closest builtin:PythonWrapError,TimeoutExpired(TimeoutError),RateLimitExceeded,CircuitOpenError,ValidationError(
ValueError) andArgumentTypeError(TypeError).oncegained.reset()and.called.py.typedmarker, and aNullHandleron thepython_wraplogger.Changed
src/layout and Hatchling, and requires Python 3.10 or newer.wraptandtyping-extensionswere unused and have been removed.retrywaits withasyncio.sleepforasync deffunctions, and re-raises the finalexception unchanged with its original traceback.
timeoutraisesTimeoutExpired. Async functions are cancelled withasyncio.wait_for; syncfunctions are interrupted with
SIGALRMon the Unix main thread. The thread fallback usedelsewhere cannot stop the work, and is now documented.
memoizeandcachekeys are bound to the function signature with defaults applied, sof(1),f(x=1)andf(1, y=<default>)share one entry; list, dict and set arguments areconverted to hashable keys.
deprecatepoints its warning at the caller and sets__deprecated__(PEP 702).from python_wrap.decorators import ...still works through a compatibility module.timeout(...)now returns aTimeoutobject, which is both a decorator and a context manager.Decorator use is unchanged.
retrynever retries cancellation-type exceptions (asyncio.CancelledError,KeyboardInterrupt,SystemExit,GeneratorExit), even ifon=lists them.redact=default onlog_argsandauditisNone, which means "the configured value, orDEFAULT_REDACT". Passredact=()to turn redaction off.auditwith nosinkand a disabled log level builds no record at all.Limited.bulkheadis a read-only property.fast_mode()andfake_clock()no longer shortenlimits on how long to wait: async
Bulkhead/limit_concurrencytimeouts,race(timeout=),async
hedgedelays and asyncbatch_calls/debouncewindows run on real time, like theirsync versions and
timeout.fast_mode(), a blocking rate limiter (block=True) that would wait lets the callthrough at once and still counts it.
Fixed
revert_on_failurenever restored anything because its snapshot was always empty.transactionalwas a no-op; it now commits and rolls back through aTransaction.memoizeandcachebuilt keys fromstr(args), which collided and ignored keywordequivalence.
cache()also shared a single store across all functions it decorated.timingmeasured about zero seconds forasync deffunctions, and now usesperf_counter.measure_memoryusedru_maxrss, which is Unix-only and reported the wrong units; it nowuses
tracemalloc.resourcemodule.auditreported the wrapper's own frame as the caller.oncewas not thread-safe, and now retries after an exception.validate_argsrebuilt the function signature on every call.fast_mode()orfake_clock(), async bulkhead andracetimeouts expired at once andhedgelaunched every backup attempt immediately.RateLimiter/rate_limit(block=True)busy-spun at full CPU underfast_mode().timeout's thread fallback reported a call as late when it advanced the fake clock (forexample a
retryinside it), and a nested signal-basedtimeoutdid not fire underfake_clock().@transactionalon a function with a positional-only (or*tx/**tx)txparametercrashed at call time; it now raises
TypeErrorwhen decorating.singleflight's default key treatedf(1, b=2)andf((1,), (("b", 2),))as the same call,and
memoizeon a callable without an inspectable signature treatedf(1, b=2)andf(1, 2)as the same call.Performance
memoizehit path is about 9x faster.validate_args,check_type,enforce_types,log_argsandauditskip signature binding onplain positional calls (5x to 40x faster).
auditskips building a record when nobody would receive it, andtimingskips formattingwhen its log level is disabled.
performance page.
Deprecated
retry_on_exception: useretry(on=...). Emits aDeprecationWarning.no_debug: a no-op, to be removed in 0.3.cache: soft-deprecated in the docs only (no warning yet); prefermemoize(ttl=...).Removed
Publishing).
Infrastructure
mypy --strict, the test suite on Linux, macOS and Windows across Python 3.10 to3.14, and a wheel check.
CONTRIBUTING.md,SECURITY.md,CODE_OF_CONDUCT.mdandRELEASING.md.Known limitations
timeoutthread fallback (Windows, non-main threads) cannot stop the running work.rate_limit,throttleanddebounceshare state across instances when used on methods(
rate_limitacceptskey=to split it).memoizeon methods keeps instances alive; usemaxsizeorttl.measure_memoryshare one tracker, andprofileon an async functionincludes event-loop work.
transactionalinjectstx=by keyword, so declaretxlast or keyword-only.