Skip to content

Releases: Stubsmith/stubsmith-python

0.6.0

Choose a tag to compare

@bramhoven bramhoven released this 04 Sep 18:20
23d4239

Bodies are never truncated. A captured body is now recorded whole or not at all.

Truncating to max_body_bytes sliced JSON mid-token and stored a document that could not parse, with nothing in the payload to say so. A truncated capture was indistinguishable from an API that had genuinely returned malformed JSON, so a replayed sample raised JSONDecodeError and the blame landed upstream, on a failure that never happened. There is no byte count at which a JSON document is still a JSON document, so a larger default would only move the cliff.

New: max_payload_bytes, default 10 MiB. The size question is answered on the assembled payload rather than on individual bodies, because that is the quantity the server can measure too. Over the ceiling both bodies are omitted and bodies_omitted is set: the server records the request shape, its occurrence count and the response variant, stores no sample, and raises a dashboard alert. The endpoint stays visible and nothing half-captured is stored. The SDK logs a warning naming the endpoint and sizes, once per endpoint.

New: max_queue_bytes, default 32 MiB. queue_maxsize bounds items, not bytes, so with megabyte-scale payloads a full queue could hold gigabytes inside the application being instrumented.

Captures are compressed in transit. Payloads over 1 KiB are sent with Content-Encoding: gzip. Masked bodies are dominated by repeated <masked>, so they compress by roughly two orders of magnitude: a 111,110 byte capture goes out as 477 bytes. Set compress=False to disable. The size ceiling is applied before compression; limiting compressed bytes would let a payload under the limit expand to hundreds of megabytes on the server.

body_encoding is honoured on replay. The field records that a stored body is not the response itself, and the SDK sets it to base64 when it substitutes a 1x1 placeholder for an image. Replay ignored it, so an image recording was served as base64 text to code expecting image bytes. It is now decoded, and an unrecognised or malformed encoding falls back to the literal text rather than raising, so a stub's encoding cannot take a test suite down.

max_body_bytes is still accepted and no longer truncates anything.

If you run your own capture endpoint, note that captures now arrive gzipped unless you set compress=False; the hosted ingest handles this.

0.5.0 was never published, so its changelog entries are included here.

Published to PyPI as stubsmith 0.6.0.

0.4.0

Choose a tag to compare

@bramhoven bramhoven released this 04 Sep 14:20
219d1a3

New: replay_all(statuses={...}). Loops only recordings of the given statuses, so one loop can be exhaustive about success bodies and another about failure handling, with assertions specific to each.

def test_parses_every_recorded_success():
    for attempt in stubsmith.replay_all(statuses={200}):
        with attempt:
            result = connector.sync_orders()
        assert result.orders is not None

def test_degrades_on_every_recorded_failure():
    for attempt in stubsmith.replay_all(statuses={429, 500}):
        with attempt:
            with pytest.raises((RateLimited, UpstreamUnavailable)):
                connector.sync_orders()

Filtering decides what a red build means. Unfiltered, the loop feeds successes and failures into one test body, so the day a 5xx first enters the recorded window, assertions written when only 200s existed start failing and the build breaks because the recording changed rather than because the code did. Filtered, a failure means your code cannot handle a response your API genuinely returns. It also bounds the pass count, otherwise roughly (samples per response) × (distinct statuses), which grows with traffic on its own.

A shape with recordings but none of the requested status raises StubNotFound, naming the filter and listing the statuses it does have, rather than substituting a response the loop was told to exclude.

stubsmith pull --endpoint can be repeated. --samples above 1 needs an endpoint, so covering several endpoints previously meant one pull each and merging the files by hand. Each endpoint is now one request and the responses are merged, deduplicating request shapes and carrying truncated through.

Corrected: the background threads are not named at the OS level. The docs said they were "identifiable in a thread dump", which sent a reader to /proc/<pid>/task/*/comm, where CPython shows python for both because it does not set the native thread name from Thread(name=...). The names are visible through threading.enumerate(); is_installed() is the supported way to check that capture is armed.

Additive: an unfiltered replay_all() and a single --endpoint behave exactly as in 0.3.0, so upgrading asks nothing of you.

Published to PyPI as stubsmith 0.4.0.

0.3.0

Choose a tag to compare

@bramhoven bramhoven released this 04 Sep 06:09
26a0a9c

New: replay_all(), for looping every recorded response. replay() serves one response per request shape, the newest recording of the most frequent status, which leaves the rest of the rolling sample window unexercised: the 429 and the 500 the API really returned are recorded and never tested against.

for attempt in stubsmith.replay_all():
    with attempt:
        result = connector.sync_orders()
    assert result.ok or result.retried

No endpoint is named: which shapes a pass touches is discovered by running your code, so a connector whose call sequence changes between passes, because it branches on the response it got, still works. An endpoint first reached on a later pass is looped from its own first recording rather than starting partway through. Pass one serves exactly what replay() serves, so moving an existing test to replay_all() does not change what the first pass asserts.

New: replay(select=...) and by_status(). Pin a single recorded response. by_status() raises StubNotFound when no recording of that status exists rather than serving a different one, so a rate-limit test cannot silently pass against a 200.

New: ReplayContext.served(). Reports which recordings actually ran, with the endpoint, status, capture_id, captured_at and position in the shape's window, so a test can assert its coverage instead of assuming it.

New: fetch_bundle() and stubsmith pull --samples. A bundle carries one recording per response by default, so --samples N or --samples all is what puts the window into one; it requires --endpoint. fetch_bundle() fetches without going through a file, for a suite that wants current recordings at collection time.

Nothing in this release changes existing behaviour: replay() with no arguments serves the same response it did in 0.2.0, and a bundle pulled without --samples is byte-identical.

Published to PyPI as stubsmith 0.3.0.

0.2.0

Choose a tag to compare

@bramhoven bramhoven released this 04 Sep 06:09
cacd2a1

Captures are no longer lost in forked workers. Threads do not survive fork(), so a child inherited a dead sender and a queue nothing would drain: under gunicorn --preload, uWSGI or Celery, where install() runs in a master and workers are forked, every capture in every worker was enqueued and silently discarded. The client now re-arms itself in the child.

install() is idempotent. It returns the client already installed in this process instead of building another one. Previously each call added a sender thread, a rules-cache poller, an atexit hook and a 60-second backend poll.

Key paths are reported from every array element, not just the first. This changes the fingerprint of any request or response containing arrays whose elements differ in shape: those endpoints reappear in the review queue as novel and need approving once more. Approvals for every other endpoint are unaffected.

A prepared request sent with Session.send() is now captured. Only Session.request was patched, so a caller that built its own PreparedRequest was silently invisible: no error, no fingerprint.

install() accepts flush_timeout, and stubsmith.is_installed() is a supported way to assert that capture is armed.

Process exit no longer waits on an unreachable ingest host. The at-exit budget is now one second rather than five.

Documented: what gets patched, both egress hosts, both thread names, argument versus environment precedence, and an annotated capture payload. The README previously said masking was applied server-side, which is the opposite of what the SDK does.

Published to PyPI as stubsmith 0.2.0.

Release created retroactively; the version was published from this commit by a manual workflow dispatch on 2026-08-31.

0.1.0

Choose a tag to compare

@bramhoven bramhoven released this 04 Sep 06:07

First public release of the Stubsmith Python SDK.

Captures outbound requests and httpx traffic, masks it in-process before anything is transmitted, and replays recorded responses offline in tests.

Published to PyPI as stubsmith 0.1.0.

Release created retroactively; the version was published from this commit by a manual workflow dispatch on 2026-08-27.