wrapture
Wrap anything, capture everything, change nothing.
wrapture (wrapt + capture) is a Python library for attaching bindings to
arbitrary call sites, without modifying the code being observed, and doing
something useful with what flows through them.
It is a sibling project to wrapt and autowrapt, building on the safe monkey-patching machinery wrapt provides.
Note
wrapture is in beta ahead of 1.0.0, with pre-releases published to
PyPI. Until 1.0.0 is final, a
plain pip install wrapture picks up the latest pre-release
automatically, so there is no need to pin a specific version. The API
is complete for the three uses described below and is not foreseen to
break, and the recording path has been through a performance pass, so
code written against it today is expected to carry forward to 1.0.0.
What the beta series needs now is use: unittest.mock and
OpenTelemetry’s own instrumentation are the established tools for the
two halves of what wrapture does, and the open question is whether an
alternative doing both from one mechanism is something people want.
The blog posts and workshops page lists
the quickest ways to try it, including guided workshops that run in the
browser with nothing installed.
Reports of it working, or not, on real code, and of what confused or
was missing, are what will decide whether anything changes before a
release candidate.
What it does
One mechanism, three uses, in increasing order of machinery:
Monkey patching. A clean lifecycle and behaviour vocabulary over wrapt’s
wrap_object(). Point at a method by name and stub it, fail it, transform its arguments or result, or wrap it with a decorator, then remove it again, with honest reporting if something else displaced the patch in the meantime. Useful entirely on its own, with nothing else switched on.Unit testing. Observe and assert on how calls actually flowed through a real call graph (nesting, ordering, arguments and return values) and optionally intervene (stub, transform, fail-inject). Unlike a
unittest.mockMock, which fabricates values and cannot see calls an object makes to itself, wrapture watches the real code run, and when a test must supply a stand-in it provides strict, recorded ones:stub()for a callable, spec-requiredmock()for a collaborator.Ad-hoc tracing. Attach bindings to a running application, including one you cannot modify or redeploy, and emit a structured, nested trace to process or chart elsewhere. Name a handful of methods and a call tree appears; no code changes required: with a
wrapture.tomlnaming the methods and a sink,python -m wrapture manage.py runservertraces the application untouched. With autowrapt installed, not even the launcher is needed:AUTOWRAPT_BOOTSTRAP=wrapturein the environment applies the same config at interpreter startup, so the program starts with plainpython.
On top of the tracing layer sits
OpenTelemetry export: the same recorded events sent
to any OTLP backend as traces, metrics and correlated logs, switched
on by one [otel] table in the config, with the trace identity
arriving and leaving in W3C traceparent headers so two observed
services join one distributed trace. These are layers of one
mechanism, not separate products: the binding vocabulary that stubs a
method in a test is the same one that traces it in production, and
the config that names methods for a printed call tree is the config
that exports spans, so what starts as a monkey patch or a test
assertion can grow into full observability without the code being
rewritten along the way.
The distinction that matters: most instrumentation, OpenTelemetry’s own included, either has to be written into the code as SDK calls, or arrives as auto-instrumentation covering only the frameworks it already knows. wrapture needs neither: you point at your own methods by name and a trace appears, and the same pointing is how it lands in a test, a terminal, or a backend.
At a glance
None of the classes below import wrapture or know they are observed:
place = wrapture.binding(OrderService, "place")
charge = wrapture.binding(Gateway, "charge")
record = wrapture.binding(Ledger, "record")
with wrapture.timeline(place, charge, record) as tape:
OrderService().place(500)
print(tape.tree())
OrderService.place(amount=500) -> {'id': 'ch_500', 'amount': 500}
Gateway.charge(amount=500, currency='USD') -> {'id': 'ch_500', 'amount': 500}
Ledger.record(entry={'id': 'ch_500', 'amount': 500}) -> 'led_ch_500'
New here? Start with the getting started page; everything on it can be
pasted into an interpreter. Coming from unittest.mock? The comparison
page maps each mock idiom to its wrapture counterpart.
How it was built
wrapture’s code and documentation were written by an AI assistant under the direction of Graham Dumpleton, the author of wrapt, through a long process of specification, layered implementation, and validation against real-world test suites. How wrapture was built explains the process and the thinking, so you can judge the provenance with the facts in hand.
Documentation
The guides are organised by mechanism: bindings and behaviours, then recording in tests, then tracing a running process. The design concepts page opens the section and is the recommended first read: every idea the guides build on, a paragraph apiece, and how they fit together. The worked examples are organised by the question you arrive with, and each one combines several of those mechanisms on a small concrete scenario, building up from the problem to a finished test or configuration.
Start here
- Getting started
- Design philosophy
- Coming from unittest.mock
- Stubbing a return value
- Injecting a failure
- A sequence of outcomes
- Patching several methods at once
- Setting a value rather than replacing a call
- Running the real code while modifying the call
- Seeing calls an object makes to itself
- Asserting on what happened
- Async code
- Asserting on log messages
- Where unittest.mock still fits
- Blog posts and workshops
- How wrapture was built
Guides
- Design concepts
- The wrapped call site
- Bindings: naming a location
- Behaviour: channels, verbs and phases
- Value and mapping bindings: holding instead of wrapping
- Iterator bindings: consumption is the event
- Timeline and tape: scoped recording
- Expectations: asserting up front
- Stand-ins: stub, mock and observed
- Scoping forms: with, decorators, fixtures
- Log capture: messages as events
- Block events: phases the code declares
- From tests to tracing
- Instrumentation: packaged patching for a target
- Trace identity: one trace across processes
- OpenTelemetry export: the same events, sent to a backend
- The map
- Monkey patching
- Creating a binding
- Applying and removing
- Call behaviour: changing what a call does
- Phased behaviour: changing what a call does over time
- Suspending and resuming
- Binding groups
- Binding modes: call versus attribute
- Attribute bindings
- Value bindings: holding a value in place
- Mapping bindings: substituting a mapping’s content
- Iterators and generators
- Patching a module before it is imported
- Escape hatch: dropping down to wrapt
- Using wrapture in tests
- Scoping with a context manager
- Scoping with decorators
- Scoping with pytest fixtures
- Scoping with unittest
- Sharing binding declarations
- Do not mix scoping styles
- Discovering members by pattern
- Observing a bare callable
- Supplying a stand-in with stub()
- A collaborator double with mock()
- Recording calls on a timeline
- Filtering and asserting on events
- The call tree and ordering
- Capturing log messages
- Declaring blocks of code
- Applying instrumentation in a test
- Finding a binding applied elsewhere
- The pytest plugin
- Verifying nothing leaked by hand
- Ad-hoc tracing
- Sinks: where events go
- Process and scoped listening
- Watching calls live: Printer
- Counting without retaining
- Composing sinks: fan-out, sampling and filtering
- Deciding at the binding: when=
- Declining a whole tree: tree=
- Terminal nodes: leaf= and category=
- Deciding the name, kind and tags per operation: resolvers
- Streaming to disk: JSONLines
- Output paths and rotation
- Writing your own sink
- Instrumenting your own code
- Configuring from a file
- Injection without a launcher: autowrapt
- Forked worker processes
- Trace identity and propagation
- Work the caller does not wait for
- Exporting traces to other tools
- WSGI request tracing
- What a request event contains
- Redacting secrets from recorded requests
- Ignoring whole requests
- The on_request namespace
- One boundary per request
- Asserting on requests in tests
- From a config file
- Framework instrumentation under the covers
- When the framework catches the exception
- Protocol obligations, honoured
- ASGI request tracing
- Scheduled tracing
- OpenTelemetry export
- Instrumentation packages
- Manual setup
Worked examples
- Testing code that calls external services
- The application code
- The naive approach: swap the client out
- Stubbing the response, on the class
- Injecting a timeout and checking the service copes
- Running the real client code, with one thing changed
- Recording what the service did to the gateway
- Declaring the expectation up front
- Grouping the client’s methods
- As a pytest suite
- Where next
- Supplying hooks and collaborators
- Checking that resources are released
- The application: a database, connections, and a repository
- The naive approach: a fake database that keeps a list
- Recording acquire and release on one timeline
- Pairing each acquisition with its release
- Watching the closed flag itself
- Naming the line that acquired without releasing
- Counting acquisitions against releases without keeping events
- The same check as a pytest test
- The same counters from a config file
- Where next
- Testing generators and streamed results
- The application code: a paginated catalogue and its consumers
- The naive approach: a canned list of pages
- Recording the iteration: one event, live item count
- Watching each item with an iterator proxy
- Injecting a failure at page k
- Transforming items on the way through
- Proxying on the consumer side: an argument, not a result
- Putting it together in a pytest test
- Where next
- Testing async code
- Finding where a request spends its time
- The application
- The naive approach
- One request as one event
- The layers beneath the request
- Watching it live, with timings
- Printing less: depth, filters and samples
- Reading the time off the tree
- Tagging a request with who it was for
- Handing the trace to other tools
- The same thing from a config file
- Where next
- Watching a service over time
- The service: a shop answering quotes
- The naive approach: a log line and a stopwatch
- One report: the Aggregate collector in a window
- Reports on a schedule:
Windowwithevery= - Reports as files:
report=and its path template - Opening a window on demand:
on_signalandon_file - The always-on stream:
JSONLineswithrotate= - The same thing from a config file: the Flask shop
- Where next
- Changing what a third-party library does
- The library you cannot edit
- The naive approach: assign over the method
- Injecting the header: a transform on the way in
- Reversibility: suspend, resume, remove
- Reconfiguring the live patch
- Retrying with a wrapper around the whole call
- Clamping an attribute the library reads
- Applying before the library is imported
- Recording calls without recording the token
- Escape hatch: the wrapt handle underneath
- The same thing from a config file
- Where next
- Pinning configuration for a test
Reference
- Known limitations
- Attribute bindings intercept instance access only
- Module attributes are intercepted through a type swap
- Attribute bindings install on the class, never one instance
- Dynamically served attributes have no place to patch
- Class access and instance access look the same
- Targets must already be imported
- Calls on other threads may not be recorded
- A forked child starts with nothing in flight
- Iteration recording covers generators only
- Builtin and extension types cannot be patched
- Release notes
Source, issues and releases
wrapture is developed on GitHub at
GrahamDumpleton/wrapture,
under the BSD 2-Clause licence. Bug reports and feature requests go to
the issue tracker;
a report is easiest to act on when it names the wrapture and Python
versions and includes a small binding that reproduces the problem.
Releases are published to PyPI,
and each page of these docs carries an “Edit on GitHub” link to its
source under docs/ in the repository, so a documentation fix can be
raised the same way.