Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
<div align="center">

# python-wrap
<a href="https://python-wrap.readthedocs.io">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/LitePy/PythonWrap/main/docs/assets/logo-dark.svg">
<img alt="python-wrap" src="https://raw.githubusercontent.com/LitePy/PythonWrap/main/docs/assets/logo-light.svg" height="64">
</picture>
</a>

**The resilience and concurrency toolkit you add to every Python project.**<br>
Retry (with `Retry-After`), circuit breaker, timeout, cache, rate limit, bulkhead, batching, async helpers, metrics and test helpers. One import, sync and async, zero dependencies.
Expand Down
17 changes: 17 additions & 0 deletions docs/assets/logo-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions docs/assets/logo-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions docs/assets/logo-mark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
112 changes: 97 additions & 15 deletions docs/index.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
# python-wrap
---
title: python-wrap
hide:
- navigation
- toc
---

**The resilience and concurrency toolkit you add to every project.** Retry (with `Retry-After`), circuit breaker, timeout, cache, rate limit, bulkhead, batching, async helpers, metrics and test helpers, in one typed, async-aware package with no dependencies.
<div class="pw-hero" markdown>

<img class="pw-logo" src="assets/logo-mark.svg" alt="">

# The toolkit you add to every Python project

<p class="pw-tagline">Retry (with <code>Retry-After</code>), circuit breaker, timeout, cache, rate limit,
bulkhead, batching, async helpers, metrics and test helpers. One import, sync and async,
fully typed, zero dependencies.</p>

[Get started](#install){ .md-button .md-button--primary }
[Choose a decorator](choosing.md){ .md-button }
[View on GitHub](https://github.com/LitePy/PythonWrap){ .md-button }

</div>

## One decorator, the whole policy

```python
from python_wrap import CircuitBreaker, memoize, policy, retry, retry_after_header, timeout
Expand Down Expand Up @@ -168,25 +189,86 @@ Not sure which decorator you need? Start with [Choose a decorator](choosing.md).

## Principles

**Zero dependencies.** `dependencies = []` is a feature: python-wrap is safe to add to any project, including libraries.
<div class="grid cards" markdown>

- :material-package-variant-closed:{ .lg .middle } __Zero dependencies__

---

`dependencies = []` is a feature: python-wrap is safe to add to any project, including libraries.

- :material-language-python:{ .lg .middle } __Typed__

---

Ships `py.typed`, passes `mypy --strict`, and preserves signatures with `ParamSpec`, so your editor still knows what the decorated function takes and returns.

- :material-lightning-bolt:{ .lg .middle } __Async is first class__

---

Async functions get `async def` wrappers, waits use `asyncio` instead of blocking the loop, and caches share concurrent awaits. See [Async support](async.md).

- :material-text-box-search-outline:{ .lg .middle } __Never configures logging__

---

Importing python-wrap does not touch the root logger. Decorators log to the `python_wrap` logger, which has a `NullHandler`. See [Observability](guides/observability.md#configuring-logging).

- :material-chart-line:{ .lg .middle } __Observable and testable__

---

**Typed.** The package ships `py.typed`, passes `mypy --strict`, and preserves signatures with `ParamSpec`, so your editor still knows what the decorated function takes and returns.
`count_calls` and one [event hook](guides/metrics.md) cover retries, caches, circuits and limiters; [`python_wrap.testing`](testing.md) makes tests instant. Overhead numbers: [Performance](performance.md).

**Async is first class.** Async functions get `async def` wrappers (so `inspect.iscoroutinefunction` stays true), retries and rate limits wait with `asyncio` instead of blocking the loop, and caches de-duplicate concurrent awaits. See [Async support](async.md).
- :material-alert-octagon-outline:{ .lg .middle } __Failures are explicit__

**It never configures logging.** Importing python-wrap does not touch the root logger. Decorators log to the `python_wrap` logger (or the `logger=` you give them), which carries a `NullHandler`, so nothing is printed until your application configures logging. See [Observability](guides/observability.md#configuring-logging).
---

**Observable and testable.** `count_calls` and one [event hook](guides/metrics.md) cover retries, caches, circuits and limiters, and [`python_wrap.testing`](testing.md) makes tests instant and deterministic. See [Performance](performance.md) for overhead numbers.
Its own exceptions (`TimeoutExpired`, `RateLimitExceeded`, `CircuitOpenError`, `BulkheadFull`, ...) also subclass the closest built-in, so `except TimeoutError` keeps working.

**Failures are explicit.** The package raises its own exceptions (`TimeoutExpired`, `RateLimitExceeded`, `CircuitOpenError`, `BulkheadFull`, `BatchError`, `ValidationError`, `ArgumentTypeError`), each also a subclass of the closest built-in, so `except TimeoutError` and `except ValueError` keep working.
</div>

**Honest about limits.** Some of these tools cannot do everything (a thread timeout cannot stop the work, for example). They are documented on one page: [Limitations](limitations.md).
Some of these tools cannot do everything (a thread timeout cannot stop the work, for example). Every such limit is on one page: [Limitations](limitations.md).

## Where to go next

- [Choose a decorator](choosing.md): a table by problem.
- Guides: [Resilience](guides/resilience.md), [Caching](guides/caching.md), [Concurrency](guides/concurrency.md), [Async toolkit](guides/async-toolkit.md), [Batching and hedging](guides/batching.md), [Observability](guides/observability.md), [Metrics and events](guides/metrics.md), [Validation](guides/validation.md), [State safety](guides/state.md), [Policies](guides/policies.md), [Developer tools](guides/devtools.md), [Configuration](guides/configuration.md).
- [Cookbook](cookbook/index.md): complete recipes for HTTP clients, crawlers, FastAPI, Django, CLIs and tests.
- [Testing](testing.md) and [Performance](performance.md).
- [Stacking decorators](stacking.md): order matters.
- [Migrating from 0.1](migration.md): every breaking change, with before and after code.
<div class="grid cards" markdown>

- :material-compass-outline:{ .lg .middle } __[Choose a decorator](choosing.md)__

---

A table by problem: what to reach for, and what to combine it with.

- :material-book-open-variant:{ .lg .middle } __Guides__

---

[Resilience](guides/resilience.md), [Caching](guides/caching.md), [Concurrency](guides/concurrency.md), [Async toolkit](guides/async-toolkit.md), [Batching](guides/batching.md), [Observability](guides/observability.md), [Metrics](guides/metrics.md), [Validation](guides/validation.md), [State](guides/state.md), [Policies](guides/policies.md), [Dev tools](guides/devtools.md), [Configuration](guides/configuration.md).

- :material-chef-hat:{ .lg .middle } __[Cookbook](cookbook/index.md)__

---

Complete recipes for HTTP clients, crawlers, FastAPI, Django, CLIs and tests.

- :material-test-tube:{ .lg .middle } __[Testing](testing.md)__

---

`fast_mode`, a fake clock, spies and `eventually`: instant, deterministic tests.

- :material-layers-triple-outline:{ .lg .middle } __[Stacking decorators](stacking.md)__

---

Order matters. The recommended order, and why `policy(...)` applies it for you.

- :material-swap-horizontal:{ .lg .middle } __[Migrating from 0.1](migration.md)__

---

Every breaking change, with before and after code.

</div>
84 changes: 84 additions & 0 deletions docs/stylesheets/extra.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/* Brand: the teal of the logo. */
:root,
[data-md-color-scheme="default"] {
--md-primary-fg-color: #0f766e;
--md-primary-fg-color--light: #2dd4bf;
--md-primary-fg-color--dark: #115e59;
--md-accent-fg-color: #0d9488;
--md-typeset-a-color: #0f766e;
}

[data-md-color-scheme="slate"] {
--md-primary-fg-color: #0d9488;
--md-primary-fg-color--light: #5eead4;
--md-primary-fg-color--dark: #0f766e;
--md-accent-fg-color: #5eead4;
--md-typeset-a-color: #5eead4;
--md-default-bg-color: #0b1112;
}

.md-header,
.md-tabs {
background: linear-gradient(90deg, #115e59, #0d9488);
}

.md-typeset h1 {
color: var(--md-default-fg-color);
font-weight: 700;
letter-spacing: -0.02em;
}

.md-typeset h2 {
font-weight: 650;
letter-spacing: -0.01em;
}

/* ── home page ──────────────────────────────────────────────────────────── */

.pw-hero {
text-align: center;
padding: 1.5rem 0 0.5rem;
}

.pw-hero .pw-logo {
width: 5rem;
height: 5rem;
margin-bottom: 0.5rem;
}

.pw-hero h1 {
font-size: 2.6rem;
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.15;
margin: 0 0 0.75rem;
}

.pw-hero .pw-tagline {
font-size: 1.15rem;
color: var(--md-default-fg-color--light);
max-width: 40rem;
margin: 0 auto 1.5rem;
}

.pw-hero .md-button {
margin: 0.25rem;
}

@media screen and (max-width: 44.9375em) {
.pw-hero h1 {
font-size: 1.9rem;
}

.pw-hero .pw-tagline {
font-size: 1rem;
}
}

.md-typeset .grid.cards > ul > li {
border-radius: 0.6rem;
}

.md-typeset .grid.cards > ul > li:hover {
border-color: var(--md-accent-fg-color);
}
32 changes: 28 additions & 4 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,36 @@ validation:

theme:
name: material
logo: assets/logo-mark.svg
favicon: assets/logo-mark.svg
icon:
repo: fontawesome/brands/github
font:
text: Inter
code: JetBrains Mono
palette:
- media: "(prefers-color-scheme)"
toggle:
icon: material/brightness-auto
name: Switch to light mode
- media: "(prefers-color-scheme: light)"
scheme: default
primary: indigo
accent: indigo
primary: custom
accent: custom
toggle:
icon: material/weather-sunny
name: Switch to dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: indigo
accent: indigo
primary: custom
accent: custom
toggle:
icon: material/weather-night
name: Follow the system
features:
- navigation.instant
- navigation.instant.progress
- navigation.tracking
- navigation.tabs
- navigation.sections
- navigation.top
Expand All @@ -44,8 +52,14 @@ theme:
- toc.follow
- search.suggest
- search.highlight
- search.share
- content.code.copy
- content.code.annotate
- content.tabs.link
- content.action.edit

extra_css:
- stylesheets/extra.css

extra:
social:
Expand All @@ -72,15 +86,25 @@ plugins:
merge_init_into_class: true

markdown_extensions:
- abbr
- admonition
- attr_list
- def_list
- footnotes
- md_in_html
- tables
- toc:
permalink: true
- pymdownx.details
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
- pymdownx.highlight:
anchor_linenums: true
line_spans: __span
pygments_lang_class: true
- pymdownx.inlinehilite
- pymdownx.keys
- pymdownx.snippets:
base_path: [.]
check_paths: true
Expand Down
14 changes: 8 additions & 6 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from python_wrap import _core
from python_wrap.config import configure, configured, get_config, reset_config
from python_wrap.deadline import deadline
from python_wrap.logs import audit, log_args
from python_wrap.logs import AuditRecord, audit, log_args
from python_wrap.retry import retry, retry_stats, retrying


Expand Down Expand Up @@ -202,8 +202,10 @@ def f(ssn: str, password: str) -> None: ...
def g(ssn: str, password: str) -> None: ...

g("123", "pw")
text = repr(records[0])
assert "123" not in text and "pw" in text
record = records[0]
assert isinstance(record, AuditRecord)
# Only the arguments: the timestamp or duration can contain "123" by chance.
assert record.arguments == {"ssn": "***", "password": "pw"}


def test_redact_extra_adds_to_defaults_everywhere(caplog: pytest.LogCaptureFixture) -> None:
Expand All @@ -222,9 +224,9 @@ def f(ssn: str, password: str, name: str) -> None: ...
def g(ssn: str, token: str, name: str, other: str) -> None: ...

g("1", "s3cr3t", "bob", "keep")
text = repr(records[0])
assert "bob" not in text and "s3cr3t" not in text and "'1'" not in text
assert "keep" in text
record = records[0]
assert isinstance(record, AuditRecord)
assert record.arguments == {"ssn": "***", "token": "***", "name": "***", "other": "keep"}


def test_redact_extra_with_explicit_redact(caplog: pytest.LogCaptureFixture) -> None:
Expand Down
Loading