From 44fef68a55d4326536ecc3f464fc6b008705846d Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 02:26:38 -0500 Subject: [PATCH 01/33] Bring docs/proposals/ into the rename branch --- .../2026-04-26-tripwire-port-feedback.md | 424 ++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100644 docs/proposals/2026-04-26-tripwire-port-feedback.md diff --git a/docs/proposals/2026-04-26-tripwire-port-feedback.md b/docs/proposals/2026-04-26-tripwire-port-feedback.md new file mode 100644 index 0000000..ffab884 --- /dev/null +++ b/docs/proposals/2026-04-26-tripwire-port-feedback.md @@ -0,0 +1,424 @@ +# Bigfoot improvement proposals: feedback from the tripwire (Nim) port + +**Created:** 2026-04-26 +**Project:** bigfoot (`/Users/eek/Development/bigfoot`) +**For:** a fresh Claude Code session - paste this whole file as the first message. + +--- + +## Context + +You are working on `bigfoot`, a Python testing/sandboxing library. The user is the +author and the only user. There is no compatibility window to worry about. + +Recently I ported (some of) bigfoot's design into `tripwire`, a Nim equivalent +used by paperplanes (a Kraken arbitrage bot). The port surfaced several +design seams in bigfoot worth examining. This file is the prompt to act on +that feedback. + +The goals here are concrete, prioritized, and bounded. Do them in priority +order. After each, run the test suite (or write tests first per the user's +preference) and commit. Do NOT bundle multiple proposals into one commit; +each lands separately. + +The user's standing direction (verbatim): "If there is a bug, fix it. If there +is a missing feature, add it." "What is most correct, most ergonomic? we don't +care about blast radius, we just want what is correct and right." "no need +for deprecated alias. this is all liquid, baby." + +--- + +## Operating directives + +- **You are the bigfoot author and the only user.** Single source of authority. + No "but what about other users." +- **Subagents for substantive work** per `~/.claude/CLAUDE.md`. Dispatch them + for code reads / writes / tests; orchestrate from the main context. +- **TDD where it fits.** Behavioral changes get a failing test first. +- **No commits with AI attribution** (no `Co-Authored-By`, no "Generated with + Claude," no bot signatures). +- **No GitHub issue references** in commit messages, PR titles, or PR + descriptions (no `#123`, no `fixes #X`). +- **No em-dashes** in any user-facing text. Use `-` or restructure. +- **No `--no-verify`** on commits. If a hook fails, fix it. +- **No push without explicit user approval.** Local commits are pre-authorized; + pushes are not. +- **Worktree** if the change set spans many files. Optional. +- Run `python -m pytest` (or whatever the project uses; verify via `pyproject.toml`) + before each commit. Don't regress. + +--- + +## Proposals, in priority order + +Each proposal has: rationale, concrete change, acceptance test, and effort +estimate. Land them in this order; each is independent. + +--- + +### Proposal 1 - Default `guard` to `"error"`, not `"warn"` + +**Priority:** HIGH. This is the biggest one. + +**Rationale.** Bigfoot today defaults `guard` to `"warn"`. A fresh project +that imports bigfoot and writes a test without configuring guard will +silently make real network calls, real subprocess invocations, real DNS +lookups - they pass through with a warning the test runner may swallow. +That is the opposite of what a sandbox should do. A testing tool's prime +directive is "no surprise side effects." Default-warn violates that. + +The tripwire port deliberately defaults to error. A dev who wants warn +opts in. CI never accidentally hits real DNS. + +The README sells "warn" as the gentle on-ramp. That's fine; reframe it +as a *legacy-migration* mode, not a default. New projects should fail +loud. + +**Concrete change.** + +1. In `pyproject.toml` schema and bigfoot's config defaults: set the + default for `[tool.bigfoot] guard` to `"error"` (find the constant or + the parser default; grep for `"warn"` near guard parsing). +2. Update `README.md` and any quickstart docs: + - State the default is `"error"`. + - Add a "When to use `warn`" subsection: incremental migration of + legacy test suites that have not yet been wrapped in `with bigfoot:` + blocks. Caveat that warn mode lets real I/O through and should not + be the steady state. +3. Update CHANGELOG.md under `[Unreleased]` with a clear breaking-change + note. + +**Acceptance test.** + +Add a test that: +- creates a project with no bigfoot config, +- imports bigfoot, +- makes an unmocked HTTP request outside any `with bigfoot:` block, +- asserts an error is raised (not a warning logged). + +**Effort.** Small (defaults change + docs + 1 test). + +--- + +### Proposal 2 - Per-plugin `passthrough_safe` declaration + distinct error + +**Priority:** HIGH. This prevents `guard="warn"` from being a footgun. + +**Rationale.** When `guard="warn"`, bigfoot today probably lets every +unmocked call through. For some plugins that's fine: a Mock plugin's +"passthrough" is identity, no harm done. For other plugins it's +destructive: HTTP makes a real network request, subprocess forks a real +process, DNS hits a real resolver, file writes mutate the disk. CI +quotas, external state, and real money can all be at risk. + +Tripwire's plugin base class has `supportsPassthrough() -> bool`. Mock +returns true; httpclient, subprocess, websock, and chronos return false. +When tripwire's outside-sandbox guard mode is set to warn but the plugin +can't passthrough safely, it raises `OutsideSandboxNoPassthroughDefect` +with a pedagogical message: "plugin X doesn't support outside-sandbox +passthrough; install a sandbox or set guard=error." + +Bigfoot would benefit from the same gate. Otherwise `guard="warn"` is +indistinguishable from "make all real I/O happen, with a log line." + +**Concrete change.** + +1. Add a `passthrough_safe: bool` class attribute (default `False`) to + bigfoot's plugin base class. +2. Set `passthrough_safe = True` on Mock-style plugins where passthrough + is genuinely a no-op or identity. Audit each plugin in the codebase; + default to False if there is any doubt. +3. Add a new exception class `UnsafePassthroughError` (or similar; align + with bigfoot's existing exception naming convention - read `errors.py` + or wherever exceptions live first). +4. In the guard-mode dispatch path: when `guard="warn"` and an unmocked + call hits a plugin where `passthrough_safe is False`, raise + `UnsafePassthroughError` with a message that says, verbatim or close: + "plugin {name} doesn't support outside-sandbox passthrough; either + install a `with bigfoot:` block, set guard='error' to make this fail + loudly, or mark this plugin passthrough_safe=True if you've audited + that the underlying call has no side effects." + +**Acceptance test.** + +Two cases: +- guard=warn + Mock plugin (passthrough_safe=True) -> warning logged, + call returns the underlying value. +- guard=warn + HTTP plugin (passthrough_safe=False) -> raises + UnsafePassthroughError with the helpful message. + +**Effort.** Small to medium. The guts are one new attribute and one new +branch in the guard dispatch path. The audit of which plugins are +genuinely safe is the larger piece. + +--- + +### Proposal 3 - Per-protocol guard granularity + +**Priority:** MEDIUM. Quality-of-life feature, not a correctness fix. + +**Rationale.** `guard` today is a single binary global. Operators in +practice want different strictness for different protocols. Pytest +collectors stat() lots of files; defaulting to error on file I/O is +hostile. But DNS and subprocess should fail loud unconditionally. + +**Concrete change.** + +Allow per-protocol overrides in `pyproject.toml`: + +```toml +[tool.bigfoot] +guard = "warn" # default for everything +guard.dns = "error" # except DNS, always raise +guard.subprocess = "error" +guard.file = "warn" # explicit, even though same as default +``` + +Implementation: +1. Extend the config parser to accept a dict-or-string under `guard`. If + string: backward-compatible global. If dict: per-protocol with a + default key (or use `guard` itself as the default and `guard.X` for + overrides; pick whichever schema is cleanest in TOML). +2. Wire the per-protocol values through the dispatch path. The plugin + knows its own protocol identifier already. + +**Acceptance test.** + +Config with `guard = "warn"` plus `guard.dns = "error"`: +- Outside-sandbox HTTP call -> warns (or passthrough per Proposal 2). +- Outside-sandbox DNS lookup -> raises. + +**Effort.** Medium. Config schema + dispatch wiring + tests. + +--- + +### Proposal 4 - Distinguish "no sandbox ever" from "post-sandbox interaction" + +**Priority:** MEDIUM. Async-debugging quality issue. + +**Rationale.** Tripwire has two distinct defects: +- `LeakedInteractionDefect`: TRM fired with empty verifier stack ("you + forgot a sandbox"). +- `PostTestInteractionDefect`: verifier was popped (sandbox exited) but + generation counter still active ("your async cleanup is wrong; a + Future / Task / Thread survived `with bigfoot:` exit and fired after"). + +These are *genuinely different bugs*. The first is a missing sandbox +declaration. The second is a leak of in-flight async work past the +sandbox lifetime. Catching both under one error makes async leak +debugging much harder than necessary. + +If bigfoot today raises one error for both, split them. Verify by reading +bigfoot's exception hierarchy (`grep -rn "class.*Error\\|class.*Defect" src/`). + +**Concrete change.** + +1. Read bigfoot's current handling of "call fired outside the active + sandbox" - is this one path or already two? If one, split. If already + two, this proposal is satisfied; move on. +2. Add `PostSandboxInteractionError` (or whatever fits bigfoot's naming). +3. Track sandbox generation: when `with bigfoot:` exits, mark the sandbox + inactive but keep an identity. Calls that fire on a-known-but-inactive + sandbox raise the post-sandbox error; calls with no sandbox identity + raise the leaked-interaction error. + +**Acceptance test.** + +Two tests: +- Call without ever entering `with bigfoot:` -> `LeakedInteractionError`. +- `with bigfoot:` block that schedules an asyncio Task; block exits before + Task completes; Task makes an unmocked call -> `PostSandboxInteractionError`. + +**Effort.** Medium. Generation tracking on the sandbox object plus exception +split. + +--- + +### Proposal 5 - Pedagogical error messages for outside-sandbox failures + +**Priority:** LOW-MEDIUM. UX polish, not correctness. + +**Rationale.** When bigfoot raises outside a sandbox, the message should +state the user's mental model, not just the implementation detail. A new +user seeing the current error has to figure out from context that they +forgot to wrap in `with bigfoot:`. + +Tripwire's message: `"TRM fired on thread {tid} with no active verifier +at {file}:{line}"` is functional but mechanical. Better: + +> `Call to {plugin}.{method}({args}) at {file}:{line} happened OUTSIDE +> any "with bigfoot:" block. Wrap the call in a sandbox and add an +> allow(...) for it, OR set guard="warn" in pyproject.toml if the call +> is intentional and safe.` + +**Concrete change.** + +Find every outside-sandbox raise site. Update the message to include: +- Which plugin and method was called +- The call site (file:line) +- The user-mental-model framing ("OUTSIDE any `with bigfoot:` block") +- The two options for fixing it + +**Acceptance test.** + +Capture the exception message and assert it contains the framing strings. +This is a regression guard against the message drifting back to mechanical. + +**Effort.** Small. String changes + assert tests. + +--- + +### Proposal 6 - Pytest marker for per-test guard override + +**Priority:** LOW-MEDIUM. Migration affordance. + +**Rationale.** Per-test override lets strict tests live next to permissive +ones during a guard migration. Natural fit for pytest. Tripwire can't do +this cleanly because it's compile-time; bigfoot can. + +**Concrete change.** + +Register a pytest marker `bigfoot_guard("error" | "warn" | dict-form)`. +When the test starts, the marker (if present) overrides the project's +guard setting for the duration of that test. When the test ends, restore +prior config. + +```python +@pytest.mark.bigfoot_guard("error") +def test_strict_dns(): + # this test fails loud on any unmocked call + ... +``` + +Implementation: +1. Add the marker registration in bigfoot's pytest plugin (find it in + `src/bigfoot/pytest_plugin.py` or similar). +2. Hook into a pytest fixture (autouse, narrow scope) that reads the + marker, overrides the config, yields, and restores. +3. Document in the README and the migration guide. + +**Acceptance test.** + +A test file with two tests: +- One marked `bigfoot_guard("error")`: makes an unmocked call, expects raise. +- One marked `bigfoot_guard("warn")`: makes an unmocked call, expects warning. + +**Effort.** Small. Pytest marker registration + fixture + docs. + +--- + +### Proposal 7 - Strict TOML schema validation at parse time + +**Priority:** LOW. Footgun-prevention. + +**Rationale.** `guard = "Warn"` (capital W) versus `guard = "warn"` is the +kind of typo that silently does the wrong thing if the parser falls back +to a default. Validate strictly. Reject unknown values at parse time. + +Tripwire's parser validates against the enum at parse time and raises +on unknown values. + +**Concrete change.** + +Find the TOML config parser. After reading `guard`, validate the value +is one of the accepted set. On mismatch, raise a clear error: + +> `Invalid value "{got}" for [tool.bigfoot] guard. Expected one of: "warn", +> "error". (Per-protocol form also accepted; see docs.)` + +Apply the same validation to any other config keys that take a closed set. + +**Acceptance test.** + +Config with `guard = "Warn"` (typo) -> ImportError or ConfigError at +collection time, not silent warn-or-error fallback. + +**Effort.** Small. Validation function + tests. + +--- + +### Proposal 8 - README "what default should I pick?" guidance + +**Priority:** LOW. Docs only. + +**Rationale.** Both modes are described neutrally today. After Proposal 1 +flips the default, the README needs a clear positioning paragraph that +tells the user when to use which: + +- **For new projects:** keep the default `"error"`. Real I/O outside + sandboxes is almost always a bug. +- **For migrating legacy test suites:** set `guard = "warn"` while you + add `with bigfoot:` blocks incrementally. Plan to flip back to + `"error"` once the migration is done. +- **For mixed CI:** use per-protocol overrides (Proposal 3) to be strict + on the dangerous protocols (DNS, subprocess) and permissive on the + safe ones (file). + +**Concrete change.** Edit the README. Add a section under the +configuration docs. + +**Acceptance test.** N/A (docs). + +**Effort.** Small. + +--- + +## Bonus: documentation work + +These don't ship as code but are worth noting. Decide whether to land them +during this batch or after. + +### B1 - Vocabulary clarity + +Make explicit in docs that `bigfoot.allow(...)` and `bigfoot.restrict(...)` +are *sandbox-scoped*, while `guard` is *module-scoped (global)*. They do +not compose. A user who tries `bigfoot.allow(...)` outside a `with` block +will get a confusing error because there is no sandbox to attach the +allow to. Either: +- document the divide loudly, OR +- raise a clear error from `allow()` / `restrict()` when called outside + a sandbox, with a message that points the user at `guard` instead. + +### B2 - Async edge case: contextvars + threadpools + +Verify `with bigfoot:` survives correctly across: +- `asyncio.to_thread(...)` (which uses a default thread executor) +- `concurrent.futures.ProcessPoolExecutor` (which fork-execs a child) +- `asyncio.create_task(...)` (which inherits the current context) + +Python's contextvars + threadpools are notoriously subtle. The right +behavior is probably: `with bigfoot:` state propagates to threads via +contextvars (it should "just work"), but does NOT propagate to subprocesses +(those are a separate process boundary; configure them via env or config +file, not via context). Document and test. + +--- + +## What "done" looks like + +Per proposal: +1. A failing test is committed first (TDD). +2. The implementation is committed second. +3. Proposals are landed in priority order, NOT bundled. +4. CHANGELOG.md `[Unreleased]` entry per proposal. +5. README and other docs updated where applicable. +6. `python -m pytest` (or the project's actual test command) is green + after each commit. + +Final state: 8 proposals' worth of commits on a branch ready to push. +Do not push without explicit user approval. + +--- + +## Pointers + +- `src/bigfoot/` for the implementation +- `tests/` for the test suite +- `pyproject.toml` for project configuration and config schema +- `CHANGELOG.md` for release notes +- `docs/` for the user-facing documentation +- `~/.claude/CLAUDE.md` for the user's standing operating directives +- `AGENTS.md` (or `CLAUDE.md`) at the bigfoot repo root for repo-specific + conventions; read first + +Good luck. The user is patient and rigorous; match the energy. From 707a440b4ff212b1ffd881f4080222c44cf9965f Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 02:54:20 -0500 Subject: [PATCH 02/33] Rename bigfoot to tripwire; default guard=error Mechanical rename of the package from bigfoot to tripwire (PyPI distribution, Python import name, public API symbols, exception class names, internal sentinels, pytest fixtures, pytest entry-point, and the config table [tool.bigfoot] -> [tool.tripwire]). Bundles Proposal 1's default guard flip from "warn" to "error" because both are breaking and a separate point release for the default flip would create a confusing two-step history. Bumps version to 0.20.0. Internal sentinels restructured from underscore-flat (`bigfoot_subprocess_run`) to colon-namespaced `:` (e.g., `subprocess:run`, `httpx:get`, `socket:connect`). The `tripwire:` prefix is intentionally omitted because the namespace is implicit inside the tripwire package. Adds ConfigMigrationError raised when [tool.bigfoot] is present in pyproject.toml during config load. Adds scripts/preflight.sh used as the pre-rename gate. --- .claude/skills/adding-plugins/SKILL.md | 76 +-- .github/ISSUE_TEMPLATE/bug_report.yml | 6 +- .github/workflows/ci.yml | 2 +- AGENTS.md | 50 +- CHANGELOG.md | 10 + CLAUDE.md | 6 +- CODE_OF_CONDUCT.md | 2 +- CONTRIBUTING.md | 18 +- README.md | 194 +++---- SECURITY.md | 6 +- docs/guides/async-subprocess-plugin.md | 34 +- docs/guides/async.md | 56 +- docs/guides/asyncpg-plugin.md | 40 +- docs/guides/boto3-plugin.md | 50 +- docs/guides/celery-plugin.md | 50 +- docs/guides/configuration.md | 38 +- docs/guides/crypto-plugin.md | 54 +- docs/guides/database-plugin.md | 54 +- docs/guides/dns-plugin.md | 52 +- docs/guides/elasticsearch-plugin.md | 58 +-- docs/guides/file-io-plugin.md | 80 +-- docs/guides/grpc-plugin.md | 70 +-- docs/guides/guard-mode.md | 112 ++-- docs/guides/how-it-works.md | 58 +-- docs/guides/http-plugin.md | 152 +++--- docs/guides/installation.md | 40 +- docs/guides/jwt-plugin.md | 50 +- docs/guides/logging-plugin.md | 42 +- docs/guides/mcp-plugin.md | 58 +-- docs/guides/memcache-plugin.md | 56 +- docs/guides/mock-plugin.md | 76 +-- docs/guides/mongo-plugin.md | 68 +-- docs/guides/native-plugin.md | 60 +-- docs/guides/pika-plugin.md | 70 +-- docs/guides/plugin-layers.md | 32 +- docs/guides/popen-plugin.md | 48 +- docs/guides/psycopg2-plugin.md | 42 +- docs/guides/pytest-integration.md | 70 +-- docs/guides/quickstart.md | 46 +- docs/guides/redis-plugin.md | 50 +- docs/guides/smtp-plugin.md | 96 ++-- docs/guides/socket-plugin.md | 56 +- docs/guides/ssh-plugin.md | 74 +-- docs/guides/stateful-plugins.md | 136 ++--- docs/guides/subprocess-plugin.md | 56 +- docs/guides/threading.md | 18 +- docs/guides/websocket-plugin.md | 80 +-- docs/guides/writing-plugins.md | 96 ++-- docs/index.md | 16 +- .../2026-04-26-tripwire-port-feedback.md | 94 ++-- docs/reference/async-subprocess-plugin.md | 2 +- docs/reference/asyncpg-plugin.md | 2 +- docs/reference/boto3-plugin.md | 2 +- docs/reference/celery-plugin.md | 2 +- docs/reference/configuration.md | 2 +- docs/reference/crypto-plugin.md | 2 +- docs/reference/database-plugin.md | 2 +- docs/reference/dns-plugin.md | 2 +- docs/reference/elasticsearch-plugin.md | 2 +- docs/reference/errors.md | 18 +- docs/reference/file-io-plugin.md | 2 +- docs/reference/grpc-plugin.md | 2 +- docs/reference/http-plugin.md | 2 +- docs/reference/index.md | 34 +- docs/reference/jwt-plugin.md | 2 +- docs/reference/logging-plugin.md | 2 +- docs/reference/mcp-plugin.md | 4 +- docs/reference/memcache-plugin.md | 2 +- docs/reference/mock-plugin.md | 2 +- docs/reference/mongo-plugin.md | 2 +- docs/reference/native-plugin.md | 2 +- docs/reference/pika-plugin.md | 2 +- docs/reference/popen-plugin.md | 2 +- docs/reference/psycopg2-plugin.md | 2 +- docs/reference/redis-plugin.md | 2 +- docs/reference/smtp-plugin.md | 2 +- docs/reference/socket-plugin.md | 2 +- docs/reference/ssh-plugin.md | 2 +- docs/reference/subprocess-plugin.md | 2 +- docs/reference/verifier.md | 6 +- docs/reference/websocket-plugin.md | 4 +- examples/async_api/README.md | 2 +- examples/async_api/test_app.py | 22 +- examples/async_subprocess_example/test_app.py | 12 +- examples/asyncpg_example/test_app.py | 14 +- examples/boto3_service/test_app.py | 14 +- examples/celery_tasks/test_app.py | 18 +- examples/cli_tool/README.md | 4 +- examples/cli_tool/test_app.py | 32 +- examples/conftest.py | 6 +- examples/crypto_sign/test_app.py | 16 +- examples/database_example/test_app.py | 16 +- examples/dns_lookup/test_app.py | 10 +- examples/elasticsearch_search/test_app.py | 10 +- examples/email_service/README.md | 4 +- examples/email_service/test_app.py | 20 +- examples/file_processor/test_app.py | 22 +- examples/flask_api/README.md | 6 +- examples/flask_api/test_app.py | 14 +- examples/grpc_service/test_app.py | 14 +- examples/jwt_auth/test_app.py | 14 +- examples/logging_example/test_app.py | 12 +- examples/mcp_tool/test_app.py | 10 +- examples/memcache_session/test_app.py | 16 +- examples/mongo_store/test_app.py | 14 +- examples/native_lib/test_app.py | 10 +- examples/pika_queue/test_app.py | 16 +- examples/popen_example/test_app.py | 12 +- examples/psycopg2_example/test_app.py | 16 +- examples/redis_cache/README.md | 4 +- examples/redis_cache/test_app.py | 16 +- examples/socket_example/test_app.py | 16 +- examples/ssh_remote/test_app.py | 18 +- examples/websocket_example/test_app.py | 20 +- mkdocs.yml | 8 +- pyproject.toml | 30 +- scripts/preflight.sh | 48 ++ src/bigfoot/__init__.pyi | 195 ------- src/bigfoot/_config.py | 28 - src/{bigfoot => tripwire}/__init__.py | 240 ++++----- src/tripwire/__init__.pyi | 195 +++++++ src/{bigfoot => tripwire}/_base_plugin.py | 24 +- src/{bigfoot => tripwire}/_compat.py | 0 src/tripwire/_config.py | 35 ++ src/{bigfoot => tripwire}/_context.py | 38 +- .../_context_propagation.py | 4 +- src/{bigfoot => tripwire}/_errors.py | 105 ++-- src/{bigfoot => tripwire}/_firewall.py | 6 +- .../_firewall_request.py | 0 src/{bigfoot => tripwire}/_glob.py | 6 +- src/{bigfoot => tripwire}/_guard.py | 20 +- src/{bigfoot => tripwire}/_match.py | 10 +- src/{bigfoot => tripwire}/_mock_plugin.py | 44 +- src/{bigfoot => tripwire}/_normalize.py | 0 src/{bigfoot => tripwire}/_patching.py | 2 +- src/{bigfoot => tripwire}/_path_resolution.py | 0 src/{bigfoot => tripwire}/_recording.py | 0 src/{bigfoot => tripwire}/_registry.py | 78 +-- .../_state_machine_plugin.py | 10 +- src/{bigfoot => tripwire}/_timeline.py | 8 +- src/{bigfoot => tripwire}/_verifier.py | 62 +-- src/{bigfoot => tripwire}/plugins/__init__.py | 0 .../plugins/async_subprocess_plugin.py | 58 +-- .../plugins/asyncpg_plugin.py | 30 +- .../plugins/boto3_plugin.py | 22 +- .../plugins/celery_plugin.py | 24 +- .../plugins/crypto_plugin.py | 26 +- .../plugins/database_plugin.py | 28 +- .../plugins/dns_plugin.py | 36 +- .../plugins/elasticsearch_plugin.py | 34 +- .../plugins/file_io_plugin.py | 42 +- .../plugins/grpc_plugin.py | 28 +- src/{bigfoot => tripwire}/plugins/http.py | 101 ++-- .../plugins/jwt_plugin.py | 24 +- .../plugins/logging_plugin.py | 32 +- .../plugins/mcp_plugin.py | 26 +- .../plugins/memcache_plugin.py | 31 +- .../plugins/mongo_plugin.py | 24 +- .../plugins/native_plugin.py | 20 +- .../plugins/pika_plugin.py | 32 +- .../plugins/popen_plugin.py | 46 +- .../plugins/psycopg2_plugin.py | 28 +- .../plugins/redis_plugin.py | 24 +- .../plugins/smtp_plugin.py | 34 +- .../plugins/socket_plugin.py | 26 +- .../plugins/ssh_plugin.py | 38 +- .../plugins/subprocess.py | 48 +- .../plugins/websocket_plugin.py | 54 +- src/{bigfoot => tripwire}/py.typed | 0 src/{bigfoot => tripwire}/pytest_plugin.py | 84 +-- tests/dogfood/test_dogfood.py | 126 ++--- tests/integration/test_default_guard_error.py | 50 ++ tests/integration/test_integration.py | 14 +- tests/integration/test_mock_e2e.py | 56 +- tests/unit/test_async_subprocess_plugin.py | 68 +-- tests/unit/test_asyncpg_plugin.py | 28 +- tests/unit/test_base_plugin.py | 30 +- tests/unit/test_bigfoot_migration_error.py | 73 +++ tests/unit/test_boto3_plugin.py | 94 ++-- tests/unit/test_celery_plugin.py | 104 ++-- tests/unit/test_config.py | 64 +-- tests/unit/test_context.py | 6 +- tests/unit/test_context_propagation.py | 36 +- tests/unit/test_crypto_plugin.py | 112 ++-- tests/unit/test_database_plugin.py | 48 +- tests/unit/test_default_guard_error.py | 14 + tests/unit/test_dns_plugin.py | 130 ++--- tests/unit/test_elasticsearch_plugin.py | 136 ++--- tests/unit/test_enforce_flag.py | 40 +- tests/unit/test_errors.py | 182 +++---- tests/unit/test_explicit_enable_error.py | 40 +- tests/unit/test_file_io_plugin.py | 118 ++--- tests/unit/test_firewall.py | 8 +- tests/unit/test_firewall_errors.py | 10 +- tests/unit/test_firewall_integration.py | 6 +- tests/unit/test_firewall_toml.py | 12 +- tests/unit/test_glob.py | 26 +- tests/unit/test_grpc_plugin.py | 288 +++++------ tests/unit/test_guard.py | 488 +++++++++--------- tests/unit/test_guard_new.py | 10 +- tests/unit/test_http_plugin.py | 72 +-- tests/unit/test_import_site_mock.py | 28 +- tests/unit/test_init.py | 256 ++++----- tests/unit/test_jwt_plugin.py | 102 ++-- tests/unit/test_logging_plugin.py | 32 +- tests/unit/test_match.py | 6 +- tests/unit/test_mcp_plugin.py | 182 +++---- tests/unit/test_memcache_plugin.py | 124 ++--- tests/unit/test_mock_factory.py | 34 +- tests/unit/test_mock_plugin.py | 50 +- tests/unit/test_mongo_plugin.py | 322 ++++++------ tests/unit/test_native_plugin.py | 158 +++--- tests/unit/test_normalize.py | 4 +- tests/unit/test_patching.py | 2 +- tests/unit/test_path_resolution.py | 2 +- tests/unit/test_pika_plugin.py | 268 +++++----- tests/unit/test_popen_plugin.py | 58 +-- tests/unit/test_project_structure.py | 16 +- tests/unit/test_psycopg2_plugin.py | 28 +- tests/unit/test_pytest_plugin.py | 66 +-- tests/unit/test_redis_plugin.py | 104 ++-- tests/unit/test_registry.py | 54 +- tests/unit/test_sandbox_mock_activation.py | 28 +- tests/unit/test_smoke_rename.py | 119 +++++ tests/unit/test_smtp_plugin.py | 48 +- tests/unit/test_socket_plugin.py | 32 +- tests/unit/test_source_id_namespace.py | 57 ++ tests/unit/test_spy_mode.py | 34 +- tests/unit/test_ssh_plugin.py | 284 +++++----- tests/unit/test_state_machine_plugin.py | 14 +- tests/unit/test_subprocess_plugin.py | 74 +-- tests/unit/test_timeline.py | 12 +- tests/unit/test_verifier.py | 48 +- tests/unit/test_websocket_plugin.py | 64 +-- tests/unit/test_wildcard_detection.py | 36 +- 235 files changed, 5623 insertions(+), 5234 deletions(-) create mode 100755 scripts/preflight.sh delete mode 100644 src/bigfoot/__init__.pyi delete mode 100644 src/bigfoot/_config.py rename src/{bigfoot => tripwire}/__init__.py (77%) create mode 100644 src/tripwire/__init__.pyi rename src/{bigfoot => tripwire}/_base_plugin.py (92%) rename src/{bigfoot => tripwire}/_compat.py (100%) create mode 100644 src/tripwire/_config.py rename src/{bigfoot => tripwire}/_context.py (80%) rename src/{bigfoot => tripwire}/_context_propagation.py (98%) rename src/{bigfoot => tripwire}/_errors.py (81%) rename src/{bigfoot => tripwire}/_firewall.py (95%) rename src/{bigfoot => tripwire}/_firewall_request.py (100%) rename src/{bigfoot => tripwire}/_glob.py (96%) rename src/{bigfoot => tripwire}/_guard.py (84%) rename src/{bigfoot => tripwire}/_match.py (96%) rename src/{bigfoot => tripwire}/_mock_plugin.py (94%) rename src/{bigfoot => tripwire}/_normalize.py (100%) rename src/{bigfoot => tripwire}/_patching.py (97%) rename src/{bigfoot => tripwire}/_path_resolution.py (100%) rename src/{bigfoot => tripwire}/_recording.py (100%) rename src/{bigfoot => tripwire}/_registry.py (70%) rename src/{bigfoot => tripwire}/_state_machine_plugin.py (98%) rename src/{bigfoot => tripwire}/_timeline.py (92%) rename src/{bigfoot => tripwire}/_verifier.py (91%) rename src/{bigfoot => tripwire}/plugins/__init__.py (100%) rename src/{bigfoot => tripwire}/plugins/async_subprocess_plugin.py (87%) rename src/{bigfoot => tripwire}/plugins/asyncpg_plugin.py (92%) rename src/{bigfoot => tripwire}/plugins/boto3_plugin.py (94%) rename src/{bigfoot => tripwire}/plugins/celery_plugin.py (94%) rename src/{bigfoot => tripwire}/plugins/crypto_plugin.py (94%) rename src/{bigfoot => tripwire}/plugins/database_plugin.py (92%) rename src/{bigfoot => tripwire}/plugins/dns_plugin.py (94%) rename src/{bigfoot => tripwire}/plugins/elasticsearch_plugin.py (93%) rename src/{bigfoot => tripwire}/plugins/file_io_plugin.py (95%) rename src/{bigfoot => tripwire}/plugins/grpc_plugin.py (95%) rename src/{bigfoot => tripwire}/plugins/http.py (94%) rename src/{bigfoot => tripwire}/plugins/jwt_plugin.py (93%) rename src/{bigfoot => tripwire}/plugins/logging_plugin.py (94%) rename src/{bigfoot => tripwire}/plugins/mcp_plugin.py (97%) rename src/{bigfoot => tripwire}/plugins/memcache_plugin.py (93%) rename src/{bigfoot => tripwire}/plugins/mongo_plugin.py (96%) rename src/{bigfoot => tripwire}/plugins/native_plugin.py (96%) rename src/{bigfoot => tripwire}/plugins/pika_plugin.py (93%) rename src/{bigfoot => tripwire}/plugins/popen_plugin.py (90%) rename src/{bigfoot => tripwire}/plugins/psycopg2_plugin.py (93%) rename src/{bigfoot => tripwire}/plugins/redis_plugin.py (94%) rename src/{bigfoot => tripwire}/plugins/smtp_plugin.py (92%) rename src/{bigfoot => tripwire}/plugins/socket_plugin.py (94%) rename src/{bigfoot => tripwire}/plugins/ssh_plugin.py (93%) rename src/{bigfoot => tripwire}/plugins/subprocess.py (93%) rename src/{bigfoot => tripwire}/plugins/websocket_plugin.py (92%) rename src/{bigfoot => tripwire}/py.typed (100%) rename src/{bigfoot => tripwire}/pytest_plugin.py (82%) create mode 100644 tests/integration/test_default_guard_error.py create mode 100644 tests/unit/test_bigfoot_migration_error.py create mode 100644 tests/unit/test_default_guard_error.py create mode 100644 tests/unit/test_smoke_rename.py create mode 100644 tests/unit/test_source_id_namespace.py diff --git a/.claude/skills/adding-plugins/SKILL.md b/.claude/skills/adding-plugins/SKILL.md index 2197960..c9c5924 100644 --- a/.claude/skills/adding-plugins/SKILL.md +++ b/.claude/skills/adding-plugins/SKILL.md @@ -1,10 +1,10 @@ -# Adding Plugins to bigfoot +# Adding Plugins to tripwire -Use when: the user wants to create a new bigfoot plugin, says "add a plugin", "write a plugin", "I want a [X] plugin", or describes a library/service they want bigfoot to intercept. +Use when: the user wants to create a new tripwire plugin, says "add a plugin", "write a plugin", "I want a [X] plugin", or describes a library/service they want tripwire to intercept. ## Overview -This skill guides the complete lifecycle of adding a new plugin to bigfoot: discovery, architecture classification, TDD implementation, integration (registry, proxy, `__init__.py`), documentation, examples, and README updates. It works standalone or integrates with the `develop` skill if available. +This skill guides the complete lifecycle of adding a new plugin to tripwire: discovery, architecture classification, TDD implementation, integration (registry, proxy, `__init__.py`), documentation, examples, and README updates. It works standalone or integrates with the `develop` skill if available. --- @@ -52,7 +52,7 @@ If StateMachinePlugin was chosen: ### 1.5 Integration Context Ask: -- **Does this library make calls that another bigfoot plugin already intercepts?** (e.g., boto3 uses HTTP, elasticsearch uses HTTP, gRPC uses HTTP) +- **Does this library make calls that another tripwire plugin already intercepts?** (e.g., boto3 uses HTTP, elasticsearch uses HTTP, gRPC uses HTTP) - If yes, document the layering. Users typically disable the lower-level plugin. - **Should this plugin be default-enabled?** Most are. Set `default_enabled=False` only for plugins that are too broad (file I/O) or too specialized (ctypes/cffi). - **What pyproject.toml extra name should it use?** Usually matches the registry name. Check existing extras in `pyproject.toml` for conventions. @@ -83,7 +83,7 @@ Plugin Name: [name] Registry Name: [e.g., "redis", "mongo", "pika"] Plugin Class: [e.g., "RedisPlugin", "MongoPlugin"] Base Class: [BasePlugin | StateMachinePlugin] -File: src/bigfoot/plugins/[name]_plugin.py +File: src/tripwire/plugins/[name]_plugin.py Test File: tests/unit/test_[name]_plugin.py Import Name: [Python import, e.g., "redis", "pymongo"] @@ -195,15 +195,15 @@ from __future__ import annotations import pytest -from bigfoot._context import _current_test_verifier -from bigfoot._errors import InteractionMismatchError, UnmockedInteractionError -from bigfoot._verifier import StrictVerifier +from tripwire._context import _current_test_verifier +from tripwire._errors import InteractionMismatchError, UnmockedInteractionError +from tripwire._verifier import StrictVerifier -# Import the library directly -- all optional deps are in bigfoot[dev]. +# Import the library directly -- all optional deps are in tripwire[dev]. # Never use pytest.importorskip (green mirage). import [lib] -from bigfoot.plugins.[name]_plugin import ( +from tripwire.plugins.[name]_plugin import ( _[LIB]_AVAILABLE, [Name]MockConfig, [Name]Plugin, @@ -245,12 +245,12 @@ def clean_plugin_counts() -> None: Run the test file and confirm all tests fail (no implementation yet): ```bash -cd /Users/elijahrutschman/Development/bigfoot && uv run pytest tests/unit/test_[name]_plugin.py -v +cd /Users/elijahrutschman/Development/tripwire && uv run pytest tests/unit/test_[name]_plugin.py -v ``` ### 3.3 Write Plugin Implementation -Create `src/bigfoot/plugins/[name]_plugin.py`. +Create `src/tripwire/plugins/[name]_plugin.py`. **BasePlugin implementation structure:** @@ -265,13 +265,13 @@ from collections import deque from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, ClassVar -from bigfoot._base_plugin import BasePlugin -from bigfoot._context import _get_verifier_or_raise -from bigfoot._errors import UnmockedInteractionError -from bigfoot._timeline import Interaction +from tripwire._base_plugin import BasePlugin +from tripwire._context import _get_verifier_or_raise +from tripwire._errors import UnmockedInteractionError +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # Optional dependency guard try: @@ -387,12 +387,12 @@ class [Name]Plugin(BasePlugin): Update these files to register the new plugin: -**`src/bigfoot/_registry.py`** -- Add a `PluginEntry`: +**`src/tripwire/_registry.py`** -- Add a `PluginEntry`: ```python -PluginEntry("[name]", "bigfoot.plugins.[name]_plugin", "[Name]Plugin", "[availability_check]"), +PluginEntry("[name]", "tripwire.plugins.[name]_plugin", "[Name]Plugin", "[availability_check]"), ``` -**`src/bigfoot/__init__.py`** -- Add: +**`src/tripwire/__init__.py`** -- Add: 1. Import (with try/except for optional deps) 2. Proxy class (`_[Name]Proxy`) 3. Proxy singleton (`[name]_mock = _[Name]Proxy()`) @@ -412,12 +412,12 @@ And add to the `all` extra. ### 3.5 Run Tests ```bash -cd /Users/elijahrutschman/Development/bigfoot && uv run pytest tests/unit/test_[name]_plugin.py tests/unit/test_init.py -v +cd /Users/elijahrutschman/Development/tripwire && uv run pytest tests/unit/test_[name]_plugin.py tests/unit/test_init.py -v ``` Then full suite: ```bash -cd /Users/elijahrutschman/Development/bigfoot && uv run pytest tests/ -x +cd /Users/elijahrutschman/Development/tripwire && uv run pytest tests/ -x ``` --- @@ -466,7 +466,7 @@ If `auditing-green-mirage` skill is available, invoke it. Otherwise, verify: ### Gate 5: Full Test Suite ```bash -cd /Users/elijahrutschman/Development/bigfoot && uv run pytest tests/ -x +cd /Users/elijahrutschman/Development/tripwire && uv run pytest tests/ -x ``` ALL tests must pass. @@ -490,13 +490,13 @@ mock responses and assert exactly what your code sent. ## Installation ```bash -pip install bigfoot[[name]] +pip install tripwire[[name]] ``` ## Quick Start ```python -import bigfoot +import tripwire def my_function(): """Production code that uses [library].""" @@ -505,14 +505,14 @@ def my_function(): def test_my_function(): # 1. Register mocks - bigfoot.[name]_mock.mock_[operation]([args], returns=[value]) + tripwire.[name]_mock.mock_[operation]([args], returns=[value]) # 2. Run production code in sandbox - with bigfoot: + with tripwire: result = my_function() # 3. Assert what happened - bigfoot.[name]_mock.assert_[operation]([expected_fields]) + tripwire.[name]_mock.assert_[operation]([expected_fields]) assert result == [expected] ``` @@ -521,7 +521,7 @@ def test_my_function(): ### [operation_name] ```python -bigfoot.[name]_mock.mock_[operation]( +tripwire.[name]_mock.mock_[operation]( [params], returns=[value], raises=None, # optional: raise this exception instead @@ -536,14 +536,14 @@ bigfoot.[name]_mock.mock_[operation]( ### Typed Helpers ```python -bigfoot.[name]_mock.assert_[operation]([params]) +tripwire.[name]_mock.assert_[operation]([params]) ``` ### Generic Assert ```python -bigfoot.assert_interaction( - bigfoot.[name]_mock.sentinel.[operation], +tripwire.assert_interaction( + tripwire.[name]_mock.sentinel.[operation], [field]=[value], ) ``` @@ -551,7 +551,7 @@ bigfoot.assert_interaction( ## Exception Simulation ```python -bigfoot.[name]_mock.mock_[operation]( +tripwire.[name]_mock.mock_[operation]( [params], returns=None, raises=[LibraryError]("simulated failure"), @@ -576,7 +576,7 @@ Create `docs/reference/[name]-plugin.md`: ```markdown # [Name]Plugin API Reference -::: bigfoot.plugins.[name]_plugin.[Name]Plugin +::: tripwire.plugins.[name]_plugin.[Name]Plugin options: show_source: false members: @@ -584,7 +584,7 @@ Create `docs/reference/[name]-plugin.md`: - assert_[operation] [... list all public methods] -::: bigfoot.plugins.[name]_plugin.[Name]MockConfig +::: tripwire.plugins.[name]_plugin.[Name]MockConfig options: show_source: false ``` @@ -632,8 +632,8 @@ Before marking the plugin as complete, verify ALL items: - [ ] Tests written first (TDD) - [ ] Tests cover all required categories (9+ base, additional for specific architectures) - [ ] Plugin implementation complete -- [ ] `src/bigfoot/_registry.py` updated with `PluginEntry` -- [ ] `src/bigfoot/__init__.py` updated (import, proxy class, singleton, `__all__`) +- [ ] `src/tripwire/_registry.py` updated with `PluginEntry` +- [ ] `src/tripwire/__init__.py` updated (import, proxy class, singleton, `__all__`) - [ ] `tests/unit/test_init.py` updated - [ ] `pyproject.toml` updated (optional dependency extra, added to `all` extra) - [ ] All quality gates passed (completeness, code review, fact-check, green mirage, tests) @@ -669,7 +669,7 @@ When building a new plugin, use the closest existing plugin as a template: | Socket-level | SocketPlugin | `socket_plugin.py` | | WebSocket | WebSocketPlugin | `websocket_plugin.py` | -## Reference: bigfoot Invariants +## Reference: tripwire Invariants These rules are inviolable. Every plugin must follow them: diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e2a1da2..227220e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ name: Bug Report -description: Report a bug in bigfoot +description: Report a bug in tripwire labels: ["bug"] body: - type: textarea @@ -22,9 +22,9 @@ body: required: true - type: input - id: bigfoot-version + id: tripwire-version attributes: - label: bigfoot version + label: tripwire version placeholder: "0.6.0" validations: required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11aef3b..2d1c865 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: run: ruff check src/ tests/ - name: Mypy - run: mypy src/bigfoot/ --strict + run: mypy src/tripwire/ --strict test: uses: ./.github/workflows/test.yml diff --git a/AGENTS.md b/AGENTS.md index cda1c14..bb91e00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# bigfoot - Agent Instructions +# tripwire - Agent Instructions ## Build & Test Commands @@ -13,19 +13,19 @@ uv run mkdocs serve # Serve docs locally at localhost:8000 ## Architecture Overview -bigfoot is a deterministic test interaction auditor for Python. It intercepts external calls (HTTP, DB, subprocess, etc.) via monkeypatching and enforces three guarantees: every call must be pre-authorized (mocked), every recorded interaction must be explicitly asserted, and every registered mock must be triggered. +tripwire is a deterministic test interaction auditor for Python. It intercepts external calls (HTTP, DB, subprocess, etc.) via monkeypatching and enforces three guarantees: every call must be pre-authorized (mocked), every recorded interaction must be explicitly asserted, and every registered mock must be triggered. ### Key Modules | Path | Purpose | |------|---------| -| `src/bigfoot/_verifier.py` | `StrictVerifier` - the core orchestrator | -| `src/bigfoot/_base_plugin.py` | `BasePlugin` ABC for stateless intercept plugins | -| `src/bigfoot/_state_machine_plugin.py` | `StateMachinePlugin` ABC for lifecycle plugins | -| `src/bigfoot/_registry.py` | `PLUGIN_REGISTRY` with `PluginEntry` dataclasses | -| `src/bigfoot/__init__.py` | Proxy singletons, `__all__` exports | -| `src/bigfoot/plugins/` | All plugin implementations | -| `src/bigfoot/pytest_plugin.py` | Auto-use fixture for pytest integration | +| `src/tripwire/_verifier.py` | `StrictVerifier` - the core orchestrator | +| `src/tripwire/_base_plugin.py` | `BasePlugin` ABC for stateless intercept plugins | +| `src/tripwire/_state_machine_plugin.py` | `StateMachinePlugin` ABC for lifecycle plugins | +| `src/tripwire/_registry.py` | `PLUGIN_REGISTRY` with `PluginEntry` dataclasses | +| `src/tripwire/__init__.py` | Proxy singletons, `__all__` exports | +| `src/tripwire/plugins/` | All plugin implementations | +| `src/tripwire/pytest_plugin.py` | Auto-use fixture for pytest integration | ### Plugin Patterns @@ -41,13 +41,13 @@ bigfoot is a deterministic test interaction auditor for Python. It intercepts ex ## Guard Mode -Guard mode is enabled by default (`[tool.bigfoot] guard = true`). It installs I/O plugin interceptors at session startup and blocks any real external call that happens outside a sandbox. +Guard mode is enabled by default (`[tool.tripwire] guard = true`). It installs I/O plugin interceptors at session startup and blocks any real external call that happens outside a sandbox. -- Tests that need real network access (e.g., boto3 setup making DNS/socket calls) should use `@pytest.mark.allow("dns", "socket")` or `with bigfoot.allow("dns", "socket"):`. -- To narrow the allowlist and re-guard specific plugins, use `@pytest.mark.deny(...)` or `with bigfoot.deny(...)`. Deny removes plugins from the current allowlist; it nests and restores on exit. +- Tests that need real network access (e.g., boto3 setup making DNS/socket calls) should use `@pytest.mark.allow("dns", "socket")` or `with tripwire.allow("dns", "socket"):`. +- To narrow the allowlist and re-guard specific plugins, use `@pytest.mark.deny(...)` or `with tripwire.deny(...)`. Deny removes plugins from the current allowlist; it nests and restores on exit. - Non-I/O plugins must set `supports_guard: ClassVar[bool] = False` (e.g., LoggingPlugin, JwtPlugin, CryptoPlugin, CeleryPlugin, MockPlugin). - Guard-eligible interceptors must handle `_GuardPassThrough` (catch it and call the original function). -- Allowed calls are invisible to bigfoot and are not recorded on the timeline. +- Allowed calls are invisible to tripwire and are not recorded on the timeline. ## Testable Documentation Examples @@ -57,8 +57,8 @@ All code examples shown in plugin guide documentation MUST be runnable and teste 1. **Example files** live in `examples/{name}/` with: - `__init__.py` (empty package marker) - - `app.py` (production code - the function under test, NO bigfoot imports) - - `test_app.py` (bigfoot test following the standard pattern) + - `app.py` (production code - the function under test, NO tripwire imports) + - `test_app.py` (tripwire test following the standard pattern) 2. **Guide pages** include these files via `pymdownx.snippets` in their "Full example" sections: ````markdown @@ -84,23 +84,23 @@ All code examples shown in plugin guide documentation MUST be runnable and teste ```python """Brief description.""" -import bigfoot +import tripwire from .app import production_function def test_something(): # Register mocks BEFORE the sandbox - bigfoot.plugin_proxy.mock_xxx(...) + tripwire.plugin_proxy.mock_xxx(...) - with bigfoot: + with tripwire: result = production_function(...) # Value assertions assert result == expected # Interaction assertions (AFTER the sandbox) - bigfoot.plugin_proxy.assert_xxx(...) + tripwire.plugin_proxy.assert_xxx(...) ``` ### Rules @@ -108,7 +108,7 @@ def test_something(): - **Never** put inline code examples in guide "Full example" sections. Always use snippet includes from `examples/`. - **Every** new plugin guide must have a corresponding `examples/` directory with working tests. - If a library generates DEBUG logs (boto3, pymongo, celery, etc.), add an autouse fixture to silence them so they don't interfere with LoggingPlugin. -- **Never** use `pytest.importorskip()` in tests. All optional dependencies are included in `bigfoot[dev]` and are expected to be installed. Skipping on missing imports is a green mirage. +- **Never** use `pytest.importorskip()` in tests. All optional dependencies are included in `tripwire[dev]` and are expected to be installed. Skipping on missing imports is a green mirage. - The `.claude/skills/adding-plugins/SKILL.md` skill automates the full plugin creation lifecycle including examples and docs. ## Selective Installation @@ -116,10 +116,10 @@ def test_something(): Core plugins (subprocess, logging, database, socket, file-io, native, dns) require no extras. Optional plugins need: ```bash -pip install bigfoot[all] # Everything -pip install bigfoot[http] # httpx, requests, urllib -pip install bigfoot[redis] # redis -pip install bigfoot[boto3] # botocore -pip install bigfoot[pymongo] # pymongo +pip install tripwire[all] # Everything +pip install tripwire[http] # httpx, requests, urllib +pip install tripwire[redis] # redis +pip install tripwire[boto3] # botocore +pip install tripwire[pymongo] # pymongo # ... see pyproject.toml [project.optional-dependencies] for full list ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index abe0718..e7d504a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.20.0] - 2026-04-26 + +### Changed +- **Breaking:** Renamed package `bigfoot` to `tripwire`. PyPI distribution, Python import name, public API symbols, exception class names, internal sentinels, pytest fixtures, pytest entry-point, and the `[tool.bigfoot]` config table all rename to `tripwire` / `[tool.tripwire]`. No deprecation alias. A `[tool.bigfoot]` section in pyproject.toml raises `ConfigMigrationError` with a clear rename hint. +- **Breaking:** Internal source-id sentinels restructured from underscore-flat (`bigfoot_subprocess_run`) to colon-namespaced `:` (e.g., `subprocess:run`, `httpx:get`, `socket:connect`). User-facing only via `GuardedCallError` messages and the `source_id` argument of plugin APIs. The `tripwire:` prefix is intentionally omitted because the namespace is implicit inside the tripwire package. +- **Breaking:** Default `[tool.tripwire] guard` flipped from `"warn"` to `"error"`. New projects fail loud on unmocked I/O outside a sandbox. To preserve prior behavior during legacy migration, set `guard = "warn"` explicitly. + +### Added +- `ConfigMigrationError` (subclass of `TripwireError`) raised when `[tool.bigfoot]` is present in pyproject.toml during config load. + ## [0.19.2] - 2026-04-08 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 0111a6d..13f9749 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,8 @@ -# bigfoot — Project Instructions +# tripwire — Project Instructions ## Certainty is the Contract -bigfoot's entire value proposition is certainty: when a test passes, you **know** exactly what happened — not just that nothing crashed. +tripwire's entire value proposition is certainty: when a test passes, you **know** exactly what happened — not just that nothing crashed. ### Full Assertion Certainty is Mandatory for All Plugins @@ -12,7 +12,7 @@ Every plugin MUST enforce that all recorded fields are asserted. This is non-neg ### Auto-Assert is PROHIBITED -**Auto-asserting interactions is not acceptable under any circumstances.** Auto-assert means calling `timeline.mark_asserted(interaction)` at the time an interaction is *recorded*, before the test author has explicitly called `assert_interaction()`. This defeats the entire purpose of bigfoot. +**Auto-asserting interactions is not acceptable under any circumstances.** Auto-assert means calling `timeline.mark_asserted(interaction)` at the time an interaction is *recorded*, before the test author has explicitly called `assert_interaction()`. This defeats the entire purpose of tripwire. Do **not** implement auto-assert. Do **not** suggest auto-assert as a design option. Do **not** add it back under any framing ("convenience", "ergonomic default", "opt-in certainty", etc.). It is wrong. It was already removed from StateMachinePlugin and RedisPlugin for this reason. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 85944d5..4c15c8b 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -37,7 +37,7 @@ This Code of Conduct applies within all community spaces, and also applies when ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at https://github.com/axiomantic/bigfoot/issues. All complaints will be reviewed and investigated promptly and fairly. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at https://github.com/axiomantic/tripwire/issues. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67524c7..7451331 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,13 +1,13 @@ -# Contributing to bigfoot +# Contributing to tripwire -Thanks for your interest in contributing to bigfoot! This guide will help you get started. +Thanks for your interest in contributing to tripwire! This guide will help you get started. ## Development Setup ```bash # Clone the repo -git clone https://github.com/axiomantic/bigfoot.git -cd bigfoot +git clone https://github.com/axiomantic/tripwire.git +cd tripwire # Create a virtual environment python -m venv .venv @@ -46,7 +46,7 @@ mypy src/ ## Making Changes 1. **Fork the repo** and create a branch from `main`. -2. **Write tests first.** bigfoot uses test-driven development. Every new feature or bug fix needs tests. +2. **Write tests first.** tripwire uses test-driven development. Every new feature or bug fix needs tests. 3. **Run the full test suite** before submitting. All tests must pass. 4. **Run linting and type checking.** Zero warnings required. 5. **Keep commits focused.** One logical change per commit. @@ -56,7 +56,7 @@ mypy src/ - Keep PR titles short and descriptive. - Include a summary of what changed and why in the PR description. - If your PR adds a new plugin, include: - - The plugin implementation in `src/bigfoot/plugins/` + - The plugin implementation in `src/tripwire/plugins/` - Unit tests in `tests/unit/` - A README section documenting the plugin - A mkdocs guide in `docs/guides/` @@ -64,7 +64,7 @@ mypy src/ ## Writing Plugins -See the [Writing Plugins](https://axiomantic.github.io/bigfoot/guides/writing-plugins/) guide for the full protocol. Key points: +See the [Writing Plugins](https://axiomantic.github.io/tripwire/guides/writing-plugins/) guide for the full protocol. Key points: - Subclass `BasePlugin` and implement all abstract methods. - Every field in `interaction.details` must be assertable. No silent fields. @@ -80,8 +80,8 @@ See the [Writing Plugins](https://axiomantic.github.io/bigfoot/guides/writing-pl ## Reporting Issues -- Use the [issue tracker](https://github.com/axiomantic/bigfoot/issues). -- For bugs, include: Python version, bigfoot version, minimal reproduction, and full traceback. +- Use the [issue tracker](https://github.com/axiomantic/tripwire/issues). +- For bugs, include: Python version, tripwire version, minimal reproduction, and full traceback. - For feature requests, describe the use case and why existing plugins don't cover it. ## License diff --git a/README.md b/README.md index 01b4296..38a812d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# bigfoot +# tripwire -[![CI](https://github.com/axiomantic/bigfoot/actions/workflows/ci.yml/badge.svg)](https://github.com/axiomantic/bigfoot/actions/workflows/ci.yml) -[![PyPI](https://img.shields.io/pypi/v/bigfoot)](https://pypi.org/project/bigfoot/) +[![CI](https://github.com/axiomantic/tripwire/actions/workflows/ci.yml/badge.svg)](https://github.com/axiomantic/tripwire/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/tripwire)](https://pypi.org/project/tripwire/) [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) @@ -12,15 +12,15 @@ You've had tests pass in CI and then watched the thing they were supposedly test This is what testing with `unittest.mock` is like. It gives you the tools to mock things, but it's entirely on you to remember to assert every call, verify every argument, and notice when production code starts making calls your tests don't account for. Most of the time, you won't. Not because you're careless, but because `unittest.mock` is designed around silence -- if you forget to check something, it has no way of telling you. -bigfoot replaces `unittest.mock` with mocking that actually enforces correctness. +tripwire replaces `unittest.mock` with mocking that actually enforces correctness. ```bash -pip install bigfoot[all] +pip install tripwire[all] ``` ## The three guarantees -bigfoot intercepts every external call your code makes and enforces three rules that `unittest.mock` leaves entirely to you: +tripwire intercepts every external call your code makes and enforces three rules that `unittest.mock` leaves entirely to you: 1. **Every call must be pre-authorized.** Code makes a call with no registered mock? `UnmockedInteractionError`, immediately. 2. **Every recorded interaction must be explicitly asserted.** Forget to assert an interaction? `UnassertedInteractionsError` at teardown. @@ -40,23 +40,23 @@ def test_payment(mock_post): # Called with wrong amount? Test passes. # Added a second HTTP call? Test passes. -# bigfoot -- every interaction is accounted for +# tripwire -- every interaction is accounted for def test_payment(): - bigfoot.http.mock_response("POST", "https://api.stripe.com/v1/charges", + tripwire.http.mock_response("POST", "https://api.stripe.com/v1/charges", json={"id": "ch_123"}, status=200) - with bigfoot: + with tripwire: result = create_charge(5000) # MUST assert this or test fails at teardown - bigfoot.http.assert_request( + tripwire.http.assert_request( "POST", "https://api.stripe.com/v1/charges", headers=IsInstance(dict), body='{"amount": 5000}', ).assert_response(200, IsInstance(dict), '{"id": "ch_123"}') assert result["id"] == "ch_123" ``` -| Scenario | unittest.mock | bigfoot | +| Scenario | unittest.mock | tripwire | |----------|---------------|---------| | Mocked function is never called | Passes silently | `UnusedMocksError` | | Wrong arguments | Only caught if you add `assert_called_with` | `InteractionMismatchError` | @@ -67,35 +67,35 @@ def test_payment(): ## Firewall mode -Firewall mode is on by default. When your test session starts, bigfoot installs interceptors that catch any real I/O call happening outside a sandbox. +Firewall mode is on by default. When your test session starts, tripwire installs interceptors that catch any real I/O call happening outside a sandbox. -In `"warn"` mode (the default), accidental calls emit a `GuardedCallWarning` and proceed normally, so your existing suite keeps working while showing you exactly which calls are unguarded. Set `guard = "error"` under `[tool.bigfoot]` in your `pyproject.toml` for strict enforcement. +In `"warn"` mode (the default), accidental calls emit a `GuardedCallWarning` and proceed normally, so your existing suite keeps working while showing you exactly which calls are unguarded. Set `guard = "error"` under `[tool.tripwire]` in your `pyproject.toml` for strict enforcement. ```python -from bigfoot import M +from tripwire import M # Selectively permit real calls within a scope -with bigfoot.allow("dns", "socket"): +with tripwire.allow("dns", "socket"): ... # Or via marker for an entire test @pytest.mark.allow("dns", "socket") # Granular patterns -with bigfoot.allow(M(protocol="http", host="*.example.com")): +with tripwire.allow(M(protocol="http", host="*.example.com")): ... # Set a ceiling that inner blocks cannot widen -with bigfoot.restrict("http", "subprocess"): +with tripwire.restrict("http", "subprocess"): ... ``` -Configure project-wide allow/deny rules in `[tool.bigfoot.firewall]` in your `pyproject.toml`. +Configure project-wide allow/deny rules in `[tool.tripwire.firewall]` in your `pyproject.toml`. ## Quick Start ```python -import bigfoot +import tripwire from dirty_equals import IsInstance def create_charge(amount): @@ -106,23 +106,23 @@ def create_charge(amount): return response.json() def test_payment_flow(): - bigfoot.http.mock_response("POST", "https://api.stripe.com/v1/charges", + tripwire.http.mock_response("POST", "https://api.stripe.com/v1/charges", json={"id": "ch_123"}, status=200) - with bigfoot: + with tripwire: result = create_charge(5000) - bigfoot.http.assert_request( + tripwire.http.assert_request( "POST", "https://api.stripe.com/v1/charges", headers=IsInstance(dict), body='{"amount": 5000}', ).assert_response(200, IsInstance(dict), '{"id": "ch_123"}') assert result["id"] == "ch_123" ``` -If you forget the `assert_request()` call, bigfoot fails the test at teardown: +If you forget the `assert_request()` call, tripwire fails the test at teardown: ``` -E bigfoot._errors.UnassertedInteractionsError: 1 interaction was not asserted. +E tripwire._errors.UnassertedInteractionsError: 1 interaction was not asserted. E E http.assert_request( E "POST", @@ -143,21 +143,21 @@ The error output includes every field with its actual value, so you can usually ## How it works 1. **Register mocks** before the sandbox (`mock_response`, `mock_run`, `returns`, etc.) -2. **Open the sandbox** with `with bigfoot:` (or `async with bigfoot:`) +2. **Open the sandbox** with `with tripwire:` (or `async with tripwire:`) 3. **Code runs normally** inside the sandbox, but external calls are intercepted and recorded 4. **Assert interactions** after the sandbox closes, in order 5. **`verify_all()`** runs automatically at test teardown via the pytest plugin -Since bigfoot uses a module-level API, there are no fixtures to set up or inject. You just import it. +Since tripwire uses a module-level API, there are no fixtures to set up or inject. You just import it. ## Coming from unittest.mock ### Concepts mapping -| unittest.mock | bigfoot equivalent | Notes | +| unittest.mock | tripwire equivalent | Notes | |---------------|-------------------|-------| -| `@patch("module.Class")` | `bigfoot.mock("module:Class")` | Colon-separated import path | -| `@patch.object(obj, "attr")` | `bigfoot.mock.object(obj, "attr")` | Same idea, stricter enforcement | +| `@patch("module.Class")` | `tripwire.mock("module:Class")` | Colon-separated import path | +| `@patch.object(obj, "attr")` | `tripwire.mock.object(obj, "attr")` | Same idea, stricter enforcement | | `MagicMock()` | Plugin-specific mocks | `mock_response`, `mock_run`, `mock_command`, etc. | | `mock.return_value = X` | `.returns(X)` | Explicit, typed return values | | `mock.side_effect = Exception` | `mock_error(..., raises=Exception)` | Explicit error mocking | @@ -183,18 +183,18 @@ def test_fetch_user(mock_get): mock_get.assert_called_once_with("https://api.example.com/users/42") assert user["name"] == "Alice" -# AFTER: bigfoot +# AFTER: tripwire from dirty_equals import IsInstance def test_fetch_user(): - bigfoot.http.mock_response( + tripwire.http.mock_response( "GET", "https://api.example.com/users/42", json={"name": "Alice"}, status=200, ) - with bigfoot: + with tripwire: user = fetch_user(42) - bigfoot.http.assert_request( + tripwire.http.assert_request( "GET", "https://api.example.com/users/42", headers=IsInstance(dict), body=None, ).assert_response(200, IsInstance(dict), '{"name": "Alice"}') @@ -214,16 +214,16 @@ def test_deploy(mock_run): result = deploy("prod") mock_run.assert_called_once() -# AFTER: bigfoot +# AFTER: tripwire def test_deploy(): - bigfoot.subprocess_mock.mock_run( + tripwire.subprocess_mock.mock_run( ["kubectl", "apply", "-f", "prod.yaml"], returncode=0, stdout="deployed", ) - with bigfoot: + with tripwire: result = deploy("prod") - bigfoot.subprocess_mock.assert_run( + tripwire.subprocess_mock.assert_run( ["kubectl", "apply", "-f", "prod.yaml"], returncode=0, stdout="deployed", ) @@ -241,12 +241,12 @@ def test_cached_lookup(mock_cache): result = lookup("key") mock_cache.get.assert_called_once_with("key") -# AFTER: bigfoot +# AFTER: tripwire def test_cached_lookup(): - cache_mock = bigfoot.mock("myapp.services:cache") + cache_mock = tripwire.mock("myapp.services:cache") cache_mock.get.returns("cached_value") - with bigfoot: + with tripwire: result = lookup("key") cache_mock.get.assert_call(args=("key",), kwargs={}, returned="cached_value") @@ -254,39 +254,39 @@ def test_cached_lookup(): ### Incremental adoption -You do not have to migrate your entire test suite at once. bigfoot and `unittest.mock` can coexist in the same project: +You do not have to migrate your entire test suite at once. tripwire and `unittest.mock` can coexist in the same project: -1. **Start with guard mode.** Install bigfoot and run your suite. Guard mode (default `"warn"`) will show you every real I/O call across all tests without breaking anything. -2. **Migrate test by test.** Pick tests that touch HTTP, subprocess, or database calls first -- these benefit most from bigfoot's strict enforcement. +1. **Start with guard mode.** Install tripwire and run your suite. Guard mode (default `"warn"`) will show you every real I/O call across all tests without breaking anything. +2. **Migrate test by test.** Pick tests that touch HTTP, subprocess, or database calls first -- these benefit most from tripwire's strict enforcement. 3. **Escalate to strict guard mode.** Once coverage is high, set `guard = "error"` in `pyproject.toml` to catch any remaining leaks. ## Plugins -bigfoot ships with 27 plugins covering the most common external dependencies: +tripwire ships with 27 plugins covering the most common external dependencies: | Category | Plugins | Intercepts | |----------|---------|------------| -| **General** | [MockPlugin](https://axiomantic.github.io/bigfoot/guides/mock-plugin/), [LoggingPlugin](https://axiomantic.github.io/bigfoot/guides/logging-plugin/) | Named mock proxies, `logging` module | -| **HTTP** | [HttpPlugin](https://axiomantic.github.io/bigfoot/guides/http-plugin/) | `httpx`, `requests`, `urllib`, `aiohttp` | -| **Subprocess** | [SubprocessPlugin](https://axiomantic.github.io/bigfoot/guides/subprocess-plugin/), [PopenPlugin](https://axiomantic.github.io/bigfoot/guides/popen-plugin/), [AsyncSubprocessPlugin](https://axiomantic.github.io/bigfoot/guides/async-subprocess-plugin/) | `subprocess.run`, `shutil.which`, `Popen`, `asyncio.create_subprocess_*` | -| **Database** | [DatabasePlugin](https://axiomantic.github.io/bigfoot/guides/database-plugin/), [Psycopg2Plugin](https://axiomantic.github.io/bigfoot/guides/psycopg2-plugin/), [AsyncpgPlugin](https://axiomantic.github.io/bigfoot/guides/asyncpg-plugin/), [MongoPlugin](https://axiomantic.github.io/bigfoot/guides/mongo-plugin/), [ElasticsearchPlugin](https://axiomantic.github.io/bigfoot/guides/elasticsearch-plugin/) | `sqlite3`, `psycopg2`, `asyncpg`, `pymongo`, `elasticsearch` | -| **Cache** | [RedisPlugin](https://axiomantic.github.io/bigfoot/guides/redis-plugin/), [MemcachePlugin](https://axiomantic.github.io/bigfoot/guides/memcache-plugin/) | `redis`, `pymemcache` | -| **Network** | [SmtpPlugin](https://axiomantic.github.io/bigfoot/guides/smtp-plugin/), [SocketPlugin](https://axiomantic.github.io/bigfoot/guides/socket-plugin/), [WebSocket](https://axiomantic.github.io/bigfoot/guides/websocket-plugin/), [DnsPlugin](https://axiomantic.github.io/bigfoot/guides/dns-plugin/), [SshPlugin](https://axiomantic.github.io/bigfoot/guides/ssh-plugin/), [GrpcPlugin](https://axiomantic.github.io/bigfoot/guides/grpc-plugin/) | `smtplib`, `socket`, `websockets`, `websocket-client`, DNS resolution, `paramiko`, `grpcio` | -| **Cloud & Messaging** | [Boto3Plugin](https://axiomantic.github.io/bigfoot/guides/boto3-plugin/), [CeleryPlugin](https://axiomantic.github.io/bigfoot/guides/celery-plugin/), [PikaPlugin](https://axiomantic.github.io/bigfoot/guides/pika-plugin/) | `boto3` (AWS), `celery` tasks, `pika` (RabbitMQ) | -| **Crypto & Auth** | [JwtPlugin](https://axiomantic.github.io/bigfoot/guides/jwt-plugin/), [CryptoPlugin](https://axiomantic.github.io/bigfoot/guides/crypto-plugin/) | `PyJWT`, `cryptography` | -| **System** | [FileIoPlugin](https://axiomantic.github.io/bigfoot/guides/file-io-plugin/), [NativePlugin](https://axiomantic.github.io/bigfoot/guides/native-plugin/) | `open`, `pathlib`, `os`; `ctypes`, `cffi` | +| **General** | [MockPlugin](https://axiomantic.github.io/tripwire/guides/mock-plugin/), [LoggingPlugin](https://axiomantic.github.io/tripwire/guides/logging-plugin/) | Named mock proxies, `logging` module | +| **HTTP** | [HttpPlugin](https://axiomantic.github.io/tripwire/guides/http-plugin/) | `httpx`, `requests`, `urllib`, `aiohttp` | +| **Subprocess** | [SubprocessPlugin](https://axiomantic.github.io/tripwire/guides/subprocess-plugin/), [PopenPlugin](https://axiomantic.github.io/tripwire/guides/popen-plugin/), [AsyncSubprocessPlugin](https://axiomantic.github.io/tripwire/guides/async-subprocess-plugin/) | `subprocess.run`, `shutil.which`, `Popen`, `asyncio.create_subprocess_*` | +| **Database** | [DatabasePlugin](https://axiomantic.github.io/tripwire/guides/database-plugin/), [Psycopg2Plugin](https://axiomantic.github.io/tripwire/guides/psycopg2-plugin/), [AsyncpgPlugin](https://axiomantic.github.io/tripwire/guides/asyncpg-plugin/), [MongoPlugin](https://axiomantic.github.io/tripwire/guides/mongo-plugin/), [ElasticsearchPlugin](https://axiomantic.github.io/tripwire/guides/elasticsearch-plugin/) | `sqlite3`, `psycopg2`, `asyncpg`, `pymongo`, `elasticsearch` | +| **Cache** | [RedisPlugin](https://axiomantic.github.io/tripwire/guides/redis-plugin/), [MemcachePlugin](https://axiomantic.github.io/tripwire/guides/memcache-plugin/) | `redis`, `pymemcache` | +| **Network** | [SmtpPlugin](https://axiomantic.github.io/tripwire/guides/smtp-plugin/), [SocketPlugin](https://axiomantic.github.io/tripwire/guides/socket-plugin/), [WebSocket](https://axiomantic.github.io/tripwire/guides/websocket-plugin/), [DnsPlugin](https://axiomantic.github.io/tripwire/guides/dns-plugin/), [SshPlugin](https://axiomantic.github.io/tripwire/guides/ssh-plugin/), [GrpcPlugin](https://axiomantic.github.io/tripwire/guides/grpc-plugin/) | `smtplib`, `socket`, `websockets`, `websocket-client`, DNS resolution, `paramiko`, `grpcio` | +| **Cloud & Messaging** | [Boto3Plugin](https://axiomantic.github.io/tripwire/guides/boto3-plugin/), [CeleryPlugin](https://axiomantic.github.io/tripwire/guides/celery-plugin/), [PikaPlugin](https://axiomantic.github.io/tripwire/guides/pika-plugin/) | `boto3` (AWS), `celery` tasks, `pika` (RabbitMQ) | +| **Crypto & Auth** | [JwtPlugin](https://axiomantic.github.io/tripwire/guides/jwt-plugin/), [CryptoPlugin](https://axiomantic.github.io/tripwire/guides/crypto-plugin/) | `PyJWT`, `cryptography` | +| **System** | [FileIoPlugin](https://axiomantic.github.io/tripwire/guides/file-io-plugin/), [NativePlugin](https://axiomantic.github.io/tripwire/guides/native-plugin/) | `open`, `pathlib`, `os`; `ctypes`, `cffi` |
Plugin examples **Subprocess** ```python -bigfoot.subprocess_mock.mock_run(["git", "pull"], returncode=0, stdout="Up to date.\n") +tripwire.subprocess_mock.mock_run(["git", "pull"], returncode=0, stdout="Up to date.\n") ``` **Database (sqlite3)** ```python -bigfoot.db_mock.new_session() \ +tripwire.db_mock.new_session() \ .expect("connect", returns=None) \ .expect("execute", returns=[]) \ .expect("commit", returns=None) \ @@ -295,22 +295,22 @@ bigfoot.db_mock.new_session() \ **Redis** ```python -bigfoot.redis_mock.mock_command("GET", returns=b"cached_value") +tripwire.redis_mock.mock_command("GET", returns=b"cached_value") ``` **MongoDB** ```python -bigfoot.mongo_mock.mock_operation("find_one", returns={"_id": "abc", "name": "Alice"}) +tripwire.mongo_mock.mock_operation("find_one", returns={"_id": "abc", "name": "Alice"}) ``` **AWS (boto3)** ```python -bigfoot.boto3_mock.mock_api_call("s3", "GetObject", returns={"Body": b"file contents"}) +tripwire.boto3_mock.mock_api_call("s3", "GetObject", returns={"Body": b"file contents"}) ``` **RabbitMQ (pika)** ```python -bigfoot.pika_mock.new_session() \ +tripwire.pika_mock.new_session() \ .expect("connect", returns=None) \ .expect("channel", returns=None) \ .expect("publish", returns=None) \ @@ -319,7 +319,7 @@ bigfoot.pika_mock.new_session() \ **SSH (paramiko)** ```python -bigfoot.ssh_mock.new_session() \ +tripwire.ssh_mock.new_session() \ .expect("connect", returns=None) \ .expect("exec_command", returns=(b"", b"output\n", b"")) \ .expect("close", returns=None) @@ -327,7 +327,7 @@ bigfoot.ssh_mock.new_session() \ **SMTP** ```python -bigfoot.smtp_mock.new_session() \ +tripwire.smtp_mock.new_session() \ .expect("connect", returns=(220, b"OK")) \ .expect("ehlo", returns=(250, b"OK")) \ .expect("sendmail", returns={}) \ @@ -336,12 +336,12 @@ bigfoot.smtp_mock.new_session() \ **Logging** ```python -bigfoot.log_mock.assert_info("User logged in", "myapp") +tripwire.log_mock.assert_info("User logged in", "myapp") ``` **Mock (general)** ```python -svc = bigfoot.mock("myapp.payments:PaymentService") +svc = tripwire.mock("myapp.payments:PaymentService") svc.charge.returns({"status": "ok"}) ``` @@ -352,10 +352,10 @@ svc.charge.returns({"status": "ok"}) **Concurrent assertions** -- relax FIFO ordering for parallel requests: ```python -with bigfoot.in_any_order(): - bigfoot.http.assert_request(method="GET", url=".../a", headers=IsInstance(dict), body=None, +with tripwire.in_any_order(): + tripwire.http.assert_request(method="GET", url=".../a", headers=IsInstance(dict), body=None, require_response=False) - bigfoot.http.assert_request(method="GET", url=".../b", headers=IsInstance(dict), body=None, + tripwire.http.assert_request(method="GET", url=".../b", headers=IsInstance(dict), body=None, require_response=False) ``` @@ -363,21 +363,21 @@ with bigfoot.in_any_order(): ```python # Mock a module-level attribute -cache_mock = bigfoot.mock("myapp.services:cache") +cache_mock = tripwire.mock("myapp.services:cache") cache_mock.get.returns("cached_value") # Mock an attribute on a specific object -mock = bigfoot.mock.object(my_module, "service") +mock = tripwire.mock.object(my_module, "service") # Spy on real implementation -spy = bigfoot.spy("myapp.services:cache") +spy = tripwire.spy("myapp.services:cache") ``` **Context managers** -- sandbox activates all mocks and enforces assertions: ```python # Sandbox activates all mocks, enforces assertions -with bigfoot.sandbox(): +with tripwire.sandbox(): result = code_under_test() # Individual activation (no assertion enforcement) @@ -389,10 +389,10 @@ with cache_mock: ```python # Mock errors -bigfoot.http.mock_error("GET", url, raises=httpx.ConnectError("refused")) +tripwire.http.mock_error("GET", url, raises=httpx.ConnectError("refused")) # Assert errors -bigfoot.http.assert_request("GET", url, headers=..., body="", +tripwire.http.assert_request("GET", url, headers=..., body="", raised=IsInstance(httpx.ConnectError)) ``` @@ -406,48 +406,48 @@ spy.assert_call(args=("bad",), kwargs={}, raised=IsInstance(KeyError)) **Pass-through** -- delegate to the real service, still record and require assertion: ```python -bigfoot.http.pass_through("GET", url) +tripwire.http.pass_through("GET", url) ``` **Configuration** via `pyproject.toml`: ```toml -[tool.bigfoot.http] +[tool.tripwire.http] require_response = true # This is the default; set to false to opt out ``` -Per-call arguments override project-level settings. See the [configuration guide](https://axiomantic.github.io/bigfoot/guides/configuration/). +Per-call arguments override project-level settings. See the [configuration guide](https://axiomantic.github.io/tripwire/guides/configuration/). ## Selective Installation -`bigfoot[all]` installs everything. For a smaller footprint, pick only what you need: +`tripwire[all]` installs everything. For a smaller footprint, pick only what you need: ```bash -pip install bigfoot # Core plugins (no optional deps) -pip install bigfoot[http] # + httpx, requests, urllib -pip install bigfoot[aiohttp] # + aiohttp -pip install bigfoot[redis] # + Redis -pip install bigfoot[pymemcache] # + Memcached -pip install bigfoot[pymongo] # + MongoDB -pip install bigfoot[elasticsearch] # + Elasticsearch/OpenSearch -pip install bigfoot[psycopg2] # + PostgreSQL (psycopg2) -pip install bigfoot[asyncpg] # + PostgreSQL (asyncpg) -pip install bigfoot[boto3] # + AWS SDK -pip install bigfoot[pika] # + RabbitMQ -pip install bigfoot[celery] # + Celery tasks -pip install bigfoot[grpc] # + gRPC -pip install bigfoot[paramiko] # + SSH -pip install bigfoot[jwt] # + PyJWT -pip install bigfoot[crypto] # + cryptography -pip install bigfoot[cffi] # + cffi (C FFI) -pip install bigfoot[websockets] # + async WebSocket -pip install bigfoot[websocket-client] # + sync WebSocket -pip install bigfoot[matchers] # + dirty-equals matchers +pip install tripwire # Core plugins (no optional deps) +pip install tripwire[http] # + httpx, requests, urllib +pip install tripwire[aiohttp] # + aiohttp +pip install tripwire[redis] # + Redis +pip install tripwire[pymemcache] # + Memcached +pip install tripwire[pymongo] # + MongoDB +pip install tripwire[elasticsearch] # + Elasticsearch/OpenSearch +pip install tripwire[psycopg2] # + PostgreSQL (psycopg2) +pip install tripwire[asyncpg] # + PostgreSQL (asyncpg) +pip install tripwire[boto3] # + AWS SDK +pip install tripwire[pika] # + RabbitMQ +pip install tripwire[celery] # + Celery tasks +pip install tripwire[grpc] # + gRPC +pip install tripwire[paramiko] # + SSH +pip install tripwire[jwt] # + PyJWT +pip install tripwire[crypto] # + cryptography +pip install tripwire[cffi] # + cffi (C FFI) +pip install tripwire[websockets] # + async WebSocket +pip install tripwire[websocket-client] # + sync WebSocket +pip install tripwire[matchers] # + dirty-equals matchers ``` ## Documentation -Full API reference, plugin guides, and advanced usage: **[axiomantic.github.io/bigfoot](https://axiomantic.github.io/bigfoot/)** +Full API reference, plugin guides, and advanced usage: **[axiomantic.github.io/tripwire](https://axiomantic.github.io/tripwire/)** ## License diff --git a/SECURITY.md b/SECURITY.md index 30c011b..d031d52 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -9,9 +9,9 @@ ## Scope -bigfoot is a **testing library** that runs in development and CI environments. Its attack surface is narrower than production-facing software. Security issues relevant to this project include: +tripwire is a **testing library** that runs in development and CI environments. Its attack surface is narrower than production-facing software. Security issues relevant to this project include: -- Dependency vulnerabilities in bigfoot's direct dependencies +- Dependency vulnerabilities in tripwire's direct dependencies - Code execution through crafted test fixtures or plugin configurations - Information disclosure through error messages or recorded interactions - Supply chain integrity of published PyPI packages @@ -20,7 +20,7 @@ bigfoot is a **testing library** that runs in development and CI environments. I **Do not open a public GitHub issue for security vulnerabilities.** -Instead, use [GitHub's private security advisory feature](https://github.com/axiomantic/bigfoot/security/advisories/new) to report the issue confidentially. +Instead, use [GitHub's private security advisory feature](https://github.com/axiomantic/tripwire/security/advisories/new) to report the issue confidentially. Include: diff --git a/docs/guides/async-subprocess-plugin.md b/docs/guides/async-subprocess-plugin.md index f0195bc..df7a7d3 100644 --- a/docs/guides/async-subprocess-plugin.md +++ b/docs/guides/async-subprocess-plugin.md @@ -1,6 +1,6 @@ # AsyncSubprocessPlugin Guide -`AsyncSubprocessPlugin` intercepts `asyncio.create_subprocess_exec` and `asyncio.create_subprocess_shell` by replacing them with fake implementations that route process lifecycle through a session script. It is included in core bigfoot -- no extra required. +`AsyncSubprocessPlugin` intercepts `asyncio.create_subprocess_exec` and `asyncio.create_subprocess_shell` by replacing them with fake implementations that route process lifecycle through a session script. It is included in core tripwire -- no extra required. ## Relationship to PopenPlugin @@ -8,34 +8,34 @@ ## Setup -In pytest, access `AsyncSubprocessPlugin` through the `bigfoot.async_subprocess_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `AsyncSubprocessPlugin` through the `tripwire.async_subprocess_mock` proxy. It auto-creates the plugin for the current test on first use: ```python import asyncio -import bigfoot +import tripwire async def test_run_command(): - (bigfoot.async_subprocess_mock + (tripwire.async_subprocess_mock .new_session() .expect("spawn", returns=None) .expect("communicate", returns=(b"hello\n", b"", 0))) - with bigfoot: + with tripwire: proc = await asyncio.create_subprocess_exec("echo", "hello") stdout, stderr = await proc.communicate() assert stdout == b"hello\n" assert proc.returncode == 0 - bigfoot.async_subprocess_mock.assert_spawn(command=["echo", "hello"], stdin=None) - bigfoot.async_subprocess_mock.assert_communicate(input=None) + tripwire.async_subprocess_mock.assert_spawn(command=["echo", "hello"], stdin=None) + tripwire.async_subprocess_mock.assert_communicate(input=None) ``` For manual use outside pytest, construct `AsyncSubprocessPlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.async_subprocess_plugin import AsyncSubprocessPlugin +from tripwire import StrictVerifier +from tripwire.plugins.async_subprocess_plugin import AsyncSubprocessPlugin verifier = StrictVerifier() plugin = AsyncSubprocessPlugin(verifier) @@ -60,7 +60,7 @@ The `spawn` step fires automatically during `asyncio.create_subprocess_exec(...) Use `new_session()` to create a `SessionHandle` and chain `.expect()` calls to build the script: ```python -(bigfoot.async_subprocess_mock +(tripwire.async_subprocess_mock .new_session() .expect("spawn", returns=None) .expect("communicate", returns=(b"output", b"errors", 0))) @@ -85,7 +85,7 @@ Use `new_session()` to create a `SessionHandle` and chain `.expect()` calls to b ## Asserting interactions -Each step records an interaction on the timeline. Use the typed assertion helpers on `bigfoot.async_subprocess_mock`: +Each step records an interaction on the timeline. Use the typed assertion helpers on `tripwire.async_subprocess_mock`: ### `assert_spawn(*, command, stdin)` @@ -93,10 +93,10 @@ Asserts the next spawn interaction. Both `command` and `stdin` are required fiel ```python # For exec: -bigfoot.async_subprocess_mock.assert_spawn(command=["git", "status"], stdin=None) +tripwire.async_subprocess_mock.assert_spawn(command=["git", "status"], stdin=None) # For shell: -bigfoot.async_subprocess_mock.assert_spawn(command="ls -la | grep foo", stdin=None) +tripwire.async_subprocess_mock.assert_spawn(command="ls -la | grep foo", stdin=None) ``` ### `assert_communicate(*, input)` @@ -104,7 +104,7 @@ bigfoot.async_subprocess_mock.assert_spawn(command="ls -la | grep foo", stdin=No Asserts the next communicate interaction. The `input` field is required. ```python -bigfoot.async_subprocess_mock.assert_communicate(input=None) +tripwire.async_subprocess_mock.assert_communicate(input=None) ``` ### `assert_wait()` @@ -112,7 +112,7 @@ bigfoot.async_subprocess_mock.assert_communicate(input=None) Asserts the next wait interaction. No fields are required. ```python -bigfoot.async_subprocess_mock.assert_wait() +tripwire.async_subprocess_mock.assert_wait() ``` ## Full example @@ -131,10 +131,10 @@ bigfoot.async_subprocess_mock.assert_wait() ## ConflictError -At sandbox entry, `AsyncSubprocessPlugin` checks whether `asyncio.create_subprocess_exec` and `asyncio.create_subprocess_shell` have already been patched by another library. If either has been modified by a third party, bigfoot raises `ConflictError`: +At sandbox entry, `AsyncSubprocessPlugin` checks whether `asyncio.create_subprocess_exec` and `asyncio.create_subprocess_shell` have already been patched by another library. If either has been modified by a third party, tripwire raises `ConflictError`: ``` ConflictError: target='asyncio.create_subprocess_exec', patcher='unittest.mock' ``` -Nested bigfoot sandboxes use reference counting and do not conflict with each other. +Nested tripwire sandboxes use reference counting and do not conflict with each other. diff --git a/docs/guides/async.md b/docs/guides/async.md index 0728cda..c078d16 100644 --- a/docs/guides/async.md +++ b/docs/guides/async.md @@ -1,27 +1,27 @@ # Async Usage -bigfoot supports async tests natively. `bigfoot` and `bigfoot.in_any_order()` both implement `__aenter__` and `__aexit__`. +tripwire supports async tests natively. `tripwire` and `tripwire.in_any_order()` both implement `__aenter__` and `__aexit__`. -## async with bigfoot +## async with tripwire -Use `async with bigfoot:` in an async test function: +Use `async with tripwire:` in an async test function: ```python -import bigfoot +import tripwire import httpx async def test_async_http(): - bigfoot.http.mock_response("GET", "https://api.example.com/data", json={"ok": True}) + tripwire.http.mock_response("GET", "https://api.example.com/data", json={"ok": True}) - async with bigfoot: + async with tripwire: async with httpx.AsyncClient() as client: response = await client.get("https://api.example.com/data") assert response.json() == {"ok": True} - bigfoot.assert_interaction(bigfoot.http.request, method="GET", url="https://api.example.com/data") + tripwire.assert_interaction(tripwire.http.request, method="GET", url="https://api.example.com/data") ``` -`async with bigfoot:` is shorthand for `async with bigfoot.sandbox():`. Both return the active `StrictVerifier` from `__aenter__`. `bigfoot.sandbox()` is also available as the explicit form and returns a `SandboxContext` for cases where you need to pass the context manager around. +`async with tripwire:` is shorthand for `async with tripwire.sandbox():`. Both return the active `StrictVerifier` from `__aenter__`. `tripwire.sandbox()` is also available as the explicit form and returns a `SandboxContext` for cases where you need to pass the context manager around. The sync and async forms are equivalent. `SandboxContext._enter()` and `_exit()` are synchronous under the hood; the async wrapper simply delegates to them. @@ -30,7 +30,7 @@ The sync and async forms are equivalent. `SandboxContext._enter()` and `_exit()` The active verifier is stored in a `contextvars.ContextVar`. Each `asyncio.create_task()` call inherits a copy of the current context, so concurrent tasks see the correct verifier without interference: ```python -import bigfoot +import tripwire import asyncio, httpx async def fetch(url: str) -> dict: @@ -39,38 +39,38 @@ async def fetch(url: str) -> dict: return response.json() async def test_concurrent_requests(): - bigfoot.http.mock_response("GET", "https://api.example.com/a", json={"name": "a"}) - bigfoot.http.mock_response("GET", "https://api.example.com/b", json={"name": "b"}) + tripwire.http.mock_response("GET", "https://api.example.com/a", json={"name": "a"}) + tripwire.http.mock_response("GET", "https://api.example.com/b", json={"name": "b"}) - async with bigfoot: + async with tripwire: a, b = await asyncio.gather( asyncio.create_task(fetch("https://api.example.com/a")), asyncio.create_task(fetch("https://api.example.com/b")), ) - with bigfoot.in_any_order(): - bigfoot.assert_interaction(bigfoot.http.request, method="GET", url="https://api.example.com/a") - bigfoot.assert_interaction(bigfoot.http.request, method="GET", url="https://api.example.com/b") + with tripwire.in_any_order(): + tripwire.assert_interaction(tripwire.http.request, method="GET", url="https://api.example.com/a") + tripwire.assert_interaction(tripwire.http.request, method="GET", url="https://api.example.com/b") ``` -Because concurrent tasks may complete in any order, use `bigfoot.in_any_order()` when asserting interactions from concurrent work. +Because concurrent tasks may complete in any order, use `tripwire.in_any_order()` when asserting interactions from concurrent work. ## async with in_any_order `in_any_order()` also supports `async with`: ```python -async with bigfoot.in_any_order(): - bigfoot.assert_interaction(bigfoot.http.request, method="GET", url="https://api.example.com/a") - bigfoot.assert_interaction(bigfoot.http.request, method="GET", url="https://api.example.com/b") +async with tripwire.in_any_order(): + tripwire.assert_interaction(tripwire.http.request, method="GET", url="https://api.example.com/a") + tripwire.assert_interaction(tripwire.http.request, method="GET", url="https://api.example.com/b") ``` ## run_in_executor propagation -When `HttpPlugin` is active, bigfoot patches `asyncio.BaseEventLoop.run_in_executor` to copy the current `contextvars` context into the thread pool executor. This means HTTP calls made from a thread via `run_in_executor` are intercepted by the correct verifier: +When `HttpPlugin` is active, tripwire patches `asyncio.BaseEventLoop.run_in_executor` to copy the current `contextvars` context into the thread pool executor. This means HTTP calls made from a thread via `run_in_executor` are intercepted by the correct verifier: ```python -import bigfoot +import tripwire import asyncio, urllib.request async def fetch_in_thread(url: str) -> bytes: @@ -78,13 +78,13 @@ async def fetch_in_thread(url: str) -> bytes: return await loop.run_in_executor(None, lambda: urllib.request.urlopen(url).read()) async def test_thread_pool_interception(): - bigfoot.http.mock_response("GET", "https://api.example.com/data", body=b"hello") + tripwire.http.mock_response("GET", "https://api.example.com/data", body=b"hello") - async with bigfoot: + async with tripwire: data = await fetch_in_thread("https://api.example.com/data") assert data == b"hello" - bigfoot.assert_interaction(bigfoot.http.request, method="GET") + tripwire.assert_interaction(tripwire.http.request, method="GET") ``` Without this patch, the thread would not inherit the ContextVar and would see no active sandbox. @@ -94,15 +94,15 @@ Without this patch, the thread would not inherit the ContextVar and would see no `MockPlugin` works identically in async tests. No special async API is needed because mock calls are synchronous intercepts: ```python -import bigfoot +import tripwire async def test_async_mock(): - repo = bigfoot.mock("UserRepository") + repo = tripwire.mock("UserRepository") repo.find_by_id.returns({"id": 1, "name": "Alice"}) - async with bigfoot: + async with tripwire: user = repo.find_by_id(1) assert user["name"] == "Alice" - bigfoot.assert_interaction(repo.find_by_id) + tripwire.assert_interaction(repo.find_by_id) ``` diff --git a/docs/guides/asyncpg-plugin.md b/docs/guides/asyncpg-plugin.md index c499f75..476cd29 100644 --- a/docs/guides/asyncpg-plugin.md +++ b/docs/guides/asyncpg-plugin.md @@ -5,24 +5,24 @@ ## Installation ```bash -pip install bigfoot[asyncpg] +pip install tripwire[asyncpg] ``` ## Setup -In pytest, access `AsyncpgPlugin` through the `bigfoot.asyncpg_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `AsyncpgPlugin` through the `tripwire.asyncpg_mock` proxy. It auto-creates the plugin for the current test on first use: ```python -import bigfoot +import tripwire async def test_fetch_users(): - (bigfoot.asyncpg_mock + (tripwire.asyncpg_mock .new_session() .expect("connect", returns=None) .expect("fetch", returns=[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]) .expect("close", returns=None)) - with bigfoot: + with tripwire: import asyncpg conn = await asyncpg.connect(host="localhost", database="myapp", user="admin") rows = await conn.fetch("SELECT id, name FROM users") @@ -30,16 +30,16 @@ async def test_fetch_users(): assert rows == [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}] - bigfoot.asyncpg_mock.assert_connect(host="localhost", database="myapp", user="admin") - bigfoot.asyncpg_mock.assert_fetch(query="SELECT id, name FROM users", args=[]) - bigfoot.asyncpg_mock.assert_close() + tripwire.asyncpg_mock.assert_connect(host="localhost", database="myapp", user="admin") + tripwire.asyncpg_mock.assert_fetch(query="SELECT id, name FROM users", args=[]) + tripwire.asyncpg_mock.assert_close() ``` For manual use outside pytest, construct `AsyncpgPlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.asyncpg_plugin import AsyncpgPlugin +from tripwire import StrictVerifier +from tripwire.plugins.asyncpg_plugin import AsyncpgPlugin verifier = StrictVerifier() apg = AsyncpgPlugin(verifier) @@ -65,7 +65,7 @@ Unlike psycopg2/sqlite3, asyncpg does not have an explicit transaction state for Use `new_session()` to create a `SessionHandle` and chain `.expect()` calls: ```python -(bigfoot.asyncpg_mock +(tripwire.asyncpg_mock .new_session() .expect("connect", returns=None) .expect("fetch", returns=[{"id": 1}]) @@ -109,22 +109,22 @@ The `assert_connect()` helper accepts whichever parameters were used: ```python # For DSN connections -bigfoot.asyncpg_mock.assert_connect(dsn="postgresql://admin@localhost/myapp") +tripwire.asyncpg_mock.assert_connect(dsn="postgresql://admin@localhost/myapp") # For keyword connections -bigfoot.asyncpg_mock.assert_connect(host="localhost", port=5432, database="myapp", user="admin") +tripwire.asyncpg_mock.assert_connect(host="localhost", port=5432, database="myapp", user="admin") ``` ## Asserting interactions -Each step records an interaction on the timeline. Use the typed assertion helpers on `bigfoot.asyncpg_mock`: +Each step records an interaction on the timeline. Use the typed assertion helpers on `tripwire.asyncpg_mock`: ### `assert_connect(**kwargs)` Asserts the next connect interaction. Pass whichever connection fields were used. ```python -bigfoot.asyncpg_mock.assert_connect(host="localhost", database="myapp", user="admin") +tripwire.asyncpg_mock.assert_connect(host="localhost", database="myapp", user="admin") ``` ### `assert_execute(*, query, args)` @@ -132,7 +132,7 @@ bigfoot.asyncpg_mock.assert_connect(host="localhost", database="myapp", user="ad Asserts the next execute interaction. Both `query` and `args` are required. ```python -bigfoot.asyncpg_mock.assert_execute( +tripwire.asyncpg_mock.assert_execute( query="INSERT INTO users (name) VALUES ($1)", args=["Alice"], ) @@ -143,7 +143,7 @@ bigfoot.asyncpg_mock.assert_execute( Asserts the next fetch interaction. Both `query` and `args` are required. ```python -bigfoot.asyncpg_mock.assert_fetch(query="SELECT id, name FROM users", args=[]) +tripwire.asyncpg_mock.assert_fetch(query="SELECT id, name FROM users", args=[]) ``` ### `assert_fetchrow(*, query, args)` @@ -151,7 +151,7 @@ bigfoot.asyncpg_mock.assert_fetch(query="SELECT id, name FROM users", args=[]) Asserts the next fetchrow interaction. Both `query` and `args` are required. ```python -bigfoot.asyncpg_mock.assert_fetchrow( +tripwire.asyncpg_mock.assert_fetchrow( query="SELECT id, name FROM users WHERE id = $1", args=[1], ) @@ -162,7 +162,7 @@ bigfoot.asyncpg_mock.assert_fetchrow( Asserts the next fetchval interaction. Both `query` and `args` are required. ```python -bigfoot.asyncpg_mock.assert_fetchval(query="SELECT count(*) FROM users", args=[]) +tripwire.asyncpg_mock.assert_fetchval(query="SELECT count(*) FROM users", args=[]) ``` ### `assert_close()` @@ -170,7 +170,7 @@ bigfoot.asyncpg_mock.assert_fetchval(query="SELECT count(*) FROM users", args=[] Asserts the next close interaction. No fields are required. ```python -bigfoot.asyncpg_mock.assert_close() +tripwire.asyncpg_mock.assert_close() ``` ## Full example diff --git a/docs/guides/boto3-plugin.md b/docs/guides/boto3-plugin.md index 172f19f..2de8926 100644 --- a/docs/guides/boto3-plugin.md +++ b/docs/guides/boto3-plugin.md @@ -5,32 +5,32 @@ ## Installation ```bash -pip install bigfoot[boto3] +pip install tripwire[boto3] ``` This installs `botocore`. ## Setup -In pytest, access `Boto3Plugin` through the `bigfoot.boto3_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `Boto3Plugin` through the `tripwire.boto3_mock` proxy. It auto-creates the plugin for the current test on first use: ```python -import bigfoot +import tripwire def test_s3_get_object(): - bigfoot.boto3_mock.mock_call( + tripwire.boto3_mock.mock_call( "s3", "GetObject", returns={"Body": b"file-contents", "ContentLength": 13}, ) - with bigfoot: + with tripwire: import boto3 client = boto3.client("s3") response = client.get_object(Bucket="my-bucket", Key="data.csv") assert response["ContentLength"] == 13 - bigfoot.boto3_mock.assert_boto3_call( + tripwire.boto3_mock.assert_boto3_call( service="s3", operation="GetObject", params={"Bucket": "my-bucket", "Key": "data.csv"}, @@ -40,8 +40,8 @@ def test_s3_get_object(): For manual use outside pytest, construct `Boto3Plugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.boto3_plugin import Boto3Plugin +from tripwire import StrictVerifier +from tripwire.plugins.boto3_plugin import Boto3Plugin verifier = StrictVerifier() boto3_mock = Boto3Plugin(verifier) @@ -51,11 +51,11 @@ Each verifier may have at most one `Boto3Plugin`. A second `Boto3Plugin(verifier ## Registering mocks -Use `bigfoot.boto3_mock.mock_call(service, operation, *, returns, ...)` to register a mock before entering the sandbox: +Use `tripwire.boto3_mock.mock_call(service, operation, *, returns, ...)` to register a mock before entering the sandbox: ```python -bigfoot.boto3_mock.mock_call("sqs", "SendMessage", returns={"MessageId": "abc123"}) -bigfoot.boto3_mock.mock_call("dynamodb", "PutItem", returns={}) +tripwire.boto3_mock.mock_call("sqs", "SendMessage", returns={"MessageId": "abc123"}) +tripwire.boto3_mock.mock_call("dynamodb", "PutItem", returns={}) ``` ### Parameters @@ -74,16 +74,16 @@ Each service:operation pair has its own independent FIFO queue. Multiple `mock_c ```python def test_multiple_s3_gets(): - bigfoot.boto3_mock.mock_call( + tripwire.boto3_mock.mock_call( "s3", "GetObject", returns={"Body": b"first", "ContentLength": 5}, ) - bigfoot.boto3_mock.mock_call( + tripwire.boto3_mock.mock_call( "s3", "GetObject", returns={"Body": b"second", "ContentLength": 6}, ) - with bigfoot: + with tripwire: import boto3 client = boto3.client("s3") r1 = client.get_object(Bucket="bucket", Key="a.txt") @@ -92,11 +92,11 @@ def test_multiple_s3_gets(): assert r1["Body"] == b"first" assert r2["Body"] == b"second" - bigfoot.boto3_mock.assert_boto3_call( + tripwire.boto3_mock.assert_boto3_call( service="s3", operation="GetObject", params={"Bucket": "bucket", "Key": "a.txt"}, ) - bigfoot.boto3_mock.assert_boto3_call( + tripwire.boto3_mock.assert_boto3_call( service="s3", operation="GetObject", params={"Bucket": "bucket", "Key": "b.txt"}, ) @@ -104,12 +104,12 @@ def test_multiple_s3_gets(): ## Asserting interactions -Use the `assert_boto3_call` helper on `bigfoot.boto3_mock`. All three fields (`service`, `operation`, `params`) are required: +Use the `assert_boto3_call` helper on `tripwire.boto3_mock`. All three fields (`service`, `operation`, `params`) are required: ### `assert_boto3_call(service, operation, *, params)` ```python -bigfoot.boto3_mock.assert_boto3_call( +tripwire.boto3_mock.assert_boto3_call( service="sqs", operation="SendMessage", params={"QueueUrl": "https://sqs.us-east-1.amazonaws.com/123/my-queue", "MessageBody": "hello"}, @@ -128,17 +128,17 @@ Use the `raises` parameter to simulate AWS service errors: ```python from botocore.exceptions import ClientError -import bigfoot +import tripwire def test_s3_not_found(): error_response = {"Error": {"Code": "NoSuchKey", "Message": "The specified key does not exist."}} - bigfoot.boto3_mock.mock_call( + tripwire.boto3_mock.mock_call( "s3", "GetObject", returns=None, raises=ClientError(error_response, "GetObject"), ) - with bigfoot: + with tripwire: import boto3 client = boto3.client("s3") with pytest.raises(ClientError) as exc_info: @@ -146,7 +146,7 @@ def test_s3_not_found(): assert exc_info.value.response["Error"]["Code"] == "NoSuchKey" - bigfoot.boto3_mock.assert_boto3_call( + tripwire.boto3_mock.assert_boto3_call( service="s3", operation="GetObject", params={"Bucket": "my-bucket", "Key": "missing.csv"}, ) @@ -171,17 +171,17 @@ def test_s3_not_found(): Mark a mock as optional with `required=False`: ```python -bigfoot.boto3_mock.mock_call("cloudwatch", "PutMetricData", returns={}, required=False) +tripwire.boto3_mock.mock_call("cloudwatch", "PutMetricData", returns={}, required=False) ``` An optional mock that is never triggered does not cause `UnusedMocksError` at teardown. ## UnmockedInteractionError -When code calls a boto3 API operation that has no remaining mocks in its queue, bigfoot raises `UnmockedInteractionError`: +When code calls a boto3 API operation that has no remaining mocks in its queue, tripwire raises `UnmockedInteractionError`: ``` s3.GetObject(...) was called but no mock was registered. Register a mock with: - bigfoot.boto3_mock.mock_call('s3', 'GetObject', returns=...) + tripwire.boto3_mock.mock_call('s3', 'GetObject', returns=...) ``` diff --git a/docs/guides/celery-plugin.md b/docs/guides/celery-plugin.md index 5a70fdf..e18db91 100644 --- a/docs/guides/celery-plugin.md +++ b/docs/guides/celery-plugin.md @@ -5,29 +5,29 @@ ## Installation ```bash -pip install bigfoot[celery] +pip install tripwire[celery] ``` This installs `celery`. ## Setup -In pytest, access `CeleryPlugin` through the `bigfoot.celery_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `CeleryPlugin` through the `tripwire.celery_mock` proxy. It auto-creates the plugin for the current test on first use: ```python -import bigfoot +import tripwire def test_send_welcome_email(): - bigfoot.celery_mock.mock_delay( + tripwire.celery_mock.mock_delay( "myapp.tasks.send_email", returns=None, ) - with bigfoot: + with tripwire: from myapp.tasks import send_email send_email.delay("user@example.com", "Welcome!") - bigfoot.celery_mock.assert_delay( + tripwire.celery_mock.assert_delay( task_name="myapp.tasks.send_email", args=("user@example.com", "Welcome!"), kwargs={}, @@ -38,8 +38,8 @@ def test_send_welcome_email(): For manual use outside pytest, construct `CeleryPlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.celery_plugin import CeleryPlugin +from tripwire import StrictVerifier +from tripwire.plugins.celery_plugin import CeleryPlugin verifier = StrictVerifier() celery_mock = CeleryPlugin(verifier) @@ -54,7 +54,7 @@ CeleryPlugin provides two mock registration methods, one for each dispatch metho ### `mock_delay(task_name, *, returns, ...)` ```python -bigfoot.celery_mock.mock_delay("myapp.tasks.process_order", returns=None) +tripwire.celery_mock.mock_delay("myapp.tasks.process_order", returns=None) ``` | Parameter | Type | Default | Description | @@ -67,7 +67,7 @@ bigfoot.celery_mock.mock_delay("myapp.tasks.process_order", returns=None) ### `mock_apply_async(task_name, *, returns, ...)` ```python -bigfoot.celery_mock.mock_apply_async("myapp.tasks.generate_report", returns=None) +tripwire.celery_mock.mock_apply_async("myapp.tasks.generate_report", returns=None) ``` | Parameter | Type | Default | Description | @@ -83,21 +83,21 @@ Each task_name:dispatch_method pair has its own independent FIFO queue. Multiple ```python def test_multiple_email_dispatches(): - bigfoot.celery_mock.mock_delay("myapp.tasks.send_email", returns=None) - bigfoot.celery_mock.mock_delay("myapp.tasks.send_email", returns=None) + tripwire.celery_mock.mock_delay("myapp.tasks.send_email", returns=None) + tripwire.celery_mock.mock_delay("myapp.tasks.send_email", returns=None) - with bigfoot: + with tripwire: from myapp.tasks import send_email send_email.delay("alice@example.com", "Hello Alice") send_email.delay("bob@example.com", "Hello Bob") - bigfoot.celery_mock.assert_delay( + tripwire.celery_mock.assert_delay( task_name="myapp.tasks.send_email", args=("alice@example.com", "Hello Alice"), kwargs={}, options={}, ) - bigfoot.celery_mock.assert_delay( + tripwire.celery_mock.assert_delay( task_name="myapp.tasks.send_email", args=("bob@example.com", "Hello Bob"), kwargs={}, @@ -107,12 +107,12 @@ def test_multiple_email_dispatches(): ## Asserting interactions -Use the typed assertion helpers on `bigfoot.celery_mock`. All four fields (`task_name`, `args`, `kwargs`, `options`) are required: +Use the typed assertion helpers on `tripwire.celery_mock`. All four fields (`task_name`, `args`, `kwargs`, `options`) are required: ### `assert_delay(task_name, args, kwargs, options)` ```python -bigfoot.celery_mock.assert_delay( +tripwire.celery_mock.assert_delay( task_name="myapp.tasks.send_email", args=("user@example.com", "Welcome!"), kwargs={}, @@ -130,7 +130,7 @@ bigfoot.celery_mock.assert_delay( ### `assert_apply_async(task_name, args, kwargs, options)` ```python -bigfoot.celery_mock.assert_apply_async( +tripwire.celery_mock.assert_apply_async( task_name="myapp.tasks.generate_report", args=("q1", 2024), kwargs={"format": "pdf"}, @@ -150,21 +150,21 @@ bigfoot.celery_mock.assert_apply_async( Use the `raises` parameter to simulate Celery dispatch failures: ```python -import bigfoot +import tripwire def test_celery_dispatch_error(): - bigfoot.celery_mock.mock_delay( + tripwire.celery_mock.mock_delay( "myapp.tasks.send_email", returns=None, raises=ConnectionError("Broker unavailable"), ) - with bigfoot: + with tripwire: from myapp.tasks import send_email with pytest.raises(ConnectionError): send_email.delay("user@example.com", "Hello") - bigfoot.celery_mock.assert_delay( + tripwire.celery_mock.assert_delay( task_name="myapp.tasks.send_email", args=("user@example.com", "Hello"), kwargs={}, @@ -191,17 +191,17 @@ def test_celery_dispatch_error(): Mark a mock as optional with `required=False`: ```python -bigfoot.celery_mock.mock_delay("myapp.tasks.update_metrics", returns=None, required=False) +tripwire.celery_mock.mock_delay("myapp.tasks.update_metrics", returns=None, required=False) ``` An optional mock that is never triggered does not cause `UnusedMocksError` at teardown. ## UnmockedInteractionError -When code calls `delay()` or `apply_async()` on a task that has no remaining mocks in its queue, bigfoot raises `UnmockedInteractionError`: +When code calls `delay()` or `apply_async()` on a task that has no remaining mocks in its queue, tripwire raises `UnmockedInteractionError`: ``` celery.delay('myapp.tasks.send_email', ...) was called but no mock was registered. Register a mock with: - bigfoot.celery_mock.mock_delay('myapp.tasks.send_email', returns=...) + tripwire.celery_mock.mock_delay('myapp.tasks.send_email', returns=...) ``` diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 433d64d..ac2d90a 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -1,14 +1,14 @@ # Configuration Guide -bigfoot reads project-level configuration from `pyproject.toml` under the `[tool.bigfoot]` table. Configuration is optional; bigfoot works with sensible defaults when no configuration is present. +tripwire reads project-level configuration from `pyproject.toml` under the `[tool.tripwire]` table. Configuration is optional; tripwire works with sensible defaults when no configuration is present. ## Config file discovery -bigfoot walks up from the current working directory to find the nearest `pyproject.toml`. It checks each directory from `Path.cwd()` through all parent directories, stopping at the first `pyproject.toml` it finds. If no file is found, all configuration values use their defaults. +tripwire walks up from the current working directory to find the nearest `pyproject.toml`. It checks each directory from `Path.cwd()` through all parent directories, stopping at the first `pyproject.toml` it finds. If no file is found, all configuration values use their defaults. ``` my-project/ - pyproject.toml <-- bigfoot finds this + pyproject.toml <-- tripwire finds this src/ myapp/ client.py @@ -18,25 +18,25 @@ my-project/ ## Configuration format -Plugin configuration lives under `[tool.bigfoot.]`. Each plugin declares its own `config_key()` class method that determines which sub-table it reads from. +Plugin configuration lives under `[tool.tripwire.]`. Each plugin declares its own `config_key()` class method that determines which sub-table it reads from. ```toml -[tool.bigfoot.http] +[tool.tripwire.http] require_response = true ``` -The top-level `[tool.bigfoot]` table can contain plugin sub-tables. Unknown keys at any level are silently ignored for forward-compatibility. +The top-level `[tool.tripwire]` table can contain plugin sub-tables. Unknown keys at any level are silently ignored for forward-compatibility. ## HTTP plugin configuration -The `HttpPlugin` is currently the only plugin that reads configuration. It maps to the `[tool.bigfoot.http]` section. +The `HttpPlugin` is currently the only plugin that reads configuration. It maps to the `[tool.tripwire.http]` section. ### `require_response` When `true`, `assert_request()` returns an `HttpAssertionBuilder` that requires a chained `.assert_response()` call to complete the assertion. When `false` (the default), `assert_request()` is terminal and asserts only the request fields. ```toml -[tool.bigfoot.http] +[tool.tripwire.http] require_response = true ``` @@ -47,7 +47,7 @@ This setting can be overridden on a per-call basis: ```python # Project config sets require_response = true, but override for this one call: -bigfoot.http.assert_request("GET", "https://api.example.com/health", require_response=False) +tripwire.http.assert_request("GET", "https://api.example.com/health", require_response=False) ``` See the [HttpPlugin Guide](http-plugin.md) for details on the `require_response` feature. @@ -60,11 +60,11 @@ For example, with `require_response = true` in `pyproject.toml`: ```python # Uses the project default (require_response=True) -- must chain assert_response() -bigfoot.http.assert_request("GET", "https://api.example.com/users") \ +tripwire.http.assert_request("GET", "https://api.example.com/users") \ .assert_response(200, {}, "[]") # Overrides the project default for this call only -bigfoot.http.assert_request("GET", "https://api.example.com/health", require_response=False) +tripwire.http.assert_request("GET", "https://api.example.com/health", require_response=False) ``` ## Error handling @@ -76,14 +76,14 @@ If `pyproject.toml` exists but contains invalid TOML syntax, `tomllib.TOMLDecode # pyproject.toml with syntax errors ``` -If `pyproject.toml` is valid but has no `[tool.bigfoot]` section, an empty dict is returned and all plugins use their defaults. +If `pyproject.toml` is valid but has no `[tool.tripwire]` section, an empty dict is returned and all plugins use their defaults. ## How config loading works internally Configuration loading follows this flow: -1. `StrictVerifier.__init__()` calls `load_bigfoot_config()` to find and parse `pyproject.toml` -2. The result is stored as `verifier._bigfoot_config` +1. `StrictVerifier.__init__()` calls `load_tripwire_config()` to find and parse `pyproject.toml` +2. The result is stored as `verifier._tripwire_config` 3. Each plugin's `__init__()` checks its `config_key()` and calls `self.load_config(config_dict)` with the matching sub-table 4. `load_config()` validates and applies the configuration values @@ -93,13 +93,13 @@ If you are writing a custom plugin that needs configuration, implement two metho ### `config_key()` class method -Return a string that maps to `[tool.bigfoot.]`, or `None` to opt out of configuration: +Return a string that maps to `[tool.tripwire.]`, or `None` to opt out of configuration: ```python class MyPlugin(BasePlugin): @classmethod def config_key(cls) -> str | None: - return "my_plugin" # reads from [tool.bigfoot.my_plugin] + return "my_plugin" # reads from [tool.tripwire.my_plugin] ``` ### `load_config()` method @@ -113,7 +113,7 @@ class MyPlugin(BasePlugin): val = config["timeout"] if not isinstance(val, (int, float)): raise TypeError( - f"[tool.bigfoot.my_plugin] timeout must be a number, " + f"[tool.tripwire.my_plugin] timeout must be a number, " f"got {type(val).__name__}" ) self._timeout = val @@ -126,7 +126,7 @@ The `load_config()` method is called as the last step of the plugin's `__init__( If built-in plugins interfere with your custom plugin's tests, disable them: ```toml -[tool.bigfoot] +[tool.tripwire] disabled_plugins = ["socket", "subprocess"] ``` @@ -139,6 +139,6 @@ See [Writing Plugins](writing-plugins.md) for the plugin authoring guide. name = "my-app" version = "1.0.0" -[tool.bigfoot.http] +[tool.tripwire.http] require_response = true ``` diff --git a/docs/guides/crypto-plugin.md b/docs/guides/crypto-plugin.md index 6ad7d51..ee29061 100644 --- a/docs/guides/crypto-plugin.md +++ b/docs/guides/crypto-plugin.md @@ -5,36 +5,36 @@ ## Installation ```bash -pip install bigfoot[crypto] +pip install tripwire[crypto] ``` This installs `cryptography`. ## Setup -In pytest, access `CryptoPlugin` through the `bigfoot.crypto_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `CryptoPlugin` through the `tripwire.crypto_mock` proxy. It auto-creates the plugin for the current test on first use: ```python -import bigfoot +import tripwire def test_encrypt_payload(): - bigfoot.crypto_mock.mock_encrypt(returns=b"gAAAAABencrypted...") + tripwire.crypto_mock.mock_encrypt(returns=b"gAAAAABencrypted...") - with bigfoot: + with tripwire: from cryptography.fernet import Fernet f = Fernet(b"test-key-base64-encoded-padding=") ciphertext = f.encrypt(b"sensitive data") assert ciphertext == b"gAAAAABencrypted..." - bigfoot.crypto_mock.assert_encrypt(plaintext_length=14) + tripwire.crypto_mock.assert_encrypt(plaintext_length=14) ``` For manual use outside pytest, construct `CryptoPlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.crypto_plugin import CryptoPlugin +from tripwire import StrictVerifier +from tripwire.plugins.crypto_plugin import CryptoPlugin verifier = StrictVerifier() crypto_mock = CryptoPlugin(verifier) @@ -51,7 +51,7 @@ Each verifier may have at most one `CryptoPlugin`. A second `CryptoPlugin(verifi Register a mock for `Fernet.encrypt()`: ```python -bigfoot.crypto_mock.mock_encrypt(returns=b"gAAAAABencrypted_token") +tripwire.crypto_mock.mock_encrypt(returns=b"gAAAAABencrypted_token") ``` | Parameter | Type | Default | Description | @@ -65,7 +65,7 @@ bigfoot.crypto_mock.mock_encrypt(returns=b"gAAAAABencrypted_token") Register a mock for `Fernet.decrypt()`: ```python -bigfoot.crypto_mock.mock_decrypt(returns=b"decrypted plaintext") +tripwire.crypto_mock.mock_decrypt(returns=b"decrypted plaintext") ``` | Parameter | Type | Default | Description | @@ -79,7 +79,7 @@ bigfoot.crypto_mock.mock_decrypt(returns=b"decrypted plaintext") Register a mock for `rsa.generate_private_key()`: ```python -bigfoot.crypto_mock.mock_generate_key(returns=mock_private_key) +tripwire.crypto_mock.mock_generate_key(returns=mock_private_key) ``` | Parameter | Type | Default | Description | @@ -94,10 +94,10 @@ Each operation (`fernet_encrypt`, `fernet_decrypt`, `generate_key`) has its own ```python def test_encrypt_multiple_fields(): - bigfoot.crypto_mock.mock_encrypt(returns=b"encrypted_email") - bigfoot.crypto_mock.mock_encrypt(returns=b"encrypted_ssn") + tripwire.crypto_mock.mock_encrypt(returns=b"encrypted_email") + tripwire.crypto_mock.mock_encrypt(returns=b"encrypted_ssn") - with bigfoot: + with tripwire: from cryptography.fernet import Fernet f = Fernet(b"test-key-base64-encoded-padding=") ct1 = f.encrypt(b"alice@example.com") @@ -106,20 +106,20 @@ def test_encrypt_multiple_fields(): assert ct1 == b"encrypted_email" assert ct2 == b"encrypted_ssn" - bigfoot.crypto_mock.assert_encrypt(plaintext_length=17) - bigfoot.crypto_mock.assert_encrypt(plaintext_length=11) + tripwire.crypto_mock.assert_encrypt(plaintext_length=17) + tripwire.crypto_mock.assert_encrypt(plaintext_length=11) ``` ## Asserting interactions -Use the typed assertion helpers on `bigfoot.crypto_mock`. +Use the typed assertion helpers on `tripwire.crypto_mock`. ### `assert_encrypt(*, plaintext_length)` Asserts the next `Fernet.encrypt()` interaction. Only the plaintext length is recorded, not the actual data. ```python -bigfoot.crypto_mock.assert_encrypt(plaintext_length=14) +tripwire.crypto_mock.assert_encrypt(plaintext_length=14) ``` | Parameter | Type | Description | @@ -131,7 +131,7 @@ bigfoot.crypto_mock.assert_encrypt(plaintext_length=14) Asserts the next `Fernet.decrypt()` interaction. The token (ciphertext) is safe to record since it is not secret. ```python -bigfoot.crypto_mock.assert_decrypt(token=b"gAAAAABencrypted_token", ttl=None) +tripwire.crypto_mock.assert_decrypt(token=b"gAAAAABencrypted_token", ttl=None) ``` | Parameter | Type | Default | Description | @@ -144,7 +144,7 @@ bigfoot.crypto_mock.assert_decrypt(token=b"gAAAAABencrypted_token", ttl=None) Asserts the next `rsa.generate_private_key()` interaction. ```python -bigfoot.crypto_mock.assert_generate_key(algorithm="RSA", key_size=2048) +tripwire.crypto_mock.assert_generate_key(algorithm="RSA", key_size=2048) ``` | Parameter | Type | Description | @@ -166,21 +166,21 @@ Use the `raises` parameter to simulate cryptography errors: ```python from cryptography.fernet import InvalidToken -import bigfoot +import tripwire def test_invalid_token(): - bigfoot.crypto_mock.mock_decrypt( + tripwire.crypto_mock.mock_decrypt( returns=None, raises=InvalidToken(), ) - with bigfoot: + with tripwire: from cryptography.fernet import Fernet f = Fernet(b"test-key-base64-encoded-padding=") with pytest.raises(InvalidToken): f.decrypt(b"corrupted_ciphertext") - bigfoot.crypto_mock.assert_decrypt(token=b"corrupted_ciphertext", ttl=None) + tripwire.crypto_mock.assert_decrypt(token=b"corrupted_ciphertext", ttl=None) ``` ## Full example @@ -202,17 +202,17 @@ def test_invalid_token(): Mark a mock as optional with `required=False`: ```python -bigfoot.crypto_mock.mock_encrypt(returns=b"optional_ct", required=False) +tripwire.crypto_mock.mock_encrypt(returns=b"optional_ct", required=False) ``` An optional mock that is never triggered does not cause `UnusedMocksError` at teardown. ## UnmockedInteractionError -When code calls an intercepted cryptography function with no remaining mocks in its queue, bigfoot raises `UnmockedInteractionError`: +When code calls an intercepted cryptography function with no remaining mocks in its queue, tripwire raises `UnmockedInteractionError`: ``` crypto.fernet_encrypt(...) was called but no mock was registered. Register a mock with: - bigfoot.crypto_mock.mock_encrypt(returns=...) + tripwire.crypto_mock.mock_encrypt(returns=...) ``` diff --git a/docs/guides/database-plugin.md b/docs/guides/database-plugin.md index d4c59b3..3998d18 100644 --- a/docs/guides/database-plugin.md +++ b/docs/guides/database-plugin.md @@ -1,22 +1,22 @@ # DatabasePlugin Guide -`DatabasePlugin` intercepts `sqlite3.connect()` and returns a fake connection object that routes all operations through a session script. It is included in core bigfoot -- no extra required. +`DatabasePlugin` intercepts `sqlite3.connect()` and returns a fake connection object that routes all operations through a session script. It is included in core tripwire -- no extra required. ## Setup -In pytest, access `DatabasePlugin` through the `bigfoot.db_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `DatabasePlugin` through the `tripwire.db_mock` proxy. It auto-creates the plugin for the current test on first use: ```python -import bigfoot +import tripwire def test_select_users(): - (bigfoot.db_mock + (tripwire.db_mock .new_session() .expect("connect", returns=None) .expect("execute", returns=[[1, "Alice"], [2, "Bob"]]) .expect("close", returns=None)) - with bigfoot: + with tripwire: import sqlite3 conn = sqlite3.connect(":memory:") cursor = conn.execute("SELECT id, name FROM users") @@ -25,16 +25,16 @@ def test_select_users(): assert rows == [[1, "Alice"], [2, "Bob"]] - bigfoot.db_mock.assert_connect(database=":memory:") - bigfoot.db_mock.assert_execute(sql="SELECT id, name FROM users", parameters=()) - bigfoot.db_mock.assert_close() + tripwire.db_mock.assert_connect(database=":memory:") + tripwire.db_mock.assert_execute(sql="SELECT id, name FROM users", parameters=()) + tripwire.db_mock.assert_close() ``` For manual use outside pytest, construct `DatabasePlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.database_plugin import DatabasePlugin +from tripwire import StrictVerifier +from tripwire.plugins.database_plugin import DatabasePlugin verifier = StrictVerifier() db = DatabasePlugin(verifier) @@ -60,7 +60,7 @@ in_transaction --close--> closed Use `new_session()` to create a `SessionHandle` and chain `.expect()` calls: ```python -(bigfoot.db_mock +(tripwire.db_mock .new_session() .expect("connect", returns=None) .expect("execute", returns=[["row1"], ["row2"]]) @@ -92,13 +92,13 @@ Use `new_session()` to create a `SessionHandle` and chain `.expect()` calls: The fake connection's `execute()` method returns a cursor proxy. The rows you specify in `returns=` are available through the standard cursor methods: ```python -(bigfoot.db_mock +(tripwire.db_mock .new_session() .expect("connect", returns=None) .expect("execute", returns=[[1, "Alice"], [2, "Bob"], [3, "Carol"]]) .expect("close", returns=None)) -with bigfoot: +with tripwire: conn = sqlite3.connect(":memory:") cursor = conn.execute("SELECT id, name FROM users") @@ -117,7 +117,7 @@ with bigfoot: You can also use `conn.cursor()` to create a cursor first, then call `cursor.execute()`: ```python -with bigfoot: +with tripwire: conn = sqlite3.connect(":memory:") cur = conn.cursor() cur.execute("SELECT val FROM t") @@ -129,14 +129,14 @@ Both styles produce the same interactions on the timeline. ## Asserting interactions -Each step records an interaction on the timeline. Use the typed assertion helpers on `bigfoot.db_mock`: +Each step records an interaction on the timeline. Use the typed assertion helpers on `tripwire.db_mock`: ### `assert_connect(*, database)` Asserts the next connect interaction. The `database` field is required. ```python -bigfoot.db_mock.assert_connect(database=":memory:") +tripwire.db_mock.assert_connect(database=":memory:") ``` ### `assert_execute(*, sql, parameters)` @@ -144,7 +144,7 @@ bigfoot.db_mock.assert_connect(database=":memory:") Asserts the next execute interaction. Both `sql` and `parameters` are required. ```python -bigfoot.db_mock.assert_execute(sql="INSERT INTO users (name) VALUES (?)", parameters=("Alice",)) +tripwire.db_mock.assert_execute(sql="INSERT INTO users (name) VALUES (?)", parameters=("Alice",)) ``` ### `assert_commit()` @@ -152,7 +152,7 @@ bigfoot.db_mock.assert_execute(sql="INSERT INTO users (name) VALUES (?)", parame Asserts the next commit interaction. No fields are required. ```python -bigfoot.db_mock.assert_commit() +tripwire.db_mock.assert_commit() ``` ### `assert_rollback()` @@ -160,7 +160,7 @@ bigfoot.db_mock.assert_commit() Asserts the next rollback interaction. No fields are required. ```python -bigfoot.db_mock.assert_rollback() +tripwire.db_mock.assert_rollback() ``` ### `assert_close()` @@ -168,7 +168,7 @@ bigfoot.db_mock.assert_rollback() Asserts the next close interaction. No fields are required. ```python -bigfoot.db_mock.assert_close() +tripwire.db_mock.assert_close() ``` ## Commit and rollback @@ -177,7 +177,7 @@ Each `execute()` moves the connection into `in_transaction`. `commit()` and `rol ```python def test_commit_then_execute(): - (bigfoot.db_mock + (tripwire.db_mock .new_session() .expect("connect", returns=None) .expect("execute", returns=[]) @@ -185,18 +185,18 @@ def test_commit_then_execute(): .expect("execute", returns=[]) .expect("close", returns=None)) - with bigfoot: + with tripwire: conn = sqlite3.connect(":memory:") conn.execute("INSERT INTO t VALUES (1)") conn.commit() conn.execute("INSERT INTO t VALUES (2)") conn.close() - bigfoot.db_mock.assert_connect(database=":memory:") - bigfoot.db_mock.assert_execute(sql="INSERT INTO t VALUES (1)", parameters=()) - bigfoot.db_mock.assert_commit() - bigfoot.db_mock.assert_execute(sql="INSERT INTO t VALUES (2)", parameters=()) - bigfoot.db_mock.assert_close() + tripwire.db_mock.assert_connect(database=":memory:") + tripwire.db_mock.assert_execute(sql="INSERT INTO t VALUES (1)", parameters=()) + tripwire.db_mock.assert_commit() + tripwire.db_mock.assert_execute(sql="INSERT INTO t VALUES (2)", parameters=()) + tripwire.db_mock.assert_close() ``` Calling `commit()` from `connected` (before any `execute()`) raises `InvalidStateError`. diff --git a/docs/guides/dns-plugin.md b/docs/guides/dns-plugin.md index 57007f4..d7a5184 100644 --- a/docs/guides/dns-plugin.md +++ b/docs/guides/dns-plugin.md @@ -6,28 +6,28 @@ `DnsPlugin` intercepts stdlib `socket` functions, so no extra installation is needed. If you also want to intercept `dnspython` resolution, install it separately. -In pytest, access `DnsPlugin` through the `bigfoot.dns_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `DnsPlugin` through the `tripwire.dns_mock` proxy. It auto-creates the plugin for the current test on first use: ```python import socket -import bigfoot +import tripwire def test_hostname_resolution(): - bigfoot.dns_mock.mock_gethostbyname("api.example.com", returns="93.184.216.34") + tripwire.dns_mock.mock_gethostbyname("api.example.com", returns="93.184.216.34") - with bigfoot: + with tripwire: ip = socket.gethostbyname("api.example.com") assert ip == "93.184.216.34" - bigfoot.dns_mock.assert_gethostbyname(hostname="api.example.com") + tripwire.dns_mock.assert_gethostbyname(hostname="api.example.com") ``` For manual use outside pytest, construct `DnsPlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.dns_plugin import DnsPlugin +from tripwire import StrictVerifier +from tripwire.plugins.dns_plugin import DnsPlugin verifier = StrictVerifier() dns_mock = DnsPlugin(verifier) @@ -44,7 +44,7 @@ Each verifier may have at most one `DnsPlugin`. A second `DnsPlugin(verifier)` r Register a mock for `socket.getaddrinfo()`: ```python -bigfoot.dns_mock.mock_getaddrinfo( +tripwire.dns_mock.mock_getaddrinfo( "api.example.com", returns=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443))], ) @@ -62,7 +62,7 @@ bigfoot.dns_mock.mock_getaddrinfo( Register a mock for `socket.gethostbyname()`: ```python -bigfoot.dns_mock.mock_gethostbyname("db.internal", returns="10.0.1.5") +tripwire.dns_mock.mock_gethostbyname("db.internal", returns="10.0.1.5") ``` | Parameter | Type | Default | Description | @@ -77,7 +77,7 @@ bigfoot.dns_mock.mock_gethostbyname("db.internal", returns="10.0.1.5") Register a mock for `dns.resolver.resolve()` (requires dnspython): ```python -bigfoot.dns_mock.mock_resolve("mail.example.com", "MX", returns=mock_mx_answer) +tripwire.dns_mock.mock_resolve("mail.example.com", "MX", returns=mock_mx_answer) ``` | Parameter | Type | Default | Description | @@ -94,28 +94,28 @@ Each hostname (scoped by operation type) has its own independent FIFO queue. Mul ```python def test_multiple_resolutions(): - bigfoot.dns_mock.mock_gethostbyname("api.example.com", returns="93.184.216.34") - bigfoot.dns_mock.mock_gethostbyname("api.example.com", returns="93.184.216.35") + tripwire.dns_mock.mock_gethostbyname("api.example.com", returns="93.184.216.34") + tripwire.dns_mock.mock_gethostbyname("api.example.com", returns="93.184.216.35") - with bigfoot: + with tripwire: ip1 = socket.gethostbyname("api.example.com") ip2 = socket.gethostbyname("api.example.com") assert ip1 == "93.184.216.34" assert ip2 == "93.184.216.35" - bigfoot.dns_mock.assert_gethostbyname(hostname="api.example.com") - bigfoot.dns_mock.assert_gethostbyname(hostname="api.example.com") + tripwire.dns_mock.assert_gethostbyname(hostname="api.example.com") + tripwire.dns_mock.assert_gethostbyname(hostname="api.example.com") ``` ## Asserting interactions -Use the typed assertion helpers on `bigfoot.dns_mock`. Each helper requires all detail fields for its operation type. +Use the typed assertion helpers on `tripwire.dns_mock`. Each helper requires all detail fields for its operation type. ### `assert_getaddrinfo(host, port, family, type, proto)` ```python -bigfoot.dns_mock.assert_getaddrinfo( +tripwire.dns_mock.assert_getaddrinfo( host="api.example.com", port=443, family=socket.AF_INET, @@ -135,7 +135,7 @@ bigfoot.dns_mock.assert_getaddrinfo( ### `assert_gethostbyname(hostname)` ```python -bigfoot.dns_mock.assert_gethostbyname(hostname="api.example.com") +tripwire.dns_mock.assert_gethostbyname(hostname="api.example.com") ``` | Parameter | Type | Description | @@ -145,7 +145,7 @@ bigfoot.dns_mock.assert_gethostbyname(hostname="api.example.com") ### `assert_resolve(qname, rdtype)` ```python -bigfoot.dns_mock.assert_resolve(qname="mail.example.com", rdtype="MX") +tripwire.dns_mock.assert_resolve(qname="mail.example.com", rdtype="MX") ``` | Parameter | Type | Description | @@ -159,20 +159,20 @@ Use the `raises` parameter to simulate DNS resolution failures: ```python import socket -import bigfoot +import tripwire def test_dns_resolution_failure(): - bigfoot.dns_mock.mock_gethostbyname( + tripwire.dns_mock.mock_gethostbyname( "nonexistent.example.com", returns=None, raises=socket.gaierror(8, "nodename nor servname provided, or not known"), ) - with bigfoot: + with tripwire: with pytest.raises(socket.gaierror): socket.gethostbyname("nonexistent.example.com") - bigfoot.dns_mock.assert_gethostbyname(hostname="nonexistent.example.com") + tripwire.dns_mock.assert_gethostbyname(hostname="nonexistent.example.com") ``` ## Full example @@ -194,17 +194,17 @@ def test_dns_resolution_failure(): Mark a mock as optional with `required=False`: ```python -bigfoot.dns_mock.mock_gethostbyname("optional.host", returns="127.0.0.1", required=False) +tripwire.dns_mock.mock_gethostbyname("optional.host", returns="127.0.0.1", required=False) ``` An optional mock that is never triggered does not cause `UnusedMocksError` at teardown. ## UnmockedInteractionError -When code calls a DNS function for a hostname that has no remaining mocks in its queue, bigfoot raises `UnmockedInteractionError`: +When code calls a DNS function for a hostname that has no remaining mocks in its queue, tripwire raises `UnmockedInteractionError`: ``` socket.gethostbyname('unknown.host') was called but no mock was registered. Register a mock with: - bigfoot.dns_mock.mock_gethostbyname('unknown.host', returns=...) + tripwire.dns_mock.mock_gethostbyname('unknown.host', returns=...) ``` diff --git a/docs/guides/elasticsearch-plugin.md b/docs/guides/elasticsearch-plugin.md index c996abc..13c3df3 100644 --- a/docs/guides/elasticsearch-plugin.md +++ b/docs/guides/elasticsearch-plugin.md @@ -5,32 +5,32 @@ ## Installation ```bash -pip install bigfoot[elasticsearch] +pip install tripwire[elasticsearch] ``` This installs `elasticsearch`. ## Setup -In pytest, access `ElasticsearchPlugin` through the `bigfoot.elasticsearch_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `ElasticsearchPlugin` through the `tripwire.elasticsearch_mock` proxy. It auto-creates the plugin for the current test on first use: ```python -import bigfoot +import tripwire def test_index_document(): - bigfoot.elasticsearch_mock.mock_operation( + tripwire.elasticsearch_mock.mock_operation( "index", returns={"_id": "doc_1", "result": "created"}, ) - with bigfoot: + with tripwire: from elasticsearch import Elasticsearch es = Elasticsearch("http://localhost:9200") result = es.index(index="products", document={"name": "Widget", "price": 9.99}, id="doc_1") assert result["result"] == "created" - bigfoot.elasticsearch_mock.assert_index( + tripwire.elasticsearch_mock.assert_index( index="products", document={"name": "Widget", "price": 9.99}, id="doc_1", @@ -40,8 +40,8 @@ def test_index_document(): For manual use outside pytest, construct `ElasticsearchPlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.elasticsearch_plugin import ElasticsearchPlugin +from tripwire import StrictVerifier +from tripwire.plugins.elasticsearch_plugin import ElasticsearchPlugin verifier = StrictVerifier() es_mock = ElasticsearchPlugin(verifier) @@ -51,11 +51,11 @@ Each verifier may have at most one `ElasticsearchPlugin`. A second `Elasticsearc ## Registering mock operations -Use `bigfoot.elasticsearch_mock.mock_operation(operation, *, returns, ...)` to register a mock before entering the sandbox: +Use `tripwire.elasticsearch_mock.mock_operation(operation, *, returns, ...)` to register a mock before entering the sandbox: ```python -bigfoot.elasticsearch_mock.mock_operation("search", returns={"hits": {"hits": [], "total": {"value": 0}}}) -bigfoot.elasticsearch_mock.mock_operation("index", returns={"_id": "1", "result": "created"}) +tripwire.elasticsearch_mock.mock_operation("search", returns={"hits": {"hits": [], "total": {"value": 0}}}) +tripwire.elasticsearch_mock.mock_operation("index", returns={"_id": "1", "result": "created"}) ``` ### Parameters @@ -89,16 +89,16 @@ Each operation name has its own independent FIFO queue. Multiple `mock_operation ```python def test_paginated_search(): - bigfoot.elasticsearch_mock.mock_operation( + tripwire.elasticsearch_mock.mock_operation( "search", returns={"hits": {"hits": [{"_id": "1"}], "total": {"value": 25}}}, ) - bigfoot.elasticsearch_mock.mock_operation( + tripwire.elasticsearch_mock.mock_operation( "search", returns={"hits": {"hits": [{"_id": "11"}], "total": {"value": 25}}}, ) - with bigfoot: + with tripwire: from elasticsearch import Elasticsearch es = Elasticsearch() page1 = es.search(index="logs", query={"match_all": {}}, size=10) @@ -107,18 +107,18 @@ def test_paginated_search(): assert page1["hits"]["hits"][0]["_id"] == "1" assert page2["hits"]["hits"][0]["_id"] == "11" - bigfoot.elasticsearch_mock.assert_search(index="logs", query={"match_all": {}}, size=10) - bigfoot.elasticsearch_mock.assert_search(index="logs", query={"match_all": {}}, size=10, from_=10) + tripwire.elasticsearch_mock.assert_search(index="logs", query={"match_all": {}}, size=10) + tripwire.elasticsearch_mock.assert_search(index="logs", query={"match_all": {}}, size=10, from_=10) ``` ## Asserting interactions -Use the typed assertion helpers on `bigfoot.elasticsearch_mock`. Each helper accepts keyword arguments matching the detail fields captured for that operation. +Use the typed assertion helpers on `tripwire.elasticsearch_mock`. Each helper accepts keyword arguments matching the detail fields captured for that operation. ### `assert_index(*, index, document, id=None)` ```python -bigfoot.elasticsearch_mock.assert_index( +tripwire.elasticsearch_mock.assert_index( index="products", document={"name": "Widget", "price": 9.99}, id="doc_1", @@ -134,7 +134,7 @@ bigfoot.elasticsearch_mock.assert_index( ### `assert_search(*, index=None, query=None, size=None, from_=None)` ```python -bigfoot.elasticsearch_mock.assert_search( +tripwire.elasticsearch_mock.assert_search( index="logs", query={"match": {"level": "error"}}, size=50, @@ -151,7 +151,7 @@ bigfoot.elasticsearch_mock.assert_search( ### `assert_get(*, index, id)` ```python -bigfoot.elasticsearch_mock.assert_get(index="products", id="doc_1") +tripwire.elasticsearch_mock.assert_get(index="products", id="doc_1") ``` | Parameter | Type | Description | @@ -162,7 +162,7 @@ bigfoot.elasticsearch_mock.assert_get(index="products", id="doc_1") ### `assert_delete(*, index, id)` ```python -bigfoot.elasticsearch_mock.assert_delete(index="products", id="doc_1") +tripwire.elasticsearch_mock.assert_delete(index="products", id="doc_1") ``` | Parameter | Type | Description | @@ -173,7 +173,7 @@ bigfoot.elasticsearch_mock.assert_delete(index="products", id="doc_1") ### `assert_bulk(*, operations)` ```python -bigfoot.elasticsearch_mock.assert_bulk( +tripwire.elasticsearch_mock.assert_bulk( operations=[ {"index": {"_index": "logs", "_id": "1"}}, {"message": "first log entry"}, @@ -191,22 +191,22 @@ Use the `raises` parameter to simulate Elasticsearch errors: ```python from elasticsearch import NotFoundError -import bigfoot +import tripwire def test_document_not_found(): - bigfoot.elasticsearch_mock.mock_operation( + tripwire.elasticsearch_mock.mock_operation( "get", returns=None, raises=NotFoundError(404, "document_missing_exception", {"_index": "products", "_id": "missing"}), ) - with bigfoot: + with tripwire: from elasticsearch import Elasticsearch es = Elasticsearch() with pytest.raises(NotFoundError): es.get(index="products", id="missing") - bigfoot.elasticsearch_mock.assert_get(index="products", id="missing") + tripwire.elasticsearch_mock.assert_get(index="products", id="missing") ``` ## Full example @@ -228,17 +228,17 @@ def test_document_not_found(): Mark a mock as optional with `required=False`: ```python -bigfoot.elasticsearch_mock.mock_operation("count", returns={"count": 0}, required=False) +tripwire.elasticsearch_mock.mock_operation("count", returns={"count": 0}, required=False) ``` An optional mock that is never triggered does not cause `UnusedMocksError` at teardown. ## UnmockedInteractionError -When code calls an Elasticsearch method that has no remaining mocks in its queue, bigfoot raises `UnmockedInteractionError`: +When code calls an Elasticsearch method that has no remaining mocks in its queue, tripwire raises `UnmockedInteractionError`: ``` elasticsearch.search(...) was called but no mock was registered. Register a mock with: - bigfoot.elasticsearch_mock.mock_operation('search', returns=...) + tripwire.elasticsearch_mock.mock_operation('search', returns=...) ``` diff --git a/docs/guides/file-io-plugin.md b/docs/guides/file-io-plugin.md index cbcdd6a..447cc6f 100644 --- a/docs/guides/file-io-plugin.md +++ b/docs/guides/file-io-plugin.md @@ -1,36 +1,36 @@ # FileIoPlugin Guide -`FileIoPlugin` intercepts file system operations across `builtins.open`, `pathlib.Path` read/write methods, `os` file operations, and `shutil` copy/remove operations. Each operation+path combination has its own independent FIFO queue. The plugin uses a `ContextVar`-based reentrancy guard to prevent self-interference with bigfoot's own file I/O. +`FileIoPlugin` intercepts file system operations across `builtins.open`, `pathlib.Path` read/write methods, `os` file operations, and `shutil` copy/remove operations. Each operation+path combination has its own independent FIFO queue. The plugin uses a `ContextVar`-based reentrancy guard to prevent self-interference with tripwire's own file I/O. -**Important:** `FileIoPlugin` is always available (no extra install required) but is NOT default enabled. You must explicitly enable it via `enabled_plugins = ["file_io"]` in your bigfoot config, or access it through the `bigfoot.file_io_mock` proxy. +**Important:** `FileIoPlugin` is always available (no extra install required) but is NOT default enabled. You must explicitly enable it via `enabled_plugins = ["file_io"]` in your tripwire config, or access it through the `tripwire.file_io_mock` proxy. ## Setup -In pytest, access `FileIoPlugin` through the `bigfoot.file_io_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `FileIoPlugin` through the `tripwire.file_io_mock` proxy. It auto-creates the plugin for the current test on first use: ```python -import bigfoot +import tripwire def test_read_config(): - bigfoot.file_io_mock.mock_operation( + tripwire.file_io_mock.mock_operation( "read_text", "/etc/myapp/config.yaml", returns="database:\n host: localhost\n port: 5432", ) - with bigfoot: + with tripwire: from pathlib import Path config = Path("/etc/myapp/config.yaml").read_text() assert "localhost" in config - bigfoot.file_io_mock.assert_read_text(path="/etc/myapp/config.yaml") + tripwire.file_io_mock.assert_read_text(path="/etc/myapp/config.yaml") ``` For manual use outside pytest, construct `FileIoPlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.file_io_plugin import FileIoPlugin +from tripwire import StrictVerifier +from tripwire.plugins.file_io_plugin import FileIoPlugin verifier = StrictVerifier() file_io_mock = FileIoPlugin(verifier) @@ -40,11 +40,11 @@ Each verifier may have at most one `FileIoPlugin`. A second `FileIoPlugin(verifi ## Registering mocks -Use `bigfoot.file_io_mock.mock_operation(operation, path_pattern, *, returns, ...)` to register a mock before entering the sandbox: +Use `tripwire.file_io_mock.mock_operation(operation, path_pattern, *, returns, ...)` to register a mock before entering the sandbox: ```python -bigfoot.file_io_mock.mock_operation("open", "/tmp/data.csv", returns="id,name\n1,Alice") -bigfoot.file_io_mock.mock_operation("remove", "/tmp/data.csv", returns=None) +tripwire.file_io_mock.mock_operation("open", "/tmp/data.csv", returns="id,name\n1,Alice") +tripwire.file_io_mock.mock_operation("remove", "/tmp/data.csv", returns=None) ``` ### Parameters @@ -83,10 +83,10 @@ Each operation+path combination has its own independent FIFO queue. Multiple moc ```python def test_multiple_reads(): - bigfoot.file_io_mock.mock_operation("read_text", "/etc/myapp/config.yaml", returns="v1") - bigfoot.file_io_mock.mock_operation("read_text", "/etc/myapp/config.yaml", returns="v2") + tripwire.file_io_mock.mock_operation("read_text", "/etc/myapp/config.yaml", returns="v1") + tripwire.file_io_mock.mock_operation("read_text", "/etc/myapp/config.yaml", returns="v2") - with bigfoot: + with tripwire: from pathlib import Path first = Path("/etc/myapp/config.yaml").read_text() second = Path("/etc/myapp/config.yaml").read_text() @@ -94,44 +94,44 @@ def test_multiple_reads(): assert first == "v1" assert second == "v2" - bigfoot.file_io_mock.assert_read_text(path="/etc/myapp/config.yaml") - bigfoot.file_io_mock.assert_read_text(path="/etc/myapp/config.yaml") + tripwire.file_io_mock.assert_read_text(path="/etc/myapp/config.yaml") + tripwire.file_io_mock.assert_read_text(path="/etc/myapp/config.yaml") ``` ## Asserting interactions -Use the typed assertion helpers on `bigfoot.file_io_mock`: +Use the typed assertion helpers on `tripwire.file_io_mock`: ### `assert_open(**expected)` All three fields (`path`, `mode`, `encoding`) are required: ```python -bigfoot.file_io_mock.assert_open(path="/tmp/data.csv", mode="r", encoding="utf-8") +tripwire.file_io_mock.assert_open(path="/tmp/data.csv", mode="r", encoding="utf-8") ``` ### `assert_read_text(path)` ```python -bigfoot.file_io_mock.assert_read_text(path="/etc/myapp/config.yaml") +tripwire.file_io_mock.assert_read_text(path="/etc/myapp/config.yaml") ``` ### `assert_read_bytes(path)` ```python -bigfoot.file_io_mock.assert_read_bytes(path="/var/data/image.png") +tripwire.file_io_mock.assert_read_bytes(path="/var/data/image.png") ``` ### `assert_write_text(path, data)` ```python -bigfoot.file_io_mock.assert_write_text(path="/tmp/output.txt", data="result: success") +tripwire.file_io_mock.assert_write_text(path="/tmp/output.txt", data="result: success") ``` ### `assert_write_bytes(path, data)` ```python -bigfoot.file_io_mock.assert_write_bytes(path="/tmp/output.bin", data=b"\x00\x01\x02") +tripwire.file_io_mock.assert_write_bytes(path="/tmp/output.bin", data=b"\x00\x01\x02") ``` ### `assert_remove(path)` @@ -139,7 +139,7 @@ bigfoot.file_io_mock.assert_write_bytes(path="/tmp/output.bin", data=b"\x00\x01\ Matches both `os.remove` and `os.unlink` interactions: ```python -bigfoot.file_io_mock.assert_remove(path="/tmp/old-file.txt") +tripwire.file_io_mock.assert_remove(path="/tmp/old-file.txt") ``` ### `assert_rename(src, dst)` @@ -147,19 +147,19 @@ bigfoot.file_io_mock.assert_remove(path="/tmp/old-file.txt") Matches both `os.rename` and `os.replace` interactions: ```python -bigfoot.file_io_mock.assert_rename(src="/tmp/draft.txt", dst="/tmp/final.txt") +tripwire.file_io_mock.assert_rename(src="/tmp/draft.txt", dst="/tmp/final.txt") ``` ### `assert_makedirs(path, exist_ok)` ```python -bigfoot.file_io_mock.assert_makedirs(path="/var/data/exports", exist_ok=True) +tripwire.file_io_mock.assert_makedirs(path="/var/data/exports", exist_ok=True) ``` ### `assert_mkdir(path)` ```python -bigfoot.file_io_mock.assert_mkdir(path="/tmp/workdir") +tripwire.file_io_mock.assert_mkdir(path="/tmp/workdir") ``` ### `assert_copy(src, dst)` @@ -167,19 +167,19 @@ bigfoot.file_io_mock.assert_mkdir(path="/tmp/workdir") Matches both `shutil.copy` and `shutil.copy2` interactions: ```python -bigfoot.file_io_mock.assert_copy(src="/etc/myapp/config.yaml", dst="/tmp/config-backup.yaml") +tripwire.file_io_mock.assert_copy(src="/etc/myapp/config.yaml", dst="/tmp/config-backup.yaml") ``` ### `assert_copytree(src, dst)` ```python -bigfoot.file_io_mock.assert_copytree(src="/var/data/source", dst="/var/data/archive") +tripwire.file_io_mock.assert_copytree(src="/var/data/source", dst="/var/data/archive") ``` ### `assert_rmtree(path)` ```python -bigfoot.file_io_mock.assert_rmtree(path="/tmp/build-artifacts") +tripwire.file_io_mock.assert_rmtree(path="/tmp/build-artifacts") ``` ## Simulating errors @@ -187,20 +187,20 @@ bigfoot.file_io_mock.assert_rmtree(path="/tmp/build-artifacts") Use the `raises` parameter to simulate file system errors: ```python -import bigfoot +import tripwire def test_file_not_found(): - bigfoot.file_io_mock.mock_operation( + tripwire.file_io_mock.mock_operation( "read_text", "/etc/myapp/config.yaml", raises=FileNotFoundError("[Errno 2] No such file or directory: '/etc/myapp/config.yaml'"), ) - with bigfoot: + with tripwire: from pathlib import Path with pytest.raises(FileNotFoundError): Path("/etc/myapp/config.yaml").read_text() - bigfoot.file_io_mock.assert_read_text(path="/etc/myapp/config.yaml") + tripwire.file_io_mock.assert_read_text(path="/etc/myapp/config.yaml") ``` ## Full example @@ -227,18 +227,18 @@ When mocking `open()`, the return value is automatically wrapped in the appropri ```python def test_open_read(): - bigfoot.file_io_mock.mock_operation( + tripwire.file_io_mock.mock_operation( "open", "/tmp/data.csv", returns="id,name\n1,Alice\n2,Bob", ) - with bigfoot: + with tripwire: with open("/tmp/data.csv", "r") as f: lines = f.readlines() assert len(lines) == 3 - bigfoot.file_io_mock.assert_open(path="/tmp/data.csv", mode="r", encoding="utf-8") + tripwire.file_io_mock.assert_open(path="/tmp/data.csv", mode="r", encoding="utf-8") ``` ## Optional mocks @@ -246,17 +246,17 @@ def test_open_read(): Mark a mock as optional with `required=False`: ```python -bigfoot.file_io_mock.mock_operation("read_text", "/tmp/cache.json", returns="{}", required=False) +tripwire.file_io_mock.mock_operation("read_text", "/tmp/cache.json", returns="{}", required=False) ``` An optional mock that is never triggered does not cause `UnusedMocksError` at teardown. ## UnmockedInteractionError -When code performs a file operation that has no remaining mocks in its queue, bigfoot raises `UnmockedInteractionError`: +When code performs a file operation that has no remaining mocks in its queue, tripwire raises `UnmockedInteractionError`: ``` Path.read_text('/etc/myapp/config.yaml', ...) was called but no mock was registered. Register a mock with: - bigfoot.file_io_mock.mock_operation('read_text', '/etc/myapp/config.yaml', returns=...) + tripwire.file_io_mock.mock_operation('read_text', '/etc/myapp/config.yaml', returns=...) ``` diff --git a/docs/guides/grpc-plugin.md b/docs/guides/grpc-plugin.md index 5177ba6..3f6c6ff 100644 --- a/docs/guides/grpc-plugin.md +++ b/docs/guides/grpc-plugin.md @@ -5,25 +5,25 @@ ## Installation ```bash -pip install bigfoot[grpc] +pip install tripwire[grpc] ``` This installs `grpcio`. ## Setup -In pytest, access `GrpcPlugin` through the `bigfoot.grpc_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `GrpcPlugin` through the `tripwire.grpc_mock` proxy. It auto-creates the plugin for the current test on first use: ```python -import bigfoot +import tripwire def test_grpc_unary_call(): - bigfoot.grpc_mock.mock_unary_unary( + tripwire.grpc_mock.mock_unary_unary( "/mypackage.UserService/GetUser", returns={"id": 1, "name": "Alice"}, ) - with bigfoot: + with tripwire: import grpc channel = grpc.insecure_channel("localhost:50051") stub = channel.unary_unary("/mypackage.UserService/GetUser") @@ -31,7 +31,7 @@ def test_grpc_unary_call(): assert response["name"] == "Alice" - bigfoot.grpc_mock.assert_unary_unary( + tripwire.grpc_mock.assert_unary_unary( "/mypackage.UserService/GetUser", request={"id": 1}, metadata=None, @@ -41,8 +41,8 @@ def test_grpc_unary_call(): For manual use outside pytest, construct `GrpcPlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.grpc_plugin import GrpcPlugin +from tripwire import StrictVerifier +from tripwire.plugins.grpc_plugin import GrpcPlugin verifier = StrictVerifier() grpc_mock = GrpcPlugin(verifier) @@ -57,7 +57,7 @@ GrpcPlugin provides four mock registration methods, one for each call type: ### `mock_unary_unary(method, *, returns, ...)` ```python -bigfoot.grpc_mock.mock_unary_unary("/pkg.Svc/DoThing", returns={"status": "ok"}) +tripwire.grpc_mock.mock_unary_unary("/pkg.Svc/DoThing", returns={"status": "ok"}) ``` | Parameter | Type | Default | Description | @@ -72,7 +72,7 @@ bigfoot.grpc_mock.mock_unary_unary("/pkg.Svc/DoThing", returns={"status": "ok"}) For server streaming RPCs, `returns` is a list of responses that are yielded to the caller: ```python -bigfoot.grpc_mock.mock_unary_stream( +tripwire.grpc_mock.mock_unary_stream( "/pkg.Svc/ListItems", returns=[{"id": 1}, {"id": 2}, {"id": 3}], ) @@ -90,7 +90,7 @@ bigfoot.grpc_mock.mock_unary_stream( For client streaming RPCs, the client sends a stream of requests and receives a single response: ```python -bigfoot.grpc_mock.mock_stream_unary( +tripwire.grpc_mock.mock_stream_unary( "/pkg.Svc/UploadChunks", returns={"bytes_received": 1024}, ) @@ -108,7 +108,7 @@ bigfoot.grpc_mock.mock_stream_unary( For bidirectional streaming RPCs, `returns` is a list of responses yielded to the caller: ```python -bigfoot.grpc_mock.mock_stream_stream( +tripwire.grpc_mock.mock_stream_stream( "/pkg.Svc/Chat", returns=[{"text": "Hello"}, {"text": "How can I help?"}], ) @@ -127,10 +127,10 @@ Each (call_type, method) pair has its own independent FIFO queue. Multiple mocks ```python def test_multiple_unary_calls(): - bigfoot.grpc_mock.mock_unary_unary("/pkg.Svc/GetUser", returns={"id": 1, "name": "Alice"}) - bigfoot.grpc_mock.mock_unary_unary("/pkg.Svc/GetUser", returns={"id": 2, "name": "Bob"}) + tripwire.grpc_mock.mock_unary_unary("/pkg.Svc/GetUser", returns={"id": 1, "name": "Alice"}) + tripwire.grpc_mock.mock_unary_unary("/pkg.Svc/GetUser", returns={"id": 2, "name": "Bob"}) - with bigfoot: + with tripwire: import grpc channel = grpc.insecure_channel("localhost:50051") stub = channel.unary_unary("/pkg.Svc/GetUser") @@ -140,18 +140,18 @@ def test_multiple_unary_calls(): assert r1["name"] == "Alice" assert r2["name"] == "Bob" - bigfoot.grpc_mock.assert_unary_unary("/pkg.Svc/GetUser", request={"id": 1}) - bigfoot.grpc_mock.assert_unary_unary("/pkg.Svc/GetUser", request={"id": 2}) + tripwire.grpc_mock.assert_unary_unary("/pkg.Svc/GetUser", request={"id": 1}) + tripwire.grpc_mock.assert_unary_unary("/pkg.Svc/GetUser", request={"id": 2}) ``` ## Asserting interactions -Use the typed assertion helpers on `bigfoot.grpc_mock`. All fields (`method`, `request`, `metadata`) are required: +Use the typed assertion helpers on `tripwire.grpc_mock`. All fields (`method`, `request`, `metadata`) are required: ### `assert_unary_unary(method, request, metadata=None)` ```python -bigfoot.grpc_mock.assert_unary_unary( +tripwire.grpc_mock.assert_unary_unary( "/mypackage.UserService/GetUser", request={"id": 1}, metadata=None, @@ -161,7 +161,7 @@ bigfoot.grpc_mock.assert_unary_unary( ### `assert_unary_stream(method, request, metadata=None)` ```python -bigfoot.grpc_mock.assert_unary_stream( +tripwire.grpc_mock.assert_unary_stream( "/mypackage.ItemService/ListItems", request={"category": "electronics"}, metadata=None, @@ -173,7 +173,7 @@ bigfoot.grpc_mock.assert_unary_stream( For client streaming RPCs, `request` is a list (the iterator is eagerly consumed and stored): ```python -bigfoot.grpc_mock.assert_stream_unary( +tripwire.grpc_mock.assert_stream_unary( "/mypackage.UploadService/UploadChunks", request=[b"chunk1", b"chunk2", b"chunk3"], metadata=None, @@ -185,7 +185,7 @@ bigfoot.grpc_mock.assert_stream_unary( For bidirectional streaming RPCs, `request` is a list: ```python -bigfoot.grpc_mock.assert_stream_stream( +tripwire.grpc_mock.assert_stream_stream( "/mypackage.ChatService/Chat", request=[{"text": "Hi"}, {"text": "Help me"}], metadata=None, @@ -204,36 +204,36 @@ Use the `raises` parameter to simulate gRPC errors: ```python import grpc as grpc_lib -import bigfoot +import tripwire def test_grpc_unavailable(): - bigfoot.grpc_mock.mock_unary_unary( + tripwire.grpc_mock.mock_unary_unary( "/pkg.Svc/GetUser", returns=None, raises=grpc_lib.RpcError(), ) - with bigfoot: + with tripwire: import grpc channel = grpc.insecure_channel("localhost:50051") stub = channel.unary_unary("/pkg.Svc/GetUser") with pytest.raises(grpc_lib.RpcError): stub({"id": 1}) - bigfoot.grpc_mock.assert_unary_unary("/pkg.Svc/GetUser", request={"id": 1}) + tripwire.grpc_mock.assert_unary_unary("/pkg.Svc/GetUser", request={"id": 1}) ``` For streaming responses, the `raises` parameter causes the exception to be raised after all responses have been yielded: ```python def test_stream_partial_failure(): - bigfoot.grpc_mock.mock_unary_stream( + tripwire.grpc_mock.mock_unary_stream( "/pkg.Svc/ListItems", returns=[{"id": 1}, {"id": 2}], raises=grpc_lib.RpcError(), ) - with bigfoot: + with tripwire: import grpc channel = grpc.insecure_channel("localhost:50051") stub = channel.unary_stream("/pkg.Svc/ListItems") @@ -244,7 +244,7 @@ def test_stream_partial_failure(): assert len(results) == 2 - bigfoot.grpc_mock.assert_unary_stream( + tripwire.grpc_mock.assert_unary_stream( "/pkg.Svc/ListItems", request={"category": "all"}, ) ``` @@ -269,9 +269,9 @@ def test_stream_partial_failure(): ```python def test_secure_channel(): - bigfoot.grpc_mock.mock_unary_unary("/pkg.Svc/GetSecret", returns={"value": "s3cr3t"}) + tripwire.grpc_mock.mock_unary_unary("/pkg.Svc/GetSecret", returns={"value": "s3cr3t"}) - with bigfoot: + with tripwire: import grpc creds = grpc.ssl_channel_credentials() channel = grpc.secure_channel("secure.example.com:443", creds) @@ -280,7 +280,7 @@ def test_secure_channel(): assert response["value"] == "s3cr3t" - bigfoot.grpc_mock.assert_unary_unary("/pkg.Svc/GetSecret", request={"key": "api_token"}) + tripwire.grpc_mock.assert_unary_unary("/pkg.Svc/GetSecret", request={"key": "api_token"}) ``` ## Optional mocks @@ -288,17 +288,17 @@ def test_secure_channel(): Mark a mock as optional with `required=False`: ```python -bigfoot.grpc_mock.mock_unary_unary("/pkg.Svc/Ping", returns={"status": "ok"}, required=False) +tripwire.grpc_mock.mock_unary_unary("/pkg.Svc/Ping", returns={"status": "ok"}, required=False) ``` An optional mock that is never triggered does not cause `UnusedMocksError` at teardown. ## UnmockedInteractionError -When code makes a gRPC call that has no remaining mocks in its queue, bigfoot raises `UnmockedInteractionError`: +When code makes a gRPC call that has no remaining mocks in its queue, tripwire raises `UnmockedInteractionError`: ``` grpc.unary_unary('/pkg.Svc/GetUser') was called but no mock was registered. Register a mock with: - bigfoot.grpc_mock.mock_unary_unary('/pkg.Svc/GetUser', returns=...) + tripwire.grpc_mock.mock_unary_unary('/pkg.Svc/GetUser', returns=...) ``` diff --git a/docs/guides/guard-mode.md b/docs/guides/guard-mode.md index 5fb0d03..4f15828 100644 --- a/docs/guides/guard-mode.md +++ b/docs/guides/guard-mode.md @@ -2,9 +2,9 @@ ## What is firewall mode? -Firewall mode prevents accidental real I/O during tests. bigfoot installs interceptors at **session startup** and keeps them active for the entire test run. Every I/O call is routed through bigfoot, and any call that is not covered by a sandbox or an explicit firewall rule is either warned about or blocked, depending on the firewall level. +Firewall mode prevents accidental real I/O during tests. tripwire installs interceptors at **session startup** and keeps them active for the entire test run. Every I/O call is routed through tripwire, and any call that is not covered by a sandbox or an explicit firewall rule is either warned about or blocked, depending on the firewall level. -In earlier versions of bigfoot, this was called "guard mode" with coarse plugin-level `allow("http")` / `deny("redis")` rules. The firewall redesign introduces **granular pattern matching** via `M()` objects, **TOML-based configuration**, **ceiling restrictions** via `restrict()`, and **protocol-typed request objects** (`FirewallRequest`). +In earlier versions of tripwire, this was called "guard mode" with coarse plugin-level `allow("http")` / `deny("redis")` rules. The firewall redesign introduces **granular pattern matching** via `M()` objects, **TOML-based configuration**, **ceiling restrictions** via `restrict()`, and **protocol-typed request objects** (`FirewallRequest`). ## I just saw a warning. What do I do? @@ -12,10 +12,10 @@ If you see a warning like this: ``` GuardedCallWarning: 'http:request' called outside sandbox. -Silence with @pytest.mark.allow("http") or set guard = "error" in [tool.bigfoot] to make this an error. +Silence with @pytest.mark.allow("http") or set guard = "error" in [tool.tripwire] to make this an error. ``` -This means your test made a real I/O call outside a bigfoot sandbox. The call still executed normally. To silence the warning, pick one of these options: +This means your test made a real I/O call outside a tripwire sandbox. The call still executed normally. To silence the warning, pick one of these options: **Option 1: Allow the plugin for the entire test** (most common): @@ -28,7 +28,7 @@ def test_something(): **Option 2: Allow with a granular pattern:** ```python -from bigfoot import M +from tripwire import M @pytest.mark.allow(M(protocol="http", host="*.example.com")) def test_something(): @@ -38,14 +38,14 @@ def test_something(): **Option 3: Allow in a scoped block:** ```python -with bigfoot.allow("http"): +with tripwire.allow("http"): ... ``` **Option 4: Mock the call with a sandbox:** ```python -with bigfoot: +with tripwire: ... ``` @@ -56,7 +56,7 @@ with bigfoot: The simplest way to use the firewall is with string-based plugin names, which works the same as the old guard mode: ```python -import bigfoot +import tripwire # Mark: allow for entire test @pytest.mark.allow("http") @@ -65,7 +65,7 @@ def test_needs_http(): # Context manager: allow for a block def test_scoped(): - with bigfoot.allow("dns", "socket"): + with tripwire.allow("dns", "socket"): ... ``` @@ -74,7 +74,7 @@ def test_scoped(): For granular control, use `M()` pattern objects: ```python -from bigfoot import M +from tripwire import M # Allow HTTP only to specific hosts @pytest.mark.allow(M(protocol="http", host="*.example.com")) @@ -94,7 +94,7 @@ def test_cache_reads(): ## Firewall levels -Configure the firewall level in `pyproject.toml` under `[tool.bigfoot]`: +Configure the firewall level in `pyproject.toml` under `[tool.tripwire]`: | Level | Config | Behavior | |-------|--------|----------| @@ -105,7 +105,7 @@ Configure the firewall level in `pyproject.toml` under `[tool.bigfoot]`: ```toml # pyproject.toml -[tool.bigfoot] +[tool.tripwire] guard = "error" # strict enforcement ``` @@ -120,7 +120,7 @@ The `M()` object lets you define granular firewall rules that match against `Fir `M()` accepts keyword arguments. Each key corresponds to a field on the protocol's `FirewallRequest` dataclass: ```python -from bigfoot import M +from tripwire import M # Exact match M(protocol="http", method="GET") @@ -171,13 +171,13 @@ def test_multi_host(): ### Basic TOML firewall config -The `[tool.bigfoot.firewall]` section in `pyproject.toml` replaces the old `guard_allow` key. It provides structured, per-protocol rules: +The `[tool.tripwire.firewall]` section in `pyproject.toml` replaces the old `guard_allow` key. It provides structured, per-protocol rules: ```toml -[tool.bigfoot] +[tool.tripwire] guard = "error" -[tool.bigfoot.firewall] +[tool.tripwire.firewall] allow = [ "http://*.example.com", "http://api.stripe.com", @@ -192,7 +192,7 @@ allow = [ ### Denying in TOML ```toml -[tool.bigfoot.firewall] +[tool.tripwire.firewall] deny = ["http://*.production.internal"] ``` @@ -202,22 +202,22 @@ Override firewall rules for specific test files using the flat `per-file-allow` Keys are glob patterns matched against test file paths; values are lists of allow rules: ```toml -[tool.bigfoot.firewall.per-file-allow] +[tool.tripwire.firewall.per-file-allow] "tests/integration/test_api.py" = ["http:*"] "tests/api/*" = ["http:*", "dns:*"] ``` ### Legacy `guard_allow` migration -The old `guard_allow` config key has been removed. If you see a `BigfootConfigError` about `guard_allow`, migrate as follows: +The old `guard_allow` config key has been removed. If you see a `TripwireConfigError` about `guard_allow`, migrate as follows: ```toml # OLD (removed): -[tool.bigfoot] +[tool.tripwire] guard_allow = ["socket", "database"] # NEW: -[tool.bigfoot.firewall] +[tool.tripwire.firewall] allow = ["socket:*", "database:*"] ``` @@ -227,7 +227,7 @@ Firewall rules combine from three sources, with later sources able to narrow but 1. **TOML** (`pyproject.toml`): Project-wide defaults. Applied to every test. 2. **Marks** (`@pytest.mark.allow`, `@pytest.mark.deny`): Per-test overrides. Can widen or narrow the TOML rules. -3. **Context managers** (`bigfoot.allow()`, `bigfoot.deny()`, `bigfoot.restrict()`): Scoped blocks within a test. `restrict()` enforces a ceiling that inner blocks cannot widen. +3. **Context managers** (`tripwire.allow()`, `tripwire.deny()`, `tripwire.restrict()`): Scoped blocks within a test. `restrict()` enforces a ceiling that inner blocks cannot widen. ### Precedence @@ -284,16 +284,16 @@ def test_network_but_not_http(): Widen the allowlist for a scoped block: ```python -import bigfoot +import tripwire def test_boto3_integration(): - bigfoot.boto3_mock.mock_api_call("s3", "PutObject", returns={}) + tripwire.boto3_mock.mock_api_call("s3", "PutObject", returns={}) - with bigfoot.allow("dns", "socket"): - with bigfoot: + with tripwire.allow("dns", "socket"): + with tripwire: upload_file("my-bucket", "key", b"data") - bigfoot.boto3_mock.assert_api_call( + tripwire.boto3_mock.assert_api_call( service="s3", operation="PutObject", params={"Bucket": "my-bucket"}, ) ``` @@ -301,9 +301,9 @@ def test_boto3_integration(): `allow()` calls are additive. Inner blocks add to the outer allowlist: ```python -with bigfoot.allow("dns"): +with tripwire.allow("dns"): # dns is allowed - with bigfoot.allow("socket"): + with tripwire.allow("socket"): # both dns and socket are allowed # back to dns only ``` @@ -313,8 +313,8 @@ with bigfoot.allow("dns"): Narrow the current allowlist: ```python -with bigfoot.allow("dns", "socket", "http"): - with bigfoot.deny("http"): +with tripwire.allow("dns", "socket", "http"): + with tripwire.deny("http"): # dns and socket still pass through # http is guarded again ... @@ -328,9 +328,9 @@ Like `allow()`, `deny()` blocks nest and restore the previous state on exit. Set a **ceiling** that inner blocks cannot widen. This is the key new context manager in the firewall redesign: ```python -with bigfoot.restrict("http"): +with tripwire.restrict("http"): # Only http is allowed in this block, nothing else - with bigfoot.allow("dns"): + with tripwire.allow("dns"): # dns is NOT allowed here -- restrict() prevents widening # only http is still allowed ... @@ -339,10 +339,10 @@ with bigfoot.restrict("http"): `restrict()` is useful for enforcing that a code path only makes specific types of calls: ```python -from bigfoot import M +from tripwire import M def test_payment_isolation(): - with bigfoot.restrict(M(protocol="http", host="api.stripe.com")): + with tripwire.restrict(M(protocol="http", host="api.stripe.com")): # Only Stripe HTTP calls are allowed # Any other HTTP call, or any non-HTTP call, is blocked process_payment(amount=5000) @@ -355,7 +355,7 @@ Fixtures can set up firewall rules during test setup: ```python @pytest.fixture def allow_dns(): - with bigfoot.allow("dns"): + with tripwire.allow("dns"): yield ``` @@ -393,18 +393,18 @@ Plugin authors constructing `FirewallRequest` objects should populate all availa ## How it works -bigfoot's pytest plugin installs two layers of firewall infrastructure: +tripwire's pytest plugin installs two layers of firewall infrastructure: -1. **Session-scoped patches** (`_bigfoot_guard_patches`): At the start of the test session, bigfoot activates every guard-eligible plugin. The interceptors remain installed for the entire session. +1. **Session-scoped patches** (`_tripwire_guard_patches`): At the start of the test session, tripwire activates every guard-eligible plugin. The interceptors remain installed for the entire session. -2. **Per-test firewall activation** (`pytest_runtest_call` hook): During each test function's body, bigfoot sets the firewall ContextVars. When an interceptor fires and there is no active sandbox, it checks firewall state and either warns, blocks, or passes through. +2. **Per-test firewall activation** (`pytest_runtest_call` hook): During each test function's body, tripwire sets the firewall ContextVars. When an interceptor fires and there is no active sandbox, it checks firewall state and either warns, blocks, or passes through. ### Decision tree When an interceptor fires, `get_verifier_or_raise()` follows this precedence: 1. **Sandbox active**: Return the verifier. The call is mocked and recorded as usual. -2. **Firewall active, request matches allow rule**: Raise `GuardPassThrough` internally. The interceptor catches this and delegates to the original function. The call is invisible to bigfoot. +2. **Firewall active, request matches allow rule**: Raise `GuardPassThrough` internally. The interceptor catches this and delegates to the original function. The call is invisible to tripwire. 3. **Firewall active, request matches deny rule** (or no allow rule matches): Check firewall level. 4. **Warn mode**: Emit `GuardedCallWarning`, then raise `GuardPassThrough`. The real call proceeds. 5. **Error mode**: Raise `GuardedCallError`. The test fails immediately. @@ -418,7 +418,7 @@ In short: **sandbox > allow/deny/restrict > firewall level**. ### HTTP ```python -from bigfoot import M +from tripwire import M # Allow all HTTP to a specific host @pytest.mark.allow(M(protocol="http", host="api.example.com")) @@ -434,7 +434,7 @@ def test_readonly_api(): ### Redis ```python -from bigfoot import M +from tripwire import M # Allow read-only Redis commands @pytest.mark.allow(M(protocol="redis", command="GET")) @@ -450,7 +450,7 @@ def test_local_redis(): ### Subprocess ```python -from bigfoot import M +from tripwire import M # Allow specific binaries @pytest.mark.allow(M(protocol="subprocess", command="/usr/bin/git")) @@ -466,7 +466,7 @@ def test_local_tools(): ### boto3 ```python -from bigfoot import M +from tripwire import M # Allow S3 operations only @pytest.mark.allow(M(protocol="boto3", service="s3")) @@ -488,17 +488,17 @@ boto3 makes DNS lookups and raw socket connections internally. A test that mocks ```python import pytest -import bigfoot -from bigfoot import M +import tripwire +from tripwire import M @pytest.mark.allow("dns", "socket") def test_s3_upload(): - bigfoot.boto3_mock.mock_api_call("s3", "PutObject", returns={}) + tripwire.boto3_mock.mock_api_call("s3", "PutObject", returns={}) - with bigfoot: + with tripwire: upload_to_s3("my-bucket", "my-key", b"hello") - bigfoot.boto3_mock.assert_api_call( + tripwire.boto3_mock.assert_api_call( service="s3", operation="PutObject", params={"Bucket": "my-bucket", "Key": "my-key", "Body": b"hello"}, ) @@ -509,7 +509,7 @@ def test_s3_upload(): When firewall mode is set to `"error"` and blocks a call, `GuardedCallError` provides resolution options: ``` -GuardedCallError: 'http:request' blocked by bigfoot firewall. +GuardedCallError: 'http:request' blocked by tripwire firewall. Request details: protocol=http, method=POST, host=api.stripe.com, path=/v1/charges @@ -527,17 +527,17 @@ GuardedCallError: 'http:request' blocked by bigfoot firewall. ... # Or use a context manager (scoped to a block): - with bigfoot.allow("http"): + with tripwire.allow("http"): ... # Or mock the call with a sandbox: - with bigfoot: + with tripwire: ... Valid plugin names for allow(): async_subprocess, async_websocket, ... - Docs: https://bigfoot.readthedocs.io/guides/guard-mode/ + Docs: https://tripwire.readthedocs.io/guides/guard-mode/ ``` ## Filtering warnings @@ -546,7 +546,7 @@ In warn mode, you can filter `GuardedCallWarning` using Python's standard `warni ```python import warnings -from bigfoot import GuardedCallWarning +from tripwire import GuardedCallWarning # Suppress all guard warnings warnings.filterwarnings("ignore", category=GuardedCallWarning) @@ -560,7 +560,7 @@ Or in `pyproject.toml` via pytest's warning filters: ```toml [tool.pytest.ini_options] filterwarnings = [ - "ignore::bigfoot.GuardedCallWarning", + "ignore::tripwire.GuardedCallWarning", ] ``` @@ -614,7 +614,7 @@ These plugins set `supports_guard = False` because they do not perform external Firewall mode and sandbox mode are complementary: -- **Inside a sandbox** (`with bigfoot:`): All calls are intercepted, mocked, and recorded. Firewall mode is irrelevant because the sandbox verifier handles everything. +- **Inside a sandbox** (`with tripwire:`): All calls are intercepted, mocked, and recorded. Firewall mode is irrelevant because the sandbox verifier handles everything. - **Outside a sandbox, firewall active**: Calls to firewall-eligible plugins are checked against the allow/deny rules and `M()` patterns. Calls that do not match an allow rule are warned about or blocked. The `restrict()` context manager can set a ceiling that inner blocks cannot widen. - **Outside a sandbox, firewall inactive** (fixture setup/teardown): Interceptors are installed but pass through to originals. This prevents the firewall from interfering with test infrastructure. diff --git a/docs/guides/how-it-works.md b/docs/guides/how-it-works.md index 3c10ffc..01d35b6 100644 --- a/docs/guides/how-it-works.md +++ b/docs/guides/how-it-works.md @@ -1,20 +1,20 @@ -# How bigfoot Works +# How tripwire Works -This guide explains bigfoot's architecture: how the sandbox intercepts external calls, how interactions are recorded and asserted, and how the plugin system ties it all together. +This guide explains tripwire's architecture: how the sandbox intercepts external calls, how interactions are recorded and asserted, and how the plugin system ties it all together. ## The Three Guarantees -bigfoot enforces three rules that most mocking libraries leave silent: +tripwire enforces three rules that most mocking libraries leave silent: -1. **Every call must be pre-authorized.** If your code makes an external call with no registered mock, bigfoot raises `UnmockedInteractionError` immediately, not at teardown. -2. **Every recorded interaction must be explicitly asserted.** If an interaction is recorded but never asserted, bigfoot raises `UnassertedInteractionsError` at teardown. -3. **Every registered mock must actually be triggered.** If you register a mock that never fires, bigfoot raises `UnusedMocksError` at teardown. +1. **Every call must be pre-authorized.** If your code makes an external call with no registered mock, tripwire raises `UnmockedInteractionError` immediately, not at teardown. +2. **Every recorded interaction must be explicitly asserted.** If an interaction is recorded but never asserted, tripwire raises `UnassertedInteractionsError` at teardown. +3. **Every registered mock must actually be triggered.** If you register a mock that never fires, tripwire raises `UnusedMocksError` at teardown. Together, these guarantees mean that when a test passes, you know exactly what happened -- not just that nothing crashed. ## Sandbox Lifecycle -The core entry point is `with bigfoot:`, which creates a **sandbox** -- a controlled environment where all external calls are intercepted. Here is the exact sequence of events: +The core entry point is `with tripwire:`, which creates a **sandbox** -- a controlled environment where all external calls are intercepted. Here is the exact sequence of events: ### Entering the sandbox @@ -41,7 +41,7 @@ The `SandboxContext` supports both `with` and `async with`, using the same `_ent ## Interception Model -bigfoot intercepts external calls through class-level monkeypatching. The design uses two key patterns: **module-level capture of originals** and **class-level reference counting**. +tripwire intercepts external calls through class-level monkeypatching. The design uses two key patterns: **module-level capture of originals** and **class-level reference counting**. ### Module-level capture of originals @@ -82,11 +82,11 @@ This means patches are shared across all verifier instances. The reference count ## ContextVar Routing -The central question for any interceptor is: "which verifier should I report to?" bigfoot answers this with a `ContextVar`: +The central question for any interceptor is: "which verifier should I report to?" tripwire answers this with a `ContextVar`: ```python _active_verifier: contextvars.ContextVar[StrictVerifier | None] = contextvars.ContextVar( - "bigfoot_active_verifier", default=None + "tripwire_active_verifier", default=None ) ``` @@ -100,12 +100,12 @@ Python's `contextvars.ContextVar` is both **thread-safe** and **async-safe**. Ea - Multiple async tasks can run separate sandboxes concurrently without interference. - No global mutable state, no locks needed for routing. -bigfoot uses three ContextVars: +tripwire uses three ContextVars: | ContextVar | Purpose | |---|---| | `_active_verifier` | Points interceptors to the current verifier. Set on sandbox enter, reset on exit. | -| `_current_test_verifier` | Points module-level API functions (`bigfoot.mock()`, `bigfoot.assert_interaction()`) to the per-test verifier. Managed by the pytest fixture. | +| `_current_test_verifier` | Points module-level API functions (`tripwire.mock()`, `tripwire.assert_interaction()`) to the per-test verifier. Managed by the pytest fixture. | | `_any_order_depth` | Tracks nesting depth of `in_any_order()` blocks. When > 0, assertions match in any order. | ## Timeline and Interactions @@ -143,7 +143,7 @@ Sequence numbers establish a total ordering of all interactions across all plugi ### Recording guard -The `BasePlugin.record()` method sets a `_recording_in_progress` ContextVar before appending to the timeline. If any code calls `Timeline.mark_asserted()` while recording is in progress, bigfoot raises `AutoAssertError`. This is a runtime guard against the auto-assert anti-pattern -- plugins must never mark interactions as asserted during recording. +The `BasePlugin.record()` method sets a `_recording_in_progress` ContextVar before appending to the timeline. If any code calls `Timeline.mark_asserted()` while recording is in progress, tripwire raises `AutoAssertError`. This is a runtime guard against the auto-assert anti-pattern -- plugins must never mark interactions as asserted during recording. ## Mock Queues @@ -172,7 +172,7 @@ If the queue is empty and no `wraps` object is configured, `UnmockedInteractionE The FIFO pattern means you can chain multiple configurations to handle sequential calls: ```python -db = bigfoot.mock("db") +db = tripwire.mock("db") db.query.returns(["row1"]).returns(["row2"]) # First call returns ["row1"], second returns ["row2"] ``` @@ -185,7 +185,7 @@ Assertions happen in two phases: **inline assertions** during the test, and **te ### Inline assertions: `assert_interaction()` -When you call `assert_interaction()` (or a plugin helper like `http.assert_request()`), bigfoot: +When you call `assert_interaction()` (or a plugin helper like `http.assert_request()`), tripwire: 1. **Finds the next unasserted interaction** by peeking at the timeline. In normal mode, this is strictly the next unasserted interaction in sequence order. Inside an `in_any_order()` block, it searches all unasserted interactions for a match. @@ -210,7 +210,7 @@ If both violations exist, they are combined into a single `VerificationError` so ## Plugin Registry -bigfoot uses a registry to manage its built-in plugins and supports entry points for third-party plugins. +tripwire uses a registry to manage its built-in plugins and supports entry points for third-party plugins. ### Built-in registry @@ -220,7 +220,7 @@ The `PLUGIN_REGISTRY` tuple in `_registry.py` lists every built-in plugin with i @dataclass(frozen=True) class PluginEntry: name: str # e.g., "http" - import_path: str # e.g., "bigfoot.plugins.http" + import_path: str # e.g., "tripwire.plugins.http" class_name: str # e.g., "HttpPlugin" availability_check: str # dependency check strategy default_enabled: bool # False for opt-in plugins @@ -243,19 +243,19 @@ Availability is checked via the `availability_check` field: | `"redis"` | Single module; must be importable | | `"flag:module:attr"` | Read a boolean flag from a plugin module | -Plugins whose dependencies are not installed are silently skipped -- unless they were explicitly listed in `enabled_plugins`, which raises `BigfootConfigError`. +Plugins whose dependencies are not installed are silently skipped -- unless they were explicitly listed in `enabled_plugins`, which raises `TripwireConfigError`. ### Third-party plugins via entry points -After built-in plugins are loaded, bigfoot discovers third-party plugins registered under the `bigfoot.plugins` entry point group: +After built-in plugins are loaded, tripwire discovers third-party plugins registered under the `tripwire.plugins` entry point group: ```python -for ep in entry_points(group="bigfoot.plugins"): +for ep in entry_points(group="tripwire.plugins"): plugin_cls = ep.load() plugin_cls(verifier) ``` -This allows library authors to ship bigfoot plugins that activate automatically when installed. +This allows library authors to ship tripwire plugins that activate automatically when installed. ### Deduplication @@ -263,20 +263,20 @@ The `_register_plugin()` method on `StrictVerifier` silently skips duplicate plu ## pytest Integration -bigfoot ships as a pytest plugin, registered via the `bigfoot` entry point. It provides two fixtures: +tripwire ships as a pytest plugin, registered via the `tripwire` entry point. It provides two fixtures: -### `_bigfoot_auto_verifier` (autouse) +### `_tripwire_auto_verifier` (autouse) This fixture runs automatically for every test. It: 1. Creates a fresh `StrictVerifier` (which auto-instantiates all enabled plugins). -2. Sets the `_current_test_verifier` ContextVar so module-level functions like `bigfoot.mock()` and `bigfoot.http.mock_response()` can find the verifier. +2. Sets the `_current_test_verifier` ContextVar so module-level functions like `tripwire.mock()` and `tripwire.http.mock_response()` can find the verifier. 3. Yields the verifier to the test. 4. On teardown, resets the ContextVar and calls `verify_all()`. ```python @pytest.fixture(autouse=True) -def _bigfoot_auto_verifier() -> Generator[StrictVerifier, None, None]: +def _tripwire_auto_verifier() -> Generator[StrictVerifier, None, None]: verifier = StrictVerifier() token = _current_test_verifier.set(verifier) yield verifier @@ -284,12 +284,12 @@ def _bigfoot_auto_verifier() -> Generator[StrictVerifier, None, None]: verifier.verify_all() ``` -Because it is autouse, test authors do not need to request it. Every test gets a verifier, and every test gets `verify_all()` at teardown. If a test does not use bigfoot at all, `verify_all()` is a no-op (no interactions, no mocks, nothing to verify). +Because it is autouse, test authors do not need to request it. Every test gets a verifier, and every test gets `verify_all()` at teardown. If a test does not use tripwire at all, `verify_all()` is a no-op (no interactions, no mocks, nothing to verify). -### `bigfoot_verifier` (explicit) +### `tripwire_verifier` (explicit) -For tests that need direct access to the verifier instance (e.g., to manually construct plugins), the `bigfoot_verifier` fixture exposes the same verifier created by the autouse fixture. +For tests that need direct access to the verifier instance (e.g., to manually construct plugins), the `tripwire_verifier` fixture exposes the same verifier created by the autouse fixture. ### The sandbox is not automatic -The pytest plugin creates the verifier and runs verification, but it does **not** activate the sandbox automatically. The test author controls sandbox lifetime with `with bigfoot:` or `async with bigfoot:`. This is intentional: mock registration and assertions happen outside the sandbox, and only the code under test runs inside it. +The pytest plugin creates the verifier and runs verification, but it does **not** activate the sandbox automatically. The test author controls sandbox lifetime with `with tripwire:` or `async with tripwire:`. This is intentional: mock registration and assertions happen outside the sandbox, and only the code under test runs inside it. diff --git a/docs/guides/http-plugin.md b/docs/guides/http-plugin.md index 2568b54..0a42baa 100644 --- a/docs/guides/http-plugin.md +++ b/docs/guides/http-plugin.md @@ -1,32 +1,32 @@ # HttpPlugin Guide -`HttpPlugin` intercepts HTTP calls made through `httpx` (sync and async), `requests`, `urllib`, and `aiohttp` (if installed). It requires the `bigfoot[http]` extra. For aiohttp support, also install `bigfoot[aiohttp]`. +`HttpPlugin` intercepts HTTP calls made through `httpx` (sync and async), `requests`, `urllib`, and `aiohttp` (if installed). It requires the `tripwire[http]` extra. For aiohttp support, also install `tripwire[aiohttp]`. ## Installation ```bash -pip install bigfoot[http] # httpx, requests, urllib -pip install bigfoot[aiohttp] # + aiohttp support -pip install bigfoot[http,aiohttp] # both +pip install tripwire[http] # httpx, requests, urllib +pip install tripwire[aiohttp] # + aiohttp support +pip install tripwire[http,aiohttp] # both ``` -`bigfoot[http]` installs `httpx>=0.25.0` and `requests>=2.31.0`. `bigfoot[aiohttp]` installs `aiohttp>=3.9.0`. +`tripwire[http]` installs `httpx>=0.25.0` and `requests>=2.31.0`. `tripwire[aiohttp]` installs `aiohttp>=3.9.0`. ## Setup -In pytest, access `HttpPlugin` through the `bigfoot.http` proxy. It auto-creates the plugin for the current test on first use — no explicit instantiation needed: +In pytest, access `HttpPlugin` through the `tripwire.http` proxy. It auto-creates the plugin for the current test on first use — no explicit instantiation needed: ```python -import bigfoot +import tripwire def test_api(): - bigfoot.http.mock_response("GET", "https://api.example.com/users", json={"users": []}) + tripwire.http.mock_response("GET", "https://api.example.com/users", json={"users": []}) - with bigfoot: + with tripwire: import httpx response = httpx.get("https://api.example.com/users") - bigfoot.http.assert_request("GET", "https://api.example.com/users", + tripwire.http.assert_request("GET", "https://api.example.com/users", headers=IsMapping(), body="") \ .assert_response(200, {"content-type": "application/json"}, '{"users": []}') ``` @@ -34,8 +34,8 @@ def test_api(): For manual use outside pytest, construct `HttpPlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.http import HttpPlugin +from tripwire import StrictVerifier +from tripwire.plugins.http import HttpPlugin verifier = StrictVerifier() http = HttpPlugin(verifier) @@ -45,10 +45,10 @@ Each verifier may have at most one `HttpPlugin`. A second `HttpPlugin(verifier)` ## Registering mock responses -Use `bigfoot.http.mock_response(method, url, ...)` to register a response before entering the sandbox: +Use `tripwire.http.mock_response(method, url, ...)` to register a response before entering the sandbox: ```python -bigfoot.http.mock_response("GET", "https://api.example.com/users", json={"users": []}) +tripwire.http.mock_response("GET", "https://api.example.com/users", json={"users": []}) ``` Parameters: @@ -71,8 +71,8 @@ Parameters: Multiple `mock_response()` calls for the same method+URL are consumed in registration order. The first matching request gets the first registered response, and so on. If a request arrives with no matching mock remaining, `UnmockedInteractionError` is raised. ```python -bigfoot.http.mock_response("GET", "https://api.example.com/token", json={"token": "first"}) -bigfoot.http.mock_response("GET", "https://api.example.com/token", json={"token": "second"}) +tripwire.http.mock_response("GET", "https://api.example.com/token", json={"token": "first"}) +tripwire.http.mock_response("GET", "https://api.example.com/token", json={"token": "second"}) ``` ## Optional responses @@ -80,34 +80,34 @@ bigfoot.http.mock_response("GET", "https://api.example.com/token", json={"token" Mark a mock response as optional with `required=False`: ```python -bigfoot.http.mock_response("GET", "https://api.example.com/health", json={"ok": True}, required=False) +tripwire.http.mock_response("GET", "https://api.example.com/health", json={"ok": True}, required=False) ``` An optional mock that is never triggered does not cause `UnusedMocksError` at teardown. ## URL matching -bigfoot matches on scheme, host, path, and (if `params` is provided) query parameters. Query parameters in the actual URL that are not listed in `params` are ignored. +tripwire matches on scheme, host, path, and (if `params` is provided) query parameters. Query parameters in the actual URL that are not listed in `params` are ignored. ```python # Matches https://api.example.com/search?q=hello&page=2 if params={"q": "hello"} -bigfoot.http.mock_response("GET", "https://api.example.com/search", json={...}, params={"q": "hello"}) +tripwire.http.mock_response("GET", "https://api.example.com/search", json={...}, params={"q": "hello"}) ``` ## Asserting HTTP interactions -Use `bigfoot.http.assert_request()` to assert interactions after the sandbox exits. By default, `assert_request()` returns an `HttpAssertionBuilder` that must be completed with `.assert_response()`: +Use `tripwire.http.assert_request()` to assert interactions after the sandbox exits. By default, `assert_request()` returns an `HttpAssertionBuilder` that must be completed with `.assert_response()`: ```python -import bigfoot, httpx +import tripwire, httpx def test_users(): - bigfoot.http.mock_response("GET", "https://api.example.com/users", json=[]) + tripwire.http.mock_response("GET", "https://api.example.com/users", json=[]) - with bigfoot: + with tripwire: response = httpx.get("https://api.example.com/users") - bigfoot.http.assert_request("GET", "https://api.example.com/users", + tripwire.http.assert_request("GET", "https://api.example.com/users", headers=IsMapping(), body="") \ .assert_response(200, {"content-type": "application/json"}, '[]') ``` @@ -117,7 +117,7 @@ def test_users(): To assert only request fields without response assertions, pass `require_response=False`: ```python -bigfoot.http.assert_request("GET", "https://api.example.com/users", +tripwire.http.assert_request("GET", "https://api.example.com/users", headers=IsMapping(), body="", require_response=False) ``` @@ -134,17 +134,17 @@ Parameters for `assert_request()`: ## Using with httpx sync ```python -import bigfoot, httpx +import tripwire, httpx def test_httpx_sync(): - bigfoot.http.mock_response("GET", "https://api.example.com/data", json={"value": 42}) + tripwire.http.mock_response("GET", "https://api.example.com/data", json={"value": 42}) - with bigfoot: + with tripwire: response = httpx.get("https://api.example.com/data") assert response.status_code == 200 assert response.json() == {"value": 42} - bigfoot.http.assert_request("GET", "https://api.example.com/data", + tripwire.http.assert_request("GET", "https://api.example.com/data", headers=IsMapping(), body="") \ .assert_response(200, {"content-type": "application/json"}, '{"value": 42}') ``` @@ -152,17 +152,17 @@ def test_httpx_sync(): ## Using with httpx async ```python -import bigfoot, httpx +import tripwire, httpx async def test_httpx_async(): - bigfoot.http.mock_response("POST", "https://api.example.com/items", json={"id": 1}, status=201) + tripwire.http.mock_response("POST", "https://api.example.com/items", json={"id": 1}, status=201) - async with bigfoot: + async with tripwire: async with httpx.AsyncClient() as client: response = await client.post("https://api.example.com/items", json={"name": "widget"}) assert response.status_code == 201 - bigfoot.http.assert_request("POST", "https://api.example.com/items", + tripwire.http.assert_request("POST", "https://api.example.com/items", headers=IsMapping(), body="") \ .assert_response(201, {"content-type": "application/json"}, '{"id": 1}') ``` @@ -170,38 +170,38 @@ async def test_httpx_async(): ## Using with requests ```python -import bigfoot, requests +import tripwire, requests def test_requests(): - bigfoot.http.mock_response("DELETE", "https://api.example.com/items/99", status=204) + tripwire.http.mock_response("DELETE", "https://api.example.com/items/99", status=204) - with bigfoot: + with tripwire: response = requests.delete("https://api.example.com/items/99") assert response.status_code == 204 - bigfoot.http.assert_request("DELETE", "https://api.example.com/items/99", + tripwire.http.assert_request("DELETE", "https://api.example.com/items/99", headers=IsMapping(), body="") \ .assert_response(204, IsMapping(), "") ``` ## Mocking errors -Use `bigfoot.http.mock_error(method, url, raises=...)` to register a mock that raises an exception instead of returning a response. This simulates connection failures, timeouts, and other transport-level errors: +Use `tripwire.http.mock_error(method, url, raises=...)` to register a mock that raises an exception instead of returning a response. This simulates connection failures, timeouts, and other transport-level errors: ```python -import bigfoot, httpx +import tripwire, httpx def test_connection_failure(): - bigfoot.http.mock_error("GET", "https://api.example.com/data", + tripwire.http.mock_error("GET", "https://api.example.com/data", raises=httpx.ConnectError("Connection refused")) - with bigfoot: + with tripwire: try: httpx.get("https://api.example.com/data") except httpx.ConnectError: pass # expected - bigfoot.http.assert_request("GET", "https://api.example.com/data", + tripwire.http.assert_request("GET", "https://api.example.com/data", headers={}, body="", raised=IsInstance(httpx.ConnectError)) ``` @@ -220,8 +220,8 @@ Error mocks participate in the same FIFO queue as `mock_response()` calls. They ```python # First call succeeds, second fails -bigfoot.http.mock_response("GET", "https://api.example.com/data", json={"ok": True}) -bigfoot.http.mock_error("GET", "https://api.example.com/data", +tripwire.http.mock_response("GET", "https://api.example.com/data", json={"ok": True}) +tripwire.http.mock_error("GET", "https://api.example.com/data", raises=httpx.ReadTimeout("timeout")) ``` @@ -230,7 +230,7 @@ bigfoot.http.mock_error("GET", "https://api.example.com/data", When an error mock fires, the interaction is recorded with request fields plus a `raised` field instead of response fields. Use the `raised` parameter on `assert_request()` to assert these interactions: ```python -bigfoot.http.assert_request("GET", "https://api.example.com/data", +tripwire.http.assert_request("GET", "https://api.example.com/data", headers={}, body="", raised=IsInstance(httpx.ConnectError)) ``` @@ -241,49 +241,49 @@ The assertable fields for error interactions are: `method`, `url`, `request_head ## UnmockedInteractionError for HTTP -When HTTP code fires a request with no matching mock, bigfoot raises `UnmockedInteractionError` with a hint: +When HTTP code fires a request with no matching mock, tripwire raises `UnmockedInteractionError` with a hint: ``` Unexpected HTTP request: GET https://api.example.com/data To mock this request, add before your sandbox: - bigfoot.http.mock_response("GET", "https://api.example.com/data", json={...}) + tripwire.http.mock_response("GET", "https://api.example.com/data", json={...}) Or to mark it optional: - bigfoot.http.mock_response("GET", "https://api.example.com/data", json={...}, required=False) + tripwire.http.mock_response("GET", "https://api.example.com/data", json={...}, required=False) ``` ## ConflictError -At sandbox entry, `HttpPlugin` checks whether `httpx.HTTPTransport.handle_request`, `httpx.AsyncHTTPTransport.handle_async_request`, and `requests.adapters.HTTPAdapter.send` have already been patched by another library. If any of these have been modified by a third party (respx, responses, httpretty, or an unknown library), bigfoot raises `ConflictError`: +At sandbox entry, `HttpPlugin` checks whether `httpx.HTTPTransport.handle_request`, `httpx.AsyncHTTPTransport.handle_async_request`, and `requests.adapters.HTTPAdapter.send` have already been patched by another library. If any of these have been modified by a third party (respx, responses, httpretty, or an unknown library), tripwire raises `ConflictError`: ``` ConflictError: target='httpx.HTTPTransport.handle_request', patcher='respx' ``` -Nested bigfoot sandboxes use reference counting and do not conflict with each other. +Nested tripwire sandboxes use reference counting and do not conflict with each other. ## Pass-Through: Real HTTP Calls -`bigfoot.http.pass_through(method, url)` registers a permanent routing rule. When an incoming request matches the rule and no mock response matches first, the real HTTP call is made through the original transport (bypassing bigfoot's interception layer). The interaction is still recorded on the timeline and must be asserted like any other interaction. +`tripwire.http.pass_through(method, url)` registers a permanent routing rule. When an incoming request matches the rule and no mock response matches first, the real HTTP call is made through the original transport (bypassing tripwire's interception layer). The interaction is still recorded on the timeline and must be asserted like any other interaction. Pass-through rules are routing hints, not assertions. An unused pass-through rule does not raise `UnusedMocksError` at teardown. ```python -import bigfoot, httpx +import tripwire, httpx def test_mixed(): - bigfoot.http.mock_response("GET", "https://api.example.com/cached", json={"data": "cached"}) - bigfoot.http.pass_through("GET", "https://api.example.com/live") + tripwire.http.mock_response("GET", "https://api.example.com/cached", json={"data": "cached"}) + tripwire.http.pass_through("GET", "https://api.example.com/live") - with bigfoot: + with tripwire: mocked = httpx.get("https://api.example.com/cached") # returns mock response real = httpx.get("https://api.example.com/live") # makes real HTTP call - bigfoot.http.assert_request("GET", "https://api.example.com/cached", + tripwire.http.assert_request("GET", "https://api.example.com/cached", headers=IsMapping(), body="") \ .assert_response(200, IsMapping(), '{"data": "cached"}') - bigfoot.http.assert_request("GET", "https://api.example.com/live", + tripwire.http.assert_request("GET", "https://api.example.com/live", headers=IsMapping(), body="") \ .assert_response(IsInstance(int), IsMapping(), IsInstance(str)) ``` @@ -299,22 +299,22 @@ By default, `assert_request()` returns an `HttpAssertionBuilder` that must be co The default is `require_response = true`. To disable it project-wide, add to your `pyproject.toml`: ```toml -[tool.bigfoot.http] +[tool.tripwire.http] require_response = false ``` With the default setting (or explicit `require_response = true`), every `assert_request()` call returns an `HttpAssertionBuilder`: ```python -import bigfoot, httpx +import tripwire, httpx def test_api_with_response(): - bigfoot.http.mock_response("GET", "https://api.example.com/users", json={"users": []}) + tripwire.http.mock_response("GET", "https://api.example.com/users", json={"users": []}) - with bigfoot: + with tripwire: response = httpx.get("https://api.example.com/users") - bigfoot.http.assert_request("GET", "https://api.example.com/users") \ + tripwire.http.assert_request("GET", "https://api.example.com/users") \ .assert_response(200, {"content-type": "application/json"}, '{"users": []}') ``` @@ -323,8 +323,8 @@ def test_api_with_response(): Pass `require_response=True` when constructing the plugin manually: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.http import HttpPlugin +from tripwire import StrictVerifier +from tripwire.plugins.http import HttpPlugin verifier = StrictVerifier() http = HttpPlugin(verifier, require_response=True) @@ -336,11 +336,11 @@ The `require_response` parameter on `assert_request()` overrides both the constr ```python # Force response assertion for this call (this is the default): -bigfoot.http.assert_request("GET", "https://api.example.com/data", require_response=True) \ +tripwire.http.assert_request("GET", "https://api.example.com/data", require_response=True) \ .assert_response(200, {}, '{"value": 42}') # Disable response assertion for this call (opt out of the default): -bigfoot.http.assert_request("GET", "https://api.example.com/health", require_response=False) +tripwire.http.assert_request("GET", "https://api.example.com/health", require_response=False) ``` ### HttpAssertionBuilder @@ -350,7 +350,7 @@ When `require_response` is active, `assert_request()` returns an `HttpAssertionB `assert_response(status, headers, body)` finalizes the assertion by calling `verifier.assert_interaction()` with all seven fields: ```python -builder = bigfoot.http.assert_request("POST", "https://api.example.com/items", +builder = tripwire.http.assert_request("POST", "https://api.example.com/items", headers={"content-type": "application/json"}, body='{"name": "widget"}', require_response=True) @@ -359,26 +359,26 @@ builder.assert_response(201, {"content-type": "application/json"}, '{"id": 1}') ### Configuration via pyproject.toml -See the [Configuration Guide](configuration.md) for full details on `[tool.bigfoot.http]`. +See the [Configuration Guide](configuration.md) for full details on `[tool.tripwire.http]`. ## Using with aiohttp -Requires `bigfoot[aiohttp]`. If aiohttp is not installed, `HttpPlugin` works normally for other transports. +Requires `tripwire[aiohttp]`. If aiohttp is not installed, `HttpPlugin` works normally for other transports. ```python -import bigfoot, aiohttp +import tripwire, aiohttp async def test_aiohttp_get(): - bigfoot.http.mock_response("GET", "https://api.example.com/data", json={"value": 42}) + tripwire.http.mock_response("GET", "https://api.example.com/data", json={"value": 42}) - async with bigfoot: + async with tripwire: async with aiohttp.ClientSession() as session: response = await session.get("https://api.example.com/data") assert response.status == 200 body = await response.json() assert body == {"value": 42} - bigfoot.http.assert_request("GET", "https://api.example.com/data", + tripwire.http.assert_request("GET", "https://api.example.com/data", headers={}, body="", require_response=True) \ .assert_response(200, {"content-type": "application/json"}, '{"value": 42}') @@ -388,16 +388,16 @@ aiohttp POST with JSON body: ```python async def test_aiohttp_post(): - bigfoot.http.mock_response("POST", "https://api.example.com/items", + tripwire.http.mock_response("POST", "https://api.example.com/items", json={"id": 1}, status=201) - async with bigfoot: + async with tripwire: async with aiohttp.ClientSession() as session: response = await session.post("https://api.example.com/items", json={"name": "widget"}) assert response.status == 201 - bigfoot.http.assert_request("POST", "https://api.example.com/items", + tripwire.http.assert_request("POST", "https://api.example.com/items", headers={}, body='{"name": "widget"}', require_response=True) \ .assert_response(201, {"content-type": "application/json"}, '{"id": 1}') diff --git a/docs/guides/installation.md b/docs/guides/installation.md index d5f1550..f57afd3 100644 --- a/docs/guides/installation.md +++ b/docs/guides/installation.md @@ -5,7 +5,7 @@ Install everything: ```bash -pip install bigfoot[all] +pip install tripwire[all] ``` This includes all plugins and their optional dependencies (httpx, requests, aiohttp, websockets, websocket-client, redis, psycopg2, asyncpg, dirty-equals). @@ -15,20 +15,20 @@ This includes all plugins and their optional dependencies (httpx, requests, aioh For a more compact installation, pick only the extras you need: ```bash -pip install bigfoot # Core plugins (no extra deps) -pip install bigfoot[http] # + HttpPlugin (httpx, requests, urllib) -pip install bigfoot[aiohttp] # + aiohttp support for HttpPlugin -pip install bigfoot[psycopg2] # + Psycopg2Plugin (PostgreSQL) -pip install bigfoot[asyncpg] # + AsyncpgPlugin (async PostgreSQL) -pip install bigfoot[websockets] # + AsyncWebSocketPlugin -pip install bigfoot[websocket-client] # + SyncWebSocketPlugin -pip install bigfoot[redis] # + RedisPlugin -pip install bigfoot[matchers] # + dirty-equals matchers +pip install tripwire # Core plugins (no extra deps) +pip install tripwire[http] # + HttpPlugin (httpx, requests, urllib) +pip install tripwire[aiohttp] # + aiohttp support for HttpPlugin +pip install tripwire[psycopg2] # + Psycopg2Plugin (PostgreSQL) +pip install tripwire[asyncpg] # + AsyncpgPlugin (async PostgreSQL) +pip install tripwire[websockets] # + AsyncWebSocketPlugin +pip install tripwire[websocket-client] # + SyncWebSocketPlugin +pip install tripwire[redis] # + RedisPlugin +pip install tripwire[matchers] # + dirty-equals matchers ``` ### Core plugins (no extra dependencies) -These plugins are always available with a bare `pip install bigfoot`: +These plugins are always available with a bare `pip install tripwire`: - `MockPlugin` -- general-purpose mock objects - `SubprocessPlugin` -- `subprocess.run` and `shutil.which` @@ -44,33 +44,33 @@ These plugins are always available with a bare `pip install bigfoot`: [dirty-equals](https://dirty-equals.helpmanual.io/) matchers can be used as expected field values in assertions: ```bash -pip install bigfoot[matchers] +pip install tripwire[matchers] ``` ## pytest fixture -The `bigfoot_verifier` pytest fixture is registered automatically via the `pytest11` entry point. No `conftest.py` changes are needed: +The `tripwire_verifier` pytest fixture is registered automatically via the `pytest11` entry point. No `conftest.py` changes are needed: ```python -def test_example(bigfoot_verifier): - # bigfoot_verifier is a StrictVerifier with automatic verify_all() at teardown +def test_example(tripwire_verifier): + # tripwire_verifier is a StrictVerifier with automatic verify_all() at teardown ... ``` Or use the context manager directly: ```python -import bigfoot +import tripwire def test_example(): - with bigfoot: + with tripwire: ... # all enabled plugins active ``` ## Guard Mode -bigfoot activates guard mode by default. When your tests make real I/O calls -(HTTP requests, database queries, subprocess calls, etc.) outside a bigfoot +tripwire activates guard mode by default. When your tests make real I/O calls +(HTTP requests, database queries, subprocess calls, etc.) outside a tripwire sandbox, you will see warnings like: ``` @@ -82,6 +82,6 @@ calls are unguarded so you can decide how to handle them: - **Silence a specific plugin:** `@pytest.mark.allow("http")` on the test - **Silence all warnings:** `warnings.filterwarnings("ignore", category=GuardedCallWarning)` -- **Enforce strict mode:** Set `guard = "error"` in `[tool.bigfoot]` in `pyproject.toml` +- **Enforce strict mode:** Set `guard = "error"` in `[tool.tripwire]` in `pyproject.toml` See the [Guard Mode guide](guard-mode.md) for full details. diff --git a/docs/guides/jwt-plugin.md b/docs/guides/jwt-plugin.md index c0b6685..2eab020 100644 --- a/docs/guides/jwt-plugin.md +++ b/docs/guides/jwt-plugin.md @@ -5,28 +5,28 @@ ## Installation ```bash -pip install bigfoot[jwt] +pip install tripwire[jwt] ``` This installs `PyJWT`. ## Setup -In pytest, access `JwtPlugin` through the `bigfoot.jwt_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `JwtPlugin` through the `tripwire.jwt_mock` proxy. It auto-creates the plugin for the current test on first use: ```python -import bigfoot +import tripwire def test_token_generation(): - bigfoot.jwt_mock.mock_encode(returns="eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.test") + tripwire.jwt_mock.mock_encode(returns="eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.test") - with bigfoot: + with tripwire: import jwt token = jwt.encode({"user_id": "42", "exp": 1700000000}, "secret", algorithm="HS256") assert token == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.test" - bigfoot.jwt_mock.assert_encode( + tripwire.jwt_mock.assert_encode( payload={"user_id": "42", "exp": 1700000000}, algorithm="HS256", extra_kwargs={}, @@ -36,8 +36,8 @@ def test_token_generation(): For manual use outside pytest, construct `JwtPlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.jwt_plugin import JwtPlugin +from tripwire import StrictVerifier +from tripwire.plugins.jwt_plugin import JwtPlugin verifier = StrictVerifier() jwt_mock = JwtPlugin(verifier) @@ -54,7 +54,7 @@ Each verifier may have at most one `JwtPlugin`. A second `JwtPlugin(verifier)` r Register a mock for `jwt.encode()`: ```python -bigfoot.jwt_mock.mock_encode(returns="mocked.jwt.token") +tripwire.jwt_mock.mock_encode(returns="mocked.jwt.token") ``` | Parameter | Type | Default | Description | @@ -68,7 +68,7 @@ bigfoot.jwt_mock.mock_encode(returns="mocked.jwt.token") Register a mock for `jwt.decode()`: ```python -bigfoot.jwt_mock.mock_decode(returns={"user_id": "42", "role": "admin"}) +tripwire.jwt_mock.mock_decode(returns={"user_id": "42", "role": "admin"}) ``` | Parameter | Type | Default | Description | @@ -83,10 +83,10 @@ Each operation (`encode`, `decode`) has its own independent FIFO queue. Multiple ```python def test_multiple_decodes(): - bigfoot.jwt_mock.mock_decode(returns={"user_id": "1", "role": "admin"}) - bigfoot.jwt_mock.mock_decode(returns={"user_id": "2", "role": "viewer"}) + tripwire.jwt_mock.mock_decode(returns={"user_id": "1", "role": "admin"}) + tripwire.jwt_mock.mock_decode(returns={"user_id": "2", "role": "viewer"}) - with bigfoot: + with tripwire: import jwt claims1 = jwt.decode("token1", "secret", algorithms=["HS256"]) claims2 = jwt.decode("token2", "secret", algorithms=["HS256"]) @@ -94,20 +94,20 @@ def test_multiple_decodes(): assert claims1["role"] == "admin" assert claims2["role"] == "viewer" - bigfoot.jwt_mock.assert_decode(token="token1", algorithms=["HS256"], options=None) - bigfoot.jwt_mock.assert_decode(token="token2", algorithms=["HS256"], options=None) + tripwire.jwt_mock.assert_decode(token="token1", algorithms=["HS256"], options=None) + tripwire.jwt_mock.assert_decode(token="token2", algorithms=["HS256"], options=None) ``` ## Asserting interactions -Use the typed assertion helpers on `bigfoot.jwt_mock`. +Use the typed assertion helpers on `tripwire.jwt_mock`. ### `assert_encode(*, payload, algorithm, extra_kwargs=None)` Asserts the next `jwt.encode()` interaction. ```python -bigfoot.jwt_mock.assert_encode( +tripwire.jwt_mock.assert_encode( payload={"user_id": "42", "exp": 1700000000}, algorithm="HS256", extra_kwargs={}, @@ -125,7 +125,7 @@ bigfoot.jwt_mock.assert_encode( Asserts the next `jwt.decode()` interaction. ```python -bigfoot.jwt_mock.assert_decode( +tripwire.jwt_mock.assert_decode( token="eyJ0eXAiOiJKV1Qi...", algorithms=["HS256"], options=None, @@ -148,19 +148,19 @@ Use the `raises` parameter to simulate JWT errors: ```python import jwt -import bigfoot +import tripwire def test_expired_token(): - bigfoot.jwt_mock.mock_decode( + tripwire.jwt_mock.mock_decode( returns=None, raises=jwt.ExpiredSignatureError("Signature has expired"), ) - with bigfoot: + with tripwire: with pytest.raises(jwt.ExpiredSignatureError): jwt.decode("expired.token", "secret", algorithms=["HS256"]) - bigfoot.jwt_mock.assert_decode( + tripwire.jwt_mock.assert_decode( token="expired.token", algorithms=["HS256"], options=None, @@ -186,17 +186,17 @@ def test_expired_token(): Mark a mock as optional with `required=False`: ```python -bigfoot.jwt_mock.mock_decode(returns={"sub": "test"}, required=False) +tripwire.jwt_mock.mock_decode(returns={"sub": "test"}, required=False) ``` An optional mock that is never triggered does not cause `UnusedMocksError` at teardown. ## UnmockedInteractionError -When code calls `jwt.encode()` or `jwt.decode()` with no remaining mocks in the queue, bigfoot raises `UnmockedInteractionError`: +When code calls `jwt.encode()` or `jwt.decode()` with no remaining mocks in the queue, tripwire raises `UnmockedInteractionError`: ``` jwt.encode(...) was called but no mock was registered. Register a mock with: - bigfoot.jwt_mock.mock_encode(returns=...) + tripwire.jwt_mock.mock_encode(returns=...) ``` diff --git a/docs/guides/logging-plugin.md b/docs/guides/logging-plugin.md index 0cd5506..5fd421d 100644 --- a/docs/guides/logging-plugin.md +++ b/docs/guides/logging-plugin.md @@ -1,29 +1,29 @@ # LoggingPlugin Guide -`LoggingPlugin` intercepts Python's `logging` module globally during a sandbox. It is included in core bigfoot -- no extra required. +`LoggingPlugin` intercepts Python's `logging` module globally during a sandbox. It is included in core tripwire -- no extra required. ## Setup -In pytest, access `LoggingPlugin` through the `bigfoot.log_mock` proxy. It auto-creates the plugin for the current test on first use -- no explicit instantiation needed: +In pytest, access `LoggingPlugin` through the `tripwire.log_mock` proxy. It auto-creates the plugin for the current test on first use -- no explicit instantiation needed: ```python -import bigfoot +import tripwire import logging def test_audit_trail(): logger = logging.getLogger("myapp.auth") - with bigfoot: + with tripwire: logger.info("User logged in") - bigfoot.log_mock.assert_info("User logged in", "myapp.auth") + tripwire.log_mock.assert_info("User logged in", "myapp.auth") ``` For manual use outside pytest, construct `LoggingPlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.logging_plugin import LoggingPlugin +from tripwire import StrictVerifier +from tripwire.plugins.logging_plugin import LoggingPlugin verifier = StrictVerifier() lp = LoggingPlugin(verifier) @@ -42,10 +42,10 @@ This is the same pattern used by `shutil.which` in `SubprocessPlugin`: unmocked ## Registering log mocks -Use `bigfoot.log_mock.mock_log(level, message, logger_name=None)` to register expected log calls before entering the sandbox: +Use `tripwire.log_mock.mock_log(level, message, logger_name=None)` to register expected log calls before entering the sandbox: ```python -bigfoot.log_mock.mock_log("INFO", "User logged in", logger_name="myapp.auth") +tripwire.log_mock.mock_log("INFO", "User logged in", logger_name="myapp.auth") ``` Parameters: @@ -61,13 +61,13 @@ Mocks are consumed in FIFO order. If a log call matches the next mock in the que ## Asserting log interactions -Use `bigfoot.log_mock.log` as the source in `assert_interaction()`, or use the typed assertion helpers. +Use `tripwire.log_mock.log` as the source in `assert_interaction()`, or use the typed assertion helpers. ### Using assert_interaction directly ```python -bigfoot.assert_interaction( - bigfoot.log_mock.log, +tripwire.assert_interaction( + tripwire.log_mock.log, level="INFO", message="User logged in", logger_name="myapp.auth", @@ -79,9 +79,9 @@ All three fields (`level`, `message`, `logger_name`) are required. ### Using assertion helpers ```python -bigfoot.log_mock.assert_log("INFO", "User logged in", "myapp.auth") -bigfoot.log_mock.assert_info("User logged in", "myapp.auth") -bigfoot.log_mock.assert_warning("Rate limit approaching", "myapp.auth") +tripwire.log_mock.assert_log("INFO", "User logged in", "myapp.auth") +tripwire.log_mock.assert_info("User logged in", "myapp.auth") +tripwire.log_mock.assert_warning("Rate limit approaching", "myapp.auth") ``` Available helpers: @@ -111,30 +111,30 @@ Assert against the fully formatted message, not the template. Different logger names are recorded as-is. You can assert interactions from multiple loggers: ```python -import bigfoot +import tripwire import logging def test_multi_service(): auth_logger = logging.getLogger("service.auth") payment_logger = logging.getLogger("service.payment") - with bigfoot: + with tripwire: auth_logger.info("authenticated") payment_logger.warning("rate limited") - bigfoot.log_mock.assert_info("authenticated", "service.auth") - bigfoot.log_mock.assert_warning("rate limited", "service.payment") + tripwire.log_mock.assert_info("authenticated", "service.auth") + tripwire.log_mock.assert_warning("rate limited", "service.payment") ``` ## ConflictError -At sandbox entry, `LoggingPlugin` checks whether `logging.Logger._log` has already been patched by another library. If it has been modified by a third party, bigfoot raises `ConflictError`: +At sandbox entry, `LoggingPlugin` checks whether `logging.Logger._log` has already been patched by another library. If it has been modified by a third party, tripwire raises `ConflictError`: ``` ConflictError: target='logging.Logger._log', patcher='unknown' ``` -Nested bigfoot sandboxes use reference counting and do not conflict with each other. +Nested tripwire sandboxes use reference counting and do not conflict with each other. ## Full example diff --git a/docs/guides/mcp-plugin.md b/docs/guides/mcp-plugin.md index 961229c..0aafda7 100644 --- a/docs/guides/mcp-plugin.md +++ b/docs/guides/mcp-plugin.md @@ -5,35 +5,35 @@ ## Installation ```bash -pip install bigfoot[mcp] +pip install tripwire[mcp] ``` This installs the `mcp` SDK. ## Setup -In pytest, access `McpPlugin` through the `bigfoot.mcp_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `McpPlugin` through the `tripwire.mcp_mock` proxy. It auto-creates the plugin for the current test on first use: ```python import pytest -import bigfoot +import tripwire @pytest.mark.asyncio async def test_call_tool(): from mcp.client.session import ClientSession - bigfoot.mcp_mock.mock_call_tool( + tripwire.mcp_mock.mock_call_tool( "my_tool", returns={"result": "ok"}, ) - with bigfoot: + with tripwire: session = object.__new__(ClientSession) result = await session.call_tool("my_tool", {"key": "value"}) assert result == {"result": "ok"} - bigfoot.mcp_mock.assert_call_tool( + tripwire.mcp_mock.assert_call_tool( "my_tool", arguments={"key": "value"}, direction="client", @@ -43,8 +43,8 @@ async def test_call_tool(): For manual use outside pytest, construct `McpPlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.mcp_plugin import McpPlugin +from tripwire import StrictVerifier +from tripwire.plugins.mcp_plugin import McpPlugin verifier = StrictVerifier() mcp_mock = McpPlugin(verifier) @@ -68,7 +68,7 @@ Client mocks intercept `ClientSession` method calls. Three methods are available ### `mock_call_tool(tool_name, *, returns, ...)` ```python -bigfoot.mcp_mock.mock_call_tool("get_weather", returns={"temp": "72F"}) +tripwire.mcp_mock.mock_call_tool("get_weather", returns={"temp": "72F"}) ``` | Parameter | Type | Default | Description | @@ -81,7 +81,7 @@ bigfoot.mcp_mock.mock_call_tool("get_weather", returns={"temp": "72F"}) ### `mock_read_resource(uri, *, returns, ...)` ```python -bigfoot.mcp_mock.mock_read_resource("file:///data.json", returns={"contents": "[1,2,3]"}) +tripwire.mcp_mock.mock_read_resource("file:///data.json", returns={"contents": "[1,2,3]"}) ``` | Parameter | Type | Default | Description | @@ -94,7 +94,7 @@ bigfoot.mcp_mock.mock_read_resource("file:///data.json", returns={"contents": "[ ### `mock_get_prompt(prompt_name, *, returns, ...)` ```python -bigfoot.mcp_mock.mock_get_prompt("summarize", returns={"messages": [{"role": "user", "content": "..."}]}) +tripwire.mcp_mock.mock_get_prompt("summarize", returns={"messages": [{"role": "user", "content": "..."}]}) ``` | Parameter | Type | Default | Description | @@ -111,19 +111,19 @@ Server mocks intercept incoming requests handled by `Server._handle_request`. Th ### `mock_server_call_tool(tool_name, *, returns, ...)` ```python -bigfoot.mcp_mock.mock_server_call_tool("calculate", returns={"result": 42}) +tripwire.mcp_mock.mock_server_call_tool("calculate", returns={"result": 42}) ``` ### `mock_server_read_resource(uri, *, returns, ...)` ```python -bigfoot.mcp_mock.mock_server_read_resource("db://users/1", returns={"name": "Alice"}) +tripwire.mcp_mock.mock_server_read_resource("db://users/1", returns={"name": "Alice"}) ``` ### `mock_server_get_prompt(prompt_name, *, returns, ...)` ```python -bigfoot.mcp_mock.mock_server_get_prompt("greet", returns={"messages": [{"role": "assistant", "content": "Hello!"}]}) +tripwire.mcp_mock.mock_server_get_prompt("greet", returns={"messages": [{"role": "assistant", "content": "Hello!"}]}) ``` All three accept the same parameters as their client counterparts (`returns`, `raises`, `required`). @@ -135,10 +135,10 @@ Each (direction, method, key) triple has its own independent FIFO queue. Multipl ```python @pytest.mark.asyncio async def test_multiple_tool_calls(): - bigfoot.mcp_mock.mock_call_tool("search", returns={"results": ["a"]}) - bigfoot.mcp_mock.mock_call_tool("search", returns={"results": ["b"]}) + tripwire.mcp_mock.mock_call_tool("search", returns={"results": ["a"]}) + tripwire.mcp_mock.mock_call_tool("search", returns={"results": ["b"]}) - with bigfoot: + with tripwire: from mcp.client.session import ClientSession session = object.__new__(ClientSession) r1 = await session.call_tool("search", {"query": "first"}) @@ -147,18 +147,18 @@ async def test_multiple_tool_calls(): assert r1 == {"results": ["a"]} assert r2 == {"results": ["b"]} - bigfoot.mcp_mock.assert_call_tool("search", arguments={"query": "first"}) - bigfoot.mcp_mock.assert_call_tool("search", arguments={"query": "second"}) + tripwire.mcp_mock.assert_call_tool("search", arguments={"query": "first"}) + tripwire.mcp_mock.assert_call_tool("search", arguments={"query": "second"}) ``` ## Asserting interactions -Use the typed assertion helpers on `bigfoot.mcp_mock`. All recorded fields are required. +Use the typed assertion helpers on `tripwire.mcp_mock`. All recorded fields are required. ### `assert_call_tool(tool_name, *, arguments, direction)` ```python -bigfoot.mcp_mock.assert_call_tool( +tripwire.mcp_mock.assert_call_tool( "get_weather", arguments={"city": "San Francisco"}, direction="client", @@ -174,7 +174,7 @@ bigfoot.mcp_mock.assert_call_tool( ### `assert_read_resource(uri, *, direction)` ```python -bigfoot.mcp_mock.assert_read_resource( +tripwire.mcp_mock.assert_read_resource( "file:///data.json", direction="client", ) @@ -188,7 +188,7 @@ bigfoot.mcp_mock.assert_read_resource( ### `assert_get_prompt(prompt_name, *, arguments, direction)` ```python -bigfoot.mcp_mock.assert_get_prompt( +tripwire.mcp_mock.assert_get_prompt( "summarize", arguments={"length": "short"}, direction="client", @@ -208,19 +208,19 @@ Use the `raises` parameter to simulate MCP errors: ```python @pytest.mark.asyncio async def test_tool_error(): - bigfoot.mcp_mock.mock_call_tool( + tripwire.mcp_mock.mock_call_tool( "flaky_tool", returns=None, raises=RuntimeError("MCP server unavailable"), ) - with bigfoot: + with tripwire: from mcp.client.session import ClientSession session = object.__new__(ClientSession) with pytest.raises(RuntimeError, match="MCP server unavailable"): await session.call_tool("flaky_tool", {"input": "data"}) - bigfoot.mcp_mock.assert_call_tool( + tripwire.mcp_mock.assert_call_tool( "flaky_tool", arguments={"input": "data"}, direction="client", @@ -246,17 +246,17 @@ async def test_tool_error(): Mark a mock as optional with `required=False`: ```python -bigfoot.mcp_mock.mock_call_tool("analytics_ping", returns={"status": "ok"}, required=False) +tripwire.mcp_mock.mock_call_tool("analytics_ping", returns={"status": "ok"}, required=False) ``` An optional mock that is never triggered does not cause `UnusedMocksError` at teardown. ## UnmockedInteractionError -When code makes an MCP call that has no remaining mocks in its queue, bigfoot raises `UnmockedInteractionError`: +When code makes an MCP call that has no remaining mocks in its queue, tripwire raises `UnmockedInteractionError`: ``` mcp client call_tool('get_weather') was called but no mock was registered. Register a mock with: - bigfoot.mcp_mock.mock_call_tool('get_weather', returns=...) + tripwire.mcp_mock.mock_call_tool('get_weather', returns=...) ``` diff --git a/docs/guides/memcache-plugin.md b/docs/guides/memcache-plugin.md index 4a32dd3..b2d18d2 100644 --- a/docs/guides/memcache-plugin.md +++ b/docs/guides/memcache-plugin.md @@ -5,36 +5,36 @@ ## Installation ```bash -pip install bigfoot[pymemcache] +pip install tripwire[pymemcache] ``` This installs `pymemcache`. ## Setup -In pytest, access `MemcachePlugin` through the `bigfoot.memcache_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `MemcachePlugin` through the `tripwire.memcache_mock` proxy. It auto-creates the plugin for the current test on first use: ```python -import bigfoot +import tripwire def test_session_cache(): - bigfoot.memcache_mock.mock_command("GET", returns=b"user:42") + tripwire.memcache_mock.mock_command("GET", returns=b"user:42") - with bigfoot: + with tripwire: from pymemcache.client.base import Client client = Client(("localhost", 11211)) value = client.get("session:abc") assert value == b"user:42" - bigfoot.memcache_mock.assert_get(command="GET", key="session:abc") + tripwire.memcache_mock.assert_get(command="GET", key="session:abc") ``` For manual use outside pytest, construct `MemcachePlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.memcache_plugin import MemcachePlugin +from tripwire import StrictVerifier +from tripwire.plugins.memcache_plugin import MemcachePlugin verifier = StrictVerifier() memcache_mock = MemcachePlugin(verifier) @@ -44,11 +44,11 @@ Each verifier may have at most one `MemcachePlugin`. A second `MemcachePlugin(ve ## Registering mock commands -Use `bigfoot.memcache_mock.mock_command(command, *, returns, ...)` to register a mock before entering the sandbox: +Use `tripwire.memcache_mock.mock_command(command, *, returns, ...)` to register a mock before entering the sandbox: ```python -bigfoot.memcache_mock.mock_command("SET", returns=True) -bigfoot.memcache_mock.mock_command("GET", returns=b"cached") +tripwire.memcache_mock.mock_command("SET", returns=True) +tripwire.memcache_mock.mock_command("GET", returns=b"cached") ``` ### Parameters @@ -80,10 +80,10 @@ Each command name has its own independent FIFO queue. Multiple `mock_command("GE ```python def test_multiple_gets(): - bigfoot.memcache_mock.mock_command("GET", returns=b"first") - bigfoot.memcache_mock.mock_command("GET", returns=b"second") + tripwire.memcache_mock.mock_command("GET", returns=b"first") + tripwire.memcache_mock.mock_command("GET", returns=b"second") - with bigfoot: + with tripwire: from pymemcache.client.base import Client client = Client(("localhost", 11211)) v1 = client.get("key1") @@ -92,22 +92,22 @@ def test_multiple_gets(): assert v1 == b"first" assert v2 == b"second" - bigfoot.memcache_mock.assert_get(command="GET", key="key1") - bigfoot.memcache_mock.assert_get(command="GET", key="key2") + tripwire.memcache_mock.assert_get(command="GET", key="key1") + tripwire.memcache_mock.assert_get(command="GET", key="key2") ``` Command names are case-insensitive: `mock_command("get", ...)` matches a `client.get(...)` call. ## Asserting interactions -Use the typed assertion helpers on `bigfoot.memcache_mock`. Each helper requires all detail fields for its operation type. +Use the typed assertion helpers on `tripwire.memcache_mock`. Each helper requires all detail fields for its operation type. ### `assert_get(command, key)` Asserts the next read interaction (GET, GETS, DELETE). ```python -bigfoot.memcache_mock.assert_get(command="GET", key="session:abc") +tripwire.memcache_mock.assert_get(command="GET", key="session:abc") ``` | Parameter | Type | Description | @@ -120,7 +120,7 @@ bigfoot.memcache_mock.assert_get(command="GET", key="session:abc") Asserts the next write interaction (SET, ADD, REPLACE, CAS, APPEND, PREPEND). ```python -bigfoot.memcache_mock.assert_set(command="SET", key="session:abc", value=b"user:42", expire=3600) +tripwire.memcache_mock.assert_set(command="SET", key="session:abc", value=b"user:42", expire=3600) ``` | Parameter | Type | Default | Description | @@ -135,7 +135,7 @@ bigfoot.memcache_mock.assert_set(command="SET", key="session:abc", value=b"user: Asserts the next delete interaction. ```python -bigfoot.memcache_mock.assert_delete(command="DELETE", key="session:abc") +tripwire.memcache_mock.assert_delete(command="DELETE", key="session:abc") ``` | Parameter | Type | Description | @@ -148,7 +148,7 @@ bigfoot.memcache_mock.assert_delete(command="DELETE", key="session:abc") Asserts the next counter interaction (INCR, DECR). ```python -bigfoot.memcache_mock.assert_incr(command="INCR", key="page_views", value=1) +tripwire.memcache_mock.assert_incr(command="INCR", key="page_views", value=1) ``` | Parameter | Type | Default | Description | @@ -162,22 +162,22 @@ bigfoot.memcache_mock.assert_incr(command="INCR", key="page_views", value=1) Use the `raises` parameter to simulate memcache errors: ```python -import bigfoot +import tripwire def test_memcache_connection_error(): - bigfoot.memcache_mock.mock_command( + tripwire.memcache_mock.mock_command( "GET", returns=None, raises=ConnectionError("memcached unreachable"), ) - with bigfoot: + with tripwire: from pymemcache.client.base import Client client = Client(("localhost", 11211)) with pytest.raises(ConnectionError): client.get("mykey") - bigfoot.memcache_mock.assert_get(command="GET", key="mykey") + tripwire.memcache_mock.assert_get(command="GET", key="mykey") ``` ## Full example @@ -199,17 +199,17 @@ def test_memcache_connection_error(): Mark a mock as optional with `required=False`: ```python -bigfoot.memcache_mock.mock_command("DELETE", returns=True, required=False) +tripwire.memcache_mock.mock_command("DELETE", returns=True, required=False) ``` An optional mock that is never triggered does not cause `UnusedMocksError` at teardown. ## UnmockedInteractionError -When code calls a memcache method that has no remaining mocks in its queue, bigfoot raises `UnmockedInteractionError`: +When code calls a memcache method that has no remaining mocks in its queue, tripwire raises `UnmockedInteractionError`: ``` memcache.GET(...) was called but no mock was registered. Register a mock with: - bigfoot.memcache_mock.mock_command('GET', returns=...) + tripwire.memcache_mock.mock_command('GET', returns=...) ``` diff --git a/docs/guides/mock-plugin.md b/docs/guides/mock-plugin.md index ba21d2a..d9fcf74 100644 --- a/docs/guides/mock-plugin.md +++ b/docs/guides/mock-plugin.md @@ -1,34 +1,34 @@ # MockPlugin Guide -`MockPlugin` intercepts method calls on mock objects. It is the core mocking mechanism in bigfoot, created automatically when you call `bigfoot.mock()` or `bigfoot.spy()`. +`MockPlugin` intercepts method calls on mock objects. It is the core mocking mechanism in tripwire, created automatically when you call `tripwire.mock()` or `tripwire.spy()`. ## Creating mocks -### Import-site mock: `bigfoot.mock("mod:attr")` +### Import-site mock: `tripwire.mock("mod:attr")` Patches a module-level attribute at its import location. The path uses colon-separated `"module.path:attribute"` syntax: ```python -import bigfoot +import tripwire -cache = bigfoot.mock("myapp.services:cache") +cache = tripwire.mock("myapp.services:cache") cache.get.returns("cached_value") cache.set.returns(None) ``` -When the sandbox activates, bigfoot resolves the path, saves the original value of `myapp.services.cache`, and replaces it with a dispatch proxy. When the sandbox exits, the original is restored. +When the sandbox activates, tripwire resolves the path, saves the original value of `myapp.services.cache`, and replaces it with a dispatch proxy. When the sandbox exits, the original is restored. The colon separates the importable module path from the attribute path. Nested attributes work with dots after the colon: `"myapp.services:registry.cache"`. -### Object mock: `bigfoot.mock.object(target, "attr")` +### Object mock: `tripwire.mock.object(target, "attr")` Patches an attribute on a specific object instance: ```python -import bigfoot +import tripwire service = EmailService() -mock = bigfoot.mock.object(service, "send") +mock = tripwire.mock.object(service, "send") mock.returns(True) ``` @@ -36,10 +36,10 @@ This is useful when you have direct access to the object being tested and do not ### Individual activation (context manager) -Mocks can be activated individually using the context manager protocol, outside a bigfoot sandbox. In this mode, interactions are recorded but not enforced at teardown: +Mocks can be activated individually using the context manager protocol, outside a tripwire sandbox. In this mode, interactions are recorded but not enforced at teardown: ```python -cache = bigfoot.mock("myapp.services:cache") +cache = tripwire.mock("myapp.services:cache") cache.get.returns("setup_value") with cache: @@ -48,17 +48,17 @@ with cache: # cache is deactivated, original restored ``` -This is useful for setup code that should not be subject to bigfoot's strict verification. +This is useful for setup code that should not be subject to tripwire's strict verification. ### Sandbox activation (standard) -When you use `with bigfoot:`, all registered mocks are activated together and enforcement is enabled. Interactions must be asserted and mocks must be consumed: +When you use `with tripwire:`, all registered mocks are activated together and enforcement is enabled. Interactions must be asserted and mocks must be consumed: ```python -cache = bigfoot.mock("myapp.services:cache") +cache = tripwire.mock("myapp.services:cache") cache.get.returns("value") -with bigfoot: +with tripwire: result = get_from_cache("key") cache.get.assert_call(args=("key",), kwargs={}) @@ -73,12 +73,12 @@ cache.get.returns("first") cache.get.returns("second") ``` -Multiple `.returns()` calls build a queue. Each call to the mock consumes one entry. If the queue is exhausted and the mock is called again, bigfoot raises `UnmockedInteractionError`. +Multiple `.returns()` calls build a queue. Each call to the mock consumes one entry. If the queue is exhausted and the mock is called again, tripwire raises `UnmockedInteractionError`. For single-callable targets (functions, not objects with methods), use `.returns()` directly on the mock: ```python -mock_fn = bigfoot.mock("myapp.utils:calculate_tax") +mock_fn = tripwire.mock("myapp.utils:calculate_tax") mock_fn.returns(42.0) ``` @@ -127,7 +127,7 @@ cache.get.returns("first").returns("second").raises(IOError("down")) ## Optional mocks -By default, every registered side effect is `required=True`. If a required mock is never consumed by the time `verify_all()` runs, bigfoot raises `UnusedMocksError`. +By default, every registered side effect is `required=True`. If a required mock is never consumed by the time `verify_all()` runs, tripwire raises `UnusedMocksError`. Mark a side effect as optional with `.required(False)`: @@ -148,16 +148,16 @@ A spy wraps a real implementation. When the spy's call queue has an entry, that ### Creating a spy -Use `bigfoot.spy("mod:attr")` for import-site spies or `bigfoot.spy.object(target, "attr")` for object spies: +Use `tripwire.spy("mod:attr")` for import-site spies or `tripwire.spy.object(target, "attr")` for object spies: ```python -import bigfoot +import tripwire # Import-site spy: wraps the real myapp.services.cache -spy = bigfoot.spy("myapp.services:cache") +spy = tripwire.spy("myapp.services:cache") spy.get.returns("override") # queue entry: takes priority on first call -with bigfoot: +with tripwire: result1 = get_from_cache("key1") # returns "override" (queue entry) result2 = get_from_cache("key2") # delegates to real cache.get("key2") @@ -169,7 +169,7 @@ Object spy: ```python real_service = PaymentService() -spy = bigfoot.spy.object(real_service, "charge") +spy = tripwire.spy.object(real_service, "charge") ``` ### Spy return value and exception recording @@ -200,13 +200,13 @@ The `returned` and `raised` fields are only present when the spy delegates to th Assertions happen after the sandbox exits. Use `.assert_call()` on the `MethodProxy`: ```python -import bigfoot +import tripwire def test_cache_lookup(): - cache = bigfoot.mock("myapp.services:cache") + cache = tripwire.mock("myapp.services:cache") cache.get.returns("value") - with bigfoot: + with tripwire: result = get_from_cache("my_key") cache.get.assert_call(args=("my_key",), kwargs={}) @@ -217,10 +217,10 @@ def test_cache_lookup(): For single-callable targets, use `.assert_call()` directly on the mock: ```python -mock_fn = bigfoot.mock("myapp.utils:calculate_tax") +mock_fn = tripwire.mock("myapp.utils:calculate_tax") mock_fn.returns(42.0) -with bigfoot: +with tripwire: result = calculate_tax(100.0) mock_fn.assert_call(args=(100.0,), kwargs={}) @@ -233,7 +233,7 @@ mock_fn.assert_call(args=(100.0,), kwargs={}) cache.get.assert_call(args=("key",), kwargs={}) # Equivalent low-level call: -bigfoot.assert_interaction(cache.get, args=("key",), kwargs={}) +tripwire.assert_interaction(cache.get, args=("key",), kwargs={}) ``` ### Asserting raised exceptions @@ -243,7 +243,7 @@ When a mock uses `.raises()`, include `raised` in the assertion: ```python cache.get.raises(ConnectionError("down")) -with bigfoot: +with tripwire: try: get_from_cache("key") except ConnectionError: @@ -261,9 +261,9 @@ cache.get.assert_call( When a spy delegates to the real implementation, include `returned` in the assertion: ```python -spy = bigfoot.spy("myapp.services:cache") +spy = tripwire.spy("myapp.services:cache") -with bigfoot: +with tripwire: result = get_from_cache("key") spy.get.assert_call(args=("key",), kwargs={}, returned="cached_value") @@ -271,20 +271,20 @@ spy.get.assert_call(args=("key",), kwargs={}, returned="cached_value") ## In-any-order assertions -By default, `assert_call()` checks the next unasserted interaction in timeline order. If multiple mocks fire and order does not matter, wrap assertions in `bigfoot.in_any_order()`: +By default, `assert_call()` checks the next unasserted interaction in timeline order. If multiple mocks fire and order does not matter, wrap assertions in `tripwire.in_any_order()`: ```python -import bigfoot +import tripwire def test_parallel_lookups(): - cache = bigfoot.mock("myapp.services:cache") + cache = tripwire.mock("myapp.services:cache") cache.get.returns("a").returns("b") - with bigfoot: + with tripwire: get_from_cache("key1") get_from_cache("key2") - with bigfoot.in_any_order(): + with tripwire.in_any_order(): cache.get.assert_call(args=("key2",), kwargs={}) cache.get.assert_call(args=("key1",), kwargs={}) ``` @@ -295,7 +295,7 @@ def test_parallel_lookups(): ### UnmockedInteractionError -When a mock method is called inside the sandbox but its queue is empty, bigfoot raises `UnmockedInteractionError` immediately. The error message includes a copy-pasteable hint: +When a mock method is called inside the sandbox but its queue is empty, tripwire raises `UnmockedInteractionError` immediately. The error message includes a copy-pasteable hint: ``` Unexpected call to myapp.services:cache.get @@ -311,7 +311,7 @@ Unexpected call to myapp.services:cache.get ### InteractionMismatchError -When `assert_call()` is called and the expected source or fields do not match the next recorded interaction, bigfoot raises `InteractionMismatchError`. The error includes the full remaining timeline and a hint. +When `assert_call()` is called and the expected source or fields do not match the next recorded interaction, tripwire raises `InteractionMismatchError`. The error includes the full remaining timeline and a hint. ### UnusedMocksError diff --git a/docs/guides/mongo-plugin.md b/docs/guides/mongo-plugin.md index 6c1be11..4e34488 100644 --- a/docs/guides/mongo-plugin.md +++ b/docs/guides/mongo-plugin.md @@ -5,29 +5,29 @@ ## Installation ```bash -pip install bigfoot[pymongo] +pip install tripwire[pymongo] ``` This installs `pymongo`. ## Setup -In pytest, access `MongoPlugin` through the `bigfoot.mongo_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `MongoPlugin` through the `tripwire.mongo_mock` proxy. It auto-creates the plugin for the current test on first use: ```python -import bigfoot +import tripwire def test_find_user(): - bigfoot.mongo_mock.mock_operation("find_one", returns={"_id": "abc", "name": "Alice"}) + tripwire.mongo_mock.mock_operation("find_one", returns={"_id": "abc", "name": "Alice"}) - with bigfoot: + with tripwire: import pymongo client = pymongo.MongoClient("mongodb://localhost:27017") user = client.mydb.users.find_one({"email": "alice@example.com"}) assert user == {"_id": "abc", "name": "Alice"} - bigfoot.mongo_mock.assert_find_one( + tripwire.mongo_mock.assert_find_one( database="mydb", collection="users", filter={"email": "alice@example.com"}, @@ -38,8 +38,8 @@ def test_find_user(): For manual use outside pytest, construct `MongoPlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.mongo_plugin import MongoPlugin +from tripwire import StrictVerifier +from tripwire.plugins.mongo_plugin import MongoPlugin verifier = StrictVerifier() mongo_mock = MongoPlugin(verifier) @@ -49,11 +49,11 @@ Each verifier may have at most one `MongoPlugin`. A second `MongoPlugin(verifier ## Registering mock operations -Use `bigfoot.mongo_mock.mock_operation(operation, *, returns, ...)` to register a mock before entering the sandbox: +Use `tripwire.mongo_mock.mock_operation(operation, *, returns, ...)` to register a mock before entering the sandbox: ```python -bigfoot.mongo_mock.mock_operation("find_one", returns={"_id": "1", "status": "active"}) -bigfoot.mongo_mock.mock_operation("insert_one", returns=type("Result", (), {"inserted_id": "2"})()) +tripwire.mongo_mock.mock_operation("find_one", returns={"_id": "1", "status": "active"}) +tripwire.mongo_mock.mock_operation("insert_one", returns=type("Result", (), {"inserted_id": "2"})()) ``` ### Parameters @@ -86,10 +86,10 @@ Each operation name has its own independent FIFO queue. Multiple `mock_operation ```python def test_sequential_queries(): - bigfoot.mongo_mock.mock_operation("find_one", returns={"_id": "1", "role": "admin"}) - bigfoot.mongo_mock.mock_operation("find_one", returns=None) + tripwire.mongo_mock.mock_operation("find_one", returns={"_id": "1", "role": "admin"}) + tripwire.mongo_mock.mock_operation("find_one", returns=None) - with bigfoot: + with tripwire: import pymongo client = pymongo.MongoClient() db = client.mydb @@ -100,11 +100,11 @@ def test_sequential_queries(): assert admin == {"_id": "1", "role": "admin"} assert ghost is None - bigfoot.mongo_mock.assert_find_one( + tripwire.mongo_mock.assert_find_one( database="mydb", collection="users", filter={"role": "admin"}, projection=None, ) - bigfoot.mongo_mock.assert_find_one( + tripwire.mongo_mock.assert_find_one( database="mydb", collection="users", filter={"role": "ghost"}, projection=None, ) @@ -112,12 +112,12 @@ def test_sequential_queries(): ## Asserting interactions -Use the typed assertion helpers on `bigfoot.mongo_mock`. Each helper requires `database` and `collection` plus the operation-specific fields. +Use the typed assertion helpers on `tripwire.mongo_mock`. Each helper requires `database` and `collection` plus the operation-specific fields. ### `assert_find(database, collection, filter, projection=None)` ```python -bigfoot.mongo_mock.assert_find( +tripwire.mongo_mock.assert_find( database="mydb", collection="users", filter={"active": True}, projection=None, ) @@ -126,7 +126,7 @@ bigfoot.mongo_mock.assert_find( ### `assert_find_one(database, collection, filter, projection=None)` ```python -bigfoot.mongo_mock.assert_find_one( +tripwire.mongo_mock.assert_find_one( database="mydb", collection="users", filter={"_id": "abc"}, projection=None, ) @@ -135,7 +135,7 @@ bigfoot.mongo_mock.assert_find_one( ### `assert_insert_one(database, collection, document)` ```python -bigfoot.mongo_mock.assert_insert_one( +tripwire.mongo_mock.assert_insert_one( database="mydb", collection="users", document={"name": "Alice", "email": "alice@example.com"}, ) @@ -144,7 +144,7 @@ bigfoot.mongo_mock.assert_insert_one( ### `assert_insert_many(database, collection, documents)` ```python -bigfoot.mongo_mock.assert_insert_many( +tripwire.mongo_mock.assert_insert_many( database="mydb", collection="events", documents=[{"type": "click"}, {"type": "view"}], ) @@ -153,7 +153,7 @@ bigfoot.mongo_mock.assert_insert_many( ### `assert_update_one(database, collection, filter, update)` ```python -bigfoot.mongo_mock.assert_update_one( +tripwire.mongo_mock.assert_update_one( database="mydb", collection="users", filter={"_id": "abc"}, update={"$set": {"last_login": "2025-01-15"}}, @@ -163,7 +163,7 @@ bigfoot.mongo_mock.assert_update_one( ### `assert_update_many(database, collection, filter, update)` ```python -bigfoot.mongo_mock.assert_update_many( +tripwire.mongo_mock.assert_update_many( database="mydb", collection="sessions", filter={"expired": True}, update={"$set": {"cleaned": True}}, @@ -173,7 +173,7 @@ bigfoot.mongo_mock.assert_update_many( ### `assert_delete_one(database, collection, filter)` ```python -bigfoot.mongo_mock.assert_delete_one( +tripwire.mongo_mock.assert_delete_one( database="mydb", collection="users", filter={"_id": "abc"}, ) @@ -182,7 +182,7 @@ bigfoot.mongo_mock.assert_delete_one( ### `assert_delete_many(database, collection, filter)` ```python -bigfoot.mongo_mock.assert_delete_many( +tripwire.mongo_mock.assert_delete_many( database="mydb", collection="sessions", filter={"expired": True}, ) @@ -191,7 +191,7 @@ bigfoot.mongo_mock.assert_delete_many( ### `assert_aggregate(database, collection, pipeline)` ```python -bigfoot.mongo_mock.assert_aggregate( +tripwire.mongo_mock.assert_aggregate( database="mydb", collection="orders", pipeline=[{"$match": {"status": "complete"}}, {"$group": {"_id": "$customer", "total": {"$sum": "$amount"}}}], ) @@ -200,7 +200,7 @@ bigfoot.mongo_mock.assert_aggregate( ### `assert_count_documents(database, collection, filter)` ```python -bigfoot.mongo_mock.assert_count_documents( +tripwire.mongo_mock.assert_count_documents( database="mydb", collection="users", filter={"active": True}, ) @@ -212,22 +212,22 @@ Use the `raises` parameter to simulate MongoDB errors: ```python import pymongo.errors -import bigfoot +import tripwire def test_duplicate_key(): - bigfoot.mongo_mock.mock_operation( + tripwire.mongo_mock.mock_operation( "insert_one", returns=None, raises=pymongo.errors.DuplicateKeyError("E11000 duplicate key error"), ) - with bigfoot: + with tripwire: import pymongo client = pymongo.MongoClient() with pytest.raises(pymongo.errors.DuplicateKeyError): client.mydb.users.insert_one({"_id": "abc", "name": "Alice"}) - bigfoot.mongo_mock.assert_insert_one( + tripwire.mongo_mock.assert_insert_one( database="mydb", collection="users", document={"_id": "abc", "name": "Alice"}, ) @@ -252,17 +252,17 @@ def test_duplicate_key(): Mark a mock as optional with `required=False`: ```python -bigfoot.mongo_mock.mock_operation("count_documents", returns=0, required=False) +tripwire.mongo_mock.mock_operation("count_documents", returns=0, required=False) ``` An optional mock that is never triggered does not cause `UnusedMocksError` at teardown. ## UnmockedInteractionError -When code calls a MongoDB collection method that has no remaining mocks in its queue, bigfoot raises `UnmockedInteractionError`: +When code calls a MongoDB collection method that has no remaining mocks in its queue, tripwire raises `UnmockedInteractionError`: ``` mongo.find_one(...) was called but no mock was registered. Register a mock with: - bigfoot.mongo_mock.mock_operation('find_one', returns=...) + tripwire.mongo_mock.mock_operation('find_one', returns=...) ``` diff --git a/docs/guides/native-plugin.md b/docs/guides/native-plugin.md index 45dc623..f1b90b8 100644 --- a/docs/guides/native-plugin.md +++ b/docs/guides/native-plugin.md @@ -1,27 +1,27 @@ # NativePlugin Guide -`NativePlugin` intercepts `ctypes.CDLL` and `cffi.FFI.dlopen` at the class level, replacing loaded native libraries with proxy objects that route all function calls through bigfoot's FIFO queue. Each library:function pair has its own independent queue. Arguments are automatically serialized from ctypes types to Python equivalents for assertion. +`NativePlugin` intercepts `ctypes.CDLL` and `cffi.FFI.dlopen` at the class level, replacing loaded native libraries with proxy objects that route all function calls through tripwire's FIFO queue. Each library:function pair has its own independent queue. Arguments are automatically serialized from ctypes types to Python equivalents for assertion. -**Important:** `NativePlugin` is always available (no extra install required) but is NOT default enabled. You must explicitly enable it via `enabled_plugins = ["native"]` in your bigfoot config, or access it through the `bigfoot.native_mock` proxy. cffi interception is available when `cffi` is installed. +**Important:** `NativePlugin` is always available (no extra install required) but is NOT default enabled. You must explicitly enable it via `enabled_plugins = ["native"]` in your tripwire config, or access it through the `tripwire.native_mock` proxy. cffi interception is available when `cffi` is installed. ## Setup -In pytest, access `NativePlugin` through the `bigfoot.native_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `NativePlugin` through the `tripwire.native_mock` proxy. It auto-creates the plugin for the current test on first use: ```python -import bigfoot +import tripwire def test_call_native_sqrt(): - bigfoot.native_mock.mock_call("libm", "sqrt", returns=3.0) + tripwire.native_mock.mock_call("libm", "sqrt", returns=3.0) - with bigfoot: + with tripwire: import ctypes libm = ctypes.CDLL("libm") result = libm.sqrt(ctypes.c_double(9.0)) assert result == 3.0 - bigfoot.native_mock.assert_call( + tripwire.native_mock.assert_call( library="libm", function="sqrt", args=(9.0,), ) ``` @@ -29,8 +29,8 @@ def test_call_native_sqrt(): For manual use outside pytest, construct `NativePlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.native_plugin import NativePlugin +from tripwire import StrictVerifier +from tripwire.plugins.native_plugin import NativePlugin verifier = StrictVerifier() native_mock = NativePlugin(verifier) @@ -40,11 +40,11 @@ Each verifier may have at most one `NativePlugin`. A second `NativePlugin(verifi ## Registering mocks -Use `bigfoot.native_mock.mock_call(library, function, *, returns, ...)` to register a mock before entering the sandbox: +Use `tripwire.native_mock.mock_call(library, function, *, returns, ...)` to register a mock before entering the sandbox: ```python -bigfoot.native_mock.mock_call("libcrypto", "RAND_bytes", returns=0) -bigfoot.native_mock.mock_call("libm", "pow", returns=8.0) +tripwire.native_mock.mock_call("libcrypto", "RAND_bytes", returns=0) +tripwire.native_mock.mock_call("libm", "pow", returns=8.0) ``` ### Parameters @@ -63,10 +63,10 @@ Each library:function pair has its own independent FIFO queue. Multiple mocks fo ```python def test_multiple_native_calls(): - bigfoot.native_mock.mock_call("libm", "sqrt", returns=2.0) - bigfoot.native_mock.mock_call("libm", "sqrt", returns=3.0) + tripwire.native_mock.mock_call("libm", "sqrt", returns=2.0) + tripwire.native_mock.mock_call("libm", "sqrt", returns=3.0) - with bigfoot: + with tripwire: import ctypes libm = ctypes.CDLL("libm") r1 = libm.sqrt(ctypes.c_double(4.0)) @@ -75,18 +75,18 @@ def test_multiple_native_calls(): assert r1 == 2.0 assert r2 == 3.0 - bigfoot.native_mock.assert_call(library="libm", function="sqrt", args=(4.0,)) - bigfoot.native_mock.assert_call(library="libm", function="sqrt", args=(9.0,)) + tripwire.native_mock.assert_call(library="libm", function="sqrt", args=(4.0,)) + tripwire.native_mock.assert_call(library="libm", function="sqrt", args=(9.0,)) ``` ## Asserting interactions -Use the `assert_call` helper on `bigfoot.native_mock`. All three fields (`library`, `function`, `args`) are required: +Use the `assert_call` helper on `tripwire.native_mock`. All three fields (`library`, `function`, `args`) are required: ### `assert_call(library, function, *, args)` ```python -bigfoot.native_mock.assert_call( +tripwire.native_mock.assert_call( library="libm", function="pow", args=(2.0, 3.0), ) ``` @@ -116,22 +116,22 @@ ctypes arguments are automatically converted to Python equivalents for assertion Use the `raises` parameter to simulate native function failures: ```python -import bigfoot +import tripwire def test_library_load_error(): - bigfoot.native_mock.mock_call( + tripwire.native_mock.mock_call( "libcustom", "initialize", returns=None, raises=OSError("Symbol not found: initialize"), ) - with bigfoot: + with tripwire: import ctypes lib = ctypes.CDLL("libcustom") with pytest.raises(OSError, match="Symbol not found"): lib.initialize() - bigfoot.native_mock.assert_call(library="libcustom", function="initialize", args=()) + tripwire.native_mock.assert_call(library="libcustom", function="initialize", args=()) ``` ## Full example @@ -153,12 +153,12 @@ def test_library_load_error(): When `cffi` is installed, `NativePlugin` also intercepts `cffi.FFI.dlopen`. The same `mock_call` and `assert_call` API applies: ```python -import bigfoot +import tripwire def test_cffi_library(): - bigfoot.native_mock.mock_call("libz", "compressBound", returns=1024) + tripwire.native_mock.mock_call("libz", "compressBound", returns=1024) - with bigfoot: + with tripwire: import cffi ffi = cffi.FFI() ffi.cdef("long compressBound(long sourceLen);") @@ -167,7 +167,7 @@ def test_cffi_library(): assert bound == 1024 - bigfoot.native_mock.assert_call( + tripwire.native_mock.assert_call( library="libz", function="compressBound", args=(512,), ) ``` @@ -177,17 +177,17 @@ def test_cffi_library(): Mark a mock as optional with `required=False`: ```python -bigfoot.native_mock.mock_call("libm", "log", returns=0.0, required=False) +tripwire.native_mock.mock_call("libm", "log", returns=0.0, required=False) ``` An optional mock that is never triggered does not cause `UnusedMocksError` at teardown. ## UnmockedInteractionError -When code calls a native function that has no remaining mocks in its queue, bigfoot raises `UnmockedInteractionError`: +When code calls a native function that has no remaining mocks in its queue, tripwire raises `UnmockedInteractionError`: ``` libm.sqrt(...) was called but no mock was registered. Register a mock with: - bigfoot.native_mock.mock_call('libm', 'sqrt', returns=...) + tripwire.native_mock.mock_call('libm', 'sqrt', returns=...) ``` diff --git a/docs/guides/pika-plugin.md b/docs/guides/pika-plugin.md index 635ee7a..d036149 100644 --- a/docs/guides/pika-plugin.md +++ b/docs/guides/pika-plugin.md @@ -5,27 +5,27 @@ ## Installation ```bash -pip install bigfoot[pika] +pip install tripwire[pika] ``` This installs `pika`. ## Setup -In pytest, access `PikaPlugin` through the `bigfoot.pika_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `PikaPlugin` through the `tripwire.pika_mock` proxy. It auto-creates the plugin for the current test on first use: ```python -import bigfoot +import tripwire def test_publish_message(): - (bigfoot.pika_mock + (tripwire.pika_mock .new_session() .expect("connect", returns=None) .expect("channel", returns=None) .expect("publish", returns=None) .expect("close", returns=None)) - with bigfoot: + with tripwire: import pika connection = pika.BlockingConnection( pika.ConnectionParameters(host="rabbitmq.example.com") @@ -34,19 +34,19 @@ def test_publish_message(): channel.basic_publish(exchange="", routing_key="tasks", body=b"hello") connection.close() - bigfoot.pika_mock.assert_connect(host="rabbitmq.example.com", port=5672, virtual_host="/") - bigfoot.pika_mock.assert_channel() - bigfoot.pika_mock.assert_publish( + tripwire.pika_mock.assert_connect(host="rabbitmq.example.com", port=5672, virtual_host="/") + tripwire.pika_mock.assert_channel() + tripwire.pika_mock.assert_publish( exchange="", routing_key="tasks", body=b"hello", properties=None, ) - bigfoot.pika_mock.assert_close() + tripwire.pika_mock.assert_close() ``` For manual use outside pytest, construct `PikaPlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.pika_plugin import PikaPlugin +from tripwire import StrictVerifier +from tripwire.plugins.pika_plugin import PikaPlugin verifier = StrictVerifier() pika_mock = PikaPlugin(verifier) @@ -74,7 +74,7 @@ The `connect` step fires automatically during `pika.BlockingConnection(...)` con Use `new_session()` to create a `SessionHandle` and chain `.expect()` calls: ```python -(bigfoot.pika_mock +(tripwire.pika_mock .new_session() .expect("connect", returns=None) .expect("channel", returns=None) @@ -106,12 +106,12 @@ Use `new_session()` to create a `SessionHandle` and chain `.expect()` calls: ## Asserting interactions -Each step records an interaction on the timeline. Use the typed assertion helpers on `bigfoot.pika_mock`: +Each step records an interaction on the timeline. Use the typed assertion helpers on `tripwire.pika_mock`: ### `assert_connect(*, host, port, virtual_host)` ```python -bigfoot.pika_mock.assert_connect(host="rabbitmq.example.com", port=5672, virtual_host="/") +tripwire.pika_mock.assert_connect(host="rabbitmq.example.com", port=5672, virtual_host="/") ``` ### `assert_channel()` @@ -119,13 +119,13 @@ bigfoot.pika_mock.assert_connect(host="rabbitmq.example.com", port=5672, virtual No fields are required. ```python -bigfoot.pika_mock.assert_channel() +tripwire.pika_mock.assert_channel() ``` ### `assert_publish(*, exchange, routing_key, body, properties)` ```python -bigfoot.pika_mock.assert_publish( +tripwire.pika_mock.assert_publish( exchange="", routing_key="tasks", body=b"process this", properties=None, ) ``` @@ -133,19 +133,19 @@ bigfoot.pika_mock.assert_publish( ### `assert_consume(*, queue, auto_ack)` ```python -bigfoot.pika_mock.assert_consume(queue="tasks", auto_ack=False) +tripwire.pika_mock.assert_consume(queue="tasks", auto_ack=False) ``` ### `assert_ack(*, delivery_tag)` ```python -bigfoot.pika_mock.assert_ack(delivery_tag=1) +tripwire.pika_mock.assert_ack(delivery_tag=1) ``` ### `assert_nack(*, delivery_tag, requeue)` ```python -bigfoot.pika_mock.assert_nack(delivery_tag=1, requeue=True) +tripwire.pika_mock.assert_nack(delivery_tag=1, requeue=True) ``` ### `assert_close()` @@ -153,7 +153,7 @@ bigfoot.pika_mock.assert_nack(delivery_tag=1, requeue=True) No fields are required. ```python -bigfoot.pika_mock.assert_close() +tripwire.pika_mock.assert_close() ``` ## Full example @@ -176,10 +176,10 @@ A full consumer pattern with explicit acknowledgement: ```python import pika -import bigfoot +import tripwire def test_consume_and_ack(): - (bigfoot.pika_mock + (tripwire.pika_mock .new_session() .expect("connect", returns=None) .expect("channel", returns=None) @@ -187,7 +187,7 @@ def test_consume_and_ack(): .expect("ack", returns=None) .expect("close", returns=None)) - with bigfoot: + with tripwire: params = pika.ConnectionParameters(host="localhost") connection = pika.BlockingConnection(params) channel = connection.channel() @@ -195,11 +195,11 @@ def test_consume_and_ack(): channel.basic_ack(delivery_tag=1) connection.close() - bigfoot.pika_mock.assert_connect(host="localhost", port=5672, virtual_host="/") - bigfoot.pika_mock.assert_channel() - bigfoot.pika_mock.assert_consume(queue="work", auto_ack=False) - bigfoot.pika_mock.assert_ack(delivery_tag=1) - bigfoot.pika_mock.assert_close() + tripwire.pika_mock.assert_connect(host="localhost", port=5672, virtual_host="/") + tripwire.pika_mock.assert_channel() + tripwire.pika_mock.assert_consume(queue="work", auto_ack=False) + tripwire.pika_mock.assert_ack(delivery_tag=1) + tripwire.pika_mock.assert_close() ``` ## Negative acknowledgement @@ -208,7 +208,7 @@ Use `nack` to reject and optionally requeue a message: ```python def test_nack_and_requeue(): - (bigfoot.pika_mock + (tripwire.pika_mock .new_session() .expect("connect", returns=None) .expect("channel", returns=None) @@ -216,7 +216,7 @@ def test_nack_and_requeue(): .expect("nack", returns=None) .expect("close", returns=None)) - with bigfoot: + with tripwire: params = pika.ConnectionParameters(host="localhost") connection = pika.BlockingConnection(params) channel = connection.channel() @@ -224,9 +224,9 @@ def test_nack_and_requeue(): channel.basic_nack(delivery_tag=1, requeue=True) connection.close() - bigfoot.pika_mock.assert_connect(host="localhost", port=5672, virtual_host="/") - bigfoot.pika_mock.assert_channel() - bigfoot.pika_mock.assert_consume(queue="work", auto_ack=False) - bigfoot.pika_mock.assert_nack(delivery_tag=1, requeue=True) - bigfoot.pika_mock.assert_close() + tripwire.pika_mock.assert_connect(host="localhost", port=5672, virtual_host="/") + tripwire.pika_mock.assert_channel() + tripwire.pika_mock.assert_consume(queue="work", auto_ack=False) + tripwire.pika_mock.assert_nack(delivery_tag=1, requeue=True) + tripwire.pika_mock.assert_close() ``` diff --git a/docs/guides/plugin-layers.md b/docs/guides/plugin-layers.md index cb5a757..ca5239d 100644 --- a/docs/guides/plugin-layers.md +++ b/docs/guides/plugin-layers.md @@ -1,6 +1,6 @@ # Plugin Layers -bigfoot plugins intercept I/O at different abstraction levels. High-level plugins (boto3, gRPC, elasticsearch) sit above low-level plugins (HTTP, socket). By default, all available plugins are active simultaneously, which means a single operation can be intercepted at multiple layers. +tripwire plugins intercept I/O at different abstraction levels. High-level plugins (boto3, gRPC, elasticsearch) sit above low-level plugins (HTTP, socket). By default, all available plugins are active simultaneously, which means a single operation can be intercepted at multiple layers. ## The layered interception model @@ -26,16 +26,16 @@ For example, with both `boto3` and `http` enabled: ```python def test_s3_both_layers(): # Must mock at BOTH levels - bigfoot.boto3_mock.mock_call("s3", "GetObject", returns={"Body": b"data", "ContentLength": 4}) - bigfoot.http.mock_request("PUT", "https://s3.amazonaws.com/...", returns=httpx.Response(200)) + tripwire.boto3_mock.mock_call("s3", "GetObject", returns={"Body": b"data", "ContentLength": 4}) + tripwire.http.mock_request("PUT", "https://s3.amazonaws.com/...", returns=httpx.Response(200)) - with bigfoot: + with tripwire: client = boto3.client("s3") client.get_object(Bucket="my-bucket", Key="file.txt") # Must assert at BOTH levels - bigfoot.boto3_mock.assert_boto3_call(service="s3", operation="GetObject", params={...}) - bigfoot.http.assert_request("PUT", "https://s3.amazonaws.com/...") + tripwire.boto3_mock.assert_boto3_call(service="s3", operation="GetObject", params={...}) + tripwire.http.assert_request("PUT", "https://s3.amazonaws.com/...") ``` This is almost never what you want. Testing at two layers simultaneously adds noise without adding confidence. @@ -49,18 +49,18 @@ Use `disabled_plugins` to pick the layer that matches your test's intent: Disable the low-level plugin. You test the logical operation without caring about HTTP details. ```toml -[tool.bigfoot] +[tool.tripwire] disabled_plugins = ["http", "socket"] ``` ```python -def test_s3_get(bigfoot): - bigfoot.boto3_mock.mock_call("s3", "GetObject", returns={"Body": b"data", "ContentLength": 4}) +def test_s3_get(tripwire): + tripwire.boto3_mock.mock_call("s3", "GetObject", returns={"Body": b"data", "ContentLength": 4}) - with bigfoot: + with tripwire: response = boto3.client("s3").get_object(Bucket="b", Key="k") - bigfoot.boto3_mock.assert_boto3_call( + tripwire.boto3_mock.assert_boto3_call( service="s3", operation="GetObject", params={"Bucket": "b", "Key": "k"}, ) @@ -71,7 +71,7 @@ def test_s3_get(bigfoot): Disable the high-level plugin. Useful when you need to verify exact HTTP behavior, headers, or retry logic. ```toml -[tool.bigfoot] +[tool.tripwire] disabled_plugins = ["boto3"] ``` @@ -93,23 +93,23 @@ Requires mocking and asserting at both layers. Only do this if you genuinely nee For projects that use multiple high-level plugins (e.g., both boto3 and elasticsearch), disabling the shared low-level layer once covers all of them: ```toml -[tool.bigfoot] +[tool.tripwire] disabled_plugins = ["http", "socket"] ``` ## Configuration -Add `disabled_plugins` to `[tool.bigfoot]` in your `pyproject.toml`: +Add `disabled_plugins` to `[tool.tripwire]` in your `pyproject.toml`: ```toml -[tool.bigfoot] +[tool.tripwire] disabled_plugins = ["http", "socket"] ``` Alternatively, use `enabled_plugins` to allowlist only the plugins you need. The two options are mutually exclusive: ```toml -[tool.bigfoot] +[tool.tripwire] # Only these plugins will be active -- everything else is off. enabled_plugins = ["boto3", "subprocess", "logging"] ``` diff --git a/docs/guides/popen-plugin.md b/docs/guides/popen-plugin.md index 32b9618..80d6800 100644 --- a/docs/guides/popen-plugin.md +++ b/docs/guides/popen-plugin.md @@ -1,6 +1,6 @@ # PopenPlugin Guide -`PopenPlugin` intercepts `subprocess.Popen` by replacing the class with a fake that routes process lifecycle through a session script. It is included in core bigfoot -- no extra required. +`PopenPlugin` intercepts `subprocess.Popen` by replacing the class with a fake that routes process lifecycle through a session script. It is included in core tripwire -- no extra required. ## Coexistence with SubprocessPlugin @@ -8,18 +8,18 @@ ## Setup -In pytest, access `PopenPlugin` through the `bigfoot.popen_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `PopenPlugin` through the `tripwire.popen_mock` proxy. It auto-creates the plugin for the current test on first use: ```python -import bigfoot +import tripwire def test_run_command(): - (bigfoot.popen_mock + (tripwire.popen_mock .new_session() .expect("spawn", returns=None) .expect("communicate", returns=(b"hello\n", b"", 0))) - with bigfoot: + with tripwire: import subprocess proc = subprocess.Popen(["echo", "hello"], stdout=subprocess.PIPE) stdout, stderr = proc.communicate() @@ -27,15 +27,15 @@ def test_run_command(): assert stdout == b"hello\n" assert proc.returncode == 0 - bigfoot.popen_mock.assert_spawn(command=["echo", "hello"], stdin=None) - bigfoot.popen_mock.assert_communicate(input=None) + tripwire.popen_mock.assert_spawn(command=["echo", "hello"], stdin=None) + tripwire.popen_mock.assert_communicate(input=None) ``` For manual use outside pytest, construct `PopenPlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.popen_plugin import PopenPlugin +from tripwire import StrictVerifier +from tripwire.plugins.popen_plugin import PopenPlugin verifier = StrictVerifier() popen = PopenPlugin(verifier) @@ -57,7 +57,7 @@ The `spawn` step fires automatically during `subprocess.Popen(...)` construction Use `new_session()` to create a `SessionHandle` and chain `.expect()` calls to build the script: ```python -(bigfoot.popen_mock +(tripwire.popen_mock .new_session() .expect("spawn", returns=None) .expect("communicate", returns=(b"output", b"errors", 0))) @@ -82,14 +82,14 @@ Use `new_session()` to create a `SessionHandle` and chain `.expect()` calls to b ## Asserting interactions -Each step records an interaction on the timeline. Use the typed assertion helpers on `bigfoot.popen_mock`: +Each step records an interaction on the timeline. Use the typed assertion helpers on `tripwire.popen_mock`: ### `assert_spawn(*, command, stdin)` Asserts the next spawn interaction. Both `command` and `stdin` are required fields. ```python -bigfoot.popen_mock.assert_spawn(command=["git", "status"], stdin=None) +tripwire.popen_mock.assert_spawn(command=["git", "status"], stdin=None) ``` ### `assert_communicate(*, input)` @@ -97,7 +97,7 @@ bigfoot.popen_mock.assert_spawn(command=["git", "status"], stdin=None) Asserts the next communicate interaction. The `input` field is required. ```python -bigfoot.popen_mock.assert_communicate(input=None) +tripwire.popen_mock.assert_communicate(input=None) ``` ### `assert_wait()` @@ -105,7 +105,7 @@ bigfoot.popen_mock.assert_communicate(input=None) Asserts the next wait interaction. No fields are required. ```python -bigfoot.popen_mock.assert_wait() +tripwire.popen_mock.assert_wait() ``` ## Full example @@ -126,47 +126,47 @@ bigfoot.popen_mock.assert_wait() ```python def test_failing_command(): - (bigfoot.popen_mock + (tripwire.popen_mock .new_session() .expect("spawn", returns=None) .expect("communicate", returns=(b"", b"command not found\n", 127))) - with bigfoot: + with tripwire: proc = subprocess.Popen(["bogus-cmd"]) stdout, stderr = proc.communicate() assert proc.returncode == 127 assert stderr == b"command not found\n" - bigfoot.popen_mock.assert_spawn(command=["bogus-cmd"], stdin=None) - bigfoot.popen_mock.assert_communicate(input=None) + tripwire.popen_mock.assert_spawn(command=["bogus-cmd"], stdin=None) + tripwire.popen_mock.assert_communicate(input=None) ``` ## Passing input to communicate() ```python def test_communicate_with_input(): - (bigfoot.popen_mock + (tripwire.popen_mock .new_session() .expect("spawn", returns=None) .expect("communicate", returns=(b"response\n", b"", 0))) - with bigfoot: + with tripwire: proc = subprocess.Popen(["cat"], stdin=subprocess.PIPE, stdout=subprocess.PIPE) stdout, stderr = proc.communicate(input=b"hello\n") assert stdout == b"response\n" - bigfoot.popen_mock.assert_spawn(command=["cat"], stdin=None) - bigfoot.popen_mock.assert_communicate(input=b"hello\n") + tripwire.popen_mock.assert_spawn(command=["cat"], stdin=None) + tripwire.popen_mock.assert_communicate(input=b"hello\n") ``` ## ConflictError -At sandbox entry, `PopenPlugin` checks whether `subprocess.Popen` has already been patched by another library. If it has been modified by a third party (unittest.mock, pytest-mock, or an unknown library), bigfoot raises `ConflictError`: +At sandbox entry, `PopenPlugin` checks whether `subprocess.Popen` has already been patched by another library. If it has been modified by a third party (unittest.mock, pytest-mock, or an unknown library), tripwire raises `ConflictError`: ``` ConflictError: target='subprocess.Popen', patcher='unittest.mock' ``` -Nested bigfoot sandboxes use reference counting and do not conflict with each other. +Nested tripwire sandboxes use reference counting and do not conflict with each other. diff --git a/docs/guides/psycopg2-plugin.md b/docs/guides/psycopg2-plugin.md index 51a7f4e..de7c97d 100644 --- a/docs/guides/psycopg2-plugin.md +++ b/docs/guides/psycopg2-plugin.md @@ -5,24 +5,24 @@ ## Installation ```bash -pip install bigfoot[psycopg2] +pip install tripwire[psycopg2] ``` ## Setup -In pytest, access `Psycopg2Plugin` through the `bigfoot.psycopg2_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `Psycopg2Plugin` through the `tripwire.psycopg2_mock` proxy. It auto-creates the plugin for the current test on first use: ```python -import bigfoot +import tripwire def test_select_users(): - (bigfoot.psycopg2_mock + (tripwire.psycopg2_mock .new_session() .expect("connect", returns=None) .expect("execute", returns=[[1, "Alice"], [2, "Bob"]]) .expect("close", returns=None)) - with bigfoot: + with tripwire: import psycopg2 conn = psycopg2.connect(dsn="dbname=myapp") cur = conn.cursor() @@ -32,16 +32,16 @@ def test_select_users(): assert rows == [[1, "Alice"], [2, "Bob"]] - bigfoot.psycopg2_mock.assert_connect(dsn="dbname=myapp") - bigfoot.psycopg2_mock.assert_execute(sql="SELECT id, name FROM users", parameters=None) - bigfoot.psycopg2_mock.assert_close() + tripwire.psycopg2_mock.assert_connect(dsn="dbname=myapp") + tripwire.psycopg2_mock.assert_execute(sql="SELECT id, name FROM users", parameters=None) + tripwire.psycopg2_mock.assert_close() ``` For manual use outside pytest, construct `Psycopg2Plugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.psycopg2_plugin import Psycopg2Plugin +from tripwire import StrictVerifier +from tripwire.plugins.psycopg2_plugin import Psycopg2Plugin verifier = StrictVerifier() pg = Psycopg2Plugin(verifier) @@ -67,7 +67,7 @@ in_transaction --close--> closed Use `new_session()` to create a `SessionHandle` and chain `.expect()` calls: ```python -(bigfoot.psycopg2_mock +(tripwire.psycopg2_mock .new_session() .expect("connect", returns=None) .expect("execute", returns=[["row1"], ["row2"]]) @@ -110,10 +110,10 @@ The `assert_connect()` helper accepts whichever parameters were used: ```python # For DSN connections -bigfoot.psycopg2_mock.assert_connect(dsn="dbname=myapp host=localhost") +tripwire.psycopg2_mock.assert_connect(dsn="dbname=myapp host=localhost") # For keyword connections -bigfoot.psycopg2_mock.assert_connect(host="localhost", port=5432, dbname="myapp", user="admin") +tripwire.psycopg2_mock.assert_connect(host="localhost", port=5432, dbname="myapp", user="admin") ``` ## Cursor behavior @@ -121,13 +121,13 @@ bigfoot.psycopg2_mock.assert_connect(host="localhost", port=5432, dbname="myapp" The fake connection's `cursor()` returns a cursor proxy. Call `execute()` on the cursor, then use standard fetch methods: ```python -(bigfoot.psycopg2_mock +(tripwire.psycopg2_mock .new_session() .expect("connect", returns=None) .expect("execute", returns=[[1, "Alice"], [2, "Bob"], [3, "Carol"]]) .expect("close", returns=None)) -with bigfoot: +with tripwire: conn = psycopg2.connect(dsn="dbname=test") cur = conn.cursor() cur.execute("SELECT id, name FROM users") @@ -146,14 +146,14 @@ with bigfoot: ## Asserting interactions -Each step records an interaction on the timeline. Use the typed assertion helpers on `bigfoot.psycopg2_mock`: +Each step records an interaction on the timeline. Use the typed assertion helpers on `tripwire.psycopg2_mock`: ### `assert_connect(**kwargs)` Asserts the next connect interaction. Pass whichever connection fields were used. ```python -bigfoot.psycopg2_mock.assert_connect(dsn="dbname=myapp") +tripwire.psycopg2_mock.assert_connect(dsn="dbname=myapp") ``` ### `assert_execute(*, sql, parameters)` @@ -161,7 +161,7 @@ bigfoot.psycopg2_mock.assert_connect(dsn="dbname=myapp") Asserts the next execute interaction. Both `sql` and `parameters` are required. ```python -bigfoot.psycopg2_mock.assert_execute( +tripwire.psycopg2_mock.assert_execute( sql="INSERT INTO users (name) VALUES (%s)", parameters=("Alice",), ) @@ -172,7 +172,7 @@ bigfoot.psycopg2_mock.assert_execute( Asserts the next commit interaction. No fields are required. ```python -bigfoot.psycopg2_mock.assert_commit() +tripwire.psycopg2_mock.assert_commit() ``` ### `assert_rollback()` @@ -180,7 +180,7 @@ bigfoot.psycopg2_mock.assert_commit() Asserts the next rollback interaction. No fields are required. ```python -bigfoot.psycopg2_mock.assert_rollback() +tripwire.psycopg2_mock.assert_rollback() ``` ### `assert_close()` @@ -188,7 +188,7 @@ bigfoot.psycopg2_mock.assert_rollback() Asserts the next close interaction. No fields are required. ```python -bigfoot.psycopg2_mock.assert_close() +tripwire.psycopg2_mock.assert_close() ``` ## Full example diff --git a/docs/guides/pytest-integration.md b/docs/guides/pytest-integration.md index f3c9a43..4134610 100644 --- a/docs/guides/pytest-integration.md +++ b/docs/guides/pytest-integration.md @@ -1,71 +1,71 @@ # pytest Integration > **Writing a custom plugin?** See [Writing Plugins](writing-plugins.md) for the plugin authoring guide. -> **Configuring plugins?** See [Configuration](configuration.md) for `[tool.bigfoot]` settings. +> **Configuring plugins?** See [Configuration](configuration.md) for `[tool.tripwire]` settings. -bigfoot integrates with pytest automatically via the `pytest11` entry point. No `conftest.py` changes are required. Install bigfoot and every test gets a fresh verifier with automatic teardown verification. +tripwire integrates with pytest automatically via the `pytest11` entry point. No `conftest.py` changes are required. Install tripwire and every test gets a fresh verifier with automatic teardown verification. ## Module-level API (preferred) -The simplest way to use bigfoot is to `import bigfoot` and call module-level functions directly: +The simplest way to use tripwire is to `import tripwire` and call module-level functions directly: ```python -import bigfoot +import tripwire def test_example(): - email = bigfoot.mock("EmailService") + email = tripwire.mock("EmailService") email.send.returns(True) - with bigfoot: + with tripwire: email.send(to="user@example.com") - bigfoot.assert_interaction(email.send) + tripwire.assert_interaction(email.send) # verify_all() is called automatically at teardown ``` -`with bigfoot:` is shorthand for `with bigfoot.sandbox():`. Both return the active `StrictVerifier` from `__enter__`, so `with bigfoot as v:` gives you the verifier directly if you need it. `bigfoot.sandbox()` remains available as the explicit form for cases where you need to pass the context manager around. +`with tripwire:` is shorthand for `with tripwire.sandbox():`. Both return the active `StrictVerifier` from `__enter__`, so `with tripwire as v:` gives you the verifier directly if you need it. `tripwire.sandbox()` remains available as the explicit form for cases where you need to pass the context manager around. Behind the scenes, an autouse fixture creates one `StrictVerifier` per test, stores it in a `ContextVar`, and calls `verify_all()` after the test completes. ## Async tests -`bigfoot` and `bigfoot.in_any_order()` both support `async with`. Use `pytest-asyncio` for async test functions: +`tripwire` and `tripwire.in_any_order()` both support `async with`. Use `pytest-asyncio` for async test functions: ```python -import bigfoot +import tripwire import httpx async def test_async_http(): - bigfoot.http.mock_response("GET", "https://api.example.com/items", json={"items": []}) + tripwire.http.mock_response("GET", "https://api.example.com/items", json={"items": []}) - async with bigfoot: + async with tripwire: async with httpx.AsyncClient() as client: response = await client.get("https://api.example.com/items") assert response.json() == {"items": []} - bigfoot.assert_interaction(bigfoot.http.request, method="GET") + tripwire.assert_interaction(tripwire.http.request, method="GET") # verify_all() called at teardown ``` -## Using bigfoot.http +## Using tripwire.http -`bigfoot.http` is a proxy to the `HttpPlugin` for the current test. It auto-creates the plugin on first access, so no explicit instantiation is needed: +`tripwire.http` is a proxy to the `HttpPlugin` for the current test. It auto-creates the plugin on first access, so no explicit instantiation is needed: ```python -import bigfoot +import tripwire import requests def test_api_call(): - bigfoot.http.mock_response("POST", "https://api.example.com/users", + tripwire.http.mock_response("POST", "https://api.example.com/users", json={"id": 42}, status=201) - with bigfoot: + with tripwire: response = requests.post("https://api.example.com/users", json={"name": "Alice"}) assert response.status_code == 201 assert response.json()["id"] == 42 - bigfoot.assert_interaction( - bigfoot.http.request, + tripwire.assert_interaction( + tripwire.http.request, method="POST", url="https://api.example.com/users", status=201, @@ -76,39 +76,39 @@ def test_api_call(): `verify_all()` is called after the test function returns (or raises). If the test fails with an assertion error mid-way, `verify_all()` still runs. If both the test assertion and `verify_all()` fail, pytest reports both errors. -## bigfoot_verifier fixture (escape hatch) +## tripwire_verifier fixture (escape hatch) -When you need direct access to the `StrictVerifier` object, inject the `bigfoot_verifier` fixture. It returns the same verifier that the module-level API uses for that test: +When you need direct access to the `StrictVerifier` object, inject the `tripwire_verifier` fixture. It returns the same verifier that the module-level API uses for that test: ```python -from bigfoot import StrictVerifier +from tripwire import StrictVerifier -def test_with_fixture(bigfoot_verifier: StrictVerifier): - email = bigfoot_verifier.mock("EmailService") +def test_with_fixture(tripwire_verifier: StrictVerifier): + email = tripwire_verifier.mock("EmailService") email.send.returns(True) - with bigfoot_verifier.sandbox(): + with tripwire_verifier.sandbox(): email.send(to="user@example.com") - bigfoot_verifier.assert_interaction(email.send) + tripwire_verifier.assert_interaction(email.send) # verify_all() called automatically at teardown ``` You can also mix styles within the same test: ```python -import bigfoot -from bigfoot import StrictVerifier +import tripwire +from tripwire import StrictVerifier -def test_mixed(bigfoot_verifier: StrictVerifier): - email = bigfoot.mock("EmailService") # same verifier +def test_mixed(tripwire_verifier: StrictVerifier): + email = tripwire.mock("EmailService") # same verifier email.send.returns(True) - with bigfoot: + with tripwire: email.send(to="user@example.com") - assert bigfoot.current_verifier() is bigfoot_verifier # True - bigfoot.assert_interaction(email.send) + assert tripwire.current_verifier() is tripwire_verifier # True + tripwire.assert_interaction(email.send) ``` ## Manual StrictVerifier @@ -116,7 +116,7 @@ def test_mixed(bigfoot_verifier: StrictVerifier): If you need a verifier outside of pytest (e.g., in a script or custom test runner), create one manually and call `verify_all()` yourself: ```python -from bigfoot import StrictVerifier +from tripwire import StrictVerifier def test_manual(): verifier = StrictVerifier() diff --git a/docs/guides/quickstart.md b/docs/guides/quickstart.md index 4776230..d27397e 100644 --- a/docs/guides/quickstart.md +++ b/docs/guides/quickstart.md @@ -1,33 +1,33 @@ # Quick Start -This guide walks through a complete bigfoot test from setup to teardown and shows what each of the three error types looks like when violated. +This guide walks through a complete tripwire test from setup to teardown and shows what each of the three error types looks like when violated. -## Step 1: Import bigfoot +## Step 1: Import tripwire ```python -import bigfoot +import tripwire ``` -bigfoot registers an autouse pytest fixture behind the scenes. Every test automatically gets a fresh `StrictVerifier`. No fixture injection or `conftest.py` changes are needed. +tripwire registers an autouse pytest fixture behind the scenes. Every test automatically gets a fresh `StrictVerifier`. No fixture injection or `conftest.py` changes are needed. ## Step 2: Create a mock -bigfoot offers two ways to create mocks: +tripwire offers two ways to create mocks: **Import-site mock** patches a module attribute at its import location. Use `"module.path:attribute"` colon-separated syntax: ```python -cache = bigfoot.mock("myapp.services:cache") +cache = tripwire.mock("myapp.services:cache") ``` **Object mock** patches an attribute on a specific object instance: ```python email_service = EmailService() -email = bigfoot.mock.object(email_service, "send") +email = tripwire.mock.object(email_service, "send") ``` -Both return a mock object. Calling `bigfoot.mock()` or `bigfoot.mock.object()` registers the mock with the current test's verifier automatically. +Both return a mock object. Calling `tripwire.mock()` or `tripwire.mock.object()` registers the mock with the current test's verifier automatically. ## Step 3: Configure return values @@ -40,7 +40,7 @@ email.returns(True) For mocks with multiple methods, access methods by attribute: ```python -cache = bigfoot.mock("myapp.services:cache") +cache = tripwire.mock("myapp.services:cache") cache.get.returns("cached_value") cache.set.returns(None) ``` @@ -48,29 +48,29 @@ cache.set.returns(None) ## Step 4: Enter the sandbox ```python -with bigfoot: +with tripwire: result = email_service.send(to="user@example.com", subject="Welcome") assert result is True ``` -`with bigfoot:` is the preferred sandbox syntax. It is shorthand for `with bigfoot.sandbox():`. Both forms activate all plugins and all registered mocks for the current test. Any mock call is intercepted, recorded to the timeline, and dispatched to the configured side effect. Outside the sandbox, calling a mocked target raises `SandboxNotActiveError`. +`with tripwire:` is the preferred sandbox syntax. It is shorthand for `with tripwire.sandbox():`. Both forms activate all plugins and all registered mocks for the current test. Any mock call is intercepted, recorded to the timeline, and dispatched to the configured side effect. Outside the sandbox, calling a mocked target raises `SandboxNotActiveError`. -`with bigfoot:` returns the active `StrictVerifier` from `__enter__`, so you can capture it if needed: +`with tripwire:` returns the active `StrictVerifier` from `__enter__`, so you can capture it if needed: ```python -with bigfoot as v: +with tripwire as v: result = email_service.send(to="user@example.com", subject="Welcome") # v is the StrictVerifier for this test ``` -This is equivalent to `with bigfoot.sandbox() as v:`. Most tests use the module-level API (`bigfoot.mock()`, `bigfoot.assert_interaction()`, etc.) and never need `v` directly. The main case where you need it is registering custom plugins manually: +This is equivalent to `with tripwire.sandbox() as v:`. Most tests use the module-level API (`tripwire.mock()`, `tripwire.assert_interaction()`, etc.) and never need `v` directly. The main case where you need it is registering custom plugins manually: ```python -import bigfoot +import tripwire from myapp.plugins import DatabasePlugin def test_with_custom_plugin(): - with bigfoot as v: + with tripwire as v: db = DatabasePlugin(v) # register plugin on this verifier db.mock_query("SELECT 1", result=[1]) ... @@ -82,7 +82,7 @@ def test_with_custom_plugin(): email.assert_call(args=(), kwargs={"to": "user@example.com", "subject": "Welcome"}) ``` -Assertions must happen **after** the sandbox exits. `assert_call()` takes `args` (positional arguments tuple) and `kwargs` (keyword arguments dict) that must match the recorded interaction's details. Both `args` and `kwargs` are required. By default it checks the next unasserted interaction in sequence order. Use `bigfoot.in_any_order()` to relax ordering. +Assertions must happen **after** the sandbox exits. `assert_call()` takes `args` (positional arguments tuple) and `kwargs` (keyword arguments dict) that must match the recorded interaction's details. Both `args` and `kwargs` are required. By default it checks the next unasserted interaction in sequence order. Use `tripwire.in_any_order()` to relax ordering. For import-site mocks with methods, assert on the method proxy: @@ -166,14 +166,14 @@ Raised when `assert_interaction()`, `in_any_order()`, or `verify_all()` is calle ## Complete example ```python -import bigfoot +import tripwire def test_welcome_email(): # Create a mock that patches myapp.email:service at the import site - email = bigfoot.mock("myapp.email:service") + email = tripwire.mock("myapp.email:service") email.send.returns(True) - with bigfoot: + with tripwire: # Code under test calls myapp.email.service.send(...) from myapp.email import service result = service.send(to="user@example.com", subject="Welcome") @@ -191,7 +191,7 @@ def test_welcome_email(): For simpler cases where you have direct access to the object being tested: ```python -import bigfoot +import tripwire class EmailService: def send(self, to: str, subject: str) -> bool: @@ -199,10 +199,10 @@ class EmailService: def test_welcome_email_object_mock(): service = EmailService() - mock = bigfoot.mock.object(service, "send") + mock = tripwire.mock.object(service, "send") mock.returns(True) - with bigfoot: + with tripwire: result = service.send(to="user@example.com", subject="Welcome") assert result is True diff --git a/docs/guides/redis-plugin.md b/docs/guides/redis-plugin.md index 04ebd5c..fee3bdd 100644 --- a/docs/guides/redis-plugin.md +++ b/docs/guides/redis-plugin.md @@ -5,36 +5,36 @@ ## Installation ```bash -pip install bigfoot[redis] +pip install tripwire[redis] ``` This installs `redis>=4.0.0`. ## Setup -In pytest, access `RedisPlugin` through the `bigfoot.redis_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `RedisPlugin` through the `tripwire.redis_mock` proxy. It auto-creates the plugin for the current test on first use: ```python -import bigfoot +import tripwire def test_cache_lookup(): - bigfoot.redis_mock.mock_command("GET", returns="cached_value") + tripwire.redis_mock.mock_command("GET", returns="cached_value") - with bigfoot: + with tripwire: import redis r = redis.Redis() value = r.execute_command("GET", "mykey") assert value == "cached_value" - bigfoot.redis_mock.assert_command("GET", args=("mykey",), kwargs={}) + tripwire.redis_mock.assert_command("GET", args=("mykey",), kwargs={}) ``` For manual use outside pytest, construct `RedisPlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.redis_plugin import RedisPlugin +from tripwire import StrictVerifier +from tripwire.plugins.redis_plugin import RedisPlugin verifier = StrictVerifier() redis_mock = RedisPlugin(verifier) @@ -44,11 +44,11 @@ Each verifier may have at most one `RedisPlugin`. A second `RedisPlugin(verifier ## Registering mock commands -Use `bigfoot.redis_mock.mock_command(command, *, returns, ...)` to register a mock before entering the sandbox: +Use `tripwire.redis_mock.mock_command(command, *, returns, ...)` to register a mock before entering the sandbox: ```python -bigfoot.redis_mock.mock_command("SET", returns=True) -bigfoot.redis_mock.mock_command("GET", returns="hello") +tripwire.redis_mock.mock_command("SET", returns=True) +tripwire.redis_mock.mock_command("GET", returns="hello") ``` ### Parameters @@ -66,10 +66,10 @@ Each command name has its own independent FIFO queue. Multiple `mock_command("GE ```python def test_multiple_gets(): - bigfoot.redis_mock.mock_command("GET", returns="first") - bigfoot.redis_mock.mock_command("GET", returns="second") + tripwire.redis_mock.mock_command("GET", returns="first") + tripwire.redis_mock.mock_command("GET", returns="second") - with bigfoot: + with tripwire: r = redis.Redis() v1 = r.execute_command("GET", "key1") v2 = r.execute_command("GET", "key2") @@ -77,20 +77,20 @@ def test_multiple_gets(): assert v1 == "first" assert v2 == "second" - bigfoot.redis_mock.assert_command("GET", args=("key1",), kwargs={}) - bigfoot.redis_mock.assert_command("GET", args=("key2",), kwargs={}) + tripwire.redis_mock.assert_command("GET", args=("key1",), kwargs={}) + tripwire.redis_mock.assert_command("GET", args=("key2",), kwargs={}) ``` Command names are case-insensitive: `mock_command("get", ...)` matches `execute_command("GET", ...)`. ## Asserting interactions -Use the `assert_command` helper on `bigfoot.redis_mock`. All three fields (`command`, `args`, `kwargs`) are required: +Use the `assert_command` helper on `tripwire.redis_mock`. All three fields (`command`, `args`, `kwargs`) are required: ### `assert_command(command, args, kwargs)` ```python -bigfoot.redis_mock.assert_command("SET", args=("mykey", "myvalue"), kwargs={}) +tripwire.redis_mock.assert_command("SET", args=("mykey", "myvalue"), kwargs={}) ``` | Parameter | Type | Default | Description | @@ -105,21 +105,21 @@ Use the `raises` parameter to simulate Redis errors: ```python import redis as redis_lib -import bigfoot +import tripwire def test_redis_error(): - bigfoot.redis_mock.mock_command( + tripwire.redis_mock.mock_command( "GET", returns=None, raises=redis_lib.exceptions.ResponseError("WRONGTYPE"), ) - with bigfoot: + with tripwire: r = redis.Redis() with pytest.raises(redis_lib.exceptions.ResponseError): r.execute_command("GET", "badkey") - bigfoot.redis_mock.assert_command("GET", args=("badkey",), kwargs={}) + tripwire.redis_mock.assert_command("GET", args=("badkey",), kwargs={}) ``` ## Full example @@ -141,17 +141,17 @@ def test_redis_error(): Mark a mock as optional with `required=False`: ```python -bigfoot.redis_mock.mock_command("PING", returns="PONG", required=False) +tripwire.redis_mock.mock_command("PING", returns="PONG", required=False) ``` An optional mock that is never triggered does not cause `UnusedMocksError` at teardown. ## UnmockedInteractionError -When code calls `execute_command` with a command that has no remaining mocks in its queue, bigfoot raises `UnmockedInteractionError`: +When code calls `execute_command` with a command that has no remaining mocks in its queue, tripwire raises `UnmockedInteractionError`: ``` redis.GET(...) was called but no mock was registered. Register a mock with: - bigfoot.redis_mock.mock_command('GET', returns=...) + tripwire.redis_mock.mock_command('GET', returns=...) ``` diff --git a/docs/guides/smtp-plugin.md b/docs/guides/smtp-plugin.md index 803c3f0..d2063ba 100644 --- a/docs/guides/smtp-plugin.md +++ b/docs/guides/smtp-plugin.md @@ -1,44 +1,44 @@ # SmtpPlugin Guide -`SmtpPlugin` replaces `smtplib.SMTP` with a fake class that routes all SMTP operations through a session script. It is included in core bigfoot -- no extra required. +`SmtpPlugin` replaces `smtplib.SMTP` with a fake class that routes all SMTP operations through a session script. It is included in core tripwire -- no extra required. ## Setup -In pytest, access `SmtpPlugin` through the `bigfoot.smtp_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `SmtpPlugin` through the `tripwire.smtp_mock` proxy. It auto-creates the plugin for the current test on first use: ```python -import bigfoot +import tripwire def test_send_email(): - (bigfoot.smtp_mock + (tripwire.smtp_mock .new_session() .expect("connect", returns=None) .expect("ehlo", returns=(250, b"OK")) .expect("sendmail", returns={}) .expect("quit", returns=(221, b"Bye"))) - with bigfoot: + with tripwire: import smtplib smtp = smtplib.SMTP("mail.example.com", 25) smtp.ehlo() smtp.sendmail("from@example.com", ["to@example.com"], "Subject: hi\r\n\r\nhello") smtp.quit() - bigfoot.smtp_mock.assert_connect(host="mail.example.com", port=25) - bigfoot.smtp_mock.assert_ehlo(name="") - bigfoot.smtp_mock.assert_sendmail( + tripwire.smtp_mock.assert_connect(host="mail.example.com", port=25) + tripwire.smtp_mock.assert_ehlo(name="") + tripwire.smtp_mock.assert_sendmail( from_addr="from@example.com", to_addrs=["to@example.com"], msg="Subject: hi\r\n\r\nhello", ) - bigfoot.smtp_mock.assert_quit() + tripwire.smtp_mock.assert_quit() ``` For manual use outside pytest, construct `SmtpPlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.smtp_plugin import SmtpPlugin +from tripwire import StrictVerifier +from tripwire.plugins.smtp_plugin import SmtpPlugin verifier = StrictVerifier() smtp = SmtpPlugin(verifier) @@ -63,7 +63,7 @@ The `connect` step fires automatically during `smtplib.SMTP(host, port)` constru Use `new_session()` to create a `SessionHandle` and chain `.expect()` calls: ```python -(bigfoot.smtp_mock +(tripwire.smtp_mock .new_session() .expect("connect", returns=None) .expect("ehlo", returns=(250, b"OK")) @@ -97,24 +97,24 @@ Use `new_session()` to create a `SessionHandle` and chain `.expect()` calls: ## Asserting interactions -Each step records an interaction on the timeline. Use the typed assertion helpers on `bigfoot.smtp_mock`: +Each step records an interaction on the timeline. Use the typed assertion helpers on `tripwire.smtp_mock`: ### `assert_connect(*, host, port)` ```python -bigfoot.smtp_mock.assert_connect(host="mail.example.com", port=587) +tripwire.smtp_mock.assert_connect(host="mail.example.com", port=587) ``` ### `assert_ehlo(*, name)` ```python -bigfoot.smtp_mock.assert_ehlo(name="") +tripwire.smtp_mock.assert_ehlo(name="") ``` ### `assert_helo(*, name)` ```python -bigfoot.smtp_mock.assert_helo(name="") +tripwire.smtp_mock.assert_helo(name="") ``` ### `assert_starttls()` @@ -122,19 +122,19 @@ bigfoot.smtp_mock.assert_helo(name="") No fields are required. ```python -bigfoot.smtp_mock.assert_starttls() +tripwire.smtp_mock.assert_starttls() ``` ### `assert_login(*, user, password)` ```python -bigfoot.smtp_mock.assert_login(user="user@example.com", password="s3cret") +tripwire.smtp_mock.assert_login(user="user@example.com", password="s3cret") ``` ### `assert_sendmail(*, from_addr, to_addrs, msg)` ```python -bigfoot.smtp_mock.assert_sendmail( +tripwire.smtp_mock.assert_sendmail( from_addr="from@example.com", to_addrs=["to@example.com"], msg="Subject: hello\r\n\r\nhello", @@ -144,7 +144,7 @@ bigfoot.smtp_mock.assert_sendmail( ### `assert_send_message(*, msg)` ```python -bigfoot.smtp_mock.assert_send_message(msg=email_message_object) +tripwire.smtp_mock.assert_send_message(msg=email_message_object) ``` ### `assert_quit()` @@ -152,7 +152,7 @@ bigfoot.smtp_mock.assert_send_message(msg=email_message_object) No fields are required. ```python -bigfoot.smtp_mock.assert_quit() +tripwire.smtp_mock.assert_quit() ``` ## Full authenticated flow @@ -161,7 +161,7 @@ The full flow with TLS and authentication: ```python import smtplib -import bigfoot +import tripwire def send_secure_email(host, port, user, password, from_addr, to_addrs, body): smtp = smtplib.SMTP(host, port) @@ -172,7 +172,7 @@ def send_secure_email(host, port, user, password, from_addr, to_addrs, body): smtp.quit() def test_send_secure_email(): - (bigfoot.smtp_mock + (tripwire.smtp_mock .new_session() .expect("connect", returns=None) .expect("ehlo", returns=(250, b"OK")) @@ -181,7 +181,7 @@ def test_send_secure_email(): .expect("sendmail", returns={}) .expect("quit", returns=(221, b"Bye"))) - with bigfoot: + with tripwire: send_secure_email( "smtp.example.com", 587, "user@example.com", "s3cret", @@ -189,16 +189,16 @@ def test_send_secure_email(): "Subject: Report\r\n\r\nSee attached.", ) - bigfoot.smtp_mock.assert_connect(host="smtp.example.com", port=587) - bigfoot.smtp_mock.assert_ehlo(name="") - bigfoot.smtp_mock.assert_starttls() - bigfoot.smtp_mock.assert_login(user="user@example.com", password="s3cret") - bigfoot.smtp_mock.assert_sendmail( + tripwire.smtp_mock.assert_connect(host="smtp.example.com", port=587) + tripwire.smtp_mock.assert_ehlo(name="") + tripwire.smtp_mock.assert_starttls() + tripwire.smtp_mock.assert_login(user="user@example.com", password="s3cret") + tripwire.smtp_mock.assert_sendmail( from_addr="user@example.com", to_addrs=["recipient@example.com"], msg="Subject: Report\r\n\r\nSee attached.", ) - bigfoot.smtp_mock.assert_quit() + tripwire.smtp_mock.assert_quit() ``` ## Unauthenticated flow @@ -207,27 +207,27 @@ Skip `starttls` and `login` for servers that do not require authentication: ```python def test_send_unauthenticated_email(): - (bigfoot.smtp_mock + (tripwire.smtp_mock .new_session() .expect("connect", returns=None) .expect("ehlo", returns=(250, b"OK")) .expect("sendmail", returns={}) .expect("quit", returns=(221, b"Bye"))) - with bigfoot: + with tripwire: smtp = smtplib.SMTP("mail.example.com", 25) smtp.ehlo() smtp.sendmail("from@example.com", ["to@example.com"], "Subject: test\r\n\r\ntest") smtp.quit() - bigfoot.smtp_mock.assert_connect(host="mail.example.com", port=25) - bigfoot.smtp_mock.assert_ehlo(name="") - bigfoot.smtp_mock.assert_sendmail( + tripwire.smtp_mock.assert_connect(host="mail.example.com", port=25) + tripwire.smtp_mock.assert_ehlo(name="") + tripwire.smtp_mock.assert_sendmail( from_addr="from@example.com", to_addrs=["to@example.com"], msg="Subject: test\r\n\r\ntest", ) - bigfoot.smtp_mock.assert_quit() + tripwire.smtp_mock.assert_quit() ``` The state machine validates that `sendmail` is called from `greeted` (after `ehlo` without login) or from `authenticated` (after login). Calling `sendmail` from `connected` (skipping `ehlo`) raises `InvalidStateError`. @@ -238,27 +238,27 @@ Some legacy servers use `HELO` instead of `EHLO`. The state machine treats both ```python def test_helo_flow(): - (bigfoot.smtp_mock + (tripwire.smtp_mock .new_session() .expect("connect", returns=None) .expect("helo", returns=(250, b"OK")) .expect("sendmail", returns={}) .expect("quit", returns=(221, b"Bye"))) - with bigfoot: + with tripwire: smtp = smtplib.SMTP("mail.example.com", 25) smtp.helo() smtp.sendmail("from@example.com", ["to@example.com"], "Subject: test\r\n\r\ntest") smtp.quit() - bigfoot.smtp_mock.assert_connect(host="mail.example.com", port=25) - bigfoot.smtp_mock.assert_helo(name="") - bigfoot.smtp_mock.assert_sendmail( + tripwire.smtp_mock.assert_connect(host="mail.example.com", port=25) + tripwire.smtp_mock.assert_helo(name="") + tripwire.smtp_mock.assert_sendmail( from_addr="from@example.com", to_addrs=["to@example.com"], msg="Subject: test\r\n\r\ntest", ) - bigfoot.smtp_mock.assert_quit() + tripwire.smtp_mock.assert_quit() ``` ## Using `send_message` @@ -275,21 +275,21 @@ def test_send_message(): msg["To"] = "to@example.com" msg.set_content("See attached.") - (bigfoot.smtp_mock + (tripwire.smtp_mock .new_session() .expect("connect", returns=None) .expect("ehlo", returns=(250, b"OK")) .expect("send_message", returns={}) .expect("quit", returns=(221, b"Bye"))) - with bigfoot: + with tripwire: smtp = smtplib.SMTP("mail.example.com", 25) smtp.ehlo() smtp.send_message(msg) smtp.quit() - bigfoot.smtp_mock.assert_connect(host="mail.example.com", port=25) - bigfoot.smtp_mock.assert_ehlo(name="") - bigfoot.smtp_mock.assert_send_message(msg=msg) - bigfoot.smtp_mock.assert_quit() + tripwire.smtp_mock.assert_connect(host="mail.example.com", port=25) + tripwire.smtp_mock.assert_ehlo(name="") + tripwire.smtp_mock.assert_send_message(msg=msg) + tripwire.smtp_mock.assert_quit() ``` diff --git a/docs/guides/socket-plugin.md b/docs/guides/socket-plugin.md index 77e9a19..024fbed 100644 --- a/docs/guides/socket-plugin.md +++ b/docs/guides/socket-plugin.md @@ -1,23 +1,23 @@ # SocketPlugin Guide -`SocketPlugin` intercepts `socket.socket` at the class level, patching `connect`, `send`, `sendall`, `recv`, and `close`. It is included in core bigfoot -- no extra required. +`SocketPlugin` intercepts `socket.socket` at the class level, patching `connect`, `send`, `sendall`, `recv`, and `close`. It is included in core tripwire -- no extra required. ## Setup -In pytest, access `SocketPlugin` through the `bigfoot.socket_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `SocketPlugin` through the `tripwire.socket_mock` proxy. It auto-creates the plugin for the current test on first use: ```python -import bigfoot +import tripwire def test_echo_client(): - (bigfoot.socket_mock + (tripwire.socket_mock .new_session() .expect("connect", returns=None) .expect("sendall", returns=None) .expect("recv", returns=b"pong") .expect("close", returns=None)) - with bigfoot: + with tripwire: import socket sock = socket.socket() sock.connect(("127.0.0.1", 9999)) @@ -27,17 +27,17 @@ def test_echo_client(): assert data == b"pong" - bigfoot.socket_mock.assert_connect(host="127.0.0.1", port=9999) - bigfoot.socket_mock.assert_sendall(data=b"ping") - bigfoot.socket_mock.assert_recv(size=1024, data=b"pong") - bigfoot.socket_mock.assert_close() + tripwire.socket_mock.assert_connect(host="127.0.0.1", port=9999) + tripwire.socket_mock.assert_sendall(data=b"ping") + tripwire.socket_mock.assert_recv(size=1024, data=b"pong") + tripwire.socket_mock.assert_close() ``` For manual use outside pytest, construct `SocketPlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.socket_plugin import SocketPlugin +from tripwire import StrictVerifier +from tripwire.plugins.socket_plugin import SocketPlugin verifier = StrictVerifier() sock = SocketPlugin(verifier) @@ -58,7 +58,7 @@ disconnected --connect--> connected --send/sendall/recv--> connected --close--> Use `new_session()` to create a `SessionHandle` and chain `.expect()` calls: ```python -(bigfoot.socket_mock +(tripwire.socket_mock .new_session() .expect("connect", returns=None) .expect("send", returns=5) @@ -87,24 +87,24 @@ Use `new_session()` to create a `SessionHandle` and chain `.expect()` calls: ## Asserting interactions -Each step records an interaction on the timeline. Use the typed assertion helpers on `bigfoot.socket_mock`: +Each step records an interaction on the timeline. Use the typed assertion helpers on `tripwire.socket_mock`: ### `assert_connect(*, host, port)` ```python -bigfoot.socket_mock.assert_connect(host="127.0.0.1", port=8080) +tripwire.socket_mock.assert_connect(host="127.0.0.1", port=8080) ``` ### `assert_send(*, data)` ```python -bigfoot.socket_mock.assert_send(data=b"hello") +tripwire.socket_mock.assert_send(data=b"hello") ``` ### `assert_sendall(*, data)` ```python -bigfoot.socket_mock.assert_sendall(data=b"hello") +tripwire.socket_mock.assert_sendall(data=b"hello") ``` ### `assert_recv(*, size, data)` @@ -112,7 +112,7 @@ bigfoot.socket_mock.assert_sendall(data=b"hello") Both `size` and `data` are required. `size` is the buffer size passed to `recv()`, and `data` is the bytes actually returned. ```python -bigfoot.socket_mock.assert_recv(size=1024, data=b"response") +tripwire.socket_mock.assert_recv(size=1024, data=b"response") ``` ### `assert_close()` @@ -120,7 +120,7 @@ bigfoot.socket_mock.assert_recv(size=1024, data=b"response") No fields are required. ```python -bigfoot.socket_mock.assert_close() +tripwire.socket_mock.assert_close() ``` ## Multiple connections @@ -129,19 +129,19 @@ Sessions are consumed in registration order. The first `socket.connect()` pops t ```python def test_two_connections(): - (bigfoot.socket_mock + (tripwire.socket_mock .new_session() .expect("connect", returns=None) .expect("recv", returns=b"first") .expect("close", returns=None)) - (bigfoot.socket_mock + (tripwire.socket_mock .new_session() .expect("connect", returns=None) .expect("recv", returns=b"second") .expect("close", returns=None)) - with bigfoot: + with tripwire: s1 = socket.socket() s2 = socket.socket() s1.connect(("127.0.0.1", 9001)) @@ -151,12 +151,12 @@ def test_two_connections(): s1.close() s2.close() - bigfoot.socket_mock.assert_connect(host="127.0.0.1", port=9001) - bigfoot.socket_mock.assert_connect(host="127.0.0.1", port=9002) - bigfoot.socket_mock.assert_recv(size=1024, data=b"first") - bigfoot.socket_mock.assert_recv(size=1024, data=b"second") - bigfoot.socket_mock.assert_close() - bigfoot.socket_mock.assert_close() + tripwire.socket_mock.assert_connect(host="127.0.0.1", port=9001) + tripwire.socket_mock.assert_connect(host="127.0.0.1", port=9002) + tripwire.socket_mock.assert_recv(size=1024, data=b"first") + tripwire.socket_mock.assert_recv(size=1024, data=b"second") + tripwire.socket_mock.assert_close() + tripwire.socket_mock.assert_close() ``` ## Full example @@ -178,7 +178,7 @@ def test_two_connections(): Calling a method from the wrong state raises `InvalidStateError` immediately. For example, calling `recv()` before `connect()`: ``` -bigfoot.InvalidStateError: 'recv' called in state 'disconnected'; valid from: frozenset({'connected'}) +tripwire.InvalidStateError: 'recv' called in state 'disconnected'; valid from: frozenset({'connected'}) ``` Check the state machine diagram to ensure your session script matches the expected call order. diff --git a/docs/guides/ssh-plugin.md b/docs/guides/ssh-plugin.md index 6c98444..b06840b 100644 --- a/docs/guides/ssh-plugin.md +++ b/docs/guides/ssh-plugin.md @@ -5,26 +5,26 @@ ## Installation ```bash -pip install bigfoot[paramiko] +pip install tripwire[paramiko] ``` This installs `paramiko`. ## Setup -In pytest, access `SshPlugin` through the `bigfoot.ssh_mock` proxy. It auto-creates the plugin for the current test on first use: +In pytest, access `SshPlugin` through the `tripwire.ssh_mock` proxy. It auto-creates the plugin for the current test on first use: ```python -import bigfoot +import tripwire def test_remote_command(): - (bigfoot.ssh_mock + (tripwire.ssh_mock .new_session() .expect("connect", returns=None) .expect("exec_command", returns=(None, b"hello\n", b"")) .expect("close", returns=None)) - with bigfoot: + with tripwire: import paramiko client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) @@ -32,18 +32,18 @@ def test_remote_command(): stdin, stdout, stderr = client.exec_command("echo hello") client.close() - bigfoot.ssh_mock.assert_connect( + tripwire.ssh_mock.assert_connect( hostname="server.example.com", port=22, username="deploy", auth_method="password", ) - bigfoot.ssh_mock.assert_exec_command(command="echo hello") - bigfoot.ssh_mock.assert_close() + tripwire.ssh_mock.assert_exec_command(command="echo hello") + tripwire.ssh_mock.assert_close() ``` For manual use outside pytest, construct `SshPlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.ssh_plugin import SshPlugin +from tripwire import StrictVerifier +from tripwire.plugins.ssh_plugin import SshPlugin verifier = StrictVerifier() ssh_mock = SshPlugin(verifier) @@ -69,7 +69,7 @@ Unlike pika.BlockingConnection, `paramiko.SSHClient()` does not connect on const Use `new_session()` to create a `SessionHandle` and chain `.expect()` calls: ```python -(bigfoot.ssh_mock +(tripwire.ssh_mock .new_session() .expect("connect", returns=None) .expect("exec_command", returns=(None, b"output", b"")) @@ -103,14 +103,14 @@ Use `new_session()` to create a `SessionHandle` and chain `.expect()` calls: ## Asserting interactions -Each step records an interaction on the timeline. Use the typed assertion helpers on `bigfoot.ssh_mock`: +Each step records an interaction on the timeline. Use the typed assertion helpers on `tripwire.ssh_mock`: ### `assert_connect(*, hostname, port, username, auth_method)` The `auth_method` is automatically determined: `"key"` if `pkey` or `key_filename` is passed to `connect()`, otherwise `"password"`. ```python -bigfoot.ssh_mock.assert_connect( +tripwire.ssh_mock.assert_connect( hostname="server.example.com", port=22, username="deploy", auth_method="password", ) ``` @@ -118,7 +118,7 @@ bigfoot.ssh_mock.assert_connect( ### `assert_exec_command(*, command)` ```python -bigfoot.ssh_mock.assert_exec_command(command="systemctl restart nginx") +tripwire.ssh_mock.assert_exec_command(command="systemctl restart nginx") ``` ### `assert_open_sftp()` @@ -126,43 +126,43 @@ bigfoot.ssh_mock.assert_exec_command(command="systemctl restart nginx") No fields are required. ```python -bigfoot.ssh_mock.assert_open_sftp() +tripwire.ssh_mock.assert_open_sftp() ``` ### `assert_sftp_get(*, remotepath, localpath)` ```python -bigfoot.ssh_mock.assert_sftp_get(remotepath="/var/log/app.log", localpath="/tmp/app.log") +tripwire.ssh_mock.assert_sftp_get(remotepath="/var/log/app.log", localpath="/tmp/app.log") ``` ### `assert_sftp_put(*, localpath, remotepath)` ```python -bigfoot.ssh_mock.assert_sftp_put(localpath="/tmp/config.yaml", remotepath="/etc/app/config.yaml") +tripwire.ssh_mock.assert_sftp_put(localpath="/tmp/config.yaml", remotepath="/etc/app/config.yaml") ``` ### `assert_sftp_listdir(*, path)` ```python -bigfoot.ssh_mock.assert_sftp_listdir(path="/var/log") +tripwire.ssh_mock.assert_sftp_listdir(path="/var/log") ``` ### `assert_sftp_stat(*, path)` ```python -bigfoot.ssh_mock.assert_sftp_stat(path="/etc/app/config.yaml") +tripwire.ssh_mock.assert_sftp_stat(path="/etc/app/config.yaml") ``` ### `assert_sftp_mkdir(*, path)` ```python -bigfoot.ssh_mock.assert_sftp_mkdir(path="/var/data/exports") +tripwire.ssh_mock.assert_sftp_mkdir(path="/var/data/exports") ``` ### `assert_sftp_remove(*, path)` ```python -bigfoot.ssh_mock.assert_sftp_remove(path="/tmp/old-backup.tar.gz") +tripwire.ssh_mock.assert_sftp_remove(path="/tmp/old-backup.tar.gz") ``` ### `assert_close()` @@ -170,7 +170,7 @@ bigfoot.ssh_mock.assert_sftp_remove(path="/tmp/old-backup.tar.gz") No fields are required. ```python -bigfoot.ssh_mock.assert_close() +tripwire.ssh_mock.assert_close() ``` ## Full example @@ -193,24 +193,24 @@ When `pkey` or `key_filename` is passed to `connect()`, the `auth_method` detail ```python def test_key_auth(): - (bigfoot.ssh_mock + (tripwire.ssh_mock .new_session() .expect("connect", returns=None) .expect("exec_command", returns=(None, b"ok", b"")) .expect("close", returns=None)) - with bigfoot: + with tripwire: client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) client.connect("bastion.example.com", username="ops", key_filename="/home/ops/.ssh/id_ed25519") client.exec_command("whoami") client.close() - bigfoot.ssh_mock.assert_connect( + tripwire.ssh_mock.assert_connect( hostname="bastion.example.com", port=22, username="ops", auth_method="key", ) - bigfoot.ssh_mock.assert_exec_command(command="whoami") - bigfoot.ssh_mock.assert_close() + tripwire.ssh_mock.assert_exec_command(command="whoami") + tripwire.ssh_mock.assert_close() ``` ## SFTP file operations @@ -219,7 +219,7 @@ A full SFTP session with multiple file operations: ```python def test_sftp_operations(): - (bigfoot.ssh_mock + (tripwire.ssh_mock .new_session() .expect("connect", returns=None) .expect("open_sftp", returns=None) @@ -230,7 +230,7 @@ def test_sftp_operations(): .expect("sftp_remove", returns=None) .expect("close", returns=None)) - with bigfoot: + with tripwire: client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) client.connect("fileserver.example.com", username="sync") @@ -242,14 +242,14 @@ def test_sftp_operations(): sftp.remove("/data/incoming/data.csv") client.close() - bigfoot.ssh_mock.assert_connect( + tripwire.ssh_mock.assert_connect( hostname="fileserver.example.com", port=22, username="sync", auth_method="password", ) - bigfoot.ssh_mock.assert_open_sftp() - bigfoot.ssh_mock.assert_sftp_listdir(path="/data/incoming") - bigfoot.ssh_mock.assert_sftp_get(remotepath="/data/incoming/data.csv", localpath="/tmp/data.csv") - bigfoot.ssh_mock.assert_sftp_mkdir(path="/data/processed") - bigfoot.ssh_mock.assert_sftp_put(localpath="/tmp/result.csv", remotepath="/data/processed/result.csv") - bigfoot.ssh_mock.assert_sftp_remove(path="/data/incoming/data.csv") - bigfoot.ssh_mock.assert_close() + tripwire.ssh_mock.assert_open_sftp() + tripwire.ssh_mock.assert_sftp_listdir(path="/data/incoming") + tripwire.ssh_mock.assert_sftp_get(remotepath="/data/incoming/data.csv", localpath="/tmp/data.csv") + tripwire.ssh_mock.assert_sftp_mkdir(path="/data/processed") + tripwire.ssh_mock.assert_sftp_put(localpath="/tmp/result.csv", remotepath="/data/processed/result.csv") + tripwire.ssh_mock.assert_sftp_remove(path="/data/incoming/data.csv") + tripwire.ssh_mock.assert_close() ``` diff --git a/docs/guides/stateful-plugins.md b/docs/guides/stateful-plugins.md index ec92dac..cdc2b6e 100644 --- a/docs/guides/stateful-plugins.md +++ b/docs/guides/stateful-plugins.md @@ -2,7 +2,7 @@ Most protocols are not a bag of independent calls. A database connection must be opened before queries can run. A socket must connect before it can send. SMTP must greet the server before submitting a message. The order matters, the state matters, and a test that does not enforce both can pass while the production code does something impossible. -bigfoot's stateful plugins address this by modelling each protocol as an explicit state machine. Before your test runs, you write a session script: an ordered list of method calls you expect to happen, each paired with the value it should return. bigfoot consumes that script step by step during the test and raises `InvalidStateError` immediately if a method is called from the wrong state. +tripwire's stateful plugins address this by modelling each protocol as an explicit state machine. Before your test runs, you write a session script: an ordered list of method calls you expect to happen, each paired with the value it should return. tripwire consumes that script step by step during the test and raises `InvalidStateError` immediately if a method is called from the wrong state. This guide covers each stateful plugin with working examples derived from the test suite. @@ -18,7 +18,7 @@ Every stateful plugin (except `RedisPlugin`, which is stateless) extends `StateM **FIFO binding.** Sessions are consumed in registration order. The first call to the connection entry point (e.g., `socket.connect()`) pops the first queued session and binds it to that connection object. If two connections are opened, they each get their own session in the order they were registered. -**Auto-assertion.** State machine interactions are marked as asserted the moment they are recorded. You do not call `bigfoot.assert_interaction()` for stateful plugins. `verify_all()` still runs at teardown and will report any `required=True` steps that were configured but never consumed. +**Auto-assertion.** State machine interactions are marked as asserted the moment they are recorded. You do not call `tripwire.assert_interaction()` for stateful plugins. `verify_all()` still runs at teardown and will report any `required=True` steps that were configured but never consumed. --- @@ -32,23 +32,23 @@ Every stateful plugin (except `RedisPlugin`, which is stateless) extends `StateM disconnected --connect--> connected --send/sendall/recv--> connected --close--> closed ``` -**Proxy:** `bigfoot.socket_mock` +**Proxy:** `tripwire.socket_mock` ### Quickstart ```python import socket -import bigfoot +import tripwire def test_echo_client(): - (bigfoot.socket_mock + (tripwire.socket_mock .new_session() .expect("connect", returns=None) .expect("sendall", returns=None) .expect("recv", returns=b"pong") .expect("close", returns=None)) - with bigfoot: + with tripwire: sock = socket.socket() sock.connect(("127.0.0.1", 9999)) sock.sendall(b"ping") @@ -59,7 +59,7 @@ def test_echo_client(): # verify_all() called automatically at teardown ``` -No imports other than `bigfoot` and `socket`. The proxy `bigfoot.socket_mock` auto-creates the plugin on the current test verifier the first time it is accessed. +No imports other than `tripwire` and `socket`. The proxy `tripwire.socket_mock` auto-creates the plugin on the current test verifier the first time it is accessed. ### Scripting multiple connections @@ -67,19 +67,19 @@ Sessions are consumed in registration order: ```python def test_two_connections(): - (bigfoot.socket_mock + (tripwire.socket_mock .new_session() .expect("connect", returns=None) .expect("recv", returns=b"first") .expect("close", returns=None)) - (bigfoot.socket_mock + (tripwire.socket_mock .new_session() .expect("connect", returns=None) .expect("recv", returns=b"second") .expect("close", returns=None)) - with bigfoot: + with tripwire: s1 = socket.socket() s2 = socket.socket() s1.connect(("127.0.0.1", 9001)) @@ -96,17 +96,17 @@ Calling a method from the wrong state raises `InvalidStateError` immediately: ```python def test_recv_before_connect(): - bigfoot.socket_mock.new_session() # empty session + tripwire.socket_mock.new_session() # empty session - with bigfoot: + with tripwire: sock = socket.socket() # Bind the session without connecting first by directly using _bind_connection: - from bigfoot.plugins.socket_plugin import SocketPlugin - plugin = next(p for p in bigfoot.current_verifier()._plugins if isinstance(p, SocketPlugin)) + from tripwire.plugins.socket_plugin import SocketPlugin + plugin = next(p for p in tripwire.current_verifier()._plugins if isinstance(p, SocketPlugin)) handle = plugin._bind_connection(sock) # handle._state == "disconnected" - with pytest.raises(bigfoot.InvalidStateError) as exc_info: + with pytest.raises(tripwire.InvalidStateError) as exc_info: plugin._execute_step(handle, "recv", (1024,), {}, "socket:recv") exc = exc_info.value @@ -131,21 +131,21 @@ in_transaction --commit/rollback--> connected connected/in_transaction --close--> closed ``` -**Proxy:** `bigfoot.db_mock` +**Proxy:** `tripwire.db_mock` ### Quickstart ```python import sqlite3 -import bigfoot +import tripwire def test_select_users(): - (bigfoot.db_mock + (tripwire.db_mock .new_session() .expect("execute", returns=[[1, "Alice"], [2, "Bob"]]) .expect("close", returns=None)) - with bigfoot: + with tripwire: conn = sqlite3.connect(":memory:") cursor = conn.execute("SELECT id, name FROM users") rows = cursor.fetchall() @@ -158,12 +158,12 @@ def test_select_users(): ```python def test_cursor_style(): - (bigfoot.db_mock + (tripwire.db_mock .new_session() .expect("execute", returns=[["x"], ["y"]]) .expect("close", returns=None)) - with bigfoot: + with tripwire: conn = sqlite3.connect(":memory:") cur = conn.cursor() cur.execute("SELECT val FROM t") @@ -181,14 +181,14 @@ Each `execute()` moves the connection into `in_transaction`. `commit()` and `rol ```python def test_commit_then_execute(): - (bigfoot.db_mock + (tripwire.db_mock .new_session() .expect("execute", returns=[]) .expect("commit", returns=None) .expect("execute", returns=[]) # valid only after commit reset state to "connected" .expect("close", returns=None)) - with bigfoot: + with tripwire: conn = sqlite3.connect(":memory:") conn.execute("INSERT INTO t VALUES (1)") conn.commit() @@ -199,9 +199,9 @@ def test_commit_then_execute(): Calling `commit()` from `connected` (before any `execute()`) raises `InvalidStateError`: ```python -with bigfoot: +with tripwire: conn = sqlite3.connect(":memory:") - with pytest.raises(bigfoot.InvalidStateError) as exc_info: + with pytest.raises(tripwire.InvalidStateError) as exc_info: conn.commit() conn.close() @@ -215,7 +215,7 @@ assert exc_info.value.valid_states == frozenset({"in_transaction"}) `AsyncWebSocketPlugin` intercepts `websockets.connect` and returns an async context manager that drives the session script. -**Requires:** `pip install bigfoot[websockets]` +**Requires:** `pip install tripwire[websockets]` **State machine:** @@ -223,24 +223,24 @@ assert exc_info.value.valid_states == frozenset({"in_transaction"}) connecting --connect (on __aenter__)--> open --send/recv--> open --close--> closed ``` -**Proxy:** `bigfoot.async_websocket_mock` +**Proxy:** `tripwire.async_websocket_mock` ### Quickstart ```python import websockets -import bigfoot +import tripwire import pytest async def test_ws_echo(): - (bigfoot.async_websocket_mock + (tripwire.async_websocket_mock .new_session() .expect("connect", returns=None) .expect("send", returns=None) .expect("recv", returns="pong") .expect("close", returns=None)) - with bigfoot: + with tripwire: async with websockets.connect("ws://localhost:8765") as ws: await ws.send("ping") message = await ws.recv() @@ -257,19 +257,19 @@ Sessions are popped at `websockets.connect()` call time, not at `__aenter__` tim ```python async def test_two_ws_connections(): - (bigfoot.async_websocket_mock + (tripwire.async_websocket_mock .new_session() .expect("connect", returns=None) .expect("recv", returns="first") .expect("close", returns=None)) - (bigfoot.async_websocket_mock + (tripwire.async_websocket_mock .new_session() .expect("connect", returns=None) .expect("recv", returns="second") .expect("close", returns=None)) - with bigfoot: + with tripwire: cm1 = websockets.connect("ws://localhost:8765") cm2 = websockets.connect("ws://localhost:8765") async with cm1 as ws1: @@ -284,7 +284,7 @@ async def test_two_ws_connections(): `SyncWebSocketPlugin` intercepts `websocket.create_connection` from the `websocket-client` library and returns a fake connection object. -**Requires:** `pip install bigfoot[websocket-client]` +**Requires:** `pip install tripwire[websocket-client]` **State machine:** @@ -292,23 +292,23 @@ async def test_two_ws_connections(): connecting --connect--> open --send/recv--> open --close--> closed ``` -**Proxy:** `bigfoot.sync_websocket_mock` +**Proxy:** `tripwire.sync_websocket_mock` ### Quickstart ```python import websocket -import bigfoot +import tripwire def test_sync_ws(): - (bigfoot.sync_websocket_mock + (tripwire.sync_websocket_mock .new_session() .expect("connect", returns=None) .expect("send", returns=None) .expect("recv", returns="hello") .expect("close", returns=None)) - with bigfoot: + with tripwire: ws = websocket.create_connection("ws://localhost:8765") ws.send("hi") message = ws.recv() @@ -333,7 +333,7 @@ running --communicate--> terminated running --wait--> terminated (also releases the session) ``` -**Proxy:** `bigfoot.popen_mock` +**Proxy:** `tripwire.popen_mock` **Coexistence with SubprocessPlugin:** `SubprocessPlugin` patches `subprocess.run` and `shutil.which`. `PopenPlugin` patches `subprocess.Popen`. Both can be active in the same sandbox without interference. @@ -343,15 +343,15 @@ The most common usage pattern. The `communicate` step returns a 3-tuple `(stdout ```python import subprocess -import bigfoot +import tripwire def test_run_command(): - (bigfoot.popen_mock + (tripwire.popen_mock .new_session() .expect("init", returns=None) .expect("communicate", returns=(b"hello\n", b"", 0))) - with bigfoot: + with tripwire: proc = subprocess.Popen(["echo", "hello"], stdout=subprocess.PIPE) stdout, stderr = proc.communicate() @@ -364,12 +364,12 @@ def test_run_command(): ```python def test_failing_command(): - (bigfoot.popen_mock + (tripwire.popen_mock .new_session() .expect("init", returns=None) .expect("communicate", returns=(b"", b"command not found", 127))) - with bigfoot: + with tripwire: proc = subprocess.Popen(["bogus-cmd"]) stdout, stderr = proc.communicate() @@ -383,12 +383,12 @@ def test_failing_command(): ```python def test_wait(): - (bigfoot.popen_mock + (tripwire.popen_mock .new_session() .expect("init", returns=None) .expect("wait", returns=0)) - with bigfoot: + with tripwire: proc = subprocess.Popen(["sleep", "1"]) rc = proc.wait() @@ -402,12 +402,12 @@ For code that reads `proc.stdout` and `proc.stderr` directly rather than using ` ```python def test_stream_read(): - (bigfoot.popen_mock + (tripwire.popen_mock .new_session() .expect("init", returns=None) .expect("stdout.read", returns=b"output data")) - with bigfoot: + with tripwire: proc = subprocess.Popen(["cmd"], stdout=subprocess.PIPE) data = proc.stdout.read() @@ -432,16 +432,16 @@ sending/greeted/authenticated --quit--> closed `starttls` and `login` are optional steps. Skip them in your session script for an unauthenticated flow. -**Proxy:** `bigfoot.smtp_mock` +**Proxy:** `tripwire.smtp_mock` ### Full authenticated flow (ehlo + starttls + login + sendmail + quit) ```python import smtplib -import bigfoot +import tripwire def test_send_authenticated_email(): - (bigfoot.smtp_mock + (tripwire.smtp_mock .new_session() .expect("connect", returns=None) .expect("ehlo", returns=(250, b"OK")) @@ -450,7 +450,7 @@ def test_send_authenticated_email(): .expect("sendmail", returns={}) .expect("quit", returns=(221, b"Bye"))) - with bigfoot: + with tripwire: smtp = smtplib.SMTP("mail.example.com", 587) smtp.ehlo() smtp.starttls() @@ -467,14 +467,14 @@ def test_send_authenticated_email(): ```python def test_send_unauthenticated_email(): - (bigfoot.smtp_mock + (tripwire.smtp_mock .new_session() .expect("connect", returns=None) .expect("ehlo", returns=(250, b"OK")) .expect("sendmail", returns={}) .expect("quit", returns=(221, b"Bye"))) - with bigfoot: + with tripwire: smtp = smtplib.SMTP("mail.example.com", 25) smtp.ehlo() smtp.sendmail( @@ -493,20 +493,20 @@ The state machine validates that `sendmail` is called from `greeted` (after `ehl `RedisPlugin` intercepts `redis.Redis.execute_command` at the class level. Unlike the other stateful plugins, Redis commands carry no inherent ordering constraint — GET and SET do not depend on each other's state. `RedisPlugin` therefore extends `BasePlugin` directly and uses a per-command FIFO queue rather than a session handle. -**Requires:** `pip install bigfoot[redis]` +**Requires:** `pip install tripwire[redis]` -**Proxy:** `bigfoot.redis_mock` +**Proxy:** `tripwire.redis_mock` ### Quickstart ```python import redis -import bigfoot +import tripwire def test_cache_lookup(): - bigfoot.redis_mock.mock_command("GET", returns="cached_value") + tripwire.redis_mock.mock_command("GET", returns="cached_value") - with bigfoot: + with tripwire: r = redis.Redis() value = r.execute_command("GET", "mykey") @@ -519,11 +519,11 @@ Each command name has its own independent FIFO queue. Multiple `mock_command("GE ```python def test_get_set(): - bigfoot.redis_mock.mock_command("SET", returns=True) - bigfoot.redis_mock.mock_command("GET", returns="first") - bigfoot.redis_mock.mock_command("GET", returns="second") + tripwire.redis_mock.mock_command("SET", returns=True) + tripwire.redis_mock.mock_command("GET", returns="first") + tripwire.redis_mock.mock_command("GET", returns="second") - with bigfoot: + with tripwire: r = redis.Redis() r.execute_command("SET", "k", "v") v1 = r.execute_command("GET", "key1") @@ -540,13 +540,13 @@ Command names are case-insensitive: `mock_command("get", ...)` matches `execute_ ```python def test_redis_error(): import redis as redis_lib - bigfoot.redis_mock.mock_command( + tripwire.redis_mock.mock_command( "GET", returns=None, raises=redis_lib.exceptions.ResponseError("WRONGTYPE"), ) - with bigfoot: + with tripwire: r = redis.Redis() with pytest.raises(redis_lib.exceptions.ResponseError): r.execute_command("GET", "badkey") @@ -561,7 +561,7 @@ def test_redis_error(): Raised when a method is called from a state it is not valid in. The error carries `source_id`, `method`, `current_state`, and `valid_states`. ``` -bigfoot.InvalidStateError: 'recv' called in state 'disconnected'; valid from: frozenset({'connected'}) +tripwire.InvalidStateError: 'recv' called in state 'disconnected'; valid from: frozenset({'connected'}) ``` **Fix:** Check the state machine diagram for the plugin. You likely have a missing step in your session script (e.g., no `connect` step before the first `recv`), or the code under test is calling methods out of order. @@ -574,10 +574,10 @@ Raised when a connection entry point fires (e.g., `socket.connect()`, `sqlite3.c UnmockedInteractionError: source_id='socket:connect' hint='socket.socket.connect(...) was called but no session was queued. Register a session with: - bigfoot.socket_mock.new_session().expect("connect", returns=...)' + tripwire.socket_mock.new_session().expect("connect", returns=...)' ``` -**Fix:** Call `bigfoot.socket_mock.new_session()` (or the appropriate proxy) before entering the sandbox. +**Fix:** Call `tripwire.socket_mock.new_session()` (or the appropriate proxy) before entering the sandbox. Also raised when the session script is exhausted but the code under test makes another call. In this case the hint shows the method that ran out of steps. diff --git a/docs/guides/subprocess-plugin.md b/docs/guides/subprocess-plugin.md index ea04f08..acc94d1 100644 --- a/docs/guides/subprocess-plugin.md +++ b/docs/guides/subprocess-plugin.md @@ -1,28 +1,28 @@ # SubprocessPlugin Guide -`SubprocessPlugin` intercepts `subprocess.run` and `shutil.which` globally during a sandbox. It is included in core bigfoot — no extra required. +`SubprocessPlugin` intercepts `subprocess.run` and `shutil.which` globally during a sandbox. It is included in core tripwire — no extra required. ## Setup -In pytest, access `SubprocessPlugin` through the `bigfoot.subprocess_mock` proxy. It auto-creates the plugin for the current test on first use — no explicit instantiation needed: +In pytest, access `SubprocessPlugin` through the `tripwire.subprocess_mock` proxy. It auto-creates the plugin for the current test on first use — no explicit instantiation needed: ```python -import bigfoot +import tripwire def test_build(): - bigfoot.subprocess_mock.mock_run(["make", "all"], returncode=0) + tripwire.subprocess_mock.mock_run(["make", "all"], returncode=0) - with bigfoot: + with tripwire: run_build() - bigfoot.subprocess_mock.assert_run(command=["make", "all"], returncode=0, stdout="", stderr="") + tripwire.subprocess_mock.assert_run(command=["make", "all"], returncode=0, stdout="", stderr="") ``` For manual use outside pytest, construct `SubprocessPlugin` explicitly: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.subprocess import SubprocessPlugin +from tripwire import StrictVerifier +from tripwire.plugins.subprocess import SubprocessPlugin verifier = StrictVerifier() sp = SubprocessPlugin(verifier) @@ -32,10 +32,10 @@ Each verifier may have at most one `SubprocessPlugin`. A second `SubprocessPlugi ## Registering `subprocess.run` mocks -Use `bigfoot.subprocess_mock.mock_run(command, ...)` to register a mock before entering the sandbox: +Use `tripwire.subprocess_mock.mock_run(command, ...)` to register a mock before entering the sandbox: ```python -bigfoot.subprocess_mock.mock_run(["git", "status"], returncode=0, stdout="On branch main\n") +tripwire.subprocess_mock.mock_run(["git", "status"], returncode=0, stdout="On branch main\n") ``` Parameters: @@ -54,8 +54,8 @@ Parameters: `subprocess.run` uses a strict FIFO queue. Each registered mock is consumed in registration order. If code calls `subprocess.run` with a command that does not match the next entry in the queue, `UnmockedInteractionError` is raised immediately at call time. ```python -bigfoot.subprocess_mock.mock_run(["git", "fetch"], returncode=0) -bigfoot.subprocess_mock.mock_run(["git", "merge", "origin/main"], returncode=0) +tripwire.subprocess_mock.mock_run(["git", "fetch"], returncode=0) +tripwire.subprocess_mock.mock_run(["git", "merge", "origin/main"], returncode=0) # The first subprocess.run call must be ["git", "fetch"], # the second must be ["git", "merge", "origin/main"]. ``` @@ -64,31 +64,31 @@ Calling `subprocess.run` with an unregistered command or in the wrong order rais ## Asserting `subprocess.run` interactions -Use `bigfoot.subprocess_mock.assert_run()` to assert subprocess interactions: +Use `tripwire.subprocess_mock.assert_run()` to assert subprocess interactions: ```python -bigfoot.subprocess_mock.assert_run(command=["git", "fetch"], returncode=0, stdout="", stderr="") -bigfoot.subprocess_mock.assert_run(command=["git", "merge", "origin/main"], returncode=0, stdout="", stderr="") +tripwire.subprocess_mock.assert_run(command=["git", "fetch"], returncode=0, stdout="", stderr="") +tripwire.subprocess_mock.assert_run(command=["git", "merge", "origin/main"], returncode=0, stdout="", stderr="") ``` `assert_run()` is a convenience wrapper around the lower-level `assert_interaction()` call: ```python # Convenience (recommended): -bigfoot.subprocess_mock.assert_run(command=["git", "fetch"], returncode=0, stdout="", stderr="") +tripwire.subprocess_mock.assert_run(command=["git", "fetch"], returncode=0, stdout="", stderr="") # Equivalent low-level call: -bigfoot.assert_interaction(bigfoot.subprocess_mock.run, command=["git", "fetch"], +tripwire.assert_interaction(tripwire.subprocess_mock.run, command=["git", "fetch"], returncode=0, stdout="", stderr="") ``` ## Registering `shutil.which` mocks -Use `bigfoot.subprocess_mock.mock_which(name, returns, ...)` to register a mock before entering the sandbox: +Use `tripwire.subprocess_mock.mock_which(name, returns, ...)` to register a mock before entering the sandbox: ```python -bigfoot.subprocess_mock.mock_which("git", returns="/usr/bin/git") -bigfoot.subprocess_mock.mock_which("svn", returns=None) # simulate not found +tripwire.subprocess_mock.mock_which("git", returns="/usr/bin/git") +tripwire.subprocess_mock.mock_which("svn", returns=None) # simulate not found ``` Parameters: @@ -107,20 +107,20 @@ This differs from `subprocess.run`, which enforces a strict queue. The rationale ## Asserting `shutil.which` interactions -Use `bigfoot.subprocess_mock.assert_which()` to assert `shutil.which` interactions: +Use `tripwire.subprocess_mock.assert_which()` to assert `shutil.which` interactions: ```python -bigfoot.subprocess_mock.assert_which(name="git", returns="/usr/bin/git") +tripwire.subprocess_mock.assert_which(name="git", returns="/usr/bin/git") ``` `assert_which()` is a convenience wrapper around the lower-level `assert_interaction()` call: ```python # Convenience (recommended): -bigfoot.subprocess_mock.assert_which(name="git", returns="/usr/bin/git") +tripwire.subprocess_mock.assert_which(name="git", returns="/usr/bin/git") # Equivalent low-level call: -bigfoot.assert_interaction(bigfoot.subprocess_mock.which, name="git", returns="/usr/bin/git") +tripwire.assert_interaction(tripwire.subprocess_mock.which, name="git", returns="/usr/bin/git") ``` Only registered names record interactions. Calls to unregistered names are not recorded and cannot be asserted. @@ -131,9 +131,9 @@ Only registered names record interactions. Calls to unregistered names are not r ```python def test_no_subprocess_calls(): - bigfoot.subprocess_mock.install() # any subprocess.run call will raise UnmockedInteractionError + tripwire.subprocess_mock.install() # any subprocess.run call will raise UnmockedInteractionError - with bigfoot: + with tripwire: result = function_that_should_not_call_subprocess() assert result == expected @@ -143,13 +143,13 @@ def test_no_subprocess_calls(): ## ConflictError -At sandbox entry, `SubprocessPlugin` checks whether `subprocess.run` or `shutil.which` have already been patched by another library. If either has been modified by a third party, bigfoot raises `ConflictError`: +At sandbox entry, `SubprocessPlugin` checks whether `subprocess.run` or `shutil.which` have already been patched by another library. If either has been modified by a third party, tripwire raises `ConflictError`: ``` ConflictError: target='subprocess.run', patcher='unknown' ``` -Nested bigfoot sandboxes use reference counting and do not conflict with each other. +Nested tripwire sandboxes use reference counting and do not conflict with each other. ## Full example diff --git a/docs/guides/threading.md b/docs/guides/threading.md index cf76edf..9041905 100644 --- a/docs/guides/threading.md +++ b/docs/guides/threading.md @@ -2,9 +2,9 @@ ## Overview -bigfoot uses `ContextVar` instances to track sandbox state, guard mode, and test verifiers. By default, Python threads do not inherit `ContextVar` values from their parent (per [PEP 567](https://peps.python.org/pep-0567/)). This means a child thread would not see the active sandbox or guard state, and intercepted calls in that thread would raise `SandboxNotActiveError` or bypass guard mode entirely. +tripwire uses `ContextVar` instances to track sandbox state, guard mode, and test verifiers. By default, Python threads do not inherit `ContextVar` values from their parent (per [PEP 567](https://peps.python.org/pep-0567/)). This means a child thread would not see the active sandbox or guard state, and intercepted calls in that thread would raise `SandboxNotActiveError` or bypass guard mode entirely. -bigfoot solves this automatically. At test session startup, it installs context propagation patches that copy all `ContextVar` values to child threads. No configuration is required. +tripwire solves this automatically. At test session startup, it installs context propagation patches that copy all `ContextVar` values to child threads. No configuration is required. ## How it works @@ -16,7 +16,7 @@ The `_context_propagation` module patches two thread-creation mechanisms: 3. **`ThreadPoolExecutor.submit`** -- the standard library executor. Submitted callables are wrapped to run inside the copied context. -When either path creates a thread, bigfoot calls `contextvars.copy_context()` at creation time. This captures a snapshot of all active `ContextVar` values. The child thread's callable then runs inside that copied context via `Context.run()`. +When either path creates a thread, tripwire calls `contextvars.copy_context()` at creation time. This captures a snapshot of all active `ContextVar` values. The child thread's callable then runs inside that copied context via `Context.run()`. The patches are installed at `pytest_configure` and removed at `pytest_unconfigure`. They are idempotent and thread-safe (guarded by a module-level lock). @@ -24,7 +24,7 @@ On Python 3.14+ free-threaded builds where `sys.flags.thread_inherit_context` is ## What gets propagated -bigfoot defines nine `ContextVar` instances. All of them are captured by `copy_context()`: +tripwire defines nine `ContextVar` instances. All of them are captured by `copy_context()`: | ContextVar | Module | Purpose | |---|---|---| @@ -46,7 +46,7 @@ Context propagation matters whenever code under test (or a test utility) creates **ThreadPoolExecutor in production code.** If your application dispatches work to a thread pool, those worker threads need the sandbox context to route intercepted calls correctly. -**Custom threading.Thread usage.** Any code that creates `threading.Thread` instances benefits from propagation. bigfoot patches both `threading.Thread.start()` and the lower-level `_thread.start_new_thread()` to cover all thread-creation paths. +**Custom threading.Thread usage.** Any code that creates `threading.Thread` instances benefits from propagation. tripwire patches both `threading.Thread.start()` and the lower-level `_thread.start_new_thread()` to cover all thread-creation paths. **Libraries that create threads internally.** Some libraries spawn threads for connection pools, background polling, or heartbeats. The low-level `_thread.start_new_thread` patch catches these without needing per-library workarounds. @@ -66,22 +66,22 @@ The only blind spot is C code that calls `PyThread_start_new_thread` directly fr ## Free-threaded Python (3.14t) -bigfoot supports free-threaded Python (the `t` suffix builds with `Py_GIL_DISABLED`). On these builds, `sys.flags.thread_inherit_context` is `True` and threads natively inherit `ContextVar` values, so bigfoot skips the `Thread.start` patch. +tripwire supports free-threaded Python (the `t` suffix builds with `Py_GIL_DISABLED`). On these builds, `sys.flags.thread_inherit_context` is `True` and threads natively inherit `ContextVar` values, so tripwire skips the `Thread.start` patch. -When developing or testing bigfoot on free-threaded Python, use the `dev-ft` extra instead of `dev`: +When developing or testing tripwire on free-threaded Python, use the `dev-ft` extra instead of `dev`: ```bash pip install -e ".[dev-ft]" ``` -The `dev-ft` extra excludes `psycopg2-binary`, which does not ship prebuilt wheels for free-threaded Python and fails to build from source without `libpq` development headers. All other bigfoot plugins and test dependencies are included. Tests for `Psycopg2Plugin` will be skipped due to the missing import. +The `dev-ft` extra excludes `psycopg2-binary`, which does not ship prebuilt wheels for free-threaded Python and fails to build from source without `libpq` development headers. All other tripwire plugins and test dependencies are included. Tests for `Psycopg2Plugin` will be skipped due to the missing import. ## Interaction with guard mode Firewall state (`_guard_active`, `_guard_allowlist`, `_guard_level`, `_guard_patches_installed`) propagates to child threads through the same mechanism. This means: - If a test is running with guard mode active, calls in child threads are guarded. -- If a test uses `@pytest.mark.allow("http")` or `bigfoot.allow("http")`, the allowlist propagates to child threads. +- If a test uses `@pytest.mark.allow("http")` or `tripwire.allow("http")`, the allowlist propagates to child threads. - Guard warnings and errors fire correctly in child threads, with the same level and allowlist as the parent. For full details on guard mode, see [Guard Mode](guard-mode.md). diff --git a/docs/guides/websocket-plugin.md b/docs/guides/websocket-plugin.md index fd83427..4033d09 100644 --- a/docs/guides/websocket-plugin.md +++ b/docs/guides/websocket-plugin.md @@ -1,6 +1,6 @@ # WebSocket Plugins Guide -bigfoot provides two WebSocket plugins covering both major Python WebSocket libraries: +tripwire provides two WebSocket plugins covering both major Python WebSocket libraries: - **AsyncWebSocketPlugin** intercepts `websockets.connect` (the `websockets` library for async usage) - **SyncWebSocketPlugin** intercepts `websocket.create_connection` (the `websocket-client` library for sync usage) @@ -12,13 +12,13 @@ Both use the same state machine and assertion pattern. === "Async (websockets)" ```bash - pip install bigfoot[websockets] + pip install tripwire[websockets] ``` === "Sync (websocket-client)" ```bash - pip install bigfoot[websocket-client] + pip install tripwire[websocket-client] ``` ## State machine @@ -35,22 +35,22 @@ The `connect` step fires during `websockets.connect().__aenter__()` (async) or ` ## AsyncWebSocketPlugin -**Proxy:** `bigfoot.async_websocket_mock` +**Proxy:** `tripwire.async_websocket_mock` ### Setup ```python -import bigfoot +import tripwire async def test_ws_echo(): - (bigfoot.async_websocket_mock + (tripwire.async_websocket_mock .new_session() .expect("connect", returns=None) .expect("send", returns=None) .expect("recv", returns="pong") .expect("close", returns=None)) - with bigfoot: + with tripwire: import websockets async with websockets.connect("ws://localhost:8765") as ws: await ws.send("ping") @@ -59,17 +59,17 @@ async def test_ws_echo(): assert message == "pong" - bigfoot.async_websocket_mock.assert_connect(uri="ws://localhost:8765") - bigfoot.async_websocket_mock.assert_send(message="ping") - bigfoot.async_websocket_mock.assert_recv(message="pong") - bigfoot.async_websocket_mock.assert_close() + tripwire.async_websocket_mock.assert_connect(uri="ws://localhost:8765") + tripwire.async_websocket_mock.assert_send(message="ping") + tripwire.async_websocket_mock.assert_recv(message="pong") + tripwire.async_websocket_mock.assert_close() ``` For manual use outside pytest: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.websocket_plugin import AsyncWebSocketPlugin +from tripwire import StrictVerifier +from tripwire.plugins.websocket_plugin import AsyncWebSocketPlugin verifier = StrictVerifier() ws = AsyncWebSocketPlugin(verifier) @@ -87,19 +87,19 @@ Sessions are consumed in registration order: ```python async def test_two_ws_connections(): - (bigfoot.async_websocket_mock + (tripwire.async_websocket_mock .new_session() .expect("connect", returns=None) .expect("recv", returns="first") .expect("close", returns=None)) - (bigfoot.async_websocket_mock + (tripwire.async_websocket_mock .new_session() .expect("connect", returns=None) .expect("recv", returns="second") .expect("close", returns=None)) - with bigfoot: + with tripwire: cm1 = websockets.connect("ws://localhost:8765") cm2 = websockets.connect("ws://localhost:8765") async with cm1 as ws1: @@ -107,12 +107,12 @@ async def test_two_ws_connections(): assert await ws1.recv() == "first" assert await ws2.recv() == "second" - bigfoot.async_websocket_mock.assert_connect(uri="ws://localhost:8765") - bigfoot.async_websocket_mock.assert_connect(uri="ws://localhost:8765") - bigfoot.async_websocket_mock.assert_recv(message="first") - bigfoot.async_websocket_mock.assert_recv(message="second") - bigfoot.async_websocket_mock.assert_close() - bigfoot.async_websocket_mock.assert_close() + tripwire.async_websocket_mock.assert_connect(uri="ws://localhost:8765") + tripwire.async_websocket_mock.assert_connect(uri="ws://localhost:8765") + tripwire.async_websocket_mock.assert_recv(message="first") + tripwire.async_websocket_mock.assert_recv(message="second") + tripwire.async_websocket_mock.assert_close() + tripwire.async_websocket_mock.assert_close() ``` ### Assertion helpers @@ -120,19 +120,19 @@ async def test_two_ws_connections(): #### `assert_connect(*, uri)` ```python -bigfoot.async_websocket_mock.assert_connect(uri="ws://localhost:8765") +tripwire.async_websocket_mock.assert_connect(uri="ws://localhost:8765") ``` #### `assert_send(*, message)` ```python -bigfoot.async_websocket_mock.assert_send(message="hello") +tripwire.async_websocket_mock.assert_send(message="hello") ``` #### `assert_recv(*, message)` ```python -bigfoot.async_websocket_mock.assert_recv(message="world") +tripwire.async_websocket_mock.assert_recv(message="world") ``` #### `assert_close()` @@ -140,29 +140,29 @@ bigfoot.async_websocket_mock.assert_recv(message="world") No fields are required. ```python -bigfoot.async_websocket_mock.assert_close() +tripwire.async_websocket_mock.assert_close() ``` --- ## SyncWebSocketPlugin -**Proxy:** `bigfoot.sync_websocket_mock` +**Proxy:** `tripwire.sync_websocket_mock` ### Setup ```python -import bigfoot +import tripwire def test_sync_ws(): - (bigfoot.sync_websocket_mock + (tripwire.sync_websocket_mock .new_session() .expect("connect", returns=None) .expect("send", returns=None) .expect("recv", returns="hello") .expect("close", returns=None)) - with bigfoot: + with tripwire: import websocket ws = websocket.create_connection("ws://localhost:8765") ws.send("hi") @@ -171,17 +171,17 @@ def test_sync_ws(): assert message == "hello" - bigfoot.sync_websocket_mock.assert_connect(uri="ws://localhost:8765") - bigfoot.sync_websocket_mock.assert_send(message="hi") - bigfoot.sync_websocket_mock.assert_recv(message="hello") - bigfoot.sync_websocket_mock.assert_close() + tripwire.sync_websocket_mock.assert_connect(uri="ws://localhost:8765") + tripwire.sync_websocket_mock.assert_send(message="hi") + tripwire.sync_websocket_mock.assert_recv(message="hello") + tripwire.sync_websocket_mock.assert_close() ``` For manual use outside pytest: ```python -from bigfoot import StrictVerifier -from bigfoot.plugins.websocket_plugin import SyncWebSocketPlugin +from tripwire import StrictVerifier +from tripwire.plugins.websocket_plugin import SyncWebSocketPlugin verifier = StrictVerifier() ws = SyncWebSocketPlugin(verifier) @@ -196,19 +196,19 @@ The `connect` step executes immediately inside `create_connection()` before the #### `assert_connect(*, uri)` ```python -bigfoot.sync_websocket_mock.assert_connect(uri="ws://localhost:8765") +tripwire.sync_websocket_mock.assert_connect(uri="ws://localhost:8765") ``` #### `assert_send(*, message)` ```python -bigfoot.sync_websocket_mock.assert_send(message="hello") +tripwire.sync_websocket_mock.assert_send(message="hello") ``` #### `assert_recv(*, message)` ```python -bigfoot.sync_websocket_mock.assert_recv(message="world") +tripwire.sync_websocket_mock.assert_recv(message="world") ``` #### `assert_close()` @@ -216,7 +216,7 @@ bigfoot.sync_websocket_mock.assert_recv(message="world") No fields are required. ```python -bigfoot.sync_websocket_mock.assert_close() +tripwire.sync_websocket_mock.assert_close() ``` --- diff --git a/docs/guides/writing-plugins.md b/docs/guides/writing-plugins.md index 40f7670..ece7bc4 100644 --- a/docs/guides/writing-plugins.md +++ b/docs/guides/writing-plugins.md @@ -1,19 +1,19 @@ # Writing Plugins -> **Using pytest?** See [pytest integration](pytest-integration.md) for the standard `with bigfoot:` pattern. The manual `StrictVerifier()` pattern below is for use outside pytest only. +> **Using pytest?** See [pytest integration](pytest-integration.md) for the standard `with tripwire:` pattern. The manual `StrictVerifier()` pattern below is for use outside pytest only. -> **Do not use `bigfoot_verifier` fixture in your plugin fixtures.** Use `bigfoot.current_verifier()` instead. +> **Do not use `tripwire_verifier` fixture in your plugin fixtures.** Use `tripwire.current_verifier()` instead. -bigfoot's plugin system allows you to add interception for any type of interaction, not just HTTP or method calls. Custom plugins follow the `BasePlugin` abstract base class. +tripwire's plugin system allows you to add interception for any type of interaction, not just HTTP or method calls. Custom plugins follow the `BasePlugin` abstract base class. ## BasePlugin contract All plugins must subclass `BasePlugin` and implement nine abstract methods. The `__init__` method must call `super().__init__(verifier)`, which registers the plugin with the verifier. ```python -from bigfoot._base_plugin import BasePlugin -from bigfoot._timeline import Interaction -from bigfoot._verifier import StrictVerifier +from tripwire._base_plugin import BasePlugin +from tripwire._timeline import Interaction +from tripwire._verifier import StrictVerifier ``` ## Lifecycle methods @@ -38,13 +38,13 @@ Called once when the install count reaches 0 (last deactivation). Restore origin ### Common mistakes -bigfoot emits runtime warnings for two frequent plugin authoring errors: +tripwire emits runtime warnings for two frequent plugin authoring errors: 1. **Overriding `activate()` or `deactivate()` directly.** These methods own the reference counting logic. Overriding them bypasses ref counting and breaks nested sandboxes. Override `install_patches()` and `restore_patches()` instead. 2. **Using private underscore names** (`_install_patches`, `_restore_patches`). `BasePlugin` calls `self.install_patches()`, not `self._install_patches()`. The private-name variant will never be called and your plugin will silently do nothing. -If `install_patches()` is not overridden when `activate()` runs, bigfoot warns that the plugin may be misconfigured. +If `install_patches()` is not overridden when `activate()` runs, tripwire warns that the plugin may be misconfigured. ## Abstract methods @@ -88,7 +88,7 @@ def format_assert_hint(self, interaction: Interaction) -> str: ... Return copy-pasteable code that would assert this specific interaction. This hint appears in `UnassertedInteractionsError` when a test forgets to assert a recorded interaction. The goal is a snippet the developer can copy directly into their test. -**If your plugin provides convenience assertion methods** (e.g., `assert_request`, `assert_command`, `assert_log`), the hint should show the convenience method, not raw `verifier.assert_interaction()`. Every built-in bigfoot plugin follows this pattern. Convenience wrappers are easier to read, match the API the developer actually uses, and use the plugin's own parameter names rather than the internal `details` keys. +**If your plugin provides convenience assertion methods** (e.g., `assert_request`, `assert_command`, `assert_log`), the hint should show the convenience method, not raw `verifier.assert_interaction()`. Every built-in tripwire plugin follows this pattern. Convenience wrappers are easier to read, match the API the developer actually uses, and use the plugin's own parameter names rather than the internal `details` keys. For example, `HttpPlugin.format_assert_hint()` returns: @@ -129,7 +129,7 @@ def assert_query(self, query: str) -> None: Convenience wrapper around verifier.assert_interaction(). """ - from bigfoot._context import _get_test_verifier_or_raise + from tripwire._context import _get_test_verifier_or_raise _get_test_verifier_or_raise().assert_interaction(self._sentinel, query=query) ``` @@ -137,7 +137,7 @@ def assert_query(self, query: str) -> None: - Name methods `assert_` (e.g., `assert_connect`, `assert_send`, `assert_command`) - Accept the same fields returned by `assertable_fields()`, using domain-specific names -- Import `_get_test_verifier_or_raise` from `bigfoot._context` to get the current verifier +- Import `_get_test_verifier_or_raise` from `tripwire._context` to get the current verifier - Update `format_assert_hint()` to show the convenience method, not `verifier.assert_interaction()` All 14 built-in plugins follow this pattern. The raw `verifier.assert_interaction()` call still works and is documented as the low-level equivalent, but convenience methods are the recommended API. @@ -183,10 +183,10 @@ Call `self.record(interaction)` from your interceptor after a call fires. ```python import threading from typing import Any -from bigfoot._base_plugin import BasePlugin -from bigfoot._errors import UnmockedInteractionError -from bigfoot._timeline import Interaction -from bigfoot._verifier import StrictVerifier +from tripwire._base_plugin import BasePlugin +from tripwire._errors import UnmockedInteractionError +from tripwire._timeline import Interaction +from tripwire._verifier import StrictVerifier class DbMockConfig: @@ -280,7 +280,7 @@ class DatabasePlugin(BasePlugin): Convenience wrapper around verifier.assert_interaction(). """ - from bigfoot._context import _get_test_verifier_or_raise + from tripwire._context import _get_test_verifier_or_raise _get_test_verifier_or_raise().assert_interaction(self._sentinel, query=query) def format_assert_hint(self, interaction: Interaction) -> str: @@ -304,16 +304,16 @@ class DatabasePlugin(BasePlugin): ## Registering and using the plugin -In pytest, use `bigfoot.current_verifier()` to register the plugin against the autouse verifier: +In pytest, use `tripwire.current_verifier()` to register the plugin against the autouse verifier: ```python -import bigfoot +import tripwire def test_db_query(): - db = DatabasePlugin(bigfoot.current_verifier(), my_connection) + db = DatabasePlugin(tripwire.current_verifier(), my_connection) db.mock_query("SELECT * FROM users", result=[{"id": 1}]) - with bigfoot: + with tripwire: rows = my_connection.execute("SELECT * FROM users") assert rows == [{"id": 1}] @@ -321,7 +321,7 @@ def test_db_query(): db.assert_query(query="SELECT * FROM users") # Equivalent low-level call: - # bigfoot.assert_interaction(db_sentinel, query="SELECT * FROM users") + # tripwire.assert_interaction(db_sentinel, query="SELECT * FROM users") # verify_all() called automatically at teardown ``` @@ -329,7 +329,7 @@ def test_db_query(): For manual use outside pytest: ```python -from bigfoot import StrictVerifier +from tripwire import StrictVerifier verifier = StrictVerifier() db = DatabasePlugin(verifier, my_connection) @@ -415,7 +415,7 @@ def _unmocked_source_id(self) -> str: Before the sandbox runs, register one session per expected connection: ```python -handle = bigfoot.socket_mock.new_session() +handle = tripwire.socket_mock.new_session() handle.expect("connect", returns=None) handle.expect("recv", returns=b"pong") handle.expect("close", returns=None) @@ -424,7 +424,7 @@ handle.expect("close", returns=None) `new_session()` returns a `SessionHandle`. `expect()` appends one `ScriptStep` to the handle's FIFO script and returns the handle, so calls chain naturally: ```python -(bigfoot.socket_mock +(tripwire.socket_mock .new_session() .expect("connect", returns=None) .expect("send", returns=4) @@ -453,8 +453,8 @@ State machine plugins require explicit assertion like all other plugins. Each sc import threading from typing import Any, ClassVar -from bigfoot._state_machine_plugin import StateMachinePlugin -from bigfoot._timeline import Interaction +from tripwire._state_machine_plugin import StateMachinePlugin +from tripwire._timeline import Interaction class FtpPlugin(StateMachinePlugin): @@ -507,14 +507,14 @@ class FtpPlugin(StateMachinePlugin): def format_mock_hint(self, interaction: Interaction) -> str: method = interaction.details.get("method", "?") - return f" bigfoot.ftp_mock.new_session().expect({method!r}, returns=...)" + return f" tripwire.ftp_mock.new_session().expect({method!r}, returns=...)" def format_unmocked_hint(self, source_id: str, args: tuple, kwargs: dict) -> str: method = source_id.split(":")[-1] if ":" in source_id else source_id return ( f"ftp.{method}(...) was called but no session was queued.\n" f"Register a session with:\n" - f" bigfoot.ftp_mock.new_session().expect({method!r}, returns=...)" + f" tripwire.ftp_mock.new_session().expect({method!r}, returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: @@ -547,7 +547,7 @@ Plugins declare whether they participate in firewall mode (formerly guard mode) ### Default: `supports_guard = True` -The default value in `BasePlugin` is `True`. This means bigfoot will activate the plugin at session startup during firewall mode, and any intercepted call outside a sandbox will be checked against the firewall rules (allow/deny/restrict with `M()` patterns). This is correct for any plugin that intercepts external I/O (HTTP, database, socket, etc.). +The default value in `BasePlugin` is `True`. This means tripwire will activate the plugin at session startup during firewall mode, and any intercepted call outside a sandbox will be checked against the firewall rules (allow/deny/restrict with `M()` patterns). This is correct for any plugin that intercepts external I/O (HTTP, database, socket, etc.). ### Setting `supports_guard = False` @@ -583,7 +583,7 @@ Populate all available fields so that user-defined `M()` patterns can match prec When firewall mode is active and a call is allowed (via `allow()`, `@pytest.mark.allow`, or an `M()` pattern match), `get_verifier_or_raise()` raises `GuardPassThrough` instead of returning a verifier. The allowlist can also be narrowed with `deny()`, `@pytest.mark.deny`, or `restrict()`. Firewall-eligible interceptors must catch `GuardPassThrough` and delegate to the original function: ```python -from bigfoot import get_verifier_or_raise, GuardPassThrough +from tripwire import get_verifier_or_raise, GuardPassThrough def _my_interceptor(original_self, *args, **kwargs): try: @@ -603,46 +603,46 @@ If your plugin has `supports_guard = False`, you do not need `GuardPassThrough` ## Using a coding assistant -If you use Claude Code or another AI coding assistant, bigfoot includes a project skill at `.claude/skills/adding-plugins/SKILL.md` that automates the full plugin creation lifecycle: scaffolding, implementation, tests, documentation, and example creation. Invoke it with "add a plugin for X" or similar phrasing. +If you use Claude Code or another AI coding assistant, tripwire includes a project skill at `.claude/skills/adding-plugins/SKILL.md` that automates the full plugin creation lifecycle: scaffolding, implementation, tests, documentation, and example creation. Invoke it with "add a plugin for X" or similar phrasing. --- ## 1st party vs 3rd party plugins -bigfoot plugins don't depend on the libraries they intercept at install time. All library dependencies are optional extras (`pip install bigfoot[http]`, `pip install bigfoot[redis]`, etc.), so a 1st party plugin for any library costs nothing to users who don't install that extra. This means the usual "heavy dependencies" argument for splitting into a separate package doesn't apply. +tripwire plugins don't depend on the libraries they intercept at install time. All library dependencies are optional extras (`pip install tripwire[http]`, `pip install tripwire[redis]`, etc.), so a 1st party plugin for any library costs nothing to users who don't install that extra. This means the usual "heavy dependencies" argument for splitting into a separate package doesn't apply. ### When to contribute a 1st party plugin -Most plugins should be 1st party (in-tree). Contribute directly to bigfoot when: +Most plugins should be 1st party (in-tree). Contribute directly to tripwire when: -- **The library is widely used.** If a meaningful percentage of Python projects use it, bigfoot should support it out of the box. Examples: HTTP clients, database drivers, cloud SDKs, message queues, caching libraries. -- **Interception is complex.** Plugins that need ContextVar routing, class-level ref counting, reentrancy guards, or factory replacement patterns benefit from living alongside bigfoot's internals where they can evolve together. -- **Layer coexistence matters.** If the plugin participates in bigfoot's interception matrix (e.g., boto3 sits above HTTP, SSH sits above socket), coordinating `disabled_plugins` behavior is much easier in-tree. -- **You want the core team to maintain it.** 1st party plugins are tested in CI against every bigfoot release. +- **The library is widely used.** If a meaningful percentage of Python projects use it, tripwire should support it out of the box. Examples: HTTP clients, database drivers, cloud SDKs, message queues, caching libraries. +- **Interception is complex.** Plugins that need ContextVar routing, class-level ref counting, reentrancy guards, or factory replacement patterns benefit from living alongside tripwire's internals where they can evolve together. +- **Layer coexistence matters.** If the plugin participates in tripwire's interception matrix (e.g., boto3 sits above HTTP, SSH sits above socket), coordinating `disabled_plugins` behavior is much easier in-tree. +- **You want the core team to maintain it.** 1st party plugins are tested in CI against every tripwire release. ### When to create a 3rd party plugin Create a separate package when: -- **Independent release cycles are needed.** The target library changes its internals frequently and the plugin needs to release on its own schedule, decoupled from bigfoot releases. +- **Independent release cycles are needed.** The target library changes its internals frequently and the plugin needs to release on its own schedule, decoupled from tripwire releases. - **The plugin is domain-specific.** It targets an internal company SDK, a proprietary protocol, or a library used by a very small community. - **The maintainer is outside the core team** and prefers to own the release process. ### Packaging a 3rd party plugin -A 3rd party plugin is a standard Python package that depends on `bigfoot`. Users install it alongside bigfoot: +A 3rd party plugin is a standard Python package that depends on `tripwire`. Users install it alongside tripwire: ```bash -pip install bigfoot bigfoot-myservice +pip install tripwire tripwire-myservice ``` **Project structure:** ``` -bigfoot-myservice/ +tripwire-myservice/ ├── pyproject.toml ├── src/ -│ └── bigfoot_myservice/ +│ └── tripwire_myservice/ │ ├── __init__.py # exports proxy, plugin class │ └── plugin.py # MyServicePlugin(BasePlugin) └── tests/ @@ -651,18 +651,18 @@ bigfoot-myservice/ **Entry point for auto-discovery:** -Register your plugin using the `bigfoot.plugins` entry point group so bigfoot discovers and activates it automatically when installed: +Register your plugin using the `tripwire.plugins` entry point group so tripwire discovers and activates it automatically when installed: ```toml # In your package's pyproject.toml -[project.entry-points."bigfoot.plugins"] -myservice = "bigfoot_myservice.plugin:MyServicePlugin" +[project.entry-points."tripwire.plugins"] +myservice = "tripwire_myservice.plugin:MyServicePlugin" ``` -With this entry point, users don't need to manually register the plugin. Installing the package is enough -- bigfoot's `StrictVerifier` discovers entry-point plugins alongside built-in ones. +With this entry point, users don't need to manually register the plugin. Installing the package is enough -- tripwire's `StrictVerifier` discovers entry-point plugins alongside built-in ones. **Key points:** -- Subclass `BasePlugin` or `StateMachinePlugin` from `bigfoot` +- Subclass `BasePlugin` or `StateMachinePlugin` from `tripwire` - Follow the same conventions as built-in plugins (sentinel proxies, FIFO queues, `assertable_fields` returning `frozenset(interaction.details.keys())`) -- Test against bigfoot's public API only; don't depend on private internals that may change +- Test against tripwire's public API only; don't depend on private internals that may change diff --git a/docs/index.md b/docs/index.md index 7b0e308..9142374 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ -# bigfoot +# tripwire -**bigfoot** intercepts every external call your code makes and forces your tests to account for all of them. It ships with 27 plugins for HTTP, subprocess, database, cache, cloud, messaging, crypto, file I/O, and more. It enforces three guarantees that most mocking libraries leave silent: +**tripwire** intercepts every external call your code makes and forces your tests to account for all of them. It ships with 27 plugins for HTTP, subprocess, database, cache, cloud, messaging, crypto, file I/O, and more. It enforces three guarantees that most mocking libraries leave silent: 1. **Every call must be pre-authorized.** Code makes a call with no registered mock? `UnmockedInteractionError`, immediately. 2. **Every recorded interaction must be explicitly asserted.** Forget to assert an interaction? `UnassertedInteractionsError` at teardown. @@ -11,7 +11,7 @@ A plugin system makes it straightforward to intercept any service and enforce al ## Quick example ```python -import bigfoot +import tripwire from dirty_equals import IsInstance def create_charge(amount): @@ -22,20 +22,20 @@ def create_charge(amount): return response.json() def test_payment_flow(): - bigfoot.http.mock_response("POST", "https://api.stripe.com/v1/charges", + tripwire.http.mock_response("POST", "https://api.stripe.com/v1/charges", json={"id": "ch_123"}, status=200) - with bigfoot: + with tripwire: result = create_charge(5000) - bigfoot.http.assert_request( + tripwire.http.assert_request( "POST", "https://api.stripe.com/v1/charges", headers=IsInstance(dict), body='{"amount": 5000}', ) assert result["id"] == "ch_123" ``` -The test calls `create_charge()`, which internally uses httpx. bigfoot intercepts the HTTP call transparently. If you forget the `assert_request()` call, bigfoot raises `UnassertedInteractionsError` at teardown. If the mock is never triggered, `UnusedMocksError`. If code makes an unmocked HTTP call, `UnmockedInteractionError` immediately. +The test calls `create_charge()`, which internally uses httpx. tripwire intercepts the HTTP call transparently. If you forget the `assert_request()` call, tripwire raises `UnassertedInteractionsError` at teardown. If the mock is never triggered, `UnusedMocksError`. If code makes an unmocked HTTP call, `UnmockedInteractionError` immediately. ## Navigation @@ -43,7 +43,7 @@ The test calls `create_charge()`, which internally uses httpx. bigfoot intercept - **[Installation](guides/installation.md)** - Install bigfoot and its optional extras. + Install tripwire and its optional extras. - **[Quick Start](guides/quickstart.md)** diff --git a/docs/proposals/2026-04-26-tripwire-port-feedback.md b/docs/proposals/2026-04-26-tripwire-port-feedback.md index ffab884..2474707 100644 --- a/docs/proposals/2026-04-26-tripwire-port-feedback.md +++ b/docs/proposals/2026-04-26-tripwire-port-feedback.md @@ -1,19 +1,19 @@ -# Bigfoot improvement proposals: feedback from the tripwire (Nim) port +# Tripwire improvement proposals: feedback from the tripwire (Nim) port **Created:** 2026-04-26 -**Project:** bigfoot (`/Users/eek/Development/bigfoot`) +**Project:** tripwire (`/Users/eek/Development/tripwire`) **For:** a fresh Claude Code session - paste this whole file as the first message. --- ## Context -You are working on `bigfoot`, a Python testing/sandboxing library. The user is the +You are working on `tripwire`, a Python testing/sandboxing library. The user is the author and the only user. There is no compatibility window to worry about. -Recently I ported (some of) bigfoot's design into `tripwire`, a Nim equivalent +Recently I ported (some of) tripwire's design into `tripwire`, a Nim equivalent used by paperplanes (a Kraken arbitrage bot). The port surfaced several -design seams in bigfoot worth examining. This file is the prompt to act on +design seams in tripwire worth examining. This file is the prompt to act on that feedback. The goals here are concrete, prioritized, and bounded. Do them in priority @@ -30,7 +30,7 @@ for deprecated alias. this is all liquid, baby." ## Operating directives -- **You are the bigfoot author and the only user.** Single source of authority. +- **You are the tripwire author and the only user.** Single source of authority. No "but what about other users." - **Subagents for substantive work** per `~/.claude/CLAUDE.md`. Dispatch them for code reads / writes / tests; orchestrate from the main context. @@ -60,8 +60,8 @@ estimate. Land them in this order; each is independent. **Priority:** HIGH. This is the biggest one. -**Rationale.** Bigfoot today defaults `guard` to `"warn"`. A fresh project -that imports bigfoot and writes a test without configuring guard will +**Rationale.** Tripwire today defaults `guard` to `"warn"`. A fresh project +that imports tripwire and writes a test without configuring guard will silently make real network calls, real subprocess invocations, real DNS lookups - they pass through with a warning the test runner may swallow. That is the opposite of what a sandbox should do. A testing tool's prime @@ -76,13 +76,13 @@ loud. **Concrete change.** -1. In `pyproject.toml` schema and bigfoot's config defaults: set the - default for `[tool.bigfoot] guard` to `"error"` (find the constant or +1. In `pyproject.toml` schema and tripwire's config defaults: set the + default for `[tool.tripwire] guard` to `"error"` (find the constant or the parser default; grep for `"warn"` near guard parsing). 2. Update `README.md` and any quickstart docs: - State the default is `"error"`. - Add a "When to use `warn`" subsection: incremental migration of - legacy test suites that have not yet been wrapped in `with bigfoot:` + legacy test suites that have not yet been wrapped in `with tripwire:` blocks. Caveat that warn mode lets real I/O through and should not be the steady state. 3. Update CHANGELOG.md under `[Unreleased]` with a clear breaking-change @@ -91,9 +91,9 @@ loud. **Acceptance test.** Add a test that: -- creates a project with no bigfoot config, -- imports bigfoot, -- makes an unmocked HTTP request outside any `with bigfoot:` block, +- creates a project with no tripwire config, +- imports tripwire, +- makes an unmocked HTTP request outside any `with tripwire:` block, - asserts an error is raised (not a warning logged). **Effort.** Small (defaults change + docs + 1 test). @@ -104,7 +104,7 @@ Add a test that: **Priority:** HIGH. This prevents `guard="warn"` from being a footgun. -**Rationale.** When `guard="warn"`, bigfoot today probably lets every +**Rationale.** When `guard="warn"`, tripwire today probably lets every unmocked call through. For some plugins that's fine: a Mock plugin's "passthrough" is identity, no harm done. For other plugins it's destructive: HTTP makes a real network request, subprocess forks a real @@ -118,24 +118,24 @@ can't passthrough safely, it raises `OutsideSandboxNoPassthroughDefect` with a pedagogical message: "plugin X doesn't support outside-sandbox passthrough; install a sandbox or set guard=error." -Bigfoot would benefit from the same gate. Otherwise `guard="warn"` is +Tripwire would benefit from the same gate. Otherwise `guard="warn"` is indistinguishable from "make all real I/O happen, with a log line." **Concrete change.** 1. Add a `passthrough_safe: bool` class attribute (default `False`) to - bigfoot's plugin base class. + tripwire's plugin base class. 2. Set `passthrough_safe = True` on Mock-style plugins where passthrough is genuinely a no-op or identity. Audit each plugin in the codebase; default to False if there is any doubt. 3. Add a new exception class `UnsafePassthroughError` (or similar; align - with bigfoot's existing exception naming convention - read `errors.py` + with tripwire's existing exception naming convention - read `errors.py` or wherever exceptions live first). 4. In the guard-mode dispatch path: when `guard="warn"` and an unmocked call hits a plugin where `passthrough_safe is False`, raise `UnsafePassthroughError` with a message that says, verbatim or close: "plugin {name} doesn't support outside-sandbox passthrough; either - install a `with bigfoot:` block, set guard='error' to make this fail + install a `with tripwire:` block, set guard='error' to make this fail loudly, or mark this plugin passthrough_safe=True if you've audited that the underlying call has no side effects." @@ -167,7 +167,7 @@ hostile. But DNS and subprocess should fail loud unconditionally. Allow per-protocol overrides in `pyproject.toml`: ```toml -[tool.bigfoot] +[tool.tripwire] guard = "warn" # default for everything guard.dns = "error" # except DNS, always raise guard.subprocess = "error" @@ -201,23 +201,23 @@ Config with `guard = "warn"` plus `guard.dns = "error"`: forgot a sandbox"). - `PostTestInteractionDefect`: verifier was popped (sandbox exited) but generation counter still active ("your async cleanup is wrong; a - Future / Task / Thread survived `with bigfoot:` exit and fired after"). + Future / Task / Thread survived `with tripwire:` exit and fired after"). These are *genuinely different bugs*. The first is a missing sandbox declaration. The second is a leak of in-flight async work past the sandbox lifetime. Catching both under one error makes async leak debugging much harder than necessary. -If bigfoot today raises one error for both, split them. Verify by reading -bigfoot's exception hierarchy (`grep -rn "class.*Error\\|class.*Defect" src/`). +If tripwire today raises one error for both, split them. Verify by reading +tripwire's exception hierarchy (`grep -rn "class.*Error\\|class.*Defect" src/`). **Concrete change.** -1. Read bigfoot's current handling of "call fired outside the active +1. Read tripwire's current handling of "call fired outside the active sandbox" - is this one path or already two? If one, split. If already two, this proposal is satisfied; move on. -2. Add `PostSandboxInteractionError` (or whatever fits bigfoot's naming). -3. Track sandbox generation: when `with bigfoot:` exits, mark the sandbox +2. Add `PostSandboxInteractionError` (or whatever fits tripwire's naming). +3. Track sandbox generation: when `with tripwire:` exits, mark the sandbox inactive but keep an identity. Calls that fire on a-known-but-inactive sandbox raise the post-sandbox error; calls with no sandbox identity raise the leaked-interaction error. @@ -225,8 +225,8 @@ bigfoot's exception hierarchy (`grep -rn "class.*Error\\|class.*Defect" src/`). **Acceptance test.** Two tests: -- Call without ever entering `with bigfoot:` -> `LeakedInteractionError`. -- `with bigfoot:` block that schedules an asyncio Task; block exits before +- Call without ever entering `with tripwire:` -> `LeakedInteractionError`. +- `with tripwire:` block that schedules an asyncio Task; block exits before Task completes; Task makes an unmocked call -> `PostSandboxInteractionError`. **Effort.** Medium. Generation tracking on the sandbox object plus exception @@ -238,16 +238,16 @@ split. **Priority:** LOW-MEDIUM. UX polish, not correctness. -**Rationale.** When bigfoot raises outside a sandbox, the message should +**Rationale.** When tripwire raises outside a sandbox, the message should state the user's mental model, not just the implementation detail. A new user seeing the current error has to figure out from context that they -forgot to wrap in `with bigfoot:`. +forgot to wrap in `with tripwire:`. Tripwire's message: `"TRM fired on thread {tid} with no active verifier at {file}:{line}"` is functional but mechanical. Better: > `Call to {plugin}.{method}({args}) at {file}:{line} happened OUTSIDE -> any "with bigfoot:" block. Wrap the call in a sandbox and add an +> any "with tripwire:" block. Wrap the call in a sandbox and add an > allow(...) for it, OR set guard="warn" in pyproject.toml if the call > is intentional and safe.` @@ -256,7 +256,7 @@ at {file}:{line}"` is functional but mechanical. Better: Find every outside-sandbox raise site. Update the message to include: - Which plugin and method was called - The call site (file:line) -- The user-mental-model framing ("OUTSIDE any `with bigfoot:` block") +- The user-mental-model framing ("OUTSIDE any `with tripwire:` block") - The two options for fixing it **Acceptance test.** @@ -274,25 +274,25 @@ This is a regression guard against the message drifting back to mechanical. **Rationale.** Per-test override lets strict tests live next to permissive ones during a guard migration. Natural fit for pytest. Tripwire can't do -this cleanly because it's compile-time; bigfoot can. +this cleanly because it's compile-time; tripwire can. **Concrete change.** -Register a pytest marker `bigfoot_guard("error" | "warn" | dict-form)`. +Register a pytest marker `tripwire_guard("error" | "warn" | dict-form)`. When the test starts, the marker (if present) overrides the project's guard setting for the duration of that test. When the test ends, restore prior config. ```python -@pytest.mark.bigfoot_guard("error") +@pytest.mark.tripwire_guard("error") def test_strict_dns(): # this test fails loud on any unmocked call ... ``` Implementation: -1. Add the marker registration in bigfoot's pytest plugin (find it in - `src/bigfoot/pytest_plugin.py` or similar). +1. Add the marker registration in tripwire's pytest plugin (find it in + `src/tripwire/pytest_plugin.py` or similar). 2. Hook into a pytest fixture (autouse, narrow scope) that reads the marker, overrides the config, yields, and restores. 3. Document in the README and the migration guide. @@ -300,8 +300,8 @@ Implementation: **Acceptance test.** A test file with two tests: -- One marked `bigfoot_guard("error")`: makes an unmocked call, expects raise. -- One marked `bigfoot_guard("warn")`: makes an unmocked call, expects warning. +- One marked `tripwire_guard("error")`: makes an unmocked call, expects raise. +- One marked `tripwire_guard("warn")`: makes an unmocked call, expects warning. **Effort.** Small. Pytest marker registration + fixture + docs. @@ -323,7 +323,7 @@ on unknown values. Find the TOML config parser. After reading `guard`, validate the value is one of the accepted set. On mismatch, raise a clear error: -> `Invalid value "{got}" for [tool.bigfoot] guard. Expected one of: "warn", +> `Invalid value "{got}" for [tool.tripwire] guard. Expected one of: "warn", > "error". (Per-protocol form also accepted; see docs.)` Apply the same validation to any other config keys that take a closed set. @@ -348,7 +348,7 @@ tells the user when to use which: - **For new projects:** keep the default `"error"`. Real I/O outside sandboxes is almost always a bug. - **For migrating legacy test suites:** set `guard = "warn"` while you - add `with bigfoot:` blocks incrementally. Plan to flip back to + add `with tripwire:` blocks incrementally. Plan to flip back to `"error"` once the migration is done. - **For mixed CI:** use per-protocol overrides (Proposal 3) to be strict on the dangerous protocols (DNS, subprocess) and permissive on the @@ -370,9 +370,9 @@ during this batch or after. ### B1 - Vocabulary clarity -Make explicit in docs that `bigfoot.allow(...)` and `bigfoot.restrict(...)` +Make explicit in docs that `tripwire.allow(...)` and `tripwire.restrict(...)` are *sandbox-scoped*, while `guard` is *module-scoped (global)*. They do -not compose. A user who tries `bigfoot.allow(...)` outside a `with` block +not compose. A user who tries `tripwire.allow(...)` outside a `with` block will get a confusing error because there is no sandbox to attach the allow to. Either: - document the divide loudly, OR @@ -381,13 +381,13 @@ allow to. Either: ### B2 - Async edge case: contextvars + threadpools -Verify `with bigfoot:` survives correctly across: +Verify `with tripwire:` survives correctly across: - `asyncio.to_thread(...)` (which uses a default thread executor) - `concurrent.futures.ProcessPoolExecutor` (which fork-execs a child) - `asyncio.create_task(...)` (which inherits the current context) Python's contextvars + threadpools are notoriously subtle. The right -behavior is probably: `with bigfoot:` state propagates to threads via +behavior is probably: `with tripwire:` state propagates to threads via contextvars (it should "just work"), but does NOT propagate to subprocesses (those are a separate process boundary; configure them via env or config file, not via context). Document and test. @@ -412,13 +412,13 @@ Do not push without explicit user approval. ## Pointers -- `src/bigfoot/` for the implementation +- `src/tripwire/` for the implementation - `tests/` for the test suite - `pyproject.toml` for project configuration and config schema - `CHANGELOG.md` for release notes - `docs/` for the user-facing documentation - `~/.claude/CLAUDE.md` for the user's standing operating directives -- `AGENTS.md` (or `CLAUDE.md`) at the bigfoot repo root for repo-specific +- `AGENTS.md` (or `CLAUDE.md`) at the tripwire repo root for repo-specific conventions; read first Good luck. The user is patient and rigorous; match the energy. diff --git a/docs/reference/async-subprocess-plugin.md b/docs/reference/async-subprocess-plugin.md index 1b2e4f6..62d28bc 100644 --- a/docs/reference/async-subprocess-plugin.md +++ b/docs/reference/async-subprocess-plugin.md @@ -1,3 +1,3 @@ # AsyncSubprocessPlugin -::: bigfoot.plugins.async_subprocess_plugin.AsyncSubprocessPlugin +::: tripwire.plugins.async_subprocess_plugin.AsyncSubprocessPlugin diff --git a/docs/reference/asyncpg-plugin.md b/docs/reference/asyncpg-plugin.md index fe6800e..6ccc4c8 100644 --- a/docs/reference/asyncpg-plugin.md +++ b/docs/reference/asyncpg-plugin.md @@ -1,3 +1,3 @@ # AsyncpgPlugin -::: bigfoot.plugins.asyncpg_plugin.AsyncpgPlugin +::: tripwire.plugins.asyncpg_plugin.AsyncpgPlugin diff --git a/docs/reference/boto3-plugin.md b/docs/reference/boto3-plugin.md index 0b841ff..5d9ce65 100644 --- a/docs/reference/boto3-plugin.md +++ b/docs/reference/boto3-plugin.md @@ -1,3 +1,3 @@ # Boto3Plugin -::: bigfoot.plugins.boto3_plugin.Boto3Plugin +::: tripwire.plugins.boto3_plugin.Boto3Plugin diff --git a/docs/reference/celery-plugin.md b/docs/reference/celery-plugin.md index 5b18a1f..2f5c28d 100644 --- a/docs/reference/celery-plugin.md +++ b/docs/reference/celery-plugin.md @@ -1,3 +1,3 @@ # CeleryPlugin -::: bigfoot.plugins.celery_plugin.CeleryPlugin +::: tripwire.plugins.celery_plugin.CeleryPlugin diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index a4da21c..d133eaa 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1,3 +1,3 @@ # Configuration -::: bigfoot._config.load_bigfoot_config +::: tripwire._config.load_tripwire_config diff --git a/docs/reference/crypto-plugin.md b/docs/reference/crypto-plugin.md index 7061844..7155adc 100644 --- a/docs/reference/crypto-plugin.md +++ b/docs/reference/crypto-plugin.md @@ -1,3 +1,3 @@ # CryptoPlugin -::: bigfoot.plugins.crypto_plugin.CryptoPlugin +::: tripwire.plugins.crypto_plugin.CryptoPlugin diff --git a/docs/reference/database-plugin.md b/docs/reference/database-plugin.md index 93f047f..8b915b5 100644 --- a/docs/reference/database-plugin.md +++ b/docs/reference/database-plugin.md @@ -1,3 +1,3 @@ # DatabasePlugin -::: bigfoot.plugins.database_plugin.DatabasePlugin +::: tripwire.plugins.database_plugin.DatabasePlugin diff --git a/docs/reference/dns-plugin.md b/docs/reference/dns-plugin.md index 4a25bef..ca987bd 100644 --- a/docs/reference/dns-plugin.md +++ b/docs/reference/dns-plugin.md @@ -1,3 +1,3 @@ # DnsPlugin -::: bigfoot.plugins.dns_plugin.DnsPlugin +::: tripwire.plugins.dns_plugin.DnsPlugin diff --git a/docs/reference/elasticsearch-plugin.md b/docs/reference/elasticsearch-plugin.md index 47d7bf9..be309aa 100644 --- a/docs/reference/elasticsearch-plugin.md +++ b/docs/reference/elasticsearch-plugin.md @@ -1,3 +1,3 @@ # ElasticsearchPlugin -::: bigfoot.plugins.elasticsearch_plugin.ElasticsearchPlugin +::: tripwire.plugins.elasticsearch_plugin.ElasticsearchPlugin diff --git a/docs/reference/errors.md b/docs/reference/errors.md index 842c0a5..3e05822 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -1,19 +1,19 @@ # Errors -::: bigfoot.BigfootError +::: tripwire.TripwireError -::: bigfoot.UnmockedInteractionError +::: tripwire.UnmockedInteractionError -::: bigfoot.UnassertedInteractionsError +::: tripwire.UnassertedInteractionsError -::: bigfoot.UnusedMocksError +::: tripwire.UnusedMocksError -::: bigfoot.VerificationError +::: tripwire.VerificationError -::: bigfoot.InteractionMismatchError +::: tripwire.InteractionMismatchError -::: bigfoot.MissingAssertionFieldsError +::: tripwire.MissingAssertionFieldsError -::: bigfoot.SandboxNotActiveError +::: tripwire.SandboxNotActiveError -::: bigfoot.ConflictError +::: tripwire.ConflictError diff --git a/docs/reference/file-io-plugin.md b/docs/reference/file-io-plugin.md index 1559ff3..2e94a17 100644 --- a/docs/reference/file-io-plugin.md +++ b/docs/reference/file-io-plugin.md @@ -1,3 +1,3 @@ # FileIoPlugin -::: bigfoot.plugins.file_io_plugin.FileIoPlugin +::: tripwire.plugins.file_io_plugin.FileIoPlugin diff --git a/docs/reference/grpc-plugin.md b/docs/reference/grpc-plugin.md index 2aa708b..1271683 100644 --- a/docs/reference/grpc-plugin.md +++ b/docs/reference/grpc-plugin.md @@ -1,3 +1,3 @@ # GrpcPlugin -::: bigfoot.plugins.grpc_plugin.GrpcPlugin +::: tripwire.plugins.grpc_plugin.GrpcPlugin diff --git a/docs/reference/http-plugin.md b/docs/reference/http-plugin.md index 3070184..c5968a1 100644 --- a/docs/reference/http-plugin.md +++ b/docs/reference/http-plugin.md @@ -1,3 +1,3 @@ # HttpPlugin -::: bigfoot.plugins.http.HttpPlugin +::: tripwire.plugins.http.HttpPlugin diff --git a/docs/reference/index.md b/docs/reference/index.md index 4c30c67..748b8e9 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -1,19 +1,19 @@ # API Reference -All public symbols are importable from `bigfoot` directly. `HttpPlugin` requires the `bigfoot[http]` extra and is imported from `bigfoot.plugins.http`. +All public symbols are importable from `tripwire` directly. `HttpPlugin` requires the `tripwire[http]` extra and is imported from `tripwire.plugins.http`. ## Public symbols | Symbol | Type | Description | |---|---|---| -| `StrictVerifier` | class | Central orchestrator. Owns the timeline and plugin registry. Entry point for all bigfoot operations. | +| `StrictVerifier` | class | Central orchestrator. Owns the timeline and plugin registry. Entry point for all tripwire operations. | | `SandboxContext` | class | Context manager returned by `verifier.sandbox()`. Activates all plugins for the duration of the `with` block. Supports both sync and async. | | `InAnyOrderContext` | class | Context manager returned by `verifier.in_any_order()`. Inside this block, `assert_interaction()` matches any unasserted interaction regardless of timeline order. | | `MockPlugin` | class | Intercepts method calls on named proxy objects. Created automatically by `verifier.mock()`. | -| `HttpPlugin` | class | Intercepts `httpx`, `requests`, and `urllib` HTTP calls. Requires `bigfoot[http]`. Import from `bigfoot.plugins.http`. | -| `SubprocessPlugin` | class | Intercepts `subprocess.run` and `shutil.which`. Included in core bigfoot. Import from `bigfoot.plugins.subprocess`. | +| `HttpPlugin` | class | Intercepts `httpx`, `requests`, and `urllib` HTTP calls. Requires `tripwire[http]`. Import from `tripwire.plugins.http`. | +| `SubprocessPlugin` | class | Intercepts `subprocess.run` and `shutil.which`. Included in core tripwire. Import from `tripwire.plugins.subprocess`. | | `subprocess_mock` | proxy | Module-level proxy to `SubprocessPlugin` for the current test. Auto-creates the plugin on first access. | -| `BigfootError` | exception | Base class for all bigfoot exceptions. | +| `TripwireError` | exception | Base class for all tripwire exceptions. | | `UnmockedInteractionError` | exception | Raised at call time when an intercepted call has no matching mock. | | `UnassertedInteractionsError` | exception | Raised at teardown when interactions were recorded but never asserted. | | `UnusedMocksError` | exception | Raised at teardown when required mocks were registered but never triggered. | @@ -28,15 +28,15 @@ These types appear in error messages, docstrings, and plugin implementations but | Symbol | Module | Description | |---|---|---| -| `MockProxy` | `bigfoot._mock_plugin` | Object returned by `verifier.mock()`. Attribute access yields `MethodProxy`. | -| `MethodProxy` | `bigfoot._mock_plugin` | Per-method interceptor with `.returns()`, `.raises()`, `.calls()`, `.required()`. | -| `MockConfig` | `bigfoot._mock_plugin` | Internal record of one configured side effect. | -| `HttpRequestSentinel` | `bigfoot.plugins.http` | Opaque object returned by `http.request`. Used as source in `assert_interaction()`. | -| `SubprocessRunSentinel` | `bigfoot.plugins.subprocess` | Opaque handle returned by `subprocess_mock.run`. Used as source in `assert_interaction()`. | -| `SubprocessWhichSentinel` | `bigfoot.plugins.subprocess` | Opaque handle returned by `subprocess_mock.which`. Used as source in `assert_interaction()`. | -| `RunMockConfig` | `bigfoot.plugins.subprocess` | Internal record of a registered `mock_run` configuration. | -| `WhichMockConfig` | `bigfoot.plugins.subprocess` | Internal record of a registered `mock_which` configuration. | -| `HttpMockConfig` | `bigfoot.plugins.http` | Internal record of a registered mock response. | -| `BasePlugin` | `bigfoot._base_plugin` | Abstract base class for all plugins. | -| `Interaction` | `bigfoot._timeline` | Dataclass representing one recorded event in the timeline. | -| `Timeline` | `bigfoot._timeline` | Thread-safe ordered list of `Interaction` objects. | +| `MockProxy` | `tripwire._mock_plugin` | Object returned by `verifier.mock()`. Attribute access yields `MethodProxy`. | +| `MethodProxy` | `tripwire._mock_plugin` | Per-method interceptor with `.returns()`, `.raises()`, `.calls()`, `.required()`. | +| `MockConfig` | `tripwire._mock_plugin` | Internal record of one configured side effect. | +| `HttpRequestSentinel` | `tripwire.plugins.http` | Opaque object returned by `http.request`. Used as source in `assert_interaction()`. | +| `SubprocessRunSentinel` | `tripwire.plugins.subprocess` | Opaque handle returned by `subprocess_mock.run`. Used as source in `assert_interaction()`. | +| `SubprocessWhichSentinel` | `tripwire.plugins.subprocess` | Opaque handle returned by `subprocess_mock.which`. Used as source in `assert_interaction()`. | +| `RunMockConfig` | `tripwire.plugins.subprocess` | Internal record of a registered `mock_run` configuration. | +| `WhichMockConfig` | `tripwire.plugins.subprocess` | Internal record of a registered `mock_which` configuration. | +| `HttpMockConfig` | `tripwire.plugins.http` | Internal record of a registered mock response. | +| `BasePlugin` | `tripwire._base_plugin` | Abstract base class for all plugins. | +| `Interaction` | `tripwire._timeline` | Dataclass representing one recorded event in the timeline. | +| `Timeline` | `tripwire._timeline` | Thread-safe ordered list of `Interaction` objects. | diff --git a/docs/reference/jwt-plugin.md b/docs/reference/jwt-plugin.md index e11c8ef..eb05729 100644 --- a/docs/reference/jwt-plugin.md +++ b/docs/reference/jwt-plugin.md @@ -1,3 +1,3 @@ # JwtPlugin -::: bigfoot.plugins.jwt_plugin.JwtPlugin +::: tripwire.plugins.jwt_plugin.JwtPlugin diff --git a/docs/reference/logging-plugin.md b/docs/reference/logging-plugin.md index 86f20d8..5527b59 100644 --- a/docs/reference/logging-plugin.md +++ b/docs/reference/logging-plugin.md @@ -1,3 +1,3 @@ # LoggingPlugin -::: bigfoot.plugins.logging_plugin.LoggingPlugin +::: tripwire.plugins.logging_plugin.LoggingPlugin diff --git a/docs/reference/mcp-plugin.md b/docs/reference/mcp-plugin.md index 99c82fb..c505e3a 100644 --- a/docs/reference/mcp-plugin.md +++ b/docs/reference/mcp-plugin.md @@ -1,7 +1,7 @@ # McpPlugin -::: bigfoot.plugins.mcp_plugin.McpPlugin +::: tripwire.plugins.mcp_plugin.McpPlugin ## McpMockConfig -::: bigfoot.plugins.mcp_plugin.McpMockConfig +::: tripwire.plugins.mcp_plugin.McpMockConfig diff --git a/docs/reference/memcache-plugin.md b/docs/reference/memcache-plugin.md index 56357bd..953fc77 100644 --- a/docs/reference/memcache-plugin.md +++ b/docs/reference/memcache-plugin.md @@ -1,3 +1,3 @@ # MemcachePlugin -::: bigfoot.plugins.memcache_plugin.MemcachePlugin +::: tripwire.plugins.memcache_plugin.MemcachePlugin diff --git a/docs/reference/mock-plugin.md b/docs/reference/mock-plugin.md index f7e02a2..ad3259f 100644 --- a/docs/reference/mock-plugin.md +++ b/docs/reference/mock-plugin.md @@ -1,3 +1,3 @@ # MockPlugin -::: bigfoot.MockPlugin +::: tripwire.MockPlugin diff --git a/docs/reference/mongo-plugin.md b/docs/reference/mongo-plugin.md index c9621e2..df0f29e 100644 --- a/docs/reference/mongo-plugin.md +++ b/docs/reference/mongo-plugin.md @@ -1,3 +1,3 @@ # MongoPlugin -::: bigfoot.plugins.mongo_plugin.MongoPlugin +::: tripwire.plugins.mongo_plugin.MongoPlugin diff --git a/docs/reference/native-plugin.md b/docs/reference/native-plugin.md index 7f397b6..a92483c 100644 --- a/docs/reference/native-plugin.md +++ b/docs/reference/native-plugin.md @@ -1,3 +1,3 @@ # NativePlugin -::: bigfoot.plugins.native_plugin.NativePlugin +::: tripwire.plugins.native_plugin.NativePlugin diff --git a/docs/reference/pika-plugin.md b/docs/reference/pika-plugin.md index a7d2bc5..cb9b3c0 100644 --- a/docs/reference/pika-plugin.md +++ b/docs/reference/pika-plugin.md @@ -1,3 +1,3 @@ # PikaPlugin -::: bigfoot.plugins.pika_plugin.PikaPlugin +::: tripwire.plugins.pika_plugin.PikaPlugin diff --git a/docs/reference/popen-plugin.md b/docs/reference/popen-plugin.md index 6dbf84d..0a5033f 100644 --- a/docs/reference/popen-plugin.md +++ b/docs/reference/popen-plugin.md @@ -1,3 +1,3 @@ # PopenPlugin -::: bigfoot.plugins.popen_plugin.PopenPlugin +::: tripwire.plugins.popen_plugin.PopenPlugin diff --git a/docs/reference/psycopg2-plugin.md b/docs/reference/psycopg2-plugin.md index 2f27410..f15c3c3 100644 --- a/docs/reference/psycopg2-plugin.md +++ b/docs/reference/psycopg2-plugin.md @@ -1,3 +1,3 @@ # Psycopg2Plugin -::: bigfoot.plugins.psycopg2_plugin.Psycopg2Plugin +::: tripwire.plugins.psycopg2_plugin.Psycopg2Plugin diff --git a/docs/reference/redis-plugin.md b/docs/reference/redis-plugin.md index 95ec103..f3f98aa 100644 --- a/docs/reference/redis-plugin.md +++ b/docs/reference/redis-plugin.md @@ -1,3 +1,3 @@ # RedisPlugin -::: bigfoot.plugins.redis_plugin.RedisPlugin +::: tripwire.plugins.redis_plugin.RedisPlugin diff --git a/docs/reference/smtp-plugin.md b/docs/reference/smtp-plugin.md index a370252..cde5066 100644 --- a/docs/reference/smtp-plugin.md +++ b/docs/reference/smtp-plugin.md @@ -1,3 +1,3 @@ # SmtpPlugin -::: bigfoot.plugins.smtp_plugin.SmtpPlugin +::: tripwire.plugins.smtp_plugin.SmtpPlugin diff --git a/docs/reference/socket-plugin.md b/docs/reference/socket-plugin.md index 87c05fc..d53548f 100644 --- a/docs/reference/socket-plugin.md +++ b/docs/reference/socket-plugin.md @@ -1,3 +1,3 @@ # SocketPlugin -::: bigfoot.plugins.socket_plugin.SocketPlugin +::: tripwire.plugins.socket_plugin.SocketPlugin diff --git a/docs/reference/ssh-plugin.md b/docs/reference/ssh-plugin.md index a8510a4..b72ef06 100644 --- a/docs/reference/ssh-plugin.md +++ b/docs/reference/ssh-plugin.md @@ -1,3 +1,3 @@ # SshPlugin -::: bigfoot.plugins.ssh_plugin.SshPlugin +::: tripwire.plugins.ssh_plugin.SshPlugin diff --git a/docs/reference/subprocess-plugin.md b/docs/reference/subprocess-plugin.md index 30cd5d7..dc8b0a6 100644 --- a/docs/reference/subprocess-plugin.md +++ b/docs/reference/subprocess-plugin.md @@ -1,3 +1,3 @@ # SubprocessPlugin -::: bigfoot.plugins.subprocess.SubprocessPlugin +::: tripwire.plugins.subprocess.SubprocessPlugin diff --git a/docs/reference/verifier.md b/docs/reference/verifier.md index 174d4ad..fa74433 100644 --- a/docs/reference/verifier.md +++ b/docs/reference/verifier.md @@ -1,7 +1,7 @@ # StrictVerifier -::: bigfoot.StrictVerifier +::: tripwire.StrictVerifier -::: bigfoot.SandboxContext +::: tripwire.SandboxContext -::: bigfoot.InAnyOrderContext +::: tripwire.InAnyOrderContext diff --git a/docs/reference/websocket-plugin.md b/docs/reference/websocket-plugin.md index 55ebbb0..e485e58 100644 --- a/docs/reference/websocket-plugin.md +++ b/docs/reference/websocket-plugin.md @@ -2,8 +2,8 @@ ## AsyncWebSocketPlugin -::: bigfoot.plugins.websocket_plugin.AsyncWebSocketPlugin +::: tripwire.plugins.websocket_plugin.AsyncWebSocketPlugin ## SyncWebSocketPlugin -::: bigfoot.plugins.websocket_plugin.SyncWebSocketPlugin +::: tripwire.plugins.websocket_plugin.SyncWebSocketPlugin diff --git a/examples/async_api/README.md b/examples/async_api/README.md index aa17c3d..025481e 100644 --- a/examples/async_api/README.md +++ b/examples/async_api/README.md @@ -1,6 +1,6 @@ # Async API Example -Demonstrates bigfoot with async code, combining HTTP, asyncpg, and +Demonstrates tripwire with async code, combining HTTP, asyncpg, and logging plugins in a single test. The application module (`app.py`) fetches user data from an external API diff --git a/examples/async_api/test_app.py b/examples/async_api/test_app.py index b760e30..b16d3c6 100644 --- a/examples/async_api/test_app.py +++ b/examples/async_api/test_app.py @@ -1,8 +1,8 @@ -"""Test async API using bigfoot HTTP, asyncpg, and logging plugins.""" +"""Test async API using tripwire HTTP, asyncpg, and logging plugins.""" import pytest -import bigfoot +import tripwire from .app import sync_user_data @@ -10,20 +10,20 @@ @pytest.mark.asyncio async def test_sync_user_data_fetches_and_stores(): # Mock the external API response - bigfoot.http.mock_response( + tripwire.http.mock_response( "GET", "https://api.example.com/users/1", json={"id": 1, "name": "Alice", "email": "alice@example.com"}, ) # Script the asyncpg session - bigfoot.asyncpg_mock.new_session() \ + tripwire.asyncpg_mock.new_session() \ .expect("connect", returns=None) \ .expect("execute", returns="INSERT 0 1") \ .expect("fetchrow", returns={"id": 1, "name": "Alice", "email": "alice@example.com"}) \ .expect("close", returns=None) - async with bigfoot: + async with tripwire: result = await sync_user_data( user_id=1, db_url="postgresql://localhost/app", @@ -33,7 +33,7 @@ async def test_sync_user_data_fetches_and_stores(): assert result == {"id": 1, "name": "Alice", "email": "alice@example.com"} # Assert the HTTP request and response - bigfoot.http.assert_request( + tripwire.http.assert_request( method="GET", url="https://api.example.com/users/1", ).assert_response( @@ -43,16 +43,16 @@ async def test_sync_user_data_fetches_and_stores(): ) # Assert the log message - bigfoot.log_mock.assert_info("Fetched user 1 from API", "sync_api") + tripwire.log_mock.assert_info("Fetched user 1 from API", "sync_api") # Assert the database interactions - bigfoot.asyncpg_mock.assert_connect(dsn="postgresql://localhost/app") - bigfoot.asyncpg_mock.assert_execute( + tripwire.asyncpg_mock.assert_connect(dsn="postgresql://localhost/app") + tripwire.asyncpg_mock.assert_execute( query="INSERT INTO users (id, name, email) VALUES ($1, $2, $3)", args=[1, "Alice", "alice@example.com"], ) - bigfoot.asyncpg_mock.assert_fetchrow( + tripwire.asyncpg_mock.assert_fetchrow( query="SELECT * FROM users WHERE id = $1", args=[1], ) - bigfoot.asyncpg_mock.assert_close() + tripwire.asyncpg_mock.assert_close() diff --git a/examples/async_subprocess_example/test_app.py b/examples/async_subprocess_example/test_app.py index abe1a45..86ad2c7 100644 --- a/examples/async_subprocess_example/test_app.py +++ b/examples/async_subprocess_example/test_app.py @@ -1,23 +1,23 @@ -"""Test run_linter using bigfoot async_subprocess_mock.""" +"""Test run_linter using tripwire async_subprocess_mock.""" -import bigfoot +import tripwire from .app import run_linter async def test_linter_clean(): - (bigfoot.async_subprocess_mock + (tripwire.async_subprocess_mock .new_session() .expect("spawn", returns=None) .expect("communicate", returns=(b"All checks passed.\n", b"", 0))) - with bigfoot: + with tripwire: rc, output = await run_linter("src/") assert rc == 0 assert output == "All checks passed.\n" - bigfoot.async_subprocess_mock.assert_spawn( + tripwire.async_subprocess_mock.assert_spawn( command=["ruff", "check", "src/"], stdin=None ) - bigfoot.async_subprocess_mock.assert_communicate(input=None) + tripwire.async_subprocess_mock.assert_communicate(input=None) diff --git a/examples/asyncpg_example/test_app.py b/examples/asyncpg_example/test_app.py index 1c20522..4cc1e81 100644 --- a/examples/asyncpg_example/test_app.py +++ b/examples/asyncpg_example/test_app.py @@ -1,22 +1,22 @@ -"""Test get_user_count using bigfoot asyncpg_mock.""" +"""Test get_user_count using tripwire asyncpg_mock.""" -import bigfoot +import tripwire from .app import get_user_count async def test_get_user_count(): - (bigfoot.asyncpg_mock + (tripwire.asyncpg_mock .new_session() .expect("connect", returns=None) .expect("fetchval", returns=42) .expect("close", returns=None)) - with bigfoot: + with tripwire: result = await get_user_count() assert result == 42 - bigfoot.asyncpg_mock.assert_connect(host="localhost", database="app", user="app") - bigfoot.asyncpg_mock.assert_fetchval(query="SELECT count(*) FROM users", args=[]) - bigfoot.asyncpg_mock.assert_close() + tripwire.asyncpg_mock.assert_connect(host="localhost", database="app", user="app") + tripwire.asyncpg_mock.assert_fetchval(query="SELECT count(*) FROM users", args=[]) + tripwire.asyncpg_mock.assert_close() diff --git a/examples/boto3_service/test_app.py b/examples/boto3_service/test_app.py index 691d261..ed44db2 100644 --- a/examples/boto3_service/test_app.py +++ b/examples/boto3_service/test_app.py @@ -1,10 +1,10 @@ -"""Test boto3 S3 upload with SQS notification using bigfoot boto3_mock.""" +"""Test boto3 S3 upload with SQS notification using tripwire boto3_mock.""" import logging import pytest -import bigfoot +import tripwire from .app import upload_and_notify @@ -17,20 +17,20 @@ def _silence_botocore(): def test_upload_and_notify(): - bigfoot.boto3_mock.mock_call("s3", "PutObject", returns={}) - bigfoot.boto3_mock.mock_call("sqs", "SendMessage", returns={"MessageId": "msg-001"}) + tripwire.boto3_mock.mock_call("s3", "PutObject", returns={}) + tripwire.boto3_mock.mock_call("sqs", "SendMessage", returns={"MessageId": "msg-001"}) - with bigfoot: + with tripwire: upload_and_notify( "data-bucket", "reports/q1.csv", b"revenue,100", "https://sqs.us-east-1.amazonaws.com/123/notifications", ) - bigfoot.boto3_mock.assert_boto3_call( + tripwire.boto3_mock.assert_boto3_call( service="s3", operation="PutObject", params={"Bucket": "data-bucket", "Key": "reports/q1.csv", "Body": b"revenue,100"}, ) - bigfoot.boto3_mock.assert_boto3_call( + tripwire.boto3_mock.assert_boto3_call( service="sqs", operation="SendMessage", params={ "QueueUrl": "https://sqs.us-east-1.amazonaws.com/123/notifications", diff --git a/examples/celery_tasks/test_app.py b/examples/celery_tasks/test_app.py index aaf91e0..0f76475 100644 --- a/examples/celery_tasks/test_app.py +++ b/examples/celery_tasks/test_app.py @@ -1,10 +1,10 @@ -"""Test Celery task dispatch using bigfoot celery_mock.""" +"""Test Celery task dispatch using tripwire celery_mock.""" import logging import pytest -import bigfoot +import tripwire from .app import enqueue_order_pipeline @@ -17,26 +17,26 @@ def _silence_celery(): def test_enqueue_order_pipeline(): - bigfoot.celery_mock.mock_delay("example.validate_order", returns=None) - bigfoot.celery_mock.mock_apply_async("example.charge_payment", returns=None) - bigfoot.celery_mock.mock_delay("example.send_confirmation", returns=None) + tripwire.celery_mock.mock_delay("example.validate_order", returns=None) + tripwire.celery_mock.mock_apply_async("example.charge_payment", returns=None) + tripwire.celery_mock.mock_delay("example.send_confirmation", returns=None) - with bigfoot: + with tripwire: enqueue_order_pipeline("order-42", "buyer@example.com") - bigfoot.celery_mock.assert_delay( + tripwire.celery_mock.assert_delay( task_name="example.validate_order", args=("order-42",), kwargs={}, options={}, ) - bigfoot.celery_mock.assert_apply_async( + tripwire.celery_mock.assert_apply_async( task_name="example.charge_payment", args=("order-42",), kwargs={"currency": "USD"}, options={"countdown": 5}, ) - bigfoot.celery_mock.assert_delay( + tripwire.celery_mock.assert_delay( task_name="example.send_confirmation", args=("buyer@example.com", "order-42"), kwargs={}, diff --git a/examples/cli_tool/README.md b/examples/cli_tool/README.md index 655f745..eb7d44b 100644 --- a/examples/cli_tool/README.md +++ b/examples/cli_tool/README.md @@ -1,11 +1,11 @@ # CLI Tool Example -Demonstrates bigfoot's subprocess plugin for mocking `subprocess.run` +Demonstrates tripwire's subprocess plugin for mocking `subprocess.run` and `shutil.which`. The application module (`app.py`) locates `gcc`, compiles a source file, and runs the resulting binary. The test (`test_app.py`) uses -`bigfoot.subprocess_mock` to intercept both `which` and `run` calls, +`tripwire.subprocess_mock` to intercept both `which` and `run` calls, verifying the exact commands and their ordering. Run: `python -m pytest examples/cli_tool/ -v` diff --git a/examples/cli_tool/test_app.py b/examples/cli_tool/test_app.py index 7c8d3e7..f1a4fe7 100644 --- a/examples/cli_tool/test_app.py +++ b/examples/cli_tool/test_app.py @@ -1,36 +1,36 @@ -"""Test build_and_run using bigfoot subprocess mocking.""" +"""Test build_and_run using tripwire subprocess mocking.""" -import bigfoot +import tripwire from .app import build_and_run def test_build_and_run_compiles_and_executes(): - bigfoot.subprocess_mock.mock_which("gcc", returns="/usr/bin/gcc") - bigfoot.subprocess_mock.mock_run( + tripwire.subprocess_mock.mock_which("gcc", returns="/usr/bin/gcc") + tripwire.subprocess_mock.mock_run( ["/usr/bin/gcc", "-o", "/tmp/out", "hello.c"], returncode=0 ) - bigfoot.subprocess_mock.mock_run( + tripwire.subprocess_mock.mock_run( ["/tmp/out"], returncode=0, stdout="Hello, world!\n" ) - with bigfoot: + with tripwire: output = build_and_run("hello.c") assert output == "Hello, world!\n" - bigfoot.assert_interaction( - bigfoot.subprocess_mock.which, name="gcc", returns="/usr/bin/gcc" + tripwire.assert_interaction( + tripwire.subprocess_mock.which, name="gcc", returns="/usr/bin/gcc" ) - bigfoot.assert_interaction( - bigfoot.subprocess_mock.run, + tripwire.assert_interaction( + tripwire.subprocess_mock.run, command=["/usr/bin/gcc", "-o", "/tmp/out", "hello.c"], returncode=0, stdout="", stderr="", ) - bigfoot.assert_interaction( - bigfoot.subprocess_mock.run, + tripwire.assert_interaction( + tripwire.subprocess_mock.run, command=["/tmp/out"], returncode=0, stdout="Hello, world!\n", @@ -39,9 +39,9 @@ def test_build_and_run_compiles_and_executes(): def test_build_and_run_raises_when_gcc_missing(): - bigfoot.subprocess_mock.mock_which("gcc", returns=None) + tripwire.subprocess_mock.mock_which("gcc", returns=None) - with bigfoot: + with tripwire: try: build_and_run("hello.c") except RuntimeError as exc: @@ -49,6 +49,6 @@ def test_build_and_run_raises_when_gcc_missing(): else: raise AssertionError("Expected RuntimeError") - bigfoot.assert_interaction( - bigfoot.subprocess_mock.which, name="gcc", returns=None + tripwire.assert_interaction( + tripwire.subprocess_mock.which, name="gcc", returns=None ) diff --git a/examples/conftest.py b/examples/conftest.py index 4544720..da058ec 100644 --- a/examples/conftest.py +++ b/examples/conftest.py @@ -1,4 +1,4 @@ -"""Shared fixtures for bigfoot examples.""" +"""Shared fixtures for tripwire examples.""" import logging @@ -7,10 +7,10 @@ @pytest.fixture(autouse=True) def _enable_all_log_levels(): - """Set root logger to DEBUG so bigfoot's LoggingPlugin can intercept all levels. + """Set root logger to DEBUG so tripwire's LoggingPlugin can intercept all levels. Python's logging module checks the effective level before calling Logger._log(). - bigfoot intercepts at the _log() level, so the logger must be configured to + tripwire intercepts at the _log() level, so the logger must be configured to pass messages through to that point. """ root = logging.getLogger() diff --git a/examples/crypto_sign/test_app.py b/examples/crypto_sign/test_app.py index 4c1d8b0..f1f40e7 100644 --- a/examples/crypto_sign/test_app.py +++ b/examples/crypto_sign/test_app.py @@ -1,8 +1,8 @@ -"""Test PII encryption and decryption using bigfoot crypto_mock.""" +"""Test PII encryption and decryption using tripwire crypto_mock.""" from cryptography.fernet import Fernet -import bigfoot +import tripwire from .app import decrypt_pii_field, encrypt_pii_field @@ -11,22 +11,22 @@ def test_encrypt_pii(): - bigfoot.crypto_mock.mock_encrypt(returns=b"gAAAAABencrypted_ssn") + tripwire.crypto_mock.mock_encrypt(returns=b"gAAAAABencrypted_ssn") - with bigfoot: + with tripwire: ciphertext = encrypt_pii_field(TEST_KEY, "123-45-6789") assert ciphertext == b"gAAAAABencrypted_ssn" - bigfoot.crypto_mock.assert_encrypt(plaintext_length=11) + tripwire.crypto_mock.assert_encrypt(plaintext_length=11) def test_decrypt_pii(): - bigfoot.crypto_mock.mock_decrypt(returns=b"123-45-6789") + tripwire.crypto_mock.mock_decrypt(returns=b"123-45-6789") - with bigfoot: + with tripwire: plaintext = decrypt_pii_field(TEST_KEY, b"gAAAAABencrypted_ssn") assert plaintext == "123-45-6789" - bigfoot.crypto_mock.assert_decrypt(token=b"gAAAAABencrypted_ssn", ttl=None) + tripwire.crypto_mock.assert_decrypt(token=b"gAAAAABencrypted_ssn", ttl=None) diff --git a/examples/database_example/test_app.py b/examples/database_example/test_app.py index 501d732..b687feb 100644 --- a/examples/database_example/test_app.py +++ b/examples/database_example/test_app.py @@ -1,25 +1,25 @@ -"""Test save_user using bigfoot db_mock.""" +"""Test save_user using tripwire db_mock.""" -import bigfoot +import tripwire from .app import save_user def test_save_user(): - (bigfoot.db_mock + (tripwire.db_mock .new_session() .expect("connect", returns=None) .expect("execute", returns=[]) .expect("commit", returns=None) .expect("close", returns=None)) - with bigfoot: + with tripwire: save_user("Alice", "alice@example.com") - bigfoot.db_mock.assert_connect(database="app.db") - bigfoot.db_mock.assert_execute( + tripwire.db_mock.assert_connect(database="app.db") + tripwire.db_mock.assert_execute( sql="INSERT INTO users (name, email) VALUES (?, ?)", parameters=("Alice", "alice@example.com"), ) - bigfoot.db_mock.assert_commit() - bigfoot.db_mock.assert_close() + tripwire.db_mock.assert_commit() + tripwire.db_mock.assert_close() diff --git a/examples/dns_lookup/test_app.py b/examples/dns_lookup/test_app.py index 9964a1f..816a0f3 100644 --- a/examples/dns_lookup/test_app.py +++ b/examples/dns_lookup/test_app.py @@ -1,26 +1,26 @@ -"""Test DNS service resolution using bigfoot dns_mock.""" +"""Test DNS service resolution using tripwire dns_mock.""" import socket -import bigfoot +import tripwire from .app import resolve_service_endpoint def test_resolve_service_endpoint(): - bigfoot.dns_mock.mock_getaddrinfo( + tripwire.dns_mock.mock_getaddrinfo( "payments.internal", returns=[ (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.2.15", 443)), ], ) - with bigfoot: + with tripwire: addr = resolve_service_endpoint("payments.internal") assert addr == ("10.0.2.15", 443) - bigfoot.dns_mock.assert_getaddrinfo( + tripwire.dns_mock.assert_getaddrinfo( host="payments.internal", port=443, family=socket.AF_INET, diff --git a/examples/elasticsearch_search/test_app.py b/examples/elasticsearch_search/test_app.py index fc8c312..989bb0e 100644 --- a/examples/elasticsearch_search/test_app.py +++ b/examples/elasticsearch_search/test_app.py @@ -1,12 +1,12 @@ -"""Test Elasticsearch error log search using bigfoot elasticsearch_mock.""" +"""Test Elasticsearch error log search using tripwire elasticsearch_mock.""" -import bigfoot +import tripwire from .app import search_error_logs def test_search_error_logs(): - bigfoot.elasticsearch_mock.mock_operation( + tripwire.elasticsearch_mock.mock_operation( "search", returns={ "hits": { @@ -19,7 +19,7 @@ def test_search_error_logs(): }, ) - with bigfoot: + with tripwire: from elasticsearch import Elasticsearch es = Elasticsearch("http://localhost:9200") logs = search_error_logs(es, "app-logs", hours=12, max_results=50) @@ -28,7 +28,7 @@ def test_search_error_logs(): assert logs[0]["message"] == "timeout" assert logs[1]["message"] == "connection refused" - bigfoot.elasticsearch_mock.assert_search( + tripwire.elasticsearch_mock.assert_search( index="app-logs", query={ "bool": { diff --git a/examples/email_service/README.md b/examples/email_service/README.md index c9aee20..1bc93ee 100644 --- a/examples/email_service/README.md +++ b/examples/email_service/README.md @@ -1,10 +1,10 @@ # Email Service Example -Demonstrates bigfoot's SMTP plugin with full state machine assertions. +Demonstrates tripwire's SMTP plugin with full state machine assertions. The application module (`app.py`) sends a welcome email through an SMTP server using `smtplib`. The test (`test_app.py`) scripts an entire SMTP session (connect, ehlo, starttls, login, send_message, quit) and verifies -each step with `bigfoot.smtp_mock` assertion helpers. +each step with `tripwire.smtp_mock` assertion helpers. Run: `python -m pytest examples/email_service/ -v` diff --git a/examples/email_service/test_app.py b/examples/email_service/test_app.py index b48fcb9..282aeba 100644 --- a/examples/email_service/test_app.py +++ b/examples/email_service/test_app.py @@ -1,16 +1,16 @@ -"""Test send_welcome_email using bigfoot SMTP state machine assertions.""" +"""Test send_welcome_email using tripwire SMTP state machine assertions.""" from email.message import EmailMessage from dirty_equals import IsInstance -import bigfoot +import tripwire from .app import send_welcome_email def test_send_welcome_email_full_smtp_session(): - bigfoot.smtp_mock.new_session() \ + tripwire.smtp_mock.new_session() \ .expect("connect", returns=(220, b"OK")) \ .expect("ehlo", returns=(250, b"OK")) \ .expect("starttls", returns=(220, b"Ready")) \ @@ -18,12 +18,12 @@ def test_send_welcome_email_full_smtp_session(): .expect("send_message", returns={}) \ .expect("quit", returns=(221, b"Bye")) - with bigfoot: + with tripwire: send_welcome_email("alice@example.com", "Alice") - bigfoot.smtp_mock.assert_connect(host="smtp.example.com", port=587) - bigfoot.smtp_mock.assert_ehlo(name="example.com") - bigfoot.smtp_mock.assert_starttls() - bigfoot.smtp_mock.assert_login(user="noreply@example.com", password="secret") - bigfoot.smtp_mock.assert_send_message(msg=IsInstance(EmailMessage)) - bigfoot.smtp_mock.assert_quit() + tripwire.smtp_mock.assert_connect(host="smtp.example.com", port=587) + tripwire.smtp_mock.assert_ehlo(name="example.com") + tripwire.smtp_mock.assert_starttls() + tripwire.smtp_mock.assert_login(user="noreply@example.com", password="secret") + tripwire.smtp_mock.assert_send_message(msg=IsInstance(EmailMessage)) + tripwire.smtp_mock.assert_quit() diff --git a/examples/file_processor/test_app.py b/examples/file_processor/test_app.py index f00680c..2636685 100644 --- a/examples/file_processor/test_app.py +++ b/examples/file_processor/test_app.py @@ -1,28 +1,28 @@ -"""Test file archival using bigfoot file_io_mock.""" +"""Test file archival using tripwire file_io_mock.""" -import bigfoot +import tripwire from .app import archive_and_clean def test_archive_and_clean(): - bigfoot.file_io_mock.mock_operation("makedirs", "/backups/2024", returns=None) - bigfoot.file_io_mock.mock_operation("copytree", "/var/data/reports", returns=None) - bigfoot.file_io_mock.mock_operation( + tripwire.file_io_mock.mock_operation("makedirs", "/backups/2024", returns=None) + tripwire.file_io_mock.mock_operation("copytree", "/var/data/reports", returns=None) + tripwire.file_io_mock.mock_operation( "write_text", "/var/log/manifest.txt", returns=None, ) - bigfoot.file_io_mock.mock_operation("rmtree", "/var/data/reports", returns=None) + tripwire.file_io_mock.mock_operation("rmtree", "/var/data/reports", returns=None) - with bigfoot: + with tripwire: archive_and_clean( "/var/data/reports", "/backups/2024", "/var/log/manifest.txt", ) - bigfoot.file_io_mock.assert_makedirs(path="/backups/2024", exist_ok=True) - bigfoot.file_io_mock.assert_copytree( + tripwire.file_io_mock.assert_makedirs(path="/backups/2024", exist_ok=True) + tripwire.file_io_mock.assert_copytree( src="/var/data/reports", dst="/backups/2024/latest", ) - bigfoot.file_io_mock.assert_write_text( + tripwire.file_io_mock.assert_write_text( path="/var/log/manifest.txt", data="archived: /var/data/reports", ) - bigfoot.file_io_mock.assert_rmtree(path="/var/data/reports") + tripwire.file_io_mock.assert_rmtree(path="/var/data/reports") diff --git a/examples/flask_api/README.md b/examples/flask_api/README.md index 6ea6417..bed784a 100644 --- a/examples/flask_api/README.md +++ b/examples/flask_api/README.md @@ -1,9 +1,9 @@ # Flask API Example -Demonstrates bigfoot's HTTP and logging plugins together. +Demonstrates tripwire's HTTP and logging plugins together. The application module (`app.py`) makes an HTTP POST to a payment provider -and logs the result. The test (`test_app.py`) uses `bigfoot.http` to mock -the outbound HTTP call and `bigfoot.log_mock` to verify the log message. +and logs the result. The test (`test_app.py`) uses `tripwire.http` to mock +the outbound HTTP call and `tripwire.log_mock` to verify the log message. Run: `python -m pytest examples/flask_api/ -v` diff --git a/examples/flask_api/test_app.py b/examples/flask_api/test_app.py index e8cd8b5..eeaf705 100644 --- a/examples/flask_api/test_app.py +++ b/examples/flask_api/test_app.py @@ -1,26 +1,26 @@ -"""Test create_charge using bigfoot HTTP mocking and log assertions.""" +"""Test create_charge using tripwire HTTP mocking and log assertions.""" from dirty_equals import IsInstance -import bigfoot +import tripwire from .app import create_charge def test_create_charge_posts_to_stripe_and_logs(): - bigfoot.http.mock_response( + tripwire.http.mock_response( "POST", "https://api.stripe.com/v1/charges", json={"id": "ch_test_123", "amount": 5000, "currency": "usd"}, status=200, ) - with bigfoot: + with tripwire: result = create_charge(amount=5000, currency="usd") assert result == {"id": "ch_test_123", "amount": 5000, "currency": "usd"} - bigfoot.http.assert_request( + tripwire.http.assert_request( method="POST", url="https://api.stripe.com/v1/charges", headers=IsInstance(dict), @@ -30,10 +30,10 @@ def test_create_charge_posts_to_stripe_and_logs(): headers=IsInstance(dict), body='{"id": "ch_test_123", "amount": 5000, "currency": "usd"}', ) - bigfoot.log_mock.assert_info( + tripwire.log_mock.assert_info( 'HTTP Request: POST https://api.stripe.com/v1/charges "HTTP/1.1 200 OK"', "httpx", ) - bigfoot.log_mock.assert_info( + tripwire.log_mock.assert_info( "Charge created: ch_test_123 for 5000 usd", "payments" ) diff --git a/examples/grpc_service/test_app.py b/examples/grpc_service/test_app.py index b519d9a..de7e3eb 100644 --- a/examples/grpc_service/test_app.py +++ b/examples/grpc_service/test_app.py @@ -1,16 +1,16 @@ -"""Test gRPC service calls using bigfoot grpc_mock.""" +"""Test gRPC service calls using tripwire grpc_mock.""" -import bigfoot +import tripwire from .app import fetch_user_orders def test_fetch_user_orders(): - bigfoot.grpc_mock.mock_unary_unary( + tripwire.grpc_mock.mock_unary_unary( "/commerce.UserService/GetUser", returns={"id": 7, "name": "Alice", "email": "alice@example.com"}, ) - bigfoot.grpc_mock.mock_unary_stream( + tripwire.grpc_mock.mock_unary_stream( "/commerce.OrderService/ListOrders", returns=[ {"order_id": "A1", "total": 29.99}, @@ -18,16 +18,16 @@ def test_fetch_user_orders(): ], ) - with bigfoot: + with tripwire: user, orders = fetch_user_orders(7) assert user["name"] == "Alice" assert len(orders) == 2 assert orders[0]["order_id"] == "A1" - bigfoot.grpc_mock.assert_unary_unary( + tripwire.grpc_mock.assert_unary_unary( "/commerce.UserService/GetUser", request={"id": 7}, ) - bigfoot.grpc_mock.assert_unary_stream( + tripwire.grpc_mock.assert_unary_stream( "/commerce.OrderService/ListOrders", request={"user_id": 7}, ) diff --git a/examples/jwt_auth/test_app.py b/examples/jwt_auth/test_app.py index 170c5e9..2859572 100644 --- a/examples/jwt_auth/test_app.py +++ b/examples/jwt_auth/test_app.py @@ -1,15 +1,15 @@ -"""Test JWT token issuance and verification using bigfoot jwt_mock.""" +"""Test JWT token issuance and verification using tripwire jwt_mock.""" -import bigfoot +import tripwire from .app import issue_access_token, verify_access_token def test_issue_and_verify_token(): - bigfoot.jwt_mock.mock_encode(returns="signed.access.token") - bigfoot.jwt_mock.mock_decode(returns={"sub": "user_42", "role": "editor", "iat": 1700000000}) + tripwire.jwt_mock.mock_encode(returns="signed.access.token") + tripwire.jwt_mock.mock_decode(returns={"sub": "user_42", "role": "editor", "iat": 1700000000}) - with bigfoot: + with tripwire: token = issue_access_token("user_42", "editor", "my-secret") claims = verify_access_token(token, "my-secret") @@ -17,12 +17,12 @@ def test_issue_and_verify_token(): assert claims["sub"] == "user_42" assert claims["role"] == "editor" - bigfoot.jwt_mock.assert_encode( + tripwire.jwt_mock.assert_encode( payload={"sub": "user_42", "role": "editor", "iat": 1700000000}, algorithm="HS256", extra_kwargs={}, ) - bigfoot.jwt_mock.assert_decode( + tripwire.jwt_mock.assert_decode( token="signed.access.token", algorithms=["HS256"], options=None, diff --git a/examples/logging_example/test_app.py b/examples/logging_example/test_app.py index bfaebb5..4d2d31e 100644 --- a/examples/logging_example/test_app.py +++ b/examples/logging_example/test_app.py @@ -1,16 +1,16 @@ -"""Test process_order using bigfoot log_mock.""" +"""Test process_order using tripwire log_mock.""" -import bigfoot +import tripwire from .app import process_order def test_process_order(): - with bigfoot: + with tripwire: result = process_order(42) assert result == "success" - bigfoot.log_mock.assert_info("Processing order 42", "orders") - bigfoot.log_mock.assert_debug("Validating payment for order 42", "orders") - bigfoot.log_mock.assert_info("Order 42 completed", "orders") + tripwire.log_mock.assert_info("Processing order 42", "orders") + tripwire.log_mock.assert_debug("Validating payment for order 42", "orders") + tripwire.log_mock.assert_info("Order 42 completed", "orders") diff --git a/examples/mcp_tool/test_app.py b/examples/mcp_tool/test_app.py index 2e9e47f..45b72e8 100644 --- a/examples/mcp_tool/test_app.py +++ b/examples/mcp_tool/test_app.py @@ -1,8 +1,8 @@ -"""Test MCP tool call using bigfoot mcp_mock.""" +"""Test MCP tool call using tripwire mcp_mock.""" import pytest -import bigfoot +import tripwire from .app import fetch_weather @@ -11,18 +11,18 @@ async def test_fetch_weather(): from mcp.client.session import ClientSession - bigfoot.mcp_mock.mock_call_tool( + tripwire.mcp_mock.mock_call_tool( "get_weather", returns={"content": [{"type": "text", "text": "Sunny, 72F"}]}, ) - with bigfoot: + with tripwire: session = object.__new__(ClientSession) result = await fetch_weather(session, "San Francisco") assert result == {"content": [{"type": "text", "text": "Sunny, 72F"}]} - bigfoot.mcp_mock.assert_call_tool( + tripwire.mcp_mock.assert_call_tool( "get_weather", arguments={"city": "San Francisco"}, direction="client", diff --git a/examples/memcache_session/test_app.py b/examples/memcache_session/test_app.py index 6f4039b..4b9f352 100644 --- a/examples/memcache_session/test_app.py +++ b/examples/memcache_session/test_app.py @@ -1,32 +1,32 @@ -"""Test memcache user profile caching using bigfoot memcache_mock.""" +"""Test memcache user profile caching using tripwire memcache_mock.""" -import bigfoot +import tripwire from .app import cache_user_profile, get_user_profile def test_cache_hit(): - bigfoot.memcache_mock.mock_command("GET", returns=b'{"name": "Alice"}') + tripwire.memcache_mock.mock_command("GET", returns=b'{"name": "Alice"}') - with bigfoot: + with tripwire: from pymemcache.client.base import Client client = Client(("localhost", 11211)) result = get_user_profile(client, "42") assert result == '{"name": "Alice"}' - bigfoot.memcache_mock.assert_get(command="GET", key="profile:42") + tripwire.memcache_mock.assert_get(command="GET", key="profile:42") def test_cache_write(): - bigfoot.memcache_mock.mock_command("SET", returns=True) + tripwire.memcache_mock.mock_command("SET", returns=True) - with bigfoot: + with tripwire: from pymemcache.client.base import Client client = Client(("localhost", 11211)) cache_user_profile(client, "42", '{"name": "Alice"}', ttl=600) - bigfoot.memcache_mock.assert_set( + tripwire.memcache_mock.assert_set( command="SET", key="profile:42", value=b'{"name": "Alice"}', diff --git a/examples/mongo_store/test_app.py b/examples/mongo_store/test_app.py index bc01818..042b389 100644 --- a/examples/mongo_store/test_app.py +++ b/examples/mongo_store/test_app.py @@ -1,11 +1,11 @@ -"""Test MongoDB order creation using bigfoot mongo_mock.""" +"""Test MongoDB order creation using tripwire mongo_mock.""" import logging import pymongo import pytest -import bigfoot +import tripwire from .app import create_order @@ -19,17 +19,17 @@ def _silence_pymongo(): def test_create_order(): mock_result = type("InsertOneResult", (), {"inserted_id": "order_789"})() - bigfoot.mongo_mock.mock_operation("insert_one", returns=mock_result) + tripwire.mongo_mock.mock_operation("insert_one", returns=mock_result) update_result = type("UpdateResult", (), {"modified_count": 1})() - bigfoot.mongo_mock.mock_operation("update_one", returns=update_result) + tripwire.mongo_mock.mock_operation("update_one", returns=update_result) - with bigfoot: + with tripwire: client = pymongo.MongoClient("mongodb://localhost:27017") order_id = create_order(client.shopdb, "cust_123", [{"sku": "WIDGET", "qty": 3}]) assert order_id == "order_789" - bigfoot.mongo_mock.assert_insert_one( + tripwire.mongo_mock.assert_insert_one( database="shopdb", collection="orders", document={ @@ -38,7 +38,7 @@ def test_create_order(): "status": "pending", }, ) - bigfoot.mongo_mock.assert_update_one( + tripwire.mongo_mock.assert_update_one( database="shopdb", collection="customers", filter={"_id": "cust_123"}, diff --git a/examples/native_lib/test_app.py b/examples/native_lib/test_app.py index 15ea91d..692e635 100644 --- a/examples/native_lib/test_app.py +++ b/examples/native_lib/test_app.py @@ -1,18 +1,18 @@ -"""Test native library calls using bigfoot native_mock.""" +"""Test native library calls using tripwire native_mock.""" -import bigfoot +import tripwire from .app import compute_distance def test_compute_distance(): - bigfoot.native_mock.mock_call("libm", "sqrt", returns=5.0) + tripwire.native_mock.mock_call("libm", "sqrt", returns=5.0) - with bigfoot: + with tripwire: result = compute_distance(0.0, 0.0, 3.0, 4.0) assert result == 5.0 - bigfoot.native_mock.assert_call( + tripwire.native_mock.assert_call( library="libm", function="sqrt", args=(25.0,), ) diff --git a/examples/pika_queue/test_app.py b/examples/pika_queue/test_app.py index 9ebbf47..5ef2dba 100644 --- a/examples/pika_queue/test_app.py +++ b/examples/pika_queue/test_app.py @@ -1,27 +1,27 @@ -"""Test RabbitMQ publishing using bigfoot pika_mock.""" +"""Test RabbitMQ publishing using tripwire pika_mock.""" -import bigfoot +import tripwire from .app import publish_event def test_publish_event(): - (bigfoot.pika_mock + (tripwire.pika_mock .new_session() .expect("connect", returns=None) .expect("channel", returns=None) .expect("publish", returns=None) .expect("close", returns=None)) - with bigfoot: + with tripwire: publish_event("mq.internal", "events", "order.created", b'{"order_id": 42}') - bigfoot.pika_mock.assert_connect(host="mq.internal", port=5672, virtual_host="/") - bigfoot.pika_mock.assert_channel() - bigfoot.pika_mock.assert_publish( + tripwire.pika_mock.assert_connect(host="mq.internal", port=5672, virtual_host="/") + tripwire.pika_mock.assert_channel() + tripwire.pika_mock.assert_publish( exchange="events", routing_key="order.created", body=b'{"order_id": 42}', properties=None, ) - bigfoot.pika_mock.assert_close() + tripwire.pika_mock.assert_close() diff --git a/examples/popen_example/test_app.py b/examples/popen_example/test_app.py index d3589b1..7b7f314 100644 --- a/examples/popen_example/test_app.py +++ b/examples/popen_example/test_app.py @@ -1,21 +1,21 @@ -"""Test run_linter using bigfoot popen_mock.""" +"""Test run_linter using tripwire popen_mock.""" -import bigfoot +import tripwire from .app import run_linter def test_linter_clean(): - (bigfoot.popen_mock + (tripwire.popen_mock .new_session() .expect("spawn", returns=None) .expect("communicate", returns=(b"All checks passed.\n", b"", 0))) - with bigfoot: + with tripwire: rc, output = run_linter("src/") assert rc == 0 assert output == "All checks passed.\n" - bigfoot.popen_mock.assert_spawn(command=["ruff", "check", "src/"], stdin=None) - bigfoot.popen_mock.assert_communicate(input=None) + tripwire.popen_mock.assert_spawn(command=["ruff", "check", "src/"], stdin=None) + tripwire.popen_mock.assert_communicate(input=None) diff --git a/examples/psycopg2_example/test_app.py b/examples/psycopg2_example/test_app.py index 58caff0..4365269 100644 --- a/examples/psycopg2_example/test_app.py +++ b/examples/psycopg2_example/test_app.py @@ -1,25 +1,25 @@ -"""Test save_user using bigfoot psycopg2_mock.""" +"""Test save_user using tripwire psycopg2_mock.""" -import bigfoot +import tripwire from .app import save_user def test_save_user(): - (bigfoot.psycopg2_mock + (tripwire.psycopg2_mock .new_session() .expect("connect", returns=None) .expect("execute", returns=[]) .expect("commit", returns=None) .expect("close", returns=None)) - with bigfoot: + with tripwire: save_user("Alice", "alice@example.com") - bigfoot.psycopg2_mock.assert_connect(host="localhost", dbname="app", user="app") - bigfoot.psycopg2_mock.assert_execute( + tripwire.psycopg2_mock.assert_connect(host="localhost", dbname="app", user="app") + tripwire.psycopg2_mock.assert_execute( sql="INSERT INTO users (name, email) VALUES (%s, %s)", parameters=("Alice", "alice@example.com"), ) - bigfoot.psycopg2_mock.assert_commit() - bigfoot.psycopg2_mock.assert_close() + tripwire.psycopg2_mock.assert_commit() + tripwire.psycopg2_mock.assert_close() diff --git a/examples/redis_cache/README.md b/examples/redis_cache/README.md index 22bc871..05a0b80 100644 --- a/examples/redis_cache/README.md +++ b/examples/redis_cache/README.md @@ -1,9 +1,9 @@ # Redis Cache Example -Demonstrates bigfoot's Redis plugin for mocking Redis commands. +Demonstrates tripwire's Redis plugin for mocking Redis commands. The application module (`app.py`) reads from a Redis cache. The test -(`test_app.py`) uses `bigfoot.redis_mock` to mock `GET` commands and +(`test_app.py`) uses `tripwire.redis_mock` to mock `GET` commands and verify the exact key lookups, covering both cache hit and cache miss scenarios. diff --git a/examples/redis_cache/test_app.py b/examples/redis_cache/test_app.py index 9c5d304..83738b9 100644 --- a/examples/redis_cache/test_app.py +++ b/examples/redis_cache/test_app.py @@ -1,28 +1,28 @@ -"""Test Redis cache using bigfoot redis_mock.""" +"""Test Redis cache using tripwire redis_mock.""" -import bigfoot +import tripwire from dirty_equals import IsInstance from .app import get_user def test_get_user_cache_hit(): - bigfoot.redis_mock.mock_command( + tripwire.redis_mock.mock_command( "GET", returns=b'{"id": 1, "name": "Alice"}' ) - with bigfoot: + with tripwire: result = get_user(1) assert result == {"id": 1, "name": "Alice"} - bigfoot.redis_mock.assert_command("GET", args=("user:1",), kwargs=IsInstance(dict)) + tripwire.redis_mock.assert_command("GET", args=("user:1",), kwargs=IsInstance(dict)) def test_get_user_cache_miss(): - bigfoot.redis_mock.mock_command("GET", returns=None) + tripwire.redis_mock.mock_command("GET", returns=None) - with bigfoot: + with tripwire: result = get_user(42) assert result is None - bigfoot.redis_mock.assert_command("GET", args=("user:42",), kwargs=IsInstance(dict)) + tripwire.redis_mock.assert_command("GET", args=("user:42",), kwargs=IsInstance(dict)) diff --git a/examples/socket_example/test_app.py b/examples/socket_example/test_app.py index 813ee87..d541835 100644 --- a/examples/socket_example/test_app.py +++ b/examples/socket_example/test_app.py @@ -1,24 +1,24 @@ -"""Test fetch_status using bigfoot socket_mock.""" +"""Test fetch_status using tripwire socket_mock.""" -import bigfoot +import tripwire from .app import fetch_status def test_fetch_status(): - (bigfoot.socket_mock + (tripwire.socket_mock .new_session() .expect("connect", returns=None) .expect("sendall", returns=None) .expect("recv", returns=b"OK 200\r\n") .expect("close", returns=None)) - with bigfoot: + with tripwire: result = fetch_status("monitoring.internal", 5000) assert result == "OK 200\r\n" - bigfoot.socket_mock.assert_connect(host="monitoring.internal", port=5000) - bigfoot.socket_mock.assert_sendall(data=b"STATUS\r\n") - bigfoot.socket_mock.assert_recv(size=4096, data=b"OK 200\r\n") - bigfoot.socket_mock.assert_close() + tripwire.socket_mock.assert_connect(host="monitoring.internal", port=5000) + tripwire.socket_mock.assert_sendall(data=b"STATUS\r\n") + tripwire.socket_mock.assert_recv(size=4096, data=b"OK 200\r\n") + tripwire.socket_mock.assert_close() diff --git a/examples/ssh_remote/test_app.py b/examples/ssh_remote/test_app.py index 66d2641..dbbfc12 100644 --- a/examples/ssh_remote/test_app.py +++ b/examples/ssh_remote/test_app.py @@ -1,12 +1,12 @@ -"""Test SSH deployment using bigfoot ssh_mock.""" +"""Test SSH deployment using tripwire ssh_mock.""" -import bigfoot +import tripwire from .app import deploy_config def test_deploy_config(): - (bigfoot.ssh_mock + (tripwire.ssh_mock .new_session() .expect("connect", returns=None) .expect("open_sftp", returns=None) @@ -14,19 +14,19 @@ def test_deploy_config(): .expect("exec_command", returns=(None, b"", b"")) .expect("close", returns=None)) - with bigfoot: + with tripwire: deploy_config( "prod-1.example.com", "deploy", "/tmp/app.conf", "/etc/myapp/app.conf", ) - bigfoot.ssh_mock.assert_connect( + tripwire.ssh_mock.assert_connect( hostname="prod-1.example.com", port=22, username="deploy", auth_method="password", ) - bigfoot.ssh_mock.assert_open_sftp() - bigfoot.ssh_mock.assert_sftp_put( + tripwire.ssh_mock.assert_open_sftp() + tripwire.ssh_mock.assert_sftp_put( localpath="/tmp/app.conf", remotepath="/etc/myapp/app.conf", ) - bigfoot.ssh_mock.assert_exec_command(command="systemctl reload myapp") - bigfoot.ssh_mock.assert_close() + tripwire.ssh_mock.assert_exec_command(command="systemctl reload myapp") + tripwire.ssh_mock.assert_close() diff --git a/examples/websocket_example/test_app.py b/examples/websocket_example/test_app.py index 3792383..fcf175a 100644 --- a/examples/websocket_example/test_app.py +++ b/examples/websocket_example/test_app.py @@ -1,12 +1,12 @@ -"""Test chat_client using bigfoot sync_websocket_mock.""" +"""Test chat_client using tripwire sync_websocket_mock.""" -import bigfoot +import tripwire from .app import chat_client def test_chat_client(): - (bigfoot.sync_websocket_mock + (tripwire.sync_websocket_mock .new_session() .expect("connect", returns=None) .expect("send", returns=None) @@ -15,14 +15,14 @@ def test_chat_client(): .expect("recv", returns="echo: world") .expect("close", returns=None)) - with bigfoot: + with tripwire: responses = chat_client("ws://chat.example.com/ws", ["hello", "world"]) assert responses == ["echo: hello", "echo: world"] - bigfoot.sync_websocket_mock.assert_connect(uri="ws://chat.example.com/ws") - bigfoot.sync_websocket_mock.assert_send(message="hello") - bigfoot.sync_websocket_mock.assert_recv(message="echo: hello") - bigfoot.sync_websocket_mock.assert_send(message="world") - bigfoot.sync_websocket_mock.assert_recv(message="echo: world") - bigfoot.sync_websocket_mock.assert_close() + tripwire.sync_websocket_mock.assert_connect(uri="ws://chat.example.com/ws") + tripwire.sync_websocket_mock.assert_send(message="hello") + tripwire.sync_websocket_mock.assert_recv(message="echo: hello") + tripwire.sync_websocket_mock.assert_send(message="world") + tripwire.sync_websocket_mock.assert_recv(message="echo: world") + tripwire.sync_websocket_mock.assert_close() diff --git a/mkdocs.yml b/mkdocs.yml index 0a702c9..2d61594 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,8 +1,8 @@ -site_name: bigfoot +site_name: tripwire site_description: "Full-certainty test mocking for Python." -site_url: https://axiomantic.github.io/bigfoot/ -repo_url: https://github.com/axiomantic/bigfoot -repo_name: axiomantic/bigfoot +site_url: https://axiomantic.github.io/tripwire/ +repo_url: https://github.com/axiomantic/tripwire +repo_name: axiomantic/tripwire theme: name: material diff --git a/pyproject.toml b/pyproject.toml index db25d95..752a7ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,8 +3,8 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "bigfoot" -version = "0.19.2" +name = "tripwire" +version = "0.20.0" description = "Full-certainty test mocking: every call recorded and verified" requires-python = ">=3.10" readme = "README.md" @@ -33,10 +33,10 @@ dependencies = [ ] [project.urls] -Homepage = "https://github.com/axiomantic/bigfoot" -Repository = "https://github.com/axiomantic/bigfoot" -Issues = "https://github.com/axiomantic/bigfoot/issues" -Changelog = "https://github.com/axiomantic/bigfoot/blob/main/CHANGELOG.md" +Homepage = "https://github.com/axiomantic/tripwire" +Repository = "https://github.com/axiomantic/tripwire" +Issues = "https://github.com/axiomantic/tripwire/issues" +Changelog = "https://github.com/axiomantic/tripwire/blob/main/CHANGELOG.md" [project.optional-dependencies] http = [ @@ -105,10 +105,10 @@ mcp = [ "mcp>=1.0.0", ] all = [ - "bigfoot[http,matchers,websockets,websocket-client,redis,aiohttp,psycopg2,asyncpg,boto3,pika,celery,grpc,pymemcache,pymongo,cffi,jwt,crypto,elasticsearch,dnspython,paramiko,mcp]", + "tripwire[http,matchers,websockets,websocket-client,redis,aiohttp,psycopg2,asyncpg,boto3,pika,celery,grpc,pymemcache,pymongo,cffi,jwt,crypto,elasticsearch,dnspython,paramiko,mcp]", ] all-ft = [ - "bigfoot[http,matchers,websockets,websocket-client,redis,boto3,jwt,dnspython,pymemcache,elasticsearch]", + "tripwire[http,matchers,websockets,websocket-client,redis,boto3,jwt,dnspython,pymemcache,elasticsearch]", ] docs = [ "mkdocs>=1.6", @@ -117,7 +117,7 @@ docs = [ "mike>=2.1", ] dev = [ - "bigfoot[all,docs]", + "tripwire[all,docs]", "pytest>=7.4.0", "pytest-asyncio>=0.23.0", "pytest-cov>=4.1.0", @@ -125,17 +125,17 @@ dev = [ "ruff>=0.1.0", ] dev-ft = [ - "bigfoot[all-ft]", + "tripwire[all-ft]", "pytest>=7.4.0", "pytest-asyncio>=0.23.0", "pytest-cov>=4.1.0", ] [project.entry-points."pytest11"] -bigfoot = "bigfoot.pytest_plugin" +tripwire = "tripwire.pytest_plugin" [tool.hatch.build.targets.wheel] -packages = ["src/bigfoot"] +packages = ["src/tripwire"] [tool.pytest.ini_options] testpaths = ["tests", "examples"] @@ -143,11 +143,11 @@ asyncio_mode = "auto" markers = [ "slow: tests that take >5 seconds", "integration: tests requiring real subsystem activation", - "network: tests that would make real network calls (should all be intercepted by bigfoot)", + "network: tests that would make real network calls (should all be intercepted by tripwire)", ] [tool.coverage.run] -source_pkgs = ["bigfoot"] +source_pkgs = ["tripwire"] branch = true [tool.coverage.report] @@ -186,7 +186,7 @@ module = [ ] ignore_missing_imports = true -[tool.bigfoot.firewall] +[tool.tripwire.firewall] allow = ["dns:*", "socket:*"] [tool.ruff] diff --git a/scripts/preflight.sh b/scripts/preflight.sh new file mode 100755 index 0000000..bfdd13f --- /dev/null +++ b/scripts/preflight.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# scripts/preflight.sh - Pre-C1 rename preflight checks. +# Run from the repo root. Exits non-zero on any failure so CI catches drift. +set -euo pipefail + +echo "=== 1. Sentinel collision audit ===" +# Confirm no existing source ID already uses the new colon-namespace shape +# in a way that would clash with the post-rename sentinels. +grep -rn '"\(subprocess\|httpx\|socket\|asyncio\|http\|dns\|file_io\):' src/ tests/ \ + --include="*.py" || true # informational; collisions logged for review + +echo "=== 2. Version-resolution check ===" +# M-5: identify any dynamic version-resolution call that the rename pass must update. +# As of design time this returns nothing (no __version__ exposed, no +# importlib.metadata.version("bigfoot") in source). If this returns hits, the +# rename pass must include them. +grep -rn "__version__\|importlib\.metadata\.version" src/ pyproject.toml || true + +echo "=== 3. Readthedocs URL audit ===" +# Find every readthedocs / docs URL that needs updating from bigfoot to tripwire. +grep -rn "bigfoot\.readthedocs\.io\|bigfoot.*docs" \ + src/ tests/ docs/ README.md mkdocs.yml pyproject.toml || true + +echo "=== 4. Plugin enumeration via BasePlugin.__subclasses__() ===" +# I-1: confirm the live plugin enumeration matches the §4 migration table BEFORE +# the rename pass. If the live set diverges from the table, the table needs an +# update before C2 can land. +uv run python -c " +import bigfoot # autodiscovery loads plugins +from bigfoot._base_plugin import BasePlugin +def _walk(cls): + for sub in cls.__subclasses__(): + yield sub + yield from _walk(sub) +names = sorted({c.__module__ + '.' + c.__qualname__ for c in _walk(BasePlugin)}) +for n in names: + print(n) +" + +echo "=== 5. Worktree state ===" +git status --porcelain | (! grep . > /dev/null) || \ + { echo "FAIL: worktree not clean"; exit 1; } + +echo "=== 6. Branch base ===" +git rev-parse --abbrev-ref HEAD | grep -q '^rename/tripwire-and-proposals$' || \ + { echo "FAIL: not on rename/tripwire-and-proposals branch"; exit 1; } + +echo "=== Preflight OK ===" diff --git a/src/bigfoot/__init__.pyi b/src/bigfoot/__init__.pyi deleted file mode 100644 index f71d71d..0000000 --- a/src/bigfoot/__init__.pyi +++ /dev/null @@ -1,195 +0,0 @@ -"""Type stubs for bigfoot's dynamic module-level API. - -Enables Pyright/mypy to resolve: -- ``with bigfoot:`` context manager protocol -- Module-level functions (current_verifier, sandbox, assert_interaction, etc.) -- Module-level factories (mock, spy) -- Plugin proxy attributes (http, subprocess_mock, etc.) -- All error classes -""" - -from __future__ import annotations - -import types -from typing import Any - -from bigfoot._base_plugin import BasePlugin as BasePlugin -from bigfoot._context import GuardPassThrough as GuardPassThrough -from bigfoot._context import get_verifier_or_raise as get_verifier_or_raise -from bigfoot._errors import AllWildcardAssertionError as AllWildcardAssertionError -from bigfoot._errors import AssertionInsideSandboxError as AssertionInsideSandboxError -from bigfoot._errors import AutoAssertError as AutoAssertError -from bigfoot._errors import BigfootConfigError as BigfootConfigError -from bigfoot._errors import BigfootError as BigfootError -from bigfoot._errors import ConflictError as ConflictError -from bigfoot._errors import GuardedCallError as GuardedCallError -from bigfoot._errors import GuardedCallWarning as GuardedCallWarning -from bigfoot._errors import InteractionMismatchError as InteractionMismatchError -from bigfoot._errors import InvalidStateError as InvalidStateError -from bigfoot._errors import MissingAssertionFieldsError as MissingAssertionFieldsError -from bigfoot._errors import NoActiveVerifierError as NoActiveVerifierError -from bigfoot._errors import SandboxNotActiveError as SandboxNotActiveError -from bigfoot._errors import UnassertedInteractionsError as UnassertedInteractionsError -from bigfoot._errors import UnmockedInteractionError as UnmockedInteractionError -from bigfoot._errors import UnusedMocksError as UnusedMocksError -from bigfoot._errors import VerificationError as VerificationError -from bigfoot._guard import allow as allow -from bigfoot._guard import deny as deny -from bigfoot._mock_plugin import ImportSiteMock, ObjectMock -from bigfoot._mock_plugin import MockPlugin as MockPlugin -from bigfoot._registry import PluginEntry as PluginEntry -from bigfoot._registry import is_guard_eligible as is_guard_eligible -from bigfoot._timeline import Interaction as Interaction -from bigfoot._timeline import Timeline as Timeline -from bigfoot._verifier import InAnyOrderContext as InAnyOrderContext -from bigfoot._verifier import SandboxContext as SandboxContext -from bigfoot._verifier import StrictVerifier as StrictVerifier -from bigfoot.plugins.async_subprocess_plugin import ( - AsyncSubprocessPlugin as AsyncSubprocessPlugin, -) -from bigfoot.plugins.database_plugin import DatabasePlugin as DatabasePlugin -from bigfoot.plugins.dns_plugin import DnsPlugin as DnsPlugin -from bigfoot.plugins.file_io_plugin import FileIoPlugin as FileIoPlugin -from bigfoot.plugins.logging_plugin import LoggingPlugin as LoggingPlugin -from bigfoot.plugins.memcache_plugin import MemcachePlugin as MemcachePlugin -from bigfoot.plugins.native_plugin import NativePlugin as NativePlugin -from bigfoot.plugins.popen_plugin import PopenPlugin as PopenPlugin -from bigfoot.plugins.redis_plugin import RedisPlugin as RedisPlugin -from bigfoot.plugins.smtp_plugin import SmtpPlugin as SmtpPlugin -from bigfoot.plugins.socket_plugin import SocketPlugin as SocketPlugin -from bigfoot.plugins.subprocess import SubprocessPlugin as SubprocessPlugin -from bigfoot.plugins.websocket_plugin import ( - AsyncWebSocketPlugin as AsyncWebSocketPlugin, -) -from bigfoot.plugins.websocket_plugin import ( - SyncWebSocketPlugin as SyncWebSocketPlugin, -) - -# Optional plugin classes (may not be importable if extras not installed) -try: - from bigfoot.plugins.http import HttpPlugin as HttpPlugin -except ImportError: ... - -try: - from bigfoot.plugins.celery_plugin import CeleryPlugin as CeleryPlugin -except ImportError: ... - -try: - from bigfoot.plugins.boto3_plugin import Boto3Plugin as Boto3Plugin -except ImportError: ... - -try: - from bigfoot.plugins.elasticsearch_plugin import ( - ElasticsearchPlugin as ElasticsearchPlugin, - ) -except ImportError: ... - -try: - from bigfoot.plugins.jwt_plugin import JwtPlugin as JwtPlugin -except ImportError: ... - -try: - from bigfoot.plugins.crypto_plugin import CryptoPlugin as CryptoPlugin -except ImportError: ... - -try: - from bigfoot.plugins.mongo_plugin import MongoPlugin as MongoPlugin -except ImportError: ... - -try: - from bigfoot.plugins.pika_plugin import PikaPlugin as PikaPlugin -except ImportError: ... - -try: - from bigfoot.plugins.ssh_plugin import SshPlugin as SshPlugin -except ImportError: ... - -try: - from bigfoot.plugins.grpc_plugin import GrpcPlugin as GrpcPlugin -except ImportError: ... - -try: - from bigfoot.plugins.mcp_plugin import McpPlugin as McpPlugin -except ImportError: ... - -try: - from bigfoot.plugins.psycopg2_plugin import Psycopg2Plugin as Psycopg2Plugin -except ImportError: ... - -try: - from bigfoot.plugins.asyncpg_plugin import AsyncpgPlugin as AsyncpgPlugin -except ImportError: ... - -# --------------------------------------------------------------------------- -# Module-level context manager protocol -# --------------------------------------------------------------------------- - -def __enter__() -> StrictVerifier: ... # noqa: N807 -def __exit__( # noqa: N807 - __exc_type: type[BaseException] | None, - __exc_val: BaseException | None, - __exc_tb: types.TracebackType | None, -) -> None: ... -async def __aenter__() -> StrictVerifier: ... # noqa: N807 -async def __aexit__( # noqa: N807 - __exc_type: type[BaseException] | None, - __exc_val: BaseException | None, - __exc_tb: types.TracebackType | None, -) -> None: ... - -# --------------------------------------------------------------------------- -# Module-level functions -# --------------------------------------------------------------------------- - -def current_verifier() -> StrictVerifier: ... -def sandbox() -> SandboxContext: ... -def assert_interaction(source: Any, **expected: object) -> None: ... # noqa: ANN401 -def in_any_order() -> InAnyOrderContext: ... -def verify_all() -> None: ... - -# --------------------------------------------------------------------------- -# Module-level factories -# --------------------------------------------------------------------------- - -class _MockFactory: - def __call__(self, path: str) -> ImportSiteMock: ... - def object(self, target: object, attr: str) -> ObjectMock: ... - -class _SpyFactory: - def __call__(self, path: str) -> ImportSiteMock: ... - def object(self, target: object, attr: str) -> ObjectMock: ... - -mock: _MockFactory -spy: _SpyFactory - -# --------------------------------------------------------------------------- -# Plugin proxy singletons -# --------------------------------------------------------------------------- - -http: Any # HttpPlugin proxy; typed as Any because httpx/requests are optional -subprocess_mock: Any # SubprocessPlugin proxy -popen_mock: Any # PopenPlugin proxy -smtp_mock: Any # SmtpPlugin proxy -socket_mock: Any # SocketPlugin proxy -db_mock: Any # DatabasePlugin proxy -async_websocket_mock: Any # AsyncWebSocketPlugin proxy -sync_websocket_mock: Any # SyncWebSocketPlugin proxy -redis_mock: Any # RedisPlugin proxy -mongo_mock: Any # MongoPlugin proxy -dns_mock: Any # DnsPlugin proxy -memcache_mock: Any # MemcachePlugin proxy -celery_mock: Any # CeleryPlugin proxy -log_mock: Any # LoggingPlugin proxy -async_subprocess_mock: Any # AsyncSubprocessPlugin proxy -psycopg2_mock: Any # Psycopg2Plugin proxy -asyncpg_mock: Any # AsyncpgPlugin proxy -boto3_mock: Any # Boto3Plugin proxy -elasticsearch_mock: Any # ElasticsearchPlugin proxy -jwt_mock: Any # JwtPlugin proxy -crypto_mock: Any # CryptoPlugin proxy -file_io_mock: Any # FileIoPlugin proxy -pika_mock: Any # PikaPlugin proxy -ssh_mock: Any # SshPlugin proxy -grpc_mock: Any # GrpcPlugin proxy -mcp_mock: Any # McpPlugin proxy -native_mock: Any # NativePlugin proxy diff --git a/src/bigfoot/_config.py b/src/bigfoot/_config.py deleted file mode 100644 index 7cb90c8..0000000 --- a/src/bigfoot/_config.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Config loading for bigfoot: reads [tool.bigfoot] from pyproject.toml.""" - -from pathlib import Path -from typing import Any - -from bigfoot._compat import tomllib - - -def load_bigfoot_config(start: Path | None = None) -> dict[str, Any]: - """Walk up from start (default: Path.cwd()) to find pyproject.toml. - - Returns the [tool.bigfoot] table as a dict, or {} if: - - no pyproject.toml found in start or any ancestor directory - - pyproject.toml found but has no [tool.bigfoot] section - - Raises tomllib.TOMLDecodeError if pyproject.toml is malformed. - This is intentional: a malformed pyproject.toml is a user error that - must not silently produce empty config. - """ - search = start or Path.cwd() - for directory in (search, *search.parents): - candidate = directory / "pyproject.toml" - if candidate.is_file(): - with candidate.open("rb") as f: - data = tomllib.load(f) - result: dict[str, Any] = data.get("tool", {}).get("bigfoot", {}) - return result - return {} diff --git a/src/bigfoot/__init__.py b/src/tripwire/__init__.py similarity index 77% rename from src/bigfoot/__init__.py rename to src/tripwire/__init__.py index 9e9176e..fbfb63f 100644 --- a/src/bigfoot/__init__.py +++ b/src/tripwire/__init__.py @@ -1,33 +1,33 @@ -"""bigfoot - Full-certainty test mocking. +"""tripwire - Full-certainty test mocking. Quick start: # 1. Configure in pyproject.toml: - # [tool.bigfoot] + # [tool.tripwire] # guard = "error" # or "warn" (default), or false # # 2. Mock, execute, assert: - bigfoot.http.mock_response("GET", "/api", json={"ok": True}) - with bigfoot: + tripwire.http.mock_response("GET", "/api", json={"ok": True}) + with tripwire: response = requests.get("/api") - bigfoot.http.assert_request("GET", "/api", status=200) + tripwire.http.assert_request("GET", "/api", status=200) # 3. Every intercepted call MUST be asserted. # Unasserted interactions raise UnassertedInteractionsError. # This is the core guarantee. Do not bypass it. Anti-patterns: - - NEVER create StrictVerifier directly. Use ``with bigfoot:`` context. - - NEVER use verifier.sandbox() directly. Use ``with bigfoot:``. + - NEVER create StrictVerifier directly. Use ``with tripwire:`` context. + - NEVER use verifier.sandbox() directly. Use ``with tripwire:``. - NEVER skip assert_* calls. Every mock MUST be asserted. - NEVER wildcard ALL fields in assert_* calls. Partial wildcards OK, all-wildcard verifies nothing. - - Configure plugins via [tool.bigfoot], not by code. + - Configure plugins via [tool.tripwire], not by code. Plugin authoring: - Subclass BasePlugin and register via [tool.bigfoot] in pyproject.toml. - Import authoring types from bigfoot directly: - from bigfoot import BasePlugin, Interaction, Timeline - See bigfoot documentation for the plugin authoring guide. + Subclass BasePlugin and register via [tool.tripwire] in pyproject.toml. + Import authoring types from tripwire directly: + from tripwire import BasePlugin, Interaction, Timeline + See tripwire documentation for the plugin authoring guide. """ from __future__ import annotations @@ -38,14 +38,12 @@ from collections.abc import Callable from typing import TYPE_CHECKING, TypeVar, cast -from bigfoot._base_plugin import BasePlugin -from bigfoot._context import GuardPassThrough, _get_test_verifier_or_raise, get_verifier_or_raise -from bigfoot._errors import ( +from tripwire._base_plugin import BasePlugin +from tripwire._context import GuardPassThrough, _get_test_verifier_or_raise, get_verifier_or_raise +from tripwire._errors import ( AllWildcardAssertionError, AssertionInsideSandboxError, AutoAssertError, - BigfootConfigError, - BigfootError, ConflictError, GuardedCallError, GuardedCallWarning, @@ -54,105 +52,107 @@ MissingAssertionFieldsError, NoActiveVerifierError, SandboxNotActiveError, + TripwireConfigError, + TripwireError, UnassertedInteractionsError, UnmockedInteractionError, UnusedMocksError, VerificationError, ) -from bigfoot._firewall import Disposition -from bigfoot._firewall_request import FirewallRequest -from bigfoot._guard import allow, deny, restrict -from bigfoot._match import M -from bigfoot._mock_plugin import MockPlugin -from bigfoot._registry import PluginEntry, is_guard_eligible -from bigfoot._timeline import Interaction, Timeline -from bigfoot._verifier import InAnyOrderContext, SandboxContext, StrictVerifier +from tripwire._firewall import Disposition +from tripwire._firewall_request import FirewallRequest +from tripwire._guard import allow, deny, restrict +from tripwire._match import M +from tripwire._mock_plugin import MockPlugin +from tripwire._registry import PluginEntry, is_guard_eligible +from tripwire._timeline import Interaction, Timeline +from tripwire._verifier import InAnyOrderContext, SandboxContext, StrictVerifier try: - from bigfoot.plugins.http import HttpPlugin # noqa: F401 + from tripwire.plugins.http import HttpPlugin # noqa: F401 except ImportError: # pragma: no cover pass # http extra not installed -from bigfoot.plugins.async_subprocess_plugin import ( +from tripwire.plugins.async_subprocess_plugin import ( AsyncSubprocessPlugin as _AsyncSubprocessPlugin, # noqa: F401 ) -from bigfoot.plugins.database_plugin import DatabasePlugin as _DatabasePlugin # noqa: F401 -from bigfoot.plugins.logging_plugin import LoggingPlugin as _LoggingPlugin # noqa: F401 -from bigfoot.plugins.popen_plugin import PopenPlugin as _PopenPlugin # noqa: F401 +from tripwire.plugins.database_plugin import DatabasePlugin as _DatabasePlugin # noqa: F401 +from tripwire.plugins.logging_plugin import LoggingPlugin as _LoggingPlugin # noqa: F401 +from tripwire.plugins.popen_plugin import PopenPlugin as _PopenPlugin # noqa: F401 try: - from bigfoot.plugins.celery_plugin import CeleryPlugin as _CeleryPlugin # noqa: F401 + from tripwire.plugins.celery_plugin import CeleryPlugin as _CeleryPlugin # noqa: F401 except ImportError: # pragma: no cover pass # celery extra not installed try: - from bigfoot.plugins.boto3_plugin import Boto3Plugin as _Boto3Plugin # noqa: F401 + from tripwire.plugins.boto3_plugin import Boto3Plugin as _Boto3Plugin # noqa: F401 except ImportError: # pragma: no cover pass # boto3 extra not installed try: - from bigfoot.plugins.elasticsearch_plugin import ( + from tripwire.plugins.elasticsearch_plugin import ( ElasticsearchPlugin as _ElasticsearchPlugin, # noqa: F401 ) except ImportError: # pragma: no cover pass # elasticsearch extra not installed try: - from bigfoot.plugins.jwt_plugin import JwtPlugin as _JwtPlugin # noqa: F401 + from tripwire.plugins.jwt_plugin import JwtPlugin as _JwtPlugin # noqa: F401 except ImportError: # pragma: no cover pass # jwt extra not installed try: - from bigfoot.plugins.crypto_plugin import CryptoPlugin as _CryptoPlugin # noqa: F401 + from tripwire.plugins.crypto_plugin import CryptoPlugin as _CryptoPlugin # noqa: F401 except ImportError: # pragma: no cover pass # crypto extra not installed -from bigfoot.plugins.dns_plugin import DnsPlugin as _DnsPlugin # noqa: F401 -from bigfoot.plugins.file_io_plugin import FileIoPlugin as _FileIoPlugin # noqa: F401 -from bigfoot.plugins.memcache_plugin import MemcachePlugin as _MemcachePlugin # noqa: F401 -from bigfoot.plugins.native_plugin import NativePlugin as _NativePlugin # noqa: F401 -from bigfoot.plugins.redis_plugin import RedisPlugin as _RedisPlugin # noqa: F401 +from tripwire.plugins.dns_plugin import DnsPlugin as _DnsPlugin # noqa: F401 +from tripwire.plugins.file_io_plugin import FileIoPlugin as _FileIoPlugin # noqa: F401 +from tripwire.plugins.memcache_plugin import MemcachePlugin as _MemcachePlugin # noqa: F401 +from tripwire.plugins.native_plugin import NativePlugin as _NativePlugin # noqa: F401 +from tripwire.plugins.redis_plugin import RedisPlugin as _RedisPlugin # noqa: F401 try: - from bigfoot.plugins.mongo_plugin import MongoPlugin as _MongoPlugin # noqa: F401 + from tripwire.plugins.mongo_plugin import MongoPlugin as _MongoPlugin # noqa: F401 except ImportError: # pragma: no cover pass # pymongo extra not installed -from bigfoot.plugins.smtp_plugin import SmtpPlugin as _SmtpPlugin # noqa: F401 +from tripwire.plugins.smtp_plugin import SmtpPlugin as _SmtpPlugin # noqa: F401 try: - from bigfoot.plugins.pika_plugin import PikaPlugin as _PikaPlugin # noqa: F401 + from tripwire.plugins.pika_plugin import PikaPlugin as _PikaPlugin # noqa: F401 except ImportError: # pragma: no cover pass # pika extra not installed try: - from bigfoot.plugins.ssh_plugin import SshPlugin as _SshPlugin # noqa: F401 + from tripwire.plugins.ssh_plugin import SshPlugin as _SshPlugin # noqa: F401 except ImportError: # pragma: no cover pass # paramiko extra not installed try: - from bigfoot.plugins.grpc_plugin import GrpcPlugin as _GrpcPlugin # noqa: F401 + from tripwire.plugins.grpc_plugin import GrpcPlugin as _GrpcPlugin # noqa: F401 except ImportError: # pragma: no cover pass # grpc extra not installed try: - from bigfoot.plugins.mcp_plugin import McpPlugin as _McpPlugin # noqa: F401 + from tripwire.plugins.mcp_plugin import McpPlugin as _McpPlugin # noqa: F401 except ImportError: # pragma: no cover pass # mcp extra not installed try: - from bigfoot.plugins.psycopg2_plugin import Psycopg2Plugin as _Psycopg2Plugin # noqa: F401 + from tripwire.plugins.psycopg2_plugin import Psycopg2Plugin as _Psycopg2Plugin # noqa: F401 except ImportError: # pragma: no cover pass # psycopg2 extra not installed try: - from bigfoot.plugins.asyncpg_plugin import AsyncpgPlugin as _AsyncpgPlugin # noqa: F401 + from tripwire.plugins.asyncpg_plugin import AsyncpgPlugin as _AsyncpgPlugin # noqa: F401 except ImportError: # pragma: no cover pass # asyncpg extra not installed -from bigfoot.plugins.socket_plugin import SocketPlugin as _SocketPlugin # noqa: F401 -from bigfoot.plugins.subprocess import SubprocessPlugin as _SubprocessPlugin # noqa: F401 -from bigfoot.plugins.websocket_plugin import ( +from tripwire.plugins.socket_plugin import SocketPlugin as _SocketPlugin # noqa: F401 +from tripwire.plugins.subprocess import SubprocessPlugin as _SubprocessPlugin # noqa: F401 +from tripwire.plugins.websocket_plugin import ( AsyncWebSocketPlugin as _AsyncWebSocketPlugin, ) -from bigfoot.plugins.websocket_plugin import ( +from tripwire.plugins.websocket_plugin import ( SyncWebSocketPlugin as _SyncWebSocketPlugin, ) @@ -230,9 +230,9 @@ pass if TYPE_CHECKING: - from bigfoot._mock_plugin import ImportSiteMock, MethodProxy, ObjectMock - from bigfoot.plugins.http import HttpRequestSentinel - from bigfoot.plugins.subprocess import SubprocessRunSentinel, SubprocessWhichSentinel + from tripwire._mock_plugin import ImportSiteMock, MethodProxy, ObjectMock + from tripwire.plugins.http import HttpRequestSentinel + from tripwire.plugins.subprocess import SubprocessRunSentinel, SubprocessWhichSentinel __all__ = [ # Plugin authoring API @@ -278,8 +278,8 @@ "GuardedCallWarning", # Errors "AllWildcardAssertionError", - "BigfootConfigError", - "BigfootError", + "TripwireConfigError", + "TripwireError", "AssertionInsideSandboxError", "AutoAssertError", "InvalidStateError", @@ -364,17 +364,17 @@ def _get_or_create_plugin(verifier: StrictVerifier, plugin_type: type[_T]) -> _T class _MockFactory: - """Callable object: bigfoot.mock("mod:attr") and bigfoot.mock.object(target, "attr").""" + """Callable object: tripwire.mock("mod:attr") and tripwire.mock.object(target, "attr").""" def __call__(self, path: str) -> ImportSiteMock: - from bigfoot._mock_plugin import MockPlugin as _MP # noqa: PLC0415, N814 + from tripwire._mock_plugin import MockPlugin as _MP # noqa: PLC0415, N814 verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _MP) return plugin.create_import_site_mock(path, spy=False) def object(self, target: object, attr: str) -> ObjectMock: - from bigfoot._mock_plugin import MockPlugin as _MP # noqa: PLC0415, N814 + from tripwire._mock_plugin import MockPlugin as _MP # noqa: PLC0415, N814 verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _MP) @@ -382,17 +382,17 @@ def object(self, target: object, attr: str) -> ObjectMock: class _SpyFactory: - """Callable object: bigfoot.spy("mod:attr") and bigfoot.spy.object(target, "attr").""" + """Callable object: tripwire.spy("mod:attr") and tripwire.spy.object(target, "attr").""" def __call__(self, path: str) -> ImportSiteMock: - from bigfoot._mock_plugin import MockPlugin as _MP # noqa: PLC0415, N814 + from tripwire._mock_plugin import MockPlugin as _MP # noqa: PLC0415, N814 verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _MP) return plugin.create_import_site_mock(path, spy=True) def object(self, target: object, attr: str) -> ObjectMock: - from bigfoot._mock_plugin import MockPlugin as _MP # noqa: PLC0415, N814 + from tripwire._mock_plugin import MockPlugin as _MP # noqa: PLC0415, N814 verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _MP) @@ -444,11 +444,11 @@ class _HttpProxy: def __getattr__(self, name: str) -> object: try: - from bigfoot.plugins.http import HttpPlugin as _HttpPlugin + from tripwire.plugins.http import HttpPlugin as _HttpPlugin except ImportError: raise ImportError( - "bigfoot[http] is required to use bigfoot.http. " - "Install it with: pip install bigfoot[http]" + "tripwire[http] is required to use tripwire.http. " + "Install it with: pip install tripwire[http]" ) from None verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _HttpPlugin) @@ -571,12 +571,12 @@ class _AsyncWebSocketProxy: """ def __getattr__(self, name: str) -> object: - from bigfoot.plugins.websocket_plugin import _WEBSOCKETS_AVAILABLE + from tripwire.plugins.websocket_plugin import _WEBSOCKETS_AVAILABLE if not _WEBSOCKETS_AVAILABLE: raise ImportError( - "bigfoot[websockets] is required to use bigfoot.async_websocket_mock. " - "Install it with: pip install bigfoot[websockets]" + "tripwire[websockets] is required to use tripwire.async_websocket_mock. " + "Install it with: pip install tripwire[websockets]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _AsyncWebSocketPlugin) @@ -599,12 +599,12 @@ class _SyncWebSocketProxy: """ def __getattr__(self, name: str) -> object: - from bigfoot.plugins.websocket_plugin import _WEBSOCKET_CLIENT_AVAILABLE + from tripwire.plugins.websocket_plugin import _WEBSOCKET_CLIENT_AVAILABLE if not _WEBSOCKET_CLIENT_AVAILABLE: raise ImportError( - "bigfoot[websocket-client] is required to use bigfoot.sync_websocket_mock. " - "Install it with: pip install bigfoot[websocket-client]" + "tripwire[websocket-client] is required to use tripwire.sync_websocket_mock. " + "Install it with: pip install tripwire[websocket-client]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _SyncWebSocketPlugin) @@ -627,12 +627,12 @@ class _RedisProxy: """ def __getattr__(self, name: str) -> object: - from bigfoot.plugins.redis_plugin import _REDIS_AVAILABLE + from tripwire.plugins.redis_plugin import _REDIS_AVAILABLE if not _REDIS_AVAILABLE: raise ImportError( - "bigfoot[redis] is required to use bigfoot.redis_mock. " - "Install it with: pip install bigfoot[redis]" + "tripwire[redis] is required to use tripwire.redis_mock. " + "Install it with: pip install tripwire[redis]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _RedisPlugin) @@ -697,12 +697,12 @@ class _PikaProxy: """ def __getattr__(self, name: str) -> object: - from bigfoot.plugins.pika_plugin import _PIKA_AVAILABLE + from tripwire.plugins.pika_plugin import _PIKA_AVAILABLE if not _PIKA_AVAILABLE: raise ImportError( - "bigfoot[pika] is required to use bigfoot.pika_mock. " - "Install it with: pip install bigfoot[pika]" + "tripwire[pika] is required to use tripwire.pika_mock. " + "Install it with: pip install tripwire[pika]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _PikaPlugin) @@ -725,12 +725,12 @@ class _SshProxy: """ def __getattr__(self, name: str) -> object: - from bigfoot.plugins.ssh_plugin import _PARAMIKO_AVAILABLE + from tripwire.plugins.ssh_plugin import _PARAMIKO_AVAILABLE if not _PARAMIKO_AVAILABLE: raise ImportError( - "bigfoot[ssh] is required to use bigfoot.ssh_mock. " - "Install it with: pip install bigfoot[ssh]" + "tripwire[ssh] is required to use tripwire.ssh_mock. " + "Install it with: pip install tripwire[ssh]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _SshPlugin) @@ -753,12 +753,12 @@ class _GrpcProxy: """ def __getattr__(self, name: str) -> object: - from bigfoot.plugins.grpc_plugin import _GRPC_AVAILABLE + from tripwire.plugins.grpc_plugin import _GRPC_AVAILABLE if not _GRPC_AVAILABLE: raise ImportError( - "bigfoot[grpc] is required to use bigfoot.grpc_mock. " - "Install it with: pip install bigfoot[grpc]" + "tripwire[grpc] is required to use tripwire.grpc_mock. " + "Install it with: pip install tripwire[grpc]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _GrpcPlugin) @@ -781,12 +781,12 @@ class _McpProxy: """ def __getattr__(self, name: str) -> object: - from bigfoot.plugins.mcp_plugin import _MCP_AVAILABLE + from tripwire.plugins.mcp_plugin import _MCP_AVAILABLE if not _MCP_AVAILABLE: raise ImportError( - "bigfoot[mcp] is required to use bigfoot.mcp_mock. " - "Install it with: pip install bigfoot[mcp]" + "tripwire[mcp] is required to use tripwire.mcp_mock. " + "Install it with: pip install tripwire[mcp]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _McpPlugin) @@ -809,12 +809,12 @@ class _MongoProxy: """ def __getattr__(self, name: str) -> object: - from bigfoot.plugins.mongo_plugin import _PYMONGO_AVAILABLE + from tripwire.plugins.mongo_plugin import _PYMONGO_AVAILABLE if not _PYMONGO_AVAILABLE: raise ImportError( - "bigfoot[mongo] is required to use bigfoot.mongo_mock. " - "Install it with: pip install bigfoot[mongo]" + "tripwire[mongo] is required to use tripwire.mongo_mock. " + "Install it with: pip install tripwire[mongo]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _MongoPlugin) @@ -858,12 +858,12 @@ class _MemcacheProxy: """ def __getattr__(self, name: str) -> object: - from bigfoot.plugins.memcache_plugin import _PYMEMCACHE_AVAILABLE + from tripwire.plugins.memcache_plugin import _PYMEMCACHE_AVAILABLE if not _PYMEMCACHE_AVAILABLE: raise ImportError( - "bigfoot[pymemcache] is required to use bigfoot.memcache_mock. " - "Install it with: pip install bigfoot[pymemcache]" + "tripwire[pymemcache] is required to use tripwire.memcache_mock. " + "Install it with: pip install tripwire[pymemcache]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _MemcachePlugin) @@ -886,12 +886,12 @@ class _CeleryProxy: """ def __getattr__(self, name: str) -> object: - from bigfoot.plugins.celery_plugin import _CELERY_AVAILABLE + from tripwire.plugins.celery_plugin import _CELERY_AVAILABLE if not _CELERY_AVAILABLE: raise ImportError( - "bigfoot[celery] is required to use bigfoot.celery_mock. " - "Install it with: pip install bigfoot[celery]" + "tripwire[celery] is required to use tripwire.celery_mock. " + "Install it with: pip install tripwire[celery]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _CeleryPlugin) @@ -934,12 +934,12 @@ class _Psycopg2Proxy: """ def __getattr__(self, name: str) -> object: - from bigfoot.plugins.psycopg2_plugin import _PSYCOPG2_AVAILABLE + from tripwire.plugins.psycopg2_plugin import _PSYCOPG2_AVAILABLE if not _PSYCOPG2_AVAILABLE: raise ImportError( - "bigfoot[psycopg2] is required to use bigfoot.psycopg2_mock. " - "Install it with: pip install bigfoot[psycopg2]" + "tripwire[psycopg2] is required to use tripwire.psycopg2_mock. " + "Install it with: pip install tripwire[psycopg2]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _Psycopg2Plugin) @@ -962,12 +962,12 @@ class _AsyncpgProxy: """ def __getattr__(self, name: str) -> object: - from bigfoot.plugins.asyncpg_plugin import _ASYNCPG_AVAILABLE + from tripwire.plugins.asyncpg_plugin import _ASYNCPG_AVAILABLE if not _ASYNCPG_AVAILABLE: raise ImportError( - "bigfoot[asyncpg] is required to use bigfoot.asyncpg_mock. " - "Install it with: pip install bigfoot[asyncpg]" + "tripwire[asyncpg] is required to use tripwire.asyncpg_mock. " + "Install it with: pip install tripwire[asyncpg]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _AsyncpgPlugin) @@ -990,12 +990,12 @@ class _Boto3Proxy: """ def __getattr__(self, name: str) -> object: - from bigfoot.plugins.boto3_plugin import _BOTO3_AVAILABLE + from tripwire.plugins.boto3_plugin import _BOTO3_AVAILABLE if not _BOTO3_AVAILABLE: raise ImportError( - "bigfoot[boto3] is required to use bigfoot.boto3_mock. " - "Install it with: pip install bigfoot[boto3]" + "tripwire[boto3] is required to use tripwire.boto3_mock. " + "Install it with: pip install tripwire[boto3]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _Boto3Plugin) @@ -1018,12 +1018,12 @@ class _ElasticsearchProxy: """ def __getattr__(self, name: str) -> object: - from bigfoot.plugins.elasticsearch_plugin import _ELASTICSEARCH_AVAILABLE + from tripwire.plugins.elasticsearch_plugin import _ELASTICSEARCH_AVAILABLE if not _ELASTICSEARCH_AVAILABLE: raise ImportError( - "bigfoot[elasticsearch] is required to use bigfoot.elasticsearch_mock. " - "Install it with: pip install bigfoot[elasticsearch]" + "tripwire[elasticsearch] is required to use tripwire.elasticsearch_mock. " + "Install it with: pip install tripwire[elasticsearch]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _ElasticsearchPlugin) @@ -1046,12 +1046,12 @@ class _JwtProxy: """ def __getattr__(self, name: str) -> object: - from bigfoot.plugins.jwt_plugin import _JWT_AVAILABLE + from tripwire.plugins.jwt_plugin import _JWT_AVAILABLE if not _JWT_AVAILABLE: raise ImportError( - "bigfoot[jwt] is required to use bigfoot.jwt_mock. " - "Install it with: pip install bigfoot[jwt]" + "tripwire[jwt] is required to use tripwire.jwt_mock. " + "Install it with: pip install tripwire[jwt]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _JwtPlugin) @@ -1074,12 +1074,12 @@ class _CryptoProxy: """ def __getattr__(self, name: str) -> object: - from bigfoot.plugins.crypto_plugin import _CRYPTOGRAPHY_AVAILABLE + from tripwire.plugins.crypto_plugin import _CRYPTOGRAPHY_AVAILABLE if not _CRYPTOGRAPHY_AVAILABLE: raise ImportError( - "bigfoot[crypto] is required to use bigfoot.crypto_mock. " - "Install it with: pip install bigfoot[crypto]" + "tripwire[crypto] is required to use tripwire.crypto_mock. " + "Install it with: pip install tripwire[crypto]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _CryptoPlugin) @@ -1110,17 +1110,17 @@ def __getattr__(self, name: str) -> object: # --------------------------------------------------------------------------- -# Module-level context manager (``with bigfoot:`` / ``async with bigfoot:``) +# Module-level context manager (``with tripwire:`` / ``async with tripwire:``) # --------------------------------------------------------------------------- _sandbox_stack: threading.local = threading.local() -class _BigfootModule(types.ModuleType): - """ModuleType subclass that makes ``bigfoot`` usable as a context manager. +class _TripwireModule(types.ModuleType): + """ModuleType subclass that makes ``tripwire`` usable as a context manager. - ``with bigfoot:`` is equivalent to ``with bigfoot.sandbox():``. - ``async with bigfoot:`` is equivalent to ``async with bigfoot.sandbox():``. + ``with tripwire:`` is equivalent to ``with tripwire.sandbox():``. + ``async with tripwire:`` is equivalent to ``async with tripwire.sandbox():``. Both forms return the active :class:`StrictVerifier` from ``__enter__``. """ @@ -1154,4 +1154,4 @@ async def __aexit__( await _sandbox_stack.stack.pop().__aexit__(exc_type, exc_val, exc_tb) -sys.modules[__name__].__class__ = _BigfootModule +sys.modules[__name__].__class__ = _TripwireModule diff --git a/src/tripwire/__init__.pyi b/src/tripwire/__init__.pyi new file mode 100644 index 0000000..447f549 --- /dev/null +++ b/src/tripwire/__init__.pyi @@ -0,0 +1,195 @@ +"""Type stubs for tripwire's dynamic module-level API. + +Enables Pyright/mypy to resolve: +- ``with tripwire:`` context manager protocol +- Module-level functions (current_verifier, sandbox, assert_interaction, etc.) +- Module-level factories (mock, spy) +- Plugin proxy attributes (http, subprocess_mock, etc.) +- All error classes +""" + +from __future__ import annotations + +import types +from typing import Any + +from tripwire._base_plugin import BasePlugin as BasePlugin +from tripwire._context import GuardPassThrough as GuardPassThrough +from tripwire._context import get_verifier_or_raise as get_verifier_or_raise +from tripwire._errors import AllWildcardAssertionError as AllWildcardAssertionError +from tripwire._errors import AssertionInsideSandboxError as AssertionInsideSandboxError +from tripwire._errors import AutoAssertError as AutoAssertError +from tripwire._errors import ConflictError as ConflictError +from tripwire._errors import GuardedCallError as GuardedCallError +from tripwire._errors import GuardedCallWarning as GuardedCallWarning +from tripwire._errors import InteractionMismatchError as InteractionMismatchError +from tripwire._errors import InvalidStateError as InvalidStateError +from tripwire._errors import MissingAssertionFieldsError as MissingAssertionFieldsError +from tripwire._errors import NoActiveVerifierError as NoActiveVerifierError +from tripwire._errors import SandboxNotActiveError as SandboxNotActiveError +from tripwire._errors import TripwireConfigError as TripwireConfigError +from tripwire._errors import TripwireError as TripwireError +from tripwire._errors import UnassertedInteractionsError as UnassertedInteractionsError +from tripwire._errors import UnmockedInteractionError as UnmockedInteractionError +from tripwire._errors import UnusedMocksError as UnusedMocksError +from tripwire._errors import VerificationError as VerificationError +from tripwire._guard import allow as allow +from tripwire._guard import deny as deny +from tripwire._mock_plugin import ImportSiteMock, ObjectMock +from tripwire._mock_plugin import MockPlugin as MockPlugin +from tripwire._registry import PluginEntry as PluginEntry +from tripwire._registry import is_guard_eligible as is_guard_eligible +from tripwire._timeline import Interaction as Interaction +from tripwire._timeline import Timeline as Timeline +from tripwire._verifier import InAnyOrderContext as InAnyOrderContext +from tripwire._verifier import SandboxContext as SandboxContext +from tripwire._verifier import StrictVerifier as StrictVerifier +from tripwire.plugins.async_subprocess_plugin import ( + AsyncSubprocessPlugin as AsyncSubprocessPlugin, +) +from tripwire.plugins.database_plugin import DatabasePlugin as DatabasePlugin +from tripwire.plugins.dns_plugin import DnsPlugin as DnsPlugin +from tripwire.plugins.file_io_plugin import FileIoPlugin as FileIoPlugin +from tripwire.plugins.logging_plugin import LoggingPlugin as LoggingPlugin +from tripwire.plugins.memcache_plugin import MemcachePlugin as MemcachePlugin +from tripwire.plugins.native_plugin import NativePlugin as NativePlugin +from tripwire.plugins.popen_plugin import PopenPlugin as PopenPlugin +from tripwire.plugins.redis_plugin import RedisPlugin as RedisPlugin +from tripwire.plugins.smtp_plugin import SmtpPlugin as SmtpPlugin +from tripwire.plugins.socket_plugin import SocketPlugin as SocketPlugin +from tripwire.plugins.subprocess import SubprocessPlugin as SubprocessPlugin +from tripwire.plugins.websocket_plugin import ( + AsyncWebSocketPlugin as AsyncWebSocketPlugin, +) +from tripwire.plugins.websocket_plugin import ( + SyncWebSocketPlugin as SyncWebSocketPlugin, +) + +# Optional plugin classes (may not be importable if extras not installed) +try: + from tripwire.plugins.http import HttpPlugin as HttpPlugin +except ImportError: ... + +try: + from tripwire.plugins.celery_plugin import CeleryPlugin as CeleryPlugin +except ImportError: ... + +try: + from tripwire.plugins.boto3_plugin import Boto3Plugin as Boto3Plugin +except ImportError: ... + +try: + from tripwire.plugins.elasticsearch_plugin import ( + ElasticsearchPlugin as ElasticsearchPlugin, + ) +except ImportError: ... + +try: + from tripwire.plugins.jwt_plugin import JwtPlugin as JwtPlugin +except ImportError: ... + +try: + from tripwire.plugins.crypto_plugin import CryptoPlugin as CryptoPlugin +except ImportError: ... + +try: + from tripwire.plugins.mongo_plugin import MongoPlugin as MongoPlugin +except ImportError: ... + +try: + from tripwire.plugins.pika_plugin import PikaPlugin as PikaPlugin +except ImportError: ... + +try: + from tripwire.plugins.ssh_plugin import SshPlugin as SshPlugin +except ImportError: ... + +try: + from tripwire.plugins.grpc_plugin import GrpcPlugin as GrpcPlugin +except ImportError: ... + +try: + from tripwire.plugins.mcp_plugin import McpPlugin as McpPlugin +except ImportError: ... + +try: + from tripwire.plugins.psycopg2_plugin import Psycopg2Plugin as Psycopg2Plugin +except ImportError: ... + +try: + from tripwire.plugins.asyncpg_plugin import AsyncpgPlugin as AsyncpgPlugin +except ImportError: ... + +# --------------------------------------------------------------------------- +# Module-level context manager protocol +# --------------------------------------------------------------------------- + +def __enter__() -> StrictVerifier: ... # noqa: N807 +def __exit__( # noqa: N807 + __exc_type: type[BaseException] | None, + __exc_val: BaseException | None, + __exc_tb: types.TracebackType | None, +) -> None: ... +async def __aenter__() -> StrictVerifier: ... # noqa: N807 +async def __aexit__( # noqa: N807 + __exc_type: type[BaseException] | None, + __exc_val: BaseException | None, + __exc_tb: types.TracebackType | None, +) -> None: ... + +# --------------------------------------------------------------------------- +# Module-level functions +# --------------------------------------------------------------------------- + +def current_verifier() -> StrictVerifier: ... +def sandbox() -> SandboxContext: ... +def assert_interaction(source: Any, **expected: object) -> None: ... # noqa: ANN401 +def in_any_order() -> InAnyOrderContext: ... +def verify_all() -> None: ... + +# --------------------------------------------------------------------------- +# Module-level factories +# --------------------------------------------------------------------------- + +class _MockFactory: + def __call__(self, path: str) -> ImportSiteMock: ... + def object(self, target: object, attr: str) -> ObjectMock: ... + +class _SpyFactory: + def __call__(self, path: str) -> ImportSiteMock: ... + def object(self, target: object, attr: str) -> ObjectMock: ... + +mock: _MockFactory +spy: _SpyFactory + +# --------------------------------------------------------------------------- +# Plugin proxy singletons +# --------------------------------------------------------------------------- + +http: Any # HttpPlugin proxy; typed as Any because httpx/requests are optional +subprocess_mock: Any # SubprocessPlugin proxy +popen_mock: Any # PopenPlugin proxy +smtp_mock: Any # SmtpPlugin proxy +socket_mock: Any # SocketPlugin proxy +db_mock: Any # DatabasePlugin proxy +async_websocket_mock: Any # AsyncWebSocketPlugin proxy +sync_websocket_mock: Any # SyncWebSocketPlugin proxy +redis_mock: Any # RedisPlugin proxy +mongo_mock: Any # MongoPlugin proxy +dns_mock: Any # DnsPlugin proxy +memcache_mock: Any # MemcachePlugin proxy +celery_mock: Any # CeleryPlugin proxy +log_mock: Any # LoggingPlugin proxy +async_subprocess_mock: Any # AsyncSubprocessPlugin proxy +psycopg2_mock: Any # Psycopg2Plugin proxy +asyncpg_mock: Any # AsyncpgPlugin proxy +boto3_mock: Any # Boto3Plugin proxy +elasticsearch_mock: Any # ElasticsearchPlugin proxy +jwt_mock: Any # JwtPlugin proxy +crypto_mock: Any # CryptoPlugin proxy +file_io_mock: Any # FileIoPlugin proxy +pika_mock: Any # PikaPlugin proxy +ssh_mock: Any # SshPlugin proxy +grpc_mock: Any # GrpcPlugin proxy +mcp_mock: Any # McpPlugin proxy +native_mock: Any # NativePlugin proxy diff --git a/src/bigfoot/_base_plugin.py b/src/tripwire/_base_plugin.py similarity index 92% rename from src/bigfoot/_base_plugin.py rename to src/tripwire/_base_plugin.py index 3074876..6f3272a 100644 --- a/src/bigfoot/_base_plugin.py +++ b/src/tripwire/_base_plugin.py @@ -1,19 +1,19 @@ -"""BasePlugin abstract base class for all bigfoot plugins.""" +"""BasePlugin abstract base class for all tripwire plugins.""" import threading import warnings from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, ClassVar -from bigfoot._recording import _recording_in_progress +from tripwire._recording import _recording_in_progress if TYPE_CHECKING: - from bigfoot._timeline import Interaction - from bigfoot._verifier import StrictVerifier + from tripwire._timeline import Interaction + from tripwire._verifier import StrictVerifier class BasePlugin(ABC): - """Abstract base for all bigfoot plugins. + """Abstract base for all tripwire plugins. To write a custom plugin: @@ -22,11 +22,11 @@ class BasePlugin(ABC): format_mock_hint, format_unmocked_hint, format_assert_hint, get_unused_mocks, format_unused_mock_hint) 3. Override install_patches() and restore_patches() for monkeypatching - 4. Register via entry_points in pyproject.toml or [tool.bigfoot] - 5. Use ``with bigfoot:`` in tests (never verifier.sandbox() directly) + 4. Register via entry_points in pyproject.toml or [tool.tripwire] + 5. Use ``with tripwire:`` in tests (never verifier.sandbox() directly) - Import from bigfoot directly: - from bigfoot import BasePlugin, Interaction, Timeline + Import from tripwire directly: + from tripwire import BasePlugin, Interaction, Timeline Subclasses get per-class _install_count and _install_lock automatically via __init_subclass__. The default activate()/deactivate() implementations @@ -192,17 +192,17 @@ def format_unused_mock_hint(self, mock_config: object) -> str: @classmethod def config_key(cls) -> str | None: - """Return the [tool.bigfoot.] section name for this plugin. + """Return the [tool.tripwire.] section name for this plugin. Return None to opt out of configuration entirely. Plugins that return None receive no load_config() call from concrete subclass __init__. - Example: HttpPlugin returns "http", mapping to [tool.bigfoot.http]. + Example: HttpPlugin returns "http", mapping to [tool.tripwire.http]. """ return None def load_config(self, config: dict[str, Any]) -> None: - """Apply configuration from the plugin's [tool.bigfoot.] sub-table. + """Apply configuration from the plugin's [tool.tripwire.] sub-table. Called as the last line of each concrete plugin's __init__, after all instance attributes have been set. The default implementation is a no-op. diff --git a/src/bigfoot/_compat.py b/src/tripwire/_compat.py similarity index 100% rename from src/bigfoot/_compat.py rename to src/tripwire/_compat.py diff --git a/src/tripwire/_config.py b/src/tripwire/_config.py new file mode 100644 index 0000000..9f977f9 --- /dev/null +++ b/src/tripwire/_config.py @@ -0,0 +1,35 @@ +"""Config loading for tripwire: reads [tool.tripwire] from pyproject.toml.""" + +from pathlib import Path +from typing import Any + +from tripwire._compat import tomllib + + +def load_tripwire_config(start: Path | None = None) -> dict[str, Any]: + """Walk up from start (default: Path.cwd()) to find pyproject.toml. + + Returns the [tool.tripwire] table as a dict, or {} if: + - no pyproject.toml found in start or any ancestor directory + - pyproject.toml found but has no [tool.tripwire] section + + Raises tomllib.TOMLDecodeError if pyproject.toml is malformed. + This is intentional: a malformed pyproject.toml is a user error that + must not silently produce empty config. + """ + from tripwire._errors import ConfigMigrationError # noqa: PLC0415 + + search = start or Path.cwd() + for directory in (search, *search.parents): + candidate = directory / "pyproject.toml" + if candidate.is_file(): + with candidate.open("rb") as f: + data = tomllib.load(f) + if "bigfoot" in data.get("tool", {}): + raise ConfigMigrationError( + "bigfoot was renamed to tripwire in 0.20.0; " + "rename the table to [tool.tripwire]" + ) + result: dict[str, Any] = data.get("tool", {}).get("tripwire", {}) + return result + return {} diff --git a/src/bigfoot/_context.py b/src/tripwire/_context.py similarity index 80% rename from src/bigfoot/_context.py rename to src/tripwire/_context.py index 11c1022..e59098d 100644 --- a/src/bigfoot/_context.py +++ b/src/tripwire/_context.py @@ -1,7 +1,7 @@ -"""Module-level ContextVars for bigfoot. +"""Module-level ContextVars for tripwire. Import this module first to avoid circular imports. It has no dependencies -on other bigfoot modules at import time (only deferred imports in functions). +on other tripwire modules at import time (only deferred imports in functions). """ from __future__ import annotations @@ -10,35 +10,35 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from bigfoot._firewall_request import FirewallRequest - from bigfoot._verifier import StrictVerifier + from tripwire._firewall_request import FirewallRequest + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Module-level ContextVars # --------------------------------------------------------------------------- _active_verifier: contextvars.ContextVar[StrictVerifier | None] = contextvars.ContextVar( - "bigfoot_active_verifier", default=None + "tripwire_active_verifier", default=None ) _any_order_depth: contextvars.ContextVar[int] = contextvars.ContextVar( - "bigfoot_any_order_depth", default=0 + "tripwire_any_order_depth", default=0 ) _current_test_verifier: contextvars.ContextVar[StrictVerifier | None] = contextvars.ContextVar( - "bigfoot_current_test_verifier", default=None + "tripwire_current_test_verifier", default=None ) _guard_active: contextvars.ContextVar[bool] = contextvars.ContextVar( - "bigfoot_guard_active", default=False + "tripwire_guard_active", default=False ) _guard_level: contextvars.ContextVar[str] = contextvars.ContextVar( - "bigfoot_guard_level", default="warn" + "tripwire_guard_level", default="warn" ) _guard_patches_installed: contextvars.ContextVar[bool] = contextvars.ContextVar( - "bigfoot_guard_patches_installed", default=False + "tripwire_guard_patches_installed", default=False ) @@ -91,10 +91,10 @@ def get_verifier_or_raise( # No sandbox active -- check firewall for guard mode. if firewall_request is not None and _guard_active.get(): plugin_name = source_id.split(":")[0] - from bigfoot._registry import is_guard_eligible # noqa: PLC0415 + from tripwire._registry import is_guard_eligible # noqa: PLC0415 if is_guard_eligible(plugin_name): - from bigfoot._firewall import Disposition, get_firewall_stack # noqa: PLC0415 + from tripwire._firewall import Disposition, get_firewall_stack # noqa: PLC0415 disposition = get_firewall_stack().evaluate(firewall_request) if disposition == Disposition.ALLOW: @@ -104,12 +104,12 @@ def get_verifier_or_raise( plugin_name = source_id.split(":")[0] # Use the new unified eligibility check - from bigfoot._registry import is_guard_eligible # noqa: PLC0415 + from tripwire._registry import is_guard_eligible # noqa: PLC0415 if is_guard_eligible(plugin_name): if _guard_active.get(): if firewall_request is not None: - from bigfoot._firewall import Disposition, get_firewall_stack # noqa: PLC0415 + from tripwire._firewall import Disposition, get_firewall_stack # noqa: PLC0415 disposition = get_firewall_stack().evaluate(firewall_request) # ALLOW was already handled above, so this is DENY @@ -117,7 +117,7 @@ def get_verifier_or_raise( if level == "warn": import warnings # noqa: PLC0415 - from bigfoot._errors import GuardedCallWarning # noqa: PLC0415 + from tripwire._errors import GuardedCallWarning # noqa: PLC0415 warnings.warn( f"{source_id!r} blocked by firewall. " @@ -128,7 +128,7 @@ def get_verifier_or_raise( raise GuardPassThrough() # level == "error" - from bigfoot._errors import GuardedCallError # noqa: PLC0415 + from tripwire._errors import GuardedCallError # noqa: PLC0415 raise GuardedCallError( source_id=source_id, @@ -137,7 +137,7 @@ def get_verifier_or_raise( ) else: # No firewall_request: fail closed - from bigfoot._errors import GuardedCallError # noqa: PLC0415 + from tripwire._errors import GuardedCallError # noqa: PLC0415 raise GuardedCallError( source_id=source_id, @@ -148,7 +148,7 @@ def get_verifier_or_raise( if _guard_patches_installed.get(): raise GuardPassThrough() - from bigfoot._errors import SandboxNotActiveError # noqa: PLC0415 + from tripwire._errors import SandboxNotActiveError # noqa: PLC0415 raise SandboxNotActiveError(source_id=source_id) @@ -159,7 +159,7 @@ def _get_test_verifier_or_raise() -> StrictVerifier: Called by module-level API functions (mock, sandbox, assert_interaction, etc.) when no test verifier is active. """ - from bigfoot._errors import NoActiveVerifierError + from tripwire._errors import NoActiveVerifierError verifier = _current_test_verifier.get() if verifier is None: diff --git a/src/bigfoot/_context_propagation.py b/src/tripwire/_context_propagation.py similarity index 98% rename from src/bigfoot/_context_propagation.py rename to src/tripwire/_context_propagation.py index 7a02aa7..e4b2b6c 100644 --- a/src/bigfoot/_context_propagation.py +++ b/src/tripwire/_context_propagation.py @@ -1,4 +1,4 @@ -"""Cross-thread ContextVar propagation for bigfoot interceptors. +"""Cross-thread ContextVar propagation for tripwire interceptors. Monkey-patches ``threading.Thread.start()``, ``_thread.start_new_thread()``, and ``concurrent.futures.ThreadPoolExecutor.submit()`` to copy the current @@ -34,7 +34,7 @@ # Capture originals at install time, NOT at import time. This respects # other libraries (e.g., OTel) that may have already patched these -# before bigfoot installs. +# before tripwire installs. _saved_start_new_thread: Callable[..., Any] | None = None _saved_thread_start: Callable[..., None] | None = None _saved_tpe_submit: Callable[..., Any] | None = None diff --git a/src/bigfoot/_errors.py b/src/tripwire/_errors.py similarity index 81% rename from src/bigfoot/_errors.py rename to src/tripwire/_errors.py index e69d994..19f5a77 100644 --- a/src/bigfoot/_errors.py +++ b/src/tripwire/_errors.py @@ -1,6 +1,6 @@ -"""All bigfoot exception classes. +"""All tripwire exception classes. -This module imports NOTHING from other bigfoot modules to prevent circular imports. +This module imports NOTHING from other tripwire modules to prevent circular imports. """ from __future__ import annotations @@ -8,14 +8,14 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from bigfoot._firewall_request import FirewallRequest + from tripwire._firewall_request import FirewallRequest -class BigfootError(Exception): - """Base class for all bigfoot errors.""" +class TripwireError(Exception): + """Base class for all tripwire errors.""" -class UnmockedInteractionError(BigfootError): +class UnmockedInteractionError(TripwireError): """Raised at call time: an interaction fired with no matching registered mock. Message includes: source description, args/kwargs, copy-pasteable mock hint. @@ -37,13 +37,13 @@ def __init__( f"Add a mock before entering the sandbox:\n" f"{hint}\n\n" f"Then assert it after the sandbox closes:\n" - f" with bigfoot:\n" + f" with tripwire:\n" f" # ... your code that triggers the call\n" f" # assert_* call here (REQUIRED)" ) -class UnassertedInteractionsError(BigfootError): +class UnassertedInteractionsError(TripwireError): """Raised at teardown: timeline contains interactions not matched by assert_interaction(). Message lists each unasserted interaction with copy-pasteable assert hint. @@ -57,7 +57,7 @@ def __init__(self, interactions: list[Any], hint: str) -> None: super().__init__(f"{header}\n\n{hint}") -class UnusedMocksError(BigfootError): +class UnusedMocksError(TripwireError): """Raised at teardown: registered mocks with required=True were never triggered. Message lists each unused mock with hint to either remove or set required=False. @@ -69,7 +69,7 @@ def __init__(self, mocks: list[Any], hint: str) -> None: super().__init__(f"{hint}") -class VerificationError(BigfootError): +class VerificationError(TripwireError): """Raised at teardown when BOTH UnassertedInteractionsError and UnusedMocksError apply. Contains both reports in separate sections. @@ -97,7 +97,7 @@ def __init__( super().__init__(message) -class InteractionMismatchError(BigfootError): +class InteractionMismatchError(TripwireError): """Raised by assert_interaction() when expected source/fields don't match the next interaction in the timeline. @@ -116,24 +116,24 @@ def __init__( super().__init__(hint) -class SandboxNotActiveError(BigfootError): +class SandboxNotActiveError(TripwireError): """Raised when an intercepted call fires but no sandbox is active. Attributes: source_id: Identifier of the interceptor that fired without a sandbox. - Message includes hint: 'Did you forget bigfoot_verifier fixture or sandbox() CM?' + Message includes hint: 'Did you forget tripwire_verifier fixture or sandbox() CM?' """ def __init__(self, source_id: str) -> None: self.source_id = source_id super().__init__( f"SandboxNotActiveError: source_id={source_id!r}, " - "hint='Did you forget bigfoot_verifier fixture or sandbox() CM?'" + "hint='Did you forget tripwire_verifier fixture or sandbox() CM?'" ) -class AssertionInsideSandboxError(BigfootError): +class AssertionInsideSandboxError(TripwireError): """Raised when assert_interaction(), in_any_order(), or verify_all() is called while a sandbox is active on that verifier instance. @@ -148,19 +148,19 @@ def __init__(self) -> None: ) -class NoActiveVerifierError(BigfootError): - """Raised when a module-level bigfoot function is called outside a test context.""" +class NoActiveVerifierError(TripwireError): + """Raised when a module-level tripwire function is called outside a test context.""" def __str__(self) -> str: return ( - "NoActiveVerifierError: no active bigfoot verifier. " - "Module-level bigfoot functions (mock, sandbox, assert_interaction, etc.) " - "require an active test context. Ensure bigfoot is installed as a pytest " + "NoActiveVerifierError: no active tripwire verifier. " + "Module-level tripwire functions (mock, sandbox, assert_interaction, etc.) " + "require an active test context. Ensure tripwire is installed as a pytest " "plugin (it registers automatically) and you are running inside a pytest test." ) -class ConflictError(BigfootError): +class ConflictError(TripwireError): """Raised at activate() time if target method is already patched by another library. Message names the conflicting library and the patched target. @@ -172,7 +172,7 @@ def __init__(self, target: str, patcher: str) -> None: super().__init__(f"ConflictError: target={target!r}, patcher={patcher!r}") -class MissingAssertionFieldsError(BigfootError): +class MissingAssertionFieldsError(TripwireError): """Raised by assert_interaction() when the caller omits one or more assertable fields from **expected. @@ -205,7 +205,7 @@ def __init__( super().__init__("\n".join(lines)) -class AutoAssertError(BigfootError): +class AutoAssertError(TripwireError): """Raised when mark_asserted() is called while record() is in progress. This indicates the auto-assert anti-pattern: a plugin calling @@ -214,7 +214,7 @@ class AutoAssertError(BigfootError): """ -class AllWildcardAssertionError(BigfootError): +class AllWildcardAssertionError(TripwireError): """Raised when all assertion fields are wildcards (e.g., AnyThing()). All-wildcard assertions verify nothing. Use real expected values @@ -231,15 +231,24 @@ def __init__(self, interaction: object, hint: str) -> None: ) -class BigfootConfigError(BigfootError): - """Raised when [tool.bigfoot] configuration is invalid. +class TripwireConfigError(TripwireError): + """Raised when [tool.tripwire] configuration is invalid. Examples: mutually exclusive keys, unknown plugin names, wrong types. """ -class GuardedCallError(BigfootError): - """Raised when a call is blocked by the bigfoot firewall. +class ConfigMigrationError(TripwireError): + """Raised when a deprecated `[tool.bigfoot]` table is found in pyproject.toml. + + bigfoot was renamed to tripwire in 0.20.0. The migration check fires at + the top of `load_tripwire_config` so the error surfaces before any other + validation, with a clear hint to rename the table to `[tool.tripwire]`. + """ + + +class GuardedCallError(TripwireError): + """Raised when a call is blocked by the tripwire firewall. The error message shows: 1. Exactly what was attempted (URL, host, command, path) @@ -262,7 +271,7 @@ def __init__( def _build_message(self) -> str: req = self.firewall_request lines = [ - f"GuardedCallError: {self.source_id!r} blocked by bigfoot firewall.", + f"GuardedCallError: {self.source_id!r} blocked by tripwire firewall.", "", ] @@ -297,16 +306,16 @@ def _build_message(self) -> str: # Section 3: Or mock it lines.append(" Or mock the call with a sandbox:") lines.append("") - lines.append(" with bigfoot:") + lines.append(" with tripwire:") lines.append(" ...") lines.append("") - lines.append(" Docs: https://bigfoot.readthedocs.io/guides/guard-mode/") + lines.append(" Docs: https://tripwire.readthedocs.io/guides/guard-mode/") return "\n".join(lines) def _describe_request(self, req: FirewallRequest) -> str: """Human-readable description of what was attempted.""" - from bigfoot._firewall_request import ( # noqa: PLC0415 + from tripwire._firewall_request import ( # noqa: PLC0415 Boto3FirewallRequest, DatabaseFirewallRequest, FileIoFirewallRequest, @@ -347,14 +356,14 @@ def _recommend_fix( # Fallback: coarse plugin-level fix return ( f'@pytest.mark.allow("{self.plugin_name}")', - f'with bigfoot.allow("{self.plugin_name}"):', + f'with tripwire.allow("{self.plugin_name}"):', [ - "[tool.bigfoot.firewall]", + "[tool.tripwire.firewall]", f'allow = ["{self.plugin_name}:*"]', ], ) - from bigfoot._firewall_request import ( # noqa: PLC0415 + from tripwire._firewall_request import ( # noqa: PLC0415 Boto3FirewallRequest, FileIoFirewallRequest, HttpFirewallRequest, @@ -366,9 +375,9 @@ def _recommend_fix( m_str = f'M(protocol="http", host="{req.host}", path="{req.path}")' return ( f"@pytest.mark.allow({m_str})", - f"with bigfoot.allow({m_str}):", + f"with tripwire.allow({m_str}):", [ - "[tool.bigfoot.firewall]", + "[tool.tripwire.firewall]", f'allow = ["{req.scheme}://{req.host}{req.path}"]', ], ) @@ -377,9 +386,9 @@ def _recommend_fix( m_str = f'M(protocol="redis", host="{req.host}", port={req.port})' return ( f"@pytest.mark.allow({m_str})", - f"with bigfoot.allow({m_str}):", + f"with tripwire.allow({m_str}):", [ - "[tool.bigfoot.firewall]", + "[tool.tripwire.firewall]", f'allow = ["redis://{req.host}:{req.port}"]', ], ) @@ -388,9 +397,9 @@ def _recommend_fix( m_str = f'M(protocol="subprocess", binary="{req.binary}")' return ( f"@pytest.mark.allow({m_str})", - f"with bigfoot.allow({m_str}):", + f"with tripwire.allow({m_str}):", [ - "[tool.bigfoot.firewall]", + "[tool.tripwire.firewall]", f'allow = ["subprocess:{req.binary}"]', ], ) @@ -399,9 +408,9 @@ def _recommend_fix( m_str = f'M(protocol="file_io", path="{req.path}", operation="{req.operation}")' return ( f"@pytest.mark.allow({m_str})", - f"with bigfoot.allow({m_str}):", + f"with tripwire.allow({m_str}):", [ - "[tool.bigfoot.firewall]", + "[tool.tripwire.firewall]", 'allow = ["file_io:*"] # or restrict to specific paths', ], ) @@ -410,9 +419,9 @@ def _recommend_fix( m_str = f'M(protocol="boto3", service="{req.service}", operation="{req.operation}")' return ( f"@pytest.mark.allow({m_str})", - f"with bigfoot.allow({m_str}):", + f"with tripwire.allow({m_str}):", [ - "[tool.bigfoot.firewall]", + "[tool.tripwire.firewall]", 'allow = ["boto3:*"] # or restrict to specific services', ], ) @@ -421,9 +430,9 @@ def _recommend_fix( m_str = f'M(protocol="{req.protocol}")' return ( f"@pytest.mark.allow({m_str})", - f"with bigfoot.allow({m_str}):", + f"with tripwire.allow({m_str}):", [ - "[tool.bigfoot.firewall]", + "[tool.tripwire.firewall]", f'allow = ["{req.protocol}:*"]', ], ) @@ -438,7 +447,7 @@ class GuardedCallWarning(UserWarning): """ -class InvalidStateError(BigfootError): +class InvalidStateError(TripwireError): """Raised when a state-machine method is called from an invalid state. Attributes: diff --git a/src/bigfoot/_firewall.py b/src/tripwire/_firewall.py similarity index 95% rename from src/bigfoot/_firewall.py rename to src/tripwire/_firewall.py index 3955f19..3d81357 100644 --- a/src/bigfoot/_firewall.py +++ b/src/tripwire/_firewall.py @@ -8,8 +8,8 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from bigfoot._firewall_request import FirewallRequest - from bigfoot._match import M + from tripwire._firewall_request import FirewallRequest + from tripwire._match import M class Disposition(enum.Enum): @@ -102,7 +102,7 @@ def evaluate(self, request: FirewallRequest) -> Disposition: # --------------------------------------------------------------------------- _firewall_stack: contextvars.ContextVar[FirewallStack] = contextvars.ContextVar( - "bigfoot_firewall_stack", default=FirewallStack() + "tripwire_firewall_stack", default=FirewallStack() ) diff --git a/src/bigfoot/_firewall_request.py b/src/tripwire/_firewall_request.py similarity index 100% rename from src/bigfoot/_firewall_request.py rename to src/tripwire/_firewall_request.py diff --git a/src/bigfoot/_glob.py b/src/tripwire/_glob.py similarity index 96% rename from src/bigfoot/_glob.py rename to src/tripwire/_glob.py index fd0a97c..fd2972a 100644 --- a/src/bigfoot/_glob.py +++ b/src/tripwire/_glob.py @@ -1,4 +1,4 @@ -"""Custom glob matching for bigfoot firewall patterns. +"""Custom glob matching for tripwire firewall patterns. fnmatch is insufficient because: - It treats * and ** identically @@ -6,7 +6,7 @@ - Case sensitivity is platform-dependent This module provides: -- bigfoot_match(): general-purpose matching +- tripwire_match(): general-purpose matching - Proper anchoring: *.example.com must NOT match evil-example.com - Case-insensitive host matching (RFC 4343) - * matches within a segment; ** matches across segments (for paths) @@ -38,7 +38,7 @@ def _match_host_glob(pattern: str, value: str) -> bool: return fnmatchcase(value.lower(), pattern.lower()) -def bigfoot_match( +def tripwire_match( pattern: str, value: str, *, diff --git a/src/bigfoot/_guard.py b/src/tripwire/_guard.py similarity index 84% rename from src/bigfoot/_guard.py rename to src/tripwire/_guard.py index c6ecc65..6801d4a 100644 --- a/src/bigfoot/_guard.py +++ b/src/tripwire/_guard.py @@ -5,13 +5,13 @@ from collections.abc import Generator from contextlib import contextmanager -from bigfoot._firewall import ( +from tripwire._firewall import ( Disposition, FirewallRule, RestrictFrame, _firewall_stack, ) -from bigfoot._match import M +from tripwire._match import M def _coerce_to_m(rule: str | M) -> M: @@ -27,15 +27,15 @@ def allow(*rules: str | M) -> Generator[None, None, None]: Usage: # Coarse: allow entire protocol - with bigfoot.allow("dns", "socket"): + with tripwire.allow("dns", "socket"): ... # Granular: allow specific host pattern - with bigfoot.allow(M(protocol="http", host="*.example.com")): + with tripwire.allow(M(protocol="http", host="*.example.com")): ... # Mixed: - with bigfoot.allow("dns", M(protocol="http", host="*.example.com")): + with tripwire.allow("dns", M(protocol="http", host="*.example.com")): ... """ if not rules: @@ -60,8 +60,8 @@ def deny(*rules: str | M) -> Generator[None, None, None]: """Push DENY rules onto the firewall stack. Usage: - with bigfoot.allow("redis"): - with bigfoot.deny(M(protocol="redis", command="FLUSHALL")): + with tripwire.allow("redis"): + with tripwire.deny(M(protocol="redis", command="FLUSHALL")): # Redis allowed except FLUSHALL ... """ @@ -91,13 +91,13 @@ def restrict(*rules: str | M) -> Generator[None, None, None]: Usage: # Only HTTP allowed; inner allow("redis") is silently blocked - with bigfoot.restrict(M(protocol="http")): - with bigfoot.allow(M(protocol="http", host="*.example.com")): + with tripwire.restrict(M(protocol="http")): + with tripwire.allow(M(protocol="http", host="*.example.com")): # Only *.example.com HTTP passes ... Multiple rules are OR'd together into a single restriction pattern: - with bigfoot.restrict("http", "dns"): + with tripwire.restrict("http", "dns"): # Only HTTP and DNS can pass this ceiling ... """ diff --git a/src/bigfoot/_match.py b/src/tripwire/_match.py similarity index 96% rename from src/bigfoot/_match.py rename to src/tripwire/_match.py index 2806c28..5053727 100644 --- a/src/bigfoot/_match.py +++ b/src/tripwire/_match.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from bigfoot._firewall_request import FirewallRequest + from tripwire._firewall_request import FirewallRequest # Fields where glob auto-detection applies (strings containing * are globs). # All other string fields use exact match by default. @@ -67,10 +67,10 @@ def __init__(self, protocol: str | None = None, **kwargs: Any) -> None: # noqa: # Only normalize plain string values (not callables, not suffix-modified) if isinstance(value, str) and base_key == key: if base_key in self._HOST_FIELDS: - from bigfoot._normalize import normalize_host # noqa: PLC0415 + from tripwire._normalize import normalize_host # noqa: PLC0415 value = normalize_host(value) elif base_key in self._PATH_FIELDS: - from bigfoot._normalize import normalize_path # noqa: PLC0415 + from tripwire._normalize import normalize_path # noqa: PLC0415 # Only normalize non-glob paths (globs contain * which should not be resolved) if "*" not in value: value = normalize_path(value) @@ -165,8 +165,8 @@ def __init__(self, pattern: str) -> None: self._pattern = pattern def matches(self, actual: Any) -> bool: # noqa: ANN401 - from bigfoot._glob import bigfoot_match # noqa: PLC0415 - return bigfoot_match(self._pattern, str(actual)) + from tripwire._glob import tripwire_match # noqa: PLC0415 + return tripwire_match(self._pattern, str(actual)) def __repr__(self) -> str: return f"glob({self._pattern!r})" diff --git a/src/bigfoot/_mock_plugin.py b/src/tripwire/_mock_plugin.py similarity index 94% rename from src/bigfoot/_mock_plugin.py rename to src/tripwire/_mock_plugin.py index 3895fa5..409b581 100644 --- a/src/bigfoot/_mock_plugin.py +++ b/src/tripwire/_mock_plugin.py @@ -6,12 +6,12 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, ClassVar -from bigfoot._base_plugin import BasePlugin -from bigfoot._errors import ConflictError, UnmockedInteractionError -from bigfoot._timeline import Interaction +from tripwire._base_plugin import BasePlugin +from tripwire._errors import ConflictError, UnmockedInteractionError +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Side effect sentinel types @@ -68,7 +68,7 @@ class MethodProxy: """Interceptor + source filter for a single mock method. Attribute access on _BaseMock or MockProxy returns a MethodProxy. - Calling it routes through the bigfoot interceptor. + Calling it routes through the tripwire interceptor. """ def __init__( @@ -144,7 +144,7 @@ def assert_call( raised: Expected exception (required when .raises() was used or spy raised). returned: Expected return value (required for spy mode when real method returned). """ - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 expected: dict[str, Any] = { "args": args, @@ -183,8 +183,8 @@ def _get_wraps_target(self) -> Any: # noqa: ANN401 return object.__getattribute__(proxy, "_wraps") def __call__(self, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401 - """Called when the mock is invoked. Routes through bigfoot interceptor.""" - from bigfoot._context import get_verifier_or_raise + """Called when the mock is invoked. Routes through tripwire interceptor.""" + from tripwire._context import get_verifier_or_raise # Step 1: Verify sandbox is active (raises SandboxNotActiveError if not) get_verifier_or_raise(self.source_id) @@ -321,7 +321,7 @@ def __getattr__(self, method_name: str) -> MethodProxy: # --- Sync context manager (individual activation, enforce=False) --- def __enter__(self) -> "_BaseMock": - from bigfoot._context import _active_verifier # noqa: PLC0415 + from tripwire._context import _active_verifier # noqa: PLC0415 self._activate(enforce=False) # Set active verifier so MethodProxy.__call__ can find it @@ -329,7 +329,7 @@ def __enter__(self) -> "_BaseMock": return self def __exit__(self, *exc_info: Any) -> None: # noqa: ANN401 - from bigfoot._context import _active_verifier # noqa: PLC0415 + from tripwire._context import _active_verifier # noqa: PLC0415 self._deactivate() if hasattr(self, "_verifier_token") and self._verifier_token is not None: @@ -339,14 +339,14 @@ def __exit__(self, *exc_info: Any) -> None: # noqa: ANN401 # --- Async context manager --- async def __aenter__(self) -> "_BaseMock": - from bigfoot._context import _active_verifier # noqa: PLC0415 + from tripwire._context import _active_verifier # noqa: PLC0415 self._activate(enforce=False) self._verifier_token = _active_verifier.set(self._plugin.verifier) return self async def __aexit__(self, *exc_info: Any) -> None: # noqa: ANN401 - from bigfoot._context import _active_verifier # noqa: PLC0415 + from tripwire._context import _active_verifier # noqa: PLC0415 self._deactivate() if hasattr(self, "_verifier_token") and self._verifier_token is not None: @@ -418,7 +418,7 @@ def assert_call(self, **kwargs: Any) -> None: # noqa: ANN401 class ImportSiteMock(_BaseMock): - """A mock registered via bigfoot.mock("mod:attr").""" + """A mock registered via tripwire.mock("mod:attr").""" def __init__(self, path: str, plugin: "MockPlugin", spy: bool = False) -> None: super().__init__(plugin=plugin, spy=spy) @@ -430,7 +430,7 @@ def __init__(self, path: str, plugin: "MockPlugin", spy: bool = False) -> None: self._path = path def _resolve_target(self) -> tuple[object, str]: - from bigfoot._path_resolution import resolve_target # noqa: PLC0415 + from tripwire._path_resolution import resolve_target # noqa: PLC0415 return resolve_target(self._path) @property @@ -439,7 +439,7 @@ def _display_name(self) -> str: class ObjectMock(_BaseMock): - """A mock registered via bigfoot.mock.object(target, "attr").""" + """A mock registered via tripwire.mock.object(target, "attr").""" def __init__( self, target: object, attr: str, plugin: "MockPlugin", spy: bool = False @@ -556,7 +556,7 @@ def _register_active_patch( existing = self._active_patches[patch_key] raise ConflictError( target=mock._display_name, - patcher=f"bigfoot mock ({existing._display_name})", + patcher=f"tripwire mock ({existing._display_name})", ) self._active_patches[patch_key] = mock @@ -613,8 +613,8 @@ def format_mock_hint(self, interaction: Interaction) -> str: method_name = interaction.details.get("method_name", "?") if "raised" in interaction.details: raised = interaction.details["raised"] - return f'bigfoot.mock("{mock_name}").{method_name}.raises({raised!r})' - return f'bigfoot.mock("{mock_name}").{method_name}.returns()' + return f'tripwire.mock("{mock_name}").{method_name}.raises({raised!r})' + return f'tripwire.mock("{mock_name}").{method_name}.returns()' def format_unmocked_hint( self, @@ -632,9 +632,9 @@ def format_unmocked_hint( f"Unexpected call to {mock_name}.{method_name}\n\n" f" Called with: args={args!r}, kwargs={kwargs!r}\n\n" f" To mock this interaction, add before your sandbox:\n" - f' bigfoot.mock("{mock_name}").{method_name}.returns()\n\n' + f' tripwire.mock("{mock_name}").{method_name}.returns()\n\n' f" Or to mark it optional:\n" - f' bigfoot.mock("{mock_name}").{method_name}.required(False).returns()' + f' tripwire.mock("{mock_name}").{method_name}.required(False).returns()' ) def format_assert_hint(self, interaction: Interaction) -> str: @@ -644,7 +644,7 @@ def format_assert_hint(self, interaction: Interaction) -> str: args = interaction.details.get("args", ()) kwargs = interaction.details.get("kwargs", {}) lines = [ - f'bigfoot.mock("{mock_name}").{method_name}.assert_call(', + f'tripwire.mock("{mock_name}").{method_name}.assert_call(', f" args={args!r},", f" kwargs={kwargs!r},", ] @@ -699,6 +699,6 @@ def format_unused_mock_hint(self, mock_config: object) -> str: f"{mock_config.registration_traceback}\n" f" Options:\n" f" - Remove this mock if it's not needed\n" - f' - Mark it optional: bigfoot.mock("{mock_config.mock_name}")' + f' - Mark it optional: tripwire.mock("{mock_config.mock_name}")' f".{mock_config.method_name}.required(False).returns(...)" ) diff --git a/src/bigfoot/_normalize.py b/src/tripwire/_normalize.py similarity index 100% rename from src/bigfoot/_normalize.py rename to src/tripwire/_normalize.py diff --git a/src/bigfoot/_patching.py b/src/tripwire/_patching.py similarity index 97% rename from src/bigfoot/_patching.py rename to src/tripwire/_patching.py index e71a21e..5a1a65f 100644 --- a/src/bigfoot/_patching.py +++ b/src/tripwire/_patching.py @@ -1,4 +1,4 @@ -"""Shared patching primitives for bigfoot plugins. +"""Shared patching primitives for tripwire plugins. PatchSet manages a group of monkeypatches with apply/restore semantics. Used by domain plugins to replace custom activate/deactivate boilerplate. diff --git a/src/bigfoot/_path_resolution.py b/src/tripwire/_path_resolution.py similarity index 100% rename from src/bigfoot/_path_resolution.py rename to src/tripwire/_path_resolution.py diff --git a/src/bigfoot/_recording.py b/src/tripwire/_recording.py similarity index 100% rename from src/bigfoot/_recording.py rename to src/tripwire/_recording.py diff --git a/src/bigfoot/_registry.py b/src/tripwire/_registry.py similarity index 70% rename from src/bigfoot/_registry.py rename to src/tripwire/_registry.py index aba45be..64fe89d 100644 --- a/src/bigfoot/_registry.py +++ b/src/tripwire/_registry.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from bigfoot._base_plugin import BasePlugin + from tripwire._base_plugin import BasePlugin @dataclass(frozen=True) @@ -14,7 +14,7 @@ class PluginEntry: """Registry entry for a single plugin.""" name: str # canonical registry name (e.g., "http") - import_path: str # e.g., "bigfoot.plugins.http" + import_path: str # e.g., "tripwire.plugins.http" class_name: str # e.g., "HttpPlugin" availability_check: str # module path or flag path to check availability default_enabled: bool = True # False for opt-in plugins (e.g., file I/O, ctypes/cffi) @@ -64,57 +64,57 @@ def _is_available(entry: PluginEntry) -> bool: # Registry of all interceptor plugins (excludes MockPlugin). PLUGIN_REGISTRY: tuple[PluginEntry, ...] = ( - PluginEntry("http", "bigfoot.plugins.http", "HttpPlugin", "httpx+requests"), - PluginEntry("subprocess", "bigfoot.plugins.subprocess", "SubprocessPlugin", "always"), - PluginEntry("popen", "bigfoot.plugins.popen_plugin", "PopenPlugin", "always"), - PluginEntry("smtp", "bigfoot.plugins.smtp_plugin", "SmtpPlugin", "always"), - PluginEntry("socket", "bigfoot.plugins.socket_plugin", "SocketPlugin", "always"), - PluginEntry("database", "bigfoot.plugins.database_plugin", "DatabasePlugin", "always"), + PluginEntry("http", "tripwire.plugins.http", "HttpPlugin", "httpx+requests"), + PluginEntry("subprocess", "tripwire.plugins.subprocess", "SubprocessPlugin", "always"), + PluginEntry("popen", "tripwire.plugins.popen_plugin", "PopenPlugin", "always"), + PluginEntry("smtp", "tripwire.plugins.smtp_plugin", "SmtpPlugin", "always"), + PluginEntry("socket", "tripwire.plugins.socket_plugin", "SocketPlugin", "always"), + PluginEntry("database", "tripwire.plugins.database_plugin", "DatabasePlugin", "always"), PluginEntry( "async_websocket", - "bigfoot.plugins.websocket_plugin", + "tripwire.plugins.websocket_plugin", "AsyncWebSocketPlugin", "websockets", ), PluginEntry( "sync_websocket", - "bigfoot.plugins.websocket_plugin", + "tripwire.plugins.websocket_plugin", "SyncWebSocketPlugin", - "flag:bigfoot.plugins.websocket_plugin:_WEBSOCKET_CLIENT_AVAILABLE", + "flag:tripwire.plugins.websocket_plugin:_WEBSOCKET_CLIENT_AVAILABLE", ), - PluginEntry("redis", "bigfoot.plugins.redis_plugin", "RedisPlugin", "redis"), - PluginEntry("psycopg2", "bigfoot.plugins.psycopg2_plugin", "Psycopg2Plugin", "psycopg2"), - PluginEntry("asyncpg", "bigfoot.plugins.asyncpg_plugin", "AsyncpgPlugin", "asyncpg"), - PluginEntry("logging", "bigfoot.plugins.logging_plugin", "LoggingPlugin", "always"), + PluginEntry("redis", "tripwire.plugins.redis_plugin", "RedisPlugin", "redis"), + PluginEntry("psycopg2", "tripwire.plugins.psycopg2_plugin", "Psycopg2Plugin", "psycopg2"), + PluginEntry("asyncpg", "tripwire.plugins.asyncpg_plugin", "AsyncpgPlugin", "asyncpg"), + PluginEntry("logging", "tripwire.plugins.logging_plugin", "LoggingPlugin", "always"), PluginEntry( "async_subprocess", - "bigfoot.plugins.async_subprocess_plugin", + "tripwire.plugins.async_subprocess_plugin", "AsyncSubprocessPlugin", "always", ), - PluginEntry("dns", "bigfoot.plugins.dns_plugin", "DnsPlugin", "always"), - PluginEntry("memcache", "bigfoot.plugins.memcache_plugin", "MemcachePlugin", "pymemcache"), - PluginEntry("celery", "bigfoot.plugins.celery_plugin", "CeleryPlugin", "celery"), - PluginEntry("boto3", "bigfoot.plugins.boto3_plugin", "Boto3Plugin", "boto3"), + PluginEntry("dns", "tripwire.plugins.dns_plugin", "DnsPlugin", "always"), + PluginEntry("memcache", "tripwire.plugins.memcache_plugin", "MemcachePlugin", "pymemcache"), + PluginEntry("celery", "tripwire.plugins.celery_plugin", "CeleryPlugin", "celery"), + PluginEntry("boto3", "tripwire.plugins.boto3_plugin", "Boto3Plugin", "boto3"), PluginEntry( "elasticsearch", - "bigfoot.plugins.elasticsearch_plugin", + "tripwire.plugins.elasticsearch_plugin", "ElasticsearchPlugin", "elasticsearch", ), - PluginEntry("jwt", "bigfoot.plugins.jwt_plugin", "JwtPlugin", "jwt"), - PluginEntry("crypto", "bigfoot.plugins.crypto_plugin", "CryptoPlugin", "cryptography"), - PluginEntry("mongo", "bigfoot.plugins.mongo_plugin", "MongoPlugin", "pymongo"), + PluginEntry("jwt", "tripwire.plugins.jwt_plugin", "JwtPlugin", "jwt"), + PluginEntry("crypto", "tripwire.plugins.crypto_plugin", "CryptoPlugin", "cryptography"), + PluginEntry("mongo", "tripwire.plugins.mongo_plugin", "MongoPlugin", "pymongo"), PluginEntry( - "file_io", "bigfoot.plugins.file_io_plugin", "FileIoPlugin", + "file_io", "tripwire.plugins.file_io_plugin", "FileIoPlugin", "always", default_enabled=False, ), - PluginEntry("pika", "bigfoot.plugins.pika_plugin", "PikaPlugin", "pika"), - PluginEntry("ssh", "bigfoot.plugins.ssh_plugin", "SshPlugin", "paramiko"), - PluginEntry("grpc", "bigfoot.plugins.grpc_plugin", "GrpcPlugin", "grpc"), - PluginEntry("mcp", "bigfoot.plugins.mcp_plugin", "McpPlugin", "mcp"), + PluginEntry("pika", "tripwire.plugins.pika_plugin", "PikaPlugin", "pika"), + PluginEntry("ssh", "tripwire.plugins.ssh_plugin", "SshPlugin", "paramiko"), + PluginEntry("grpc", "tripwire.plugins.grpc_plugin", "GrpcPlugin", "grpc"), + PluginEntry("mcp", "tripwire.plugins.mcp_plugin", "McpPlugin", "mcp"), PluginEntry( - "native", "bigfoot.plugins.native_plugin", "NativePlugin", + "native", "tripwire.plugins.native_plugin", "NativePlugin", "always", default_enabled=False, ), ) @@ -171,36 +171,36 @@ def resolve_enabled_plugins( - disabled_plugins: list[str] - blocklist (all except these) - neither: all available plugins - Raises BigfootConfigError for: + Raises TripwireConfigError for: - Both keys present - Unknown plugin names - Invalid types (not a list) """ - from bigfoot._errors import BigfootConfigError + from tripwire._errors import TripwireConfigError enabled = config.get("enabled_plugins") disabled = config.get("disabled_plugins") if enabled is not None and disabled is not None: - raise BigfootConfigError( + raise TripwireConfigError( "enabled_plugins and disabled_plugins are mutually exclusive. " "Use one or the other, not both." ) # Type validation: must be lists if present if enabled is not None and not isinstance(enabled, list): - raise BigfootConfigError( + raise TripwireConfigError( f"enabled_plugins must be a list of strings, got {type(enabled).__name__}" ) if disabled is not None and not isinstance(disabled, list): - raise BigfootConfigError( + raise TripwireConfigError( f"disabled_plugins must be a list of strings, got {type(disabled).__name__}" ) if enabled is not None: unknown = set(enabled) - VALID_PLUGIN_NAMES if unknown: - raise BigfootConfigError( + raise TripwireConfigError( f"Unknown plugin name(s) in enabled_plugins: {sorted(unknown)}. " f"Valid names: {sorted(VALID_PLUGIN_NAMES)}" ) @@ -208,10 +208,10 @@ def resolve_enabled_plugins( for e in PLUGIN_REGISTRY: if e.name in enabled: if not _is_available(e): - raise BigfootConfigError( + raise TripwireConfigError( f"Plugin '{e.name}' is in enabled_plugins but its " f"dependency '{e.availability_check}' is not installed. " - f"Install with: pip install bigfoot[{e.name}]" + f"Install with: pip install tripwire[{e.name}]" ) result.append(e) return result @@ -219,7 +219,7 @@ def resolve_enabled_plugins( if disabled is not None: unknown = set(disabled) - VALID_PLUGIN_NAMES if unknown: - raise BigfootConfigError( + raise TripwireConfigError( f"Unknown plugin name(s) in disabled_plugins: {sorted(unknown)}. " f"Valid names: {sorted(VALID_PLUGIN_NAMES)}" ) diff --git a/src/bigfoot/_state_machine_plugin.py b/src/tripwire/_state_machine_plugin.py similarity index 98% rename from src/bigfoot/_state_machine_plugin.py rename to src/tripwire/_state_machine_plugin.py index aa765e9..972a678 100644 --- a/src/bigfoot/_state_machine_plugin.py +++ b/src/tripwire/_state_machine_plugin.py @@ -1,4 +1,4 @@ -"""StateMachinePlugin: base class for state-machine-driven bigfoot plugins.""" +"""StateMachinePlugin: base class for state-machine-driven tripwire plugins.""" import threading import traceback @@ -7,12 +7,12 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any -from bigfoot._base_plugin import BasePlugin -from bigfoot._errors import InvalidStateError, UnmockedInteractionError -from bigfoot._timeline import Interaction +from tripwire._base_plugin import BasePlugin +from tripwire._errors import InvalidStateError, UnmockedInteractionError +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- diff --git a/src/bigfoot/_timeline.py b/src/tripwire/_timeline.py similarity index 92% rename from src/bigfoot/_timeline.py rename to src/tripwire/_timeline.py index ab19766..a8f3045 100644 --- a/src/bigfoot/_timeline.py +++ b/src/tripwire/_timeline.py @@ -5,15 +5,15 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any -from bigfoot._recording import _recording_in_progress +from tripwire._recording import _recording_in_progress if TYPE_CHECKING: - from bigfoot._base_plugin import BasePlugin + from tripwire._base_plugin import BasePlugin @dataclass class Interaction: - """A single recorded event in the bigfoot timeline.""" + """A single recorded event in the tripwire timeline.""" source_id: str # sequence=0 is a placeholder; Timeline.append() assigns the real number atomically. @@ -57,7 +57,7 @@ def find_any_unasserted( return None def mark_asserted(self, interaction: Interaction) -> None: - from bigfoot._errors import ( + from tripwire._errors import ( AutoAssertError, # noqa: PLC0415 — avoids circular import at module level ) diff --git a/src/bigfoot/_verifier.py b/src/tripwire/_verifier.py similarity index 91% rename from src/bigfoot/_verifier.py rename to src/tripwire/_verifier.py index 9260d6d..3018415 100644 --- a/src/bigfoot/_verifier.py +++ b/src/tripwire/_verifier.py @@ -1,4 +1,4 @@ -# src/bigfoot/_verifier.py +# src/tripwire/_verifier.py """StrictVerifier, SandboxContext, and InAnyOrderContext.""" import difflib @@ -7,10 +7,10 @@ from types import TracebackType from typing import TYPE_CHECKING, Any, Protocol -from bigfoot._compat import BaseExceptionGroup -from bigfoot._config import load_bigfoot_config -from bigfoot._context import _active_verifier, _any_order_depth -from bigfoot._errors import ( +from tripwire._compat import BaseExceptionGroup +from tripwire._config import load_tripwire_config +from tripwire._context import _active_verifier, _any_order_depth +from tripwire._errors import ( AllWildcardAssertionError, AssertionInsideSandboxError, InteractionMismatchError, @@ -19,15 +19,15 @@ UnusedMocksError, VerificationError, ) -from bigfoot._timeline import Interaction, Timeline +from tripwire._timeline import Interaction, Timeline _MISSING = object() if TYPE_CHECKING: import contextvars - from bigfoot._base_plugin import BasePlugin - from bigfoot._mock_plugin import ImportSiteMock, MockPlugin + from tripwire._base_plugin import BasePlugin + from tripwire._mock_plugin import ImportSiteMock, MockPlugin class _HasSourceId(Protocol): @@ -39,19 +39,19 @@ class _HasSourceId(Protocol): class StrictVerifier: """Manages plugin lifecycle, sandbox context, and assertion verification. - Do NOT instantiate directly. Use bigfoot's pytest integration: + Do NOT instantiate directly. Use tripwire's pytest integration: # In your test: - bigfoot.http.mock_response("GET", "/api", json={"ok": True}) - with bigfoot: + tripwire.http.mock_response("GET", "/api", json={"ok": True}) + with tripwire: response = requests.get("/api") - bigfoot.http.assert_request("GET", "/api", status=200) + tripwire.http.assert_request("GET", "/api", status=200) Direct instantiation bypasses forced assertion checking and will silently produce tests that pass without verifying anything. To access the verifier in a fixture or helper: - verifier = bigfoot.current_verifier() + verifier = tripwire.current_verifier() """ _suppress_direct_warning: bool = False @@ -59,19 +59,19 @@ class StrictVerifier: def __init__(self) -> None: # Detect direct instantiation outside pytest if not StrictVerifier._suppress_direct_warning: - from bigfoot._context import _current_test_verifier # noqa: PLC0415 + from tripwire._context import _current_test_verifier # noqa: PLC0415 if _current_test_verifier.get(None) is None: warnings.warn( "StrictVerifier instantiated directly. " - "Use `with bigfoot:` for proper assertion enforcement. " + "Use `with tripwire:` for proper assertion enforcement. " "Direct instantiation bypasses the pytest fixture that " "enforces verify_all() at teardown.", stacklevel=2, ) self._plugins: list[BasePlugin] = [] self._timeline: Timeline = Timeline() - self._bigfoot_config: dict[str, Any] = load_bigfoot_config() + self._tripwire_config: dict[str, Any] = load_tripwire_config() self._auto_instantiate_plugins() def _auto_instantiate_plugins(self) -> None: @@ -81,7 +81,7 @@ def _auto_instantiate_plugins(self) -> None: Silently skips plugins whose optional deps are not installed. After built-in plugins, discovers 3rd-party plugins registered via - the ``bigfoot.plugins`` entry point group. Entry point plugins are + the ``tripwire.plugins`` entry point group. Entry point plugins are instantiated unconditionally (if installed, they should work). Constructor bugs are intentionally NOT caught: if a plugin's __init__ @@ -89,21 +89,21 @@ def _auto_instantiate_plugins(self) -> None: because a broken plugin constructor is a bug that should be fixed, not silently ignored. """ - from bigfoot._registry import get_plugin_class, resolve_enabled_plugins + from tripwire._registry import get_plugin_class, resolve_enabled_plugins - entries = resolve_enabled_plugins(self._bigfoot_config) + entries = resolve_enabled_plugins(self._tripwire_config) for entry in entries: try: plugin_cls = get_plugin_class(entry) plugin_cls(self) # BasePlugin.__init__ calls _register_plugin except ImportError: - explicitly_enabled = set(self._bigfoot_config.get("enabled_plugins", [])) + explicitly_enabled = set(self._tripwire_config.get("enabled_plugins", [])) if entry.name in explicitly_enabled: - from bigfoot._errors import BigfootConfigError - raise BigfootConfigError( + from tripwire._errors import TripwireConfigError + raise TripwireConfigError( f"Plugin '{entry.name}' is in enabled_plugins but failed " f"to import. Ensure its dependencies are installed: " - f"pip install bigfoot[{entry.name}]" + f"pip install tripwire[{entry.name}]" ) # Silent skip only for default-enabled (not explicitly listed) plugins @@ -112,12 +112,12 @@ def _auto_instantiate_plugins(self) -> None: def _load_entrypoint_plugins(self) -> None: """Discover and instantiate 3rd-party plugins from entry points. - Looks for plugins registered under the ``bigfoot.plugins`` entry point + Looks for plugins registered under the ``tripwire.plugins`` entry point group. Each entry point should resolve to a BasePlugin subclass. Duplicate types (already registered by built-in registry) are silently skipped by _register_plugin. """ - for ep in entry_points(group="bigfoot.plugins"): + for ep in entry_points(group="tripwire.plugins"): try: plugin_cls = ep.load() plugin_cls(self) @@ -125,7 +125,7 @@ def _load_entrypoint_plugins(self) -> None: pass # Optional dependency not installed; expected. except Exception as exc: warnings.warn( - f"bigfoot: entry point plugin {ep.name!r} failed to load: {exc}", + f"tripwire: entry point plugin {ep.name!r} failed to load: {exc}", stacklevel=1, ) @@ -157,7 +157,7 @@ def spy(self, path: str) -> "ImportSiteMock": def _get_or_create_mock_plugin(self) -> "MockPlugin": """Return the existing MockPlugin or create one.""" - from bigfoot._mock_plugin import MockPlugin # noqa: PLC0415 + from tripwire._mock_plugin import MockPlugin # noqa: PLC0415 for plugin in self._plugins: if isinstance(plugin, MockPlugin): @@ -422,7 +422,7 @@ def _format_unasserted_error(self, unasserted: list[Interaction]) -> str: lines.append(" Every intercepted call must be verified with an assert_* call") lines.append(" after the sandbox closes:") lines.append("") - lines.append(" with bigfoot:") + lines.append(" with tripwire:") lines.append(" result = do_something()") lines.append(" plugin.assert_*(...) # <-- required for each interaction") return "\n".join(lines) @@ -458,7 +458,7 @@ def _enter(self) -> StrictVerifier: # Activate all registered mocks with enforce=True if not errors: - from bigfoot._mock_plugin import MockPlugin as _MP # noqa: PLC0415, N814 + from tripwire._mock_plugin import MockPlugin as _MP # noqa: PLC0415, N814 mock_plugin = next( (p for p in self._verifier._plugins if isinstance(p, _MP)), None @@ -488,7 +488,7 @@ def _enter(self) -> StrictVerifier: errors.append(cleanup_e) if self._token is not None: _active_verifier.reset(self._token) - raise BaseExceptionGroup("bigfoot sandbox activation failed", errors) + raise BaseExceptionGroup("tripwire sandbox activation failed", errors) return self._verifier @@ -512,7 +512,7 @@ def _exit(self) -> None: if self._token is not None: _active_verifier.reset(self._token) if errors: - raise BaseExceptionGroup("bigfoot sandbox deactivation failed", errors) + raise BaseExceptionGroup("tripwire sandbox deactivation failed", errors) def __enter__(self) -> StrictVerifier: return self._enter() diff --git a/src/bigfoot/plugins/__init__.py b/src/tripwire/plugins/__init__.py similarity index 100% rename from src/bigfoot/plugins/__init__.py rename to src/tripwire/plugins/__init__.py diff --git a/src/bigfoot/plugins/async_subprocess_plugin.py b/src/tripwire/plugins/async_subprocess_plugin.py similarity index 87% rename from src/bigfoot/plugins/async_subprocess_plugin.py rename to src/tripwire/plugins/async_subprocess_plugin.py index 1c64f74..c68736d 100644 --- a/src/bigfoot/plugins/async_subprocess_plugin.py +++ b/src/tripwire/plugins/async_subprocess_plugin.py @@ -11,14 +11,14 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any, ClassVar, cast -from bigfoot._context import GuardPassThrough, get_verifier_or_raise -from bigfoot._errors import ConflictError -from bigfoot._firewall_request import SubprocessFirewallRequest -from bigfoot._state_machine_plugin import StateMachinePlugin, _StepSentinel -from bigfoot._timeline import Interaction +from tripwire._context import GuardPassThrough, get_verifier_or_raise +from tripwire._errors import ConflictError +from tripwire._firewall_request import SubprocessFirewallRequest +from tripwire._state_machine_plugin import StateMachinePlugin, _StepSentinel +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Source ID constants @@ -37,12 +37,12 @@ # --------------------------------------------------------------------------- # Module-level references to our own interceptor functions. -# Set during _install_patches so _check_conflicts can distinguish bigfoot's +# Set during _install_patches so _check_conflicts can distinguish tripwire's # interceptors from foreign patchers during nested sandbox activations. # --------------------------------------------------------------------------- -_bigfoot_create_subprocess_exec: Callable[..., Any] | None = None -_bigfoot_create_subprocess_shell: Callable[..., Any] | None = None +_tripwire_create_subprocess_exec: Callable[..., Any] | None = None +_tripwire_create_subprocess_shell: Callable[..., Any] | None = None # --------------------------------------------------------------------------- @@ -58,7 +58,7 @@ def _find_async_subprocess_plugin( if isinstance(plugin, AsyncSubprocessPlugin): return plugin raise RuntimeError( - "BUG: bigfoot AsyncSubprocessPlugin interceptor is active but no " + "BUG: tripwire AsyncSubprocessPlugin interceptor is active but no " "AsyncSubprocessPlugin is registered on the current verifier." ) @@ -167,7 +167,7 @@ def _unmocked_source_id(self) -> str: def install_patches(self) -> None: """Install asyncio.create_subprocess_exec/shell patches.""" - global _bigfoot_create_subprocess_exec, _bigfoot_create_subprocess_shell + global _tripwire_create_subprocess_exec, _tripwire_create_subprocess_shell AsyncSubprocessPlugin._original_exec = asyncio.create_subprocess_exec AsyncSubprocessPlugin._original_shell = asyncio.create_subprocess_shell @@ -230,15 +230,15 @@ async def _fake_create_subprocess_shell( ) return proc - _bigfoot_create_subprocess_exec = _fake_create_subprocess_exec - _bigfoot_create_subprocess_shell = _fake_create_subprocess_shell + _tripwire_create_subprocess_exec = _fake_create_subprocess_exec + _tripwire_create_subprocess_shell = _fake_create_subprocess_shell setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec) setattr(asyncio, "create_subprocess_shell", _fake_create_subprocess_shell) def restore_patches(self) -> None: """Restore original asyncio.create_subprocess_exec/shell.""" - global _bigfoot_create_subprocess_exec, _bigfoot_create_subprocess_shell + global _tripwire_create_subprocess_exec, _tripwire_create_subprocess_shell if AsyncSubprocessPlugin._original_exec is not None: asyncio.create_subprocess_exec = AsyncSubprocessPlugin._original_exec @@ -246,8 +246,8 @@ def restore_patches(self) -> None: if AsyncSubprocessPlugin._original_shell is not None: asyncio.create_subprocess_shell = AsyncSubprocessPlugin._original_shell AsyncSubprocessPlugin._original_shell = None - _bigfoot_create_subprocess_exec = None - _bigfoot_create_subprocess_shell = None + _tripwire_create_subprocess_exec = None + _tripwire_create_subprocess_shell = None # ------------------------------------------------------------------ # Conflict detection @@ -255,21 +255,21 @@ def restore_patches(self) -> None: def check_conflicts(self) -> None: """Verify asyncio.create_subprocess_exec/shell have not been patched by a third party.""" - for target_name, current, original, bigfoot_ref in [ + for target_name, current, original, tripwire_ref in [ ( "asyncio.create_subprocess_exec", asyncio.create_subprocess_exec, _ORIGINAL_CREATE_SUBPROCESS_EXEC, - _bigfoot_create_subprocess_exec, + _tripwire_create_subprocess_exec, ), ( "asyncio.create_subprocess_shell", asyncio.create_subprocess_shell, _ORIGINAL_CREATE_SUBPROCESS_SHELL, - _bigfoot_create_subprocess_shell, + _tripwire_create_subprocess_shell, ), ]: - if current is not original and current is not bigfoot_ref: + if current is not original and current is not tripwire_ref: mod = getattr(current, "__module__", None) or "" qualname = getattr(current, "__qualname__", None) or "" if "unittest.mock" in mod or "MagicMock" in qualname: @@ -297,15 +297,15 @@ def format_interaction(self, interaction: Interaction) -> str: def format_mock_hint(self, interaction: Interaction) -> str: if interaction.source_id == _SOURCE_SPAWN: - return " bigfoot.async_subprocess_mock.new_session().expect('spawn', returns=None)" + return " tripwire.async_subprocess_mock.new_session().expect('spawn', returns=None)" if interaction.source_id == _SOURCE_COMMUNICATE: return ( - " bigfoot.async_subprocess_mock.new_session()" + " tripwire.async_subprocess_mock.new_session()" ".expect('communicate', returns=(b'', b'', 0))" ) if interaction.source_id == _SOURCE_WAIT: - return " bigfoot.async_subprocess_mock.new_session().expect('wait', returns=0)" - return " bigfoot.async_subprocess_mock.new_session().expect('?', returns=...)" + return " tripwire.async_subprocess_mock.new_session().expect('wait', returns=0)" + return " tripwire.async_subprocess_mock.new_session().expect('?', returns=...)" def format_unmocked_hint( self, @@ -317,11 +317,11 @@ def format_unmocked_hint( return ( f"asyncio.create_subprocess_{method}(...) was called but no session was queued.\n" f"Register a session with:\n" - f" bigfoot.async_subprocess_mock.new_session().expect({method!r}, returns=...)" + f" tripwire.async_subprocess_mock.new_session().expect({method!r}, returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: - pm = "bigfoot.async_subprocess_mock" + pm = "tripwire.async_subprocess_mock" sid = interaction.source_id if sid == _SOURCE_SPAWN: command = interaction.details.get("command", []) @@ -352,19 +352,19 @@ def assertable_fields(self, interaction: Interaction) -> frozenset[str]: return frozenset(interaction.details.keys()) def assert_spawn(self, *, command: list[str] | str, stdin: bytes | None) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._spawn_sentinel, command=command, stdin=stdin ) def assert_communicate(self, *, input: bytes | None) -> None: # noqa: A002 - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._communicate_sentinel, input=input ) def assert_wait(self) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._wait_sentinel) def format_unused_mock_hint(self, mock_config: object) -> str: diff --git a/src/bigfoot/plugins/asyncpg_plugin.py b/src/tripwire/plugins/asyncpg_plugin.py similarity index 92% rename from src/bigfoot/plugins/asyncpg_plugin.py rename to src/tripwire/plugins/asyncpg_plugin.py index 98a948a..ff9533e 100644 --- a/src/bigfoot/plugins/asyncpg_plugin.py +++ b/src/tripwire/plugins/asyncpg_plugin.py @@ -3,13 +3,13 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any, ClassVar, cast -from bigfoot._context import GuardPassThrough, get_verifier_or_raise -from bigfoot._firewall_request import PostgresFirewallRequest -from bigfoot._state_machine_plugin import SessionHandle, StateMachinePlugin, _StepSentinel -from bigfoot._timeline import Interaction +from tripwire._context import GuardPassThrough, get_verifier_or_raise +from tripwire._firewall_request import PostgresFirewallRequest +from tripwire._state_machine_plugin import SessionHandle, StateMachinePlugin, _StepSentinel +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Optional dependency guard @@ -47,7 +47,7 @@ def _get_asyncpg_plugin( if isinstance(plugin, AsyncpgPlugin): return plugin raise RuntimeError( - "BUG: bigfoot AsyncpgPlugin interceptor is active but no " + "BUG: tripwire AsyncpgPlugin interceptor is active but no " "AsyncpgPlugin is registered on the current verifier." ) @@ -247,7 +247,7 @@ def format_interaction(self, interaction: Interaction) -> str: def format_mock_hint(self, interaction: Interaction) -> str: method = interaction.details.get("method", "?") - return f" bigfoot.asyncpg_mock.new_session().expect({method!r}, returns=...)" + return f" tripwire.asyncpg_mock.new_session().expect({method!r}, returns=...)" def format_unmocked_hint( self, @@ -259,11 +259,11 @@ def format_unmocked_hint( return ( f"asyncpg.{method}(...) was called but no session was queued.\n" f"Register a session with:\n" - f" bigfoot.asyncpg_mock.new_session().expect({method!r}, returns=...)" + f" tripwire.asyncpg_mock.new_session().expect({method!r}, returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: - sm = "bigfoot.asyncpg_mock" + sm = "tripwire.asyncpg_mock" sid = interaction.source_id if sid == _SOURCE_CONNECT: parts = [] @@ -323,42 +323,42 @@ def assert_connect(self, **kwargs: object) -> None: Pass whichever connection fields were used: dsn, host, port, database, user. """ - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._connect_sentinel, **kwargs ) def assert_execute(self, *, query: str, args: object) -> None: """Assert the next asyncpg execute interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._execute_sentinel, query=query, args=args ) def assert_fetch(self, *, query: str, args: object) -> None: """Assert the next asyncpg fetch interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._fetch_sentinel, query=query, args=args ) def assert_fetchrow(self, *, query: str, args: object) -> None: """Assert the next asyncpg fetchrow interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._fetchrow_sentinel, query=query, args=args ) def assert_fetchval(self, *, query: str, args: object) -> None: """Assert the next asyncpg fetchval interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._fetchval_sentinel, query=query, args=args ) def assert_close(self) -> None: """Assert the next asyncpg close interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._close_sentinel) def format_unused_mock_hint(self, mock_config: object) -> str: diff --git a/src/bigfoot/plugins/boto3_plugin.py b/src/tripwire/plugins/boto3_plugin.py similarity index 94% rename from src/bigfoot/plugins/boto3_plugin.py rename to src/tripwire/plugins/boto3_plugin.py index a61b444..8461f9b 100644 --- a/src/bigfoot/plugins/boto3_plugin.py +++ b/src/tripwire/plugins/boto3_plugin.py @@ -13,14 +13,14 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, ClassVar, cast -from bigfoot._base_plugin import BasePlugin -from bigfoot._context import GuardPassThrough, get_verifier_or_raise -from bigfoot._errors import UnmockedInteractionError -from bigfoot._firewall_request import Boto3FirewallRequest -from bigfoot._timeline import Interaction +from tripwire._base_plugin import BasePlugin +from tripwire._context import GuardPassThrough, get_verifier_or_raise +from tripwire._errors import UnmockedInteractionError +from tripwire._firewall_request import Boto3FirewallRequest +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Optional dependency guard @@ -257,7 +257,7 @@ def install_patches(self) -> None: """ if not _BOTO3_AVAILABLE: raise ImportError( - "Install bigfoot[boto3] to use Boto3Plugin: pip install bigfoot[boto3]" + "Install tripwire[boto3] to use Boto3Plugin: pip install tripwire[boto3]" ) # Save current env values and inject dummy credentials for key, value in self._CREDENTIAL_ENV_VARS.items(): @@ -314,7 +314,7 @@ def format_interaction(self, interaction: Interaction) -> str: def format_mock_hint(self, interaction: Interaction) -> str: service = interaction.details.get("service", "?") operation = interaction.details.get("operation", "?") - return f" bigfoot.boto3_mock.mock_call({service!r}, {operation!r}, returns=...)" + return f" tripwire.boto3_mock.mock_call({service!r}, {operation!r}, returns=...)" def format_unmocked_hint( self, @@ -328,7 +328,7 @@ def format_unmocked_hint( return ( f"{service}.{operation}(...) was called but no mock was registered.\n" f"Register a mock with:\n" - f" bigfoot.boto3_mock.mock_call({service!r}, {operation!r}, returns=...)" + f" tripwire.boto3_mock.mock_call({service!r}, {operation!r}, returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: @@ -336,7 +336,7 @@ def format_assert_hint(self, interaction: Interaction) -> str: operation = interaction.details.get("operation", "?") params = interaction.details.get("params", {}) return ( - f" bigfoot.boto3_mock.assert_boto3_call(\n" + f" tripwire.boto3_mock.assert_boto3_call(\n" f" service={service!r},\n" f" operation={operation!r},\n" f" params={params!r},\n" @@ -365,7 +365,7 @@ def assert_boto3_call( Wraps assert_interaction() for ergonomic use. All three fields (service, operation, params) are required. """ - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 sentinel = _Boto3Sentinel(service, operation) _get_test_verifier_or_raise().assert_interaction( diff --git a/src/bigfoot/plugins/celery_plugin.py b/src/tripwire/plugins/celery_plugin.py similarity index 94% rename from src/bigfoot/plugins/celery_plugin.py rename to src/tripwire/plugins/celery_plugin.py index c2b0b0e..5bf54d8 100644 --- a/src/bigfoot/plugins/celery_plugin.py +++ b/src/tripwire/plugins/celery_plugin.py @@ -9,13 +9,13 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, ClassVar, cast -from bigfoot._base_plugin import BasePlugin -from bigfoot._context import get_verifier_or_raise -from bigfoot._errors import UnmockedInteractionError -from bigfoot._timeline import Interaction +from tripwire._base_plugin import BasePlugin +from tripwire._context import get_verifier_or_raise +from tripwire._errors import UnmockedInteractionError +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Optional dependency guard @@ -66,7 +66,7 @@ def _get_celery_plugin() -> CeleryPlugin: if isinstance(plugin, CeleryPlugin): return plugin raise RuntimeError( - "BUG: bigfoot CeleryPlugin interceptor is active but no " + "BUG: tripwire CeleryPlugin interceptor is active but no " "CeleryPlugin is registered on the current verifier." ) @@ -271,7 +271,7 @@ def install_patches(self) -> None: """Install Celery Task.delay and Task.apply_async patches.""" if not _CELERY_AVAILABLE: raise ImportError( - "Install bigfoot[celery] to use CeleryPlugin: pip install bigfoot[celery]" + "Install tripwire[celery] to use CeleryPlugin: pip install tripwire[celery]" ) from celery.app.task import Task @@ -323,7 +323,7 @@ def format_interaction(self, interaction: Interaction) -> str: def format_mock_hint(self, interaction: Interaction) -> str: task_name = interaction.details.get("task_name", "?") dispatch = interaction.details.get("dispatch_method", "?") - return f" bigfoot.celery_mock.mock_{dispatch}({task_name!r}, returns=...)" + return f" tripwire.celery_mock.mock_{dispatch}({task_name!r}, returns=...)" def format_unmocked_hint( self, @@ -338,11 +338,11 @@ def format_unmocked_hint( return ( f"celery.{dispatch}({task_name!r}, ...) was called but no mock was registered.\n" f"Register a mock with:\n" - f" bigfoot.celery_mock.mock_{dispatch}({task_name!r}, returns=...)" + f" tripwire.celery_mock.mock_{dispatch}({task_name!r}, returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: - sm = "bigfoot.celery_mock" + sm = "tripwire.celery_mock" dispatch = interaction.details.get("dispatch_method", "?") parts = [] for k, v in interaction.details.items(): @@ -372,7 +372,7 @@ def assert_delay( options: dict[str, Any], ) -> None: """Typed helper: assert the next delay interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = f"celery:{task_name}:delay" sentinel = _CelerySentinel(source_id) @@ -393,7 +393,7 @@ def assert_apply_async( options: dict[str, Any], ) -> None: """Typed helper: assert the next apply_async interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = f"celery:{task_name}:apply_async" sentinel = _CelerySentinel(source_id) diff --git a/src/bigfoot/plugins/crypto_plugin.py b/src/tripwire/plugins/crypto_plugin.py similarity index 94% rename from src/bigfoot/plugins/crypto_plugin.py rename to src/tripwire/plugins/crypto_plugin.py index 4eab3d4..c31ce65 100644 --- a/src/bigfoot/plugins/crypto_plugin.py +++ b/src/tripwire/plugins/crypto_plugin.py @@ -9,13 +9,13 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, ClassVar, cast -from bigfoot._base_plugin import BasePlugin -from bigfoot._context import get_verifier_or_raise -from bigfoot._errors import UnmockedInteractionError -from bigfoot._timeline import Interaction +from tripwire._base_plugin import BasePlugin +from tripwire._context import get_verifier_or_raise +from tripwire._errors import UnmockedInteractionError +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Optional dependency guard @@ -72,7 +72,7 @@ def _get_crypto_plugin() -> CryptoPlugin: if isinstance(plugin, CryptoPlugin): return plugin raise RuntimeError( - "BUG: bigfoot CryptoPlugin interceptor is active but no " + "BUG: tripwire CryptoPlugin interceptor is active but no " "CryptoPlugin is registered on the current verifier." ) @@ -283,7 +283,7 @@ def install_patches(self) -> None: """Install cryptography Fernet and RSA patches.""" if not _CRYPTOGRAPHY_AVAILABLE: raise ImportError( - "Install bigfoot[crypto] to use CryptoPlugin: pip install bigfoot[crypto]" + "Install tripwire[crypto] to use CryptoPlugin: pip install tripwire[crypto]" ) CryptoPlugin._original_encrypt = _Fernet.encrypt CryptoPlugin._original_decrypt = _Fernet.decrypt @@ -338,7 +338,7 @@ def format_mock_hint(self, interaction: Interaction) -> str: source_id = interaction.source_id operation = source_id.split(":", 1)[-1] if ":" in source_id else "?" mock_name = _OPERATION_MOCK_NAMES.get(operation, f"mock_{operation}") - return f" bigfoot.crypto_mock.{mock_name}(returns=...)" + return f" tripwire.crypto_mock.{mock_name}(returns=...)" def format_unmocked_hint( self, @@ -351,7 +351,7 @@ def format_unmocked_hint( return ( f"crypto.{operation}(...) was called but no mock was registered.\n" f"Register a mock with:\n" - f" bigfoot.crypto_mock.{mock_name}(returns=...)" + f" tripwire.crypto_mock.{mock_name}(returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: @@ -366,7 +366,7 @@ def format_assert_hint(self, interaction: Interaction) -> str: "generate_key": "assert_generate_key", }.get(operation, f"assert_{operation}") return ( - f" bigfoot.crypto_mock.{helper_name}(\n" + f" tripwire.crypto_mock.{helper_name}(\n" f"{lines}\n" f" )" ) @@ -386,7 +386,7 @@ def format_unused_mock_hint(self, mock_config: object) -> str: def assert_encrypt(self, *, plaintext_length: int, **extra: Any) -> None: # noqa: ANN401 """Assert the next Fernet.encrypt() interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 sentinel = _CryptoSentinel("fernet_encrypt") _get_test_verifier_or_raise().assert_interaction( @@ -395,7 +395,7 @@ def assert_encrypt(self, *, plaintext_length: int, **extra: Any) -> None: # noq def assert_decrypt(self, *, token: bytes | str, ttl: int | None = None, **extra: Any) -> None: # noqa: ANN401 """Assert the next Fernet.decrypt() interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 sentinel = _CryptoSentinel("fernet_decrypt") _get_test_verifier_or_raise().assert_interaction( @@ -404,7 +404,7 @@ def assert_decrypt(self, *, token: bytes | str, ttl: int | None = None, **extra: def assert_generate_key(self, *, algorithm: str, key_size: int, **extra: Any) -> None: # noqa: ANN401 """Assert the next rsa.generate_private_key() interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 sentinel = _CryptoSentinel("generate_key") _get_test_verifier_or_raise().assert_interaction( diff --git a/src/bigfoot/plugins/database_plugin.py b/src/tripwire/plugins/database_plugin.py similarity index 92% rename from src/bigfoot/plugins/database_plugin.py rename to src/tripwire/plugins/database_plugin.py index 3106b13..277baae 100644 --- a/src/bigfoot/plugins/database_plugin.py +++ b/src/tripwire/plugins/database_plugin.py @@ -4,13 +4,13 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any, ClassVar, cast -from bigfoot._context import GuardPassThrough, get_verifier_or_raise -from bigfoot._firewall_request import DatabaseFirewallRequest -from bigfoot._state_machine_plugin import SessionHandle, StateMachinePlugin, _StepSentinel -from bigfoot._timeline import Interaction +from tripwire._context import GuardPassThrough, get_verifier_or_raise +from tripwire._firewall_request import DatabaseFirewallRequest +from tripwire._state_machine_plugin import SessionHandle, StateMachinePlugin, _StepSentinel +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Source ID constants @@ -36,7 +36,7 @@ def _get_database_plugin( if isinstance(plugin, DatabasePlugin): return plugin raise RuntimeError( - "BUG: bigfoot DatabasePlugin interceptor is active but no " + "BUG: tripwire DatabasePlugin interceptor is active but no " "DatabasePlugin is registered on the current verifier." ) @@ -263,7 +263,7 @@ def format_interaction(self, interaction: Interaction) -> str: def format_mock_hint(self, interaction: Interaction) -> str: method = interaction.details.get("method", "?") - return f" bigfoot.db_mock.new_session().expect({method!r}, returns=...)" + return f" tripwire.db_mock.new_session().expect({method!r}, returns=...)" def format_unmocked_hint( self, @@ -275,11 +275,11 @@ def format_unmocked_hint( return ( f"sqlite3.{method}(...) was called but no session was queued.\n" f"Register a session with:\n" - f" bigfoot.db_mock.new_session().expect({method!r}, returns=...)" + f" tripwire.db_mock.new_session().expect({method!r}, returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: - sm = "bigfoot.db_mock" + sm = "tripwire.db_mock" sid = interaction.source_id if sid == _SOURCE_CONNECT: database = interaction.details.get("database", "?") @@ -319,31 +319,31 @@ def assertable_fields(self, interaction: Interaction) -> frozenset[str]: def assert_connect(self, *, database: str) -> None: """Assert the next database connect interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._connect_sentinel, database=database ) def assert_execute(self, *, sql: str, parameters: object) -> None: """Assert the next database execute interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._execute_sentinel, sql=sql, parameters=parameters ) def assert_commit(self) -> None: """Assert the next database commit interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._commit_sentinel) def assert_rollback(self) -> None: """Assert the next database rollback interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._rollback_sentinel) def assert_close(self) -> None: """Assert the next database close interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._close_sentinel) def format_unused_mock_hint(self, mock_config: object) -> str: diff --git a/src/bigfoot/plugins/dns_plugin.py b/src/tripwire/plugins/dns_plugin.py similarity index 94% rename from src/bigfoot/plugins/dns_plugin.py rename to src/tripwire/plugins/dns_plugin.py index 53af337..237462a 100644 --- a/src/bigfoot/plugins/dns_plugin.py +++ b/src/tripwire/plugins/dns_plugin.py @@ -10,14 +10,14 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, ClassVar, cast -from bigfoot._base_plugin import BasePlugin -from bigfoot._context import GuardPassThrough, get_verifier_or_raise -from bigfoot._errors import UnmockedInteractionError -from bigfoot._firewall_request import DnsFirewallRequest -from bigfoot._timeline import Interaction +from tripwire._base_plugin import BasePlugin +from tripwire._context import GuardPassThrough, get_verifier_or_raise +from tripwire._errors import UnmockedInteractionError +from tripwire._firewall_request import DnsFirewallRequest +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Optional dependency guard for dnspython @@ -423,13 +423,13 @@ def format_mock_hint(self, interaction: Interaction) -> str: hostname = parts[2] if len(parts) > 2 else "?" if operation == "getaddrinfo": - return f" bigfoot.dns_mock.mock_getaddrinfo({hostname!r}, returns=...)" + return f" tripwire.dns_mock.mock_getaddrinfo({hostname!r}, returns=...)" elif operation == "gethostbyname": - return f" bigfoot.dns_mock.mock_gethostbyname({hostname!r}, returns=...)" + return f" tripwire.dns_mock.mock_gethostbyname({hostname!r}, returns=...)" elif operation == "resolve": rdtype = interaction.details.get("rdtype", "A") - return f" bigfoot.dns_mock.mock_resolve({hostname!r}, {rdtype!r}, returns=...)" - return f" bigfoot.dns_mock.mock_{operation}({hostname!r}, returns=...)" + return f" tripwire.dns_mock.mock_resolve({hostname!r}, {rdtype!r}, returns=...)" + return f" tripwire.dns_mock.mock_{operation}({hostname!r}, returns=...)" def format_unmocked_hint( self, @@ -445,28 +445,28 @@ def format_unmocked_hint( return ( f"socket.getaddrinfo({hostname!r}, ...) was called but no mock was registered.\n" f"Register a mock with:\n" - f" bigfoot.dns_mock.mock_getaddrinfo({hostname!r}, returns=...)" + f" tripwire.dns_mock.mock_getaddrinfo({hostname!r}, returns=...)" ) elif operation == "gethostbyname": return ( f"socket.gethostbyname({hostname!r}) was called but no mock was registered.\n" f"Register a mock with:\n" - f" bigfoot.dns_mock.mock_gethostbyname({hostname!r}, returns=...)" + f" tripwire.dns_mock.mock_gethostbyname({hostname!r}, returns=...)" ) elif operation == "resolve": return ( f"dns.resolver.resolve({hostname!r}, ...) was called but no mock was registered.\n" f"Register a mock with:\n" - f" bigfoot.dns_mock.mock_resolve({hostname!r}, 'A', returns=...)" + f" tripwire.dns_mock.mock_resolve({hostname!r}, 'A', returns=...)" ) return ( f"dns.{operation}({hostname!r}) was called but no mock was registered.\n" f"Register a mock with:\n" - f" bigfoot.dns_mock.mock_{operation}({hostname!r}, returns=...)" + f" tripwire.dns_mock.mock_{operation}({hostname!r}, returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: - sm = "bigfoot.dns_mock" + sm = "tripwire.dns_mock" source_id = interaction.source_id parts = source_id.split(":", 2) operation = parts[1] if len(parts) > 1 else "?" @@ -527,7 +527,7 @@ def assert_getaddrinfo( proto: int, ) -> None: """Typed helper: assert the next getaddrinfo interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = f"dns:getaddrinfo:{host}" sentinel = _DnsSentinel(source_id) @@ -545,7 +545,7 @@ def assert_gethostbyname( hostname: str, ) -> None: """Typed helper: assert the next gethostbyname interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = f"dns:gethostbyname:{hostname}" sentinel = _DnsSentinel(source_id) @@ -560,7 +560,7 @@ def assert_resolve( rdtype: str, ) -> None: """Typed helper: assert the next resolve interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = f"dns:resolve:{qname}" sentinel = _DnsSentinel(source_id) diff --git a/src/bigfoot/plugins/elasticsearch_plugin.py b/src/tripwire/plugins/elasticsearch_plugin.py similarity index 93% rename from src/bigfoot/plugins/elasticsearch_plugin.py rename to src/tripwire/plugins/elasticsearch_plugin.py index d7c62b9..cd15d21 100644 --- a/src/bigfoot/plugins/elasticsearch_plugin.py +++ b/src/tripwire/plugins/elasticsearch_plugin.py @@ -9,15 +9,15 @@ from typing import TYPE_CHECKING, Any, ClassVar, cast from weakref import WeakKeyDictionary -from bigfoot._base_plugin import BasePlugin -from bigfoot._context import GuardPassThrough, get_verifier_or_raise -from bigfoot._errors import UnmockedInteractionError -from bigfoot._firewall_request import ElasticsearchFirewallRequest -from bigfoot._normalize import normalize_host -from bigfoot._timeline import Interaction +from tripwire._base_plugin import BasePlugin +from tripwire._context import GuardPassThrough, get_verifier_or_raise +from tripwire._errors import UnmockedInteractionError +from tripwire._firewall_request import ElasticsearchFirewallRequest +from tripwire._normalize import normalize_host +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Optional dependency guard @@ -222,8 +222,8 @@ def install_patches(self) -> None: """Install Elasticsearch method patches.""" if not _ELASTICSEARCH_AVAILABLE: raise ImportError( - "Install bigfoot[elasticsearch] to use ElasticsearchPlugin: " - "pip install bigfoot[elasticsearch]" + "Install tripwire[elasticsearch] to use ElasticsearchPlugin: " + "pip install tripwire[elasticsearch]" ) es_cls = es_lib.Elasticsearch @@ -323,7 +323,7 @@ def format_interaction(self, interaction: Interaction) -> str: def format_mock_hint(self, interaction: Interaction) -> str: source_id = interaction.source_id operation = source_id.split(":", 1)[-1] if ":" in source_id else "?" - return f" bigfoot.elasticsearch_mock.mock_operation({operation!r}, returns=...)" + return f" tripwire.elasticsearch_mock.mock_operation({operation!r}, returns=...)" def format_unmocked_hint( self, @@ -335,7 +335,7 @@ def format_unmocked_hint( return ( f"elasticsearch.{operation}(...) was called but no mock was registered.\n" f"Register a mock with:\n" - f" bigfoot.elasticsearch_mock.mock_operation({operation!r}, returns=...)" + f" tripwire.elasticsearch_mock.mock_operation({operation!r}, returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: @@ -345,7 +345,7 @@ def format_assert_hint(self, interaction: Interaction) -> str: parts = [f" {k}={v!r}," for k, v in details.items() if v is not None] lines = "\n".join(parts) return ( - f" bigfoot.elasticsearch_mock.assert_{operation}(\n" + f" tripwire.elasticsearch_mock.assert_{operation}(\n" f"{lines}\n" f" )" ) @@ -368,7 +368,7 @@ def assert_index( # noqa: A002 **extra: Any, # noqa: ANN401 ) -> None: """Assert the next index interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 sentinel = _ElasticsearchSentinel("index") kwargs: dict[str, Any] = {"index": index, "document": document} @@ -383,7 +383,7 @@ def assert_search( **extra: Any, # noqa: ANN401 ) -> None: """Assert the next search interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 sentinel = _ElasticsearchSentinel("search") kwargs: dict[str, Any] = {} @@ -400,7 +400,7 @@ def assert_search( def assert_get(self, *, index: str, id: str, **extra: Any) -> None: # noqa: A002, ANN401 """Assert the next get interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 sentinel = _ElasticsearchSentinel("get") kwargs: dict[str, Any] = {"index": index, "id": id} @@ -409,7 +409,7 @@ def assert_get(self, *, index: str, id: str, **extra: Any) -> None: # noqa: A00 def assert_delete(self, *, index: str, id: str, **extra: Any) -> None: # noqa: A002, ANN401 """Assert the next delete interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 sentinel = _ElasticsearchSentinel("delete") kwargs: dict[str, Any] = {"index": index, "id": id} @@ -418,7 +418,7 @@ def assert_delete(self, *, index: str, id: str, **extra: Any) -> None: # noqa: def assert_bulk(self, *, operations: Any, **extra: Any) -> None: # noqa: ANN401 """Assert the next bulk interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 sentinel = _ElasticsearchSentinel("bulk") kwargs: dict[str, Any] = {"operations": operations} diff --git a/src/bigfoot/plugins/file_io_plugin.py b/src/tripwire/plugins/file_io_plugin.py similarity index 95% rename from src/bigfoot/plugins/file_io_plugin.py rename to src/tripwire/plugins/file_io_plugin.py index 61acfcd..2176f09 100644 --- a/src/bigfoot/plugins/file_io_plugin.py +++ b/src/tripwire/plugins/file_io_plugin.py @@ -2,7 +2,7 @@ This plugin patches builtins.open, pathlib.Path read/write methods, os file operations, and shutil copy/remove operations. It uses a ContextVar-based -reentrancy guard to prevent self-interference with bigfoot's own file I/O. +reentrancy guard to prevent self-interference with tripwire's own file I/O. NOT default enabled: requires explicit enabled_plugins = ["file_io"] in config. """ @@ -22,13 +22,13 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, ClassVar, cast -from bigfoot._base_plugin import BasePlugin -from bigfoot._context import get_active_verifier -from bigfoot._errors import ConflictError, UnmockedInteractionError -from bigfoot._timeline import Interaction +from tripwire._base_plugin import BasePlugin +from tripwire._context import get_active_verifier +from tripwire._errors import ConflictError, UnmockedInteractionError +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Reentrancy guard @@ -708,7 +708,7 @@ def format_mock_hint(self, interaction: Interaction) -> str: source_id = interaction.source_id operation = source_id.split(":", 1)[-1] if ":" in source_id else source_id path = interaction.details.get("path", interaction.details.get("src", "?")) - return f" bigfoot.file_io_mock.mock_operation('{operation}', '{path}', returns=...)" + return f" tripwire.file_io_mock.mock_operation('{operation}', '{path}', returns=...)" def format_unmocked_hint( self, @@ -722,7 +722,7 @@ def format_unmocked_hint( return ( f"{display}('{path}', ...) was called but no mock was registered.\n" f"Register a mock with:\n" - f" bigfoot.file_io_mock.mock_operation('{operation}', '{path}', returns=...)" + f" tripwire.file_io_mock.mock_operation('{operation}', '{path}', returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: @@ -730,7 +730,7 @@ def format_assert_hint(self, interaction: Interaction) -> str: operation = source_id.split(":", 1)[-1] if ":" in source_id else source_id helper = _OP_ASSERT_HELPER.get(operation, f"assert_{operation}") - lines = [f" bigfoot.file_io_mock.{helper}("] + lines = [f" tripwire.file_io_mock.{helper}("] for key, val in interaction.details.items(): lines.append(f" {key}={val!r},") lines.append(" )") @@ -760,7 +760,7 @@ def assert_open( assertable_fields, but this helper accepts **kwargs so that the verifier can enforce completeness via MissingAssertionFieldsError. """ - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = "file_io:open" sentinel = _FileIoSentinel(source_id) @@ -773,7 +773,7 @@ def assert_open( def assert_read_text(self, path: str) -> None: """Typed helper: assert the next file_io:read_text interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = "file_io:read_text" sentinel = _FileIoSentinel(source_id) @@ -784,7 +784,7 @@ def assert_read_text(self, path: str) -> None: def assert_read_bytes(self, path: str) -> None: """Typed helper: assert the next file_io:read_bytes interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = "file_io:read_bytes" sentinel = _FileIoSentinel(source_id) @@ -795,7 +795,7 @@ def assert_read_bytes(self, path: str) -> None: def assert_write_text(self, path: str, data: str) -> None: """Typed helper: assert the next file_io:write_text interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = "file_io:write_text" sentinel = _FileIoSentinel(source_id) @@ -807,7 +807,7 @@ def assert_write_text(self, path: str, data: str) -> None: def assert_write_bytes(self, path: str, data: bytes) -> None: """Typed helper: assert the next file_io:write_bytes interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = "file_io:write_bytes" sentinel = _FileIoSentinel(source_id) @@ -819,7 +819,7 @@ def assert_write_bytes(self, path: str, data: bytes) -> None: def assert_remove(self, path: str) -> None: """Typed helper: assert the next file_io:remove or file_io:unlink interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 verifier = _get_test_verifier_or_raise() # Try both remove and unlink source_ids @@ -837,7 +837,7 @@ def assert_remove(self, path: str) -> None: def assert_rename(self, src: str, dst: str) -> None: """Typed helper: assert the next file_io:rename or file_io:replace interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 verifier = _get_test_verifier_or_raise() nsrc, ndst = os.path.normpath(src), os.path.normpath(dst) @@ -854,7 +854,7 @@ def assert_rename(self, src: str, dst: str) -> None: def assert_makedirs(self, path: str, exist_ok: bool) -> None: """Typed helper: assert the next file_io:makedirs interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 sentinel = _FileIoSentinel("file_io:makedirs") _get_test_verifier_or_raise().assert_interaction( @@ -863,7 +863,7 @@ def assert_makedirs(self, path: str, exist_ok: bool) -> None: def assert_mkdir(self, path: str) -> None: """Typed helper: assert the next file_io:mkdir interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 sentinel = _FileIoSentinel("file_io:mkdir") _get_test_verifier_or_raise().assert_interaction( @@ -872,7 +872,7 @@ def assert_mkdir(self, path: str) -> None: def assert_copy(self, src: str, dst: str) -> None: """Typed helper: assert the next file_io:copy or file_io:copy2 interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 verifier = _get_test_verifier_or_raise() nsrc, ndst = os.path.normpath(src), os.path.normpath(dst) @@ -887,7 +887,7 @@ def assert_copy(self, src: str, dst: str) -> None: def assert_copytree(self, src: str, dst: str) -> None: """Typed helper: assert the next file_io:copytree interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = "file_io:copytree" sentinel = _FileIoSentinel(source_id) @@ -899,7 +899,7 @@ def assert_copytree(self, src: str, dst: str) -> None: def assert_rmtree(self, path: str) -> None: """Typed helper: assert the next file_io:rmtree interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = "file_io:rmtree" sentinel = _FileIoSentinel(source_id) diff --git a/src/bigfoot/plugins/grpc_plugin.py b/src/tripwire/plugins/grpc_plugin.py similarity index 95% rename from src/bigfoot/plugins/grpc_plugin.py rename to src/tripwire/plugins/grpc_plugin.py index 9465355..280a027 100644 --- a/src/bigfoot/plugins/grpc_plugin.py +++ b/src/tripwire/plugins/grpc_plugin.py @@ -12,14 +12,14 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, ClassVar, cast -from bigfoot._base_plugin import BasePlugin -from bigfoot._context import GuardPassThrough, get_verifier_or_raise -from bigfoot._errors import UnmockedInteractionError -from bigfoot._firewall_request import GrpcFirewallRequest -from bigfoot._timeline import Interaction +from tripwire._base_plugin import BasePlugin +from tripwire._context import GuardPassThrough, get_verifier_or_raise +from tripwire._errors import UnmockedInteractionError +from tripwire._firewall_request import GrpcFirewallRequest +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Optional dependency guard @@ -73,7 +73,7 @@ def _get_grpc_plugin( if isinstance(plugin, GrpcPlugin): return plugin raise RuntimeError( - "BUG: bigfoot GrpcPlugin interceptor is active but no " + "BUG: tripwire GrpcPlugin interceptor is active but no " "GrpcPlugin is registered on the current verifier." ) @@ -259,7 +259,7 @@ def _parse_grpc_target(target: str) -> tuple[str, int]: def _patched_insecure_channel(target: str, *args: Any, **kwargs: Any) -> _FakeChannel: # noqa: ANN401 - from bigfoot._errors import SandboxNotActiveError # noqa: PLC0415 + from tripwire._errors import SandboxNotActiveError # noqa: PLC0415 _original = GrpcPlugin._original_insecure_channel assert _original is not None @@ -278,7 +278,7 @@ def _patched_insecure_channel(target: str, *args: Any, **kwargs: Any) -> _FakeCh def _patched_secure_channel( # noqa: ANN401 target: str, credentials: Any, *args: Any, **kwargs: Any, # noqa: ANN401 ) -> _FakeChannel: - from bigfoot._errors import SandboxNotActiveError # noqa: PLC0415 + from tripwire._errors import SandboxNotActiveError # noqa: PLC0415 _original = GrpcPlugin._original_secure_channel assert _original is not None @@ -396,7 +396,7 @@ def install_patches(self) -> None: """Install gRPC channel patches.""" if not _GRPC_AVAILABLE: raise ImportError( - "Install bigfoot[grpc] to use GrpcPlugin: pip install bigfoot[grpc]" + "Install tripwire[grpc] to use GrpcPlugin: pip install tripwire[grpc]" ) GrpcPlugin._original_insecure_channel = grpc_lib.insecure_channel GrpcPlugin._original_secure_channel = grpc_lib.secure_channel @@ -445,7 +445,7 @@ def format_interaction(self, interaction: Interaction) -> str: def format_mock_hint(self, interaction: Interaction) -> str: call_type = interaction.details.get("call_type", "?") method = interaction.details.get("method", "?") - return f" bigfoot.grpc_mock.mock_{call_type}({method!r}, returns=...)" + return f" tripwire.grpc_mock.mock_{call_type}({method!r}, returns=...)" def format_unmocked_hint( self, @@ -460,11 +460,11 @@ def format_unmocked_hint( return ( f"grpc.{call_type}({method!r}) was called but no mock was registered.\n" f"Register a mock with:\n" - f" bigfoot.grpc_mock.mock_{call_type}({method!r}, returns=...)" + f" tripwire.grpc_mock.mock_{call_type}({method!r}, returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: - sm = "bigfoot.grpc_mock" + sm = "tripwire.grpc_mock" call_type = interaction.details.get("call_type", "?") method = interaction.details.get("method", "?") request = interaction.details.get("request") @@ -502,7 +502,7 @@ def _assert_call( raised: Any = _ABSENT, # noqa: ANN401 ) -> None: """Common implementation for typed assertion helpers.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = f"grpc:{call_type}:{method}" sentinel = _GrpcSentinel(source_id) diff --git a/src/bigfoot/plugins/http.py b/src/tripwire/plugins/http.py similarity index 94% rename from src/bigfoot/plugins/http.py rename to src/tripwire/plugins/http.py index ece0f00..0492ff7 100644 --- a/src/bigfoot/plugins/http.py +++ b/src/tripwire/plugins/http.py @@ -17,7 +17,8 @@ import requests.adapters except ImportError as exc: # pragma: no cover raise ImportError( - "bigfoot[http] extra is required to use HttpPlugin. Install with: pip install bigfoot[http]" + "tripwire[http] extra is required to use HttpPlugin. " + "Install with: pip install tripwire[http]" ) from exc try: @@ -28,15 +29,15 @@ except ImportError: # pragma: no cover _AIOHTTP_AVAILABLE = False -from bigfoot._base_plugin import BasePlugin -from bigfoot._context import GuardPassThrough, get_verifier_or_raise -from bigfoot._errors import ConflictError, UnmockedInteractionError -from bigfoot._firewall_request import HttpFirewallRequest -from bigfoot._normalize import normalize_url -from bigfoot._timeline import Interaction +from tripwire._base_plugin import BasePlugin +from tripwire._context import GuardPassThrough, get_verifier_or_raise +from tripwire._errors import ConflictError, UnmockedInteractionError +from tripwire._firewall_request import HttpFirewallRequest +from tripwire._normalize import normalize_url +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Import-time constants — captured BEFORE any patches are installed. @@ -53,14 +54,14 @@ # --------------------------------------------------------------------------- # Module-level references to our own interceptors. -# Set during _install_patches so _check_conflicts can distinguish bigfoot +# Set during _install_patches so _check_conflicts can distinguish tripwire # patches from foreign patches during nested sandbox activations. # --------------------------------------------------------------------------- -_bigfoot_httpx_handle: Callable[..., Any] | None = None -_bigfoot_httpx_async_handle: Callable[..., Any] | None = None -_bigfoot_requests_send: Callable[..., Any] | None = None -_bigfoot_aiohttp_request: Callable[..., Any] | None = None +_tripwire_httpx_handle: Callable[..., Any] | None = None +_tripwire_httpx_async_handle: Callable[..., Any] | None = None +_tripwire_requests_send: Callable[..., Any] | None = None +_tripwire_aiohttp_request: Callable[..., Any] | None = None # Sentinel: distinguishes "parameter not passed" from None in assert_request(). @@ -189,7 +190,7 @@ def _find_http_plugin(verifier: "StrictVerifier") -> "HttpPlugin": if isinstance(plugin, HttpPlugin): return plugin raise RuntimeError( - "BUG: bigfoot HttpPlugin interceptor is active but no HttpPlugin " + "BUG: tripwire HttpPlugin interceptor is active but no HttpPlugin " "is registered on the current verifier." ) @@ -284,7 +285,7 @@ def _identify_patcher(method: object) -> str: class HttpPlugin(BasePlugin): - """HTTP interception plugin. Requires bigfoot[http] extra. + """HTTP interception plugin. Requires tripwire[http] extra. Patches httpx sync/async transports, requests HTTPAdapter, urllib openers, and aiohttp ClientSession (if installed) at the class level. Uses reference @@ -306,16 +307,16 @@ def __init__(self, verifier: "StrictVerifier", require_response: bool = True) -> self._asserting_request_only: bool = False self._require_response: bool = require_response self.load_config( - self.verifier._bigfoot_config.get(self.config_key() or "", {}) + self.verifier._tripwire_config.get(self.config_key() or "", {}) ) @classmethod def config_key(cls) -> str | None: - """Return 'http', mapping this plugin to [tool.bigfoot.http].""" + """Return 'http', mapping this plugin to [tool.tripwire.http].""" return "http" def load_config(self, config: dict[str, Any]) -> None: - """Apply [tool.bigfoot.http] configuration. + """Apply [tool.tripwire.http] configuration. Recognized keys: require_response (bool): When True, assert_request() returns an @@ -329,7 +330,7 @@ def load_config(self, config: dict[str, Any]) -> None: val = config["require_response"] if not isinstance(val, bool): raise TypeError( - f"[tool.bigfoot.http] require_response must be a bool, " + f"[tool.tripwire.http] require_response must be a bool, " f"got {type(val).__name__}" ) self._require_response = val @@ -506,7 +507,7 @@ def check_conflicts(self) -> None: current_httpx_sync = httpx.HTTPTransport.handle_request if ( current_httpx_sync is not _HTTPX_ORIGINAL_HANDLE - and current_httpx_sync is not _bigfoot_httpx_handle + and current_httpx_sync is not _tripwire_httpx_handle ): patcher = _identify_patcher(current_httpx_sync) raise ConflictError( @@ -517,7 +518,7 @@ def check_conflicts(self) -> None: current_httpx_async = httpx.AsyncHTTPTransport.handle_async_request if ( current_httpx_async is not _HTTPX_ORIGINAL_ASYNC_HANDLE - and current_httpx_async is not _bigfoot_httpx_async_handle + and current_httpx_async is not _tripwire_httpx_async_handle ): patcher = _identify_patcher(current_httpx_async) raise ConflictError( @@ -528,7 +529,7 @@ def check_conflicts(self) -> None: current_requests = requests.adapters.HTTPAdapter.send if ( current_requests is not _REQUESTS_ORIGINAL_SEND - and current_requests is not _bigfoot_requests_send + and current_requests is not _tripwire_requests_send ): patcher = _identify_patcher(current_requests) raise ConflictError( @@ -540,7 +541,7 @@ def check_conflicts(self) -> None: current_aiohttp = aiohttp.ClientSession._request if ( current_aiohttp is not _AIOHTTP_ORIGINAL_REQUEST - and current_aiohttp is not _bigfoot_aiohttp_request + and current_aiohttp is not _tripwire_aiohttp_request ): patcher = _identify_patcher(current_aiohttp) raise ConflictError( @@ -553,8 +554,8 @@ def check_conflicts(self) -> None: # ------------------------------------------------------------------ def install_patches(self) -> None: - global _bigfoot_httpx_handle, _bigfoot_httpx_async_handle, _bigfoot_requests_send - global _bigfoot_aiohttp_request + global _tripwire_httpx_handle, _tripwire_httpx_async_handle, _tripwire_requests_send + global _tripwire_aiohttp_request # Save originals so we can restore them later. HttpPlugin._original_httpx_transport_handle = httpx.HTTPTransport.handle_request @@ -623,9 +624,9 @@ def _requests_interceptor( plugin = _find_http_plugin(verifier) return plugin._handle_requests_request(adapter_self, request, **kwargs) - _bigfoot_httpx_handle = _sync_interceptor - _bigfoot_httpx_async_handle = _async_interceptor - _bigfoot_requests_send = _requests_interceptor + _tripwire_httpx_handle = _sync_interceptor + _tripwire_httpx_async_handle = _async_interceptor + _tripwire_requests_send = _requests_interceptor setattr(httpx.HTTPTransport, "handle_request", _sync_interceptor) setattr(httpx.AsyncHTTPTransport, "handle_async_request", _async_interceptor) @@ -635,8 +636,8 @@ def _requests_interceptor( self._install_aiohttp() def restore_patches(self) -> None: - global _bigfoot_httpx_handle, _bigfoot_httpx_async_handle, _bigfoot_requests_send - global _bigfoot_aiohttp_request + global _tripwire_httpx_handle, _tripwire_httpx_async_handle, _tripwire_requests_send + global _tripwire_aiohttp_request if HttpPlugin._original_httpx_transport_handle is not None: setattr( @@ -668,24 +669,24 @@ def restore_patches(self) -> None: setattr(aiohttp.ClientSession, "_request", HttpPlugin._original_aiohttp_request) HttpPlugin._original_aiohttp_request = None - _bigfoot_httpx_handle = None - _bigfoot_httpx_async_handle = None - _bigfoot_requests_send = None - _bigfoot_aiohttp_request = None + _tripwire_httpx_handle = None + _tripwire_httpx_async_handle = None + _tripwire_requests_send = None + _tripwire_aiohttp_request = None def _install_urllib(self) -> None: HttpPlugin._original_urllib_opener = getattr(urllib.request, "_opener", None) - class _BigfootHandler(urllib.request.BaseHandler): + class _TripwireHandler(urllib.request.BaseHandler): handler_order = 100 def http_open(self, req: urllib.request.Request) -> urllib.response.addinfourl: - return _bigfoot_urllib_dispatch(req) + return _tripwire_urllib_dispatch(req) def https_open(self, req: urllib.request.Request) -> urllib.response.addinfourl: - return _bigfoot_urllib_dispatch(req) + return _tripwire_urllib_dispatch(req) - def _bigfoot_urllib_dispatch( + def _tripwire_urllib_dispatch( req: urllib.request.Request, ) -> urllib.response.addinfourl: url = req.full_url @@ -706,11 +707,11 @@ def _bigfoot_urllib_dispatch( plugin = _find_http_plugin(verifier) return plugin._handle_urllib_request(req) - opener = urllib.request.build_opener(_BigfootHandler) + opener = urllib.request.build_opener(_TripwireHandler) urllib.request.install_opener(opener) def _install_aiohttp(self) -> None: - global _bigfoot_aiohttp_request + global _tripwire_aiohttp_request if not _AIOHTTP_AVAILABLE: return @@ -738,7 +739,7 @@ async def _aiohttp_interceptor( plugin = _find_http_plugin(verifier) return await plugin._handle_aiohttp_request(session_self, method, str_or_url, **kwargs) - _bigfoot_aiohttp_request = _aiohttp_interceptor + _tripwire_aiohttp_request = _aiohttp_interceptor setattr(aiohttp.ClientSession, "_request", _aiohttp_interceptor) # ------------------------------------------------------------------ @@ -1137,13 +1138,13 @@ def _execute_urllib_pass_through( ) -> urllib.response.addinfourl: """Forward a urllib request to the real backend and record the interaction.""" original_opener = HttpPlugin._original_urllib_opener - # Restore original opener temporarily, make the real request, then reinstall bigfoot's + # Restore original opener temporarily, make the real request, then reinstall tripwire's urllib.request.install_opener(original_opener) try: response: urllib.response.addinfourl = urllib.request.urlopen(req) finally: - # Reinstall bigfoot's opener regardless of outcome - from bigfoot.plugins.http import HttpPlugin as _Self # noqa: PLC0415 + # Reinstall tripwire's opener regardless of outcome + from tripwire.plugins.http import HttpPlugin as _Self # noqa: PLC0415 _Self._reinstall_urllib_opener() method = (req.get_method() or "GET").upper() @@ -1287,21 +1288,21 @@ async def _execute_aiohttp_pass_through( @classmethod def _reinstall_urllib_opener(cls) -> None: - """Reinstall bigfoot's urllib opener after a pass-through call.""" + """Reinstall tripwire's urllib opener after a pass-through call.""" # Build a fresh handler using the same dispatch function used in _install_urllib # We call _install_urllib again but only the opener part. # This is safe because _original_urllib_opener is still set at the class level. - class _BigfootHandler(urllib.request.BaseHandler): + class _TripwireHandler(urllib.request.BaseHandler): handler_order = 100 def http_open(self, req: urllib.request.Request) -> urllib.response.addinfourl: - return _bigfoot_urllib_dispatch_ref(req) + return _tripwire_urllib_dispatch_ref(req) def https_open(self, req: urllib.request.Request) -> urllib.response.addinfourl: - return _bigfoot_urllib_dispatch_ref(req) + return _tripwire_urllib_dispatch_ref(req) - def _bigfoot_urllib_dispatch_ref( + def _tripwire_urllib_dispatch_ref( req: urllib.request.Request, ) -> urllib.response.addinfourl: url = req.full_url @@ -1322,7 +1323,7 @@ def _bigfoot_urllib_dispatch_ref( plugin = _find_http_plugin(verifier) return plugin._handle_urllib_request(req) - opener = urllib.request.build_opener(_BigfootHandler) + opener = urllib.request.build_opener(_TripwireHandler) urllib.request.install_opener(opener) # ------------------------------------------------------------------ diff --git a/src/bigfoot/plugins/jwt_plugin.py b/src/tripwire/plugins/jwt_plugin.py similarity index 93% rename from src/bigfoot/plugins/jwt_plugin.py rename to src/tripwire/plugins/jwt_plugin.py index ee0cd61..03d491d 100644 --- a/src/bigfoot/plugins/jwt_plugin.py +++ b/src/tripwire/plugins/jwt_plugin.py @@ -9,13 +9,13 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, ClassVar, cast -from bigfoot._base_plugin import BasePlugin -from bigfoot._context import get_verifier_or_raise -from bigfoot._errors import UnmockedInteractionError -from bigfoot._timeline import Interaction +from tripwire._base_plugin import BasePlugin +from tripwire._context import get_verifier_or_raise +from tripwire._errors import UnmockedInteractionError +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Optional dependency guard @@ -64,7 +64,7 @@ def _get_jwt_plugin() -> JwtPlugin: if isinstance(plugin, JwtPlugin): return plugin raise RuntimeError( - "BUG: bigfoot JwtPlugin interceptor is active but no " + "BUG: tripwire JwtPlugin interceptor is active but no " "JwtPlugin is registered on the current verifier." ) @@ -230,7 +230,7 @@ def install_patches(self) -> None: """Install jwt.encode and jwt.decode patches.""" if not _JWT_AVAILABLE: raise ImportError( - "Install bigfoot[jwt] to use JwtPlugin: pip install bigfoot[jwt]" + "Install tripwire[jwt] to use JwtPlugin: pip install tripwire[jwt]" ) JwtPlugin._original_encode = jwt_lib.encode JwtPlugin._original_decode = jwt_lib.decode @@ -279,7 +279,7 @@ def format_interaction(self, interaction: Interaction) -> str: def format_mock_hint(self, interaction: Interaction) -> str: source_id = interaction.source_id operation = source_id.split(":", 1)[-1] if ":" in source_id else "?" - return f" bigfoot.jwt_mock.mock_{operation}(returns=...)" + return f" tripwire.jwt_mock.mock_{operation}(returns=...)" def format_unmocked_hint( self, @@ -291,7 +291,7 @@ def format_unmocked_hint( return ( f"jwt.{operation}(...) was called but no mock was registered.\n" f"Register a mock with:\n" - f" bigfoot.jwt_mock.mock_{operation}(returns=...)" + f" tripwire.jwt_mock.mock_{operation}(returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: @@ -301,7 +301,7 @@ def format_assert_hint(self, interaction: Interaction) -> str: parts = [f" {k}={v!r}," for k, v in details.items()] lines = "\n".join(parts) return ( - f" bigfoot.jwt_mock.assert_{operation}(\n" + f" tripwire.jwt_mock.assert_{operation}(\n" f"{lines}\n" f" )" ) @@ -325,7 +325,7 @@ def assert_encode( **extra: Any, # noqa: ANN401 ) -> None: """Assert the next jwt.encode() interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 sentinel = _JwtSentinel("encode") actual_extra_kwargs = extra_kwargs if extra_kwargs is not None else {} @@ -339,7 +339,7 @@ def assert_decode( # noqa: ANN401 options: Any = None, **extra: Any, # noqa: ANN401 ) -> None: """Assert the next jwt.decode() interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 sentinel = _JwtSentinel("decode") _get_test_verifier_or_raise().assert_interaction( diff --git a/src/bigfoot/plugins/logging_plugin.py b/src/tripwire/plugins/logging_plugin.py similarity index 94% rename from src/bigfoot/plugins/logging_plugin.py rename to src/tripwire/plugins/logging_plugin.py index b9b3fab..e2cb333 100644 --- a/src/bigfoot/plugins/logging_plugin.py +++ b/src/tripwire/plugins/logging_plugin.py @@ -7,12 +7,12 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, ClassVar -from bigfoot._base_plugin import BasePlugin -from bigfoot._context import get_verifier_or_raise -from bigfoot._timeline import Interaction +from tripwire._base_plugin import BasePlugin +from tripwire._context import get_verifier_or_raise +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Source ID constant @@ -29,11 +29,11 @@ # --------------------------------------------------------------------------- # Module-level reference to our own interceptor. -# Set during _install_patches so _check_conflicts can distinguish bigfoot +# Set during _install_patches so _check_conflicts can distinguish tripwire # patches from foreign patches during nested sandbox activations. # --------------------------------------------------------------------------- -_bigfoot_logger_log: Callable[..., Any] | None = None +_tripwire_logger_log: Callable[..., Any] | None = None # --------------------------------------------------------------------------- @@ -86,7 +86,7 @@ def _find_logging_plugin(verifier: "StrictVerifier") -> "LoggingPlugin": if isinstance(plugin, LoggingPlugin): return plugin raise RuntimeError( - "BUG: bigfoot LoggingPlugin interceptor is active but no " + "BUG: tripwire LoggingPlugin interceptor is active but no " "LoggingPlugin is registered on the current verifier." ) @@ -214,12 +214,12 @@ def assert_critical(self, message: str, logger_name: str) -> None: def check_conflicts(self) -> None: """Verify logging.Logger._log has not been patched by a third party.""" - from bigfoot._errors import ConflictError + from tripwire._errors import ConflictError current_log = logging.Logger._log if ( current_log is not _LOGGER_LOG_ORIGINAL - and current_log is not _bigfoot_logger_log + and current_log is not _tripwire_logger_log ): patcher = _identify_logging_patcher(current_log) raise ConflictError( @@ -232,7 +232,7 @@ def check_conflicts(self) -> None: # ------------------------------------------------------------------ def install_patches(self) -> None: - global _bigfoot_logger_log + global _tripwire_logger_log LoggingPlugin._original_logger_log = logging.Logger._log @@ -247,18 +247,18 @@ def _log_interceptor( plugin = _find_logging_plugin(verifier) plugin._handle_log(logger_self, level, msg, args) - _bigfoot_logger_log = _log_interceptor + _tripwire_logger_log = _log_interceptor setattr(logging.Logger, "_log", _log_interceptor) def restore_patches(self) -> None: - global _bigfoot_logger_log + global _tripwire_logger_log if LoggingPlugin._original_logger_log is not None: setattr(logging.Logger, "_log", LoggingPlugin._original_logger_log) LoggingPlugin._original_logger_log = None - _bigfoot_logger_log = None + _tripwire_logger_log = None # ------------------------------------------------------------------ # Request handler @@ -339,7 +339,7 @@ def format_mock_hint(self, interaction: Interaction) -> str: message = interaction.details.get("message", "") logger_name = interaction.details.get("logger_name", "root") return ( - f" bigfoot.log_mock.mock_log(" + f" tripwire.log_mock.mock_log(" f"{level!r}, {message!r}, logger_name={logger_name!r})" ) @@ -356,11 +356,11 @@ def format_unmocked_hint( return ( f"logging.{level.lower()}({message!r}) was called.\n" f"Register it with:\n" - f" bigfoot.log_mock.mock_log({level!r}, {message!r})" + f" tripwire.log_mock.mock_log({level!r}, {message!r})" ) def format_assert_hint(self, interaction: "Interaction") -> str: - lm = "bigfoot.log_mock" + lm = "tripwire.log_mock" level = interaction.details.get("level", "INFO") message = interaction.details.get("message", "") logger_name = interaction.details.get("logger_name", "root") diff --git a/src/bigfoot/plugins/mcp_plugin.py b/src/tripwire/plugins/mcp_plugin.py similarity index 97% rename from src/bigfoot/plugins/mcp_plugin.py rename to src/tripwire/plugins/mcp_plugin.py index 7e9c7a4..da0702a 100644 --- a/src/bigfoot/plugins/mcp_plugin.py +++ b/src/tripwire/plugins/mcp_plugin.py @@ -9,14 +9,14 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, ClassVar, cast -from bigfoot._base_plugin import BasePlugin -from bigfoot._context import GuardPassThrough, get_verifier_or_raise -from bigfoot._errors import UnmockedInteractionError -from bigfoot._firewall_request import McpFirewallRequest -from bigfoot._timeline import Interaction +from tripwire._base_plugin import BasePlugin +from tripwire._context import GuardPassThrough, get_verifier_or_raise +from tripwire._errors import UnmockedInteractionError +from tripwire._firewall_request import McpFirewallRequest +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Optional dependency guard @@ -513,7 +513,7 @@ def install_patches(self) -> None: """Install MCP client/server patches.""" if not _MCP_AVAILABLE: raise ImportError( - "Install bigfoot[mcp] to use McpPlugin: pip install bigfoot[mcp]" + "Install tripwire[mcp] to use McpPlugin: pip install tripwire[mcp]" ) McpPlugin._original_call_tool = _ClientSession.call_tool McpPlugin._original_read_resource = _ClientSession.read_resource @@ -584,7 +584,7 @@ def format_interaction(self, interaction: Interaction) -> str: def format_mock_hint(self, interaction: Interaction) -> str: direction = interaction.details.get("direction", "?") method = interaction.details.get("method", "?") - prefix = "bigfoot.mcp_mock" + prefix = "tripwire.mcp_mock" if direction == "server": if method == "call_tool": tool_name = interaction.details.get("tool_name", "?") @@ -618,7 +618,7 @@ def format_unmocked_hint( direction = parts[1] if len(parts) > 1 else "?" method = parts[2] if len(parts) > 2 else "?" key = parts[3] if len(parts) > 3 else "?" - prefix = "bigfoot.mcp_mock" + prefix = "tripwire.mcp_mock" if direction == "server": mock_fn = f"mock_server_{method}" @@ -632,7 +632,7 @@ def format_unmocked_hint( ) def format_assert_hint(self, interaction: Interaction) -> str: - sm = "bigfoot.mcp_mock" + sm = "tripwire.mcp_mock" direction = interaction.details.get("direction", "?") method = interaction.details.get("method", "?") @@ -692,7 +692,7 @@ def assert_call_tool( raised: Any = _ABSENT, # noqa: ANN401 ) -> None: """Assert the next call_tool interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = f"mcp:{direction}:call_tool:{tool_name}" sentinel = _McpSentinel(source_id) @@ -714,7 +714,7 @@ def assert_read_resource( raised: Any = _ABSENT, # noqa: ANN401 ) -> None: """Assert the next read_resource interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = f"mcp:{direction}:read_resource:{uri}" sentinel = _McpSentinel(source_id) @@ -736,7 +736,7 @@ def assert_get_prompt( raised: Any = _ABSENT, # noqa: ANN401 ) -> None: """Assert the next get_prompt interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = f"mcp:{direction}:get_prompt:{prompt_name}" sentinel = _McpSentinel(source_id) diff --git a/src/bigfoot/plugins/memcache_plugin.py b/src/tripwire/plugins/memcache_plugin.py similarity index 93% rename from src/bigfoot/plugins/memcache_plugin.py rename to src/tripwire/plugins/memcache_plugin.py index af5defc..ab8ccc9 100644 --- a/src/bigfoot/plugins/memcache_plugin.py +++ b/src/tripwire/plugins/memcache_plugin.py @@ -12,15 +12,15 @@ from typing import TYPE_CHECKING, Any, ClassVar, cast from weakref import WeakKeyDictionary -from bigfoot._base_plugin import BasePlugin -from bigfoot._context import GuardPassThrough, get_verifier_or_raise -from bigfoot._errors import UnmockedInteractionError -from bigfoot._firewall_request import MemcacheFirewallRequest -from bigfoot._normalize import normalize_host -from bigfoot._timeline import Interaction +from tripwire._base_plugin import BasePlugin +from tripwire._context import GuardPassThrough, get_verifier_or_raise +from tripwire._errors import UnmockedInteractionError +from tripwire._firewall_request import MemcacheFirewallRequest +from tripwire._normalize import normalize_host +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Optional dependency guard @@ -231,7 +231,8 @@ def install_patches(self) -> None: """Install pymemcache Client method patches.""" if not _PYMEMCACHE_AVAILABLE: raise ImportError( - "Install bigfoot[pymemcache] to use MemcachePlugin: pip install bigfoot[pymemcache]" + "Install tripwire[pymemcache] to use MemcachePlugin: " + "pip install tripwire[pymemcache]" ) from pymemcache.client.base import Client @@ -296,7 +297,7 @@ def format_interaction(self, interaction: Interaction) -> str: def format_mock_hint(self, interaction: Interaction) -> str: command = interaction.details.get("command", "?") - return f" bigfoot.memcache_mock.mock_command({command!r}, returns=...)" + return f" tripwire.memcache_mock.mock_command({command!r}, returns=...)" def format_unmocked_hint( self, @@ -308,11 +309,11 @@ def format_unmocked_hint( return ( f"memcache.{cmd}(...) was called but no mock was registered.\n" f"Register a mock with:\n" - f" bigfoot.memcache_mock.mock_command({cmd!r}, returns=...)" + f" tripwire.memcache_mock.mock_command({cmd!r}, returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: - sm = "bigfoot.memcache_mock" + sm = "tripwire.memcache_mock" command = interaction.details.get("command", "?") # Determine which helper to suggest helper = f"assert_{command.lower()}" @@ -345,7 +346,7 @@ def format_unused_mock_hint(self, mock_config: object) -> str: def assert_get(self, command: str, key: str) -> None: """Typed helper: assert the next memcache GET interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = f"memcache:{command.lower()}" sentinel = _MemcacheSentinel(source_id) @@ -363,7 +364,7 @@ def assert_set( expire: int = 0, ) -> None: """Typed helper: assert the next memcache SET/ADD/REPLACE interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = f"memcache:{command.lower()}" sentinel = _MemcacheSentinel(source_id) @@ -377,7 +378,7 @@ def assert_set( def assert_delete(self, command: str, key: str) -> None: """Typed helper: assert the next memcache DELETE interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = f"memcache:{command.lower()}" sentinel = _MemcacheSentinel(source_id) @@ -389,7 +390,7 @@ def assert_delete(self, command: str, key: str) -> None: def assert_incr(self, command: str, key: str, value: int = 1) -> None: """Typed helper: assert the next memcache INCR/DECR interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = f"memcache:{command.lower()}" sentinel = _MemcacheSentinel(source_id) diff --git a/src/bigfoot/plugins/mongo_plugin.py b/src/tripwire/plugins/mongo_plugin.py similarity index 96% rename from src/bigfoot/plugins/mongo_plugin.py rename to src/tripwire/plugins/mongo_plugin.py index 0a6a26e..5d07a67 100644 --- a/src/bigfoot/plugins/mongo_plugin.py +++ b/src/tripwire/plugins/mongo_plugin.py @@ -9,15 +9,15 @@ from typing import TYPE_CHECKING, Any, ClassVar, cast from weakref import WeakKeyDictionary -from bigfoot._base_plugin import BasePlugin -from bigfoot._context import GuardPassThrough, get_verifier_or_raise -from bigfoot._errors import UnmockedInteractionError -from bigfoot._firewall_request import MongoFirewallRequest -from bigfoot._normalize import normalize_host -from bigfoot._timeline import Interaction +from tripwire._base_plugin import BasePlugin +from tripwire._context import GuardPassThrough, get_verifier_or_raise +from tripwire._errors import UnmockedInteractionError +from tripwire._firewall_request import MongoFirewallRequest +from tripwire._normalize import normalize_host +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Optional dependency guard @@ -307,7 +307,7 @@ def install_patches(self) -> None: """Install pymongo Collection method patches.""" if not _PYMONGO_AVAILABLE: raise ImportError( - "Install bigfoot[mongo] to use MongoPlugin: pip install bigfoot[mongo]" + "Install tripwire[mongo] to use MongoPlugin: pip install tripwire[mongo]" ) # Patch MongoClient.__init__ to capture connection metadata @@ -401,7 +401,7 @@ def format_interaction(self, interaction: Interaction) -> str: def format_mock_hint(self, interaction: Interaction) -> str: operation = interaction.details.get("operation", "?") - return f" bigfoot.mongo_mock.mock_operation({operation!r}, returns=...)" + return f" tripwire.mongo_mock.mock_operation({operation!r}, returns=...)" def format_unmocked_hint( self, @@ -413,11 +413,11 @@ def format_unmocked_hint( return ( f"mongo.{op}(...) was called but no mock was registered.\n" f"Register a mock with:\n" - f" bigfoot.mongo_mock.mock_operation({op!r}, returns=...)" + f" tripwire.mongo_mock.mock_operation({op!r}, returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: - sm = "bigfoot.mongo_mock" + sm = "tripwire.mongo_mock" operation = interaction.details.get("operation", "?") helper_name = _ASSERT_HELPER_NAMES.get(operation, f"assert_{operation}") @@ -452,7 +452,7 @@ def _assert_operation( **expected_fields: Any, # noqa: ANN401 ) -> None: """Common implementation for typed assertion helpers.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = f"mongo:{operation}" sentinel = _MongoSentinel(source_id) diff --git a/src/bigfoot/plugins/native_plugin.py b/src/tripwire/plugins/native_plugin.py similarity index 96% rename from src/bigfoot/plugins/native_plugin.py rename to src/tripwire/plugins/native_plugin.py index c3d37fd..8191c0b 100644 --- a/src/bigfoot/plugins/native_plugin.py +++ b/src/tripwire/plugins/native_plugin.py @@ -10,13 +10,13 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, ClassVar, cast -from bigfoot._base_plugin import BasePlugin -from bigfoot._context import get_verifier_or_raise -from bigfoot._errors import ConflictError, UnmockedInteractionError -from bigfoot._timeline import Interaction +from tripwire._base_plugin import BasePlugin +from tripwire._context import get_verifier_or_raise +from tripwire._errors import ConflictError, UnmockedInteractionError +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Optional dependency guard @@ -72,7 +72,7 @@ def _get_native_plugin() -> NativePlugin: if isinstance(plugin, NativePlugin): return plugin raise RuntimeError( - "BUG: bigfoot NativePlugin interceptor is active but no " + "BUG: tripwire NativePlugin interceptor is active but no " "NativePlugin is registered on the current verifier." ) @@ -376,7 +376,7 @@ def format_interaction(self, interaction: Interaction) -> str: def format_mock_hint(self, interaction: Interaction) -> str: library = interaction.details.get("library", "?") function = interaction.details.get("function", "?") - return f" bigfoot.native_mock.mock_call({library!r}, {function!r}, returns=...)" + return f" tripwire.native_mock.mock_call({library!r}, {function!r}, returns=...)" def format_unmocked_hint( self, @@ -391,11 +391,11 @@ def format_unmocked_hint( return ( f"{library}.{function}(...) was called but no mock was registered.\n" f"Register a mock with:\n" - f" bigfoot.native_mock.mock_call({library!r}, {function!r}, returns=...)" + f" tripwire.native_mock.mock_call({library!r}, {function!r}, returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: - sm = "bigfoot.native_mock" + sm = "tripwire.native_mock" library = interaction.details.get("library", "?") function = interaction.details.get("function", "?") args = interaction.details.get("args", ()) @@ -433,7 +433,7 @@ def assert_call( Wraps assert_interaction() for ergonomic use. All three fields (library, function, args) are required. """ - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 source_id = f"native:{library}:{function}" sentinel = _NativeSentinel(source_id) diff --git a/src/bigfoot/plugins/pika_plugin.py b/src/tripwire/plugins/pika_plugin.py similarity index 93% rename from src/bigfoot/plugins/pika_plugin.py rename to src/tripwire/plugins/pika_plugin.py index 29b432e..ad71eaa 100644 --- a/src/bigfoot/plugins/pika_plugin.py +++ b/src/tripwire/plugins/pika_plugin.py @@ -4,13 +4,13 @@ from typing import TYPE_CHECKING, Any, ClassVar -from bigfoot._context import GuardPassThrough, get_verifier_or_raise -from bigfoot._firewall_request import PikaFirewallRequest -from bigfoot._state_machine_plugin import StateMachinePlugin, _StepSentinel -from bigfoot._timeline import Interaction +from tripwire._context import GuardPassThrough, get_verifier_or_raise +from tripwire._firewall_request import PikaFirewallRequest +from tripwire._state_machine_plugin import StateMachinePlugin, _StepSentinel +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Optional dependency guard @@ -54,7 +54,7 @@ def _find_pika_plugin( if isinstance(plugin, PikaPlugin): return plugin raise RuntimeError( - "BUG: bigfoot PikaPlugin interceptor is active but no " + "BUG: tripwire PikaPlugin interceptor is active but no " "PikaPlugin is registered on the current verifier." ) @@ -342,7 +342,7 @@ def format_interaction(self, interaction: Interaction) -> str: def format_mock_hint(self, interaction: Interaction) -> str: sid = interaction.source_id method = sid.split(":")[-1] if ":" in sid else sid - return f" bigfoot.pika_mock.new_session().expect({method!r}, returns=...)" + return f" tripwire.pika_mock.new_session().expect({method!r}, returns=...)" def format_unmocked_hint( self, @@ -354,11 +354,11 @@ def format_unmocked_hint( return ( f"pika.BlockingConnection.{method}(...) was called but no session was queued.\n" f"Register a session with:\n" - f" bigfoot.pika_mock.new_session().expect({method!r}, returns=...)" + f" tripwire.pika_mock.new_session().expect({method!r}, returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: - sm = "bigfoot.pika_mock" + sm = "tripwire.pika_mock" sid = interaction.source_id if sid == _SOURCE_CONNECT: host = interaction.details.get("host", "?") @@ -419,44 +419,44 @@ def assertable_fields(self, interaction: Interaction) -> frozenset[str]: # ------------------------------------------------------------------ def assert_connect(self, *, host: str, port: int, virtual_host: str) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._connect_sentinel, host=host, port=port, virtual_host=virtual_host ) def assert_channel(self) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._channel_sentinel) def assert_publish( self, *, exchange: str, routing_key: str, body: Any, properties: Any = None, # noqa: ANN401 ) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._publish_sentinel, exchange=exchange, routing_key=routing_key, body=body, properties=properties, ) def assert_consume(self, *, queue: str, auto_ack: bool) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._consume_sentinel, queue=queue, auto_ack=auto_ack ) def assert_ack(self, *, delivery_tag: int) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._ack_sentinel, delivery_tag=delivery_tag ) def assert_nack(self, *, delivery_tag: int, requeue: bool) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._nack_sentinel, delivery_tag=delivery_tag, requeue=requeue ) def assert_close(self) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._close_sentinel) def format_unused_mock_hint(self, mock_config: object) -> str: diff --git a/src/bigfoot/plugins/popen_plugin.py b/src/tripwire/plugins/popen_plugin.py similarity index 90% rename from src/bigfoot/plugins/popen_plugin.py rename to src/tripwire/plugins/popen_plugin.py index 9e1860a..6899b33 100644 --- a/src/bigfoot/plugins/popen_plugin.py +++ b/src/tripwire/plugins/popen_plugin.py @@ -12,14 +12,14 @@ import subprocess from typing import TYPE_CHECKING, Any, ClassVar -from bigfoot._context import GuardPassThrough, get_verifier_or_raise -from bigfoot._errors import ConflictError -from bigfoot._firewall_request import SubprocessFirewallRequest -from bigfoot._state_machine_plugin import StateMachinePlugin, _StepSentinel -from bigfoot._timeline import Interaction +from tripwire._context import GuardPassThrough, get_verifier_or_raise +from tripwire._errors import ConflictError +from tripwire._firewall_request import SubprocessFirewallRequest +from tripwire._state_machine_plugin import StateMachinePlugin, _StepSentinel +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Source ID constants @@ -37,11 +37,11 @@ # --------------------------------------------------------------------------- # Module-level references to our own interceptor class. -# Set during _install_patches so _check_conflicts can distinguish bigfoot's +# Set during _install_patches so _check_conflicts can distinguish tripwire's # _FakePopen from foreign patchers during nested sandbox activations. # --------------------------------------------------------------------------- -_bigfoot_popen_class: Any = None +_tripwire_popen_class: Any = None # --------------------------------------------------------------------------- @@ -57,7 +57,7 @@ def _find_popen_plugin( return next(p for p in verifier._plugins if isinstance(p, PopenPlugin)) except StopIteration: raise RuntimeError( - "BUG: bigfoot PopenPlugin interceptor is active but no " + "BUG: tripwire PopenPlugin interceptor is active but no " "PopenPlugin is registered on the current verifier." ) from None @@ -68,7 +68,7 @@ def _find_popen_plugin( class _FakeStream: - """Fake file-like stream. Stream I/O is not recorded by bigfoot. + """Fake file-like stream. Stream I/O is not recorded by tripwire. .write() returns 0 (no bytes written). .read() returns b"" (no data). Use communicate() to observe stdin input and stdout/stderr output via @@ -237,20 +237,20 @@ def _unmocked_source_id(self) -> str: def install_patches(self) -> None: """Install subprocess.Popen patch.""" - global _bigfoot_popen_class + global _tripwire_popen_class PopenPlugin._original_popen = subprocess.Popen - _bigfoot_popen_class = _FakePopen + _tripwire_popen_class = _FakePopen setattr(subprocess, "Popen", _FakePopen) def restore_patches(self) -> None: """Restore original subprocess.Popen.""" - global _bigfoot_popen_class + global _tripwire_popen_class if PopenPlugin._original_popen is not None: setattr(subprocess, "Popen", PopenPlugin._original_popen) PopenPlugin._original_popen = None - _bigfoot_popen_class = None + _tripwire_popen_class = None # ------------------------------------------------------------------ # Conflict detection @@ -287,15 +287,15 @@ def format_interaction(self, interaction: Interaction) -> str: def format_mock_hint(self, interaction: Interaction) -> str: if interaction.source_id == _SOURCE_SPAWN: - return " bigfoot.popen_mock.new_session().expect('spawn', returns=None)" + return " tripwire.popen_mock.new_session().expect('spawn', returns=None)" if interaction.source_id == _SOURCE_COMMUNICATE: return ( - " bigfoot.popen_mock.new_session()" + " tripwire.popen_mock.new_session()" ".expect('communicate', returns=(b'', b'', 0))" ) if interaction.source_id == _SOURCE_WAIT: - return " bigfoot.popen_mock.new_session().expect('wait', returns=0)" - return " bigfoot.popen_mock.new_session().expect('?', returns=...)" + return " tripwire.popen_mock.new_session().expect('wait', returns=0)" + return " tripwire.popen_mock.new_session().expect('?', returns=...)" def format_unmocked_hint( self, @@ -307,11 +307,11 @@ def format_unmocked_hint( return ( f"subprocess.Popen.{method}(...) was called but no session was queued.\n" f"Register a session with:\n" - f" bigfoot.popen_mock.new_session().expect({method!r}, returns=...)" + f" tripwire.popen_mock.new_session().expect({method!r}, returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: - pm = "bigfoot.popen_mock" + pm = "tripwire.popen_mock" sid = interaction.source_id if sid == _SOURCE_SPAWN: command = interaction.details.get("command", []) @@ -342,19 +342,19 @@ def assertable_fields(self, interaction: Interaction) -> frozenset[str]: return frozenset(interaction.details.keys()) def assert_spawn(self, *, command: list[str], stdin: bytes | None) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._spawn_sentinel, command=command, stdin=stdin ) def assert_communicate(self, *, input: bytes | None) -> None: # noqa: A002 - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._communicate_sentinel, input=input ) def assert_wait(self) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._wait_sentinel) def format_unused_mock_hint(self, mock_config: object) -> str: diff --git a/src/bigfoot/plugins/psycopg2_plugin.py b/src/tripwire/plugins/psycopg2_plugin.py similarity index 93% rename from src/bigfoot/plugins/psycopg2_plugin.py rename to src/tripwire/plugins/psycopg2_plugin.py index 6d72a90..2b10ede 100644 --- a/src/bigfoot/plugins/psycopg2_plugin.py +++ b/src/tripwire/plugins/psycopg2_plugin.py @@ -3,13 +3,13 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any, ClassVar, cast -from bigfoot._context import GuardPassThrough, get_verifier_or_raise -from bigfoot._firewall_request import PostgresFirewallRequest -from bigfoot._state_machine_plugin import SessionHandle, StateMachinePlugin, _StepSentinel -from bigfoot._timeline import Interaction +from tripwire._context import GuardPassThrough, get_verifier_or_raise +from tripwire._firewall_request import PostgresFirewallRequest +from tripwire._state_machine_plugin import SessionHandle, StateMachinePlugin, _StepSentinel +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Optional dependency guard @@ -46,7 +46,7 @@ def _get_psycopg2_plugin( if isinstance(plugin, Psycopg2Plugin): return plugin raise RuntimeError( - "BUG: bigfoot Psycopg2Plugin interceptor is active but no " + "BUG: tripwire Psycopg2Plugin interceptor is active but no " "Psycopg2Plugin is registered on the current verifier." ) @@ -291,7 +291,7 @@ def format_interaction(self, interaction: Interaction) -> str: def format_mock_hint(self, interaction: Interaction) -> str: method = interaction.details.get("method", "?") - return f" bigfoot.psycopg2_mock.new_session().expect({method!r}, returns=...)" + return f" tripwire.psycopg2_mock.new_session().expect({method!r}, returns=...)" def format_unmocked_hint( self, @@ -303,11 +303,11 @@ def format_unmocked_hint( return ( f"psycopg2.{method}(...) was called but no session was queued.\n" f"Register a session with:\n" - f" bigfoot.psycopg2_mock.new_session().expect({method!r}, returns=...)" + f" tripwire.psycopg2_mock.new_session().expect({method!r}, returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: - sm = "bigfoot.psycopg2_mock" + sm = "tripwire.psycopg2_mock" sid = interaction.source_id if sid == _SOURCE_CONNECT: # Show whichever connect fields were recorded @@ -354,31 +354,31 @@ def assert_connect(self, **kwargs: object) -> None: Pass whichever connection fields were used: dsn, host, port, dbname, user. """ - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._connect_sentinel, **kwargs ) def assert_execute(self, *, sql: str, parameters: object) -> None: """Assert the next psycopg2 execute interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._execute_sentinel, sql=sql, parameters=parameters ) def assert_commit(self) -> None: """Assert the next psycopg2 commit interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._commit_sentinel) def assert_rollback(self) -> None: """Assert the next psycopg2 rollback interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._rollback_sentinel) def assert_close(self) -> None: """Assert the next psycopg2 close interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._close_sentinel) def format_unused_mock_hint(self, mock_config: object) -> str: diff --git a/src/bigfoot/plugins/redis_plugin.py b/src/tripwire/plugins/redis_plugin.py similarity index 94% rename from src/bigfoot/plugins/redis_plugin.py rename to src/tripwire/plugins/redis_plugin.py index 9c0778b..39b773c 100644 --- a/src/bigfoot/plugins/redis_plugin.py +++ b/src/tripwire/plugins/redis_plugin.py @@ -10,15 +10,15 @@ from typing import TYPE_CHECKING, Any, ClassVar, cast from weakref import WeakKeyDictionary -from bigfoot._base_plugin import BasePlugin -from bigfoot._context import GuardPassThrough, get_verifier_or_raise -from bigfoot._errors import UnmockedInteractionError -from bigfoot._firewall_request import RedisFirewallRequest -from bigfoot._normalize import normalize_host -from bigfoot._timeline import Interaction +from tripwire._base_plugin import BasePlugin +from tripwire._context import GuardPassThrough, get_verifier_or_raise +from tripwire._errors import UnmockedInteractionError +from tripwire._firewall_request import RedisFirewallRequest +from tripwire._normalize import normalize_host +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Optional dependency guard @@ -200,7 +200,7 @@ def install_patches(self) -> None: """Install Redis.execute_command patch.""" if not _REDIS_AVAILABLE: raise ImportError( - "Install bigfoot[redis] to use RedisPlugin: pip install bigfoot[redis]" + "Install tripwire[redis] to use RedisPlugin: pip install tripwire[redis]" ) # Patch __init__ to capture connection metadata if RedisPlugin._original_init is None: @@ -265,7 +265,7 @@ def format_interaction(self, interaction: Interaction) -> str: def format_mock_hint(self, interaction: Interaction) -> str: command = interaction.details.get("command", "?") - return f" bigfoot.redis_mock.mock_command({command!r}, returns=...)" + return f" tripwire.redis_mock.mock_command({command!r}, returns=...)" def format_unmocked_hint( self, @@ -278,11 +278,11 @@ def format_unmocked_hint( return ( f"redis.{cmd}(...) was called but no mock was registered.\n" f"Register a mock with:\n" - f" bigfoot.redis_mock.mock_command({cmd!r}, returns=...)" + f" tripwire.redis_mock.mock_command({cmd!r}, returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: - sm = "bigfoot.redis_mock" + sm = "tripwire.redis_mock" command = interaction.details.get("command", "?") args = interaction.details.get("args", ()) kwargs = interaction.details.get("kwargs", {}) @@ -314,7 +314,7 @@ def assert_command( Wraps assert_interaction() for ergonomic use. All three fields (command, args, kwargs) are required. """ - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 kw = kwargs if kwargs is not None else {} cmd_upper = command.upper() diff --git a/src/bigfoot/plugins/smtp_plugin.py b/src/tripwire/plugins/smtp_plugin.py similarity index 92% rename from src/bigfoot/plugins/smtp_plugin.py rename to src/tripwire/plugins/smtp_plugin.py index 18fb939..a5c77da 100644 --- a/src/bigfoot/plugins/smtp_plugin.py +++ b/src/tripwire/plugins/smtp_plugin.py @@ -3,13 +3,13 @@ import smtplib from typing import TYPE_CHECKING, Any, ClassVar, cast -from bigfoot._context import GuardPassThrough, get_verifier_or_raise -from bigfoot._firewall_request import SmtpFirewallRequest -from bigfoot._state_machine_plugin import StateMachinePlugin, _StepSentinel -from bigfoot._timeline import Interaction +from tripwire._context import GuardPassThrough, get_verifier_or_raise +from tripwire._firewall_request import SmtpFirewallRequest +from tripwire._state_machine_plugin import StateMachinePlugin, _StepSentinel +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Import-time constant -- captured BEFORE any patches are installed. @@ -43,7 +43,7 @@ def _find_smtp_plugin( if isinstance(plugin, SmtpPlugin): return plugin raise RuntimeError( - "BUG: bigfoot SmtpPlugin interceptor is active but no " + "BUG: tripwire SmtpPlugin interceptor is active but no " "SmtpPlugin is registered on the current verifier." ) @@ -311,7 +311,7 @@ def format_interaction(self, interaction: Interaction) -> str: def format_mock_hint(self, interaction: Interaction) -> str: sid = interaction.source_id method = sid.split(":")[-1] if ":" in sid else sid - return f" bigfoot.smtp_mock.new_session().expect({method!r}, returns=...)" + return f" tripwire.smtp_mock.new_session().expect({method!r}, returns=...)" def format_unmocked_hint( self, @@ -323,11 +323,11 @@ def format_unmocked_hint( return ( f"smtplib.SMTP.{method}(...) was called but no session was queued.\n" f"Register a session with:\n" - f" bigfoot.smtp_mock.new_session().expect({method!r}, returns=...)" + f" tripwire.smtp_mock.new_session().expect({method!r}, returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: - sm = "bigfoot.smtp_mock" + sm = "tripwire.smtp_mock" sid = interaction.source_id if sid == _SOURCE_CONNECT: host = interaction.details.get("host", "?") @@ -379,41 +379,41 @@ def assertable_fields(self, interaction: Interaction) -> frozenset[str]: return frozenset(interaction.details.keys()) def assert_connect(self, *, host: str, port: int) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._connect_sentinel, host=host, port=port ) def assert_ehlo(self, *, name: str) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._ehlo_sentinel, name=name) def assert_helo(self, *, name: str) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._helo_sentinel, name=name) def assert_starttls(self) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._starttls_sentinel) def assert_login(self, *, user: str, password: str) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._login_sentinel, user=user, password=password ) def assert_sendmail(self, *, from_addr: str, to_addrs: Any, msg: Any) -> None: # noqa: ANN401 - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._sendmail_sentinel, from_addr=from_addr, to_addrs=to_addrs, msg=msg ) def assert_send_message(self, *, msg: Any) -> None: # noqa: ANN401 - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._send_message_sentinel, msg=msg) def assert_quit(self) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._quit_sentinel) def format_unused_mock_hint(self, mock_config: object) -> str: diff --git a/src/bigfoot/plugins/socket_plugin.py b/src/tripwire/plugins/socket_plugin.py similarity index 94% rename from src/bigfoot/plugins/socket_plugin.py rename to src/tripwire/plugins/socket_plugin.py index 56d6e9e..a9a67fe 100644 --- a/src/bigfoot/plugins/socket_plugin.py +++ b/src/tripwire/plugins/socket_plugin.py @@ -4,13 +4,13 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any, ClassVar, cast -from bigfoot._context import GuardPassThrough, get_active_verifier, get_verifier_or_raise -from bigfoot._firewall_request import SocketFirewallRequest -from bigfoot._state_machine_plugin import StateMachinePlugin, _StepSentinel -from bigfoot._timeline import Interaction +from tripwire._context import GuardPassThrough, get_active_verifier, get_verifier_or_raise +from tripwire._firewall_request import SocketFirewallRequest +from tripwire._state_machine_plugin import StateMachinePlugin, _StepSentinel +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Source ID constants @@ -281,7 +281,7 @@ def format_interaction(self, interaction: Interaction) -> str: def format_mock_hint(self, interaction: Interaction) -> str: sid = interaction.source_id method = sid.split(":", 1)[-1] if ":" in sid else sid - return f" bigfoot.socket_mock.new_session().expect({method!r}, returns=...)" + return f" tripwire.socket_mock.new_session().expect({method!r}, returns=...)" def format_unmocked_hint( self, @@ -293,11 +293,11 @@ def format_unmocked_hint( return ( f"socket.socket.{method}(...) was called but no session was queued.\n" f"Register a session with:\n" - f" bigfoot.socket_mock.new_session().expect({method!r}, returns=...)" + f" tripwire.socket_mock.new_session().expect({method!r}, returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: - sm = "bigfoot.socket_mock" + sm = "tripwire.socket_mock" sid = interaction.source_id if sid == _SOURCE_CONNECT: host = interaction.details.get("host", "?") @@ -344,35 +344,35 @@ def assertable_fields(self, interaction: Interaction) -> frozenset[str]: def assert_connect(self, *, host: str, port: int) -> None: """Assert the next socket connect interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._connect_sentinel, host=host, port=port ) def assert_send(self, *, data: bytes) -> None: """Assert the next socket send interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._send_sentinel, data=data ) def assert_sendall(self, *, data: bytes) -> None: """Assert the next socket sendall interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._sendall_sentinel, data=data ) def assert_recv(self, *, size: int, data: bytes) -> None: """Assert the next socket recv interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._recv_sentinel, size=size, data=data ) def assert_close(self) -> None: """Assert the next socket close interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._close_sentinel) def format_unused_mock_hint(self, mock_config: object) -> str: diff --git a/src/bigfoot/plugins/ssh_plugin.py b/src/tripwire/plugins/ssh_plugin.py similarity index 93% rename from src/bigfoot/plugins/ssh_plugin.py rename to src/tripwire/plugins/ssh_plugin.py index 646f35f..640574c 100644 --- a/src/bigfoot/plugins/ssh_plugin.py +++ b/src/tripwire/plugins/ssh_plugin.py @@ -4,13 +4,13 @@ from typing import TYPE_CHECKING, Any, ClassVar -from bigfoot._context import GuardPassThrough, get_verifier_or_raise -from bigfoot._firewall_request import SshFirewallRequest -from bigfoot._state_machine_plugin import StateMachinePlugin, _StepSentinel -from bigfoot._timeline import Interaction +from tripwire._context import GuardPassThrough, get_verifier_or_raise +from tripwire._firewall_request import SshFirewallRequest +from tripwire._state_machine_plugin import StateMachinePlugin, _StepSentinel +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Optional dependency guard @@ -57,7 +57,7 @@ def _find_ssh_plugin( if isinstance(plugin, SshPlugin): return plugin raise RuntimeError( - "BUG: bigfoot SshPlugin interceptor is active but no " + "BUG: tripwire SshPlugin interceptor is active but no " "SshPlugin is registered on the current verifier." ) @@ -412,7 +412,7 @@ def format_interaction(self, interaction: Interaction) -> str: def format_mock_hint(self, interaction: Interaction) -> str: sid = interaction.source_id method = sid.split(":")[-1] if ":" in sid else sid - return f" bigfoot.ssh_mock.new_session().expect({method!r}, returns=...)" + return f" tripwire.ssh_mock.new_session().expect({method!r}, returns=...)" def format_unmocked_hint( self, @@ -424,11 +424,11 @@ def format_unmocked_hint( return ( f"paramiko.SSHClient.{method}(...) was called but no session was queued.\n" f"Register a session with:\n" - f" bigfoot.ssh_mock.new_session().expect({method!r}, returns=...)" + f" tripwire.ssh_mock.new_session().expect({method!r}, returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: - sm = "bigfoot.ssh_mock" + sm = "tripwire.ssh_mock" sid = interaction.source_id if sid == _SOURCE_CONNECT: hostname = interaction.details.get("hostname", "?") @@ -501,60 +501,60 @@ def assertable_fields(self, interaction: Interaction) -> frozenset[str]: def assert_connect( self, *, hostname: str, port: int, username: str | None, auth_method: str, ) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._connect_sentinel, hostname=hostname, port=port, username=username, auth_method=auth_method, ) def assert_exec_command(self, *, command: str) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._exec_command_sentinel, command=command ) def assert_open_sftp(self) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._open_sftp_sentinel) def assert_sftp_get(self, *, remotepath: str, localpath: str) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._sftp_get_sentinel, remotepath=remotepath, localpath=localpath ) def assert_sftp_put(self, *, localpath: str, remotepath: str) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._sftp_put_sentinel, localpath=localpath, remotepath=remotepath ) def assert_sftp_listdir(self, *, path: str) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._sftp_listdir_sentinel, path=path ) def assert_sftp_stat(self, *, path: str) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._sftp_stat_sentinel, path=path ) def assert_sftp_mkdir(self, *, path: str) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._sftp_mkdir_sentinel, path=path ) def assert_sftp_remove(self, *, path: str) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction( self._sftp_remove_sentinel, path=path ) def assert_close(self) -> None: - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._close_sentinel) def format_unused_mock_hint(self, mock_config: object) -> str: diff --git a/src/bigfoot/plugins/subprocess.py b/src/tripwire/plugins/subprocess.py similarity index 93% rename from src/bigfoot/plugins/subprocess.py rename to src/tripwire/plugins/subprocess.py index 2c3fd8f..a2c3ce5 100644 --- a/src/bigfoot/plugins/subprocess.py +++ b/src/tripwire/plugins/subprocess.py @@ -8,14 +8,14 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, ClassVar, cast -from bigfoot._base_plugin import BasePlugin -from bigfoot._context import GuardPassThrough, get_verifier_or_raise -from bigfoot._errors import ConflictError, UnmockedInteractionError -from bigfoot._firewall_request import SubprocessFirewallRequest -from bigfoot._timeline import Interaction +from tripwire._base_plugin import BasePlugin +from tripwire._context import GuardPassThrough, get_verifier_or_raise +from tripwire._errors import ConflictError, UnmockedInteractionError +from tripwire._firewall_request import SubprocessFirewallRequest +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Source ID constants @@ -34,12 +34,12 @@ # --------------------------------------------------------------------------- # Module-level references to our own interceptors. -# Set during _install_patches so _check_conflicts can distinguish bigfoot +# Set during _install_patches so _check_conflicts can distinguish tripwire # patches from foreign patches during nested sandbox activations. # --------------------------------------------------------------------------- -_bigfoot_subprocess_run: Any = None -_bigfoot_shutil_which: Any = None +_tripwire_subprocess_run: Any = None +_tripwire_shutil_which: Any = None # --------------------------------------------------------------------------- @@ -107,7 +107,7 @@ def _find_subprocess_plugin(verifier: "StrictVerifier") -> "SubprocessPlugin": return next(p for p in verifier._plugins if isinstance(p, SubprocessPlugin)) except StopIteration: raise RuntimeError( - "BUG: bigfoot SubprocessPlugin interceptor is active but no " + "BUG: tripwire SubprocessPlugin interceptor is active but no " "SubprocessPlugin is registered on the current verifier." ) from None @@ -263,7 +263,7 @@ def check_conflicts(self) -> None: current_run = subprocess.run if ( current_run is not _SUBPROCESS_RUN_ORIGINAL - and current_run is not _bigfoot_subprocess_run + and current_run is not _tripwire_subprocess_run ): patcher = _identify_subprocess_patcher(current_run) raise ConflictError( @@ -274,7 +274,7 @@ def check_conflicts(self) -> None: current_which = shutil.which if ( current_which is not _SHUTIL_WHICH_ORIGINAL - and current_which is not _bigfoot_shutil_which + and current_which is not _tripwire_shutil_which ): patcher = _identify_subprocess_patcher(current_which) raise ConflictError( @@ -287,7 +287,7 @@ def check_conflicts(self) -> None: # ------------------------------------------------------------------ def install_patches(self) -> None: - global _bigfoot_subprocess_run, _bigfoot_shutil_which + global _tripwire_subprocess_run, _tripwire_shutil_which SubprocessPlugin._original_subprocess_run = subprocess.run SubprocessPlugin._original_shutil_which = shutil.which @@ -320,14 +320,14 @@ def _which_interceptor(name: str, **kwargs: Any) -> str | None: # noqa: ANN401 plugin = _find_subprocess_plugin(verifier) return plugin._handle_which(name, **kwargs) - _bigfoot_subprocess_run = _run_interceptor - _bigfoot_shutil_which = _which_interceptor + _tripwire_subprocess_run = _run_interceptor + _tripwire_shutil_which = _which_interceptor subprocess.run = _run_interceptor setattr(shutil, "which", _which_interceptor) def restore_patches(self) -> None: - global _bigfoot_subprocess_run, _bigfoot_shutil_which + global _tripwire_subprocess_run, _tripwire_shutil_which if SubprocessPlugin._original_subprocess_run is not None: subprocess.run = SubprocessPlugin._original_subprocess_run @@ -337,8 +337,8 @@ def restore_patches(self) -> None: shutil.which = SubprocessPlugin._original_shutil_which SubprocessPlugin._original_shutil_which = None - _bigfoot_subprocess_run = None - _bigfoot_shutil_which = None + _tripwire_subprocess_run = None + _tripwire_shutil_which = None # ------------------------------------------------------------------ # Request handlers @@ -354,7 +354,7 @@ def _handle_run(self, *args: Any, **kwargs: Any) -> "subprocess.CompletedProcess if isinstance(cmd, str): raise TypeError( f"subprocess.run() was called with a string command {cmd!r}. " - f"bigfoot requires a list of arguments, e.g. {[cmd]!r}. " + f"tripwire requires a list of arguments, e.g. {[cmd]!r}. " f"Pass a list to avoid ambiguity between shell and non-shell invocations." ) cmd_list = list(cmd) @@ -469,7 +469,7 @@ def format_mock_hint(self, interaction: Interaction) -> str: rc = interaction.details.get("returncode", 0) stdout = interaction.details.get("stdout", "") stderr = interaction.details.get("stderr", "") - parts = [f" bigfoot.subprocess_mock.mock_run({cmd!r}"] + parts = [f" tripwire.subprocess_mock.mock_run({cmd!r}"] if rc != 0: parts.append(f", returncode={rc!r}") if stdout: @@ -481,7 +481,7 @@ def format_mock_hint(self, interaction: Interaction) -> str: if interaction.source_id == _SOURCE_WHICH: name = interaction.details.get("name", "?") returns = interaction.details.get("returns") - return f" bigfoot.subprocess_mock.mock_which({name!r}, returns={returns!r})" + return f" tripwire.subprocess_mock.mock_which({name!r}, returns={returns!r})" return f" # unknown source_id={interaction.source_id!r}" def format_unmocked_hint( @@ -495,19 +495,19 @@ def format_unmocked_hint( return ( f"subprocess.run({list(cmd)!r}) was called but no mock was registered.\n" f"Register it with:\n" - f" bigfoot.subprocess_mock.mock_run({list(cmd)!r})" + f" tripwire.subprocess_mock.mock_run({list(cmd)!r})" ) if source_id == _SOURCE_WHICH: name = args[0] if args else kwargs.get("name", "?") return ( f"shutil.which({name!r}) was called but no mock was registered.\n" f"Register it with:\n" - f" bigfoot.subprocess_mock.mock_which({name!r}, returns='/path/to/{name}')" + f" tripwire.subprocess_mock.mock_which({name!r}, returns='/path/to/{name}')" ) return f"Unmocked call to source_id={source_id!r}" def format_assert_hint(self, interaction: "Interaction") -> str: - sm = "bigfoot.subprocess_mock" + sm = "tripwire.subprocess_mock" if interaction.source_id == _SOURCE_RUN: cmd = interaction.details.get("command", []) rc = interaction.details.get("returncode", 0) diff --git a/src/bigfoot/plugins/websocket_plugin.py b/src/tripwire/plugins/websocket_plugin.py similarity index 92% rename from src/bigfoot/plugins/websocket_plugin.py rename to src/tripwire/plugins/websocket_plugin.py index 4baeb22..732a0df 100644 --- a/src/bigfoot/plugins/websocket_plugin.py +++ b/src/tripwire/plugins/websocket_plugin.py @@ -5,15 +5,15 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any, ClassVar, cast -from bigfoot._context import GuardPassThrough, get_verifier_or_raise -from bigfoot._errors import UnmockedInteractionError -from bigfoot._firewall_request import WebSocketFirewallRequest -from bigfoot._normalize import normalize_url -from bigfoot._state_machine_plugin import SessionHandle, StateMachinePlugin, _StepSentinel -from bigfoot._timeline import Interaction +from tripwire._context import GuardPassThrough, get_verifier_or_raise +from tripwire._errors import UnmockedInteractionError +from tripwire._firewall_request import WebSocketFirewallRequest +from tripwire._normalize import normalize_url +from tripwire._state_machine_plugin import SessionHandle, StateMachinePlugin, _StepSentinel +from tripwire._timeline import Interaction if TYPE_CHECKING: - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- @@ -63,7 +63,7 @@ def _get_async_websocket_plugin( if isinstance(plugin, AsyncWebSocketPlugin): return plugin raise RuntimeError( - "BUG: bigfoot AsyncWebSocketPlugin interceptor is active but no " + "BUG: tripwire AsyncWebSocketPlugin interceptor is active but no " "AsyncWebSocketPlugin is registered on the current verifier." ) @@ -76,7 +76,7 @@ def _get_sync_websocket_plugin( if isinstance(plugin, SyncWebSocketPlugin): return plugin raise RuntimeError( - "BUG: bigfoot SyncWebSocketPlugin interceptor is active but no " + "BUG: tripwire SyncWebSocketPlugin interceptor is active but no " "SyncWebSocketPlugin is registered on the current verifier." ) @@ -225,8 +225,8 @@ def _unmocked_source_id(self) -> str: def install_patches(self) -> None: if not _WEBSOCKETS_AVAILABLE: raise ImportError( - "Install bigfoot[websockets] to use AsyncWebSocketPlugin: " - "pip install bigfoot[websockets]" + "Install tripwire[websockets] to use AsyncWebSocketPlugin: " + "pip install tripwire[websockets]" ) import websockets as _ws @@ -286,7 +286,7 @@ def format_interaction(self, interaction: Interaction) -> str: def format_mock_hint(self, interaction: Interaction) -> str: sid = interaction.source_id step = sid.split(":")[-1] if ":" in sid else sid - return f" bigfoot.async_websocket_mock.new_session().expect({step!r}, returns=...)" + return f" tripwire.async_websocket_mock.new_session().expect({step!r}, returns=...)" def format_unmocked_hint( self, @@ -298,11 +298,11 @@ def format_unmocked_hint( return ( f"websockets.{method}(...) was called but no session was queued.\n" f"Register a session with:\n" - f" bigfoot.async_websocket_mock.new_session().expect({method!r}, returns=...)" + f" tripwire.async_websocket_mock.new_session().expect({method!r}, returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: - sm = "bigfoot.async_websocket_mock" + sm = "tripwire.async_websocket_mock" sid = interaction.source_id if sid == _ASYNC_SOURCE_CONNECT: uri = interaction.details.get("uri", "?") @@ -336,25 +336,25 @@ def assertable_fields(self, interaction: Interaction) -> frozenset[str]: def assert_connect(self, *, uri: str) -> None: """Assert the next async websocket connect interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._connect_sentinel, uri=uri) def assert_send(self, *, message: Any) -> None: # noqa: ANN401 """Assert the next async websocket send interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._send_sentinel, message=message) def assert_recv(self, *, message: Any) -> None: # noqa: ANN401 """Assert the next async websocket recv interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._recv_sentinel, message=message) def assert_close(self) -> None: """Assert the next async websocket close interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._close_sentinel) @@ -473,8 +473,8 @@ def _unmocked_source_id(self) -> str: def install_patches(self) -> None: if not _WEBSOCKET_CLIENT_AVAILABLE: raise ImportError( - "Install bigfoot[websocket-client] to use SyncWebSocketPlugin: " - "pip install bigfoot[websocket-client]" + "Install tripwire[websocket-client] to use SyncWebSocketPlugin: " + "pip install tripwire[websocket-client]" ) import websocket as _wsc @@ -542,7 +542,7 @@ def format_interaction(self, interaction: Interaction) -> str: def format_mock_hint(self, interaction: Interaction) -> str: sid = interaction.source_id step = sid.split(":")[-1] if ":" in sid else sid - return f" bigfoot.sync_websocket_mock.new_session().expect({step!r}, returns=...)" + return f" tripwire.sync_websocket_mock.new_session().expect({step!r}, returns=...)" def format_unmocked_hint( self, @@ -554,11 +554,11 @@ def format_unmocked_hint( return ( f"websocket.{method}(...) was called but no session was queued.\n" f"Register a session with:\n" - f" bigfoot.sync_websocket_mock.new_session().expect({method!r}, returns=...)" + f" tripwire.sync_websocket_mock.new_session().expect({method!r}, returns=...)" ) def format_assert_hint(self, interaction: Interaction) -> str: - sm = "bigfoot.sync_websocket_mock" + sm = "tripwire.sync_websocket_mock" sid = interaction.source_id if sid == _SYNC_SOURCE_CONNECT: uri = interaction.details.get("uri", "?") @@ -592,25 +592,25 @@ def assertable_fields(self, interaction: Interaction) -> frozenset[str]: def assert_connect(self, *, uri: str) -> None: """Assert the next sync websocket connect interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._connect_sentinel, uri=uri) def assert_send(self, *, message: Any) -> None: # noqa: ANN401 """Assert the next sync websocket send interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._send_sentinel, message=message) def assert_recv(self, *, message: Any) -> None: # noqa: ANN401 """Assert the next sync websocket recv interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._recv_sentinel, message=message) def assert_close(self) -> None: """Assert the next sync websocket close interaction.""" - from bigfoot._context import _get_test_verifier_or_raise # noqa: PLC0415 + from tripwire._context import _get_test_verifier_or_raise # noqa: PLC0415 _get_test_verifier_or_raise().assert_interaction(self._close_sentinel) diff --git a/src/bigfoot/py.typed b/src/tripwire/py.typed similarity index 100% rename from src/bigfoot/py.typed rename to src/tripwire/py.typed diff --git a/src/bigfoot/pytest_plugin.py b/src/tripwire/pytest_plugin.py similarity index 82% rename from src/bigfoot/pytest_plugin.py rename to src/tripwire/pytest_plugin.py index a968c46..fe1ef86 100644 --- a/src/bigfoot/pytest_plugin.py +++ b/src/tripwire/pytest_plugin.py @@ -1,5 +1,5 @@ -# src/bigfoot/pytest_plugin.py -"""pytest fixture registration for bigfoot.""" +# src/tripwire/pytest_plugin.py +"""pytest fixture registration for tripwire.""" from __future__ import annotations @@ -7,18 +7,18 @@ import pytest -from bigfoot._config import load_bigfoot_config -from bigfoot._context import ( +from tripwire._config import load_tripwire_config +from tripwire._context import ( _current_test_verifier, _guard_active, _guard_level, _guard_patches_installed, ) -from bigfoot._context_propagation import ( +from tripwire._context_propagation import ( install_context_propagation, uninstall_context_propagation, ) -from bigfoot._verifier import StrictVerifier +from tripwire._verifier import StrictVerifier _VALID_GUARD_LEVELS = frozenset({"warn", "error", "strict"}) @@ -27,14 +27,14 @@ def _resolve_guard_level(config: dict[str, object]) -> str: """Parse the guard config value into a normalized level string. Returns one of: "warn", "error", "off". - Raises BigfootConfigError for invalid values. + Raises TripwireConfigError for invalid values. """ - from bigfoot._errors import BigfootConfigError # noqa: PLC0415 + from tripwire._errors import TripwireConfigError # noqa: PLC0415 - raw = config.get("guard", "warn") # default changed from True to "warn" + raw = config.get("guard", "error") # default flipped from "warn" to "error" in 0.20.0 if raw is True: - raise BigfootConfigError( + raise TripwireConfigError( 'guard = true is ambiguous. ' 'Use guard = "warn", guard = "error", or guard = false.\n' 'Valid values: "warn", "error", "strict", false' @@ -49,18 +49,18 @@ def _resolve_guard_level(config: dict[str, object]) -> str: return "error" if normalized == "warn": return "warn" - raise BigfootConfigError( + raise TripwireConfigError( f'Invalid guard value: {raw!r}. ' f'Valid values: "warn", "error", "strict", false' ) - raise BigfootConfigError( + raise TripwireConfigError( f"guard must be a string or false, got {type(raw).__name__}: {raw!r}" ) def pytest_configure(config: pytest.Config) -> None: - """Register bigfoot pytest markers and install context propagation.""" + """Register tripwire pytest markers and install context propagation.""" config.addinivalue_line( "markers", "allow(*rules): allow protocols/patterns (str or M()) to bypass guard mode", @@ -73,16 +73,16 @@ def pytest_configure(config: pytest.Config) -> None: def pytest_unconfigure(config: pytest.Config) -> None: - """Clean up bigfoot patches.""" + """Clean up tripwire patches.""" uninstall_context_propagation() @pytest.fixture(autouse=True) -def _bigfoot_auto_verifier() -> Generator[StrictVerifier, None, None]: +def _tripwire_auto_verifier() -> Generator[StrictVerifier, None, None]: """Auto-use fixture: creates a StrictVerifier for each test, invisible to test authors. verify_all() is called at teardown automatically. The sandbox is NOT automatically - activated -- the test (or module-level bigfoot.sandbox()) controls sandbox lifetime. + activated -- the test (or module-level tripwire.sandbox()) controls sandbox lifetime. """ StrictVerifier._suppress_direct_warning = True try: @@ -96,22 +96,22 @@ def _bigfoot_auto_verifier() -> Generator[StrictVerifier, None, None]: @pytest.fixture -def bigfoot_verifier(_bigfoot_auto_verifier: StrictVerifier) -> StrictVerifier: +def tripwire_verifier(_tripwire_auto_verifier: StrictVerifier) -> StrictVerifier: """Explicit fixture for tests that need direct access to the verifier. Usage: - def test_something(bigfoot_verifier): - http = HttpPlugin(bigfoot_verifier) + def test_something(tripwire_verifier): + http = HttpPlugin(tripwire_verifier) http.mock_response("GET", "https://api.example.com/data", json={}) - with bigfoot_verifier.sandbox(): + with tripwire_verifier.sandbox(): response = httpx.get("https://api.example.com/data") - bigfoot_verifier.assert_interaction(http.request, method="GET") + tripwire_verifier.assert_interaction(http.request, method="GET") """ - return _bigfoot_auto_verifier + return _tripwire_auto_verifier @pytest.fixture(autouse=True, scope="session") -def _bigfoot_guard_patches() -> Generator[None, None, None]: +def _tripwire_guard_patches() -> Generator[None, None, None]: """Install I/O plugin patches at session start for guard mode. Only installs patches for plugins that: @@ -126,14 +126,14 @@ def _bigfoot_guard_patches() -> Generator[None, None, None]: through to originals when neither sandbox nor guard is active (e.g., during fixture setup/teardown). """ - config = load_bigfoot_config() + config = load_tripwire_config() guard_level = _resolve_guard_level(config) if guard_level == "off": yield return - from bigfoot._base_plugin import BasePlugin - from bigfoot._registry import PLUGIN_REGISTRY, _is_available, get_plugin_class + from tripwire._base_plugin import BasePlugin + from tripwire._registry import PLUGIN_REGISTRY, _is_available, get_plugin_class activated: list[BasePlugin] = [] @@ -156,7 +156,7 @@ def _bigfoot_guard_patches() -> Generator[None, None, None]: import warnings warnings.warn( - f"bigfoot: guard mode failed to activate plugin {entry.name!r}", + f"tripwire: guard mode failed to activate plugin {entry.name!r}", stacklevel=1, ) @@ -186,13 +186,13 @@ def pytest_runtest_call(item: pytest.Item) -> Generator[None, None, None]: allowlist for the test. Multiple marks combine via union. Note: This hook only activates the guard ContextVars. Patch installation - is handled by ``_bigfoot_guard_patches`` (session-scoped). Per-test + is handled by ``_tripwire_guard_patches`` (session-scoped). Per-test plugin cleanup fixtures may reset install counts to 0, which removes the session fixture's patches for that test. In that case, guard mode is still active but only effective for plugins whose interceptors are installed (e.g., via sandbox activation within the test). """ - config = load_bigfoot_config() + config = load_tripwire_config() guard_level = _resolve_guard_level(config) if guard_level == "off": yield @@ -200,31 +200,31 @@ def pytest_runtest_call(item: pytest.Item) -> Generator[None, None, None]: # Reject legacy guard_allow config key if "guard_allow" in config: - from bigfoot._errors import BigfootConfigError # noqa: PLC0415 + from tripwire._errors import TripwireConfigError # noqa: PLC0415 - raise BigfootConfigError( - "The guard_allow config key has been replaced by [tool.bigfoot.firewall]. " + raise TripwireConfigError( + "The guard_allow config key has been replaced by [tool.tripwire.firewall]. " "Migration: guard_allow = [\"http\", \"dns\"] becomes " - "[tool.bigfoot.firewall]\\nallow = [\"http:*\", \"dns:*\"]" + "[tool.tripwire.firewall]\\nallow = [\"http:*\", \"dns:*\"]" ) - from bigfoot._firewall import ( # noqa: PLC0415 + from tripwire._firewall import ( # noqa: PLC0415 Disposition, FirewallRule, _firewall_stack, ) - from bigfoot._match import M # noqa: PLC0415 + from tripwire._match import M # noqa: PLC0415 frames: list[FirewallRule] = [] # --------------------------------------------------------------- - # Layer 0: Global TOML rules from [tool.bigfoot.firewall] + # Layer 0: Global TOML rules from [tool.tripwire.firewall] # --------------------------------------------------------------- firewall_config = config.get("firewall", {}) if not isinstance(firewall_config, dict): - from bigfoot._errors import BigfootConfigError # noqa: PLC0415 + from tripwire._errors import TripwireConfigError # noqa: PLC0415 - raise BigfootConfigError( + raise TripwireConfigError( f"firewall config must be a table/dict, got {type(firewall_config).__name__}" ) @@ -240,7 +240,7 @@ def pytest_runtest_call(item: pytest.Item) -> Generator[None, None, None]: FirewallRule(pattern=_parse_toml_rule(rule_str), disposition=Disposition.DENY) ) - # Structured protocol sections (e.g., [tool.bigfoot.firewall.http]) + # Structured protocol sections (e.g., [tool.tripwire.firewall.http]) structured_protocols = ("http", "redis", "subprocess", "boto3", "socket", "file_io") for proto in structured_protocols: proto_section = firewall_config.get(proto, {}) @@ -315,7 +315,7 @@ def pytest_runtest_call(item: pytest.Item) -> Generator[None, None, None]: def _parse_toml_rule(rule_str: str) -> M: # type: ignore[name-defined] # noqa: F821 """Parse a TOML rule string into an M() pattern.""" - from bigfoot._match import M # noqa: PLC0415 + from tripwire._match import M # noqa: PLC0415 # Protocol:wildcard shorthand if ":" in rule_str and "//" not in rule_str: @@ -369,6 +369,6 @@ def _parse_toml_rule(rule_str: str) -> M: # type: ignore[name-defined] # noqa: def _path_matches_glob(test_path: str, glob_pattern: str) -> bool: """Check if a test path matches a glob pattern.""" - from bigfoot._glob import bigfoot_match # noqa: PLC0415 + from tripwire._glob import tripwire_match # noqa: PLC0415 - return bigfoot_match(glob_pattern, test_path) + return tripwire_match(glob_pattern, test_path) diff --git a/tests/dogfood/test_dogfood.py b/tests/dogfood/test_dogfood.py index 3be2004..2968b67 100644 --- a/tests/dogfood/test_dogfood.py +++ b/tests/dogfood/test_dogfood.py @@ -1,6 +1,6 @@ -"""Dogfood tests: bigfoot uses itself to test bigfoot. +"""Dogfood tests: tripwire uses itself to test tripwire. -These tests exercise bigfoot's own internals via bigfoot's MockPlugin and +These tests exercise tripwire's own internals via tripwire's MockPlugin and HttpPlugin, validating production-style usage rather than isolated unit behavior. """ @@ -10,14 +10,14 @@ import pytest from dirty_equals import AnyThing, IsInstance -import bigfoot -from bigfoot import ( +import tripwire +from tripwire import ( MockPlugin, UnassertedInteractionsError, UnmockedInteractionError, UnusedMocksError, ) -from bigfoot._mock_plugin import ImportSiteMock +from tripwire._mock_plugin import ImportSiteMock pytestmark = pytest.mark.integration @@ -36,13 +36,13 @@ def _create_fake_module(name: str, **attrs: object) -> types.ModuleType: def test_mock_plugin_records_and_asserts_collaborator_interaction() -> None: - """Use bigfoot.mock() to mock a module-level function, verify interaction recorded.""" + """Use tripwire.mock() to mock a module-level function, verify interaction recorded.""" mod = _create_fake_module("_df_payment", charge=lambda *a, **kw: None) try: - service_mock = bigfoot.mock("_df_payment:charge") + service_mock = tripwire.mock("_df_payment:charge") service_mock.returns({"status": "ok", "id": "ch_001"}) - with bigfoot.sandbox(): + with tripwire.sandbox(): result = mod.charge("order_99", amount=500) assert result == {"status": "ok", "id": "ch_001"} @@ -63,10 +63,10 @@ def test_multiple_calls_asserted_in_fifo_order() -> None: """Multiple side effects consumed in FIFO order, each interaction asserted.""" mod = _create_fake_module("_df_counter", tick=lambda: None) try: - counter_mock = bigfoot.mock("_df_counter:tick") + counter_mock = tripwire.mock("_df_counter:tick") counter_mock.returns(1).returns(2).returns(3) - with bigfoot.sandbox(): + with tripwire.sandbox(): first = mod.tick() second = mod.tick() third = mod.tick() @@ -95,9 +95,9 @@ def fetch(key: str) -> str: return f"real_{key}" store = _DataStore() - mock = bigfoot.mock.object(store, "fetch") + mock = tripwire.mock.object(store, "fetch") - with bigfoot.sandbox(): + with tripwire.sandbox(): with pytest.raises(UnmockedInteractionError) as exc_info: store.fetch("user_id_123") @@ -116,15 +116,15 @@ def test_unasserted_interactions_error_at_teardown() -> None: """verify_all() raises UnassertedInteractionsError when interaction is not asserted.""" mod = _create_fake_module("_df_logger", log=lambda *a: None) try: - log_mock = bigfoot.mock("_df_logger:log") + log_mock = tripwire.mock("_df_logger:log") log_mock.returns(None) - with bigfoot.sandbox(): + with tripwire.sandbox(): mod.log("event_happened") # Deliberately skip assert with pytest.raises(UnassertedInteractionsError) as exc_info: - bigfoot.verify_all() + tripwire.verify_all() err = exc_info.value assert len(err.interactions) == 1 @@ -146,14 +146,14 @@ def test_unused_mocks_error_at_teardown() -> None: """verify_all() raises UnusedMocksError when a required mock is never called.""" mod = _create_fake_module("_df_emailer", send_email=lambda *a: None) try: - email_mock = bigfoot.mock("_df_emailer:send_email") + email_mock = tripwire.mock("_df_emailer:send_email") email_mock.returns(True) - with bigfoot.sandbox(): + with tripwire.sandbox(): pass # Never call send_email with pytest.raises(UnusedMocksError) as exc_info: - bigfoot.verify_all() + tripwire.verify_all() err = exc_info.value assert len(err.mocks) == 1 @@ -167,7 +167,7 @@ def test_unused_mocks_error_at_teardown() -> None: assert str(err) == err.hint # Mark the mock as not required so the auto-verifier teardown does not raise - verifier = bigfoot.current_verifier() + verifier = tripwire.current_verifier() for plugin in verifier._plugins: if isinstance(plugin, MockPlugin): for mock_obj in plugin._mocks: @@ -192,14 +192,14 @@ def get(key: str) -> str: return f"cached_{key}" cache = _Cache() - mock = bigfoot.mock.object(cache, "get") + mock = tripwire.mock.object(cache, "get") # Access __call__ to configure, mark not required mock.__getattr__("__call__").required(False).returns(None) - with bigfoot.sandbox(): + with tripwire.sandbox(): pass # Never call get - # verify_all() is called automatically at teardown by _bigfoot_auto_verifier -- must not raise + # verify_all() is called automatically at teardown by _tripwire_auto_verifier -- must not raise # --------------------------------------------------------------------------- @@ -212,10 +212,10 @@ def test_raises_side_effect_is_recorded_and_assertable() -> None: mod = _create_fake_module("_df_database", connect=lambda: None) try: exc = ConnectionError("db down") - db_mock = bigfoot.mock("_df_database:connect") + db_mock = tripwire.mock("_df_database:connect") db_mock.raises(exc) - with bigfoot.sandbox(): + with tripwire.sandbox(): with pytest.raises(ConnectionError, match="db down"): mod.connect() @@ -237,10 +237,10 @@ def test_calls_side_effect_delegates_to_fn() -> None: """Interaction from .calls() invokes the function with forwarded args.""" mod = _create_fake_module("_df_calc", add=lambda x, y: x + y) try: - calc_mock = bigfoot.mock("_df_calc:add") + calc_mock = tripwire.mock("_df_calc:add") calc_mock.calls(lambda x, y: x + y) - with bigfoot.sandbox(): + with tripwire.sandbox(): result = mod.add(3, 4) assert result == 7 @@ -265,17 +265,17 @@ def test_in_any_order_allows_out_of_order_assertions() -> None: send_sms=lambda *a: None, ) try: - email_mock = bigfoot.mock("_df_notify:send_email") - sms_mock = bigfoot.mock("_df_notify:send_sms") + email_mock = tripwire.mock("_df_notify:send_email") + sms_mock = tripwire.mock("_df_notify:send_sms") email_mock.returns(True) sms_mock.returns(True) - with bigfoot.sandbox(): + with tripwire.sandbox(): mod.send_sms("hello") mod.send_email("world") # Assert out-of-order: email first, then sms - with bigfoot.in_any_order(): + with tripwire.in_any_order(): email_mock.assert_call(args=("world",), kwargs={}) sms_mock.assert_call(args=("hello",), kwargs={}) finally: @@ -283,19 +283,19 @@ def test_in_any_order_allows_out_of_order_assertions() -> None: # --------------------------------------------------------------------------- -# 10. bigfoot.mock() lazily creates MockPlugin +# 10. tripwire.mock() lazily creates MockPlugin # --------------------------------------------------------------------------- def test_verifier_mock_lazily_creates_mock_plugin() -> None: - """bigfoot.mock() creates MockPlugin on first call without explicit instantiation.""" - verifier = bigfoot.current_verifier() + """tripwire.mock() creates MockPlugin on first call without explicit instantiation.""" + verifier = tripwire.current_verifier() # Before any mock() call, no MockPlugin registered (but auto-instantiated plugins are) assert not any(isinstance(p, MockPlugin) for p in verifier._plugins) initial_count = len(verifier._plugins) - mock = bigfoot.mock("os.path:sep") + mock = tripwire.mock("os.path:sep") # After mock(), MockPlugin is registered alongside auto-instantiated plugins assert len(verifier._plugins) == initial_count + 1 @@ -309,9 +309,9 @@ def test_verifier_mock_lazily_creates_mock_plugin() -> None: def test_mock_returns_different_instances_for_different_paths() -> None: - """bigfoot.mock() with different paths returns different objects.""" - mock_a = bigfoot.mock("os.path:sep") - mock_b = bigfoot.mock("os.path:join") + """tripwire.mock() with different paths returns different objects.""" + mock_a = tripwire.mock("os.path:sep") + mock_b = tripwire.mock("os.path:join") assert mock_a is not mock_b @@ -322,13 +322,13 @@ def test_mock_returns_different_instances_for_different_paths() -> None: async def test_mock_plugin_works_in_async_context() -> None: - """bigfoot MockPlugin correctly records interactions inside an async test.""" + """tripwire MockPlugin correctly records interactions inside an async test.""" mod = _create_fake_module("_df_async_svc", fetch_data=lambda *a: None) try: - mock = bigfoot.mock("_df_async_svc:fetch_data") + mock = tripwire.mock("_df_async_svc:fetch_data") mock.returns({"value": 42}) - async with bigfoot.sandbox(): + async with tripwire.sandbox(): result = mod.fetch_data("key") assert result == {"value": 42} @@ -349,20 +349,20 @@ def test_http_plugin_full_cycle_httpx() -> None: """Full mock + assert + verify cycle using httpx GET.""" httpx = pytest.importorskip("httpx") - bigfoot.http.mock_response( + tripwire.http.mock_response( "GET", "https://api.stripe.com/v1/charges", json={"id": "ch_123", "amount": 5000}, status=200, ) - with bigfoot.sandbox(): + with tripwire.sandbox(): response = httpx.get("https://api.stripe.com/v1/charges") assert response.status_code == 200 assert response.json() == {"id": "ch_123", "amount": 5000} - bigfoot.assert_interaction( - bigfoot.http.request, + tripwire.assert_interaction( + tripwire.http.request, method="GET", url="https://api.stripe.com/v1/charges", request_headers=AnyThing(), @@ -384,17 +384,17 @@ def test_mock_and_http_plugins_tracked_in_global_fifo_order() -> None: mod = _create_fake_module("_df_auth", authenticate=lambda *a: None) try: - auth_mock = bigfoot.mock("_df_auth:authenticate") + auth_mock = tripwire.mock("_df_auth:authenticate") auth_mock.returns({"token": "tok_abc"}) - bigfoot.http.mock_response( + tripwire.http.mock_response( "POST", "https://api.example.com/data", json={"created": True}, status=201, ) - with bigfoot.sandbox(): + with tripwire.sandbox(): auth_result = mod.authenticate("user_x") http_response = httpx.post("https://api.example.com/data", json={}) @@ -405,8 +405,8 @@ def test_mock_and_http_plugins_tracked_in_global_fifo_order() -> None: args=("user_x",), kwargs={}, ) - bigfoot.assert_interaction( - bigfoot.http.request, + tripwire.assert_interaction( + tripwire.http.request, method="POST", url="https://api.example.com/data", request_headers=AnyThing(), @@ -425,7 +425,7 @@ def test_mock_and_http_plugins_tracked_in_global_fifo_order() -> None: def test_spy_records_and_delegates() -> None: - """bigfoot.spy() creates a spy that delegates to real implementation.""" + """tripwire.spy() creates a spy that delegates to real implementation.""" class _Calculator: @staticmethod @@ -434,9 +434,9 @@ def add(x: int, y: int) -> int: mod = _create_fake_module("_df_calc_spy", add=_Calculator.add) try: - calc_spy = bigfoot.spy("_df_calc_spy:add") + calc_spy = tripwire.spy("_df_calc_spy:add") - with bigfoot.sandbox(): + with tripwire.sandbox(): result = mod.add(10, 20) assert result == 30 @@ -458,9 +458,9 @@ def _flaky_fetch() -> None: mod = _create_fake_module("_df_flaky_spy", fetch=_flaky_fetch) try: - flaky_spy = bigfoot.spy("_df_flaky_spy:fetch") + flaky_spy = tripwire.spy("_df_flaky_spy:fetch") - with bigfoot.sandbox(): + with tripwire.sandbox(): with pytest.raises(ConnectionError, match="unreachable"): mod.fetch() @@ -478,20 +478,20 @@ def _flaky_fetch() -> None: def test_missing_assertion_fields_error_raised_for_mock() -> None: """assert_interaction() raises MissingAssertionFieldsError when args/kwargs omitted.""" - from bigfoot import MissingAssertionFieldsError + from tripwire import MissingAssertionFieldsError mod = _create_fake_module("_df_svc_fields", process=lambda *a: None) try: - mock = bigfoot.mock("_df_svc_fields:process") + mock = tripwire.mock("_df_svc_fields:process") mock.returns("done") - with bigfoot.sandbox(): + with tripwire.sandbox(): mod.process("input") # Use assert_interaction directly with no fields to trigger MissingAssertionFieldsError method_proxy = mock.__getattr__("__call__") with pytest.raises(MissingAssertionFieldsError) as exc_info: - bigfoot.assert_interaction(method_proxy) # missing args and kwargs + tripwire.assert_interaction(method_proxy) # missing args and kwargs assert "args" in exc_info.value.missing_fields assert "kwargs" in exc_info.value.missing_fields @@ -510,13 +510,13 @@ def test_missing_assertion_fields_error_raised_for_mock() -> None: def test_http_pass_through_routes_to_real_backend() -> None: """pass_through() routes the matched request to the real transport and records interaction.""" httpx = pytest.importorskip("httpx") - from bigfoot.plugins.http import HttpPlugin + from tripwire.plugins.http import HttpPlugin - bigfoot.http.pass_through("GET", "https://real-api.example.com/data") + tripwire.http.pass_through("GET", "https://real-api.example.com/data") fake_response = httpx.Response(200, json={"real": True}) - with bigfoot.sandbox(): + with tripwire.sandbox(): real_original = HttpPlugin._original_httpx_transport_handle HttpPlugin._original_httpx_transport_handle = lambda transport_self, request: fake_response # type: ignore[assignment] try: @@ -525,8 +525,8 @@ def test_http_pass_through_routes_to_real_backend() -> None: HttpPlugin._original_httpx_transport_handle = real_original # type: ignore[assignment] assert response.status_code == 200 - bigfoot.assert_interaction( - bigfoot.http.request, + tripwire.assert_interaction( + tripwire.http.request, method="GET", url="https://real-api.example.com/data", request_headers=AnyThing(), diff --git a/tests/integration/test_default_guard_error.py b/tests/integration/test_default_guard_error.py new file mode 100644 index 0000000..6f51cbb --- /dev/null +++ b/tests/integration/test_default_guard_error.py @@ -0,0 +1,50 @@ +"""C1-T4: with no config, unmocked I/O outside a sandbox raises GuardedCallError. + +This is the integration check for the Proposal 1 default flip: previously +the same scenario only emitted a `GuardedCallWarning`. After C1 the default +is `error`, so the call must raise. +""" + +from __future__ import annotations + +import textwrap + +import pytest + +pytest_plugins = ["pytester"] + +pytestmark = pytest.mark.integration + + +@pytest.mark.allow("subprocess") +def test_unmocked_call_raises_by_default(pytester: pytest.Pytester) -> None: + """A project with no `[tool.tripwire]` config + an unmocked subprocess.run + call outside `with tripwire:` raises GuardedCallError (NOT a warning). + + This must run pytester in a SUBPROCESS rather than in-process, because + tripwire's `pytest_unconfigure` hook unconditionally uninstalls global + context propagation; running an inner pytest in-process would tear down + the parent session's shared state and corrupt later tests. + """ + # Empty pyproject: no [tool.tripwire] section at all -> default applies. + pytester.makepyprojecttoml("[project]\nname = \"client\"\nversion = \"0.0.0\"\n") + pytester.makepyfile( + test_unmocked=textwrap.dedent( + """ + import subprocess + + import pytest + + from tripwire import GuardedCallError + + + def test_unmocked_subprocess_raises(): + with pytest.raises(GuardedCallError): + subprocess.run(["true"]) + """ + ) + ) + # tripwire.pytest_plugin auto-loads via the pytest11 entry point in the + # subprocess. Run subprocess to isolate global state lifecycle. + result = pytester.runpytest_subprocess("-q") + result.assert_outcomes(passed=1) diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index 7b35fee..8897570 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -1,4 +1,4 @@ -"""Integration tests: exercise the full bigfoot system end-to-end. +"""Integration tests: exercise the full tripwire system end-to-end. Each test is self-contained. No real network calls are made. """ @@ -8,7 +8,7 @@ import pytest -from bigfoot import ( +from tripwire import ( MockPlugin, SandboxNotActiveError, StrictVerifier, @@ -16,7 +16,7 @@ UnmockedInteractionError, UnusedMocksError, ) -from bigfoot._context import get_verifier_or_raise +from tripwire._context import get_verifier_or_raise pytestmark = pytest.mark.integration @@ -232,7 +232,7 @@ def testget_verifier_or_raise_raises_sandbox_not_active_error() -> None: def test_http_plugin_mock_response_full_round_trip() -> None: """Full httpx round-trip: register mock, call inside sandbox, assert, verify_all passes.""" httpx = pytest.importorskip("httpx") - from bigfoot.plugins.http import HttpPlugin + from tripwire.plugins.http import HttpPlugin verifier = StrictVerifier() # Retrieve the auto-created HttpPlugin instead of creating a duplicate @@ -274,8 +274,8 @@ def test_conflict_error_raised_when_httpx_already_patched() -> None: is already patched by a third-party library before HttpPlugin activates. """ httpx = pytest.importorskip("httpx") - from bigfoot._errors import ConflictError - from bigfoot.plugins.http import HttpPlugin + from tripwire._errors import ConflictError + from tripwire.plugins.http import HttpPlugin # Save guard fixture state (session fixture may have installed patches) saved_count = HttpPlugin._install_count @@ -316,7 +316,7 @@ async def test_run_in_executor_propagates_context_var() -> None: """run_in_executor propagates ContextVars via centralized context propagation. Previously this was handled by HttpPlugin._patch_run_in_executor. - Now handled by bigfoot._context_propagation (installed at pytest_configure). + Now handled by tripwire._context_propagation (installed at pytest_configure). Uses a dedicated test ContextVar instead of _active_verifier to avoid triggering interceptor side effects (socket/http interceptors inspect diff --git a/tests/integration/test_mock_e2e.py b/tests/integration/test_mock_e2e.py index 0188317..3146efd 100644 --- a/tests/integration/test_mock_e2e.py +++ b/tests/integration/test_mock_e2e.py @@ -5,8 +5,8 @@ import pytest -import bigfoot -from bigfoot._verifier import StrictVerifier +import tripwire +from tripwire._verifier import StrictVerifier pytestmark = pytest.mark.integration @@ -19,62 +19,62 @@ def _create_fake_module(name: str, **attrs: object) -> types.ModuleType: return mod -def test_mock_register_sandbox_call_assert(bigfoot_verifier: StrictVerifier) -> None: +def test_mock_register_sandbox_call_assert(tripwire_verifier: StrictVerifier) -> None: """Full flow: register mock, sandbox, call, assert.""" mod = _create_fake_module("_e2e_mock1", fn=lambda: "real") try: - mock = bigfoot.mock("_e2e_mock1:fn") + mock = tripwire.mock("_e2e_mock1:fn") mock.returns("mocked") - with bigfoot: + with tripwire: result = mod.fn() assert result == "mocked" mock.assert_call(args=(), kwargs={}) - bigfoot.verify_all() + tripwire.verify_all() finally: del sys.modules["_e2e_mock1"] -def test_spy_register_sandbox_call_assert(bigfoot_verifier: StrictVerifier) -> None: +def test_spy_register_sandbox_call_assert(tripwire_verifier: StrictVerifier) -> None: """Full flow: register spy, sandbox, call real, assert with returned.""" mod = _create_fake_module("_e2e_spy1", fn=lambda x: x * 3) try: - spy = bigfoot.spy("_e2e_spy1:fn") + spy = tripwire.spy("_e2e_spy1:fn") - with bigfoot: + with tripwire: result = mod.fn(7) assert result == 21 spy.assert_call(args=(7,), kwargs={}, returned=21) - bigfoot.verify_all() + tripwire.verify_all() finally: del sys.modules["_e2e_spy1"] -def test_mock_raises_records_raised(bigfoot_verifier: StrictVerifier) -> None: +def test_mock_raises_records_raised(tripwire_verifier: StrictVerifier) -> None: """Mock with .raises() records raised in details.""" mod = _create_fake_module("_e2e_raises", fn=lambda: "real") try: - mock = bigfoot.mock("_e2e_raises:fn") + mock = tripwire.mock("_e2e_raises:fn") exc = ValueError("test error") mock.raises(exc) - with bigfoot: + with tripwire: with pytest.raises(ValueError, match="test error"): mod.fn() mock.assert_call(args=(), kwargs={}, raised=exc) - bigfoot.verify_all() + tripwire.verify_all() finally: del sys.modules["_e2e_raises"] -def test_individual_mock_enforce_false(bigfoot_verifier: StrictVerifier) -> None: +def test_individual_mock_enforce_false(tripwire_verifier: StrictVerifier) -> None: """Individual mock activation (with mock:) uses enforce=False.""" mod = _create_fake_module("_e2e_individual", fn=lambda: "real") try: - mock = bigfoot.mock("_e2e_individual:fn") + mock = tripwire.mock("_e2e_individual:fn") mock.returns("mocked") with mock: @@ -82,34 +82,34 @@ def test_individual_mock_enforce_false(bigfoot_verifier: StrictVerifier) -> None assert result == "mocked" # Do NOT assert -- enforce=False means verify_all() should not complain - bigfoot.verify_all() # should not raise + tripwire.verify_all() # should not raise finally: del sys.modules["_e2e_individual"] -def test_mock_object_api(bigfoot_verifier: StrictVerifier) -> None: - """bigfoot.mock.object() patches a specific object's attribute.""" +def test_mock_object_api(tripwire_verifier: StrictVerifier) -> None: + """tripwire.mock.object() patches a specific object's attribute.""" class Service: def compute(self, x: int) -> int: return x + 1 svc = Service() - mock = bigfoot.mock.object(svc, "compute") + mock = tripwire.mock.object(svc, "compute") mock.returns(42) - with bigfoot: + with tripwire: result = svc.compute(10) assert result == 42 mock.assert_call(args=(10,), kwargs={}) -def test_sandbox_plus_individual_mock(bigfoot_verifier: StrictVerifier) -> None: +def test_sandbox_plus_individual_mock(tripwire_verifier: StrictVerifier) -> None: """Individual activation then sandbox activation works correctly.""" mod = _create_fake_module("_e2e_combo", fn=lambda: "real") try: - mock = bigfoot.mock("_e2e_combo:fn") + mock = tripwire.mock("_e2e_combo:fn") mock.returns("setup_val") # Individual activation for setup (enforce=False) @@ -120,7 +120,7 @@ def test_sandbox_plus_individual_mock(bigfoot_verifier: StrictVerifier) -> None: # Now register another return for sandbox use mock.returns("sandbox_val") - with bigfoot: + with tripwire: sandbox_result = mod.fn() assert sandbox_result == "sandbox_val" @@ -128,22 +128,22 @@ def test_sandbox_plus_individual_mock(bigfoot_verifier: StrictVerifier) -> None: # The first is enforce=False (individual), the second is enforce=True (sandbox). mock.assert_call(args=(), kwargs={}) # individual (enforce=False) mock.assert_call(args=(), kwargs={}) # sandbox (enforce=True) - bigfoot.verify_all() + tripwire.verify_all() finally: del sys.modules["_e2e_combo"] -async def test_async_context_manager(bigfoot_verifier: StrictVerifier) -> None: +async def test_async_context_manager(tripwire_verifier: StrictVerifier) -> None: """async with mock: works for individual activation.""" mod = _create_fake_module("_e2e_async_cm", fn=lambda: "real") try: - mock = bigfoot.mock("_e2e_async_cm:fn") + mock = tripwire.mock("_e2e_async_cm:fn") mock.returns("async_mocked") async with mock: result = mod.fn() assert result == "async_mocked" - bigfoot.verify_all() # enforce=False, should not raise + tripwire.verify_all() # enforce=False, should not raise finally: del sys.modules["_e2e_async_cm"] diff --git a/tests/unit/test_async_subprocess_plugin.py b/tests/unit/test_async_subprocess_plugin.py index 0c046d8..0d6063f 100644 --- a/tests/unit/test_async_subprocess_plugin.py +++ b/tests/unit/test_async_subprocess_plugin.py @@ -6,12 +6,12 @@ import pytest -import bigfoot -from bigfoot._context import _current_test_verifier -from bigfoot._errors import InvalidStateError, UnmockedInteractionError -from bigfoot._state_machine_plugin import ScriptStep -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.async_subprocess_plugin import ( +import tripwire +from tripwire._context import _current_test_verifier +from tripwire._errors import InvalidStateError, UnmockedInteractionError +from tripwire._state_machine_plugin import ScriptStep +from tripwire._verifier import StrictVerifier +from tripwire.plugins.async_subprocess_plugin import ( _ORIGINAL_CREATE_SUBPROCESS_EXEC, _ORIGINAL_CREATE_SUBPROCESS_SHELL, AsyncSubprocessPlugin, @@ -529,26 +529,26 @@ def test_format_unused_mock_hint() -> None: # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.async_subprocess_mock +# Module-level proxy: tripwire.async_subprocess_mock # --------------------------------------------------------------------------- -def test_async_subprocess_mock_proxy_new_session(bigfoot_verifier: StrictVerifier) -> None: - from bigfoot._state_machine_plugin import SessionHandle +def test_async_subprocess_mock_proxy_new_session(tripwire_verifier: StrictVerifier) -> None: + from tripwire._state_machine_plugin import SessionHandle - session = bigfoot.async_subprocess_mock.new_session() + session = tripwire.async_subprocess_mock.new_session() assert isinstance(session, SessionHandle) result = session.expect("spawn", returns=None, required=False) assert result is session def test_async_subprocess_mock_proxy_raises_outside_context() -> None: - from bigfoot._errors import NoActiveVerifierError + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.async_subprocess_mock.new_session + _ = tripwire.async_subprocess_mock.new_session finally: _current_test_verifier.reset(token) @@ -561,7 +561,7 @@ def test_async_subprocess_mock_proxy_raises_outside_context() -> None: def test_conflict_error_exec_already_patched() -> None: from unittest.mock import MagicMock - from bigfoot._errors import ConflictError + from tripwire._errors import ConflictError v, p = _make_verifier_with_plugin() foreign_patch = MagicMock() @@ -578,7 +578,7 @@ def test_conflict_error_exec_already_patched() -> None: def test_conflict_error_shell_already_patched() -> None: from unittest.mock import MagicMock - from bigfoot._errors import ConflictError + from tripwire._errors import ConflictError v, p = _make_verifier_with_plugin() foreign_patch = MagicMock() @@ -597,34 +597,34 @@ def test_conflict_error_shell_already_patched() -> None: # --------------------------------------------------------------------------- -async def test_assertion_helpers_via_proxy(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.async_subprocess_mock.new_session() +async def test_assertion_helpers_via_proxy(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.async_subprocess_mock.new_session() session.expect("spawn", returns=None) session.expect("communicate", returns=(b"output", b"", 0)) - with bigfoot.sandbox(): + with tripwire.sandbox(): proc = await asyncio.create_subprocess_exec("make", "all") stdout, stderr = await proc.communicate() - bigfoot.async_subprocess_mock.assert_spawn(command=["make", "all"], stdin=None) - bigfoot.async_subprocess_mock.assert_communicate(input=None) + tripwire.async_subprocess_mock.assert_spawn(command=["make", "all"], stdin=None) + tripwire.async_subprocess_mock.assert_communicate(input=None) assert stdout == b"output" assert stderr == b"" assert proc.returncode == 0 -async def test_assertion_helpers_wait_via_proxy(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.async_subprocess_mock.new_session() +async def test_assertion_helpers_wait_via_proxy(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.async_subprocess_mock.new_session() session.expect("spawn", returns=None) session.expect("wait", returns=0) - with bigfoot.sandbox(): + with tripwire.sandbox(): proc = await asyncio.create_subprocess_exec("cmd") await proc.wait() - bigfoot.async_subprocess_mock.assert_spawn(command=["cmd"], stdin=None) - bigfoot.async_subprocess_mock.assert_wait() + tripwire.async_subprocess_mock.assert_spawn(command=["cmd"], stdin=None) + tripwire.async_subprocess_mock.assert_wait() # --------------------------------------------------------------------------- @@ -632,36 +632,36 @@ async def test_assertion_helpers_wait_via_proxy(bigfoot_verifier: StrictVerifier # --------------------------------------------------------------------------- -async def test_full_session_via_sandbox(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.async_subprocess_mock.new_session() +async def test_full_session_via_sandbox(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.async_subprocess_mock.new_session() session.expect("spawn", returns=None) session.expect("communicate", returns=(b"build output", b"", 0)) - with bigfoot.sandbox(): + with tripwire.sandbox(): proc = await asyncio.create_subprocess_exec("make", "all") stdout, stderr = await proc.communicate() - bigfoot.async_subprocess_mock.assert_spawn(command=["make", "all"], stdin=None) - bigfoot.async_subprocess_mock.assert_communicate(input=None) + tripwire.async_subprocess_mock.assert_spawn(command=["make", "all"], stdin=None) + tripwire.async_subprocess_mock.assert_communicate(input=None) assert stdout == b"build output" assert stderr == b"" assert proc.returncode == 0 -async def test_full_shell_session_via_sandbox(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.async_subprocess_mock.new_session() +async def test_full_shell_session_via_sandbox(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.async_subprocess_mock.new_session() session.expect("spawn", returns=None) session.expect("communicate", returns=(b"shell output", b"", 0)) - with bigfoot.sandbox(): + with tripwire.sandbox(): proc = await asyncio.create_subprocess_shell("echo hello | tr a-z A-Z") stdout, stderr = await proc.communicate() - bigfoot.async_subprocess_mock.assert_spawn( + tripwire.async_subprocess_mock.assert_spawn( command="echo hello | tr a-z A-Z", stdin=None ) - bigfoot.async_subprocess_mock.assert_communicate(input=None) + tripwire.async_subprocess_mock.assert_communicate(input=None) assert stdout == b"shell output" assert stderr == b"" diff --git a/tests/unit/test_asyncpg_plugin.py b/tests/unit/test_asyncpg_plugin.py index afe6130..501b857 100644 --- a/tests/unit/test_asyncpg_plugin.py +++ b/tests/unit/test_asyncpg_plugin.py @@ -6,12 +6,12 @@ import pytest -import bigfoot -from bigfoot._context import _current_test_verifier -from bigfoot._errors import UnmockedInteractionError -from bigfoot._state_machine_plugin import ScriptStep -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.asyncpg_plugin import AsyncpgPlugin +import tripwire +from tripwire._context import _current_test_verifier +from tripwire._errors import UnmockedInteractionError +from tripwire._state_machine_plugin import ScriptStep +from tripwire._verifier import StrictVerifier +from tripwire.plugins.asyncpg_plugin import AsyncpgPlugin # --------------------------------------------------------------------------- # Helpers @@ -327,26 +327,26 @@ async def test_connect_with_empty_queue_raises_unmocked() -> None: # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.asyncpg_mock +# Module-level proxy: tripwire.asyncpg_mock # --------------------------------------------------------------------------- -def test_asyncpg_mock_proxy_new_session(bigfoot_verifier: StrictVerifier) -> None: - from bigfoot._state_machine_plugin import SessionHandle +def test_asyncpg_mock_proxy_new_session(tripwire_verifier: StrictVerifier) -> None: + from tripwire._state_machine_plugin import SessionHandle - session = bigfoot.asyncpg_mock.new_session() + session = tripwire.asyncpg_mock.new_session() assert isinstance(session, SessionHandle) result = session.expect("execute", returns="", required=False) assert result is session def test_asyncpg_mock_proxy_raises_outside_context() -> None: - from bigfoot._errors import NoActiveVerifierError + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.asyncpg_mock.new_session + _ = tripwire.asyncpg_mock.new_session finally: _current_test_verifier.reset(token) @@ -557,9 +557,9 @@ async def test_connect_with_dsn() -> None: # --------------------------------------------------------------------------- -# AsyncpgPlugin is exposed as bigfoot.AsyncpgPlugin +# AsyncpgPlugin is exposed as tripwire.AsyncpgPlugin # --------------------------------------------------------------------------- def test_asyncpg_plugin_exported() -> None: - assert bigfoot.AsyncpgPlugin is AsyncpgPlugin + assert tripwire.AsyncpgPlugin is AsyncpgPlugin diff --git a/tests/unit/test_base_plugin.py b/tests/unit/test_base_plugin.py index b363781..1c76d22 100644 --- a/tests/unit/test_base_plugin.py +++ b/tests/unit/test_base_plugin.py @@ -7,7 +7,7 @@ import pytest -from bigfoot._timeline import Interaction +from tripwire._timeline import Interaction # --------------------------------------------------------------------------- # Stubs for StrictVerifier (not yet implemented; we only need duck-typed interface) @@ -30,7 +30,7 @@ class _StubVerifier: def __init__(self) -> None: self._timeline = _StubTimeline() self.registered_plugins: list[Any] = [] - self._bigfoot_config: dict[str, Any] = {} + self._tripwire_config: dict[str, Any] = {} def _register_plugin(self, plugin: Any) -> None: self.registered_plugins.append(plugin) @@ -68,7 +68,7 @@ class ConcretePlugin: # --------------------------------------------------------------------------- -from bigfoot._base_plugin import BasePlugin # noqa: E402 +from tripwire._base_plugin import BasePlugin # noqa: E402 class ConcretePlugin(BasePlugin): # type: ignore[no-redef] @@ -661,7 +661,7 @@ def test_record_appends_multiple_interactions_in_order() -> None: def test_assertable_fields_has_concrete_default() -> None: """A concrete subclass that omits assertable_fields() CAN be instantiated; it inherits the default.""" - from bigfoot._base_plugin import BasePlugin + from tripwire._base_plugin import BasePlugin with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) @@ -685,7 +685,7 @@ def format_unused_mock_hint(self, mock_config: object) -> str: return "" # assertable_fields deliberately omitted — should use default - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier v = StrictVerifier() p = _PluginWithoutAssertableFields(v) # Default implementation returns frozenset of details keys @@ -696,7 +696,7 @@ def format_unused_mock_hint(self, mock_config: object) -> str: def test_assertable_fields_contract_returns_frozenset() -> None: """A complete concrete plugin's assertable_fields() returns a frozenset.""" - from bigfoot._base_plugin import BasePlugin + from tripwire._base_plugin import BasePlugin with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) @@ -728,7 +728,7 @@ def format_unused_mock_hint(self, mock_config: object) -> str: def assertable_fields(self, interaction: Interaction) -> frozenset: return frozenset() - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier v = StrictVerifier() p = _CompletePlugin(v) @@ -812,7 +812,7 @@ def test_subclass_gets_own_install_lock() -> None: def test_default_activate_increments_count() -> None: """Default activate() increments _install_count.""" - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier v = StrictVerifier() plugin = _RefCountPlugin(v) initial = type(plugin)._install_count @@ -823,7 +823,7 @@ def test_default_activate_increments_count() -> None: def test_default_deactivate_decrements_count() -> None: """Default deactivate() decrements _install_count.""" - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier v = StrictVerifier() plugin = _RefCountPlugin(v) plugin.activate() @@ -837,7 +837,7 @@ def test_default_deactivate_decrements_count() -> None: def test_default_deactivate_floors_at_zero() -> None: """Default deactivate() does not go below 0.""" - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier v = StrictVerifier() plugin = _RefCountPlugin(v) plugin.deactivate() @@ -846,7 +846,7 @@ def test_default_deactivate_floors_at_zero() -> None: def test_default_activate_calls_install_patches_on_first() -> None: """Default activate() calls _install_patches() on first activation only.""" - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier call_count = 0 @@ -867,7 +867,7 @@ def install_patches(self) -> None: def test_default_deactivate_calls_restore_patches_on_last() -> None: """Default deactivate() calls _restore_patches() when count reaches 0.""" - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier call_count = 0 @@ -888,7 +888,7 @@ def restore_patches(self) -> None: def test_default_activate_calls_check_conflicts_before_install() -> None: """Default activate() calls _check_conflicts() before _install_patches().""" - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier call_order: list[str] = [] @@ -908,7 +908,7 @@ def install_patches(self) -> None: def test_assertable_fields_default_returns_details_keys() -> None: """Default assertable_fields() returns frozenset of all keys in interaction.details.""" - from bigfoot._base_plugin import BasePlugin + from tripwire._base_plugin import BasePlugin with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) @@ -924,7 +924,7 @@ def format_assert_hint(self, i: Interaction) -> str: return "" def get_unused_mocks(self) -> list: return [] def format_unused_mock_hint(self, m: object) -> str: return "" - from bigfoot._verifier import StrictVerifier + from tripwire._verifier import StrictVerifier v = StrictVerifier() p = _DefaultPlugin(v) interaction2 = Interaction(source_id="x", sequence=0, details={"x": 1, "y": 2}, plugin=p) diff --git a/tests/unit/test_bigfoot_migration_error.py b/tests/unit/test_bigfoot_migration_error.py new file mode 100644 index 0000000..de7891b --- /dev/null +++ b/tests/unit/test_bigfoot_migration_error.py @@ -0,0 +1,73 @@ +"""C1-T7: a deprecated `[tool.]` section in pyproject.toml raises +ConfigMigrationError. + +The migration check fires at the TOP of `load_tripwire_config`, BEFORE any +other validation (so it triggers even when the rest of the table would +otherwise be invalid). The companion test confirms `[tool.tripwire]` alone +parses cleanly without raising. + +Note on string construction: the legacy package name is built without +writing the literal substring, so a future re-run of the rename sed pass +cannot accidentally rewrite this test's fixtures or assertions. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tripwire._config import load_tripwire_config +from tripwire._errors import ConfigMigrationError, TripwireError + +# Reconstruct the legacy package name without writing the literal substring. +_OLD_NAME = "b" + "igfoot" + + +def _write_pyproject(path: Path, body: str) -> None: + (path / "pyproject.toml").write_text(body, encoding="utf-8") + + +def test_old_table_raises_migration(tmp_path: Path) -> None: + """A pyproject.toml containing `[tool.]` raises ConfigMigrationError + with the expected migration hint message, and the error is a + TripwireError subclass. + """ + _write_pyproject( + tmp_path, + f"[tool.{_OLD_NAME}]\nguard = \"warn\"\n", + ) + + with pytest.raises(ConfigMigrationError) as exc_info: + load_tripwire_config(tmp_path) + + expected_message = ( + f"{_OLD_NAME} was renamed to tripwire in 0.20.0; " + "rename the table to [tool.tripwire]" + ) + assert str(exc_info.value) == expected_message + assert isinstance(exc_info.value, TripwireError) + + +def test_tripwire_table_does_not_trigger_migration_error(tmp_path: Path) -> None: + """A pyproject.toml with only `[tool.tripwire]` parses cleanly: + load_tripwire_config returns the tripwire sub-table verbatim. + """ + _write_pyproject(tmp_path, "[tool.tripwire]\nguard = \"error\"\n") + assert load_tripwire_config(tmp_path) == {"guard": "error"} + + +def test_migration_check_fires_before_other_validation(tmp_path: Path) -> None: + """Even when the rest of `[tool.]` would be otherwise invalid, the + migration error fires first because the check is at the top of the loader. + """ + _write_pyproject( + tmp_path, + ( + f"[tool.{_OLD_NAME}]\n" + "guard = \"definitely-not-a-valid-value\"\n" + "enabled_plugins = \"not-a-list\"\n" + ), + ) + with pytest.raises(ConfigMigrationError): + load_tripwire_config(tmp_path) diff --git a/tests/unit/test_boto3_plugin.py b/tests/unit/test_boto3_plugin.py index 1dc1258..6a7eeed 100644 --- a/tests/unit/test_boto3_plugin.py +++ b/tests/unit/test_boto3_plugin.py @@ -6,15 +6,15 @@ import botocore import pytest -from bigfoot._context import _current_test_verifier -from bigfoot._errors import ( +from tripwire._context import _current_test_verifier +from tripwire._errors import ( InteractionMismatchError, MissingAssertionFieldsError, UnmockedInteractionError, ) -from bigfoot._timeline import Interaction -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.boto3_plugin import ( +from tripwire._timeline import Interaction +from tripwire._verifier import StrictVerifier +from tripwire.plugins.boto3_plugin import ( _BOTO3_AVAILABLE, Boto3MockConfig, Boto3Plugin, @@ -66,14 +66,14 @@ def test_boto3_available_flag() -> None: def test_activate_raises_when_boto3_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: - import bigfoot.plugins.boto3_plugin as _bp + import tripwire.plugins.boto3_plugin as _bp v, p = _make_verifier_with_plugin() monkeypatch.setattr(_bp, "_BOTO3_AVAILABLE", False) with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install bigfoot[boto3] to use Boto3Plugin: pip install bigfoot[boto3]" + "Install tripwire[boto3] to use Boto3Plugin: pip install tripwire[boto3]" ) @@ -337,7 +337,7 @@ def test_format_mock_hint() -> None: plugin=p, ) result = p.format_mock_hint(interaction) - assert result == " bigfoot.boto3_mock.mock_call('s3', 'GetObject', returns=...)" + assert result == " tripwire.boto3_mock.mock_call('s3', 'GetObject', returns=...)" def test_format_unmocked_hint() -> None: @@ -346,7 +346,7 @@ def test_format_unmocked_hint() -> None: assert result == ( "s3.GetObject(...) was called but no mock was registered.\n" "Register a mock with:\n" - " bigfoot.boto3_mock.mock_call('s3', 'GetObject', returns=...)" + " tripwire.boto3_mock.mock_call('s3', 'GetObject', returns=...)" ) @@ -360,7 +360,7 @@ def test_format_assert_hint() -> None: ) result = p.format_assert_hint(interaction) assert result == ( - " bigfoot.boto3_mock.assert_boto3_call(\n" + " tripwire.boto3_mock.assert_boto3_call(\n" " service='s3',\n" " operation='GetObject',\n" " params={'Bucket': 'b'},\n" @@ -398,33 +398,33 @@ def test_dynamic_sentinel_different_services() -> None: # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.boto3_mock +# Module-level proxy: tripwire.boto3_mock # --------------------------------------------------------------------------- -def test_boto3_mock_proxy_mock_call(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_boto3_mock_proxy_mock_call(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.boto3_mock.mock_call("s3", "GetObject", returns={"Body": b"proxied"}) + tripwire.boto3_mock.mock_call("s3", "GetObject", returns={"Body": b"proxied"}) - with bigfoot.sandbox(): + with tripwire.sandbox(): client = boto3.client("s3", region_name="us-east-1") result = client.get_object(Bucket="b", Key="k") assert result == {"Body": b"proxied"} - bigfoot.boto3_mock.assert_boto3_call( + tripwire.boto3_mock.assert_boto3_call( "s3", "GetObject", params={"Bucket": "b", "Key": "k"} ) def test_boto3_mock_proxy_raises_outside_context() -> None: - import bigfoot - from bigfoot._errors import NoActiveVerifierError + import tripwire + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.boto3_mock.mock_call + _ = tripwire.boto3_mock.mock_call finally: _current_test_verifier.reset(token) @@ -435,11 +435,11 @@ def test_boto3_mock_proxy_raises_outside_context() -> None: def test_boto3_plugin_in_all() -> None: - import bigfoot - from bigfoot.plugins.boto3_plugin import Boto3Plugin as _Boto3Plugin + import tripwire + from tripwire.plugins.boto3_plugin import Boto3Plugin as _Boto3Plugin - assert bigfoot.Boto3Plugin is _Boto3Plugin - assert type(bigfoot.boto3_mock).__name__ == "_Boto3Proxy" + assert tripwire.Boto3Plugin is _Boto3Plugin + assert type(tripwire.boto3_mock).__name__ == "_Boto3Proxy" # --------------------------------------------------------------------------- @@ -447,62 +447,62 @@ def test_boto3_plugin_in_all() -> None: # --------------------------------------------------------------------------- -def test_boto3_interactions_not_auto_asserted(bigfoot_verifier: StrictVerifier) -> None: +def test_boto3_interactions_not_auto_asserted(tripwire_verifier: StrictVerifier) -> None: """boto3 interactions are NOT auto-asserted.""" - import bigfoot + import tripwire - bigfoot.boto3_mock.mock_call("s3", "GetObject", returns={"Body": b"val"}) - with bigfoot.sandbox(): + tripwire.boto3_mock.mock_call("s3", "GetObject", returns={"Body": b"val"}) + with tripwire.sandbox(): client = boto3.client("s3", region_name="us-east-1") client.get_object(Bucket="b", Key="k") - timeline = bigfoot_verifier._timeline + timeline = tripwire_verifier._timeline interactions = timeline.all_unasserted() assert len(interactions) == 1 assert interactions[0].source_id == "boto3:s3:GetObject" - bigfoot.boto3_mock.assert_boto3_call("s3", "GetObject", params={"Bucket": "b", "Key": "k"}) + tripwire.boto3_mock.assert_boto3_call("s3", "GetObject", params={"Bucket": "b", "Key": "k"}) -def test_assert_boto3_call_typed_helper(bigfoot_verifier: StrictVerifier) -> None: +def test_assert_boto3_call_typed_helper(tripwire_verifier: StrictVerifier) -> None: """assert_boto3_call() asserts the next boto3 interaction.""" - import bigfoot + import tripwire - bigfoot.boto3_mock.mock_call("s3", "PutObject", returns={"ETag": '"abc"'}) - with bigfoot.sandbox(): + tripwire.boto3_mock.mock_call("s3", "PutObject", returns={"ETag": '"abc"'}) + with tripwire.sandbox(): client = boto3.client("s3", region_name="us-east-1") client.put_object(Bucket="b", Key="k", Body=b"data") - bigfoot.boto3_mock.assert_boto3_call( + tripwire.boto3_mock.assert_boto3_call( "s3", "PutObject", params={"Bucket": "b", "Key": "k", "Body": b"data"} ) -def test_assert_boto3_call_wrong_params_raises(bigfoot_verifier: StrictVerifier) -> None: +def test_assert_boto3_call_wrong_params_raises(tripwire_verifier: StrictVerifier) -> None: """assert_boto3_call() with wrong params raises InteractionMismatchError.""" - import bigfoot + import tripwire - bigfoot.boto3_mock.mock_call("s3", "GetObject", returns={"Body": b"val"}) - with bigfoot.sandbox(): + tripwire.boto3_mock.mock_call("s3", "GetObject", returns={"Body": b"val"}) + with tripwire.sandbox(): client = boto3.client("s3", region_name="us-east-1") client.get_object(Bucket="b", Key="k") with pytest.raises(InteractionMismatchError): - bigfoot.boto3_mock.assert_boto3_call("s3", "GetObject", params={"Bucket": "wrong"}) + tripwire.boto3_mock.assert_boto3_call("s3", "GetObject", params={"Bucket": "wrong"}) # Assert correctly so teardown passes - bigfoot.boto3_mock.assert_boto3_call("s3", "GetObject", params={"Bucket": "b", "Key": "k"}) + tripwire.boto3_mock.assert_boto3_call("s3", "GetObject", params={"Bucket": "b", "Key": "k"}) -def test_missing_assertion_fields_raises(bigfoot_verifier: StrictVerifier) -> None: +def test_missing_assertion_fields_raises(tripwire_verifier: StrictVerifier) -> None: """Incomplete fields in assert_interaction raises MissingAssertionFieldsError.""" - import bigfoot + import tripwire - bigfoot.boto3_mock.mock_call("s3", "GetObject", returns={"Body": b"val"}) - with bigfoot.sandbox(): + tripwire.boto3_mock.mock_call("s3", "GetObject", returns={"Body": b"val"}) + with tripwire.sandbox(): client = boto3.client("s3", region_name="us-east-1") client.get_object(Bucket="b", Key="k") - from bigfoot.plugins.boto3_plugin import _Boto3Sentinel + from tripwire.plugins.boto3_plugin import _Boto3Sentinel sentinel = _Boto3Sentinel("s3", "GetObject") with pytest.raises(MissingAssertionFieldsError): - bigfoot.assert_interaction(sentinel, service="s3") + tripwire.assert_interaction(sentinel, service="s3") # Assert correctly so teardown passes - bigfoot.boto3_mock.assert_boto3_call("s3", "GetObject", params={"Bucket": "b", "Key": "k"}) + tripwire.boto3_mock.assert_boto3_call("s3", "GetObject", params={"Bucket": "b", "Key": "k"}) diff --git a/tests/unit/test_celery_plugin.py b/tests/unit/test_celery_plugin.py index 5c4c549..73d1aae 100644 --- a/tests/unit/test_celery_plugin.py +++ b/tests/unit/test_celery_plugin.py @@ -5,15 +5,15 @@ import celery import pytest -from bigfoot._context import _current_test_verifier -from bigfoot._errors import ( +from tripwire._context import _current_test_verifier +from tripwire._errors import ( InteractionMismatchError, MissingAssertionFieldsError, UnmockedInteractionError, ) -from bigfoot._timeline import Interaction -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.celery_plugin import ( +from tripwire._timeline import Interaction +from tripwire._verifier import StrictVerifier +from tripwire.plugins.celery_plugin import ( _CELERY_AVAILABLE, CeleryMockConfig, CeleryPlugin, @@ -74,14 +74,14 @@ def test_celery_available_flag() -> None: def test_activate_raises_when_celery_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: - import bigfoot.plugins.celery_plugin as _cp + import tripwire.plugins.celery_plugin as _cp v, p = _make_verifier_with_plugin() monkeypatch.setattr(_cp, "_CELERY_AVAILABLE", False) with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install bigfoot[celery] to use CeleryPlugin: pip install bigfoot[celery]" + "Install tripwire[celery] to use CeleryPlugin: pip install tripwire[celery]" ) @@ -197,15 +197,15 @@ def test_mock_apply_async_returns_value() -> None: # --------------------------------------------------------------------------- -def test_assert_delay_full_assertion(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_delay_full_assertion(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.celery_mock.mock_delay("myapp.tasks.add", returns="mock-id") + tripwire.celery_mock.mock_delay("myapp.tasks.add", returns="mock-id") - with bigfoot.sandbox(): + with tripwire.sandbox(): add_task.delay(1, 2) - bigfoot.celery_mock.assert_delay( + tripwire.celery_mock.assert_delay( task_name="myapp.tasks.add", args=(1, 2), kwargs={}, @@ -213,15 +213,15 @@ def test_assert_delay_full_assertion(bigfoot_verifier: StrictVerifier) -> None: ) -def test_assert_apply_async_full_assertion(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_apply_async_full_assertion(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.celery_mock.mock_apply_async("myapp.tasks.add", returns="mock-id") + tripwire.celery_mock.mock_apply_async("myapp.tasks.add", returns="mock-id") - with bigfoot.sandbox(): + with tripwire.sandbox(): add_task.apply_async(args=(1, 2), countdown=10) - bigfoot.celery_mock.assert_apply_async( + tripwire.celery_mock.assert_apply_async( task_name="myapp.tasks.add", args=(1, 2), kwargs={}, @@ -357,23 +357,23 @@ def test_get_unused_mocks_excludes_required_false() -> None: # --------------------------------------------------------------------------- -def test_missing_assertion_fields(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot - from bigfoot.plugins.celery_plugin import _CelerySentinel +def test_missing_assertion_fields(tripwire_verifier: StrictVerifier) -> None: + import tripwire + from tripwire.plugins.celery_plugin import _CelerySentinel - bigfoot.celery_mock.mock_delay("myapp.tasks.add", returns="mock-id") + tripwire.celery_mock.mock_delay("myapp.tasks.add", returns="mock-id") - with bigfoot.sandbox(): + with tripwire.sandbox(): add_task.delay(1, 2) sentinel = _CelerySentinel("celery:myapp.tasks.add:delay") with pytest.raises(MissingAssertionFieldsError) as exc_info: # Only pass task_name, omit others - bigfoot_verifier.assert_interaction(sentinel, task_name="myapp.tasks.add") + tripwire_verifier.assert_interaction(sentinel, task_name="myapp.tasks.add") assert "dispatch_method" in exc_info.value.missing_fields # Now assert fully so teardown passes - bigfoot.celery_mock.assert_delay( + tripwire.celery_mock.assert_delay( task_name="myapp.tasks.add", args=(1, 2), kwargs={}, @@ -386,20 +386,20 @@ def test_missing_assertion_fields(bigfoot_verifier: StrictVerifier) -> None: # --------------------------------------------------------------------------- -def test_celery_interactions_not_auto_asserted(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_celery_interactions_not_auto_asserted(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.celery_mock.mock_delay("myapp.tasks.add", returns="mock-id") + tripwire.celery_mock.mock_delay("myapp.tasks.add", returns="mock-id") - with bigfoot.sandbox(): + with tripwire.sandbox(): add_task.delay(1, 2) - timeline = bigfoot_verifier._timeline + timeline = tripwire_verifier._timeline interactions = timeline.all_unasserted() assert len(interactions) == 1 assert interactions[0].source_id == "celery:myapp.tasks.add:delay" # Assert it so verify_all() at teardown succeeds - bigfoot.celery_mock.assert_delay( + tripwire.celery_mock.assert_delay( task_name="myapp.tasks.add", args=(1, 2), kwargs={}, @@ -485,7 +485,7 @@ def test_format_mock_hint() -> None: plugin=p, ) result = p.format_mock_hint(interaction) - assert result == " bigfoot.celery_mock.mock_delay('myapp.tasks.add', returns=...)" + assert result == " tripwire.celery_mock.mock_delay('myapp.tasks.add', returns=...)" def test_format_unmocked_hint() -> None: @@ -494,7 +494,7 @@ def test_format_unmocked_hint() -> None: assert result == ( "celery.delay('myapp.tasks.add', ...) was called but no mock was registered.\n" "Register a mock with:\n" - " bigfoot.celery_mock.mock_delay('myapp.tasks.add', returns=...)" + " tripwire.celery_mock.mock_delay('myapp.tasks.add', returns=...)" ) @@ -514,7 +514,7 @@ def test_format_assert_hint() -> None: ) result = p.format_assert_hint(interaction) assert result == ( - " bigfoot.celery_mock.assert_delay(\n" + " tripwire.celery_mock.assert_delay(\n" " task_name='myapp.tasks.add',\n" " dispatch_method='delay',\n" " args=(1, 2),\n" @@ -540,20 +540,20 @@ def test_format_unused_mock_hint() -> None: # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.celery_mock +# Module-level proxy: tripwire.celery_mock # --------------------------------------------------------------------------- -def test_celery_mock_proxy_mock_delay(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_celery_mock_proxy_mock_delay(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.celery_mock.mock_delay("myapp.tasks.add", returns="proxy-result") + tripwire.celery_mock.mock_delay("myapp.tasks.add", returns="proxy-result") - with bigfoot.sandbox(): + with tripwire.sandbox(): result = add_task.delay(1, 2) assert result == "proxy-result" - bigfoot.celery_mock.assert_delay( + tripwire.celery_mock.assert_delay( task_name="myapp.tasks.add", args=(1, 2), kwargs={}, @@ -562,13 +562,13 @@ def test_celery_mock_proxy_mock_delay(bigfoot_verifier: StrictVerifier) -> None: def test_celery_mock_proxy_raises_outside_context() -> None: - import bigfoot - from bigfoot._errors import NoActiveVerifierError + import tripwire + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.celery_mock.mock_delay + _ = tripwire.celery_mock.mock_delay finally: _current_test_verifier.reset(token) @@ -579,11 +579,11 @@ def test_celery_mock_proxy_raises_outside_context() -> None: def test_celery_plugin_in_all() -> None: - import bigfoot + import tripwire - assert "CeleryPlugin" in bigfoot.__all__ - assert "celery_mock" in bigfoot.__all__ - assert type(bigfoot.celery_mock).__name__ == "_CeleryProxy" + assert "CeleryPlugin" in tripwire.__all__ + assert "celery_mock" in tripwire.__all__ + assert type(tripwire.celery_mock).__name__ == "_CeleryProxy" # --------------------------------------------------------------------------- @@ -591,23 +591,23 @@ def test_celery_plugin_in_all() -> None: # --------------------------------------------------------------------------- -def test_assert_delay_wrong_args_raises(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_delay_wrong_args_raises(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.celery_mock.mock_delay("myapp.tasks.add", returns="mock-id") + tripwire.celery_mock.mock_delay("myapp.tasks.add", returns="mock-id") - with bigfoot.sandbox(): + with tripwire.sandbox(): add_task.delay(1, 2) with pytest.raises(InteractionMismatchError): - bigfoot.celery_mock.assert_delay( + tripwire.celery_mock.assert_delay( task_name="myapp.tasks.add", args=(99, 99), kwargs={}, options={}, ) # Assert correctly so teardown passes - bigfoot.celery_mock.assert_delay( + tripwire.celery_mock.assert_delay( task_name="myapp.tasks.add", args=(1, 2), kwargs={}, diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index cd9f255..a972f2a 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1,4 +1,4 @@ -"""Unit tests for bigfoot._config and plugin config integration.""" +"""Unit tests for tripwire._config and plugin config integration.""" import sys @@ -11,13 +11,13 @@ import pytest -from bigfoot._config import load_bigfoot_config +from tripwire._config import load_tripwire_config httpx = pytest.importorskip("httpx") requests = pytest.importorskip("requests") -from bigfoot._verifier import StrictVerifier # noqa: E402 -from bigfoot.plugins.http import HttpPlugin # noqa: E402 +from tripwire._verifier import StrictVerifier # noqa: E402 +from tripwire.plugins.http import HttpPlugin # noqa: E402 # --------------------------------------------------------------------------- # Stub verifier for unit-testing load_config() in isolation @@ -27,8 +27,8 @@ class _StubVerifier: """Minimal stub for StrictVerifier — only attributes that HttpPlugin touches.""" - def __init__(self, bigfoot_config: dict[str, Any] | None = None) -> None: - self._bigfoot_config: dict[str, Any] = bigfoot_config if bigfoot_config is not None else {} + def __init__(self, tripwire_config: dict[str, Any] | None = None) -> None: + self._tripwire_config: dict[str, Any] = tripwire_config if tripwire_config is not None else {} self.registered_plugins: list[Any] = [] def _register_plugin(self, plugin: Any) -> None: @@ -36,30 +36,30 @@ def _register_plugin(self, plugin: Any) -> None: # --------------------------------------------------------------------------- -# Tests for load_bigfoot_config() +# Tests for load_tripwire_config() # --------------------------------------------------------------------------- def test_no_pyproject_returns_empty(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """No pyproject.toml anywhere in the walk → returns {}.""" monkeypatch.chdir(tmp_path) - result = load_bigfoot_config(start=tmp_path) + result = load_tripwire_config(start=tmp_path) assert result == {} -def test_pyproject_without_bigfoot_section_returns_empty(tmp_path: Path) -> None: - """pyproject.toml present but no [tool.bigfoot] → returns {}.""" +def test_pyproject_without_tripwire_section_returns_empty(tmp_path: Path) -> None: + """pyproject.toml present but no [tool.tripwire] → returns {}.""" (tmp_path / "pyproject.toml").write_text("[tool.other]\nkey = 1\n") - result = load_bigfoot_config(start=tmp_path) + result = load_tripwire_config(start=tmp_path) assert result == {} -def test_pyproject_with_bigfoot_http_section(tmp_path: Path) -> None: - """pyproject.toml with [tool.bigfoot.http] → returns correct nested dict.""" +def test_pyproject_with_tripwire_http_section(tmp_path: Path) -> None: + """pyproject.toml with [tool.tripwire.http] → returns correct nested dict.""" (tmp_path / "pyproject.toml").write_text( - "[tool.bigfoot.http]\nrequire_response = true\n" + "[tool.tripwire.http]\nrequire_response = true\n" ) - result = load_bigfoot_config(start=tmp_path) + result = load_tripwire_config(start=tmp_path) assert result == {"http": {"require_response": True}} @@ -67,7 +67,7 @@ def test_malformed_toml_propagates_error(tmp_path: Path) -> None: """Malformed pyproject.toml → tomllib.TOMLDecodeError propagates.""" (tmp_path / "pyproject.toml").write_text("this is not valid toml ===\n") with pytest.raises(tomllib.TOMLDecodeError): - load_bigfoot_config(start=tmp_path) + load_tripwire_config(start=tmp_path) def test_start_param_used_instead_of_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -75,29 +75,29 @@ def test_start_param_used_instead_of_cwd(tmp_path: Path, monkeypatch: pytest.Mon # cwd has no pyproject.toml other = tmp_path / "other" other.mkdir() - (other / "pyproject.toml").write_text("[tool.bigfoot.http]\nrequire_response = false\n") + (other / "pyproject.toml").write_text("[tool.tripwire.http]\nrequire_response = false\n") # Search from 'other', not cwd - result = load_bigfoot_config(start=other) + result = load_tripwire_config(start=other) assert result == {"http": {"require_response": False}} def test_walks_up_to_parent(tmp_path: Path) -> None: """pyproject.toml in parent dir, child has none → finds parent file.""" - (tmp_path / "pyproject.toml").write_text("[tool.bigfoot.http]\nrequire_response = true\n") + (tmp_path / "pyproject.toml").write_text("[tool.tripwire.http]\nrequire_response = true\n") child = tmp_path / "child" child.mkdir() - result = load_bigfoot_config(start=child) + result = load_tripwire_config(start=child) assert result == {"http": {"require_response": True}} def test_first_pyproject_wins(tmp_path: Path) -> None: """Stops at the first pyproject.toml found (nearest ancestor).""" - (tmp_path / "pyproject.toml").write_text("[tool.bigfoot.http]\nrequire_response = true\n") + (tmp_path / "pyproject.toml").write_text("[tool.tripwire.http]\nrequire_response = true\n") child = tmp_path / "child" child.mkdir() (child / "pyproject.toml").write_text("[tool.other]\nkey = 1\n") - # child has pyproject.toml without [tool.bigfoot], so result is {} - result = load_bigfoot_config(start=child) + # child has pyproject.toml without [tool.tripwire], so result is {} + result = load_tripwire_config(start=child) assert result == {} @@ -108,7 +108,7 @@ def test_first_pyproject_wins(tmp_path: Path) -> None: def test_load_config_require_response_true() -> None: """load_config with require_response=True sets _require_response to True.""" - stub = _StubVerifier(bigfoot_config={}) + stub = _StubVerifier(tripwire_config={}) plugin = HttpPlugin(stub) # type: ignore[arg-type] # Default is True assert plugin._require_response is True @@ -118,7 +118,7 @@ def test_load_config_require_response_true() -> None: def test_load_config_require_response_false() -> None: """load_config with require_response=False sets _require_response to False.""" - stub = _StubVerifier(bigfoot_config={}) + stub = _StubVerifier(tripwire_config={}) plugin = HttpPlugin(stub) # type: ignore[arg-type] # Start at True via constructor override plugin._require_response = True @@ -128,7 +128,7 @@ def test_load_config_require_response_false() -> None: def test_load_config_wrong_type_raises_type_error() -> None: """load_config with a non-bool require_response raises TypeError.""" - stub = _StubVerifier(bigfoot_config={}) + stub = _StubVerifier(tripwire_config={}) plugin = HttpPlugin(stub) # type: ignore[arg-type] with pytest.raises(TypeError, match="require_response") as exc_info: plugin.load_config({"require_response": "yes"}) @@ -138,7 +138,7 @@ def test_load_config_wrong_type_raises_type_error() -> None: def test_load_config_int_type_raises_type_error() -> None: """TOML integer (not bool) for require_response raises TypeError.""" - stub = _StubVerifier(bigfoot_config={}) + stub = _StubVerifier(tripwire_config={}) plugin = HttpPlugin(stub) # type: ignore[arg-type] with pytest.raises(TypeError, match="require_response") as exc_info: plugin.load_config({"require_response": 1}) @@ -148,7 +148,7 @@ def test_load_config_int_type_raises_type_error() -> None: def test_load_config_missing_key_no_op() -> None: """load_config with empty dict is a no-op; _require_response unchanged.""" - stub = _StubVerifier(bigfoot_config={}) + stub = _StubVerifier(tripwire_config={}) plugin = HttpPlugin(stub) # type: ignore[arg-type] plugin._require_response = True plugin.load_config({}) @@ -157,7 +157,7 @@ def test_load_config_missing_key_no_op() -> None: def test_load_config_unknown_keys_ignored() -> None: """Unknown keys in config dict are silently ignored (forward-compat).""" - stub = _StubVerifier(bigfoot_config={}) + stub = _StubVerifier(tripwire_config={}) plugin = HttpPlugin(stub) # type: ignore[arg-type] # Should not raise plugin.load_config({"require_response": True, "unknown_key": 42, "future_option": "value"}) @@ -174,7 +174,7 @@ def test_http_plugin_reads_require_response_from_config( ) -> None: """Full integration: require_response=true in TOML → HttpPlugin._require_response is True.""" (tmp_path / "pyproject.toml").write_text( - "[tool.bigfoot.http]\nrequire_response = true\n" + "[tool.tripwire.http]\nrequire_response = true\n" ) monkeypatch.chdir(tmp_path) verifier = StrictVerifier() @@ -186,7 +186,7 @@ def test_http_plugin_reads_require_response_from_config( def test_config_absent_preserves_default( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """No [tool.bigfoot.http] in pyproject.toml → _require_response remains True (the default).""" + """No [tool.tripwire.http] in pyproject.toml → _require_response remains True (the default).""" (tmp_path / "pyproject.toml").write_text("[tool.other]\nkey = 1\n") monkeypatch.chdir(tmp_path) verifier = StrictVerifier() @@ -215,7 +215,7 @@ def test_require_response_wrong_type_raises_on_plugin_init( so the TypeError propagates from the verifier constructor. """ (tmp_path / "pyproject.toml").write_text( - '[tool.bigfoot.http]\nrequire_response = "yes"\n' + '[tool.tripwire.http]\nrequire_response = "yes"\n' ) monkeypatch.chdir(tmp_path) with pytest.raises(TypeError, match="require_response"): diff --git a/tests/unit/test_context.py b/tests/unit/test_context.py index d21a512..dbfcdd4 100644 --- a/tests/unit/test_context.py +++ b/tests/unit/test_context.py @@ -6,14 +6,14 @@ import pytest -from bigfoot._context import ( +from tripwire._context import ( _active_verifier, _any_order_depth, get_active_verifier, get_verifier_or_raise, is_in_any_order, ) -from bigfoot._errors import SandboxNotActiveError +from tripwire._errors import SandboxNotActiveError # --------------------------------------------------------------------------- # ContextVar defaults @@ -63,7 +63,7 @@ def test_get_active_verifier_returns_set_value() -> None: def testget_verifier_or_raise_raises_when_no_verifier() -> None: """With guard mode active (default), raises GuardedCallError instead of SandboxNotActiveError. Disable guard to test the original behavior.""" - from bigfoot._context import _guard_active, _guard_patches_installed + from tripwire._context import _guard_active, _guard_patches_installed guard_token = _guard_active.set(False) patches_token = _guard_patches_installed.set(False) diff --git a/tests/unit/test_context_propagation.py b/tests/unit/test_context_propagation.py index 334f8ce..062d005 100644 --- a/tests/unit/test_context_propagation.py +++ b/tests/unit/test_context_propagation.py @@ -13,12 +13,12 @@ import pytest -from bigfoot._context_propagation import ( +from tripwire._context_propagation import ( install_context_propagation, uninstall_context_propagation, ) -# Use a fresh ContextVar for isolation from bigfoot's own vars +# Use a fresh ContextVar for isolation from tripwire's own vars _test_var: contextvars.ContextVar[str] = contextvars.ContextVar("_test_var", default="unset") # Python 3.14t (free-threaded) natively inherits context to child threads, @@ -31,7 +31,7 @@ @pytest.fixture(autouse=True) def _ensure_uninstalled() -> Generator[None, None, None]: """Ensure context propagation is uninstalled for each test, then restore prior state.""" - import bigfoot._context_propagation as cp + import tripwire._context_propagation as cp was_installed = cp._installed uninstall_context_propagation() yield @@ -252,10 +252,10 @@ def test_uninstall_is_idempotent(self) -> None: # --------------------------------------------------------------------------- -# Bigfoot-specific ContextVar propagation +# Tripwire-specific ContextVar propagation # --------------------------------------------------------------------------- -from bigfoot._context import ( +from tripwire._context import ( _active_verifier, _any_order_depth, _current_test_verifier, @@ -263,12 +263,12 @@ def test_uninstall_is_idempotent(self) -> None: _guard_level, _guard_patches_installed, ) -from bigfoot._recording import _recording_in_progress -from bigfoot.plugins.file_io_plugin import _file_io_bypass +from tripwire._recording import _recording_in_progress +from tripwire.plugins.file_io_plugin import _file_io_bypass -class TestBigfootContextVarsPropagation: - """Verify all bigfoot ContextVars propagate to child threads.""" +class TestTripwireContextVarsPropagation: + """Verify all tripwire ContextVars propagate to child threads.""" @pytest.mark.parametrize( "var,value", @@ -293,12 +293,12 @@ class TestBigfootContextVarsPropagation: "file_io_bypass", ], ) - def test_bigfoot_contextvar_propagates_to_thread( + def test_tripwire_contextvar_propagates_to_thread( self, var: contextvars.ContextVar[object], value: object, ) -> None: - """Each bigfoot ContextVar value is visible in a child thread after install.""" + """Each tripwire ContextVar value is visible in a child thread after install.""" install_context_propagation() token = var.set(value) captured: list[object] = [] @@ -318,8 +318,8 @@ def worker() -> None: # Guard mode propagation # --------------------------------------------------------------------------- -from bigfoot._context import GuardPassThrough, get_verifier_or_raise -from bigfoot._errors import GuardedCallError +from tripwire._context import GuardPassThrough, get_verifier_or_raise +from tripwire._errors import GuardedCallError class TestGuardModePropagation: @@ -350,14 +350,14 @@ def worker() -> None: def test_guard_firewall_allow_propagates_to_child_thread(self) -> None: """When a firewall allow rule matches, child thread passes through.""" - from bigfoot._firewall import ( + from tripwire._firewall import ( Disposition, FirewallRule, FirewallStack, _firewall_stack, ) - from bigfoot._firewall_request import HttpFirewallRequest - from bigfoot._match import M + from tripwire._firewall_request import HttpFirewallRequest + from tripwire._match import M install_context_propagation() @@ -399,7 +399,7 @@ class TestPython314Detection: def test_thread_start_not_patched_when_runtime_handles_it(self) -> None: """When sys.flags.thread_inherit_context is True, Thread.start is not patched but _thread.start_new_thread IS (it doesn't natively inherit).""" - import bigfoot._context_propagation as cp + import tripwire._context_propagation as cp # Ensure clean state uninstall_context_propagation() @@ -464,7 +464,7 @@ def _save_threading_state(self) -> Generator[None, None, None]: def test_thread_start_is_patched_and_restored(self) -> None: """After install, threading.Thread.start is patched; after uninstall it is restored to the original.""" - import bigfoot._context_propagation as cp + import tripwire._context_propagation as cp original = threading.Thread.start diff --git a/tests/unit/test_crypto_plugin.py b/tests/unit/test_crypto_plugin.py index 8d0c5c1..54dd627 100644 --- a/tests/unit/test_crypto_plugin.py +++ b/tests/unit/test_crypto_plugin.py @@ -5,15 +5,15 @@ import cryptography # noqa: F401 import pytest -from bigfoot._context import _current_test_verifier -from bigfoot._errors import ( +from tripwire._context import _current_test_verifier +from tripwire._errors import ( InteractionMismatchError, MissingAssertionFieldsError, UnmockedInteractionError, ) -from bigfoot._timeline import Interaction -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.crypto_plugin import ( +from tripwire._timeline import Interaction +from tripwire._verifier import StrictVerifier +from tripwire.plugins.crypto_plugin import ( _CRYPTOGRAPHY_AVAILABLE, CryptoMockConfig, CryptoPlugin, @@ -57,14 +57,14 @@ def test_cryptography_available_flag() -> None: def test_activate_raises_when_cryptography_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: - import bigfoot.plugins.crypto_plugin as _cp + import tripwire.plugins.crypto_plugin as _cp v, p = _make_verifier_with_plugin() monkeypatch.setattr(_cp, "_CRYPTOGRAPHY_AVAILABLE", False) with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install bigfoot[crypto] to use CryptoPlugin: pip install bigfoot[crypto]" + "Install tripwire[crypto] to use CryptoPlugin: pip install tripwire[crypto]" ) @@ -408,7 +408,7 @@ def test_format_mock_hint() -> None: plugin=p, ) result = p.format_mock_hint(interaction) - assert result == " bigfoot.crypto_mock.mock_encrypt(returns=...)" + assert result == " tripwire.crypto_mock.mock_encrypt(returns=...)" def test_format_unmocked_hint() -> None: @@ -417,7 +417,7 @@ def test_format_unmocked_hint() -> None: assert result == ( "crypto.fernet_encrypt(...) was called but no mock was registered.\n" "Register a mock with:\n" - " bigfoot.crypto_mock.mock_encrypt(returns=...)" + " tripwire.crypto_mock.mock_encrypt(returns=...)" ) @@ -432,33 +432,33 @@ def test_format_unused_mock_hint() -> None: # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.crypto_mock +# Module-level proxy: tripwire.crypto_mock # --------------------------------------------------------------------------- -def test_crypto_mock_proxy_mock_encrypt(bigfoot_verifier: StrictVerifier) -> None: +def test_crypto_mock_proxy_mock_encrypt(tripwire_verifier: StrictVerifier) -> None: from cryptography.fernet import Fernet - import bigfoot + import tripwire - bigfoot.crypto_mock.mock_encrypt(returns=b"proxied_encrypted") + tripwire.crypto_mock.mock_encrypt(returns=b"proxied_encrypted") - with bigfoot.sandbox(): + with tripwire.sandbox(): f = Fernet(Fernet.generate_key()) result = f.encrypt(b"hello") assert result == b"proxied_encrypted" - bigfoot.crypto_mock.assert_encrypt(plaintext_length=5) + tripwire.crypto_mock.assert_encrypt(plaintext_length=5) def test_crypto_mock_proxy_raises_outside_context() -> None: - import bigfoot - from bigfoot._errors import NoActiveVerifierError + import tripwire + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.crypto_mock.mock_encrypt + _ = tripwire.crypto_mock.mock_encrypt finally: _current_test_verifier.reset(token) @@ -469,11 +469,11 @@ def test_crypto_mock_proxy_raises_outside_context() -> None: def test_crypto_plugin_in_all() -> None: - import bigfoot - from bigfoot.plugins.crypto_plugin import CryptoPlugin as _CryptoPlugin + import tripwire + from tripwire.plugins.crypto_plugin import CryptoPlugin as _CryptoPlugin - assert bigfoot.CryptoPlugin is _CryptoPlugin - assert type(bigfoot.crypto_mock).__name__ == "_CryptoProxy" + assert tripwire.CryptoPlugin is _CryptoPlugin + assert type(tripwire.crypto_mock).__name__ == "_CryptoProxy" # --------------------------------------------------------------------------- @@ -481,86 +481,86 @@ def test_crypto_plugin_in_all() -> None: # --------------------------------------------------------------------------- -def test_crypto_interactions_not_auto_asserted(bigfoot_verifier: StrictVerifier) -> None: +def test_crypto_interactions_not_auto_asserted(tripwire_verifier: StrictVerifier) -> None: from cryptography.fernet import Fernet - import bigfoot + import tripwire - bigfoot.crypto_mock.mock_encrypt(returns=b"encrypted") - with bigfoot.sandbox(): + tripwire.crypto_mock.mock_encrypt(returns=b"encrypted") + with tripwire.sandbox(): f = Fernet(Fernet.generate_key()) f.encrypt(b"data") - timeline = bigfoot_verifier._timeline + timeline = tripwire_verifier._timeline interactions = timeline.all_unasserted() assert len(interactions) == 1 assert interactions[0].source_id == "crypto:fernet_encrypt" - bigfoot.crypto_mock.assert_encrypt(plaintext_length=4) + tripwire.crypto_mock.assert_encrypt(plaintext_length=4) -def test_assert_encrypt_typed_helper(bigfoot_verifier: StrictVerifier) -> None: +def test_assert_encrypt_typed_helper(tripwire_verifier: StrictVerifier) -> None: from cryptography.fernet import Fernet - import bigfoot + import tripwire - bigfoot.crypto_mock.mock_encrypt(returns=b"encrypted") - with bigfoot.sandbox(): + tripwire.crypto_mock.mock_encrypt(returns=b"encrypted") + with tripwire.sandbox(): f = Fernet(Fernet.generate_key()) f.encrypt(b"hello") - bigfoot.crypto_mock.assert_encrypt(plaintext_length=5) + tripwire.crypto_mock.assert_encrypt(plaintext_length=5) -def test_assert_decrypt_typed_helper(bigfoot_verifier: StrictVerifier) -> None: +def test_assert_decrypt_typed_helper(tripwire_verifier: StrictVerifier) -> None: from cryptography.fernet import Fernet - import bigfoot + import tripwire - bigfoot.crypto_mock.mock_decrypt(returns=b"decrypted") - with bigfoot.sandbox(): + tripwire.crypto_mock.mock_decrypt(returns=b"decrypted") + with tripwire.sandbox(): f = Fernet(Fernet.generate_key()) f.decrypt(b"gAAAAABtoken") - bigfoot.crypto_mock.assert_decrypt(token=b"gAAAAABtoken", ttl=None) + tripwire.crypto_mock.assert_decrypt(token=b"gAAAAABtoken", ttl=None) -def test_assert_generate_key_typed_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_generate_key_typed_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire mock_key = object() - bigfoot.crypto_mock.mock_generate_key(returns=mock_key) - with bigfoot.sandbox(): + tripwire.crypto_mock.mock_generate_key(returns=mock_key) + with tripwire.sandbox(): from cryptography.hazmat.primitives.asymmetric import rsa rsa.generate_private_key(public_exponent=65537, key_size=2048) - bigfoot.crypto_mock.assert_generate_key(algorithm="RSA", key_size=2048) + tripwire.crypto_mock.assert_generate_key(algorithm="RSA", key_size=2048) -def test_assert_encrypt_wrong_params_raises(bigfoot_verifier: StrictVerifier) -> None: +def test_assert_encrypt_wrong_params_raises(tripwire_verifier: StrictVerifier) -> None: from cryptography.fernet import Fernet - import bigfoot + import tripwire - bigfoot.crypto_mock.mock_encrypt(returns=b"encrypted") - with bigfoot.sandbox(): + tripwire.crypto_mock.mock_encrypt(returns=b"encrypted") + with tripwire.sandbox(): f = Fernet(Fernet.generate_key()) f.encrypt(b"hello") with pytest.raises(InteractionMismatchError): - bigfoot.crypto_mock.assert_encrypt(plaintext_length=999) - bigfoot.crypto_mock.assert_encrypt(plaintext_length=5) + tripwire.crypto_mock.assert_encrypt(plaintext_length=999) + tripwire.crypto_mock.assert_encrypt(plaintext_length=5) -def test_missing_assertion_fields_raises(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_missing_assertion_fields_raises(tripwire_verifier: StrictVerifier) -> None: + import tripwire mock_key = object() - bigfoot.crypto_mock.mock_generate_key(returns=mock_key) - with bigfoot.sandbox(): + tripwire.crypto_mock.mock_generate_key(returns=mock_key) + with tripwire.sandbox(): from cryptography.hazmat.primitives.asymmetric import rsa rsa.generate_private_key(public_exponent=65537, key_size=2048) - from bigfoot.plugins.crypto_plugin import _CryptoSentinel + from tripwire.plugins.crypto_plugin import _CryptoSentinel sentinel = _CryptoSentinel("generate_key") with pytest.raises(MissingAssertionFieldsError): - bigfoot.assert_interaction(sentinel, algorithm="RSA") - bigfoot.crypto_mock.assert_generate_key(algorithm="RSA", key_size=2048) + tripwire.assert_interaction(sentinel, algorithm="RSA") + tripwire.crypto_mock.assert_generate_key(algorithm="RSA", key_size=2048) diff --git a/tests/unit/test_database_plugin.py b/tests/unit/test_database_plugin.py index 948a3e6..f6c18fc 100644 --- a/tests/unit/test_database_plugin.py +++ b/tests/unit/test_database_plugin.py @@ -6,12 +6,12 @@ import pytest -import bigfoot -from bigfoot._context import _current_test_verifier -from bigfoot._errors import InvalidStateError, UnmockedInteractionError -from bigfoot._state_machine_plugin import ScriptStep -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.database_plugin import DatabasePlugin +import tripwire +from tripwire._context import _current_test_verifier +from tripwire._errors import InvalidStateError, UnmockedInteractionError +from tripwire._state_machine_plugin import ScriptStep +from tripwire._verifier import StrictVerifier +from tripwire.plugins.database_plugin import DatabasePlugin # --------------------------------------------------------------------------- # Helpers @@ -34,7 +34,7 @@ def _make_verifier_with_plugin() -> tuple[StrictVerifier, DatabasePlugin]: def _reset_install_count() -> None: """Force-reset the class-level install count to 0 and restore patches if leaked.""" - from bigfoot.plugins.database_plugin import DatabasePlugin + from tripwire.plugins.database_plugin import DatabasePlugin with DatabasePlugin._install_lock: DatabasePlugin._install_count = 0 @@ -100,7 +100,7 @@ def test_unmocked_source_id() -> None: # ESCAPE: test_activate_installs_patch -# CLAIM: After activate(), sqlite3.connect is replaced with a bigfoot interceptor. +# CLAIM: After activate(), sqlite3.connect is replaced with a tripwire interceptor. # PATH: activate() -> _install_count == 0 -> store original -> install interceptor. # CHECK: sqlite3.connect is not the original function after activate(). # MUTATION: Skipping patch installation leaves original in place; identity check fails. @@ -117,7 +117,7 @@ def test_activate_installs_patch() -> None: # CLAIM: After activate() then deactivate(), sqlite3.connect is restored to the original. # PATH: deactivate() -> _install_count reaches 0 -> restore original. # CHECK: sqlite3.connect is the original function again. -# MUTATION: Not restoring in deactivate() leaves bigfoot's interceptor in place. +# MUTATION: Not restoring in deactivate() leaves tripwire's interceptor in place. # ESCAPE: Nothing reasonable -- identity comparison against saved original. def test_deactivate_restores_patch() -> None: v, p = _make_verifier_with_plugin() @@ -137,7 +137,7 @@ def test_deactivate_restores_patch() -> None: # MUTATION: Restoring on first deactivate would fail the mid-point identity check. # ESCAPE: Nothing reasonable -- sequential identity checks prove count-controlled restoration. def test_reference_counting_nested() -> None: - from bigfoot.plugins.database_plugin import DatabasePlugin + from tripwire.plugins.database_plugin import DatabasePlugin v, p = _make_verifier_with_plugin() original_connect = sqlite3.connect @@ -483,12 +483,12 @@ def test_connect_with_empty_queue_raises_unmocked() -> None: # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.db_mock +# Module-level proxy: tripwire.db_mock # --------------------------------------------------------------------------- # ESCAPE: test_db_mock_proxy_new_session -# CLAIM: bigfoot.db_mock.new_session() returns a SessionHandle that can +# CLAIM: tripwire.db_mock.new_session() returns a SessionHandle that can # be used to configure a session without importing DatabasePlugin directly. # PATH: _DatabaseProxy.__getattr__("new_session") -> get verifier -> find/create DatabasePlugin -> # return plugin.new_session. @@ -496,10 +496,10 @@ def test_connect_with_empty_queue_raises_unmocked() -> None: # Chaining .expect() on it does not raise. # MUTATION: Returning None instead of a SessionHandle would fail isinstance check. # ESCAPE: Nothing reasonable -- both isinstance and chained .expect() call check it. -def test_db_mock_proxy_new_session(bigfoot_verifier: StrictVerifier) -> None: - from bigfoot._state_machine_plugin import SessionHandle +def test_db_mock_proxy_new_session(tripwire_verifier: StrictVerifier) -> None: + from tripwire._state_machine_plugin import SessionHandle - session = bigfoot.db_mock.new_session() + session = tripwire.db_mock.new_session() assert isinstance(session, SessionHandle) # Chaining expect() with required=False so it doesn't trigger UnusedMocksError at teardown. result = session.expect("execute", returns=[], required=False) @@ -507,18 +507,18 @@ def test_db_mock_proxy_new_session(bigfoot_verifier: StrictVerifier) -> None: # ESCAPE: test_db_mock_proxy_raises_outside_context -# CLAIM: Accessing bigfoot.db_mock outside a test context raises NoActiveVerifierError. +# CLAIM: Accessing tripwire.db_mock outside a test context raises NoActiveVerifierError. # PATH: _DatabaseProxy.__getattr__ -> _get_test_verifier_or_raise -> NoActiveVerifierError. # CHECK: NoActiveVerifierError raised. # MUTATION: Silently returning None would not raise and hide context failures. # ESCAPE: Nothing reasonable -- exact exception type. def test_db_mock_proxy_raises_outside_context() -> None: - from bigfoot._errors import NoActiveVerifierError + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.db_mock.new_session + _ = tripwire.db_mock.new_session finally: _current_test_verifier.reset(token) @@ -655,17 +655,17 @@ def test_cursor_iter() -> None: # --------------------------------------------------------------------------- -# DatabasePlugin is exposed as bigfoot.DatabasePlugin +# DatabasePlugin is exposed as tripwire.DatabasePlugin # --------------------------------------------------------------------------- # ESCAPE: test_database_plugin_exported -# CLAIM: bigfoot.DatabasePlugin points to the DatabasePlugin class. -# PATH: Import bigfoot; access bigfoot.DatabasePlugin. -# CHECK: bigfoot.DatabasePlugin is the DatabasePlugin class from database_plugin module. +# CLAIM: tripwire.DatabasePlugin points to the DatabasePlugin class. +# PATH: Import tripwire; access tripwire.DatabasePlugin. +# CHECK: tripwire.DatabasePlugin is the DatabasePlugin class from database_plugin module. # MUTATION: Not adding to __init__.py would raise AttributeError. # ESCAPE: Nothing reasonable -- identity check against the imported class. def test_database_plugin_exported() -> None: - from bigfoot.plugins.database_plugin import DatabasePlugin + from tripwire.plugins.database_plugin import DatabasePlugin - assert bigfoot.DatabasePlugin is DatabasePlugin + assert tripwire.DatabasePlugin is DatabasePlugin diff --git a/tests/unit/test_default_guard_error.py b/tests/unit/test_default_guard_error.py new file mode 100644 index 0000000..126a66b --- /dev/null +++ b/tests/unit/test_default_guard_error.py @@ -0,0 +1,14 @@ +"""C1-T3: default guard level is 'error' (Proposal 1 default flip).""" + +from __future__ import annotations + +from tripwire.pytest_plugin import _resolve_guard_level + + +def test_default_guard_is_error() -> None: + """With NO `[tool.tripwire]` config, the resolved guard level is 'error'. + + Replaces the prior default of 'warn'. New projects fail loud on unmocked + I/O outside a sandbox; legacy projects must opt back into warn explicitly. + """ + assert _resolve_guard_level({}) == "error" diff --git a/tests/unit/test_dns_plugin.py b/tests/unit/test_dns_plugin.py index 7cca174..eb89ab1 100644 --- a/tests/unit/test_dns_plugin.py +++ b/tests/unit/test_dns_plugin.py @@ -6,15 +6,15 @@ import pytest -from bigfoot._context import _current_test_verifier -from bigfoot._errors import ( +from tripwire._context import _current_test_verifier +from tripwire._errors import ( InteractionMismatchError, MissingAssertionFieldsError, UnmockedInteractionError, ) -from bigfoot._timeline import Interaction -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.dns_plugin import ( +from tripwire._timeline import Interaction +from tripwire._verifier import StrictVerifier +from tripwire.plugins.dns_plugin import ( DnsMockConfig, DnsPlugin, ) @@ -87,7 +87,7 @@ def test_dns_mock_config_defaults() -> None: def test_activate_installs_getaddrinfo_patch() -> None: - """After activate(), socket.getaddrinfo is replaced with bigfoot interceptor.""" + """After activate(), socket.getaddrinfo is replaced with tripwire interceptor.""" original = socket.getaddrinfo v, p = _make_verifier_with_plugin() p.activate() @@ -96,7 +96,7 @@ def test_activate_installs_getaddrinfo_patch() -> None: def test_activate_installs_gethostbyname_patch() -> None: - """After activate(), socket.gethostbyname is replaced with bigfoot interceptor.""" + """After activate(), socket.gethostbyname is replaced with tripwire interceptor.""" original = socket.gethostbyname v, p = _make_verifier_with_plugin() p.activate() @@ -151,19 +151,19 @@ def test_mock_getaddrinfo_returns_value() -> None: assert result == expected_result -def test_mock_getaddrinfo_full_assertion(bigfoot_verifier: StrictVerifier) -> None: +def test_mock_getaddrinfo_full_assertion(tripwire_verifier: StrictVerifier) -> None: """assert_getaddrinfo asserts all fields: host, port, family, type, proto.""" - import bigfoot + import tripwire expected_result = [ (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 80)) ] - bigfoot.dns_mock.mock_getaddrinfo("example.com", returns=expected_result) + tripwire.dns_mock.mock_getaddrinfo("example.com", returns=expected_result) - with bigfoot.sandbox(): + with tripwire.sandbox(): socket.getaddrinfo("example.com", 80, socket.AF_INET, socket.SOCK_STREAM, 6) - bigfoot.dns_mock.assert_getaddrinfo( + tripwire.dns_mock.assert_getaddrinfo( host="example.com", port=80, family=socket.AF_INET, @@ -172,19 +172,19 @@ def test_mock_getaddrinfo_full_assertion(bigfoot_verifier: StrictVerifier) -> No ) -def test_mock_getaddrinfo_default_args(bigfoot_verifier: StrictVerifier) -> None: +def test_mock_getaddrinfo_default_args(tripwire_verifier: StrictVerifier) -> None: """getaddrinfo with default family/type/proto records 0 for each.""" - import bigfoot + import tripwire expected_result = [ (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 80)) ] - bigfoot.dns_mock.mock_getaddrinfo("example.com", returns=expected_result) + tripwire.dns_mock.mock_getaddrinfo("example.com", returns=expected_result) - with bigfoot.sandbox(): + with tripwire.sandbox(): socket.getaddrinfo("example.com", 80) - bigfoot.dns_mock.assert_getaddrinfo( + tripwire.dns_mock.assert_getaddrinfo( host="example.com", port=80, family=0, @@ -209,16 +209,16 @@ def test_mock_gethostbyname_returns_value() -> None: assert result == "93.184.216.34" -def test_mock_gethostbyname_full_assertion(bigfoot_verifier: StrictVerifier) -> None: +def test_mock_gethostbyname_full_assertion(tripwire_verifier: StrictVerifier) -> None: """assert_gethostbyname asserts hostname field.""" - import bigfoot + import tripwire - bigfoot.dns_mock.mock_gethostbyname("example.com", returns="93.184.216.34") + tripwire.dns_mock.mock_gethostbyname("example.com", returns="93.184.216.34") - with bigfoot.sandbox(): + with tripwire.sandbox(): socket.gethostbyname("example.com") - bigfoot.dns_mock.assert_gethostbyname(hostname="example.com") + tripwire.dns_mock.assert_gethostbyname(hostname="example.com") # --------------------------------------------------------------------------- @@ -281,24 +281,24 @@ def test_get_unused_mocks_excludes_required_false() -> None: # --------------------------------------------------------------------------- -def test_missing_assertion_fields_getaddrinfo(bigfoot_verifier: StrictVerifier) -> None: +def test_missing_assertion_fields_getaddrinfo(tripwire_verifier: StrictVerifier) -> None: """Asserting getaddrinfo with incomplete fields raises MissingAssertionFieldsError.""" - import bigfoot - from bigfoot.plugins.dns_plugin import _DnsSentinel + import tripwire + from tripwire.plugins.dns_plugin import _DnsSentinel - bigfoot.dns_mock.mock_getaddrinfo("example.com", returns=[]) + tripwire.dns_mock.mock_getaddrinfo("example.com", returns=[]) - with bigfoot.sandbox(): + with tripwire.sandbox(): socket.getaddrinfo("example.com", 80) sentinel = _DnsSentinel("dns:getaddrinfo:example.com") with pytest.raises(MissingAssertionFieldsError) as exc_info: # Only pass host, omit port/family/type/proto - bigfoot_verifier.assert_interaction(sentinel, host="example.com") + tripwire_verifier.assert_interaction(sentinel, host="example.com") assert "port" in exc_info.value.missing_fields # Now assert fully so teardown passes - bigfoot.dns_mock.assert_getaddrinfo( + tripwire.dns_mock.assert_getaddrinfo( host="example.com", port=80, family=0, type=0, proto=0, ) @@ -358,20 +358,20 @@ def test_mock_getaddrinfo_fifo() -> None: # --------------------------------------------------------------------------- -def test_dns_interactions_not_auto_asserted(bigfoot_verifier: StrictVerifier) -> None: +def test_dns_interactions_not_auto_asserted(tripwire_verifier: StrictVerifier) -> None: """DNS interactions are NOT auto-asserted -- they land on the timeline unasserted.""" - import bigfoot + import tripwire - bigfoot.dns_mock.mock_gethostbyname("example.com", returns="1.2.3.4") - with bigfoot.sandbox(): + tripwire.dns_mock.mock_gethostbyname("example.com", returns="1.2.3.4") + with tripwire.sandbox(): socket.gethostbyname("example.com") - timeline = bigfoot_verifier._timeline + timeline = tripwire_verifier._timeline interactions = timeline.all_unasserted() assert len(interactions) == 1 assert interactions[0].source_id == "dns:gethostbyname:example.com" # Assert it so verify_all() at teardown succeeds - bigfoot.dns_mock.assert_gethostbyname(hostname="example.com") + tripwire.dns_mock.assert_gethostbyname(hostname="example.com") # --------------------------------------------------------------------------- @@ -443,7 +443,7 @@ def test_format_mock_hint_getaddrinfo() -> None: plugin=p, ) result = p.format_mock_hint(interaction) - assert result == " bigfoot.dns_mock.mock_getaddrinfo('example.com', returns=...)" + assert result == " tripwire.dns_mock.mock_getaddrinfo('example.com', returns=...)" def test_format_unmocked_hint() -> None: @@ -452,7 +452,7 @@ def test_format_unmocked_hint() -> None: assert result == ( "socket.getaddrinfo('example.com', ...) was called but no mock was registered.\n" "Register a mock with:\n" - " bigfoot.dns_mock.mock_getaddrinfo('example.com', returns=...)" + " tripwire.dns_mock.mock_getaddrinfo('example.com', returns=...)" ) @@ -466,7 +466,7 @@ def test_format_assert_hint_getaddrinfo() -> None: ) result = p.format_assert_hint(interaction) assert result == ( - " bigfoot.dns_mock.assert_getaddrinfo(\n" + " tripwire.dns_mock.assert_getaddrinfo(\n" " host='example.com',\n" " port=80,\n" " family=0,\n" @@ -488,24 +488,24 @@ def test_format_unused_mock_hint() -> None: # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.dns_mock +# Module-level proxy: tripwire.dns_mock # --------------------------------------------------------------------------- -def test_dns_mock_proxy_mock_getaddrinfo(bigfoot_verifier: StrictVerifier) -> None: - """bigfoot.dns_mock.mock_getaddrinfo works via the proxy.""" - import bigfoot +def test_dns_mock_proxy_mock_getaddrinfo(tripwire_verifier: StrictVerifier) -> None: + """tripwire.dns_mock.mock_getaddrinfo works via the proxy.""" + import tripwire expected_result = [ (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 80)) ] - bigfoot.dns_mock.mock_getaddrinfo("example.com", returns=expected_result) + tripwire.dns_mock.mock_getaddrinfo("example.com", returns=expected_result) - with bigfoot.sandbox(): + with tripwire.sandbox(): result = socket.getaddrinfo("example.com", 80) assert result == expected_result - bigfoot.dns_mock.assert_getaddrinfo( + tripwire.dns_mock.assert_getaddrinfo( host="example.com", port=80, family=0, @@ -515,14 +515,14 @@ def test_dns_mock_proxy_mock_getaddrinfo(bigfoot_verifier: StrictVerifier) -> No def test_dns_mock_proxy_raises_outside_context() -> None: - """Accessing bigfoot.dns_mock outside a test context raises NoActiveVerifierError.""" - import bigfoot - from bigfoot._errors import NoActiveVerifierError + """Accessing tripwire.dns_mock outside a test context raises NoActiveVerifierError.""" + import tripwire + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.dns_mock.mock_getaddrinfo + _ = tripwire.dns_mock.mock_getaddrinfo finally: _current_test_verifier.reset(token) @@ -533,12 +533,12 @@ def test_dns_mock_proxy_raises_outside_context() -> None: def test_dns_plugin_in_all() -> None: - """DnsPlugin and dns_mock are exported from bigfoot.""" - import bigfoot + """DnsPlugin and dns_mock are exported from tripwire.""" + import tripwire - assert "DnsPlugin" in bigfoot.__all__ - assert "dns_mock" in bigfoot.__all__ - assert type(bigfoot.dns_mock).__name__ == "_DnsProxy" + assert "DnsPlugin" in tripwire.__all__ + assert "dns_mock" in tripwire.__all__ + assert type(tripwire.dns_mock).__name__ == "_DnsProxy" # --------------------------------------------------------------------------- @@ -546,17 +546,17 @@ def test_dns_plugin_in_all() -> None: # --------------------------------------------------------------------------- -def test_assert_getaddrinfo_wrong_args_raises(bigfoot_verifier: StrictVerifier) -> None: +def test_assert_getaddrinfo_wrong_args_raises(tripwire_verifier: StrictVerifier) -> None: """assert_getaddrinfo with wrong values raises InteractionMismatchError.""" - import bigfoot + import tripwire - bigfoot.dns_mock.mock_getaddrinfo("example.com", returns=[]) + tripwire.dns_mock.mock_getaddrinfo("example.com", returns=[]) - with bigfoot.sandbox(): + with tripwire.sandbox(): socket.getaddrinfo("example.com", 80) with pytest.raises(InteractionMismatchError): - bigfoot.dns_mock.assert_getaddrinfo( + tripwire.dns_mock.assert_getaddrinfo( host="wrong.com", port=80, family=0, @@ -564,7 +564,7 @@ def test_assert_getaddrinfo_wrong_args_raises(bigfoot_verifier: StrictVerifier) proto=0, ) # Assert correctly so teardown passes - bigfoot.dns_mock.assert_getaddrinfo( + tripwire.dns_mock.assert_getaddrinfo( host="example.com", port=80, family=0, @@ -597,18 +597,18 @@ def test_mock_resolve_returns_value(self) -> None: assert result == ["93.184.216.34"] - def test_mock_resolve_full_assertion(self, bigfoot_verifier: StrictVerifier) -> None: + def test_mock_resolve_full_assertion(self, tripwire_verifier: StrictVerifier) -> None: """assert_resolve asserts qname and rdtype fields.""" - import bigfoot + import tripwire - bigfoot.dns_mock.mock_resolve("example.com", "A", returns=["93.184.216.34"]) + tripwire.dns_mock.mock_resolve("example.com", "A", returns=["93.184.216.34"]) - with bigfoot.sandbox(): + with tripwire.sandbox(): import dns.resolver dns.resolver.resolve("example.com", "A") - bigfoot.dns_mock.assert_resolve(qname="example.com", rdtype="A") + tripwire.dns_mock.assert_resolve(qname="example.com", rdtype="A") def test_unmocked_resolve_raises(self) -> None: """resolve without mock raises UnmockedInteractionError.""" diff --git a/tests/unit/test_elasticsearch_plugin.py b/tests/unit/test_elasticsearch_plugin.py index 0a2896c..2b8f174 100644 --- a/tests/unit/test_elasticsearch_plugin.py +++ b/tests/unit/test_elasticsearch_plugin.py @@ -5,15 +5,15 @@ import elasticsearch import pytest -from bigfoot._context import _current_test_verifier -from bigfoot._errors import ( +from tripwire._context import _current_test_verifier +from tripwire._errors import ( InteractionMismatchError, MissingAssertionFieldsError, UnmockedInteractionError, ) -from bigfoot._timeline import Interaction -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.elasticsearch_plugin import ( +from tripwire._timeline import Interaction +from tripwire._verifier import StrictVerifier +from tripwire.plugins.elasticsearch_plugin import ( _ELASTICSEARCH_AVAILABLE, ElasticsearchMockConfig, ElasticsearchPlugin, @@ -57,15 +57,15 @@ def test_elasticsearch_available_flag() -> None: def test_activate_raises_when_elasticsearch_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: - import bigfoot.plugins.elasticsearch_plugin as _ep + import tripwire.plugins.elasticsearch_plugin as _ep v, p = _make_verifier_with_plugin() monkeypatch.setattr(_ep, "_ELASTICSEARCH_AVAILABLE", False) with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install bigfoot[elasticsearch] to use ElasticsearchPlugin: " - "pip install bigfoot[elasticsearch]" + "Install tripwire[elasticsearch] to use ElasticsearchPlugin: " + "pip install tripwire[elasticsearch]" ) @@ -339,7 +339,7 @@ def test_format_mock_hint() -> None: plugin=p, ) result = p.format_mock_hint(interaction) - assert result == " bigfoot.elasticsearch_mock.mock_operation('search', returns=...)" + assert result == " tripwire.elasticsearch_mock.mock_operation('search', returns=...)" def test_format_unmocked_hint() -> None: @@ -348,7 +348,7 @@ def test_format_unmocked_hint() -> None: assert result == ( "elasticsearch.index(...) was called but no mock was registered.\n" "Register a mock with:\n" - " bigfoot.elasticsearch_mock.mock_operation('index', returns=...)" + " tripwire.elasticsearch_mock.mock_operation('index', returns=...)" ) @@ -362,7 +362,7 @@ def test_format_assert_hint() -> None: ) result = p.format_assert_hint(interaction) assert result == ( - " bigfoot.elasticsearch_mock.assert_index(\n" + " tripwire.elasticsearch_mock.assert_index(\n" " index='my-index',\n" " document={'a': 1},\n" " )" @@ -380,31 +380,31 @@ def test_format_unused_mock_hint() -> None: # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.elasticsearch_mock +# Module-level proxy: tripwire.elasticsearch_mock # --------------------------------------------------------------------------- -def test_elasticsearch_mock_proxy_mock_operation(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_elasticsearch_mock_proxy_mock_operation(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.elasticsearch_mock.mock_operation("index", returns={"_id": "1"}) + tripwire.elasticsearch_mock.mock_operation("index", returns={"_id": "1"}) - with bigfoot.sandbox(): + with tripwire.sandbox(): es = elasticsearch.Elasticsearch("http://localhost:9200") result = es.index(index="my-index", document={"field": "val"}) assert result == {"_id": "1"} - bigfoot.elasticsearch_mock.assert_index(index="my-index", document={"field": "val"}) + tripwire.elasticsearch_mock.assert_index(index="my-index", document={"field": "val"}) def test_elasticsearch_mock_proxy_raises_outside_context() -> None: - import bigfoot - from bigfoot._errors import NoActiveVerifierError + import tripwire + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.elasticsearch_mock.mock_operation + _ = tripwire.elasticsearch_mock.mock_operation finally: _current_test_verifier.reset(token) @@ -415,13 +415,13 @@ def test_elasticsearch_mock_proxy_raises_outside_context() -> None: def test_elasticsearch_plugin_in_all() -> None: - import bigfoot - from bigfoot.plugins.elasticsearch_plugin import ( + import tripwire + from tripwire.plugins.elasticsearch_plugin import ( ElasticsearchPlugin as _ElasticsearchPlugin, ) - assert bigfoot.ElasticsearchPlugin is _ElasticsearchPlugin - assert type(bigfoot.elasticsearch_mock).__name__ == "_ElasticsearchProxy" + assert tripwire.ElasticsearchPlugin is _ElasticsearchPlugin + assert type(tripwire.elasticsearch_mock).__name__ == "_ElasticsearchProxy" # --------------------------------------------------------------------------- @@ -429,99 +429,99 @@ def test_elasticsearch_plugin_in_all() -> None: # --------------------------------------------------------------------------- -def test_elasticsearch_interactions_not_auto_asserted(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_elasticsearch_interactions_not_auto_asserted(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.elasticsearch_mock.mock_operation("index", returns={"_id": "1"}) - with bigfoot.sandbox(): + tripwire.elasticsearch_mock.mock_operation("index", returns={"_id": "1"}) + with tripwire.sandbox(): es = elasticsearch.Elasticsearch("http://localhost:9200") es.index(index="idx", document={"a": 1}) - timeline = bigfoot_verifier._timeline + timeline = tripwire_verifier._timeline interactions = timeline.all_unasserted() assert len(interactions) == 1 assert interactions[0].source_id == "elasticsearch:index" - bigfoot.elasticsearch_mock.assert_index(index="idx", document={"a": 1}) + tripwire.elasticsearch_mock.assert_index(index="idx", document={"a": 1}) -def test_assert_index_typed_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_index_typed_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.elasticsearch_mock.mock_operation("index", returns={"_id": "1"}) - with bigfoot.sandbox(): + tripwire.elasticsearch_mock.mock_operation("index", returns={"_id": "1"}) + with tripwire.sandbox(): es = elasticsearch.Elasticsearch("http://localhost:9200") es.index(index="idx", document={"a": 1}) - bigfoot.elasticsearch_mock.assert_index(index="idx", document={"a": 1}) + tripwire.elasticsearch_mock.assert_index(index="idx", document={"a": 1}) -def test_assert_search_typed_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_search_typed_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.elasticsearch_mock.mock_operation("search", returns={"hits": {"hits": []}}) - with bigfoot.sandbox(): + tripwire.elasticsearch_mock.mock_operation("search", returns={"hits": {"hits": []}}) + with tripwire.sandbox(): es = elasticsearch.Elasticsearch("http://localhost:9200") es.search(index="idx", query={"match_all": {}}) - bigfoot.elasticsearch_mock.assert_search(index="idx", query={"match_all": {}}) + tripwire.elasticsearch_mock.assert_search(index="idx", query={"match_all": {}}) -def test_assert_get_typed_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_get_typed_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.elasticsearch_mock.mock_operation("get", returns={"_source": {"a": 1}}) - with bigfoot.sandbox(): + tripwire.elasticsearch_mock.mock_operation("get", returns={"_source": {"a": 1}}) + with tripwire.sandbox(): es = elasticsearch.Elasticsearch("http://localhost:9200") es.get(index="idx", id="1") - bigfoot.elasticsearch_mock.assert_get(index="idx", id="1") + tripwire.elasticsearch_mock.assert_get(index="idx", id="1") -def test_assert_delete_typed_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_delete_typed_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.elasticsearch_mock.mock_operation("delete", returns={"result": "deleted"}) - with bigfoot.sandbox(): + tripwire.elasticsearch_mock.mock_operation("delete", returns={"result": "deleted"}) + with tripwire.sandbox(): es = elasticsearch.Elasticsearch("http://localhost:9200") es.delete(index="idx", id="1") - bigfoot.elasticsearch_mock.assert_delete(index="idx", id="1") + tripwire.elasticsearch_mock.assert_delete(index="idx", id="1") -def test_assert_bulk_typed_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_bulk_typed_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire ops = [{"index": {"_index": "idx", "_id": "1"}}, {"field": "val"}] - bigfoot.elasticsearch_mock.mock_operation("bulk", returns={"items": []}) - with bigfoot.sandbox(): + tripwire.elasticsearch_mock.mock_operation("bulk", returns={"items": []}) + with tripwire.sandbox(): es = elasticsearch.Elasticsearch("http://localhost:9200") es.bulk(operations=ops) - bigfoot.elasticsearch_mock.assert_bulk(operations=ops) + tripwire.elasticsearch_mock.assert_bulk(operations=ops) -def test_assert_index_wrong_params_raises(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_index_wrong_params_raises(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.elasticsearch_mock.mock_operation("index", returns={"_id": "1"}) - with bigfoot.sandbox(): + tripwire.elasticsearch_mock.mock_operation("index", returns={"_id": "1"}) + with tripwire.sandbox(): es = elasticsearch.Elasticsearch("http://localhost:9200") es.index(index="idx", document={"a": 1}) with pytest.raises(InteractionMismatchError): - bigfoot.elasticsearch_mock.assert_index(index="wrong", document={"a": 1}) + tripwire.elasticsearch_mock.assert_index(index="wrong", document={"a": 1}) # Assert correctly so teardown passes - bigfoot.elasticsearch_mock.assert_index(index="idx", document={"a": 1}) + tripwire.elasticsearch_mock.assert_index(index="idx", document={"a": 1}) -def test_missing_assertion_fields_raises(bigfoot_verifier: StrictVerifier) -> None: +def test_missing_assertion_fields_raises(tripwire_verifier: StrictVerifier) -> None: """Incomplete fields in assert_interaction raises MissingAssertionFieldsError.""" - import bigfoot + import tripwire - bigfoot.elasticsearch_mock.mock_operation("get", returns={"_source": {"a": 1}}) - with bigfoot.sandbox(): + tripwire.elasticsearch_mock.mock_operation("get", returns={"_source": {"a": 1}}) + with tripwire.sandbox(): es = elasticsearch.Elasticsearch("http://localhost:9200") es.get(index="idx", id="1") - from bigfoot.plugins.elasticsearch_plugin import _ElasticsearchSentinel + from tripwire.plugins.elasticsearch_plugin import _ElasticsearchSentinel sentinel = _ElasticsearchSentinel("get") with pytest.raises(MissingAssertionFieldsError): # Only providing index, missing id - bigfoot.assert_interaction(sentinel, index="idx") + tripwire.assert_interaction(sentinel, index="idx") # Assert correctly so teardown passes - bigfoot.elasticsearch_mock.assert_get(index="idx", id="1") + tripwire.elasticsearch_mock.assert_get(index="idx", id="1") diff --git a/tests/unit/test_enforce_flag.py b/tests/unit/test_enforce_flag.py index dd01737..d320a21 100644 --- a/tests/unit/test_enforce_flag.py +++ b/tests/unit/test_enforce_flag.py @@ -1,8 +1,8 @@ """Tests for the enforce flag on Interaction.""" -from bigfoot._base_plugin import BasePlugin -from bigfoot._timeline import Interaction -from bigfoot._verifier import StrictVerifier +from tripwire._base_plugin import BasePlugin +from tripwire._timeline import Interaction +from tripwire._verifier import StrictVerifier class _MinimalPlugin(BasePlugin): @@ -62,9 +62,9 @@ def test_interaction_enforce_can_be_set_to_false() -> None: assert interaction.enforce is False -def test_verify_all_skips_non_enforced_interactions(bigfoot_verifier: StrictVerifier) -> None: +def test_verify_all_skips_non_enforced_interactions(tripwire_verifier: StrictVerifier) -> None: """verify_all() does not raise for unasserted interactions with enforce=False.""" - plugin = _MinimalPlugin(bigfoot_verifier) + plugin = _MinimalPlugin(tripwire_verifier) interaction = Interaction( source_id="test:op", sequence=0, @@ -72,19 +72,19 @@ def test_verify_all_skips_non_enforced_interactions(bigfoot_verifier: StrictVeri plugin=plugin, ) interaction.enforce = False - bigfoot_verifier._timeline.append(interaction) + tripwire_verifier._timeline.append(interaction) # Should not raise -- the interaction is unasserted but enforce=False - bigfoot_verifier.verify_all() + tripwire_verifier.verify_all() -def test_verify_all_raises_for_enforced_unasserted(bigfoot_verifier: StrictVerifier) -> None: +def test_verify_all_raises_for_enforced_unasserted(tripwire_verifier: StrictVerifier) -> None: """verify_all() raises for unasserted interactions with enforce=True (default).""" import pytest - from bigfoot._errors import UnassertedInteractionsError + from tripwire._errors import UnassertedInteractionsError - plugin = _MinimalPlugin(bigfoot_verifier) + plugin = _MinimalPlugin(tripwire_verifier) interaction = Interaction( source_id="test:op", sequence=0, @@ -92,38 +92,38 @@ def test_verify_all_raises_for_enforced_unasserted(bigfoot_verifier: StrictVerif plugin=plugin, ) # enforce defaults to True - bigfoot_verifier._timeline.append(interaction) + tripwire_verifier._timeline.append(interaction) with pytest.raises(UnassertedInteractionsError): - bigfoot_verifier.verify_all() + tripwire_verifier.verify_all() # Mark asserted so the auto-teardown verify_all() does not re-raise - bigfoot_verifier._timeline.mark_asserted(interaction) + tripwire_verifier._timeline.mark_asserted(interaction) -def test_verify_all_mixed_enforce_flags(bigfoot_verifier: StrictVerifier) -> None: +def test_verify_all_mixed_enforce_flags(tripwire_verifier: StrictVerifier) -> None: """verify_all() only reports enforced unasserted interactions.""" import pytest - from bigfoot._errors import UnassertedInteractionsError + from tripwire._errors import UnassertedInteractionsError - plugin = _MinimalPlugin(bigfoot_verifier) + plugin = _MinimalPlugin(tripwire_verifier) # Non-enforced interaction i1 = Interaction(source_id="test:setup", sequence=0, details={}, plugin=plugin) i1.enforce = False - bigfoot_verifier._timeline.append(i1) + tripwire_verifier._timeline.append(i1) # Enforced interaction i2 = Interaction(source_id="test:real", sequence=0, details={"x": 1}, plugin=plugin) - bigfoot_verifier._timeline.append(i2) + tripwire_verifier._timeline.append(i2) with pytest.raises(UnassertedInteractionsError) as exc_info: - bigfoot_verifier.verify_all() + tripwire_verifier.verify_all() # Only the enforced interaction should be in the error assert len(exc_info.value.interactions) == 1 assert exc_info.value.interactions[0].source_id == "test:real" # Mark asserted so the auto-teardown verify_all() does not re-raise - bigfoot_verifier._timeline.mark_asserted(i2) + tripwire_verifier._timeline.mark_asserted(i2) diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index 4372e69..79e2e94 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -1,20 +1,20 @@ -"""Tests for Task 2: bigfoot error classes. +"""Tests for Task 2: tripwire error classes. -All 7 error types plus BigfootError base. Tests follow TDD protocol: +All 7 error types plus TripwireError base. Tests follow TDD protocol: each test must fail before implementation exists. """ import pytest -from bigfoot._errors import ( +from tripwire._errors import ( AssertionInsideSandboxError, AutoAssertError, - BigfootError, ConflictError, InteractionMismatchError, MissingAssertionFieldsError, NoActiveVerifierError, SandboxNotActiveError, + TripwireError, UnassertedInteractionsError, UnmockedInteractionError, UnusedMocksError, @@ -26,24 +26,24 @@ # --------------------------------------------------------------------------- -def test_bigfoot_error_is_exception() -> None: - """BigfootError is the base and must be a proper Exception.""" - assert issubclass(BigfootError, Exception) +def test_tripwire_error_is_exception() -> None: + """TripwireError is the base and must be a proper Exception.""" + assert issubclass(TripwireError, Exception) -def test_all_errors_subclass_bigfoot_error() -> None: - """Every domain error must be catchable as BigfootError.""" - assert issubclass(UnmockedInteractionError, BigfootError) - assert issubclass(UnassertedInteractionsError, BigfootError) - assert issubclass(UnusedMocksError, BigfootError) - assert issubclass(VerificationError, BigfootError) - assert issubclass(InteractionMismatchError, BigfootError) - assert issubclass(SandboxNotActiveError, BigfootError) - assert issubclass(ConflictError, BigfootError) - assert issubclass(AssertionInsideSandboxError, BigfootError) - assert issubclass(NoActiveVerifierError, BigfootError) - assert issubclass(MissingAssertionFieldsError, BigfootError) - assert issubclass(AutoAssertError, BigfootError) +def test_all_errors_subclass_tripwire_error() -> None: + """Every domain error must be catchable as TripwireError.""" + assert issubclass(UnmockedInteractionError, TripwireError) + assert issubclass(UnassertedInteractionsError, TripwireError) + assert issubclass(UnusedMocksError, TripwireError) + assert issubclass(VerificationError, TripwireError) + assert issubclass(InteractionMismatchError, TripwireError) + assert issubclass(SandboxNotActiveError, TripwireError) + assert issubclass(ConflictError, TripwireError) + assert issubclass(AssertionInsideSandboxError, TripwireError) + assert issubclass(NoActiveVerifierError, TripwireError) + assert issubclass(MissingAssertionFieldsError, TripwireError) + assert issubclass(AutoAssertError, TripwireError) def test_all_errors_subclass_exception() -> None: @@ -72,12 +72,12 @@ def test_unmocked_interaction_error_fields() -> None: source_id="http.get", args=("https://example.com",), kwargs={"timeout": 5}, - hint="Register a mock with bigfoot.mock('http.get', ...)", + hint="Register a mock with tripwire.mock('http.get', ...)", ) assert err.source_id == "http.get" assert err.args_tuple == ("https://example.com",) assert err.kwargs == {"timeout": 5} - assert err.hint == "Register a mock with bigfoot.mock('http.get', ...)" + assert err.hint == "Register a mock with tripwire.mock('http.get', ...)" def test_unmocked_interaction_error_missing_fields_raises_type_error() -> None: @@ -86,9 +86,9 @@ def test_unmocked_interaction_error_missing_fields_raises_type_error() -> None: UnmockedInteractionError() # type: ignore[call-arg] -def test_unmocked_interaction_error_is_catchable_as_bigfoot_error() -> None: +def test_unmocked_interaction_error_is_catchable_as_tripwire_error() -> None: """Must be raiseable and catchable via the base class.""" - with pytest.raises(BigfootError): + with pytest.raises(TripwireError): raise UnmockedInteractionError( source_id="db.query", args=(), @@ -111,7 +111,7 @@ def test_unmocked_interaction_error_str() -> None: "Add a mock before entering the sandbox:\n" "Add mock for http.post\n\n" "Then assert it after the sandbox closes:\n" - " with bigfoot:\n" + " with tripwire:\n" " # ... your code that triggers the call\n" " # assert_* call here (REQUIRED)" ) @@ -139,9 +139,9 @@ def test_unasserted_interactions_error_missing_fields_raises_type_error() -> Non UnassertedInteractionsError() # type: ignore[call-arg] -def test_unasserted_interactions_error_is_catchable_as_bigfoot_error() -> None: +def test_unasserted_interactions_error_is_catchable_as_tripwire_error() -> None: """Must be raiseable and catchable via the base class.""" - with pytest.raises(BigfootError): + with pytest.raises(TripwireError): raise UnassertedInteractionsError(interactions=[], hint="No hint.") @@ -184,9 +184,9 @@ def test_unused_mocks_error_missing_fields_raises_type_error() -> None: UnusedMocksError() # type: ignore[call-arg] -def test_unused_mocks_error_is_catchable_as_bigfoot_error() -> None: +def test_unused_mocks_error_is_catchable_as_tripwire_error() -> None: """Must be raiseable and catchable via the base class.""" - with pytest.raises(BigfootError): + with pytest.raises(TripwireError): raise UnusedMocksError(mocks=[], hint="No hint.") @@ -246,9 +246,9 @@ def test_verification_error_missing_fields_raises_type_error() -> None: VerificationError() # type: ignore[call-arg] -def test_verification_error_is_catchable_as_bigfoot_error() -> None: +def test_verification_error_is_catchable_as_tripwire_error() -> None: """Must be raiseable and catchable via the base class.""" - with pytest.raises(BigfootError): + with pytest.raises(TripwireError): raise VerificationError(unasserted=None, unused=None) @@ -333,9 +333,9 @@ def test_interaction_mismatch_error_missing_fields_raises_type_error() -> None: InteractionMismatchError() # type: ignore[call-arg] -def test_interaction_mismatch_error_is_catchable_as_bigfoot_error() -> None: +def test_interaction_mismatch_error_is_catchable_as_tripwire_error() -> None: """Must be raiseable and catchable via the base class.""" - with pytest.raises(BigfootError): + with pytest.raises(TripwireError): raise InteractionMismatchError( expected={"source_id": "http.get"}, actual={"source_id": "db.read"}, @@ -371,9 +371,9 @@ def test_sandbox_not_active_error_missing_fields_raises_type_error() -> None: SandboxNotActiveError() # type: ignore[call-arg] -def test_sandbox_not_active_error_is_catchable_as_bigfoot_error() -> None: +def test_sandbox_not_active_error_is_catchable_as_tripwire_error() -> None: """Must be raiseable and catchable via the base class.""" - with pytest.raises(BigfootError): + with pytest.raises(TripwireError): raise SandboxNotActiveError(source_id="db.write") @@ -383,7 +383,7 @@ def test_sandbox_not_active_error_str() -> None: result = str(err) assert result == ( "SandboxNotActiveError: source_id='http.get', " - "hint='Did you forget bigfoot_verifier fixture or sandbox() CM?'" + "hint='Did you forget tripwire_verifier fixture or sandbox() CM?'" ) @@ -405,9 +405,9 @@ def test_conflict_error_missing_fields_raises_type_error() -> None: ConflictError() # type: ignore[call-arg] -def test_conflict_error_is_catchable_as_bigfoot_error() -> None: +def test_conflict_error_is_catchable_as_tripwire_error() -> None: """Must be raiseable and catchable via the base class.""" - with pytest.raises(BigfootError): + with pytest.raises(TripwireError): raise ConflictError(target="httpx.Client.send", patcher="httpretty") @@ -433,9 +433,9 @@ def test_assertion_inside_sandbox_error_takes_no_arguments() -> None: ) -def test_assertion_inside_sandbox_error_is_catchable_as_bigfoot_error() -> None: +def test_assertion_inside_sandbox_error_is_catchable_as_tripwire_error() -> None: """Must be raiseable and catchable via the base class.""" - with pytest.raises(BigfootError): + with pytest.raises(TripwireError): raise AssertionInsideSandboxError() @@ -459,16 +459,16 @@ def test_no_active_verifier_error_takes_no_arguments() -> None: """NoActiveVerifierError must be constructable with no arguments.""" err = NoActiveVerifierError() assert str(err) == ( - "NoActiveVerifierError: no active bigfoot verifier. " - "Module-level bigfoot functions (mock, sandbox, assert_interaction, etc.) " - "require an active test context. Ensure bigfoot is installed as a pytest " + "NoActiveVerifierError: no active tripwire verifier. " + "Module-level tripwire functions (mock, sandbox, assert_interaction, etc.) " + "require an active test context. Ensure tripwire is installed as a pytest " "plugin (it registers automatically) and you are running inside a pytest test." ) -def test_no_active_verifier_error_is_catchable_as_bigfoot_error() -> None: +def test_no_active_verifier_error_is_catchable_as_tripwire_error() -> None: """Must be raiseable and catchable via the base class.""" - with pytest.raises(BigfootError): + with pytest.raises(TripwireError): raise NoActiveVerifierError() @@ -477,9 +477,9 @@ def test_no_active_verifier_error_str() -> None: err = NoActiveVerifierError() result = str(err) assert result == ( - "NoActiveVerifierError: no active bigfoot verifier. " - "Module-level bigfoot functions (mock, sandbox, assert_interaction, etc.) " - "require an active test context. Ensure bigfoot is installed as a pytest " + "NoActiveVerifierError: no active tripwire verifier. " + "Module-level tripwire functions (mock, sandbox, assert_interaction, etc.) " + "require an active test context. Ensure tripwire is installed as a pytest " "plugin (it registers automatically) and you are running inside a pytest test." ) @@ -495,9 +495,9 @@ def test_missing_assertion_fields_error_fields() -> None: assert err.missing_fields == frozenset({"args", "kwargs"}) -def test_missing_assertion_fields_error_is_bigfoot_error() -> None: - """MissingAssertionFieldsError must be a subclass of BigfootError.""" - assert issubclass(MissingAssertionFieldsError, BigfootError) +def test_missing_assertion_fields_error_is_tripwire_error() -> None: + """MissingAssertionFieldsError must be a subclass of TripwireError.""" + assert issubclass(MissingAssertionFieldsError, TripwireError) def test_missing_assertion_fields_error_is_exception() -> None: @@ -529,7 +529,7 @@ def test_missing_assertion_fields_error_str_multiple_fields_sorted() -> None: def test_missing_assertion_fields_error_is_raiseable() -> None: """Must be raiseable and catchable via the base class.""" - with pytest.raises(BigfootError): + with pytest.raises(TripwireError): raise MissingAssertionFieldsError(frozenset({"args"})) @@ -540,7 +540,7 @@ def test_missing_assertion_fields_error_is_raiseable() -> None: def test_invalid_state_error_message_format() -> None: """__str__ matches the exact required format.""" - from bigfoot._errors import InvalidStateError + from tripwire._errors import InvalidStateError err = InvalidStateError( source_id="my_source", @@ -556,7 +556,7 @@ def test_invalid_state_error_message_format() -> None: def test_invalid_state_error_attributes() -> None: """All four constructor arguments are stored as attributes.""" - from bigfoot._errors import InvalidStateError + from tripwire._errors import InvalidStateError err = InvalidStateError( source_id="src_abc", @@ -570,11 +570,11 @@ def test_invalid_state_error_attributes() -> None: assert err.valid_states == frozenset({"idle"}) -def test_invalid_state_error_catchable_as_bigfoot_error() -> None: - """InvalidStateError must be catchable as BigfootError.""" - from bigfoot._errors import InvalidStateError +def test_invalid_state_error_catchable_as_tripwire_error() -> None: + """InvalidStateError must be catchable as TripwireError.""" + from tripwire._errors import InvalidStateError - with pytest.raises(BigfootError): + with pytest.raises(TripwireError): raise InvalidStateError( source_id="s", method="m", @@ -588,67 +588,67 @@ def test_invalid_state_error_catchable_as_bigfoot_error() -> None: # --------------------------------------------------------------------------- -def test_auto_assert_error_is_bigfoot_error() -> None: - """AutoAssertError is a subclass of BigfootError.""" - from bigfoot._errors import AutoAssertError, BigfootError - assert issubclass(AutoAssertError, BigfootError) +def test_auto_assert_error_is_tripwire_error() -> None: + """AutoAssertError is a subclass of TripwireError.""" + from tripwire._errors import AutoAssertError, TripwireError + assert issubclass(AutoAssertError, TripwireError) def test_auto_assert_error_message() -> None: """AutoAssertError stores the message passed to it.""" - from bigfoot._errors import AutoAssertError + from tripwire._errors import AutoAssertError err = AutoAssertError("test message") assert "test message" in str(err) -def test_auto_assert_error_exported_from_bigfoot() -> None: - """AutoAssertError is accessible from the top-level bigfoot module.""" - import bigfoot - assert hasattr(bigfoot, "AutoAssertError") - from bigfoot import AutoAssertError +def test_auto_assert_error_exported_from_tripwire() -> None: + """AutoAssertError is accessible from the top-level tripwire module.""" + import tripwire + assert hasattr(tripwire, "AutoAssertError") + from tripwire import AutoAssertError assert AutoAssertError is not None # --------------------------------------------------------------------------- -# BigfootConfigError +# TripwireConfigError # --------------------------------------------------------------------------- -def test_bigfoot_config_error_is_bigfoot_error() -> None: - """BigfootConfigError must be a subclass of BigfootError.""" - from bigfoot._errors import BigfootConfigError +def test_tripwire_config_error_is_tripwire_error() -> None: + """TripwireConfigError must be a subclass of TripwireError.""" + from tripwire._errors import TripwireConfigError - assert issubclass(BigfootConfigError, BigfootError) + assert issubclass(TripwireConfigError, TripwireError) -def test_bigfoot_config_error_is_exception() -> None: - """BigfootConfigError must be catchable as Exception.""" - from bigfoot._errors import BigfootConfigError +def test_tripwire_config_error_is_exception() -> None: + """TripwireConfigError must be catchable as Exception.""" + from tripwire._errors import TripwireConfigError - assert issubclass(BigfootConfigError, Exception) + assert issubclass(TripwireConfigError, Exception) -def test_bigfoot_config_error_message() -> None: - """BigfootConfigError stores and displays its message.""" - from bigfoot._errors import BigfootConfigError +def test_tripwire_config_error_message() -> None: + """TripwireConfigError stores and displays its message.""" + from tripwire._errors import TripwireConfigError - err = BigfootConfigError("enabled_plugins and disabled_plugins are mutually exclusive") + err = TripwireConfigError("enabled_plugins and disabled_plugins are mutually exclusive") assert str(err) == "enabled_plugins and disabled_plugins are mutually exclusive" -def test_bigfoot_config_error_is_raiseable() -> None: +def test_tripwire_config_error_is_raiseable() -> None: """Must be raiseable and catchable via the base class.""" - from bigfoot._errors import BigfootConfigError + from tripwire._errors import TripwireConfigError - with pytest.raises(BigfootError): - raise BigfootConfigError("test error") + with pytest.raises(TripwireError): + raise TripwireConfigError("test error") -def test_bigfoot_config_error_exported_from_bigfoot() -> None: - """BigfootConfigError is accessible from the top-level bigfoot module.""" - import bigfoot +def test_tripwire_config_error_exported_from_tripwire() -> None: + """TripwireConfigError is accessible from the top-level tripwire module.""" + import tripwire - assert hasattr(bigfoot, "BigfootConfigError") - from bigfoot import BigfootConfigError + assert hasattr(tripwire, "TripwireConfigError") + from tripwire import TripwireConfigError - assert BigfootConfigError is not None + assert TripwireConfigError is not None diff --git a/tests/unit/test_explicit_enable_error.py b/tests/unit/test_explicit_enable_error.py index 05393ac..ceb26aa 100644 --- a/tests/unit/test_explicit_enable_error.py +++ b/tests/unit/test_explicit_enable_error.py @@ -6,47 +6,47 @@ import pytest -from bigfoot._errors import BigfootConfigError -from bigfoot._registry import PluginEntry, resolve_enabled_plugins +from tripwire._errors import TripwireConfigError +from tripwire._registry import PluginEntry, resolve_enabled_plugins def _fake_entry(name: str, avail: str) -> PluginEntry: return PluginEntry( name=name, - import_path=f"bigfoot.plugins.{name}_plugin", + import_path=f"tripwire.plugins.{name}_plugin", class_name=f"{name.title()}Plugin", availability_check=avail, ) class TestExplicitEnableError: - """Explicit enable + missing dep raises BigfootConfigError.""" + """Explicit enable + missing dep raises TripwireConfigError.""" def test_explicit_enable_missing_dep_raises(self) -> None: entry = _fake_entry("fakeplugin", "nonexistent_module_xyz") - with patch("bigfoot._registry.PLUGIN_REGISTRY", (entry,)): - with patch("bigfoot._registry.VALID_PLUGIN_NAMES", frozenset({"fakeplugin"})): - with pytest.raises(BigfootConfigError, match="fakeplugin"): + with patch("tripwire._registry.PLUGIN_REGISTRY", (entry,)): + with patch("tripwire._registry.VALID_PLUGIN_NAMES", frozenset({"fakeplugin"})): + with pytest.raises(TripwireConfigError, match="fakeplugin"): resolve_enabled_plugins({"enabled_plugins": ["fakeplugin"]}) def test_explicit_enable_missing_dep_error_message_contains_install_hint(self) -> None: entry = _fake_entry("fakeplugin", "nonexistent_module_xyz") - with patch("bigfoot._registry.PLUGIN_REGISTRY", (entry,)): - with patch("bigfoot._registry.VALID_PLUGIN_NAMES", frozenset({"fakeplugin"})): - with pytest.raises(BigfootConfigError, match=r"pip install bigfoot\[fakeplugin\]"): + with patch("tripwire._registry.PLUGIN_REGISTRY", (entry,)): + with patch("tripwire._registry.VALID_PLUGIN_NAMES", frozenset({"fakeplugin"})): + with pytest.raises(TripwireConfigError, match=r"pip install tripwire\[fakeplugin\]"): resolve_enabled_plugins({"enabled_plugins": ["fakeplugin"]}) def test_default_enable_missing_dep_silent_skip(self) -> None: entry = _fake_entry("fakeplugin", "nonexistent_module_xyz") - with patch("bigfoot._registry.PLUGIN_REGISTRY", (entry,)): - with patch("bigfoot._registry.VALID_PLUGIN_NAMES", frozenset({"fakeplugin"})): + with patch("tripwire._registry.PLUGIN_REGISTRY", (entry,)): + with patch("tripwire._registry.VALID_PLUGIN_NAMES", frozenset({"fakeplugin"})): result = resolve_enabled_plugins({}) assert not any(e.name == "fakeplugin" for e in result) def test_disabled_plugins_not_affected(self) -> None: entry = _fake_entry("fakeplugin", "nonexistent_module_xyz") - with patch("bigfoot._registry.PLUGIN_REGISTRY", (entry,)): - with patch("bigfoot._registry.VALID_PLUGIN_NAMES", frozenset({"fakeplugin"})): + with patch("tripwire._registry.PLUGIN_REGISTRY", (entry,)): + with patch("tripwire._registry.VALID_PLUGIN_NAMES", frozenset({"fakeplugin"})): result = resolve_enabled_plugins({"disabled_plugins": ["fakeplugin"]}) assert not any(e.name == "fakeplugin" for e in result) @@ -56,13 +56,13 @@ class TestAutoInstantiateExplicitEnable: def test_explicit_enable_import_failure_raises(self) -> None: entry = _fake_entry("fakeplugin", "always") - with patch("bigfoot._registry.PLUGIN_REGISTRY", (entry,)): - with patch("bigfoot._registry.VALID_PLUGIN_NAMES", frozenset({"fakeplugin"})): - with patch("bigfoot._registry.get_plugin_class", side_effect=ImportError("no module")): - from bigfoot._verifier import StrictVerifier - with pytest.raises(BigfootConfigError, match="fakeplugin"): + with patch("tripwire._registry.PLUGIN_REGISTRY", (entry,)): + with patch("tripwire._registry.VALID_PLUGIN_NAMES", frozenset({"fakeplugin"})): + with patch("tripwire._registry.get_plugin_class", side_effect=ImportError("no module")): + from tripwire._verifier import StrictVerifier + with pytest.raises(TripwireConfigError, match="fakeplugin"): v = StrictVerifier.__new__(StrictVerifier) v._plugins = [] v._timeline = None # type: ignore[assignment] - v._bigfoot_config = {"enabled_plugins": ["fakeplugin"]} + v._tripwire_config = {"enabled_plugins": ["fakeplugin"]} v._auto_instantiate_plugins() diff --git a/tests/unit/test_file_io_plugin.py b/tests/unit/test_file_io_plugin.py index 9480f68..80b290d 100644 --- a/tests/unit/test_file_io_plugin.py +++ b/tests/unit/test_file_io_plugin.py @@ -10,14 +10,14 @@ import pytest -from bigfoot._errors import ( +from tripwire._errors import ( ConflictError, MissingAssertionFieldsError, UnmockedInteractionError, ) -from bigfoot._timeline import Interaction -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.file_io_plugin import ( +from tripwire._timeline import Interaction +from tripwire._verifier import StrictVerifier +from tripwire.plugins.file_io_plugin import ( FileIoMockConfig, FileIoPlugin, _file_io_bypass, @@ -549,13 +549,13 @@ def test_unused_mock_excluded_when_required_false() -> None: # CHECK: MissingAssertionFieldsError raised. # MUTATION: Not enforcing field completeness passes silently. # ESCAPE: Nothing reasonable -- exact exception type. -def test_missing_fields_error_when_field_omitted(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_missing_fields_error_when_field_omitted(tripwire_verifier: StrictVerifier) -> None: + import tripwire - p = FileIoPlugin(bigfoot_verifier) + p = FileIoPlugin(tripwire_verifier) p.mock_operation("open", "/tmp/f.txt", returns="data") - with bigfoot.sandbox(): + with tripwire.sandbox(): f = builtins.open("/tmp/f.txt") f.close() @@ -578,13 +578,13 @@ def test_missing_fields_error_when_field_omitted(bigfoot_verifier: StrictVerifie # CHECK: No exception raised (all fields provided, values match). # MUTATION: If assert_open passes wrong sentinel, InteractionMismatchError raised. # ESCAPE: Nothing reasonable -- exact field matching. -def test_assert_open_typed_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_open_typed_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - p = FileIoPlugin(bigfoot_verifier) + p = FileIoPlugin(tripwire_verifier) p.mock_operation("open", "/tmp/f.txt", returns="data") - with bigfoot.sandbox(): + with tripwire.sandbox(): f = builtins.open("/tmp/f.txt") f.close() @@ -597,13 +597,13 @@ def test_assert_open_typed_helper(bigfoot_verifier: StrictVerifier) -> None: # CHECK: No exception raised. # MUTATION: Wrong path value causes InteractionMismatchError. # ESCAPE: Nothing reasonable -- exact field matching. -def test_assert_read_text_typed_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_read_text_typed_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - p = FileIoPlugin(bigfoot_verifier) + p = FileIoPlugin(tripwire_verifier) p.mock_operation("read_text", "/tmp/r.txt", returns="content") - with bigfoot.sandbox(): + with tripwire.sandbox(): pathlib.Path("/tmp/r.txt").read_text() p.assert_read_text(path="/tmp/r.txt") @@ -615,13 +615,13 @@ def test_assert_read_text_typed_helper(bigfoot_verifier: StrictVerifier) -> None # CHECK: No exception raised. # MUTATION: Wrong data causes InteractionMismatchError. # ESCAPE: Nothing reasonable -- exact field matching. -def test_assert_write_text_typed_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_write_text_typed_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - p = FileIoPlugin(bigfoot_verifier) + p = FileIoPlugin(tripwire_verifier) p.mock_operation("write_text", "/tmp/w.txt") - with bigfoot.sandbox(): + with tripwire.sandbox(): pathlib.Path("/tmp/w.txt").write_text("hello") p.assert_write_text(path="/tmp/w.txt", data="hello") @@ -633,13 +633,13 @@ def test_assert_write_text_typed_helper(bigfoot_verifier: StrictVerifier) -> Non # CHECK: No exception raised. # MUTATION: Wrong path causes InteractionMismatchError. # ESCAPE: Nothing reasonable -- exact field matching. -def test_assert_remove_typed_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_remove_typed_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - p = FileIoPlugin(bigfoot_verifier) + p = FileIoPlugin(tripwire_verifier) p.mock_operation("remove", "/tmp/del.txt") - with bigfoot.sandbox(): + with tripwire.sandbox(): os.remove("/tmp/del.txt") p.assert_remove(path="/tmp/del.txt") @@ -651,13 +651,13 @@ def test_assert_remove_typed_helper(bigfoot_verifier: StrictVerifier) -> None: # CHECK: No exception raised. # MUTATION: Wrong src or dst causes InteractionMismatchError. # ESCAPE: Nothing reasonable -- exact field matching. -def test_assert_rename_typed_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_rename_typed_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - p = FileIoPlugin(bigfoot_verifier) + p = FileIoPlugin(tripwire_verifier) p.mock_operation("rename", "/tmp/old.txt") - with bigfoot.sandbox(): + with tripwire.sandbox(): os.rename("/tmp/old.txt", "/tmp/new.txt") p.assert_rename(src="/tmp/old.txt", dst="/tmp/new.txt") @@ -733,12 +733,12 @@ def test_mock_remove_raises_file_not_found() -> None: # ESCAPE: test_file_io_plugin_always_importable # CLAIM: FileIoPlugin is always importable (no optional dependencies). -# PATH: import bigfoot.plugins.file_io_plugin -> no ImportError. +# PATH: import tripwire.plugins.file_io_plugin -> no ImportError. # CHECK: FileIoPlugin is a class; no exception on import. # MUTATION: Adding a try/except guard around a missing dep would change this. # ESCAPE: Nothing reasonable -- import succeeds or fails. def test_file_io_plugin_always_importable() -> None: - from bigfoot.plugins.file_io_plugin import ( + from tripwire.plugins.file_io_plugin import ( FileIoPlugin as FileIoPluginDirect, ) @@ -754,7 +754,7 @@ def test_file_io_plugin_always_importable() -> None: # CLAIM: When _file_io_bypass is True, intercepted open falls through to real builtins.open. # PATH: _file_io_bypass.set(True) -> intercepted_open checks bypass -> calls original. # CHECK: Real builtins.open is called (reading a file that actually exists). -# MUTATION: Not checking bypass would intercept bigfoot's own I/O and break the framework. +# MUTATION: Not checking bypass would intercept tripwire's own I/O and break the framework. # ESCAPE: Nothing reasonable -- if bypass fails, the mock queue is checked and # UnmockedInteractionError is raised (or wrong data returned). def test_reentrancy_guard_bypasses_when_set(tmp_path: pathlib.Path) -> None: @@ -821,7 +821,7 @@ def test_reentrancy_guard_no_verifier_falls_through(tmp_path: pathlib.Path) -> N # MUTATION: Setting default_enabled=True would include it in defaults. # ESCAPE: Nothing reasonable -- exact membership check. def test_file_io_not_default_enabled() -> None: - from bigfoot._registry import resolve_enabled_plugins + from tripwire._registry import resolve_enabled_plugins result = resolve_enabled_plugins({}) names = {e.name for e in result} @@ -832,10 +832,10 @@ def test_file_io_not_default_enabled() -> None: # CLAIM: FileIoPlugin IS included when enabled_plugins=["file_io"]. # PATH: resolve_enabled_plugins({"enabled_plugins": ["file_io"]}) includes file_io. # CHECK: "file_io" in resolved names. -# MUTATION: Not registering the plugin in PLUGIN_REGISTRY raises BigfootConfigError. +# MUTATION: Not registering the plugin in PLUGIN_REGISTRY raises TripwireConfigError. # ESCAPE: Nothing reasonable -- exact membership check. def test_file_io_included_when_explicitly_enabled() -> None: - from bigfoot._registry import resolve_enabled_plugins + from tripwire._registry import resolve_enabled_plugins result = resolve_enabled_plugins({"enabled_plugins": ["file_io"]}) names = {e.name for e in result} @@ -970,7 +970,7 @@ def test_format_mock_hint() -> None: plugin=p, ) result = p.format_mock_hint(interaction) - assert result == f" bigfoot.file_io_mock.mock_operation('open', '{os.path.normpath('/tmp/f.txt')}', returns=...)" + assert result == f" tripwire.file_io_mock.mock_operation('open', '{os.path.normpath('/tmp/f.txt')}', returns=...)" # ESCAPE: test_format_unmocked_hint @@ -986,7 +986,7 @@ def test_format_unmocked_hint() -> None: assert result == ( f"open('{np('/tmp/f.txt')}', ...) was called but no mock was registered.\n" "Register a mock with:\n" - f" bigfoot.file_io_mock.mock_operation('open', '{np('/tmp/f.txt')}', returns=...)" + f" tripwire.file_io_mock.mock_operation('open', '{np('/tmp/f.txt')}', returns=...)" ) @@ -1007,7 +1007,7 @@ def test_format_assert_hint() -> None: result = p.format_assert_hint(interaction) npath = os.path.normpath("/tmp/f.txt") assert result == ( - " bigfoot.file_io_mock.assert_open(\n" + " tripwire.file_io_mock.assert_open(\n" f" path={npath!r},\n" " mode='r',\n" " encoding='utf-8',\n" @@ -1032,7 +1032,7 @@ def test_format_assert_hint_remove() -> None: result = p.format_assert_hint(interaction) npath = os.path.normpath("/tmp/del.txt") assert result == ( - " bigfoot.file_io_mock.assert_remove(\n" + " tripwire.file_io_mock.assert_remove(\n" f" path={npath!r},\n" " )" ) @@ -1134,7 +1134,7 @@ def test_file_io_mock_config_defaults() -> None: # ESCAPE: test_activate_installs_patch -# CLAIM: After activate(), builtins.open is replaced with bigfoot interceptor. +# CLAIM: After activate(), builtins.open is replaced with tripwire interceptor. # PATH: activate() -> _install_count == 0 -> store original -> install interceptor. # CHECK: builtins.open is not the original after activate(). # MUTATION: Skipping patch installation leaves original in place; identity check fails. @@ -1191,35 +1191,35 @@ def test_reference_counting_nested() -> None: # ESCAPE: test_file_io_plugin_in_all -# CLAIM: FileIoPlugin and file_io_mock are exported from bigfoot.__all__. -# PATH: bigfoot.__all__ contains "FileIoPlugin" and "file_io_mock". -# CHECK: Both names in __all__; bigfoot.FileIoPlugin is the real class. +# CLAIM: FileIoPlugin and file_io_mock are exported from tripwire.__all__. +# PATH: tripwire.__all__ contains "FileIoPlugin" and "file_io_mock". +# CHECK: Both names in __all__; tripwire.FileIoPlugin is the real class. # MUTATION: Omitting either from __all__ fails membership check. # ESCAPE: Nothing reasonable -- exact membership check. def test_file_io_plugin_in_all() -> None: - import bigfoot + import tripwire - assert "FileIoPlugin" in bigfoot.__all__ - assert "file_io_mock" in bigfoot.__all__ - assert bigfoot.FileIoPlugin is FileIoPlugin + assert "FileIoPlugin" in tripwire.__all__ + assert "file_io_mock" in tripwire.__all__ + assert tripwire.FileIoPlugin is FileIoPlugin # ESCAPE: test_file_io_mock_proxy -# CLAIM: bigfoot.file_io_mock proxies to FileIoPlugin on the active verifier. +# CLAIM: tripwire.file_io_mock proxies to FileIoPlugin on the active verifier. # PATH: _FileIoProxy.__getattr__ -> get verifier -> find/create FileIoPlugin. # CHECK: Proxy attribute access does not raise when verifier is active. # MUTATION: Wrong proxy class or missing registration fails with AttributeError. # ESCAPE: Nothing reasonable -- attribute access succeeds or fails. -def test_file_io_mock_proxy(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_file_io_mock_proxy(tripwire_verifier: StrictVerifier) -> None: + import tripwire # End-to-end: register mock through proxy, trigger it, verify interaction - bigfoot.file_io_mock.mock_operation("open", "/tmp/proxy-test.txt", returns="proxied") - with bigfoot.sandbox(): + tripwire.file_io_mock.mock_operation("open", "/tmp/proxy-test.txt", returns="proxied") + with tripwire.sandbox(): f = builtins.open("/tmp/proxy-test.txt") result = f.read() assert result == "proxied" - bigfoot.file_io_mock.assert_open(path="/tmp/proxy-test.txt", mode="r", encoding="utf-8") + tripwire.file_io_mock.assert_open(path="/tmp/proxy-test.txt", mode="r", encoding="utf-8") # --------------------------------------------------------------------------- @@ -1259,11 +1259,11 @@ def test_matches_field_comparison() -> None: # MUTATION: Not adding to registry fails membership check. # ESCAPE: Nothing reasonable -- exact membership check. def test_file_io_in_registry() -> None: - from bigfoot._registry import PLUGIN_REGISTRY, VALID_PLUGIN_NAMES + from tripwire._registry import PLUGIN_REGISTRY, VALID_PLUGIN_NAMES assert "file_io" in VALID_PLUGIN_NAMES entry = next(e for e in PLUGIN_REGISTRY if e.name == "file_io") - assert entry.import_path == "bigfoot.plugins.file_io_plugin" + assert entry.import_path == "tripwire.plugins.file_io_plugin" assert entry.class_name == "FileIoPlugin" assert entry.availability_check == "always" assert entry.default_enabled is False @@ -1280,17 +1280,17 @@ def test_file_io_in_registry() -> None: # CHECK: all_unasserted() returns the interaction. # MUTATION: Auto-asserting would return empty list from all_unasserted(). # ESCAPE: Nothing reasonable -- exact length and source_id check. -def test_interactions_not_auto_asserted(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_interactions_not_auto_asserted(tripwire_verifier: StrictVerifier) -> None: + import tripwire - p = FileIoPlugin(bigfoot_verifier) + p = FileIoPlugin(tripwire_verifier) p.mock_operation("open", "/tmp/f.txt", returns="data") - with bigfoot.sandbox(): + with tripwire.sandbox(): f = builtins.open("/tmp/f.txt") f.close() - timeline = bigfoot_verifier._timeline + timeline = tripwire_verifier._timeline interactions = timeline.all_unasserted() assert len(interactions) == 1 assert interactions[0].source_id == "file_io:open" diff --git a/tests/unit/test_firewall.py b/tests/unit/test_firewall.py index 0037c80..a51fee2 100644 --- a/tests/unit/test_firewall.py +++ b/tests/unit/test_firewall.py @@ -1,11 +1,11 @@ -"""Tests for bigfoot._firewall -- FirewallStack evaluation.""" +"""Tests for tripwire._firewall -- FirewallStack evaluation.""" -from bigfoot._firewall import Disposition, FirewallRule, FirewallStack, RestrictFrame -from bigfoot._firewall_request import ( +from tripwire._firewall import Disposition, FirewallRule, FirewallStack, RestrictFrame +from tripwire._firewall_request import ( HttpFirewallRequest, RedisFirewallRequest, ) -from bigfoot._match import M +from tripwire._match import M class TestFirewallStackEvaluate: diff --git a/tests/unit/test_firewall_errors.py b/tests/unit/test_firewall_errors.py index 997410f..5327dc0 100644 --- a/tests/unit/test_firewall_errors.py +++ b/tests/unit/test_firewall_errors.py @@ -1,7 +1,7 @@ """Tests for GuardedCallError message generation with firewall requests.""" -from bigfoot._errors import GuardedCallError -from bigfoot._firewall_request import ( +from tripwire._errors import GuardedCallError +from tripwire._firewall_request import ( HttpFirewallRequest, RedisFirewallRequest, SubprocessFirewallRequest, @@ -18,8 +18,8 @@ def test_http_error_message(self) -> None: msg = str(err) assert "GET https://api.stripe.com:443/v1/charges" in msg assert "@pytest.mark.allow" in msg - assert "bigfoot.allow" in msg - assert "[tool.bigfoot.firewall]" in msg + assert "tripwire.allow" in msg + assert "[tool.tripwire.firewall]" in msg def test_redis_error_message(self) -> None: req = RedisFirewallRequest(host="localhost", port=6379, db=0, command="FLUSHALL") @@ -43,4 +43,4 @@ def test_old_signature_still_works(self) -> None: """Backward compat: firewall_request defaults to None.""" err = GuardedCallError("http:request", "http") msg = str(err) - assert "blocked by bigfoot firewall" in msg + assert "blocked by tripwire firewall" in msg diff --git a/tests/unit/test_firewall_integration.py b/tests/unit/test_firewall_integration.py index 9cf02c7..6674ba2 100644 --- a/tests/unit/test_firewall_integration.py +++ b/tests/unit/test_firewall_integration.py @@ -1,12 +1,12 @@ """Integration tests for firewall layering: TOML + marks + context managers.""" -from bigfoot._firewall import Disposition, FirewallRule, FirewallStack -from bigfoot._firewall_request import ( +from tripwire._firewall import Disposition, FirewallRule, FirewallStack +from tripwire._firewall_request import ( DnsFirewallRequest, HttpFirewallRequest, RedisFirewallRequest, ) -from bigfoot._match import M +from tripwire._match import M class TestAppendixAWalkthrough: diff --git a/tests/unit/test_firewall_toml.py b/tests/unit/test_firewall_toml.py index 4670aae..d36a438 100644 --- a/tests/unit/test_firewall_toml.py +++ b/tests/unit/test_firewall_toml.py @@ -1,17 +1,17 @@ """Tests for TOML firewall rule parsing.""" -from bigfoot._firewall_request import ( +from tripwire._firewall_request import ( HttpFirewallRequest, RedisFirewallRequest, SubprocessFirewallRequest, ) -from bigfoot.pytest_plugin import _parse_toml_rule +from tripwire.pytest_plugin import _parse_toml_rule class TestParseTomlRule: def test_protocol_wildcard(self) -> None: m = _parse_toml_rule("dns:*") - from bigfoot._firewall_request import DnsFirewallRequest + from tripwire._firewall_request import DnsFirewallRequest req = DnsFirewallRequest(hostname="example.com") assert m.matches(req) is True @@ -37,18 +37,18 @@ def test_redis_url_with_db(self) -> None: def test_boto3_service_operation(self) -> None: m = _parse_toml_rule("boto3:s3:GetObject") - from bigfoot._firewall_request import Boto3FirewallRequest + from tripwire._firewall_request import Boto3FirewallRequest req = Boto3FirewallRequest(service="s3", operation="GetObject") assert m.matches(req) is True def test_memcache_command(self) -> None: m = _parse_toml_rule("memcache:get") - from bigfoot._firewall_request import MemcacheFirewallRequest + from tripwire._firewall_request import MemcacheFirewallRequest req = MemcacheFirewallRequest(host="localhost", port=11211, command="get") assert m.matches(req) is True def test_file_io_path(self) -> None: m = _parse_toml_rule("file_io:/tmp/**") - from bigfoot._firewall_request import FileIoFirewallRequest + from tripwire._firewall_request import FileIoFirewallRequest req = FileIoFirewallRequest(path="/tmp/foo/bar") assert m.matches(req) is True diff --git a/tests/unit/test_glob.py b/tests/unit/test_glob.py index 341f8c5..37a4b00 100644 --- a/tests/unit/test_glob.py +++ b/tests/unit/test_glob.py @@ -1,43 +1,43 @@ -"""Tests for bigfoot._glob -- custom glob matching.""" +"""Tests for tripwire._glob -- custom glob matching.""" -from bigfoot._glob import bigfoot_match +from tripwire._glob import tripwire_match class TestHostGlob: def test_subdomain_match(self) -> None: - assert bigfoot_match("*.example.com", "sub.example.com") is True + assert tripwire_match("*.example.com", "sub.example.com") is True def test_deep_subdomain_match(self) -> None: - assert bigfoot_match("*.example.com", "deep.sub.example.com") is True + assert tripwire_match("*.example.com", "deep.sub.example.com") is True def test_bare_domain_no_match(self) -> None: - assert bigfoot_match("*.example.com", "example.com") is False + assert tripwire_match("*.example.com", "example.com") is False def test_evil_hyphen_no_match(self) -> None: """Security-critical: *.example.com must NOT match evil-example.com.""" - assert bigfoot_match("*.example.com", "evil-example.com") is False + assert tripwire_match("*.example.com", "evil-example.com") is False def test_case_insensitive_host(self) -> None: - assert bigfoot_match("*.Example.COM", "sub.example.com", case_sensitive=False) is True + assert tripwire_match("*.Example.COM", "sub.example.com", case_sensitive=False) is True class TestPathGlob: def test_double_star_deep_match(self) -> None: - assert bigfoot_match("/api/**", "/api/v1/users") is True + assert tripwire_match("/api/**", "/api/v1/users") is True def test_double_star_deeper(self) -> None: - assert bigfoot_match("/api/**", "/api/v2/items/123") is True + assert tripwire_match("/api/**", "/api/v2/items/123") is True def test_single_star_no_deep(self) -> None: - assert bigfoot_match("/api/*", "/api/v1/users") is False + assert tripwire_match("/api/*", "/api/v1/users") is False def test_single_star_one_segment(self) -> None: - assert bigfoot_match("/api/*", "/api/v1") is True + assert tripwire_match("/api/*", "/api/v1") is True class TestExactMatch: def test_no_wildcards_exact(self) -> None: - assert bigfoot_match("hello", "hello") is True + assert tripwire_match("hello", "hello") is True def test_no_wildcards_mismatch(self) -> None: - assert bigfoot_match("hello", "world") is False + assert tripwire_match("hello", "world") is False diff --git a/tests/unit/test_grpc_plugin.py b/tests/unit/test_grpc_plugin.py index 132cd9b..7bc29ce 100644 --- a/tests/unit/test_grpc_plugin.py +++ b/tests/unit/test_grpc_plugin.py @@ -5,14 +5,14 @@ import grpc import pytest -from bigfoot._context import _current_test_verifier -from bigfoot._errors import ( +from tripwire._context import _current_test_verifier +from tripwire._errors import ( InteractionMismatchError, MissingAssertionFieldsError, UnmockedInteractionError, ) -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.grpc_plugin import ( +from tripwire._verifier import StrictVerifier +from tripwire.plugins.grpc_plugin import ( _GRPC_AVAILABLE, GrpcMockConfig, GrpcPlugin, @@ -77,14 +77,14 @@ def test_grpc_available_flag() -> None: # MUTATION: Not checking the flag and proceeding normally would not raise. # ESCAPE: Raising ImportError with a different message fails the exact string check. def test_activate_raises_when_grpc_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: - import bigfoot.plugins.grpc_plugin as _gp + import tripwire.plugins.grpc_plugin as _gp v, p = _make_verifier_with_plugin() monkeypatch.setattr(_gp, "_GRPC_AVAILABLE", False) with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install bigfoot[grpc] to use GrpcPlugin: pip install bigfoot[grpc]" + "Install tripwire[grpc] to use GrpcPlugin: pip install tripwire[grpc]" ) @@ -204,17 +204,17 @@ def test_reference_counting_nested() -> None: # CHECK: Return value equals the mock value; interaction on timeline has correct details. # MUTATION: Not recording the interaction leaves timeline empty. Wrong return value fails equality. # ESCAPE: Nothing reasonable -- exact equality on return value and interaction details. -def test_unary_unary_basic_interception(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_unary_unary_basic_interception(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.grpc_mock.mock_unary_unary("/pkg.Svc/Do", returns=b"response-data") - with bigfoot.sandbox(): + tripwire.grpc_mock.mock_unary_unary("/pkg.Svc/Do", returns=b"response-data") + with tripwire.sandbox(): channel = grpc.insecure_channel("localhost:50051") stub = channel.unary_unary("/pkg.Svc/Do") result = stub(b"request-data") assert result == b"response-data" - bigfoot.grpc_mock.assert_unary_unary( + tripwire.grpc_mock.assert_unary_unary( method="/pkg.Svc/Do", request=b"request-data", metadata=None, @@ -233,7 +233,7 @@ def test_unary_unary_basic_interception(bigfoot_verifier: StrictVerifier) -> Non # MUTATION: Returning a subset would miss fields. # ESCAPE: Nothing reasonable -- exact frozenset comparison. def test_assertable_fields_returns_all_detail_keys() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -263,10 +263,10 @@ def test_assertable_fields_returns_all_detail_keys() -> None: # CHECK: UnmockedInteractionError raised with correct source_id. # MUTATION: Not raising lets the call pass through. # ESCAPE: Raising a different exception fails the type check. -def test_unmocked_error_when_no_mock_registered(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_unmocked_error_when_no_mock_registered(tripwire_verifier: StrictVerifier) -> None: + import tripwire - with bigfoot.sandbox(): + with tripwire.sandbox(): channel = grpc.insecure_channel("localhost:50051") stub = channel.unary_unary("/pkg.Svc/Missing") with pytest.raises(UnmockedInteractionError) as exc_info: @@ -320,27 +320,27 @@ def test_get_unused_mocks_excludes_required_false() -> None: # CHECK: MissingAssertionFieldsError raised. # MUTATION: Not checking fields passes the test. # ESCAPE: Nothing reasonable -- error type check. -def test_missing_fields_raises_error(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_missing_fields_raises_error(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.grpc_mock.mock_unary_unary("/pkg.Svc/Do", returns=b"val") - with bigfoot.sandbox(): + tripwire.grpc_mock.mock_unary_unary("/pkg.Svc/Do", returns=b"val") + with tripwire.sandbox(): channel = grpc.insecure_channel("localhost:50051") stub = channel.unary_unary("/pkg.Svc/Do") stub(b"req") # Only provide method, missing call_type, request, metadata - from bigfoot.plugins.grpc_plugin import _GrpcSentinel + from tripwire.plugins.grpc_plugin import _GrpcSentinel sentinel = _GrpcSentinel("grpc:unary_unary:/pkg.Svc/Do") with pytest.raises(MissingAssertionFieldsError): - bigfoot_verifier.assert_interaction( + tripwire_verifier.assert_interaction( sentinel, method="/pkg.Svc/Do", ) # Now assert correctly so teardown passes - bigfoot.grpc_mock.assert_unary_unary( + tripwire.grpc_mock.assert_unary_unary( method="/pkg.Svc/Do", request=b"req", metadata=None, @@ -358,16 +358,16 @@ def test_missing_fields_raises_error(bigfoot_verifier: StrictVerifier) -> None: # CHECK: No error raised when fields match. # MUTATION: Wrong field mapping raises InteractionMismatchError. # ESCAPE: Nothing reasonable -- exact field matching. -def test_assert_unary_unary_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_unary_unary_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.grpc_mock.mock_unary_unary("/pkg.Svc/Do", returns=b"ok") - with bigfoot.sandbox(): + tripwire.grpc_mock.mock_unary_unary("/pkg.Svc/Do", returns=b"ok") + with tripwire.sandbox(): channel = grpc.insecure_channel("localhost:50051") stub = channel.unary_unary("/pkg.Svc/Do") stub(b"req") - bigfoot.grpc_mock.assert_unary_unary( + tripwire.grpc_mock.assert_unary_unary( method="/pkg.Svc/Do", request=b"req", metadata=None, @@ -380,18 +380,18 @@ def test_assert_unary_unary_helper(bigfoot_verifier: StrictVerifier) -> None: # CHECK: No error raised when fields match. # MUTATION: Wrong field mapping raises InteractionMismatchError. # ESCAPE: Nothing reasonable. -def test_assert_unary_stream_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_unary_stream_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.grpc_mock.mock_unary_stream("/pkg.Svc/ServerStream", returns=[b"r1", b"r2"]) - with bigfoot.sandbox(): + tripwire.grpc_mock.mock_unary_stream("/pkg.Svc/ServerStream", returns=[b"r1", b"r2"]) + with tripwire.sandbox(): channel = grpc.insecure_channel("localhost:50051") stub = channel.unary_stream("/pkg.Svc/ServerStream") response_iter = stub(b"req") responses = list(response_iter) assert responses == [b"r1", b"r2"] - bigfoot.grpc_mock.assert_unary_stream( + tripwire.grpc_mock.assert_unary_stream( method="/pkg.Svc/ServerStream", request=b"req", metadata=None, @@ -404,17 +404,17 @@ def test_assert_unary_stream_helper(bigfoot_verifier: StrictVerifier) -> None: # CHECK: No error raised; request is materialized list. # MUTATION: Not consuming the iterator means request would be wrong. # ESCAPE: Nothing reasonable. -def test_assert_stream_unary_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_stream_unary_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.grpc_mock.mock_stream_unary("/pkg.Svc/ClientStream", returns=b"merged") - with bigfoot.sandbox(): + tripwire.grpc_mock.mock_stream_unary("/pkg.Svc/ClientStream", returns=b"merged") + with tripwire.sandbox(): channel = grpc.insecure_channel("localhost:50051") stub = channel.stream_unary("/pkg.Svc/ClientStream") result = stub(iter([b"c1", b"c2"])) assert result == b"merged" - bigfoot.grpc_mock.assert_stream_unary( + tripwire.grpc_mock.assert_stream_unary( method="/pkg.Svc/ClientStream", request=[b"c1", b"c2"], metadata=None, @@ -427,18 +427,18 @@ def test_assert_stream_unary_helper(bigfoot_verifier: StrictVerifier) -> None: # CHECK: No error raised; request is materialized list. # MUTATION: Not consuming the request iterator means request would be wrong. # ESCAPE: Nothing reasonable. -def test_assert_stream_stream_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_stream_stream_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.grpc_mock.mock_stream_stream("/pkg.Svc/Bidi", returns=[b"s1", b"s2"]) - with bigfoot.sandbox(): + tripwire.grpc_mock.mock_stream_stream("/pkg.Svc/Bidi", returns=[b"s1", b"s2"]) + with tripwire.sandbox(): channel = grpc.insecure_channel("localhost:50051") stub = channel.stream_stream("/pkg.Svc/Bidi") response_iter = stub(iter([b"c1"])) responses = list(response_iter) assert responses == [b"s1", b"s2"] - bigfoot.grpc_mock.assert_stream_stream( + tripwire.grpc_mock.assert_stream_stream( method="/pkg.Svc/Bidi", request=[b"c1"], metadata=None, @@ -456,23 +456,23 @@ def test_assert_stream_stream_helper(bigfoot_verifier: StrictVerifier) -> None: # CHECK: InteractionMismatchError raised. # MUTATION: Always matching would not raise. # ESCAPE: Nothing reasonable -- type check. -def test_assert_unary_unary_wrong_request_raises(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_unary_unary_wrong_request_raises(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.grpc_mock.mock_unary_unary("/pkg.Svc/Do", returns=b"ok") - with bigfoot.sandbox(): + tripwire.grpc_mock.mock_unary_unary("/pkg.Svc/Do", returns=b"ok") + with tripwire.sandbox(): channel = grpc.insecure_channel("localhost:50051") stub = channel.unary_unary("/pkg.Svc/Do") stub(b"actual-request") with pytest.raises(InteractionMismatchError): - bigfoot.grpc_mock.assert_unary_unary( + tripwire.grpc_mock.assert_unary_unary( method="/pkg.Svc/Do", request=b"WRONG-request", metadata=None, ) # Assert correctly so teardown passes - bigfoot.grpc_mock.assert_unary_unary( + tripwire.grpc_mock.assert_unary_unary( method="/pkg.Svc/Do", request=b"actual-request", metadata=None, @@ -485,23 +485,23 @@ def test_assert_unary_unary_wrong_request_raises(bigfoot_verifier: StrictVerifie # CHECK: InteractionMismatchError raised. # MUTATION: Not checking method field means wrong method passes. # ESCAPE: Nothing reasonable. -def test_assert_unary_unary_wrong_method_raises(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_unary_unary_wrong_method_raises(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.grpc_mock.mock_unary_unary("/pkg.Svc/Do", returns=b"ok") - with bigfoot.sandbox(): + tripwire.grpc_mock.mock_unary_unary("/pkg.Svc/Do", returns=b"ok") + with tripwire.sandbox(): channel = grpc.insecure_channel("localhost:50051") stub = channel.unary_unary("/pkg.Svc/Do") stub(b"req") with pytest.raises(InteractionMismatchError): - bigfoot.grpc_mock.assert_unary_unary( + tripwire.grpc_mock.assert_unary_unary( method="/pkg.Svc/WRONG", request=b"req", metadata=None, ) # Assert correctly - bigfoot.grpc_mock.assert_unary_unary( + tripwire.grpc_mock.assert_unary_unary( method="/pkg.Svc/Do", request=b"req", metadata=None, @@ -540,19 +540,19 @@ def test_conflict_detection_double_activate() -> None: # CHECK: The exact exception instance is raised. # MUTATION: Not checking raises means the mock return value is returned instead. # ESCAPE: Raising a different exception fails the identity check. -def test_exception_propagation(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_exception_propagation(tripwire_verifier: StrictVerifier) -> None: + import tripwire err = grpc.RpcError() - bigfoot.grpc_mock.mock_unary_unary("/pkg.Svc/Fail", returns=None, raises=err) - with bigfoot.sandbox(): + tripwire.grpc_mock.mock_unary_unary("/pkg.Svc/Fail", returns=None, raises=err) + with tripwire.sandbox(): channel = grpc.insecure_channel("localhost:50051") stub = channel.unary_unary("/pkg.Svc/Fail") with pytest.raises(grpc.RpcError) as exc_info: stub(b"req") assert exc_info.value is err - bigfoot.grpc_mock.assert_unary_unary( + tripwire.grpc_mock.assert_unary_unary( method="/pkg.Svc/Fail", request=b"req", metadata=None, @@ -571,18 +571,18 @@ def test_exception_propagation(bigfoot_verifier: StrictVerifier) -> None: # CHECK: Collected list equals the configured responses. # MUTATION: Not returning an iterator means direct return (not iterable). # ESCAPE: Nothing reasonable -- exact list equality. -def test_server_streaming_returns_iterator(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_server_streaming_returns_iterator(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.grpc_mock.mock_unary_stream("/pkg.Svc/Stream", returns=[b"a", b"b", b"c"]) - with bigfoot.sandbox(): + tripwire.grpc_mock.mock_unary_stream("/pkg.Svc/Stream", returns=[b"a", b"b", b"c"]) + with tripwire.sandbox(): channel = grpc.insecure_channel("localhost:50051") stub = channel.unary_stream("/pkg.Svc/Stream") response_iter = stub(b"req") responses = list(response_iter) assert responses == [b"a", b"b", b"c"] - bigfoot.grpc_mock.assert_unary_stream( + tripwire.grpc_mock.assert_unary_stream( method="/pkg.Svc/Stream", request=b"req", metadata=None, @@ -600,17 +600,17 @@ def test_server_streaming_returns_iterator(bigfoot_verifier: StrictVerifier) -> # CHECK: Interaction request equals the materialized list. # MUTATION: Not consuming the iterator means request is the iterator object. # ESCAPE: Nothing reasonable -- exact list equality. -def test_client_streaming_materializes_request(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_client_streaming_materializes_request(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.grpc_mock.mock_stream_unary("/pkg.Svc/Upload", returns=b"done") - with bigfoot.sandbox(): + tripwire.grpc_mock.mock_stream_unary("/pkg.Svc/Upload", returns=b"done") + with tripwire.sandbox(): channel = grpc.insecure_channel("localhost:50051") stub = channel.stream_unary("/pkg.Svc/Upload") result = stub(iter([b"chunk1", b"chunk2", b"chunk3"])) assert result == b"done" - bigfoot.grpc_mock.assert_stream_unary( + tripwire.grpc_mock.assert_stream_unary( method="/pkg.Svc/Upload", request=[b"chunk1", b"chunk2", b"chunk3"], metadata=None, @@ -628,18 +628,18 @@ def test_client_streaming_materializes_request(bigfoot_verifier: StrictVerifier) # CHECK: Both request and response match expected. # MUTATION: Not consuming request or not returning iterator fails. # ESCAPE: Nothing reasonable. -def test_bidi_streaming(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_bidi_streaming(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.grpc_mock.mock_stream_stream("/pkg.Svc/Chat", returns=[b"r1", b"r2"]) - with bigfoot.sandbox(): + tripwire.grpc_mock.mock_stream_stream("/pkg.Svc/Chat", returns=[b"r1", b"r2"]) + with tripwire.sandbox(): channel = grpc.insecure_channel("localhost:50051") stub = channel.stream_stream("/pkg.Svc/Chat") response_iter = stub(iter([b"c1", b"c2"])) responses = list(response_iter) assert responses == [b"r1", b"r2"] - bigfoot.grpc_mock.assert_stream_stream( + tripwire.grpc_mock.assert_stream_stream( method="/pkg.Svc/Chat", request=[b"c1", b"c2"], metadata=None, @@ -657,18 +657,18 @@ def test_bidi_streaming(bigfoot_verifier: StrictVerifier) -> None: # CHECK: Request is empty list; responses are empty list. # MUTATION: Failing on empty input means the test fails. # ESCAPE: Nothing reasonable -- exact equality on empty lists. -def test_empty_streams(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_empty_streams(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.grpc_mock.mock_stream_stream("/pkg.Svc/Empty", returns=[]) - with bigfoot.sandbox(): + tripwire.grpc_mock.mock_stream_stream("/pkg.Svc/Empty", returns=[]) + with tripwire.sandbox(): channel = grpc.insecure_channel("localhost:50051") stub = channel.stream_stream("/pkg.Svc/Empty") response_iter = stub(iter([])) responses = list(response_iter) assert responses == [] - bigfoot.grpc_mock.assert_stream_stream( + tripwire.grpc_mock.assert_stream_stream( method="/pkg.Svc/Empty", request=[], metadata=None, @@ -686,12 +686,12 @@ def test_empty_streams(bigfoot_verifier: StrictVerifier) -> None: # CHECK: Partial responses collected; then the error is raised on next iteration. # MUTATION: Not raising after responses means no error on exhaustion. # ESCAPE: Nothing reasonable -- exact list + exception identity. -def test_mid_stream_error(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_mid_stream_error(tripwire_verifier: StrictVerifier) -> None: + import tripwire err = grpc.RpcError() - bigfoot.grpc_mock.mock_unary_stream("/pkg.Svc/PartialFail", returns=[b"p1", b"p2"], raises=err) - with bigfoot.sandbox(): + tripwire.grpc_mock.mock_unary_stream("/pkg.Svc/PartialFail", returns=[b"p1", b"p2"], raises=err) + with tripwire.sandbox(): channel = grpc.insecure_channel("localhost:50051") stub = channel.unary_stream("/pkg.Svc/PartialFail") response_iter = stub(b"req") @@ -702,7 +702,7 @@ def test_mid_stream_error(bigfoot_verifier: StrictVerifier) -> None: assert collected == [b"p1", b"p2"] assert exc_info.value is err - bigfoot.grpc_mock.assert_unary_stream( + tripwire.grpc_mock.assert_unary_stream( method="/pkg.Svc/PartialFail", request=b"req", metadata=None, @@ -779,16 +779,16 @@ def test_mock_stream_iterator_empty_with_error() -> None: # CHECK: timeline.all_unasserted() contains the interaction. # MUTATION: Auto-asserting in the interceptor means all_unasserted() would be empty. # ESCAPE: Nothing reasonable -- exact check on unasserted list. -def test_grpc_interactions_not_auto_asserted(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_grpc_interactions_not_auto_asserted(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.grpc_mock.mock_unary_unary("/pkg.Svc/Do", returns=b"ok") - with bigfoot.sandbox(): + tripwire.grpc_mock.mock_unary_unary("/pkg.Svc/Do", returns=b"ok") + with tripwire.sandbox(): channel = grpc.insecure_channel("localhost:50051") stub = channel.unary_unary("/pkg.Svc/Do") stub(b"req") - timeline = bigfoot_verifier._timeline + timeline = tripwire_verifier._timeline interactions = timeline.all_unasserted() assert len(interactions) == 1 assert interactions[0].source_id == "grpc:unary_unary:/pkg.Svc/Do" @@ -799,7 +799,7 @@ def test_grpc_interactions_not_auto_asserted(bigfoot_verifier: StrictVerifier) - "metadata": None, } # Assert it so verify_all() at teardown succeeds - bigfoot.grpc_mock.assert_unary_unary( + tripwire.grpc_mock.assert_unary_unary( method="/pkg.Svc/Do", request=b"req", metadata=None, @@ -817,12 +817,12 @@ def test_grpc_interactions_not_auto_asserted(bigfoot_verifier: StrictVerifier) - # CHECK: Results match FIFO order. # MUTATION: LIFO or random order fails the equality checks. # ESCAPE: Nothing reasonable. -def test_fifo_queue_ordering(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_fifo_queue_ordering(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.grpc_mock.mock_unary_unary("/pkg.Svc/Do", returns=b"first") - bigfoot.grpc_mock.mock_unary_unary("/pkg.Svc/Do", returns=b"second") - with bigfoot.sandbox(): + tripwire.grpc_mock.mock_unary_unary("/pkg.Svc/Do", returns=b"first") + tripwire.grpc_mock.mock_unary_unary("/pkg.Svc/Do", returns=b"second") + with tripwire.sandbox(): channel = grpc.insecure_channel("localhost:50051") stub = channel.unary_unary("/pkg.Svc/Do") r1 = stub(b"req1") @@ -830,12 +830,12 @@ def test_fifo_queue_ordering(bigfoot_verifier: StrictVerifier) -> None: assert r1 == b"first" assert r2 == b"second" - bigfoot.grpc_mock.assert_unary_unary( + tripwire.grpc_mock.assert_unary_unary( method="/pkg.Svc/Do", request=b"req1", metadata=None, ) - bigfoot.grpc_mock.assert_unary_unary( + tripwire.grpc_mock.assert_unary_unary( method="/pkg.Svc/Do", request=b"req2", metadata=None, @@ -848,18 +848,18 @@ def test_fifo_queue_ordering(bigfoot_verifier: StrictVerifier) -> None: # CHECK: UnmockedInteractionError raised on the second call. # MUTATION: Not raising allows unlimited calls. # ESCAPE: Nothing reasonable. -def test_unmocked_after_queue_exhausted(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_unmocked_after_queue_exhausted(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.grpc_mock.mock_unary_unary("/pkg.Svc/Do", returns=b"only-one") - with bigfoot.sandbox(): + tripwire.grpc_mock.mock_unary_unary("/pkg.Svc/Do", returns=b"only-one") + with tripwire.sandbox(): channel = grpc.insecure_channel("localhost:50051") stub = channel.unary_unary("/pkg.Svc/Do") stub(b"req1") # consumes the mock with pytest.raises(UnmockedInteractionError): stub(b"req2") - bigfoot.grpc_mock.assert_unary_unary( + tripwire.grpc_mock.assert_unary_unary( method="/pkg.Svc/Do", request=b"req1", metadata=None, @@ -877,17 +877,17 @@ def test_unmocked_after_queue_exhausted(bigfoot_verifier: StrictVerifier) -> Non # CHECK: Asserted metadata matches what was passed. # MUTATION: Not recording metadata means assertion fails. # ESCAPE: Nothing reasonable. -def test_metadata_passed_through(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_metadata_passed_through(tripwire_verifier: StrictVerifier) -> None: + import tripwire meta = (("authorization", "Bearer token"),) - bigfoot.grpc_mock.mock_unary_unary("/pkg.Svc/Auth", returns=b"ok") - with bigfoot.sandbox(): + tripwire.grpc_mock.mock_unary_unary("/pkg.Svc/Auth", returns=b"ok") + with tripwire.sandbox(): channel = grpc.insecure_channel("localhost:50051") stub = channel.unary_unary("/pkg.Svc/Auth") stub(b"req", metadata=meta) - bigfoot.grpc_mock.assert_unary_unary( + tripwire.grpc_mock.assert_unary_unary( method="/pkg.Svc/Auth", request=b"req", metadata=(("authorization", "Bearer token"),), @@ -905,18 +905,18 @@ def test_metadata_passed_through(bigfoot_verifier: StrictVerifier) -> None: # CHECK: Call through secure_channel works identically. # MUTATION: Not patching secure_channel means call fails. # ESCAPE: Nothing reasonable. -def test_secure_channel_interception(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_secure_channel_interception(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.grpc_mock.mock_unary_unary("/pkg.Svc/Secure", returns=b"secure-ok") - with bigfoot.sandbox(): + tripwire.grpc_mock.mock_unary_unary("/pkg.Svc/Secure", returns=b"secure-ok") + with tripwire.sandbox(): creds = grpc.ssl_channel_credentials() channel = grpc.secure_channel("localhost:443", creds) stub = channel.unary_unary("/pkg.Svc/Secure") result = stub(b"req") assert result == b"secure-ok" - bigfoot.grpc_mock.assert_unary_unary( + tripwire.grpc_mock.assert_unary_unary( method="/pkg.Svc/Secure", request=b"req", metadata=None, @@ -935,7 +935,7 @@ def test_secure_channel_interception(bigfoot_verifier: StrictVerifier) -> None: # MUTATION: Wrong format string fails equality. # ESCAPE: Nothing reasonable -- exact string comparison. def test_format_interaction() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -961,7 +961,7 @@ def test_format_interaction() -> None: # MUTATION: Wrong format fails equality. # ESCAPE: Nothing reasonable. def test_format_mock_hint() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -976,7 +976,7 @@ def test_format_mock_hint() -> None: plugin=p, ) assert p.format_mock_hint(interaction) == ( - " bigfoot.grpc_mock.mock_unary_unary('/pkg.Svc/Do', returns=...)" + " tripwire.grpc_mock.mock_unary_unary('/pkg.Svc/Do', returns=...)" ) @@ -996,7 +996,7 @@ def test_format_unmocked_hint() -> None: assert result == ( "grpc.unary_unary('/pkg.Svc/Do') was called but no mock was registered.\n" "Register a mock with:\n" - " bigfoot.grpc_mock.mock_unary_unary('/pkg.Svc/Do', returns=...)" + " tripwire.grpc_mock.mock_unary_unary('/pkg.Svc/Do', returns=...)" ) @@ -1007,7 +1007,7 @@ def test_format_unmocked_hint() -> None: # MUTATION: Wrong format fails equality. # ESCAPE: Nothing reasonable. def test_format_assert_hint() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1022,7 +1022,7 @@ def test_format_assert_hint() -> None: plugin=p, ) assert p.format_assert_hint(interaction) == ( - " bigfoot.grpc_mock.assert_unary_unary(\n" + " tripwire.grpc_mock.assert_unary_unary(\n" " method='/pkg.Svc/Do',\n" " request=b'data',\n" " metadata=None,\n" @@ -1067,7 +1067,7 @@ def test_format_unused_mock_hint() -> None: # MUTATION: Always returning True fails the False check. # ESCAPE: Nothing reasonable. def test_matches_field_comparison() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1094,28 +1094,28 @@ def test_matches_field_comparison() -> None: # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.grpc_mock +# Module-level proxy: tripwire.grpc_mock # --------------------------------------------------------------------------- # ESCAPE: test_grpc_mock_proxy_works -# CLAIM: bigfoot.grpc_mock.mock_unary_unary() works when verifier is active. +# CLAIM: tripwire.grpc_mock.mock_unary_unary() works when verifier is active. # PATH: _GrpcProxy.__getattr__("mock_unary_unary") -> get verifier -> # find/create GrpcPlugin -> return plugin.mock_unary_unary. # CHECK: The proxy call does not raise and the mock is registered and consumed. # MUTATION: Returning None instead of the plugin fails with AttributeError. # ESCAPE: Nothing reasonable. -def test_grpc_mock_proxy_works(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_grpc_mock_proxy_works(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.grpc_mock.mock_unary_unary("/pkg.Svc/Proxy", returns=b"proxy-ok") - with bigfoot.sandbox(): + tripwire.grpc_mock.mock_unary_unary("/pkg.Svc/Proxy", returns=b"proxy-ok") + with tripwire.sandbox(): channel = grpc.insecure_channel("localhost:50051") stub = channel.unary_unary("/pkg.Svc/Proxy") result = stub(b"req") assert result == b"proxy-ok" - bigfoot.grpc_mock.assert_unary_unary( + tripwire.grpc_mock.assert_unary_unary( method="/pkg.Svc/Proxy", request=b"req", metadata=None, @@ -1129,14 +1129,14 @@ def test_grpc_mock_proxy_works(bigfoot_verifier: StrictVerifier) -> None: # MUTATION: Not checking for active verifier allows access. # ESCAPE: Nothing reasonable. def test_grpc_mock_proxy_raises_outside_context() -> None: - import bigfoot - from bigfoot._errors import NoActiveVerifierError + import tripwire + from tripwire._errors import NoActiveVerifierError # Ensure no verifier is active token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - bigfoot.grpc_mock.mock_unary_unary("/pkg.Svc/Do", returns=b"val") + tripwire.grpc_mock.mock_unary_unary("/pkg.Svc/Do", returns=b"val") finally: _current_test_verifier.reset(token) @@ -1147,16 +1147,16 @@ def test_grpc_mock_proxy_raises_outside_context() -> None: # ESCAPE: test_grpc_plugin_in_all -# CLAIM: GrpcPlugin and grpc_mock are exported in bigfoot.__all__. +# CLAIM: GrpcPlugin and grpc_mock are exported in tripwire.__all__. # PATH: __init__.py __all__ list. # CHECK: Both names are in __all__. # MUTATION: Removing from __all__ fails the membership check. # ESCAPE: Nothing reasonable. def test_grpc_plugin_in_all() -> None: - import bigfoot + import tripwire - assert "GrpcPlugin" in bigfoot.__all__ - assert "grpc_mock" in bigfoot.__all__ + assert "GrpcPlugin" in tripwire.__all__ + assert "grpc_mock" in tripwire.__all__ # --------------------------------------------------------------------------- @@ -1170,12 +1170,12 @@ def test_grpc_plugin_in_all() -> None: # CHECK: Each call consumes from its own queue. # MUTATION: Sharing queues means first call exhausts the wrong mock. # ESCAPE: Nothing reasonable. -def test_separate_queues_per_call_type(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_separate_queues_per_call_type(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.grpc_mock.mock_unary_unary("/pkg.Svc/Multi", returns=b"unary-resp") - bigfoot.grpc_mock.mock_unary_stream("/pkg.Svc/Multi", returns=[b"stream-resp"]) - with bigfoot.sandbox(): + tripwire.grpc_mock.mock_unary_unary("/pkg.Svc/Multi", returns=b"unary-resp") + tripwire.grpc_mock.mock_unary_stream("/pkg.Svc/Multi", returns=[b"stream-resp"]) + with tripwire.sandbox(): channel = grpc.insecure_channel("localhost:50051") unary_stub = channel.unary_unary("/pkg.Svc/Multi") @@ -1186,12 +1186,12 @@ def test_separate_queues_per_call_type(bigfoot_verifier: StrictVerifier) -> None assert r1 == b"unary-resp" assert r2 == [b"stream-resp"] - bigfoot.grpc_mock.assert_unary_unary( + tripwire.grpc_mock.assert_unary_unary( method="/pkg.Svc/Multi", request=b"req1", metadata=None, ) - bigfoot.grpc_mock.assert_unary_stream( + tripwire.grpc_mock.assert_unary_stream( method="/pkg.Svc/Multi", request=b"req2", metadata=None, @@ -1210,7 +1210,7 @@ def test_separate_queues_per_call_type(bigfoot_verifier: StrictVerifier) -> None # MUTATION: Removing the except block propagates the error instead of returning False. # ESCAPE: Nothing reasonable -- exact boolean check. def test_matches_returns_false_on_eq_exception() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction class BrokenEq: def __eq__(self, other: object) -> bool: @@ -1247,8 +1247,8 @@ def __eq__(self, other: object) -> bool: # MUTATION: Not raising means the function returns None or wrong plugin. # ESCAPE: Nothing reasonable -- exact message comparison. def test_get_grpc_plugin_raises_without_grpc_plugin() -> None: - from bigfoot._context import _active_verifier - from bigfoot.plugins.grpc_plugin import _get_grpc_plugin + from tripwire._context import _active_verifier + from tripwire.plugins.grpc_plugin import _get_grpc_plugin v = StrictVerifier() # Remove all GrpcPlugin instances from the verifier's plugin list @@ -1258,7 +1258,7 @@ def test_get_grpc_plugin_raises_without_grpc_plugin() -> None: with pytest.raises(RuntimeError) as exc_info: _get_grpc_plugin() assert str(exc_info.value) == ( - "BUG: bigfoot GrpcPlugin interceptor is active but no " + "BUG: tripwire GrpcPlugin interceptor is active but no " "GrpcPlugin is registered on the current verifier." ) finally: diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 52b424d..1d1e29a 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -6,26 +6,26 @@ import pytest -from bigfoot._context import ( +from tripwire._context import ( GuardPassThrough, _guard_active, get_verifier_or_raise, ) -from bigfoot._errors import GuardedCallError, GuardedCallWarning, SandboxNotActiveError -from bigfoot._firewall import ( +from tripwire._errors import GuardedCallError, GuardedCallWarning, SandboxNotActiveError +from tripwire._firewall import ( Disposition, FirewallRule, FirewallStack, _firewall_stack, ) -from bigfoot._match import M -from bigfoot.pytest_plugin import _resolve_guard_level +from tripwire._match import M +from tripwire.pytest_plugin import _resolve_guard_level class TestGuardContextVars: """Test guard mode ContextVars exist and have correct defaults. - Note: the _bigfoot_guard autouse fixture sets _guard_active=True during + Note: the _tripwire_guard autouse fixture sets _guard_active=True during each test body, so runtime get() returns True. These tests verify the ContextVar's declared default and token-based set/reset behavior. """ @@ -89,10 +89,10 @@ def test_not_caught_by_generic_except_exception(self) -> None: class TestGuardedCallError: """Test GuardedCallError exception class.""" - def test_inherits_from_bigfoot_error(self) -> None: - from bigfoot._errors import BigfootError + def test_inherits_from_tripwire_error(self) -> None: + from tripwire._errors import TripwireError - assert issubclass(GuardedCallError, BigfootError) + assert issubclass(GuardedCallError, TripwireError) def test_stores_source_id_and_plugin_name(self) -> None: err = GuardedCallError(source_id="dns:getaddrinfo:example.com", plugin_name="dns") @@ -102,93 +102,93 @@ def test_stores_source_id_and_plugin_name(self) -> None: def test_message_format(self) -> None: err = GuardedCallError(source_id="http:request", plugin_name="http") msg = str(err) - assert msg.startswith("GuardedCallError: 'http:request' blocked by bigfoot firewall.") + assert msg.startswith("GuardedCallError: 'http:request' blocked by tripwire firewall.") assert '@pytest.mark.allow("http")' in msg - assert 'with bigfoot.allow("http")' in msg - assert "with bigfoot:" in msg - assert "[tool.bigfoot.firewall]" in msg - assert "https://bigfoot.readthedocs.io/guides/guard-mode/" in msg + assert 'with tripwire.allow("http")' in msg + assert "with tripwire:" in msg + assert "[tool.tripwire.firewall]" in msg + assert "https://tripwire.readthedocs.io/guides/guard-mode/" in msg # Old sections removed assert "FOR PLUGIN AUTHORS" not in msg assert "FOR CONTRIBUTORS" not in msg - assert "bigfoot_verifier.sandbox()" not in msg + assert "tripwire_verifier.sandbox()" not in msg assert "Valid plugin names for allow():" not in msg def test_message_with_different_plugin(self) -> None: err = GuardedCallError(source_id="dns:getaddrinfo:example.com", plugin_name="dns") msg = str(err) - assert "'dns:getaddrinfo:example.com' blocked by bigfoot firewall." in msg + assert "'dns:getaddrinfo:example.com' blocked by tripwire firewall." in msg assert '@pytest.mark.allow("dns")' in msg - assert 'with bigfoot.allow("dns")' in msg + assert 'with tripwire.allow("dns")' in msg class TestSupportsGuard: """Test supports_guard ClassVar on plugins.""" def test_base_plugin_default_is_true(self) -> None: - from bigfoot._base_plugin import BasePlugin + from tripwire._base_plugin import BasePlugin assert BasePlugin.supports_guard is True def test_mock_plugin_is_false(self) -> None: - from bigfoot._mock_plugin import MockPlugin + from tripwire._mock_plugin import MockPlugin assert MockPlugin.supports_guard is False def test_logging_plugin_is_false(self) -> None: - from bigfoot.plugins.logging_plugin import LoggingPlugin + from tripwire.plugins.logging_plugin import LoggingPlugin assert LoggingPlugin.supports_guard is False def test_jwt_plugin_is_false(self) -> None: - from bigfoot.plugins.jwt_plugin import JwtPlugin + from tripwire.plugins.jwt_plugin import JwtPlugin assert JwtPlugin.supports_guard is False def test_crypto_plugin_is_false(self) -> None: - from bigfoot.plugins.crypto_plugin import CryptoPlugin + from tripwire.plugins.crypto_plugin import CryptoPlugin assert CryptoPlugin.supports_guard is False def test_native_plugin_is_false(self) -> None: - from bigfoot.plugins.native_plugin import NativePlugin + from tripwire.plugins.native_plugin import NativePlugin assert NativePlugin.supports_guard is False def test_celery_plugin_is_false(self) -> None: - from bigfoot.plugins.celery_plugin import CeleryPlugin + from tripwire.plugins.celery_plugin import CeleryPlugin assert CeleryPlugin.supports_guard is False def test_file_io_plugin_is_false(self) -> None: - from bigfoot.plugins.file_io_plugin import FileIoPlugin + from tripwire.plugins.file_io_plugin import FileIoPlugin assert FileIoPlugin.supports_guard is False def test_dns_plugin_inherits_true(self) -> None: - from bigfoot.plugins.dns_plugin import DnsPlugin + from tripwire.plugins.dns_plugin import DnsPlugin assert DnsPlugin.supports_guard is True def test_http_plugin_inherits_true(self) -> None: - from bigfoot.plugins.http import HttpPlugin + from tripwire.plugins.http import HttpPlugin assert HttpPlugin.supports_guard is True def test_socket_plugin_inherits_true(self) -> None: - from bigfoot.plugins.socket_plugin import SocketPlugin + from tripwire.plugins.socket_plugin import SocketPlugin assert SocketPlugin.supports_guard is True -from bigfoot._guard import allow +from tripwire._guard import allow class TestAllow: """Test allow() context manager pushes ALLOW rules onto firewall stack.""" def test_pushes_allow_rules_and_resets(self) -> None: - from bigfoot._firewall_request import NetworkFirewallRequest + from tripwire._firewall_request import NetworkFirewallRequest stack_before = _firewall_stack.get() with allow("dns", "socket"): @@ -205,7 +205,7 @@ def test_pushes_allow_rules_and_resets(self) -> None: assert _firewall_stack.get() is stack_before def test_nestable_stacks_rules(self) -> None: - from bigfoot._firewall_request import NetworkFirewallRequest + from tripwire._firewall_request import NetworkFirewallRequest stack_before = _firewall_stack.get() with allow("dns"): @@ -227,7 +227,7 @@ def test_requires_at_least_one_rule(self) -> None: pass def test_single_protocol_name(self) -> None: - from bigfoot._firewall_request import NetworkFirewallRequest + from tripwire._firewall_request import NetworkFirewallRequest with allow("http"): stack = _firewall_stack.get() @@ -246,8 +246,8 @@ class TestDeny: """Test deny() context manager pushes DENY rules onto firewall stack.""" def test_deny_blocks_allowed_protocol(self) -> None: - from bigfoot._firewall_request import NetworkFirewallRequest - from bigfoot._guard import deny + from tripwire._firewall_request import NetworkFirewallRequest + from tripwire._guard import deny dns_req = NetworkFirewallRequest(protocol="dns", host="example.com", port=53) sock_req = NetworkFirewallRequest(protocol="socket", host="127.0.0.1", port=80) @@ -263,8 +263,8 @@ def test_deny_blocks_allowed_protocol(self) -> None: assert _firewall_stack.get().evaluate(sock_req) == Disposition.ALLOW def test_deny_without_allow_keeps_deny(self) -> None: - from bigfoot._firewall_request import NetworkFirewallRequest - from bigfoot._guard import deny + from tripwire._firewall_request import NetworkFirewallRequest + from tripwire._guard import deny dns_req = NetworkFirewallRequest(protocol="dns", host="example.com", port=53) # Default disposition is DENY, so deny on top of empty stack still denies @@ -272,7 +272,7 @@ def test_deny_without_allow_keeps_deny(self) -> None: assert _firewall_stack.get().evaluate(dns_req) == Disposition.DENY def test_deny_resets_on_exception(self) -> None: - from bigfoot._guard import deny + from tripwire._guard import deny stack_before = _firewall_stack.get() with pytest.raises(ValueError, match="boom"): @@ -282,15 +282,15 @@ def test_deny_resets_on_exception(self) -> None: assert _firewall_stack.get() is stack_before def test_deny_requires_at_least_one_rule(self) -> None: - from bigfoot._guard import deny + from tripwire._guard import deny with pytest.raises(ValueError, match="deny\\(\\) requires at least one rule"): with deny(): pass def test_nested_deny(self) -> None: - from bigfoot._firewall_request import NetworkFirewallRequest - from bigfoot._guard import deny + from tripwire._firewall_request import NetworkFirewallRequest + from tripwire._guard import deny dns_req = NetworkFirewallRequest(protocol="dns", host="example.com", port=53) sock_req = NetworkFirewallRequest(protocol="socket", host="127.0.0.1", port=80) @@ -313,8 +313,8 @@ class TestRestrict: """Test restrict() context manager pushes restriction ceiling onto firewall stack.""" def test_restrict_blocks_non_matching_protocols(self) -> None: - from bigfoot._firewall_request import NetworkFirewallRequest - from bigfoot._guard import restrict + from tripwire._firewall_request import NetworkFirewallRequest + from tripwire._guard import restrict http_req = NetworkFirewallRequest(protocol="http", host="example.com", port=80) redis_req = NetworkFirewallRequest(protocol="redis", host="localhost", port=6379) @@ -330,7 +330,7 @@ def test_restrict_blocks_non_matching_protocols(self) -> None: assert _firewall_stack.get().evaluate(redis_req) == Disposition.DENY def test_restrict_resets_on_exit(self) -> None: - from bigfoot._guard import restrict + from tripwire._guard import restrict stack_before = _firewall_stack.get() with restrict("http"): @@ -338,15 +338,15 @@ def test_restrict_resets_on_exit(self) -> None: assert _firewall_stack.get() is stack_before def test_restrict_requires_at_least_one_rule(self) -> None: - from bigfoot._guard import restrict + from tripwire._guard import restrict with pytest.raises(ValueError, match="restrict\\(\\) requires at least one rule"): with restrict(): pass def test_restrict_multiple_protocols_ored(self) -> None: - from bigfoot._firewall_request import NetworkFirewallRequest - from bigfoot._guard import restrict + from tripwire._firewall_request import NetworkFirewallRequest + from tripwire._guard import restrict http_req = NetworkFirewallRequest(protocol="http", host="example.com", port=80) dns_req = NetworkFirewallRequest(protocol="dns", host="example.com", port=53) @@ -360,8 +360,8 @@ def test_restrict_multiple_protocols_ored(self) -> None: assert _firewall_stack.get().evaluate(redis_req) == Disposition.DENY def test_restrict_inner_allow_cannot_widen_ceiling(self) -> None: - from bigfoot._firewall_request import NetworkFirewallRequest - from bigfoot._guard import restrict + from tripwire._firewall_request import NetworkFirewallRequest + from tripwire._guard import restrict redis_req = NetworkFirewallRequest(protocol="redis", host="localhost", port=6379) @@ -372,60 +372,60 @@ def test_restrict_inner_allow_cannot_widen_ceiling(self) -> None: class TestPublicExports: - """Test that guard mode symbols are exported from bigfoot package.""" + """Test that guard mode symbols are exported from tripwire package.""" - def test_allow_importable_from_bigfoot(self) -> None: - from bigfoot import allow as bigfoot_allow + def test_allow_importable_from_tripwire(self) -> None: + from tripwire import allow as tripwire_allow - assert callable(bigfoot_allow) + assert callable(tripwire_allow) - def test_deny_importable_from_bigfoot(self) -> None: - from bigfoot import deny as bigfoot_deny + def test_deny_importable_from_tripwire(self) -> None: + from tripwire import deny as tripwire_deny - assert callable(bigfoot_deny) + assert callable(tripwire_deny) - def test_restrict_importable_from_bigfoot(self) -> None: - from bigfoot import restrict as bigfoot_restrict + def test_restrict_importable_from_tripwire(self) -> None: + from tripwire import restrict as tripwire_restrict - assert callable(bigfoot_restrict) + assert callable(tripwire_restrict) - def test_guarded_call_error_importable_from_bigfoot(self) -> None: - from bigfoot import GuardedCallError as BigfootGuardedCallError + def test_guarded_call_error_importable_from_tripwire(self) -> None: + from tripwire import GuardedCallError as TripwireGuardedCallError - assert issubclass(BigfootGuardedCallError, Exception) + assert issubclass(TripwireGuardedCallError, Exception) def test_allow_in_all(self) -> None: - import bigfoot + import tripwire - assert "allow" in bigfoot.__all__ + assert "allow" in tripwire.__all__ def test_restrict_in_all(self) -> None: - import bigfoot + import tripwire - assert "restrict" in bigfoot.__all__ + assert "restrict" in tripwire.__all__ def test_guarded_call_error_in_all(self) -> None: - import bigfoot + import tripwire - assert "GuardedCallError" in bigfoot.__all__ + assert "GuardedCallError" in tripwire.__all__ - def test_guarded_call_warning_importable_from_bigfoot(self) -> None: - from bigfoot import GuardedCallWarning as BigfootGuardedCallWarning + def test_guarded_call_warning_importable_from_tripwire(self) -> None: + from tripwire import GuardedCallWarning as TripwireGuardedCallWarning - assert issubclass(BigfootGuardedCallWarning, UserWarning) + assert issubclass(TripwireGuardedCallWarning, UserWarning) def test_guarded_call_warning_in_all(self) -> None: - import bigfoot + import tripwire - assert "GuardedCallWarning" in bigfoot.__all__ + assert "GuardedCallWarning" in tripwire.__all__ class TestResolveGuardLevel: """Test _resolve_guard_level config parser.""" - def test_absent_key_returns_warn(self) -> None: - """Missing guard key defaults to 'warn'.""" - assert _resolve_guard_level({}) == "warn" + def test_absent_key_returns_error(self) -> None: + """Missing guard key defaults to 'error' as of 0.20.0 (Proposal 1 default flip).""" + assert _resolve_guard_level({}) == "error" def test_warn_string_returns_warn(self) -> None: assert _resolve_guard_level({"guard": "warn"}) == "warn" @@ -442,21 +442,21 @@ def test_false_returns_off(self) -> None: def test_true_rejected_with_config_error(self) -> None: """guard = true is ambiguous and must be rejected.""" - from bigfoot._errors import BigfootConfigError + from tripwire._errors import TripwireConfigError - with pytest.raises(BigfootConfigError, match="guard = true is ambiguous"): + with pytest.raises(TripwireConfigError, match="guard = true is ambiguous"): _resolve_guard_level({"guard": True}) def test_invalid_string_rejected(self) -> None: - from bigfoot._errors import BigfootConfigError + from tripwire._errors import TripwireConfigError - with pytest.raises(BigfootConfigError, match="Invalid guard value"): + with pytest.raises(TripwireConfigError, match="Invalid guard value"): _resolve_guard_level({"guard": "invalid"}) def test_invalid_type_rejected(self) -> None: - from bigfoot._errors import BigfootConfigError + from tripwire._errors import TripwireConfigError - with pytest.raises(BigfootConfigError, match="guard must be a string or false"): + with pytest.raises(TripwireConfigError, match="guard must be a string or false"): _resolve_guard_level({"guard": 42}) def test_case_insensitive_warn(self) -> None: @@ -475,11 +475,11 @@ class TestGuardedCallWarningClass: def test_is_user_warning(self) -> None: assert issubclass(GuardedCallWarning, UserWarning) - def test_not_bigfoot_error(self) -> None: - """GuardedCallWarning is a warning, not a BigfootError.""" - from bigfoot._errors import BigfootError + def test_not_tripwire_error(self) -> None: + """GuardedCallWarning is a warning, not a TripwireError.""" + from tripwire._errors import TripwireError - assert not issubclass(GuardedCallWarning, BigfootError) + assert not issubclass(GuardedCallWarning, TripwireError) class TestWarnModeBehavior: @@ -487,8 +487,8 @@ class TestWarnModeBehavior: def test_warn_mode_emits_warning(self) -> None: """Guard in warn mode emits GuardedCallWarning.""" - from bigfoot._context import _guard_level - from bigfoot._firewall_request import NetworkFirewallRequest + from tripwire._context import _guard_level + from tripwire._firewall_request import NetworkFirewallRequest level_token = _guard_level.set("warn") guard_token = _guard_active.set(True) @@ -508,8 +508,8 @@ def test_warn_mode_emits_warning(self) -> None: def test_warn_mode_raises_guard_pass_through(self) -> None: """After warning, GuardPassThrough is raised (real call proceeds).""" - from bigfoot._context import _guard_level - from bigfoot._firewall_request import NetworkFirewallRequest + from tripwire._context import _guard_level + from tripwire._firewall_request import NetworkFirewallRequest level_token = _guard_level.set("warn") guard_token = _guard_active.set(True) @@ -525,8 +525,8 @@ def test_warn_mode_raises_guard_pass_through(self) -> None: def test_warn_mode_warning_is_filterable(self) -> None: """warnings.filterwarnings('ignore') suppresses GuardedCallWarning.""" - from bigfoot._context import _guard_level - from bigfoot._firewall_request import NetworkFirewallRequest + from tripwire._context import _guard_level + from tripwire._firewall_request import NetworkFirewallRequest level_token = _guard_level.set("warn") guard_token = _guard_active.set(True) @@ -544,8 +544,8 @@ def test_warn_mode_warning_is_filterable(self) -> None: def test_warn_mode_warning_contains_source_id(self) -> None: """Warning message includes the source_id.""" - from bigfoot._context import _guard_level - from bigfoot._firewall_request import NetworkFirewallRequest + from tripwire._context import _guard_level + from tripwire._firewall_request import NetworkFirewallRequest level_token = _guard_level.set("warn") guard_token = _guard_active.set(True) @@ -562,8 +562,8 @@ def test_warn_mode_warning_contains_source_id(self) -> None: def test_warn_mode_warning_contains_blocked_by_firewall(self) -> None: """Warning message says 'blocked by firewall'.""" - from bigfoot._context import _guard_level - from bigfoot._firewall_request import NetworkFirewallRequest + from tripwire._context import _guard_level + from tripwire._firewall_request import NetworkFirewallRequest level_token = _guard_level.set("warn") guard_token = _guard_active.set(True) @@ -581,8 +581,8 @@ def test_warn_mode_warning_contains_blocked_by_firewall(self) -> None: def test_error_mode_raises_guarded_call_error(self) -> None: """Guard in error mode raises GuardedCallError (not a warning).""" - from bigfoot._context import _guard_level - from bigfoot._firewall_request import NetworkFirewallRequest + from tripwire._context import _guard_level + from tripwire._firewall_request import NetworkFirewallRequest level_token = _guard_level.set("error") guard_token = _guard_active.set(True) @@ -596,8 +596,8 @@ def test_error_mode_raises_guarded_call_error(self) -> None: def test_firewall_allow_in_warn_mode_suppresses_warning(self) -> None: """Allowed protocols don't emit warnings even in warn mode.""" - from bigfoot._context import _guard_level - from bigfoot._firewall_request import NetworkFirewallRequest + from tripwire._context import _guard_level + from tripwire._firewall_request import NetworkFirewallRequest level_token = _guard_level.set("warn") guard_token = _guard_active.set(True) @@ -624,7 +624,7 @@ class TestHookFirewallStackMerge: def test_no_markers_empty_stack_denies(self) -> None: """Without markers, the firewall stack default-denies all protocols.""" - from bigfoot._firewall_request import NetworkFirewallRequest + from tripwire._firewall_request import NetworkFirewallRequest stack = _firewall_stack.get() http_req = NetworkFirewallRequest(protocol="http", host="example.com", port=80) @@ -635,7 +635,7 @@ def test_no_markers_empty_stack_denies(self) -> None: @pytest.mark.allow("socket") def test_marker_allow_creates_allow_rule(self) -> None: """@pytest.mark.allow('socket') creates ALLOW rule in firewall stack.""" - from bigfoot._firewall_request import NetworkFirewallRequest + from tripwire._firewall_request import NetworkFirewallRequest stack = _firewall_stack.get() sock_req = NetworkFirewallRequest(protocol="socket", host="127.0.0.1", port=80) @@ -645,7 +645,7 @@ def test_marker_allow_creates_allow_rule(self) -> None: @pytest.mark.deny("dns") def test_marker_deny_blocks_non_allowed(self) -> None: """deny('dns') blocks 'dns' when it is not in the allow set.""" - from bigfoot._firewall_request import NetworkFirewallRequest + from tripwire._firewall_request import NetworkFirewallRequest stack = _firewall_stack.get() dns_req = NetworkFirewallRequest(protocol="dns", host="example.com", port=53) @@ -663,7 +663,7 @@ def test_no_sandbox_no_guard_raises_sandbox_not_active(self) -> None: Must explicitly disable guard and guard_patches_installed since the session fixture and hook set them. """ - from bigfoot._context import _guard_patches_installed + from tripwire._context import _guard_patches_installed guard_token = _guard_active.set(False) patches_token = _guard_patches_installed.set(False) @@ -676,7 +676,7 @@ def test_no_sandbox_no_guard_raises_sandbox_not_active(self) -> None: def test_guard_active_not_in_allowlist_raises_guarded_call_error(self) -> None: """Guard active + not allowed + error level = GuardedCallError.""" - from bigfoot._context import _guard_level + from tripwire._context import _guard_level level_token = _guard_level.set("error") token = _guard_active.set(True) @@ -691,7 +691,7 @@ def test_guard_active_not_in_allowlist_raises_guarded_call_error(self) -> None: def test_guard_active_in_allowlist_raises_guard_pass_through(self) -> None: """Guard active + allowed via firewall = GuardPassThrough (interceptor should call original).""" - from bigfoot._firewall_request import NetworkFirewallRequest + from tripwire._firewall_request import NetworkFirewallRequest guard_token = _guard_active.set(True) try: @@ -704,7 +704,7 @@ def test_guard_active_in_allowlist_raises_guard_pass_through(self) -> None: def test_plugin_name_extraction_from_source_id(self) -> None: """Plugin name is the prefix before the first colon.""" - from bigfoot._context import _guard_level + from tripwire._context import _guard_level level_token = _guard_level.set("error") token = _guard_active.set(True) @@ -718,7 +718,7 @@ def test_plugin_name_extraction_from_source_id(self) -> None: def test_plugin_name_extraction_multi_colon(self) -> None: """Multi-colon source_id: plugin name is still first segment.""" - from bigfoot._context import _guard_level + from tripwire._context import _guard_level level_token = _guard_level.set("error") token = _guard_active.set(True) @@ -745,10 +745,10 @@ def test_dns_getaddrinfo_guard_blocks_when_not_allowed(self) -> None: """Guard blocks dns:getaddrinfo when dns not in allowlist.""" import socket - from bigfoot._context import _guard_level - from bigfoot._guard import deny - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.dns_plugin import DnsPlugin + from tripwire._context import _guard_level + from tripwire._guard import deny + from tripwire._verifier import StrictVerifier + from tripwire.plugins.dns_plugin import DnsPlugin v = StrictVerifier() dns = DnsPlugin(v) @@ -778,8 +778,8 @@ def test_dns_getaddrinfo_guard_passes_through_when_not_active(self) -> None: """ import socket - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.dns_plugin import DnsPlugin + from tripwire._verifier import StrictVerifier + from tripwire.plugins.dns_plugin import DnsPlugin v = StrictVerifier() dns = DnsPlugin(v) @@ -800,10 +800,10 @@ def test_dns_gethostbyname_guard_blocks_when_not_allowed(self) -> None: """Guard blocks dns:gethostbyname when dns not in allowlist.""" import socket - from bigfoot._context import _guard_level - from bigfoot._guard import deny - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.dns_plugin import DnsPlugin + from tripwire._context import _guard_level + from tripwire._guard import deny + from tripwire._verifier import StrictVerifier + from tripwire.plugins.dns_plugin import DnsPlugin v = StrictVerifier() dns = DnsPlugin(v) @@ -827,8 +827,8 @@ def test_dns_gethostbyname_guard_passes_through_when_not_active(self) -> None: """Guard pass-through: interceptor calls original gethostbyname when guard is not active.""" import socket - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.dns_plugin import DnsPlugin + from tripwire._verifier import StrictVerifier + from tripwire.plugins.dns_plugin import DnsPlugin v = StrictVerifier() dns = DnsPlugin(v) @@ -856,10 +856,10 @@ def test_socket_connect_guard_blocks_when_not_allowed(self) -> None: """Guard blocks socket:connect when socket not in allowlist.""" import socket as socket_mod - from bigfoot._context import _guard_level - from bigfoot._guard import deny - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.socket_plugin import _SOCKET_CLOSE_ORIGINAL, SocketPlugin + from tripwire._context import _guard_level + from tripwire._guard import deny + from tripwire._verifier import StrictVerifier + from tripwire.plugins.socket_plugin import _SOCKET_CLOSE_ORIGINAL, SocketPlugin v = StrictVerifier() sp = SocketPlugin(v) @@ -888,8 +888,8 @@ def test_socket_connect_guard_passes_through_when_not_active(self) -> None: """Guard pass-through: interceptor calls real connect when guard is not active.""" import socket as socket_mod - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.socket_plugin import _SOCKET_CLOSE_ORIGINAL, SocketPlugin + from tripwire._verifier import StrictVerifier + from tripwire.plugins.socket_plugin import _SOCKET_CLOSE_ORIGINAL, SocketPlugin v = StrictVerifier() sp = SocketPlugin(v) @@ -914,12 +914,12 @@ def test_socket_send_guard_passes_through_in_guard_mode(self) -> None: """In guard mode (no sandbox), send passes through to the original. The firewall decision was already made at connect time; send/recv/ - sendall/close skip bigfoot entirely when no sandbox is active. + sendall/close skip tripwire entirely when no sandbox is active. """ import socket as socket_mod - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.socket_plugin import ( + from tripwire._verifier import StrictVerifier + from tripwire.plugins.socket_plugin import ( _SOCKET_CLOSE_ORIGINAL, SocketPlugin, ) @@ -947,8 +947,8 @@ def test_socket_close_guard_passes_through_when_not_active(self) -> None: """Guard pass-through: interceptor calls real close when guard is not active.""" import socket as socket_mod - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.socket_plugin import SocketPlugin + from tripwire._verifier import StrictVerifier + from tripwire.plugins.socket_plugin import SocketPlugin v = StrictVerifier() sp = SocketPlugin(v) @@ -968,9 +968,9 @@ def test_database_connect_guard_blocks_when_not_allowed(self) -> None: """Guard blocks db:connect when db not in allowlist.""" import sqlite3 - from bigfoot._context import _guard_level - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.database_plugin import DatabasePlugin + from tripwire._context import _guard_level + from tripwire._verifier import StrictVerifier + from tripwire.plugins.database_plugin import DatabasePlugin v = StrictVerifier() dp = DatabasePlugin(v) @@ -993,8 +993,8 @@ def test_database_connect_guard_passes_through_when_not_active(self) -> None: """Guard pass-through: interceptor calls real connect when guard is not active.""" import sqlite3 - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.database_plugin import DatabasePlugin + from tripwire._verifier import StrictVerifier + from tripwire.plugins.database_plugin import DatabasePlugin v = StrictVerifier() dp = DatabasePlugin(v) @@ -1020,9 +1020,9 @@ def test_smtp_init_guard_blocks_when_not_allowed(self) -> None: """Guard blocks smtp:connect when smtp not in allowlist.""" import smtplib - from bigfoot._context import _guard_level - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.smtp_plugin import SmtpPlugin + from tripwire._context import _guard_level + from tripwire._verifier import StrictVerifier + from tripwire.plugins.smtp_plugin import SmtpPlugin v = StrictVerifier() sp = SmtpPlugin(v) @@ -1044,9 +1044,9 @@ def test_popen_init_guard_blocks_when_not_allowed(self) -> None: """Guard blocks subprocess:popen:spawn when subprocess not in allowlist.""" import subprocess - from bigfoot._context import _guard_level - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.popen_plugin import PopenPlugin + from tripwire._context import _guard_level + from tripwire._verifier import StrictVerifier + from tripwire.plugins.popen_plugin import PopenPlugin v = StrictVerifier() pp = PopenPlugin(v) @@ -1077,9 +1077,9 @@ def test_subprocess_run_guard_blocks_when_not_allowed(self) -> None: """Guard blocks subprocess.run when subprocess not in allowlist.""" import subprocess as subprocess_mod - from bigfoot._context import _guard_level - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.subprocess import SubprocessPlugin + from tripwire._context import _guard_level + from tripwire._verifier import StrictVerifier + from tripwire.plugins.subprocess import SubprocessPlugin v = StrictVerifier() sp = SubprocessPlugin(v) @@ -1102,8 +1102,8 @@ def test_subprocess_run_guard_passes_through_when_not_active(self) -> None: """Guard pass-through: interceptor calls real subprocess.run when guard is not active.""" import subprocess as subprocess_mod - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.subprocess import SubprocessPlugin + from tripwire._verifier import StrictVerifier + from tripwire.plugins.subprocess import SubprocessPlugin v = StrictVerifier() sp = SubprocessPlugin(v) @@ -1125,9 +1125,9 @@ def test_subprocess_which_guard_blocks_when_not_allowed(self) -> None: """Guard blocks shutil.which when subprocess not in allowlist.""" import shutil as shutil_mod - from bigfoot._context import _guard_level - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.subprocess import SubprocessPlugin + from tripwire._context import _guard_level + from tripwire._verifier import StrictVerifier + from tripwire.plugins.subprocess import SubprocessPlugin v = StrictVerifier() sp = SubprocessPlugin(v) @@ -1150,8 +1150,8 @@ def test_subprocess_which_guard_passes_through_when_not_active(self) -> None: """Guard pass-through: interceptor calls real shutil.which when guard is not active.""" import shutil as shutil_mod - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.subprocess import SubprocessPlugin + from tripwire._verifier import StrictVerifier + from tripwire.plugins.subprocess import SubprocessPlugin v = StrictVerifier() sp = SubprocessPlugin(v) @@ -1171,9 +1171,9 @@ def test_http_sync_guard_blocks_when_not_allowed(self) -> None: """Guard blocks httpx sync transport when http not in allowlist.""" import httpx - from bigfoot._context import _guard_level - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.http import HttpPlugin + from tripwire._context import _guard_level + from tripwire._verifier import StrictVerifier + from tripwire.plugins.http import HttpPlugin v = StrictVerifier() hp = HttpPlugin(v) @@ -1203,20 +1203,20 @@ def test_allow_mark_is_registered(self, pytestconfig: pytest.Config) -> None: assert any(m.startswith("allow") for m in markers) def test_guard_session_fixture_is_registered(self) -> None: - """The _bigfoot_guard_patches session fixture should exist in pytest_plugin.""" - from bigfoot import pytest_plugin + """The _tripwire_guard_patches session fixture should exist in pytest_plugin.""" + from tripwire import pytest_plugin - assert hasattr(pytest_plugin, "_bigfoot_guard_patches") + assert hasattr(pytest_plugin, "_tripwire_guard_patches") def test_guard_hook_is_registered(self) -> None: """The pytest_runtest_call hook should exist in pytest_plugin module.""" - from bigfoot import pytest_plugin + from tripwire import pytest_plugin assert hasattr(pytest_plugin, "pytest_runtest_call") def test_guard_hook_skips_non_guard_plugins(self) -> None: """Guard hook should not activate plugins with supports_guard=False.""" - from bigfoot._registry import PLUGIN_REGISTRY, _is_available, get_plugin_class + from tripwire._registry import PLUGIN_REGISTRY, _is_available, get_plugin_class for entry in PLUGIN_REGISTRY: if not _is_available(entry): @@ -1230,7 +1230,7 @@ def test_guard_hook_skips_non_guard_plugins(self) -> None: def test_guard_hook_skips_opt_in_plugins(self) -> None: """Guard hook should not activate opt-in plugins (default_enabled=False).""" - from bigfoot._registry import PLUGIN_REGISTRY + from tripwire._registry import PLUGIN_REGISTRY opt_in = [e for e in PLUGIN_REGISTRY if not e.default_enabled] assert len(opt_in) >= 2 # file_io and native at minimum @@ -1249,7 +1249,7 @@ def test_guard_active_is_true_during_test(self) -> None: def test_firewall_stack_denies_by_default(self) -> None: """Without @pytest.mark.allow, all protocols are denied by the firewall.""" - from bigfoot._firewall_request import NetworkFirewallRequest + from tripwire._firewall_request import NetworkFirewallRequest stack = _firewall_stack.get() req = NetworkFirewallRequest(protocol="http", host="example.com", port=80) @@ -1258,7 +1258,7 @@ def test_firewall_stack_denies_by_default(self) -> None: @pytest.mark.allow("dns", "socket") def test_mark_allow_populates_firewall_stack(self) -> None: """@pytest.mark.allow should push ALLOW rules onto firewall stack.""" - from bigfoot._firewall_request import NetworkFirewallRequest + from tripwire._firewall_request import NetworkFirewallRequest stack = _firewall_stack.get() dns_req = NetworkFirewallRequest(protocol="dns", host="example.com", port=53) @@ -1269,7 +1269,7 @@ def test_mark_allow_populates_firewall_stack(self) -> None: @pytest.mark.allow("dns") def test_mark_allow_single_plugin(self) -> None: """Single plugin in @pytest.mark.allow pushes one ALLOW rule.""" - from bigfoot._firewall_request import NetworkFirewallRequest + from tripwire._firewall_request import NetworkFirewallRequest stack = _firewall_stack.get() dns_req = NetworkFirewallRequest(protocol="dns", host="example.com", port=53) @@ -1279,7 +1279,7 @@ def test_mark_allow_single_plugin(self) -> None: @pytest.mark.allow("socket") def test_multiple_allow_marks_combine(self) -> None: """Multiple @pytest.mark.allow decorators combine into firewall rules.""" - from bigfoot._firewall_request import NetworkFirewallRequest + from tripwire._firewall_request import NetworkFirewallRequest stack = _firewall_stack.get() dns_req = NetworkFirewallRequest(protocol="dns", host="example.com", port=53) @@ -1287,9 +1287,9 @@ def test_multiple_allow_marks_combine(self) -> None: assert stack.evaluate(dns_req) == Disposition.ALLOW assert stack.evaluate(sock_req) == Disposition.ALLOW - def test_bigfoot_guard_hook_exists_in_pytest_plugin(self) -> None: + def test_tripwire_guard_hook_exists_in_pytest_plugin(self) -> None: """The pytest_runtest_call hook should exist in pytest_plugin module.""" - from bigfoot import pytest_plugin + from tripwire import pytest_plugin assert hasattr(pytest_plugin, "pytest_runtest_call") @@ -1309,10 +1309,10 @@ def test_guard_blocks_real_socket_connect_outside_sandbox(self) -> None: """ import socket as socket_mod - from bigfoot._context import _guard_level - from bigfoot._guard import deny - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.socket_plugin import _SOCKET_CLOSE_ORIGINAL, SocketPlugin + from tripwire._context import _guard_level + from tripwire._guard import deny + from tripwire._verifier import StrictVerifier + from tripwire.plugins.socket_plugin import _SOCKET_CLOSE_ORIGINAL, SocketPlugin v = StrictVerifier() sp = SocketPlugin(v) @@ -1343,8 +1343,8 @@ def test_guard_pass_through_permits_real_socket_operations(self) -> None: """ import socket as socket_mod - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.socket_plugin import SocketPlugin + from tripwire._verifier import StrictVerifier + from tripwire.plugins.socket_plugin import SocketPlugin v = StrictVerifier() sp = SocketPlugin(v) @@ -1369,8 +1369,8 @@ def test_sandbox_takes_precedence_over_guard(self) -> None: """ import socket - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.dns_plugin import DnsPlugin, _DnsSentinel + from tripwire._verifier import StrictVerifier + from tripwire.plugins.dns_plugin import DnsPlugin, _DnsSentinel v = StrictVerifier() dns_plugin = None @@ -1399,7 +1399,7 @@ def test_sandbox_takes_precedence_over_guard(self) -> None: @pytest.mark.allow("dns") def test_allow_context_manager_adds_to_marker_rules(self) -> None: """allow() inside @pytest.mark.allow adds rules to the firewall stack.""" - from bigfoot._firewall_request import NetworkFirewallRequest + from tripwire._firewall_request import NetworkFirewallRequest stack_mark = _firewall_stack.get() dns_req = NetworkFirewallRequest(protocol="dns", host="example.com", port=53) @@ -1429,8 +1429,8 @@ def test_guard_pass_through_permits_real_dns_operations(self) -> None: """ import socket as socket_mod - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.dns_plugin import DnsPlugin + from tripwire._verifier import StrictVerifier + from tripwire.plugins.dns_plugin import DnsPlugin v = StrictVerifier() dns = DnsPlugin(v) @@ -1450,7 +1450,7 @@ def test_guard_active_is_false_during_fixture_setup(self) -> None: """Indirectly verify guard is scoped to test body, not fixtures. The hook wraps pytest_runtest_call (test body only). If guard were - active during fixture setup, the _bigfoot_auto_verifier fixture + active during fixture setup, the _tripwire_auto_verifier fixture would fail when creating StrictVerifier (which internally may perform I/O-like operations). The fact that we get here proves it. """ @@ -1464,10 +1464,10 @@ def test_guard_blocks_dns_lookup_outside_sandbox(self) -> None: """ import socket as socket_mod - from bigfoot._context import _guard_level - from bigfoot._guard import deny - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.dns_plugin import DnsPlugin + from tripwire._context import _guard_level + from tripwire._guard import deny + from tripwire._verifier import StrictVerifier + from tripwire.plugins.dns_plugin import DnsPlugin v = StrictVerifier() dns = DnsPlugin(v) @@ -1491,9 +1491,9 @@ def test_guard_blocks_subprocess_outside_sandbox(self) -> None: """ import subprocess as subprocess_mod - from bigfoot._context import _guard_level - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.subprocess import SubprocessPlugin + from tripwire._context import _guard_level + from tripwire._verifier import StrictVerifier + from tripwire.plugins.subprocess import SubprocessPlugin v = StrictVerifier() sp = SubprocessPlugin(v) @@ -1515,8 +1515,8 @@ def test_guard_pass_through_permits_real_subprocess(self) -> None: """ import subprocess as subprocess_mod - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.subprocess import SubprocessPlugin + from tripwire._verifier import StrictVerifier + from tripwire.plugins.subprocess import SubprocessPlugin v = StrictVerifier() sp = SubprocessPlugin(v) @@ -1540,9 +1540,9 @@ def test_guard_blocks_http_outside_sandbox(self) -> None: """ import httpx - from bigfoot._context import _guard_level - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.http import HttpPlugin + from tripwire._context import _guard_level + from tripwire._verifier import StrictVerifier + from tripwire.plugins.http import HttpPlugin v = StrictVerifier() hp = HttpPlugin(v) @@ -1566,13 +1566,13 @@ def test_sandbox_takes_precedence_over_firewall_allow(self) -> None: """ import socket - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.dns_plugin import _DnsSentinel + from tripwire._verifier import StrictVerifier + from tripwire.plugins.dns_plugin import _DnsSentinel v = StrictVerifier() dns_plugin = None for p in v._plugins: - from bigfoot.plugins.dns_plugin import DnsPlugin + from tripwire.plugins.dns_plugin import DnsPlugin if isinstance(p, DnsPlugin): dns_plugin = p break @@ -1606,10 +1606,10 @@ def test_guarded_call_error_message_has_actionable_guidance(self) -> None: """ import socket as socket_mod - from bigfoot._context import _guard_level - from bigfoot._guard import deny - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.dns_plugin import DnsPlugin + from tripwire._context import _guard_level + from tripwire._guard import deny + from tripwire._verifier import StrictVerifier + from tripwire.plugins.dns_plugin import DnsPlugin v = StrictVerifier() dns = DnsPlugin(v) @@ -1622,18 +1622,18 @@ def test_guarded_call_error_message_has_actionable_guidance(self) -> None: socket_mod.getaddrinfo("example.com", 80) msg = str(exc_info.value) # New firewall message format - assert "blocked by bigfoot firewall" in msg + assert "blocked by tripwire firewall" in msg assert "Attempted:" in msg assert "Fix with @pytest.mark.allow:" in msg assert '@pytest.mark.allow(M(protocol="dns"))' in msg assert "Fix with context manager (scoped to a block):" in msg - assert 'bigfoot.allow(M(protocol="dns"))' in msg + assert 'tripwire.allow(M(protocol="dns"))' in msg assert "Fix in pyproject.toml:" in msg - assert "[tool.bigfoot.firewall]" in msg + assert "[tool.tripwire.firewall]" in msg assert 'allow = ["dns:*"]' in msg assert "Or mock the call with a sandbox:" in msg - assert "with bigfoot:" in msg - assert "https://bigfoot.readthedocs.io/guides/guard-mode/" in msg + assert "with tripwire:" in msg + assert "https://tripwire.readthedocs.io/guides/guard-mode/" in msg # Old sections removed assert "supports_guard" not in msg assert "Valid plugin names for allow():" not in msg @@ -1649,8 +1649,8 @@ def test_guard_allow_raises_migration_error(self) -> None: """guard_allow config key is rejected with migration instructions.""" from unittest.mock import patch - from bigfoot._errors import BigfootConfigError - from bigfoot.pytest_plugin import pytest_runtest_call + from tripwire._errors import TripwireConfigError + from tripwire.pytest_plugin import pytest_runtest_call config = {"guard": "error", "guard_allow": ["socket"]} @@ -1660,17 +1660,17 @@ def iter_markers(self, name: str): item = FakeItem() - with patch("bigfoot.pytest_plugin.load_bigfoot_config", return_value=config): + with patch("tripwire.pytest_plugin.load_tripwire_config", return_value=config): hook_gen = pytest_runtest_call(item) - with pytest.raises(BigfootConfigError, match="guard_allow config key has been replaced"): + with pytest.raises(TripwireConfigError, match="guard_allow config key has been replaced"): next(hook_gen) def test_guard_allow_string_raises_migration_error(self) -> None: """guard_allow = "socket" (string) also raises migration error.""" from unittest.mock import patch - from bigfoot._errors import BigfootConfigError - from bigfoot.pytest_plugin import pytest_runtest_call + from tripwire._errors import TripwireConfigError + from tripwire.pytest_plugin import pytest_runtest_call config = {"guard": "error", "guard_allow": "socket"} @@ -1680,21 +1680,21 @@ def iter_markers(self, name: str): item = FakeItem() - with patch("bigfoot.pytest_plugin.load_bigfoot_config", return_value=config): + with patch("tripwire.pytest_plugin.load_tripwire_config", return_value=config): hook_gen = pytest_runtest_call(item) - with pytest.raises(BigfootConfigError, match="guard_allow config key has been replaced"): + with pytest.raises(TripwireConfigError, match="guard_allow config key has been replaced"): next(hook_gen) class TestFirewallTomlConfig: - """Test [tool.bigfoot.firewall] TOML config integration with pytest hook.""" + """Test [tool.tripwire.firewall] TOML config integration with pytest hook.""" def test_firewall_allow_rule_in_config(self) -> None: - """[tool.bigfoot.firewall] allow = ["socket:*"] creates ALLOW rule.""" + """[tool.tripwire.firewall] allow = ["socket:*"] creates ALLOW rule.""" from unittest.mock import patch - from bigfoot._firewall_request import NetworkFirewallRequest - from bigfoot.pytest_plugin import pytest_runtest_call + from tripwire._firewall_request import NetworkFirewallRequest + from tripwire.pytest_plugin import pytest_runtest_call config = {"guard": "error", "firewall": {"allow": ["socket:*"]}} @@ -1706,7 +1706,7 @@ def iter_markers(self, name: str): item = FakeItem() - with patch("bigfoot.pytest_plugin.load_bigfoot_config", return_value=config): + with patch("tripwire.pytest_plugin.load_tripwire_config", return_value=config): hook_gen = pytest_runtest_call(item) next(hook_gen) stack = _firewall_stack.get() @@ -1718,11 +1718,11 @@ def iter_markers(self, name: str): pass def test_no_firewall_config_denies_all(self) -> None: - """Without [tool.bigfoot.firewall], all protocols are denied.""" + """Without [tool.tripwire.firewall], all protocols are denied.""" from unittest.mock import patch - from bigfoot._firewall_request import NetworkFirewallRequest - from bigfoot.pytest_plugin import pytest_runtest_call + from tripwire._firewall_request import NetworkFirewallRequest + from tripwire.pytest_plugin import pytest_runtest_call config = {"guard": "error"} @@ -1734,7 +1734,7 @@ def iter_markers(self, name: str): item = FakeItem() - with patch("bigfoot.pytest_plugin.load_bigfoot_config", return_value=config): + with patch("tripwire.pytest_plugin.load_tripwire_config", return_value=config): hook_gen = pytest_runtest_call(item) next(hook_gen) stack = _firewall_stack.get() @@ -1746,11 +1746,11 @@ def iter_markers(self, name: str): pass def test_marker_allow_merged_with_toml_config(self) -> None: - """@pytest.mark.allow merges with [tool.bigfoot.firewall] allow rules.""" + """@pytest.mark.allow merges with [tool.tripwire.firewall] allow rules.""" from unittest.mock import patch - from bigfoot._firewall_request import NetworkFirewallRequest - from bigfoot.pytest_plugin import pytest_runtest_call + from tripwire._firewall_request import NetworkFirewallRequest + from tripwire.pytest_plugin import pytest_runtest_call config = {"guard": "error", "firewall": {"allow": ["socket:*"]}} @@ -1768,7 +1768,7 @@ def iter_markers(self, name: str): item = FakeItem() - with patch("bigfoot.pytest_plugin.load_bigfoot_config", return_value=config): + with patch("tripwire.pytest_plugin.load_tripwire_config", return_value=config): hook_gen = pytest_runtest_call(item) next(hook_gen) stack = _firewall_stack.get() @@ -1785,8 +1785,8 @@ def test_deny_marker_overrides_toml_allow(self) -> None: """@pytest.mark.deny blocks protocols even when TOML allows them.""" from unittest.mock import patch - from bigfoot._firewall_request import NetworkFirewallRequest - from bigfoot.pytest_plugin import pytest_runtest_call + from tripwire._firewall_request import NetworkFirewallRequest + from tripwire.pytest_plugin import pytest_runtest_call config = {"guard": "error", "firewall": {"allow": ["socket:*", "dns:*"]}} @@ -1804,7 +1804,7 @@ def iter_markers(self, name: str): item = FakeItem() - with patch("bigfoot.pytest_plugin.load_bigfoot_config", return_value=config): + with patch("tripwire.pytest_plugin.load_tripwire_config", return_value=config): hook_gen = pytest_runtest_call(item) next(hook_gen) stack = _firewall_stack.get() @@ -1830,8 +1830,8 @@ def test_send_passes_through_in_guard_mode(self) -> None: """socket.send passes through to original in guard mode.""" import socket as socket_mod - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.socket_plugin import _SOCKET_CLOSE_ORIGINAL, SocketPlugin + from tripwire._verifier import StrictVerifier + from tripwire.plugins.socket_plugin import _SOCKET_CLOSE_ORIGINAL, SocketPlugin v = StrictVerifier() sp = SocketPlugin(v) @@ -1855,8 +1855,8 @@ def test_sendall_passes_through_in_guard_mode(self) -> None: """socket.sendall passes through to original in guard mode.""" import socket as socket_mod - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.socket_plugin import _SOCKET_CLOSE_ORIGINAL, SocketPlugin + from tripwire._verifier import StrictVerifier + from tripwire.plugins.socket_plugin import _SOCKET_CLOSE_ORIGINAL, SocketPlugin v = StrictVerifier() sp = SocketPlugin(v) @@ -1880,8 +1880,8 @@ def test_recv_passes_through_in_guard_mode(self) -> None: """socket.recv passes through to original in guard mode.""" import socket as socket_mod - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.socket_plugin import _SOCKET_CLOSE_ORIGINAL, SocketPlugin + from tripwire._verifier import StrictVerifier + from tripwire.plugins.socket_plugin import _SOCKET_CLOSE_ORIGINAL, SocketPlugin v = StrictVerifier() sp = SocketPlugin(v) @@ -1905,8 +1905,8 @@ def test_close_passes_through_in_guard_mode(self) -> None: """socket.close passes through to original in guard mode.""" import socket as socket_mod - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.socket_plugin import SocketPlugin + from tripwire._verifier import StrictVerifier + from tripwire.plugins.socket_plugin import SocketPlugin v = StrictVerifier() sp = SocketPlugin(v) @@ -1925,15 +1925,15 @@ def test_close_passes_through_in_guard_mode(self) -> None: def test_close_no_guarded_call_error_even_with_deny(self) -> None: """socket.close does not raise GuardedCallError even when socket is denied. - Non-connect operations bypass bigfoot entirely in guard mode, + Non-connect operations bypass tripwire entirely in guard mode, regardless of firewall rules. """ import socket as socket_mod - from bigfoot._context import _guard_level - from bigfoot._guard import deny - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.socket_plugin import SocketPlugin + from tripwire._context import _guard_level + from tripwire._guard import deny + from tripwire._verifier import StrictVerifier + from tripwire.plugins.socket_plugin import SocketPlugin v = StrictVerifier() sp = SocketPlugin(v) @@ -1955,16 +1955,16 @@ def test_close_no_guarded_call_error_even_with_deny(self) -> None: def test_send_no_guarded_call_error_even_with_deny(self) -> None: """socket.send does not raise GuardedCallError even when socket is denied. - Non-connect operations bypass bigfoot entirely in guard mode, + Non-connect operations bypass tripwire entirely in guard mode, regardless of firewall rules. The real send raises OSError because the socket is not connected. """ import socket as socket_mod - from bigfoot._context import _guard_level - from bigfoot._guard import deny - from bigfoot._verifier import StrictVerifier - from bigfoot.plugins.socket_plugin import _SOCKET_CLOSE_ORIGINAL, SocketPlugin + from tripwire._context import _guard_level + from tripwire._guard import deny + from tripwire._verifier import StrictVerifier + from tripwire.plugins.socket_plugin import _SOCKET_CLOSE_ORIGINAL, SocketPlugin v = StrictVerifier() sp = SocketPlugin(v) diff --git a/tests/unit/test_guard_new.py b/tests/unit/test_guard_new.py index 2605294..33e4ffb 100644 --- a/tests/unit/test_guard_new.py +++ b/tests/unit/test_guard_new.py @@ -1,9 +1,9 @@ -"""Tests for bigfoot._guard -- new allow/deny/restrict context managers.""" +"""Tests for tripwire._guard -- new allow/deny/restrict context managers.""" -from bigfoot._firewall import Disposition, get_firewall_stack -from bigfoot._firewall_request import HttpFirewallRequest, RedisFirewallRequest -from bigfoot._guard import allow, deny, restrict -from bigfoot._match import M +from tripwire._firewall import Disposition, get_firewall_stack +from tripwire._firewall_request import HttpFirewallRequest, RedisFirewallRequest +from tripwire._guard import allow, deny, restrict +from tripwire._match import M class TestAllow: diff --git a/tests/unit/test_http_plugin.py b/tests/unit/test_http_plugin.py index 10d48e7..06f2482 100644 --- a/tests/unit/test_http_plugin.py +++ b/tests/unit/test_http_plugin.py @@ -1,4 +1,4 @@ -"""Unit tests for bigfoot HttpPlugin. +"""Unit tests for tripwire HttpPlugin. Tests use unittest.mock.patch to avoid real network calls. httpx and requests are optional extras -- skip all tests if not installed. @@ -8,18 +8,18 @@ import pytest -import bigfoot +import tripwire httpx = pytest.importorskip("httpx") requests = pytest.importorskip("requests") import requests.adapters # noqa: E402 -- importorskip guarantees requests is available -from bigfoot._base_plugin import BasePlugin -from bigfoot._context import _active_verifier -from bigfoot._errors import ConflictError, SandboxNotActiveError, UnmockedInteractionError -from bigfoot._timeline import Interaction -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.http import ( +from tripwire._base_plugin import BasePlugin +from tripwire._context import _active_verifier +from tripwire._errors import ConflictError, SandboxNotActiveError, UnmockedInteractionError +from tripwire._timeline import Interaction +from tripwire._verifier import StrictVerifier +from tripwire.plugins.http import ( _HTTPX_ORIGINAL_ASYNC_HANDLE, _HTTPX_ORIGINAL_HANDLE, _REQUESTS_ORIGINAL_SEND, @@ -160,7 +160,7 @@ def test_deactivate_decrements_install_count() -> None: # CLAIM: deactivate() on last reference restores original transport methods. # PATH: deactivate() -> _install_count == 0 -> _restore_patches(). # CHECK: handle_request is _HTTPX_ORIGINAL_HANDLE after full deactivate. -# MUTATION: Skipping _restore_patches() leaves bigfoot patch in place. +# MUTATION: Skipping _restore_patches() leaves tripwire patch in place. # ESCAPE: Nothing reasonable -- identity comparison against import-time constant. def test_deactivate_restores_patches_on_last_call() -> None: v, p = _make_verifier_with_plugin() @@ -226,7 +226,7 @@ def test_nested_deactivate_only_uninstalls_on_last() -> None: # ESCAPE: test_check_conflicts_raises_when_httpx_sync_patched_by_foreign # CLAIM: _check_conflicts raises ConflictError if httpx.HTTPTransport.handle_request -# is neither the import-time original nor our bigfoot patch. +# is neither the import-time original nor our tripwire patch. # PATH: _check_conflicts() -> identity check -> ConflictError. # CHECK: ConflictError raised with target naming httpx sync handle. # MUTATION: Skipping the sync handle check lets the conflict through silently. @@ -311,7 +311,7 @@ def test_check_conflicts_does_not_raise_when_no_foreign_patch() -> None: # MUTATION: Calling real network instead of raising lets calls through silently. # ESCAPE: Nothing reasonable -- exact exception type. def test_httpx_interceptor_raises_sandbox_not_active_when_no_sandbox() -> None: - from bigfoot._context import _guard_active, _guard_patches_installed + from tripwire._context import _guard_active, _guard_patches_installed v, p = _make_verifier_with_plugin() p.activate() @@ -336,7 +336,7 @@ def test_httpx_interceptor_raises_sandbox_not_active_when_no_sandbox() -> None: # MUTATION: Letting request proceed to real network skips the error entirely. # ESCAPE: Nothing reasonable -- exact exception type. def test_requests_interceptor_raises_sandbox_not_active_when_no_sandbox() -> None: - from bigfoot._context import _guard_active, _guard_patches_installed + from tripwire._context import _guard_active, _guard_patches_installed v, p = _make_verifier_with_plugin() p.activate() @@ -379,7 +379,7 @@ def test_httpx_interceptor_raises_unmocked_when_no_config() -> None: # ESCAPE: Nothing reasonable -- type check plus attribute check. def test_requests_interceptor_raises_unmocked_when_no_config() -> None: v, p = _make_verifier_with_plugin() - with bigfoot.allow("dns"), v.sandbox(): + with tripwire.allow("dns"), v.sandbox(): with pytest.raises(UnmockedInteractionError) as exc_info: requests.get("https://api.example.com/no-mock") assert exc_info.value.source_id == "http:request" @@ -439,7 +439,7 @@ def test_requests_configured_response_returned() -> None: v, p = _make_verifier_with_plugin() p.mock_response("GET", "https://api.example.com/items", json={"items": [1, 2, 3]}) - with bigfoot.allow("dns"), v.sandbox(): + with tripwire.allow("dns"), v.sandbox(): response = requests.get("https://api.example.com/items") assert response.status_code == 200 @@ -456,7 +456,7 @@ def test_requests_configured_response_custom_status() -> None: v, p = _make_verifier_with_plugin() p.mock_response("GET", "https://api.example.com/missing", status=404) - with bigfoot.allow("dns"), v.sandbox(): + with tripwire.allow("dns"), v.sandbox(): response = requests.get("https://api.example.com/missing") assert response.status_code == 404 @@ -498,7 +498,7 @@ def test_interaction_recorded_after_requests_request() -> None: v, p = _make_verifier_with_plugin() p.mock_response("POST", "https://api.example.com/submit", json={"ok": True}) - with bigfoot.allow("dns"), v.sandbox(): + with tripwire.allow("dns"), v.sandbox(): requests.post("https://api.example.com/submit", json={"data": 1}) interactions = v._timeline.all_unasserted() @@ -789,7 +789,7 @@ def test_format_unused_mock_hint_includes_registration_traceback() -> None: def test_identify_patcher_recognises_respx() -> None: - from bigfoot.plugins.http import _identify_patcher + from tripwire.plugins.http import _identify_patcher method = MagicMock() method.__module__ = "respx.mock" @@ -798,7 +798,7 @@ def test_identify_patcher_recognises_respx() -> None: def test_identify_patcher_recognises_responses() -> None: - from bigfoot.plugins.http import _identify_patcher + from tripwire.plugins.http import _identify_patcher method = MagicMock() method.__module__ = "responses" @@ -807,7 +807,7 @@ def test_identify_patcher_recognises_responses() -> None: def test_identify_patcher_recognises_httpretty() -> None: - from bigfoot.plugins.http import _identify_patcher + from tripwire.plugins.http import _identify_patcher method = MagicMock() method.__module__ = "httpretty.core" @@ -816,7 +816,7 @@ def test_identify_patcher_recognises_httpretty() -> None: def test_identify_patcher_returns_unknown_for_unrecognised() -> None: - from bigfoot.plugins.http import _identify_patcher + from tripwire.plugins.http import _identify_patcher method = MagicMock() method.__module__ = "some.other.lib" @@ -832,11 +832,11 @@ def test_identify_patcher_returns_unknown_for_unrecognised() -> None: def test_find_http_plugin_raises_when_no_http_plugin_registered( monkeypatch: pytest.MonkeyPatch, ) -> None: - from bigfoot.plugins.http import _find_http_plugin + from tripwire.plugins.http import _find_http_plugin # Disable all plugins so HttpPlugin is not auto-instantiated monkeypatch.setattr( - "bigfoot._verifier.load_bigfoot_config", + "tripwire._verifier.load_tripwire_config", lambda: {"enabled_plugins": ["subprocess"]}, ) v = StrictVerifier() @@ -961,7 +961,7 @@ def test_requests_interceptor_records_str_body() -> None: v, p = _make_verifier_with_plugin() p.mock_response("POST", "https://api.example.com/str-body", json={"ok": True}) - with bigfoot.allow("dns"), v.sandbox(): + with tripwire.allow("dns"), v.sandbox(): # Sending a string body directly via prepared request req = requests.Request("POST", "https://api.example.com/str-body", data="raw string") prepared = req.prepare() @@ -1018,7 +1018,7 @@ def test_format_mock_hint_returns_correct_snippet() -> None: def test_url_matches_returns_false_when_param_value_missing() -> None: """_url_matches returns False when a required param value is absent from the actual URL.""" - from bigfoot.plugins.http import HttpMockConfig + from tripwire.plugins.http import HttpMockConfig v, p = _make_verifier_with_plugin() @@ -1036,7 +1036,7 @@ def test_url_matches_returns_false_when_param_value_missing() -> None: def test_url_matches_returns_false_when_param_key_absent() -> None: """_url_matches returns False when a required param key is entirely absent.""" - from bigfoot.plugins.http import HttpMockConfig + from tripwire.plugins.http import HttpMockConfig v, p = _make_verifier_with_plugin() @@ -1054,7 +1054,7 @@ def test_url_matches_returns_false_when_param_key_absent() -> None: def test_url_matches_returns_true_with_empty_params_dict() -> None: """_url_matches returns True when params is an empty dict (no constraints).""" - from bigfoot.plugins.http import HttpMockConfig + from tripwire.plugins.http import HttpMockConfig v, p = _make_verifier_with_plugin() @@ -1071,7 +1071,7 @@ def test_url_matches_returns_true_with_empty_params_dict() -> None: def test_url_matches_returns_false_when_val_not_in_actual_param_values() -> None: """_url_matches returns False when the param key is present but value doesn't match.""" - from bigfoot.plugins.http import HttpMockConfig + from tripwire.plugins.http import HttpMockConfig v, p = _make_verifier_with_plugin() @@ -1089,7 +1089,7 @@ def test_url_matches_returns_false_when_val_not_in_actual_param_values() -> None def test_url_matches_returns_false_when_scheme_differs() -> None: """_url_matches returns False immediately when schemes differ (short-circuit).""" - from bigfoot.plugins.http import HttpMockConfig + from tripwire.plugins.http import HttpMockConfig v, p = _make_verifier_with_plugin() @@ -1552,7 +1552,7 @@ def test_assert_request_global_require_response_true() -> None: # MUTATION: Not calling assert_interaction() terminally would let wrong calls pass. # ESCAPE: Nothing reasonable -- exact exception type check. def test_assert_request_terminal_missing_field_raises() -> None: - from bigfoot._errors import InteractionMismatchError + from tripwire._errors import InteractionMismatchError v, p = _make_verifier_with_plugin() p.mock_response("GET", "https://api.example.com/mismatch", json={"x": 1}) @@ -1913,7 +1913,7 @@ async def test_aiohttp_response_as_context_manager() -> None: def test_http_error_config_exists_and_has_expected_fields() -> None: """HttpErrorConfig dataclass has method, url, params, raises, required fields.""" - from bigfoot.plugins.http import HttpErrorConfig + from tripwire.plugins.http import HttpErrorConfig exc = ConnectionError("refused") config = HttpErrorConfig( @@ -1934,7 +1934,7 @@ def test_http_error_config_exists_and_has_expected_fields() -> None: def test_find_matching_config_returns_http_error_config() -> None: """_find_matching_config returns HttpErrorConfig when an error mock matches.""" - from bigfoot.plugins.http import HttpErrorConfig + from tripwire.plugins.http import HttpErrorConfig v, p = _make_verifier_with_plugin() exc = ConnectionError("refused") @@ -1958,7 +1958,7 @@ def test_find_matching_config_returns_http_error_config() -> None: def test_mock_error_appends_to_unified_queue() -> None: """mock_error() appends an HttpErrorConfig to the unified _mock_queue.""" - from bigfoot.plugins.http import HttpErrorConfig + from tripwire.plugins.http import HttpErrorConfig v, p = _make_verifier_with_plugin() exc = ConnectionError("refused") @@ -2044,7 +2044,7 @@ def test_requests_handler_raises_error_config() -> None: exc = requests.ConnectionError("DNS resolution failed") p.mock_error("GET", "https://api.example.com/data", raises=exc) - with bigfoot.allow("dns"), v.sandbox(): + with tripwire.allow("dns"), v.sandbox(): with pytest.raises(requests.ConnectionError, match="DNS resolution failed"): requests.get("https://api.example.com/data") @@ -2236,7 +2236,7 @@ def test_assert_request_with_raised_returns_none() -> None: def test_assert_request_missing_raised_triggers_missing_fields_error() -> None: """Omitting raised= on an error interaction triggers MissingAssertionFieldsError.""" - from bigfoot._errors import MissingAssertionFieldsError + from tripwire._errors import MissingAssertionFieldsError v, p = _make_verifier_with_plugin() exc = httpx.ConnectError("Connection refused") @@ -2355,7 +2355,7 @@ def test_format_unmocked_hint_suggests_both_options() -> None: def test_get_unused_mocks_returns_error_configs() -> None: """get_unused_mocks returns HttpErrorConfig entries from the unified queue.""" - from bigfoot.plugins.http import HttpErrorConfig + from tripwire.plugins.http import HttpErrorConfig v, p = _make_verifier_with_plugin() p.mock_error("GET", "https://api.example.com/data", raises=ConnectionError("x")) @@ -2397,7 +2397,7 @@ def test_format_unused_mock_hint_for_error_config() -> None: def test_unused_error_mock_raises_at_verify_all() -> None: """An unused required error mock causes verify_all() to raise.""" - from bigfoot._errors import UnusedMocksError, VerificationError + from tripwire._errors import UnusedMocksError, VerificationError v, p = _make_verifier_with_plugin() p.mock_error("GET", "https://api.example.com/data", raises=ConnectionError("x")) diff --git a/tests/unit/test_import_site_mock.py b/tests/unit/test_import_site_mock.py index d915e7e..9749e2c 100644 --- a/tests/unit/test_import_site_mock.py +++ b/tests/unit/test_import_site_mock.py @@ -5,9 +5,9 @@ import pytest -from bigfoot._errors import ConflictError -from bigfoot._mock_plugin import ImportSiteMock, MockPlugin, ObjectMock -from bigfoot._verifier import StrictVerifier +from tripwire._errors import ConflictError +from tripwire._mock_plugin import ImportSiteMock, MockPlugin, ObjectMock +from tripwire._verifier import StrictVerifier # --- Test fixtures --- @@ -64,7 +64,7 @@ def test_import_site_mock_display_name() -> None: def test_import_site_mock_getattr_returns_method_proxy() -> None: - from bigfoot._mock_plugin import MethodProxy + from tripwire._mock_plugin import MethodProxy v = StrictVerifier() plugin = MockPlugin(v) mock = ImportSiteMock(path="os.path:join", plugin=plugin) @@ -111,11 +111,11 @@ def test_object_mock_display_name() -> None: # --- Activation / Deactivation tests --- -def test_import_site_mock_activate_patches_target(bigfoot_verifier: StrictVerifier) -> None: +def test_import_site_mock_activate_patches_target(tripwire_verifier: StrictVerifier) -> None: """Activating an ImportSiteMock replaces the target via setattr.""" mod = _create_fake_module("_test_mod_activate", process=lambda x: x * 2) try: - plugin = MockPlugin(bigfoot_verifier) + plugin = MockPlugin(tripwire_verifier) mock = ImportSiteMock(path="_test_mod_activate:process", plugin=plugin) mock.returns(42) original = mod.process @@ -130,10 +130,10 @@ def test_import_site_mock_activate_patches_target(bigfoot_verifier: StrictVerifi del sys.modules["_test_mod_activate"] -def test_object_mock_activate_patches_target(bigfoot_verifier: StrictVerifier) -> None: +def test_object_mock_activate_patches_target(tripwire_verifier: StrictVerifier) -> None: """Activating an ObjectMock replaces the attr via setattr.""" target = _FakeService() - plugin = MockPlugin(bigfoot_verifier) + plugin = MockPlugin(tripwire_verifier) mock = ObjectMock(target=target, attr="process", plugin=plugin) mock.returns(42) original = target.process @@ -146,11 +146,11 @@ def test_object_mock_activate_patches_target(bigfoot_verifier: StrictVerifier) - _drain_unused_mocks(plugin) -def test_mock_deactivate_restores_original(bigfoot_verifier: StrictVerifier) -> None: +def test_mock_deactivate_restores_original(tripwire_verifier: StrictVerifier) -> None: """Deactivation restores the original attribute value.""" mod = _create_fake_module("_test_mod_restore", value="original") try: - plugin = MockPlugin(bigfoot_verifier) + plugin = MockPlugin(tripwire_verifier) mock = ImportSiteMock(path="_test_mod_restore:value", plugin=plugin) mock.returns("mocked") @@ -166,11 +166,11 @@ def test_mock_deactivate_restores_original(bigfoot_verifier: StrictVerifier) -> # --- Context manager tests --- -def test_mock_context_manager_sets_enforce_false(bigfoot_verifier: StrictVerifier) -> None: +def test_mock_context_manager_sets_enforce_false(tripwire_verifier: StrictVerifier) -> None: """Individual context manager (with mock:) sets enforce=False.""" mod = _create_fake_module("_test_mod_cm", fn=lambda: "real") try: - plugin = MockPlugin(bigfoot_verifier) + plugin = MockPlugin(tripwire_verifier) mock = ImportSiteMock(path="_test_mod_cm:fn", plugin=plugin) mock.returns("mocked") @@ -209,11 +209,11 @@ def test_base_mock_calls_shortcut() -> None: # --- Conflict detection tests --- -def test_conflict_detection_same_target(bigfoot_verifier: StrictVerifier) -> None: +def test_conflict_detection_same_target(tripwire_verifier: StrictVerifier) -> None: """Two mocks on the same resolved target raise ConflictError.""" mod = _create_fake_module("_test_mod_conflict", fn=lambda: "real") try: - plugin = MockPlugin(bigfoot_verifier) + plugin = MockPlugin(tripwire_verifier) m1 = ImportSiteMock(path="_test_mod_conflict:fn", plugin=plugin) m1.returns("one") m2 = ImportSiteMock(path="_test_mod_conflict:fn", plugin=plugin) diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index 6255cd7..c0a97dc 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -1,5 +1,5 @@ # tests/unit/test_init.py -"""Unit tests for bigfoot.__init__ public API. +"""Unit tests for tripwire.__init__ public API. Verifies that all public names are importable directly from the top-level package and that __all__ contains exactly the expected names. @@ -12,7 +12,7 @@ def test_all_contains_expected_names() -> None: """__all__ must be exactly the declared public API set.""" # ESCAPE: if __all__ contained extras or was missing entries callers get # wrong autocomplete / wildcard-import results - import bigfoot + import tripwire expected_all = { # Plugin authoring API @@ -60,8 +60,8 @@ def test_all_contains_expected_names() -> None: "M", # Errors "AllWildcardAssertionError", - "BigfootConfigError", - "BigfootError", + "TripwireConfigError", + "TripwireError", "AssertionInsideSandboxError", "AutoAssertError", "InvalidStateError", @@ -116,13 +116,13 @@ def test_all_contains_expected_names() -> None: "NativePlugin", "native_mock", } - assert set(bigfoot.__all__) == expected_all + assert set(tripwire.__all__) == expected_all def test_assertion_inside_sandbox_error_importable() -> None: """AssertionInsideSandboxError must be importable from the top-level package.""" - from bigfoot import AssertionInsideSandboxError - from bigfoot._errors import AssertionInsideSandboxError as _AssertionInsideSandboxError + from tripwire import AssertionInsideSandboxError + from tripwire._errors import AssertionInsideSandboxError as _AssertionInsideSandboxError assert AssertionInsideSandboxError is _AssertionInsideSandboxError @@ -130,112 +130,112 @@ def test_assertion_inside_sandbox_error_importable() -> None: def test_strict_verifier_importable() -> None: """StrictVerifier must be importable from the top-level package.""" # ESCAPE: if the import was missing or aliased wrongly instantiation would fail - from bigfoot import StrictVerifier - from bigfoot._verifier import StrictVerifier as _StrictVerifier + from tripwire import StrictVerifier + from tripwire._verifier import StrictVerifier as _StrictVerifier assert StrictVerifier is _StrictVerifier def test_sandbox_context_importable() -> None: """SandboxContext must be importable from the top-level package.""" - from bigfoot import SandboxContext - from bigfoot._verifier import SandboxContext as _SandboxContext + from tripwire import SandboxContext + from tripwire._verifier import SandboxContext as _SandboxContext assert SandboxContext is _SandboxContext def test_in_any_order_context_importable() -> None: """InAnyOrderContext must be importable from the top-level package.""" - from bigfoot import InAnyOrderContext - from bigfoot._verifier import InAnyOrderContext as _InAnyOrderContext + from tripwire import InAnyOrderContext + from tripwire._verifier import InAnyOrderContext as _InAnyOrderContext assert InAnyOrderContext is _InAnyOrderContext def test_mock_plugin_importable() -> None: """MockPlugin must be importable from the top-level package.""" - from bigfoot import MockPlugin - from bigfoot._mock_plugin import MockPlugin as _MockPlugin + from tripwire import MockPlugin + from tripwire._mock_plugin import MockPlugin as _MockPlugin assert MockPlugin is _MockPlugin -def test_bigfoot_error_importable() -> None: - """BigfootError must be importable from the top-level package.""" - from bigfoot import BigfootError - from bigfoot._errors import BigfootError as _BigfootError +def test_tripwire_error_importable() -> None: + """TripwireError must be importable from the top-level package.""" + from tripwire import TripwireError + from tripwire._errors import TripwireError as _TripwireError - assert BigfootError is _BigfootError + assert TripwireError is _TripwireError def test_unmocked_interaction_error_importable() -> None: """UnmockedInteractionError must be importable from the top-level package.""" - from bigfoot import UnmockedInteractionError - from bigfoot._errors import UnmockedInteractionError as _UnmockedInteractionError + from tripwire import UnmockedInteractionError + from tripwire._errors import UnmockedInteractionError as _UnmockedInteractionError assert UnmockedInteractionError is _UnmockedInteractionError def test_unasserted_interactions_error_importable() -> None: """UnassertedInteractionsError must be importable from the top-level package.""" - from bigfoot import UnassertedInteractionsError - from bigfoot._errors import UnassertedInteractionsError as _UnassertedInteractionsError + from tripwire import UnassertedInteractionsError + from tripwire._errors import UnassertedInteractionsError as _UnassertedInteractionsError assert UnassertedInteractionsError is _UnassertedInteractionsError def test_unused_mocks_error_importable() -> None: """UnusedMocksError must be importable from the top-level package.""" - from bigfoot import UnusedMocksError - from bigfoot._errors import UnusedMocksError as _UnusedMocksError + from tripwire import UnusedMocksError + from tripwire._errors import UnusedMocksError as _UnusedMocksError assert UnusedMocksError is _UnusedMocksError def test_verification_error_importable() -> None: """VerificationError must be importable from the top-level package.""" - from bigfoot import VerificationError - from bigfoot._errors import VerificationError as _VerificationError + from tripwire import VerificationError + from tripwire._errors import VerificationError as _VerificationError assert VerificationError is _VerificationError def test_interaction_mismatch_error_importable() -> None: """InteractionMismatchError must be importable from the top-level package.""" - from bigfoot import InteractionMismatchError - from bigfoot._errors import InteractionMismatchError as _InteractionMismatchError + from tripwire import InteractionMismatchError + from tripwire._errors import InteractionMismatchError as _InteractionMismatchError assert InteractionMismatchError is _InteractionMismatchError def test_sandbox_not_active_error_importable() -> None: """SandboxNotActiveError must be importable from the top-level package.""" - from bigfoot import SandboxNotActiveError - from bigfoot._errors import SandboxNotActiveError as _SandboxNotActiveError + from tripwire import SandboxNotActiveError + from tripwire._errors import SandboxNotActiveError as _SandboxNotActiveError assert SandboxNotActiveError is _SandboxNotActiveError def test_conflict_error_importable() -> None: """ConflictError must be importable from the top-level package.""" - from bigfoot import ConflictError - from bigfoot._errors import ConflictError as _ConflictError + from tripwire import ConflictError + from tripwire._errors import ConflictError as _ConflictError assert ConflictError is _ConflictError def test_missing_assertion_fields_error_importable() -> None: """MissingAssertionFieldsError must be importable from the top-level package.""" - from bigfoot import MissingAssertionFieldsError - from bigfoot._errors import MissingAssertionFieldsError as _MissingAssertionFieldsError + from tripwire import MissingAssertionFieldsError + from tripwire._errors import MissingAssertionFieldsError as _MissingAssertionFieldsError assert MissingAssertionFieldsError is _MissingAssertionFieldsError def test_http_plugin_importable_if_http_extra_installed() -> None: - """HttpPlugin must be importable from bigfoot if [http] extra is installed.""" + """HttpPlugin must be importable from tripwire if [http] extra is installed.""" # ESCAPE: if HttpPlugin import was missing from __init__ when http extra is - # installed, users would have to import from bigfoot.plugins.http directly + # installed, users would have to import from tripwire.plugins.http directly try: import httpx # noqa: F401 import requests # noqa: F401 @@ -247,131 +247,131 @@ def test_http_plugin_importable_if_http_extra_installed() -> None: if not http_available: pytest.skip("http extra not installed") - import bigfoot + import tripwire - assert hasattr(bigfoot, "HttpPlugin") + assert hasattr(tripwire, "HttpPlugin") - from bigfoot import HttpPlugin - from bigfoot.plugins.http import HttpPlugin as _HttpPlugin + from tripwire import HttpPlugin + from tripwire.plugins.http import HttpPlugin as _HttpPlugin assert HttpPlugin is _HttpPlugin def test_no_active_verifier_error_importable() -> None: """NoActiveVerifierError must be importable from the top-level package.""" - from bigfoot import NoActiveVerifierError - from bigfoot._errors import NoActiveVerifierError as _NoActiveVerifierError + from tripwire import NoActiveVerifierError + from tripwire._errors import NoActiveVerifierError as _NoActiveVerifierError assert NoActiveVerifierError is _NoActiveVerifierError def test_module_level_mock_importable() -> None: - """bigfoot.mock must be importable as a callable.""" - import bigfoot + """tripwire.mock must be importable as a callable.""" + import tripwire - assert callable(bigfoot.mock) + assert callable(tripwire.mock) def test_module_level_sandbox_importable() -> None: - """bigfoot.sandbox must be importable as a callable.""" - import bigfoot + """tripwire.sandbox must be importable as a callable.""" + import tripwire - assert callable(bigfoot.sandbox) + assert callable(tripwire.sandbox) def test_module_level_assert_interaction_importable() -> None: - """bigfoot.assert_interaction must be importable as a callable.""" - import bigfoot + """tripwire.assert_interaction must be importable as a callable.""" + import tripwire - assert callable(bigfoot.assert_interaction) + assert callable(tripwire.assert_interaction) def test_module_level_in_any_order_importable() -> None: - """bigfoot.in_any_order must be importable as a callable.""" - import bigfoot + """tripwire.in_any_order must be importable as a callable.""" + import tripwire - assert callable(bigfoot.in_any_order) + assert callable(tripwire.in_any_order) def test_module_level_verify_all_importable() -> None: - """bigfoot.verify_all must be importable as a callable.""" - import bigfoot + """tripwire.verify_all must be importable as a callable.""" + import tripwire - assert callable(bigfoot.verify_all) + assert callable(tripwire.verify_all) def test_module_level_current_verifier_importable() -> None: - """bigfoot.current_verifier must be importable as a callable.""" - import bigfoot + """tripwire.current_verifier must be importable as a callable.""" + import tripwire - assert callable(bigfoot.current_verifier) + assert callable(tripwire.current_verifier) def test_module_level_spy_importable() -> None: - """bigfoot.spy must be importable as a callable.""" - import bigfoot + """tripwire.spy must be importable as a callable.""" + import tripwire - assert callable(bigfoot.spy) + assert callable(tripwire.spy) def test_module_level_http_importable() -> None: - """bigfoot.http must be importable as an object.""" - import bigfoot + """tripwire.http must be importable as an object.""" + import tripwire - assert bigfoot.http is not None + assert tripwire.http is not None def test_module_level_mock_raises_no_active_verifier_error_outside_test() -> None: - """bigfoot.mock() raises NoActiveVerifierError when called outside a test context.""" - import bigfoot - from bigfoot._context import _current_test_verifier - from bigfoot._errors import NoActiveVerifierError + """tripwire.mock() raises NoActiveVerifierError when called outside a test context.""" + import tripwire + from tripwire._context import _current_test_verifier + from tripwire._errors import NoActiveVerifierError # Temporarily clear the test verifier to simulate being outside a test token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - bigfoot.mock("os.path:sep") + tripwire.mock("os.path:sep") finally: _current_test_verifier.reset(token) -def test_spy_importable_from_bigfoot() -> None: - """bigfoot.spy is importable and is callable.""" - import bigfoot +def test_spy_importable_from_tripwire() -> None: + """tripwire.spy is importable and is callable.""" + import tripwire - assert callable(bigfoot.spy) + assert callable(tripwire.spy) -def test_missing_assertion_fields_error_importable_from_bigfoot() -> None: - """MissingAssertionFieldsError is importable from the bigfoot namespace.""" - import bigfoot - from bigfoot import MissingAssertionFieldsError +def test_missing_assertion_fields_error_importable_from_tripwire() -> None: + """MissingAssertionFieldsError is importable from the tripwire namespace.""" + import tripwire + from tripwire import MissingAssertionFieldsError - assert issubclass(MissingAssertionFieldsError, bigfoot.BigfootError) + assert issubclass(MissingAssertionFieldsError, tripwire.TripwireError) def test_spy_in_all() -> None: - """'spy' is listed in bigfoot.__all__.""" - import bigfoot + """'spy' is listed in tripwire.__all__.""" + import tripwire - assert "spy" in bigfoot.__all__ + assert "spy" in tripwire.__all__ def test_missing_assertion_fields_error_in_all() -> None: - """'MissingAssertionFieldsError' is listed in bigfoot.__all__.""" - import bigfoot + """'MissingAssertionFieldsError' is listed in tripwire.__all__.""" + import tripwire - assert "MissingAssertionFieldsError" in bigfoot.__all__ + assert "MissingAssertionFieldsError" in tripwire.__all__ def test_mock_accepts_path_parameter() -> None: - """bigfoot.mock() accepts a path positional argument (new import-site API).""" + """tripwire.mock() accepts a path positional argument (new import-site API).""" import inspect - import bigfoot + import tripwire - sig = inspect.signature(bigfoot.mock) + sig = inspect.signature(tripwire.mock) assert "path" in sig.parameters @@ -382,51 +382,51 @@ def test_async_websocket_mock_raises_import_error_when_websockets_unavailable( ESCAPE: async_websocket_mock CLAIM: Accessing any attribute on async_websocket_mock raises ImportError with - instructions when bigfoot.plugins.websocket_plugin._WEBSOCKETS_AVAILABLE is False. + instructions when tripwire.plugins.websocket_plugin._WEBSOCKETS_AVAILABLE is False. PATH: _AsyncWebSocketProxy.__getattr__ -> checks _WEBSOCKETS_AVAILABLE -> raises ImportError. - CHECK: Raises ImportError with message containing "bigfoot[websockets]" and "pip install". + CHECK: Raises ImportError with message containing "tripwire[websockets]" and "pip install". MUTATION: If __getattr__ does not check _WEBSOCKETS_AVAILABLE, the error is deferred to activate() time (inside a test context), and the message will be different or absent. ESCAPE: A proxy that checks availability but emits a wrong message would still pass the attribute access but fail only the message assertion -- caught by exact string check. """ - import bigfoot - import bigfoot.plugins.websocket_plugin as ws_mod + import tripwire + import tripwire.plugins.websocket_plugin as ws_mod monkeypatch.setattr(ws_mod, "_WEBSOCKETS_AVAILABLE", False) with pytest.raises(ImportError) as exc_info: - _ = bigfoot.async_websocket_mock.new_session # noqa: B018 + _ = tripwire.async_websocket_mock.new_session # noqa: B018 - assert "bigfoot[websockets]" in str(exc_info.value) + assert "tripwire[websockets]" in str(exc_info.value) assert "pip install" in str(exc_info.value) -def test_bigfoot_module_is_context_manager(bigfoot_verifier: object) -> None: - """``with bigfoot:`` activates a sandbox and returns the active StrictVerifier. +def test_tripwire_module_is_context_manager(tripwire_verifier: object) -> None: + """``with tripwire:`` activates a sandbox and returns the active StrictVerifier. ESCAPE: module context manager - CLAIM: Entering ``with bigfoot:`` calls sandbox().__enter__() on the current + CLAIM: Entering ``with tripwire:`` calls sandbox().__enter__() on the current verifier and returns the StrictVerifier instance. - PATH: _BigfootModule.__enter__ -> sandbox() -> SandboxContext.__enter__ -> returns verifier. + PATH: _TripwireModule.__enter__ -> sandbox() -> SandboxContext.__enter__ -> returns verifier. CHECK: The ``as`` target is the StrictVerifier; calling mock/assert inside works normally. - MUTATION: If __class__ swap is missing, ``with bigfoot:`` raises AttributeError. + MUTATION: If __class__ swap is missing, ``with tripwire:`` raises AttributeError. ESCAPE: A stub that returns *something* but not the verifier would fail the isinstance check. """ import sys import types - import bigfoot - from bigfoot import StrictVerifier + import tripwire + from tripwire import StrictVerifier mod = types.ModuleType("_test_init_cm") mod.do = lambda: "real" # type: ignore[attr-defined] sys.modules["_test_init_cm"] = mod try: - mock = bigfoot.mock("_test_init_cm:do") + mock = tripwire.mock("_test_init_cm:do") mock.returns(42) - with bigfoot as v: + with tripwire as v: assert isinstance(v, StrictVerifier) result = mod.do() assert result == 42 @@ -436,30 +436,30 @@ def test_bigfoot_module_is_context_manager(bigfoot_verifier: object) -> None: del sys.modules["_test_init_cm"] -async def test_bigfoot_module_is_async_context_manager(bigfoot_verifier: object) -> None: - """``async with bigfoot:`` activates a sandbox and returns the StrictVerifier. +async def test_tripwire_module_is_async_context_manager(tripwire_verifier: object) -> None: + """``async with tripwire:`` activates a sandbox and returns the StrictVerifier. ESCAPE: async module context manager - CLAIM: Entering ``async with bigfoot:`` delegates to sandbox().__aenter__() and + CLAIM: Entering ``async with tripwire:`` delegates to sandbox().__aenter__() and returns the StrictVerifier. - PATH: _BigfootModule.__aenter__ -> sandbox() -> SandboxContext.__aenter__ -> returns verifier. + PATH: _TripwireModule.__aenter__ -> sandbox() -> SandboxContext.__aenter__ -> returns verifier. CHECK: The ``as`` target is the StrictVerifier; async code inside the block is intercepted. MUTATION: Missing __aenter__ raises AttributeError; wrong return raises AssertionError. """ import sys import types - import bigfoot - from bigfoot import StrictVerifier + import tripwire + from tripwire import StrictVerifier mod = types.ModuleType("_test_init_async_cm") mod.fetch = lambda: "real" # type: ignore[attr-defined] sys.modules["_test_init_async_cm"] = mod try: - mock = bigfoot.mock("_test_init_async_cm:fetch") + mock = tripwire.mock("_test_init_async_cm:fetch") mock.returns({"ok": True}) - async with bigfoot as v: + async with tripwire as v: assert isinstance(v, StrictVerifier) result = mod.fetch() assert result == {"ok": True} @@ -469,21 +469,21 @@ async def test_bigfoot_module_is_async_context_manager(bigfoot_verifier: object) del sys.modules["_test_init_async_cm"] -def test_bigfoot_nested_sandboxes_via_with_bigfoot(bigfoot_verifier: object) -> None: - """Nested ``with bigfoot:`` blocks use reference counting and do not conflict. +def test_tripwire_nested_sandboxes_via_with_tripwire(tripwire_verifier: object) -> None: + """Nested ``with tripwire:`` blocks use reference counting and do not conflict. ESCAPE: nested sandboxes - CLAIM: Entering ``with bigfoot:`` twice nests correctly; the inner exit does not + CLAIM: Entering ``with tripwire:`` twice nests correctly; the inner exit does not deactivate plugins prematurely. - PATH: _BigfootModule.__enter__ pushes to stack twice; __exit__ pops in LIFO order. + PATH: _TripwireModule.__enter__ pushes to stack twice; __exit__ pops in LIFO order. CHECK: Both ``as`` values are the same StrictVerifier; no errors on exit. MUTATION: A non-stacking implementation would push the same cm twice and break LIFO order. """ - import bigfoot - from bigfoot import StrictVerifier + import tripwire + from tripwire import StrictVerifier - with bigfoot as v1: - with bigfoot as v2: + with tripwire as v1: + with tripwire as v2: assert isinstance(v1, StrictVerifier) assert isinstance(v2, StrictVerifier) assert v1 is v2 @@ -496,21 +496,21 @@ def test_sync_websocket_mock_raises_import_error_when_websocket_client_unavailab ESCAPE: sync_websocket_mock CLAIM: Accessing any attribute on sync_websocket_mock raises ImportError with - instructions when bigfoot.plugins.websocket_plugin._WEBSOCKET_CLIENT_AVAILABLE is False. + instructions when tripwire.plugins.websocket_plugin._WEBSOCKET_CLIENT_AVAILABLE is False. PATH: _SyncWebSocketProxy.__getattr__ -> checks _WEBSOCKET_CLIENT_AVAILABLE -> raises ImportError. - CHECK: Raises ImportError with message containing "bigfoot[websocket-client]" and "pip install". + CHECK: Raises ImportError with message containing "tripwire[websocket-client]" and "pip install". MUTATION: If __getattr__ does not check _WEBSOCKET_CLIENT_AVAILABLE, the error is deferred to activate() time (inside a test context), and the message will be different or absent. ESCAPE: A proxy that checks availability but emits a wrong message would still pass the attribute access but fail only the message assertion -- caught by exact string check. """ - import bigfoot - import bigfoot.plugins.websocket_plugin as ws_mod + import tripwire + import tripwire.plugins.websocket_plugin as ws_mod monkeypatch.setattr(ws_mod, "_WEBSOCKET_CLIENT_AVAILABLE", False) with pytest.raises(ImportError) as exc_info: - _ = bigfoot.sync_websocket_mock.new_session # noqa: B018 + _ = tripwire.sync_websocket_mock.new_session # noqa: B018 - assert "bigfoot[websocket-client]" in str(exc_info.value) + assert "tripwire[websocket-client]" in str(exc_info.value) assert "pip install" in str(exc_info.value) diff --git a/tests/unit/test_jwt_plugin.py b/tests/unit/test_jwt_plugin.py index bb97766..297740e 100644 --- a/tests/unit/test_jwt_plugin.py +++ b/tests/unit/test_jwt_plugin.py @@ -5,15 +5,15 @@ import jwt # noqa: F401 import pytest -from bigfoot._context import _current_test_verifier -from bigfoot._errors import ( +from tripwire._context import _current_test_verifier +from tripwire._errors import ( InteractionMismatchError, MissingAssertionFieldsError, UnmockedInteractionError, ) -from bigfoot._timeline import Interaction -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.jwt_plugin import ( +from tripwire._timeline import Interaction +from tripwire._verifier import StrictVerifier +from tripwire.plugins.jwt_plugin import ( _JWT_AVAILABLE, JwtMockConfig, JwtPlugin, @@ -57,14 +57,14 @@ def test_jwt_available_flag() -> None: def test_activate_raises_when_jwt_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: - import bigfoot.plugins.jwt_plugin as _jp + import tripwire.plugins.jwt_plugin as _jp v, p = _make_verifier_with_plugin() monkeypatch.setattr(_jp, "_JWT_AVAILABLE", False) with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install bigfoot[jwt] to use JwtPlugin: pip install bigfoot[jwt]" + "Install tripwire[jwt] to use JwtPlugin: pip install tripwire[jwt]" ) @@ -389,7 +389,7 @@ def test_format_mock_hint() -> None: plugin=p, ) result = p.format_mock_hint(interaction) - assert result == " bigfoot.jwt_mock.mock_encode(returns=...)" + assert result == " tripwire.jwt_mock.mock_encode(returns=...)" def test_format_unmocked_hint() -> None: @@ -398,7 +398,7 @@ def test_format_unmocked_hint() -> None: assert result == ( "jwt.encode(...) was called but no mock was registered.\n" "Register a mock with:\n" - " bigfoot.jwt_mock.mock_encode(returns=...)" + " tripwire.jwt_mock.mock_encode(returns=...)" ) @@ -413,32 +413,32 @@ def test_format_unused_mock_hint() -> None: # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.jwt_mock +# Module-level proxy: tripwire.jwt_mock # --------------------------------------------------------------------------- -def test_jwt_mock_proxy_mock_encode(bigfoot_verifier: StrictVerifier) -> None: +def test_jwt_mock_proxy_mock_encode(tripwire_verifier: StrictVerifier) -> None: import jwt as jwt_mod - import bigfoot + import tripwire - bigfoot.jwt_mock.mock_encode(returns="proxied_token") + tripwire.jwt_mock.mock_encode(returns="proxied_token") - with bigfoot.sandbox(): + with tripwire.sandbox(): result = jwt_mod.encode({"sub": "1"}, "secret", algorithm="HS256") assert result == "proxied_token" - bigfoot.jwt_mock.assert_encode(payload={"sub": "1"}, algorithm="HS256", extra_kwargs={}) + tripwire.jwt_mock.assert_encode(payload={"sub": "1"}, algorithm="HS256", extra_kwargs={}) def test_jwt_mock_proxy_raises_outside_context() -> None: - import bigfoot - from bigfoot._errors import NoActiveVerifierError + import tripwire + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.jwt_mock.mock_encode + _ = tripwire.jwt_mock.mock_encode finally: _current_test_verifier.reset(token) @@ -449,11 +449,11 @@ def test_jwt_mock_proxy_raises_outside_context() -> None: def test_jwt_plugin_in_all() -> None: - import bigfoot - from bigfoot.plugins.jwt_plugin import JwtPlugin as _JwtPlugin + import tripwire + from tripwire.plugins.jwt_plugin import JwtPlugin as _JwtPlugin - assert bigfoot.JwtPlugin is _JwtPlugin - assert type(bigfoot.jwt_mock).__name__ == "_JwtProxy" + assert tripwire.JwtPlugin is _JwtPlugin + assert type(tripwire.jwt_mock).__name__ == "_JwtProxy" # --------------------------------------------------------------------------- @@ -461,69 +461,69 @@ def test_jwt_plugin_in_all() -> None: # --------------------------------------------------------------------------- -def test_jwt_interactions_not_auto_asserted(bigfoot_verifier: StrictVerifier) -> None: +def test_jwt_interactions_not_auto_asserted(tripwire_verifier: StrictVerifier) -> None: import jwt as jwt_mod - import bigfoot + import tripwire - bigfoot.jwt_mock.mock_encode(returns="token") - with bigfoot.sandbox(): + tripwire.jwt_mock.mock_encode(returns="token") + with tripwire.sandbox(): jwt_mod.encode({"sub": "1"}, "secret", algorithm="HS256") - timeline = bigfoot_verifier._timeline + timeline = tripwire_verifier._timeline interactions = timeline.all_unasserted() assert len(interactions) == 1 assert interactions[0].source_id == "jwt:encode" - bigfoot.jwt_mock.assert_encode(payload={"sub": "1"}, algorithm="HS256", extra_kwargs={}) + tripwire.jwt_mock.assert_encode(payload={"sub": "1"}, algorithm="HS256", extra_kwargs={}) -def test_assert_encode_typed_helper(bigfoot_verifier: StrictVerifier) -> None: +def test_assert_encode_typed_helper(tripwire_verifier: StrictVerifier) -> None: import jwt as jwt_mod - import bigfoot + import tripwire - bigfoot.jwt_mock.mock_encode(returns="token") - with bigfoot.sandbox(): + tripwire.jwt_mock.mock_encode(returns="token") + with tripwire.sandbox(): jwt_mod.encode({"sub": "1"}, "secret", algorithm="HS256") - bigfoot.jwt_mock.assert_encode(payload={"sub": "1"}, algorithm="HS256", extra_kwargs={}) + tripwire.jwt_mock.assert_encode(payload={"sub": "1"}, algorithm="HS256", extra_kwargs={}) -def test_assert_decode_typed_helper(bigfoot_verifier: StrictVerifier) -> None: +def test_assert_decode_typed_helper(tripwire_verifier: StrictVerifier) -> None: import jwt as jwt_mod - import bigfoot + import tripwire - bigfoot.jwt_mock.mock_decode(returns={"sub": "1"}) - with bigfoot.sandbox(): + tripwire.jwt_mock.mock_decode(returns={"sub": "1"}) + with tripwire.sandbox(): jwt_mod.decode("tok", "secret", algorithms=["HS256"]) - bigfoot.jwt_mock.assert_decode(token="tok", algorithms=["HS256"], options=None) + tripwire.jwt_mock.assert_decode(token="tok", algorithms=["HS256"], options=None) -def test_assert_encode_wrong_params_raises(bigfoot_verifier: StrictVerifier) -> None: +def test_assert_encode_wrong_params_raises(tripwire_verifier: StrictVerifier) -> None: import jwt as jwt_mod - import bigfoot + import tripwire - bigfoot.jwt_mock.mock_encode(returns="token") - with bigfoot.sandbox(): + tripwire.jwt_mock.mock_encode(returns="token") + with tripwire.sandbox(): jwt_mod.encode({"sub": "1"}, "secret", algorithm="HS256") with pytest.raises(InteractionMismatchError): - bigfoot.jwt_mock.assert_encode(payload={"sub": "wrong"}, algorithm="HS256", extra_kwargs={}) - bigfoot.jwt_mock.assert_encode(payload={"sub": "1"}, algorithm="HS256", extra_kwargs={}) + tripwire.jwt_mock.assert_encode(payload={"sub": "wrong"}, algorithm="HS256", extra_kwargs={}) + tripwire.jwt_mock.assert_encode(payload={"sub": "1"}, algorithm="HS256", extra_kwargs={}) -def test_missing_assertion_fields_raises(bigfoot_verifier: StrictVerifier) -> None: +def test_missing_assertion_fields_raises(tripwire_verifier: StrictVerifier) -> None: import jwt as jwt_mod - import bigfoot + import tripwire - bigfoot.jwt_mock.mock_encode(returns="token") - with bigfoot.sandbox(): + tripwire.jwt_mock.mock_encode(returns="token") + with tripwire.sandbox(): jwt_mod.encode({"sub": "1"}, "secret", algorithm="HS256") - from bigfoot.plugins.jwt_plugin import _JwtSentinel + from tripwire.plugins.jwt_plugin import _JwtSentinel sentinel = _JwtSentinel("encode") with pytest.raises(MissingAssertionFieldsError): - bigfoot.assert_interaction(sentinel, payload={"sub": "1"}) - bigfoot.jwt_mock.assert_encode(payload={"sub": "1"}, algorithm="HS256", extra_kwargs={}) + tripwire.assert_interaction(sentinel, payload={"sub": "1"}) + tripwire.jwt_mock.assert_encode(payload={"sub": "1"}, algorithm="HS256", extra_kwargs={}) diff --git a/tests/unit/test_logging_plugin.py b/tests/unit/test_logging_plugin.py index 86b1b1f..4d9bafd 100644 --- a/tests/unit/test_logging_plugin.py +++ b/tests/unit/test_logging_plugin.py @@ -1,22 +1,22 @@ -"""Unit tests for bigfoot LoggingPlugin.""" +"""Unit tests for tripwire LoggingPlugin.""" import logging from unittest.mock import MagicMock import pytest -import bigfoot -from bigfoot._context import _current_test_verifier -from bigfoot._errors import ( +import tripwire +from tripwire._context import _current_test_verifier +from tripwire._errors import ( ConflictError, InteractionMismatchError, MissingAssertionFieldsError, UnassertedInteractionsError, UnusedMocksError, ) -from bigfoot._timeline import Interaction -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.logging_plugin import ( +from tripwire._timeline import Interaction +from tripwire._verifier import StrictVerifier +from tripwire.plugins.logging_plugin import ( _LOGGER_LOG_ORIGINAL, LoggingPlugin, ) @@ -502,7 +502,7 @@ def test_format_assert_hint() -> None: plugin=p, ) result = p.format_assert_hint(interaction) - assert "bigfoot.log_mock.assert_log" in result + assert "tripwire.log_mock.assert_log" in result assert "'INFO'" in result assert "'started'" in result assert "'myapp'" in result @@ -517,7 +517,7 @@ def test_format_mock_hint() -> None: plugin=p, ) result = p.format_mock_hint(interaction) - assert "bigfoot.log_mock.mock_log" in result + assert "tripwire.log_mock.mock_log" in result assert "'WARNING'" in result assert "'low memory'" in result assert "'system'" in result @@ -534,7 +534,7 @@ def test_format_unused_mock_hint() -> None: def test_format_unmocked_hint() -> None: v, p = _make_verifier_with_plugin() result = p.format_unmocked_hint("logging:log", ("INFO", "hello"), {}) - assert "bigfoot.log_mock.mock_log" in result + assert "tripwire.log_mock.mock_log" in result # --------------------------------------------------------------------------- @@ -555,25 +555,25 @@ def test_conflict_error_logger_log_already_patched() -> None: # --------------------------------------------------------------------------- -# Module-level API via bigfoot.log_mock proxy +# Module-level API via tripwire.log_mock proxy # --------------------------------------------------------------------------- -def test_log_mock_proxy_in_sandbox(bigfoot_verifier: StrictVerifier) -> None: +def test_log_mock_proxy_in_sandbox(tripwire_verifier: StrictVerifier) -> None: logger = logging.getLogger("test.proxy") - with bigfoot.sandbox(): + with tripwire.sandbox(): logger.info("via proxy") - bigfoot.log_mock.assert_info("via proxy", "test.proxy") + tripwire.log_mock.assert_info("via proxy", "test.proxy") def test_log_mock_proxy_raises_outside_context() -> None: - from bigfoot._errors import NoActiveVerifierError + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.log_mock.mock_log + _ = tripwire.log_mock.mock_log finally: _current_test_verifier.reset(token) diff --git a/tests/unit/test_match.py b/tests/unit/test_match.py index 56a0594..162907d 100644 --- a/tests/unit/test_match.py +++ b/tests/unit/test_match.py @@ -1,12 +1,12 @@ -"""Tests for bigfoot._match -- M() pattern matching.""" +"""Tests for tripwire._match -- M() pattern matching.""" -from bigfoot._firewall_request import ( +from tripwire._firewall_request import ( DnsFirewallRequest, HttpFirewallRequest, RedisFirewallRequest, SubprocessFirewallRequest, ) -from bigfoot._match import M +from tripwire._match import M class TestProtocolMatch: diff --git a/tests/unit/test_mcp_plugin.py b/tests/unit/test_mcp_plugin.py index 7972a2e..0b73d48 100644 --- a/tests/unit/test_mcp_plugin.py +++ b/tests/unit/test_mcp_plugin.py @@ -4,15 +4,15 @@ import pytest -from bigfoot._context import _current_test_verifier -from bigfoot._errors import ( +from tripwire._context import _current_test_verifier +from tripwire._errors import ( InteractionMismatchError, MissingAssertionFieldsError, UnmockedInteractionError, ) -from bigfoot._timeline import Interaction -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.mcp_plugin import ( +from tripwire._timeline import Interaction +from tripwire._verifier import StrictVerifier +from tripwire.plugins.mcp_plugin import ( _MCP_AVAILABLE, McpMockConfig, McpPlugin, @@ -64,14 +64,14 @@ def test_mcp_available_flag() -> None: def test_activate_raises_when_mcp_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: - import bigfoot.plugins.mcp_plugin as _mp + import tripwire.plugins.mcp_plugin as _mp v, p = _make_verifier_with_plugin() monkeypatch.setattr(_mp, "_MCP_AVAILABLE", False) with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install bigfoot[mcp] to use McpPlugin: pip install bigfoot[mcp]" + "Install tripwire[mcp] to use McpPlugin: pip install tripwire[mcp]" ) @@ -154,21 +154,21 @@ def test_reference_counting_nested() -> None: @pytest.mark.asyncio -async def test_client_call_tool_mock_and_assert(bigfoot_verifier: StrictVerifier) -> None: +async def test_client_call_tool_mock_and_assert(tripwire_verifier: StrictVerifier) -> None: """Client call_tool: mock registers, patched method returns mock, assert passes.""" from mcp.client.session import ClientSession - import bigfoot + import tripwire mock_result = {"content": [{"type": "text", "text": "hello"}]} - bigfoot.mcp_mock.mock_call_tool("my_tool", returns=mock_result) + tripwire.mcp_mock.mock_call_tool("my_tool", returns=mock_result) - with bigfoot.sandbox(): + with tripwire.sandbox(): session = object.__new__(ClientSession) result = await ClientSession.call_tool(session, "my_tool", {"arg1": "val1"}) assert result == mock_result - bigfoot.mcp_mock.assert_call_tool( + tripwire.mcp_mock.assert_call_tool( "my_tool", arguments={"arg1": "val1"}, direction="client", @@ -181,21 +181,21 @@ async def test_client_call_tool_mock_and_assert(bigfoot_verifier: StrictVerifier @pytest.mark.asyncio -async def test_client_read_resource_mock_and_assert(bigfoot_verifier: StrictVerifier) -> None: +async def test_client_read_resource_mock_and_assert(tripwire_verifier: StrictVerifier) -> None: """Client read_resource: mock registers, patched method returns mock, assert passes.""" from mcp.client.session import ClientSession - import bigfoot + import tripwire mock_result = {"contents": [{"uri": "file:///data.txt", "text": "content"}]} - bigfoot.mcp_mock.mock_read_resource("file:///data.txt", returns=mock_result) + tripwire.mcp_mock.mock_read_resource("file:///data.txt", returns=mock_result) - with bigfoot.sandbox(): + with tripwire.sandbox(): session = object.__new__(ClientSession) result = await ClientSession.read_resource(session, "file:///data.txt") assert result == mock_result - bigfoot.mcp_mock.assert_read_resource( + tripwire.mcp_mock.assert_read_resource( "file:///data.txt", direction="client", ) @@ -207,21 +207,21 @@ async def test_client_read_resource_mock_and_assert(bigfoot_verifier: StrictVeri @pytest.mark.asyncio -async def test_client_get_prompt_mock_and_assert(bigfoot_verifier: StrictVerifier) -> None: +async def test_client_get_prompt_mock_and_assert(tripwire_verifier: StrictVerifier) -> None: """Client get_prompt: mock registers, patched method returns mock, assert passes.""" from mcp.client.session import ClientSession - import bigfoot + import tripwire mock_result = {"messages": [{"role": "user", "content": "hello"}]} - bigfoot.mcp_mock.mock_get_prompt("greeting", returns=mock_result) + tripwire.mcp_mock.mock_get_prompt("greeting", returns=mock_result) - with bigfoot.sandbox(): + with tripwire.sandbox(): session = object.__new__(ClientSession) result = await ClientSession.get_prompt(session, "greeting", {"name": "world"}) assert result == mock_result - bigfoot.mcp_mock.assert_get_prompt( + tripwire.mcp_mock.assert_get_prompt( "greeting", arguments={"name": "world"}, direction="client", @@ -234,16 +234,16 @@ async def test_client_get_prompt_mock_and_assert(bigfoot_verifier: StrictVerifie @pytest.mark.asyncio -async def test_client_call_tool_fifo_ordering(bigfoot_verifier: StrictVerifier) -> None: +async def test_client_call_tool_fifo_ordering(tripwire_verifier: StrictVerifier) -> None: """Multiple mocks for the same tool are consumed in FIFO order.""" from mcp.client.session import ClientSession - import bigfoot + import tripwire - bigfoot.mcp_mock.mock_call_tool("tool_a", returns={"seq": 1}) - bigfoot.mcp_mock.mock_call_tool("tool_a", returns={"seq": 2}) + tripwire.mcp_mock.mock_call_tool("tool_a", returns={"seq": 1}) + tripwire.mcp_mock.mock_call_tool("tool_a", returns={"seq": 2}) - with bigfoot.sandbox(): + with tripwire.sandbox(): session = object.__new__(ClientSession) first = await ClientSession.call_tool(session, "tool_a", {"x": "1"}) second = await ClientSession.call_tool(session, "tool_a", {"x": "2"}) @@ -251,8 +251,8 @@ async def test_client_call_tool_fifo_ordering(bigfoot_verifier: StrictVerifier) assert first == {"seq": 1} assert second == {"seq": 2} - bigfoot.mcp_mock.assert_call_tool("tool_a", arguments={"x": "1"}, direction="client") - bigfoot.mcp_mock.assert_call_tool("tool_a", arguments={"x": "2"}, direction="client") + tripwire.mcp_mock.assert_call_tool("tool_a", arguments={"x": "1"}, direction="client") + tripwire.mcp_mock.assert_call_tool("tool_a", arguments={"x": "2"}, direction="client") # --------------------------------------------------------------------------- @@ -261,25 +261,25 @@ async def test_client_call_tool_fifo_ordering(bigfoot_verifier: StrictVerifier) @pytest.mark.asyncio -async def test_unasserted_interaction_recorded(bigfoot_verifier: StrictVerifier) -> None: +async def test_unasserted_interaction_recorded(tripwire_verifier: StrictVerifier) -> None: """Interactions are NOT auto-asserted; they appear in all_unasserted().""" from mcp.client.session import ClientSession - import bigfoot + import tripwire - bigfoot.mcp_mock.mock_call_tool("my_tool", returns={"ok": True}) + tripwire.mcp_mock.mock_call_tool("my_tool", returns={"ok": True}) - with bigfoot.sandbox(): + with tripwire.sandbox(): session = object.__new__(ClientSession) await ClientSession.call_tool(session, "my_tool", {"k": "v"}) - timeline = bigfoot_verifier._timeline + timeline = tripwire_verifier._timeline unasserted = timeline.all_unasserted() assert len(unasserted) == 1 assert unasserted[0].source_id == "mcp:client:call_tool:my_tool" # Clean up by asserting - bigfoot.mcp_mock.assert_call_tool("my_tool", arguments={"k": "v"}, direction="client") + tripwire.mcp_mock.assert_call_tool("my_tool", arguments={"k": "v"}, direction="client") # --------------------------------------------------------------------------- @@ -288,13 +288,13 @@ async def test_unasserted_interaction_recorded(bigfoot_verifier: StrictVerifier) @pytest.mark.asyncio -async def test_unmocked_call_tool_raises(bigfoot_verifier: StrictVerifier) -> None: +async def test_unmocked_call_tool_raises(tripwire_verifier: StrictVerifier) -> None: """Calling a tool with no mock raises UnmockedInteractionError.""" from mcp.client.session import ClientSession - import bigfoot + import tripwire - with bigfoot.sandbox(): + with tripwire.sandbox(): session = object.__new__(ClientSession) with pytest.raises(UnmockedInteractionError) as exc_info: await ClientSession.call_tool(session, "unknown_tool", {}) @@ -303,13 +303,13 @@ async def test_unmocked_call_tool_raises(bigfoot_verifier: StrictVerifier) -> No @pytest.mark.asyncio -async def test_unmocked_read_resource_raises(bigfoot_verifier: StrictVerifier) -> None: +async def test_unmocked_read_resource_raises(tripwire_verifier: StrictVerifier) -> None: """Calling read_resource with no mock raises UnmockedInteractionError.""" from mcp.client.session import ClientSession - import bigfoot + import tripwire - with bigfoot.sandbox(): + with tripwire.sandbox(): session = object.__new__(ClientSession) with pytest.raises(UnmockedInteractionError) as exc_info: await ClientSession.read_resource(session, "file:///nope.txt") @@ -318,13 +318,13 @@ async def test_unmocked_read_resource_raises(bigfoot_verifier: StrictVerifier) - @pytest.mark.asyncio -async def test_unmocked_get_prompt_raises(bigfoot_verifier: StrictVerifier) -> None: +async def test_unmocked_get_prompt_raises(tripwire_verifier: StrictVerifier) -> None: """Calling get_prompt with no mock raises UnmockedInteractionError.""" from mcp.client.session import ClientSession - import bigfoot + import tripwire - with bigfoot.sandbox(): + with tripwire.sandbox(): session = object.__new__(ClientSession) with pytest.raises(UnmockedInteractionError) as exc_info: await ClientSession.get_prompt(session, "missing_prompt") @@ -364,23 +364,23 @@ def test_unused_mocks_excludes_required_false() -> None: @pytest.mark.asyncio -async def test_assert_wrong_tool_name_raises(bigfoot_verifier: StrictVerifier) -> None: +async def test_assert_wrong_tool_name_raises(tripwire_verifier: StrictVerifier) -> None: """assert_call_tool with wrong tool_name raises InteractionMismatchError.""" from mcp.client.session import ClientSession - import bigfoot + import tripwire - bigfoot.mcp_mock.mock_call_tool("real_tool", returns={"ok": True}) + tripwire.mcp_mock.mock_call_tool("real_tool", returns={"ok": True}) - with bigfoot.sandbox(): + with tripwire.sandbox(): session = object.__new__(ClientSession) await ClientSession.call_tool(session, "real_tool", {"k": "v"}) with pytest.raises(InteractionMismatchError): - bigfoot.mcp_mock.assert_call_tool("wrong_tool", arguments={"k": "v"}, direction="client") + tripwire.mcp_mock.assert_call_tool("wrong_tool", arguments={"k": "v"}, direction="client") # Clean up by asserting correctly - bigfoot.mcp_mock.assert_call_tool("real_tool", arguments={"k": "v"}, direction="client") + tripwire.mcp_mock.assert_call_tool("real_tool", arguments={"k": "v"}, direction="client") # --------------------------------------------------------------------------- @@ -450,24 +450,24 @@ def test_assertable_fields_returns_all_detail_keys_get_prompt() -> None: @pytest.mark.asyncio -async def test_missing_assertion_fields_raises(bigfoot_verifier: StrictVerifier) -> None: +async def test_missing_assertion_fields_raises(tripwire_verifier: StrictVerifier) -> None: """Incomplete fields in assert_interaction raises MissingAssertionFieldsError.""" from mcp.client.session import ClientSession - import bigfoot + import tripwire - bigfoot.mcp_mock.mock_call_tool("my_tool", returns={"ok": True}) + tripwire.mcp_mock.mock_call_tool("my_tool", returns={"ok": True}) - with bigfoot.sandbox(): + with tripwire.sandbox(): session = object.__new__(ClientSession) await ClientSession.call_tool(session, "my_tool", {"k": "v"}) sentinel = _McpSentinel("mcp:client:call_tool:my_tool") with pytest.raises(MissingAssertionFieldsError): - bigfoot.assert_interaction(sentinel, direction="client") + tripwire.assert_interaction(sentinel, direction="client") # Clean up by asserting correctly - bigfoot.mcp_mock.assert_call_tool("my_tool", arguments={"k": "v"}, direction="client") + tripwire.mcp_mock.assert_call_tool("my_tool", arguments={"k": "v"}, direction="client") # --------------------------------------------------------------------------- @@ -522,21 +522,21 @@ def test_server_get_prompt_mock_enqueues_correctly() -> None: @pytest.mark.asyncio -async def test_mock_call_tool_raises_exception(bigfoot_verifier: StrictVerifier) -> None: +async def test_mock_call_tool_raises_exception(tripwire_verifier: StrictVerifier) -> None: """Mock with raises parameter raises the exception instead of returning.""" from mcp.client.session import ClientSession - import bigfoot + import tripwire err = RuntimeError("boom") - bigfoot.mcp_mock.mock_call_tool("failing_tool", returns=None, raises=err) + tripwire.mcp_mock.mock_call_tool("failing_tool", returns=None, raises=err) - with bigfoot.sandbox(): + with tripwire.sandbox(): session = object.__new__(ClientSession) with pytest.raises(RuntimeError, match="boom"): await ClientSession.call_tool(session, "failing_tool") - bigfoot.mcp_mock.assert_call_tool( + tripwire.mcp_mock.assert_call_tool( "failing_tool", arguments={}, direction="client", raised=err, ) @@ -636,7 +636,7 @@ def test_format_mock_hint_client_call_tool() -> None: plugin=p, ) result = p.format_mock_hint(interaction) - assert result == " bigfoot.mcp_mock.mock_call_tool('my_tool', returns=...)" + assert result == " tripwire.mcp_mock.mock_call_tool('my_tool', returns=...)" def test_format_mock_hint_server_read_resource() -> None: @@ -652,7 +652,7 @@ def test_format_mock_hint_server_read_resource() -> None: plugin=p, ) result = p.format_mock_hint(interaction) - assert result == " bigfoot.mcp_mock.mock_server_read_resource('file:///x.txt', returns=...)" + assert result == " tripwire.mcp_mock.mock_server_read_resource('file:///x.txt', returns=...)" def test_format_unmocked_hint() -> None: @@ -661,7 +661,7 @@ def test_format_unmocked_hint() -> None: assert result == ( "mcp client call_tool('my_tool') was called but no mock was registered.\n" "Register a mock with:\n" - " bigfoot.mcp_mock.mock_call_tool('my_tool', returns=...)" + " tripwire.mcp_mock.mock_call_tool('my_tool', returns=...)" ) @@ -671,7 +671,7 @@ def test_format_unmocked_hint_server() -> None: assert result == ( "mcp server call_tool('server_tool') was called but no mock was registered.\n" "Register a mock with:\n" - " bigfoot.mcp_mock.mock_server_call_tool('server_tool', returns=...)" + " tripwire.mcp_mock.mock_server_call_tool('server_tool', returns=...)" ) @@ -690,7 +690,7 @@ def test_format_assert_hint_call_tool() -> None: ) result = p.format_assert_hint(interaction) assert result == ( - " bigfoot.mcp_mock.assert_call_tool(\n" + " tripwire.mcp_mock.assert_call_tool(\n" " tool_name='my_tool',\n" " arguments={'x': 1},\n" " direction='client',\n" @@ -712,7 +712,7 @@ def test_format_assert_hint_read_resource() -> None: ) result = p.format_assert_hint(interaction) assert result == ( - " bigfoot.mcp_mock.assert_read_resource(\n" + " tripwire.mcp_mock.assert_read_resource(\n" " uri='file:///x.txt',\n" " direction='client',\n" " )" @@ -734,7 +734,7 @@ def test_format_assert_hint_get_prompt() -> None: ) result = p.format_assert_hint(interaction) assert result == ( - " bigfoot.mcp_mock.assert_get_prompt(\n" + " tripwire.mcp_mock.assert_get_prompt(\n" " prompt_name='greeting',\n" " arguments={'name': 'world'},\n" " direction='client',\n" @@ -766,37 +766,37 @@ def test_sentinel_source_id() -> None: # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.mcp_mock +# Module-level proxy: tripwire.mcp_mock # --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_mcp_mock_proxy_mock_call_tool(bigfoot_verifier: StrictVerifier) -> None: +async def test_mcp_mock_proxy_mock_call_tool(tripwire_verifier: StrictVerifier) -> None: """Module-level proxy routes mock_call_tool correctly.""" from mcp.client.session import ClientSession - import bigfoot + import tripwire - bigfoot.mcp_mock.mock_call_tool("proxy_tool", returns={"proxied": True}) + tripwire.mcp_mock.mock_call_tool("proxy_tool", returns={"proxied": True}) - with bigfoot.sandbox(): + with tripwire.sandbox(): session = object.__new__(ClientSession) result = await ClientSession.call_tool(session, "proxy_tool", {"a": "b"}) assert result == {"proxied": True} - bigfoot.mcp_mock.assert_call_tool( + tripwire.mcp_mock.assert_call_tool( "proxy_tool", arguments={"a": "b"}, direction="client" ) def test_mcp_mock_proxy_raises_outside_context() -> None: - import bigfoot - from bigfoot._errors import NoActiveVerifierError + import tripwire + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.mcp_mock.mock_call_tool + _ = tripwire.mcp_mock.mock_call_tool finally: _current_test_verifier.reset(token) @@ -807,11 +807,11 @@ def test_mcp_mock_proxy_raises_outside_context() -> None: def test_mcp_plugin_in_all() -> None: - import bigfoot - from bigfoot.plugins.mcp_plugin import McpPlugin as _McpPlugin + import tripwire + from tripwire.plugins.mcp_plugin import McpPlugin as _McpPlugin - assert bigfoot.McpPlugin is _McpPlugin - assert type(bigfoot.mcp_mock).__name__ == "_McpProxy" + assert tripwire.McpPlugin is _McpPlugin + assert type(tripwire.mcp_mock).__name__ == "_McpProxy" # --------------------------------------------------------------------------- @@ -821,39 +821,39 @@ def test_mcp_plugin_in_all() -> None: @pytest.mark.asyncio async def test_call_tool_none_arguments_become_empty_dict( - bigfoot_verifier: StrictVerifier, + tripwire_verifier: StrictVerifier, ) -> None: """When arguments is None, interaction details record an empty dict.""" from mcp.client.session import ClientSession - import bigfoot + import tripwire - bigfoot.mcp_mock.mock_call_tool("tool_no_args", returns={"ok": True}) + tripwire.mcp_mock.mock_call_tool("tool_no_args", returns={"ok": True}) - with bigfoot.sandbox(): + with tripwire.sandbox(): session = object.__new__(ClientSession) await ClientSession.call_tool(session, "tool_no_args") - bigfoot.mcp_mock.assert_call_tool( + tripwire.mcp_mock.assert_call_tool( "tool_no_args", arguments={}, direction="client" ) @pytest.mark.asyncio async def test_get_prompt_none_arguments_become_empty_dict( - bigfoot_verifier: StrictVerifier, + tripwire_verifier: StrictVerifier, ) -> None: """When arguments is None, interaction details record an empty dict.""" from mcp.client.session import ClientSession - import bigfoot + import tripwire - bigfoot.mcp_mock.mock_get_prompt("my_prompt", returns={"messages": []}) + tripwire.mcp_mock.mock_get_prompt("my_prompt", returns={"messages": []}) - with bigfoot.sandbox(): + with tripwire.sandbox(): session = object.__new__(ClientSession) await ClientSession.get_prompt(session, "my_prompt") - bigfoot.mcp_mock.assert_get_prompt( + tripwire.mcp_mock.assert_get_prompt( "my_prompt", arguments={}, direction="client" ) diff --git a/tests/unit/test_memcache_plugin.py b/tests/unit/test_memcache_plugin.py index 774c10f..b37e66a 100644 --- a/tests/unit/test_memcache_plugin.py +++ b/tests/unit/test_memcache_plugin.py @@ -5,15 +5,15 @@ import pymemcache # noqa: F401 import pytest -from bigfoot._context import _current_test_verifier -from bigfoot._errors import ( +from tripwire._context import _current_test_verifier +from tripwire._errors import ( InteractionMismatchError, MissingAssertionFieldsError, UnmockedInteractionError, ) -from bigfoot._timeline import Interaction -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.memcache_plugin import ( +from tripwire._timeline import Interaction +from tripwire._verifier import StrictVerifier +from tripwire.plugins.memcache_plugin import ( _PYMEMCACHE_AVAILABLE, MemcacheMockConfig, MemcachePlugin, @@ -60,14 +60,14 @@ def test_pymemcache_available_flag() -> None: def test_activate_raises_when_pymemcache_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: - import bigfoot.plugins.memcache_plugin as _mp + import tripwire.plugins.memcache_plugin as _mp v, p = _make_verifier_with_plugin() monkeypatch.setattr(_mp, "_PYMEMCACHE_AVAILABLE", False) with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install bigfoot[pymemcache] to use MemcachePlugin: pip install bigfoot[pymemcache]" + "Install tripwire[pymemcache] to use MemcachePlugin: pip install tripwire[pymemcache]" ) @@ -176,32 +176,32 @@ def test_mock_command_set_returns_value() -> None: # --------------------------------------------------------------------------- -def test_assert_get_full_assertion(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_get_full_assertion(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.memcache_mock.mock_command("GET", returns=b"value") + tripwire.memcache_mock.mock_command("GET", returns=b"value") - with bigfoot.sandbox(): + with tripwire.sandbox(): from pymemcache.client.base import Client client = Client(("localhost", 11211)) client.get("mykey") - bigfoot.memcache_mock.assert_get(command="GET", key="mykey") + tripwire.memcache_mock.assert_get(command="GET", key="mykey") -def test_assert_set_full_assertion(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_set_full_assertion(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.memcache_mock.mock_command("SET", returns=True) + tripwire.memcache_mock.mock_command("SET", returns=True) - with bigfoot.sandbox(): + with tripwire.sandbox(): from pymemcache.client.base import Client client = Client(("localhost", 11211)) client.set("mykey", b"myvalue", expire=300) - bigfoot.memcache_mock.assert_set( + tripwire.memcache_mock.assert_set( command="SET", key="mykey", value=b"myvalue", expire=300, ) @@ -339,13 +339,13 @@ def test_get_unused_mocks_excludes_required_false() -> None: # --------------------------------------------------------------------------- -def test_missing_assertion_fields(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot - from bigfoot.plugins.memcache_plugin import _MemcacheSentinel +def test_missing_assertion_fields(tripwire_verifier: StrictVerifier) -> None: + import tripwire + from tripwire.plugins.memcache_plugin import _MemcacheSentinel - bigfoot.memcache_mock.mock_command("SET", returns=True) + tripwire.memcache_mock.mock_command("SET", returns=True) - with bigfoot.sandbox(): + with tripwire.sandbox(): from pymemcache.client.base import Client client = Client(("localhost", 11211)) @@ -354,11 +354,11 @@ def test_missing_assertion_fields(bigfoot_verifier: StrictVerifier) -> None: sentinel = _MemcacheSentinel("memcache:set") with pytest.raises(MissingAssertionFieldsError) as exc_info: # Only pass command, omit key/value/expire - bigfoot_verifier.assert_interaction(sentinel, command="SET") + tripwire_verifier.assert_interaction(sentinel, command="SET") assert "key" in exc_info.value.missing_fields # Now assert fully so teardown passes - bigfoot.memcache_mock.assert_set( + tripwire.memcache_mock.assert_set( command="SET", key="mykey", value=b"myvalue", expire=300, ) @@ -368,23 +368,23 @@ def test_missing_assertion_fields(bigfoot_verifier: StrictVerifier) -> None: # --------------------------------------------------------------------------- -def test_memcache_interactions_not_auto_asserted(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_memcache_interactions_not_auto_asserted(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.memcache_mock.mock_command("GET", returns=b"value") + tripwire.memcache_mock.mock_command("GET", returns=b"value") - with bigfoot.sandbox(): + with tripwire.sandbox(): from pymemcache.client.base import Client client = Client(("localhost", 11211)) client.get("mykey") - timeline = bigfoot_verifier._timeline + timeline = tripwire_verifier._timeline interactions = timeline.all_unasserted() assert len(interactions) == 1 assert interactions[0].source_id == "memcache:get" # Assert it so verify_all() at teardown succeeds - bigfoot.memcache_mock.assert_get(command="GET", key="mykey") + tripwire.memcache_mock.assert_get(command="GET", key="mykey") # --------------------------------------------------------------------------- @@ -442,7 +442,7 @@ def test_format_mock_hint() -> None: plugin=p, ) result = p.format_mock_hint(interaction) - assert result == " bigfoot.memcache_mock.mock_command('GET', returns=...)" + assert result == " tripwire.memcache_mock.mock_command('GET', returns=...)" def test_format_unmocked_hint() -> None: @@ -451,7 +451,7 @@ def test_format_unmocked_hint() -> None: assert result == ( "memcache.GET(...) was called but no mock was registered.\n" "Register a mock with:\n" - " bigfoot.memcache_mock.mock_command('GET', returns=...)" + " tripwire.memcache_mock.mock_command('GET', returns=...)" ) @@ -465,7 +465,7 @@ def test_format_assert_hint() -> None: ) result = p.format_assert_hint(interaction) assert result == ( - " bigfoot.memcache_mock.assert_get(\n" + " tripwire.memcache_mock.assert_get(\n" " command='GET',\n" " key='mykey',\n" " )" @@ -484,33 +484,33 @@ def test_format_unused_mock_hint() -> None: # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.memcache_mock +# Module-level proxy: tripwire.memcache_mock # --------------------------------------------------------------------------- -def test_memcache_mock_proxy_mock_command(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_memcache_mock_proxy_mock_command(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.memcache_mock.mock_command("GET", returns=b"proxy_value") + tripwire.memcache_mock.mock_command("GET", returns=b"proxy_value") - with bigfoot.sandbox(): + with tripwire.sandbox(): from pymemcache.client.base import Client client = Client(("localhost", 11211)) result = client.get("somekey") assert result == b"proxy_value" - bigfoot.memcache_mock.assert_get(command="GET", key="somekey") + tripwire.memcache_mock.assert_get(command="GET", key="somekey") def test_memcache_mock_proxy_raises_outside_context() -> None: - import bigfoot - from bigfoot._errors import NoActiveVerifierError + import tripwire + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.memcache_mock.mock_command + _ = tripwire.memcache_mock.mock_command finally: _current_test_verifier.reset(token) @@ -521,11 +521,11 @@ def test_memcache_mock_proxy_raises_outside_context() -> None: def test_memcache_plugin_in_all() -> None: - import bigfoot + import tripwire - assert "MemcachePlugin" in bigfoot.__all__ - assert "memcache_mock" in bigfoot.__all__ - assert type(bigfoot.memcache_mock).__name__ == "_MemcacheProxy" + assert "MemcachePlugin" in tripwire.__all__ + assert "memcache_mock" in tripwire.__all__ + assert type(tripwire.memcache_mock).__name__ == "_MemcacheProxy" # --------------------------------------------------------------------------- @@ -533,46 +533,46 @@ def test_memcache_plugin_in_all() -> None: # --------------------------------------------------------------------------- -def test_assert_delete(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_delete(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.memcache_mock.mock_command("DELETE", returns=True) + tripwire.memcache_mock.mock_command("DELETE", returns=True) - with bigfoot.sandbox(): + with tripwire.sandbox(): from pymemcache.client.base import Client client = Client(("localhost", 11211)) client.delete("mykey") - bigfoot.memcache_mock.assert_delete(command="DELETE", key="mykey") + tripwire.memcache_mock.assert_delete(command="DELETE", key="mykey") -def test_assert_incr(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_incr(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.memcache_mock.mock_command("INCR", returns=42) + tripwire.memcache_mock.mock_command("INCR", returns=42) - with bigfoot.sandbox(): + with tripwire.sandbox(): from pymemcache.client.base import Client client = Client(("localhost", 11211)) client.incr("counter", 1) - bigfoot.memcache_mock.assert_incr(command="INCR", key="counter", value=1) + tripwire.memcache_mock.assert_incr(command="INCR", key="counter", value=1) -def test_assert_get_wrong_args_raises(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_get_wrong_args_raises(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.memcache_mock.mock_command("GET", returns=b"val") + tripwire.memcache_mock.mock_command("GET", returns=b"val") - with bigfoot.sandbox(): + with tripwire.sandbox(): from pymemcache.client.base import Client client = Client(("localhost", 11211)) client.get("mykey") with pytest.raises(InteractionMismatchError): - bigfoot.memcache_mock.assert_get(command="GET", key="wrongkey") + tripwire.memcache_mock.assert_get(command="GET", key="wrongkey") # Assert correctly so teardown passes - bigfoot.memcache_mock.assert_get(command="GET", key="mykey") + tripwire.memcache_mock.assert_get(command="GET", key="mykey") diff --git a/tests/unit/test_mock_factory.py b/tests/unit/test_mock_factory.py index f7a8647..b8e78da 100644 --- a/tests/unit/test_mock_factory.py +++ b/tests/unit/test_mock_factory.py @@ -5,8 +5,8 @@ import pytest -import bigfoot -from bigfoot._mock_plugin import ImportSiteMock, ObjectMock +import tripwire +from tripwire._mock_plugin import ImportSiteMock, ObjectMock def _create_fake_module(name: str, **attrs: object) -> types.ModuleType: @@ -17,43 +17,43 @@ def _create_fake_module(name: str, **attrs: object) -> types.ModuleType: return mod -def test_bigfoot_mock_is_callable(bigfoot_verifier) -> None: - """bigfoot.mock is callable and returns ImportSiteMock.""" - mock = bigfoot.mock("os.path:sep") +def test_tripwire_mock_is_callable(tripwire_verifier) -> None: + """tripwire.mock is callable and returns ImportSiteMock.""" + mock = tripwire.mock("os.path:sep") assert isinstance(mock, ImportSiteMock) -def test_bigfoot_mock_object_returns_object_mock(bigfoot_verifier) -> None: - """bigfoot.mock.object() returns ObjectMock.""" +def test_tripwire_mock_object_returns_object_mock(tripwire_verifier) -> None: + """tripwire.mock.object() returns ObjectMock.""" class Target: value = "original" target = Target() - mock = bigfoot.mock.object(target, "value") + mock = tripwire.mock.object(target, "value") assert isinstance(mock, ObjectMock) -def test_bigfoot_spy_is_callable(bigfoot_verifier) -> None: - """bigfoot.spy is callable and returns ImportSiteMock with spy=True.""" - mock = bigfoot.spy("os.path:sep") +def test_tripwire_spy_is_callable(tripwire_verifier) -> None: + """tripwire.spy is callable and returns ImportSiteMock with spy=True.""" + mock = tripwire.spy("os.path:sep") assert isinstance(mock, ImportSiteMock) assert mock._spy is True -def test_bigfoot_spy_object_returns_object_mock(bigfoot_verifier) -> None: - """bigfoot.spy.object() returns ObjectMock with spy=True.""" +def test_tripwire_spy_object_returns_object_mock(tripwire_verifier) -> None: + """tripwire.spy.object() returns ObjectMock with spy=True.""" class Target: value = "original" target = Target() - mock = bigfoot.spy.object(target, "value") + mock = tripwire.spy.object(target, "value") assert isinstance(mock, ObjectMock) assert mock._spy is True -def test_bigfoot_mock_validates_path(bigfoot_verifier) -> None: - """bigfoot.mock() raises ValueError for invalid paths.""" +def test_tripwire_mock_validates_path(tripwire_verifier) -> None: + """tripwire.mock() raises ValueError for invalid paths.""" with pytest.raises(ValueError, match="must use.*colon"): - bigfoot.mock("invalid_path") + tripwire.mock("invalid_path") diff --git a/tests/unit/test_mock_plugin.py b/tests/unit/test_mock_plugin.py index 5618afd..f4ab367 100644 --- a/tests/unit/test_mock_plugin.py +++ b/tests/unit/test_mock_plugin.py @@ -4,17 +4,17 @@ import pytest -from bigfoot._context import _active_verifier -from bigfoot._errors import SandboxNotActiveError, UnmockedInteractionError -from bigfoot._mock_plugin import ( +from tripwire._context import _active_verifier +from tripwire._errors import SandboxNotActiveError, UnmockedInteractionError +from tripwire._mock_plugin import ( _ABSENT, MethodProxy, MockConfig, MockPlugin, MockProxy, ) -from bigfoot._timeline import Interaction -from bigfoot._verifier import StrictVerifier +from tripwire._timeline import Interaction +from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # _ABSENT sentinel @@ -32,7 +32,7 @@ def test_absent_sentinel_is_unique_object() -> None: # Setting `_ABSENT = _SENTINEL` would fail the identity check vs _SENTINEL. # ESCAPE: Nothing reasonable -- exact identity checks against all common confusions. # IMPACT: assert_call() could not distinguish "parameter not passed" from None. - from bigfoot._mock_plugin import _SENTINEL + from tripwire._mock_plugin import _SENTINEL assert _ABSENT is not None assert _ABSENT is not True @@ -947,7 +947,7 @@ def test_format_mock_hint_returns_string() -> None: plugin=p, ) result = p.format_mock_hint(interaction) - assert result == 'bigfoot.mock("Svc").method.returns()' + assert result == 'tripwire.mock("Svc").method.returns()' # --------------------------------------------------------------------------- @@ -991,9 +991,9 @@ def test_format_unmocked_hint_returns_string() -> None: "Unexpected call to Svc.method\n\n" " Called with: args=(), kwargs={}\n\n" " To mock this interaction, add before your sandbox:\n" - ' bigfoot.mock("Svc").method.returns()\n\n' + ' tripwire.mock("Svc").method.returns()\n\n' " Or to mark it optional:\n" - ' bigfoot.mock("Svc").method.required(False).returns()' + ' tripwire.mock("Svc").method.required(False).returns()' ) assert result == expected @@ -1022,7 +1022,7 @@ def test_format_assert_hint_returns_string() -> None: ) result = p.format_assert_hint(interaction) assert result == ( - 'bigfoot.mock("Svc").method.assert_call(\n' + 'tripwire.mock("Svc").method.assert_call(\n' " args=(),\n" " kwargs={},\n" ")" @@ -1043,7 +1043,7 @@ def test_method_proxy_assert_call_convenience_method() -> None: # MUTATION: Passing wrong source or swapping args/kwargs would cause InteractionMismatchError. # ESCAPE: If assert_call silently did nothing, this test would still pass; covered by next test. # IMPACT: Users have no convenience wrapper and must use raw verifier.assert_interaction(). - from bigfoot._context import _current_test_verifier + from tripwire._context import _current_test_verifier v = StrictVerifier() p = MockPlugin(v) @@ -1073,8 +1073,8 @@ def test_method_proxy_assert_call_raises_on_mismatch() -> None: # CHECK: pytest.raises(InteractionMismatchError) confirms assert_call is not a no-op. # MUTATION: A no-op assert_call would not raise, failing this test. # IMPACT: assert_call would silently pass even with wrong assertions, defeating certainty. - from bigfoot._context import _current_test_verifier - from bigfoot._errors import InteractionMismatchError + from tripwire._context import _current_test_verifier + from tripwire._errors import InteractionMismatchError v = StrictVerifier() p = MockPlugin(v) @@ -1103,7 +1103,7 @@ def test_method_proxy_assert_call_defaults_kwargs_to_empty_dict() -> None: # CHECK: No error when calling assert_call with only args= for a call that had no kwargs. # MUTATION: If default were None instead of {}, MissingAssertionFieldsError or mismatch. # IMPACT: Users would always have to pass kwargs={} explicitly, reducing convenience. - from bigfoot._context import _current_test_verifier + from tripwire._context import _current_test_verifier v = StrictVerifier() p = MockPlugin(v) @@ -1255,7 +1255,7 @@ def test_mock_plugin_implements_all_abstract_methods() -> None: # MUTATION: Removing any method implementation from MockPlugin would raise TypeError. # ESCAPE: If the test itself instantiates MockPlugin, a TypeError would prevent the test from running. # IMPACT: MockPlugin would be unusable if any abstract method is missing. - from bigfoot._base_plugin import BasePlugin + from tripwire._base_plugin import BasePlugin v = StrictVerifier() p = MockPlugin(v) # Would raise TypeError if any abstract method unimplemented @@ -1274,7 +1274,7 @@ def test_method_proxy_call_raises_runtime_error_on_unknown_side_effect() -> None somehow constructed with a side_effect that isn't _ReturnValue, _RaiseException, or _CallFn, the error surface is explicit rather than silent. """ - from bigfoot._mock_plugin import MockConfig + from tripwire._mock_plugin import MockConfig v = StrictVerifier() p = MockPlugin(v) @@ -1502,7 +1502,7 @@ def test_mock_plugin_format_assert_hint_includes_args_and_kwargs() -> None: ) result = p.format_assert_hint(interaction) assert result == ( - 'bigfoot.mock("Logger").log.assert_call(\n' + 'tripwire.mock("Logger").log.assert_call(\n' " args=('event',),\n" " kwargs={'level': 'info'},\n" ")" @@ -1726,7 +1726,7 @@ def test_mock_plugin_assertable_fields_plain_call_unchanged() -> None: def test_assert_call_with_raised_parameter() -> None: """assert_call(raised=...) passes raised to assert_interaction.""" - from bigfoot._context import _current_test_verifier + from tripwire._context import _current_test_verifier v = StrictVerifier() p = MockPlugin(v) @@ -1751,7 +1751,7 @@ def test_assert_call_with_raised_parameter() -> None: def test_assert_call_with_returned_parameter() -> None: """assert_call(returned=...) passes returned to assert_interaction for spy mode.""" - from bigfoot._context import _current_test_verifier + from tripwire._context import _current_test_verifier class _Real: def compute(self, x: int) -> int: @@ -1788,8 +1788,8 @@ def test_assert_call_missing_raised_raises_missing_fields_error() -> None: certainty contract enforcement is in assertable_fields, and assert_call is just a convenience wrapper that constructs the expected dict. """ - from bigfoot._context import _current_test_verifier - from bigfoot._errors import MissingAssertionFieldsError + from tripwire._context import _current_test_verifier + from tripwire._errors import MissingAssertionFieldsError v = StrictVerifier() p = MockPlugin(v) @@ -1838,7 +1838,7 @@ def test_format_assert_hint_includes_raised_when_present() -> None: ) result = p.format_assert_hint(interaction) assert result == ( - 'bigfoot.mock("Svc").method.assert_call(\n' + 'tripwire.mock("Svc").method.assert_call(\n' " args=('a',),\n" " kwargs={},\n" f" raised={exc!r},\n" @@ -1865,7 +1865,7 @@ def test_format_assert_hint_includes_returned_when_present() -> None: ) result = p.format_assert_hint(interaction) assert result == ( - 'bigfoot.mock("Svc").method.assert_call(\n' + 'tripwire.mock("Svc").method.assert_call(\n' " args=(),\n" " kwargs={},\n" " returned={'data': 'value'},\n" @@ -1891,7 +1891,7 @@ def test_format_assert_hint_plain_call_unchanged() -> None: ) result = p.format_assert_hint(interaction) assert result == ( - 'bigfoot.mock("Svc").method.assert_call(\n' + 'tripwire.mock("Svc").method.assert_call(\n' " args=(),\n" " kwargs={},\n" ")" @@ -1917,4 +1917,4 @@ def test_format_mock_hint_includes_raises_when_raised_in_details() -> None: plugin=p, ) result = p.format_mock_hint(interaction) - assert result == f'bigfoot.mock("Svc").method.raises({exc!r})' + assert result == f'tripwire.mock("Svc").method.raises({exc!r})' diff --git a/tests/unit/test_mongo_plugin.py b/tests/unit/test_mongo_plugin.py index c5f0975..7d6239e 100644 --- a/tests/unit/test_mongo_plugin.py +++ b/tests/unit/test_mongo_plugin.py @@ -7,14 +7,14 @@ import pymongo import pytest -from bigfoot._context import _current_test_verifier -from bigfoot._errors import ( +from tripwire._context import _current_test_verifier +from tripwire._errors import ( InteractionMismatchError, MissingAssertionFieldsError, UnmockedInteractionError, ) -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.mongo_plugin import ( +from tripwire._verifier import StrictVerifier +from tripwire.plugins.mongo_plugin import ( _PYMONGO_AVAILABLE, MongoMockConfig, MongoPlugin, @@ -96,14 +96,14 @@ def test_pymongo_available_flag() -> None: # MUTATION: Not checking the flag and proceeding normally would not raise. # ESCAPE: Raising ImportError with a different message fails the exact string check. def test_activate_raises_when_pymongo_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: - import bigfoot.plugins.mongo_plugin as _mp + import tripwire.plugins.mongo_plugin as _mp v, p = _make_verifier_with_plugin() monkeypatch.setattr(_mp, "_PYMONGO_AVAILABLE", False) with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install bigfoot[mongo] to use MongoPlugin: pip install bigfoot[mongo]" + "Install tripwire[mongo] to use MongoPlugin: pip install tripwire[mongo]" ) @@ -147,7 +147,7 @@ def test_mongo_mock_config_defaults() -> None: # ESCAPE: test_activate_installs_patch -# CLAIM: After activate(), pymongo.collection.Collection.find is replaced with bigfoot interceptor. +# CLAIM: After activate(), pymongo.collection.Collection.find is replaced with tripwire interceptor. # PATH: activate() -> _install_count == 0 -> store originals -> install interceptors. # CHECK: pymongo.collection.Collection.find is not the original after activate(). # MUTATION: Skipping patch installation leaves original in place; identity check fails. @@ -164,7 +164,7 @@ def test_activate_installs_patch() -> None: # CLAIM: After activate() then deactivate(), pymongo.collection.Collection.find is restored. # PATH: deactivate() -> _install_count reaches 0 -> restore originals. # CHECK: pymongo.collection.Collection.find is the original after deactivate(). -# MUTATION: Not restoring in deactivate() leaves bigfoot's interceptor in place. +# MUTATION: Not restoring in deactivate() leaves tripwire's interceptor in place. # ESCAPE: Nothing reasonable -- identity comparison against saved original. def test_deactivate_restores_patch() -> None: original_find = pymongo.collection.Collection.find @@ -393,7 +393,7 @@ def test_unmocked_error_after_queue_exhausted() -> None: # MUTATION: Returning frozenset() skips completeness enforcement entirely. # ESCAPE: Nothing reasonable -- exact equality. def test_assertable_fields_returns_all_detail_keys() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -424,7 +424,7 @@ def test_assertable_fields_returns_all_detail_keys() -> None: # MUTATION: Returning True always fails the non-matching field check. # ESCAPE: Nothing reasonable -- exact boolean equality on distinct cases. def test_matches_field_comparison() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -457,7 +457,7 @@ def test_matches_field_comparison() -> None: # MUTATION: Returning wrong format string fails equality check. # ESCAPE: Different order or missing fields fails equality. def test_format_interaction() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -483,7 +483,7 @@ def test_format_interaction() -> None: # MUTATION: Returning wrong format fails equality. # ESCAPE: Different format fails equality. def test_format_interaction_insert_one() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -508,7 +508,7 @@ def test_format_interaction_insert_one() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Different format fails the equality check. def test_format_mock_hint() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -518,7 +518,7 @@ def test_format_mock_hint() -> None: plugin=p, ) result = p.format_mock_hint(interaction) - assert result == " bigfoot.mongo_mock.mock_operation('find_one', returns=...)" + assert result == " tripwire.mongo_mock.mock_operation('find_one', returns=...)" # ESCAPE: test_format_unmocked_hint @@ -533,7 +533,7 @@ def test_format_unmocked_hint() -> None: assert result == ( "mongo.find_one(...) was called but no mock was registered.\n" "Register a mock with:\n" - " bigfoot.mongo_mock.mock_operation('find_one', returns=...)" + " tripwire.mongo_mock.mock_operation('find_one', returns=...)" ) @@ -544,7 +544,7 @@ def test_format_unmocked_hint() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Different format fails the equality check. def test_format_assert_hint() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -561,7 +561,7 @@ def test_format_assert_hint() -> None: ) result = p.format_assert_hint(interaction) assert result == ( - " bigfoot.mongo_mock.assert_find_one(\n" + " tripwire.mongo_mock.assert_find_one(\n" " database='testdb',\n" " collection='testcoll',\n" " filter={'_id': 1},\n" @@ -577,7 +577,7 @@ def test_format_assert_hint() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Different format fails the equality check. def test_format_assert_hint_insert_one() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -593,7 +593,7 @@ def test_format_assert_hint_insert_one() -> None: ) result = p.format_assert_hint(interaction) assert result == ( - " bigfoot.mongo_mock.assert_insert_one(\n" + " tripwire.mongo_mock.assert_insert_one(\n" " database='testdb',\n" " collection='testcoll',\n" " document={'name': 'Alice'},\n" @@ -628,15 +628,15 @@ def test_format_unused_mock_hint() -> None: # CHECK: No error raised when fields match. # MUTATION: Wrong field mapping in assert_find raises InteractionMismatchError. # ESCAPE: Nothing reasonable -- exact field matching. -def test_assert_find_typed_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_find_typed_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.mongo_mock.mock_operation("find", returns=[{"x": 1}]) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("find", returns=[{"x": 1}]) + with tripwire.sandbox(): coll = _make_collection("mydb", "users") coll.find({"active": True}, {"name": 1}) - bigfoot.mongo_mock.assert_find( + tripwire.mongo_mock.assert_find( database="mydb", collection="users", filter={"active": True}, @@ -650,15 +650,15 @@ def test_assert_find_typed_helper(bigfoot_verifier: StrictVerifier) -> None: # CHECK: No error raised when fields match. # MUTATION: Wrong field mapping raises InteractionMismatchError. # ESCAPE: Nothing reasonable. -def test_assert_find_one_typed_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_find_one_typed_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.mongo_mock.mock_operation("find_one", returns={"_id": 1}) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("find_one", returns={"_id": 1}) + with tripwire.sandbox(): coll = _make_collection("mydb", "users") coll.find_one({"_id": 1}, {"name": 1}) - bigfoot.mongo_mock.assert_find_one( + tripwire.mongo_mock.assert_find_one( database="mydb", collection="users", filter={"_id": 1}, @@ -672,15 +672,15 @@ def test_assert_find_one_typed_helper(bigfoot_verifier: StrictVerifier) -> None: # CHECK: No error raised when fields match. # MUTATION: Wrong field mapping raises InteractionMismatchError. # ESCAPE: Nothing reasonable. -def test_assert_insert_one_typed_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_insert_one_typed_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.mongo_mock.mock_operation("insert_one", returns=MagicMock(inserted_id="abc")) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("insert_one", returns=MagicMock(inserted_id="abc")) + with tripwire.sandbox(): coll = _make_collection("mydb", "users") coll.insert_one({"name": "Alice"}) - bigfoot.mongo_mock.assert_insert_one( + tripwire.mongo_mock.assert_insert_one( database="mydb", collection="users", document={"name": "Alice"}, @@ -693,15 +693,15 @@ def test_assert_insert_one_typed_helper(bigfoot_verifier: StrictVerifier) -> Non # CHECK: No error raised when fields match. # MUTATION: Wrong field mapping raises InteractionMismatchError. # ESCAPE: Nothing reasonable. -def test_assert_update_one_typed_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_update_one_typed_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.mongo_mock.mock_operation("update_one", returns=MagicMock(modified_count=1)) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("update_one", returns=MagicMock(modified_count=1)) + with tripwire.sandbox(): coll = _make_collection("mydb", "users") coll.update_one({"_id": 1}, {"$set": {"name": "Bob"}}) - bigfoot.mongo_mock.assert_update_one( + tripwire.mongo_mock.assert_update_one( database="mydb", collection="users", filter={"_id": 1}, @@ -715,15 +715,15 @@ def test_assert_update_one_typed_helper(bigfoot_verifier: StrictVerifier) -> Non # CHECK: No error raised when fields match. # MUTATION: Wrong field mapping raises InteractionMismatchError. # ESCAPE: Nothing reasonable. -def test_assert_delete_one_typed_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_delete_one_typed_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.mongo_mock.mock_operation("delete_one", returns=MagicMock(deleted_count=1)) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("delete_one", returns=MagicMock(deleted_count=1)) + with tripwire.sandbox(): coll = _make_collection("mydb", "users") coll.delete_one({"_id": 1}) - bigfoot.mongo_mock.assert_delete_one( + tripwire.mongo_mock.assert_delete_one( database="mydb", collection="users", filter={"_id": 1}, @@ -736,16 +736,16 @@ def test_assert_delete_one_typed_helper(bigfoot_verifier: StrictVerifier) -> Non # CHECK: No error raised when fields match. # MUTATION: Wrong field mapping raises InteractionMismatchError. # ESCAPE: Nothing reasonable. -def test_assert_aggregate_typed_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_aggregate_typed_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire pipeline = [{"$match": {"active": True}}, {"$group": {"_id": "$type"}}] - bigfoot.mongo_mock.mock_operation("aggregate", returns=[{"_id": "A"}]) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("aggregate", returns=[{"_id": "A"}]) + with tripwire.sandbox(): coll = _make_collection("mydb", "users") coll.aggregate(pipeline) - bigfoot.mongo_mock.assert_aggregate( + tripwire.mongo_mock.assert_aggregate( database="mydb", collection="users", pipeline=pipeline, @@ -758,15 +758,15 @@ def test_assert_aggregate_typed_helper(bigfoot_verifier: StrictVerifier) -> None # CHECK: No error raised when fields match. # MUTATION: Wrong field mapping in assert_insert_many raises InteractionMismatchError. # ESCAPE: Nothing reasonable -- exact field matching. -def test_assert_insert_many_typed_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_insert_many_typed_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.mongo_mock.mock_operation("insert_many", returns=MagicMock(inserted_ids=["a", "b"])) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("insert_many", returns=MagicMock(inserted_ids=["a", "b"])) + with tripwire.sandbox(): coll = _make_collection("mydb", "items") coll.insert_many([{"x": 1}, {"x": 2}]) - bigfoot.mongo_mock.assert_insert_many( + tripwire.mongo_mock.assert_insert_many( database="mydb", collection="items", documents=[{"x": 1}, {"x": 2}], @@ -779,15 +779,15 @@ def test_assert_insert_many_typed_helper(bigfoot_verifier: StrictVerifier) -> No # CHECK: No error raised when fields match. # MUTATION: Wrong field mapping in assert_update_many raises InteractionMismatchError. # ESCAPE: Nothing reasonable -- exact field matching. -def test_assert_update_many_typed_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_update_many_typed_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.mongo_mock.mock_operation("update_many", returns=MagicMock(modified_count=5)) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("update_many", returns=MagicMock(modified_count=5)) + with tripwire.sandbox(): coll = _make_collection("mydb", "items") coll.update_many({"status": "old"}, {"$set": {"status": "new"}}) - bigfoot.mongo_mock.assert_update_many( + tripwire.mongo_mock.assert_update_many( database="mydb", collection="items", filter={"status": "old"}, @@ -801,15 +801,15 @@ def test_assert_update_many_typed_helper(bigfoot_verifier: StrictVerifier) -> No # CHECK: No error raised when fields match. # MUTATION: Wrong field mapping in assert_delete_many raises InteractionMismatchError. # ESCAPE: Nothing reasonable -- exact field matching. -def test_assert_delete_many_typed_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_delete_many_typed_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.mongo_mock.mock_operation("delete_many", returns=MagicMock(deleted_count=3)) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("delete_many", returns=MagicMock(deleted_count=3)) + with tripwire.sandbox(): coll = _make_collection("mydb", "items") coll.delete_many({"status": "old"}) - bigfoot.mongo_mock.assert_delete_many( + tripwire.mongo_mock.assert_delete_many( database="mydb", collection="items", filter={"status": "old"}, @@ -822,15 +822,15 @@ def test_assert_delete_many_typed_helper(bigfoot_verifier: StrictVerifier) -> No # CHECK: No error raised when fields match. # MUTATION: Wrong field mapping in assert_count_documents raises InteractionMismatchError. # ESCAPE: Nothing reasonable -- exact field matching. -def test_assert_count_documents_typed_helper(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_count_documents_typed_helper(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.mongo_mock.mock_operation("count_documents", returns=42) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("count_documents", returns=42) + with tripwire.sandbox(): coll = _make_collection("mydb", "items") coll.count_documents({"active": True}) - bigfoot.mongo_mock.assert_count_documents( + tripwire.mongo_mock.assert_count_documents( database="mydb", collection="items", filter={"active": True}, @@ -848,23 +848,23 @@ def test_assert_count_documents_typed_helper(bigfoot_verifier: StrictVerifier) - # CHECK: InteractionMismatchError raised. # MUTATION: Not checking fields means no error raised. # ESCAPE: Nothing reasonable -- exception proves mismatch detection. -def test_assert_find_one_wrong_filter_raises(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_find_one_wrong_filter_raises(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.mongo_mock.mock_operation("find_one", returns={"x": 1}) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("find_one", returns={"x": 1}) + with tripwire.sandbox(): coll = _make_collection("mydb", "users") coll.find_one({"_id": 1}) with pytest.raises(InteractionMismatchError): - bigfoot.mongo_mock.assert_find_one( + tripwire.mongo_mock.assert_find_one( database="mydb", collection="users", filter={"_id": 999}, projection=None, ) # Now assert correctly so teardown passes - bigfoot.mongo_mock.assert_find_one( + tripwire.mongo_mock.assert_find_one( database="mydb", collection="users", filter={"_id": 1}, @@ -883,20 +883,20 @@ def test_assert_find_one_wrong_filter_raises(bigfoot_verifier: StrictVerifier) - # CHECK: timeline.all_unasserted() contains the interaction. # MUTATION: Auto-asserting in the interceptor means all_unasserted() would be empty. # ESCAPE: Nothing reasonable -- exact check on unasserted list. -def test_mongo_interactions_not_auto_asserted(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_mongo_interactions_not_auto_asserted(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.mongo_mock.mock_operation("find_one", returns={"x": 1}) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("find_one", returns={"x": 1}) + with tripwire.sandbox(): coll = _make_collection("mydb", "users") coll.find_one({"_id": 1}) - timeline = bigfoot_verifier._timeline + timeline = tripwire_verifier._timeline interactions = timeline.all_unasserted() assert len(interactions) == 1 assert interactions[0].source_id == "mongo:find_one" # Assert it so verify_all() at teardown succeeds - bigfoot.mongo_mock.assert_find_one( + tripwire.mongo_mock.assert_find_one( database="mydb", collection="users", filter={"_id": 1}, @@ -905,27 +905,27 @@ def test_mongo_interactions_not_auto_asserted(bigfoot_verifier: StrictVerifier) # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.mongo_mock +# Module-level proxy: tripwire.mongo_mock # --------------------------------------------------------------------------- # ESCAPE: test_mongo_mock_proxy_mock_operation -# CLAIM: bigfoot.mongo_mock.mock_operation("find_one", returns=...) works when verifier active. +# CLAIM: tripwire.mongo_mock.mock_operation("find_one", returns=...) works when verifier active. # PATH: _MongoProxy.__getattr__("mock_operation") -> get verifier -> # find/create MongoPlugin -> return plugin.mock_operation. # CHECK: The proxy call does not raise and the mock is registered. # MUTATION: Returning None instead of the plugin fails with AttributeError. # ESCAPE: Nothing reasonable -- call succeeds or raises. -def test_mongo_mock_proxy_mock_operation(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_mongo_mock_proxy_mock_operation(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.mongo_mock.mock_operation("find_one", returns={"proxy": True}) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("find_one", returns={"proxy": True}) + with tripwire.sandbox(): coll = _make_collection("proxydb", "proxycoll") result = coll.find_one({"k": 1}) assert result == {"proxy": True} - bigfoot.mongo_mock.assert_find_one( + tripwire.mongo_mock.assert_find_one( database="proxydb", collection="proxycoll", filter={"k": 1}, @@ -934,19 +934,19 @@ def test_mongo_mock_proxy_mock_operation(bigfoot_verifier: StrictVerifier) -> No # ESCAPE: test_mongo_mock_proxy_raises_outside_context -# CLAIM: Accessing bigfoot.mongo_mock outside a test context raises NoActiveVerifierError. +# CLAIM: Accessing tripwire.mongo_mock outside a test context raises NoActiveVerifierError. # PATH: _MongoProxy.__getattr__ -> _get_test_verifier_or_raise -> NoActiveVerifierError. # CHECK: NoActiveVerifierError raised. # MUTATION: Silently returning None would not raise. # ESCAPE: Nothing reasonable -- exact exception type. def test_mongo_mock_proxy_raises_outside_context() -> None: - import bigfoot - from bigfoot._errors import NoActiveVerifierError + import tripwire + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.mongo_mock.mock_operation + _ = tripwire.mongo_mock.mock_operation finally: _current_test_verifier.reset(token) @@ -957,16 +957,16 @@ def test_mongo_mock_proxy_raises_outside_context() -> None: # ESCAPE: test_mongo_plugin_in_all -# CLAIM: MongoPlugin and mongo_mock are exported from bigfoot.__all__. -# PATH: bigfoot.__all__ contains "MongoPlugin" and "mongo_mock". -# CHECK: "MongoPlugin" in bigfoot.__all__; "mongo_mock" in bigfoot.__all__. +# CLAIM: MongoPlugin and mongo_mock are exported from tripwire.__all__. +# PATH: tripwire.__all__ contains "MongoPlugin" and "mongo_mock". +# CHECK: "MongoPlugin" in tripwire.__all__; "mongo_mock" in tripwire.__all__. # MUTATION: Omitting either from __all__ fails the membership check. # ESCAPE: Nothing reasonable -- exact membership check. def test_mongo_plugin_in_all() -> None: - import bigfoot + import tripwire - assert "MongoPlugin" in bigfoot.__all__ - assert "mongo_mock" in bigfoot.__all__ + assert "MongoPlugin" in tripwire.__all__ + assert "mongo_mock" in tripwire.__all__ # --------------------------------------------------------------------------- @@ -980,18 +980,18 @@ def test_mongo_plugin_in_all() -> None: # CHECK: MissingAssertionFieldsError raised with correct missing_fields. # MUTATION: Returning frozenset() from assertable_fields would never raise. # ESCAPE: Nothing reasonable -- exact exception type and field check. -def test_missing_fields_raises_error(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot - from bigfoot.plugins.mongo_plugin import _MongoSentinel +def test_missing_fields_raises_error(tripwire_verifier: StrictVerifier) -> None: + import tripwire + from tripwire.plugins.mongo_plugin import _MongoSentinel - bigfoot.mongo_mock.mock_operation("find_one", returns={"x": 1}) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("find_one", returns={"x": 1}) + with tripwire.sandbox(): coll = _make_collection("mydb", "users") coll.find_one({"_id": 1}) sentinel = _MongoSentinel("mongo:find_one") with pytest.raises(MissingAssertionFieldsError) as exc_info: - bigfoot_verifier.assert_interaction( + tripwire_verifier.assert_interaction( sentinel, database="mydb", # Missing: collection, operation, filter, projection @@ -1001,7 +1001,7 @@ def test_missing_fields_raises_error(bigfoot_verifier: StrictVerifier) -> None: assert "filter" in exc_info.value.missing_fields # Now assert correctly so teardown passes - bigfoot.mongo_mock.assert_find_one( + tripwire.mongo_mock.assert_find_one( database="mydb", collection="users", filter={"_id": 1}, @@ -1132,16 +1132,16 @@ def test_aggregate_interception() -> None: # CHECK: All detail fields match expected values. # MUTATION: Missing a field in details fails the assertable_fields check at assertion time. # ESCAPE: Nothing reasonable -- typed helper covers all fields. -def test_find_one_records_correct_details(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_find_one_records_correct_details(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.mongo_mock.mock_operation("find_one", returns={"x": 1}) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("find_one", returns={"x": 1}) + with tripwire.sandbox(): coll = _make_collection("recdb", "reccoll") coll.find_one({"_id": 1}, {"name": 1}) # This asserts ALL required fields - bigfoot.mongo_mock.assert_find_one( + tripwire.mongo_mock.assert_find_one( database="recdb", collection="reccoll", filter={"_id": 1}, @@ -1155,15 +1155,15 @@ def test_find_one_records_correct_details(bigfoot_verifier: StrictVerifier) -> N # CHECK: Typed helper asserts all fields. # MUTATION: Missing a field fails assertable_fields check. # ESCAPE: Nothing reasonable. -def test_insert_one_records_correct_details(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_insert_one_records_correct_details(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.mongo_mock.mock_operation("insert_one", returns=MagicMock(inserted_id="abc")) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("insert_one", returns=MagicMock(inserted_id="abc")) + with tripwire.sandbox(): coll = _make_collection("recdb", "reccoll") coll.insert_one({"name": "Alice", "age": 30}) - bigfoot.mongo_mock.assert_insert_one( + tripwire.mongo_mock.assert_insert_one( database="recdb", collection="reccoll", document={"name": "Alice", "age": 30}, @@ -1176,15 +1176,15 @@ def test_insert_one_records_correct_details(bigfoot_verifier: StrictVerifier) -> # CHECK: Typed helper asserts all fields. # MUTATION: Missing a field fails assertable_fields check. # ESCAPE: Nothing reasonable. -def test_update_one_records_correct_details(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_update_one_records_correct_details(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.mongo_mock.mock_operation("update_one", returns=MagicMock(modified_count=1)) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("update_one", returns=MagicMock(modified_count=1)) + with tripwire.sandbox(): coll = _make_collection("recdb", "reccoll") coll.update_one({"_id": 1}, {"$set": {"name": "Bob"}}) - bigfoot.mongo_mock.assert_update_one( + tripwire.mongo_mock.assert_update_one( database="recdb", collection="reccoll", filter={"_id": 1}, @@ -1198,15 +1198,15 @@ def test_update_one_records_correct_details(bigfoot_verifier: StrictVerifier) -> # CHECK: Typed helper asserts all fields. # MUTATION: Missing a field fails assertable_fields check. # ESCAPE: Nothing reasonable. -def test_delete_one_records_correct_details(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_delete_one_records_correct_details(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.mongo_mock.mock_operation("delete_one", returns=MagicMock(deleted_count=1)) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("delete_one", returns=MagicMock(deleted_count=1)) + with tripwire.sandbox(): coll = _make_collection("recdb", "reccoll") coll.delete_one({"_id": 1}) - bigfoot.mongo_mock.assert_delete_one( + tripwire.mongo_mock.assert_delete_one( database="recdb", collection="reccoll", filter={"_id": 1}, @@ -1219,16 +1219,16 @@ def test_delete_one_records_correct_details(bigfoot_verifier: StrictVerifier) -> # CHECK: Typed helper asserts all fields. # MUTATION: Missing a field fails assertable_fields check. # ESCAPE: Nothing reasonable. -def test_aggregate_records_correct_details(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_aggregate_records_correct_details(tripwire_verifier: StrictVerifier) -> None: + import tripwire pipeline = [{"$match": {"active": True}}] - bigfoot.mongo_mock.mock_operation("aggregate", returns=[]) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("aggregate", returns=[]) + with tripwire.sandbox(): coll = _make_collection("recdb", "reccoll") coll.aggregate(pipeline) - bigfoot.mongo_mock.assert_aggregate( + tripwire.mongo_mock.assert_aggregate( database="recdb", collection="reccoll", pipeline=pipeline, @@ -1241,17 +1241,17 @@ def test_aggregate_records_correct_details(bigfoot_verifier: StrictVerifier) -> # CHECK: All detail fields verified via assert_interaction. # MUTATION: Missing a field fails assertable_fields check. # ESCAPE: Nothing reasonable. -def test_count_documents_records_correct_details(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot - from bigfoot.plugins.mongo_plugin import _MongoSentinel +def test_count_documents_records_correct_details(tripwire_verifier: StrictVerifier) -> None: + import tripwire + from tripwire.plugins.mongo_plugin import _MongoSentinel - bigfoot.mongo_mock.mock_operation("count_documents", returns=42) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("count_documents", returns=42) + with tripwire.sandbox(): coll = _make_collection("recdb", "reccoll") coll.count_documents({"active": True}) sentinel = _MongoSentinel("mongo:count_documents") - bigfoot_verifier.assert_interaction( + tripwire_verifier.assert_interaction( sentinel, database="recdb", collection="reccoll", @@ -1266,16 +1266,16 @@ def test_count_documents_records_correct_details(bigfoot_verifier: StrictVerifie # CHECK: Typed helper asserts all fields. # MUTATION: Missing a field fails assertable_fields check. # ESCAPE: Nothing reasonable. -def test_insert_many_records_correct_details(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_insert_many_records_correct_details(tripwire_verifier: StrictVerifier) -> None: + import tripwire docs = [{"x": 1}, {"x": 2}] - bigfoot.mongo_mock.mock_operation("insert_many", returns=MagicMock(inserted_ids=["a", "b"])) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("insert_many", returns=MagicMock(inserted_ids=["a", "b"])) + with tripwire.sandbox(): coll = _make_collection("recdb", "reccoll") coll.insert_many(docs) - bigfoot.mongo_mock.assert_insert_many( + tripwire.mongo_mock.assert_insert_many( database="recdb", collection="reccoll", documents=docs, @@ -1288,15 +1288,15 @@ def test_insert_many_records_correct_details(bigfoot_verifier: StrictVerifier) - # CHECK: Typed helper asserts all fields. # MUTATION: Missing a field fails assertable_fields check. # ESCAPE: Nothing reasonable. -def test_update_many_records_correct_details(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_update_many_records_correct_details(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.mongo_mock.mock_operation("update_many", returns=MagicMock(modified_count=5)) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("update_many", returns=MagicMock(modified_count=5)) + with tripwire.sandbox(): coll = _make_collection("recdb", "reccoll") coll.update_many({"status": "old"}, {"$set": {"status": "new"}}) - bigfoot.mongo_mock.assert_update_many( + tripwire.mongo_mock.assert_update_many( database="recdb", collection="reccoll", filter={"status": "old"}, @@ -1310,15 +1310,15 @@ def test_update_many_records_correct_details(bigfoot_verifier: StrictVerifier) - # CHECK: Typed helper asserts all fields. # MUTATION: Missing a field fails assertable_fields check. # ESCAPE: Nothing reasonable. -def test_delete_many_records_correct_details(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_delete_many_records_correct_details(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.mongo_mock.mock_operation("delete_many", returns=MagicMock(deleted_count=3)) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("delete_many", returns=MagicMock(deleted_count=3)) + with tripwire.sandbox(): coll = _make_collection("recdb", "reccoll") coll.delete_many({"status": "old"}) - bigfoot.mongo_mock.assert_delete_many( + tripwire.mongo_mock.assert_delete_many( database="recdb", collection="reccoll", filter={"status": "old"}, @@ -1336,15 +1336,15 @@ def test_delete_many_records_correct_details(bigfoot_verifier: StrictVerifier) - # CHECK: Typed helper asserts all fields match kwargs values. # MUTATION: Broken kwargs.get() records None instead of actual values. # ESCAPE: Nothing reasonable -- exact field matching. -def test_find_one_kwargs_extraction(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_find_one_kwargs_extraction(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.mongo_mock.mock_operation("find_one", returns={"x": 1}) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("find_one", returns={"x": 1}) + with tripwire.sandbox(): coll = _make_collection("mydb", "users") coll.find_one(filter={"_id": 1}, projection={"name": 1}) - bigfoot.mongo_mock.assert_find_one( + tripwire.mongo_mock.assert_find_one( database="mydb", collection="users", filter={"_id": 1}, @@ -1358,15 +1358,15 @@ def test_find_one_kwargs_extraction(bigfoot_verifier: StrictVerifier) -> None: # CHECK: Typed helper asserts all fields match kwargs values. # MUTATION: Broken kwargs.get() records None instead of actual document. # ESCAPE: Nothing reasonable -- exact field matching. -def test_insert_one_kwargs_extraction(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_insert_one_kwargs_extraction(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.mongo_mock.mock_operation("insert_one", returns=MagicMock(inserted_id="abc")) - with bigfoot.sandbox(): + tripwire.mongo_mock.mock_operation("insert_one", returns=MagicMock(inserted_id="abc")) + with tripwire.sandbox(): coll = _make_collection("mydb", "users") coll.insert_one(document={"name": "Alice"}) - bigfoot.mongo_mock.assert_insert_one( + tripwire.mongo_mock.assert_insert_one( database="mydb", collection="users", document={"name": "Alice"}, diff --git a/tests/unit/test_native_plugin.py b/tests/unit/test_native_plugin.py index f737b29..e7ac284 100644 --- a/tests/unit/test_native_plugin.py +++ b/tests/unit/test_native_plugin.py @@ -7,15 +7,15 @@ import pytest -from bigfoot._context import _current_test_verifier -from bigfoot._errors import ( +from tripwire._context import _current_test_verifier +from tripwire._errors import ( InteractionMismatchError, MissingAssertionFieldsError, UnmockedInteractionError, ) -from bigfoot._timeline import Interaction -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.native_plugin import ( +from tripwire._timeline import Interaction +from tripwire._verifier import StrictVerifier +from tripwire.plugins.native_plugin import ( CdllProxy, NativeMockConfig, NativePlugin, @@ -181,18 +181,18 @@ def test_unused_mock_excludes_required_false() -> None: # CHECK: MissingAssertionFieldsError raised; missing_fields == frozenset({"args"}). # MUTATION: Returning frozenset() from assertable_fields skips the check entirely. # ESCAPE: Nothing reasonable -- exact frozenset equality on missing_fields. -def test_missing_fields_error(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_missing_fields_error(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.native_mock.mock_call("libm", "sqrt", returns=6.48) - with bigfoot.sandbox(): + tripwire.native_mock.mock_call("libm", "sqrt", returns=6.48) + with tripwire.sandbox(): lib = ctypes.CDLL("libm") lib.sqrt(42) # Use assert_interaction directly without 'args' to trigger missing fields sentinel = _NativeSentinel("native:libm:sqrt") with pytest.raises(MissingAssertionFieldsError) as exc_info: - bigfoot_verifier.assert_interaction( + tripwire_verifier.assert_interaction( sentinel, library="libm", function="sqrt", @@ -201,7 +201,7 @@ def test_missing_fields_error(bigfoot_verifier: StrictVerifier) -> None: assert exc_info.value.missing_fields == frozenset({"args"}) # Assert correctly for teardown - bigfoot.native_mock.assert_call("libm", "sqrt", args=(42,)) + tripwire.native_mock.assert_call("libm", "sqrt", args=(42,)) # --------------------------------------------------------------------------- @@ -215,15 +215,15 @@ def test_missing_fields_error(bigfoot_verifier: StrictVerifier) -> None: # CHECK: No exception raised (test passes cleanly). # MUTATION: Wrong source_id generation in assert_call would cause InteractionMismatchError. # ESCAPE: Nothing reasonable -- test either passes or raises. -def test_assert_call_typed_helper_positive(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_call_typed_helper_positive(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.native_mock.mock_call("libm", "sqrt", returns=6.48) - with bigfoot.sandbox(): + tripwire.native_mock.mock_call("libm", "sqrt", returns=6.48) + with tripwire.sandbox(): lib = ctypes.CDLL("libm") lib.sqrt(42) - bigfoot.native_mock.assert_call("libm", "sqrt", args=(42,)) + tripwire.native_mock.assert_call("libm", "sqrt", args=(42,)) # ESCAPE: test_assert_call_typed_helper_negative_wrong_args @@ -232,19 +232,19 @@ def test_assert_call_typed_helper_positive(bigfoot_verifier: StrictVerifier) -> # CHECK: InteractionMismatchError raised. # MUTATION: Skipping field comparison would pass with wrong args. # ESCAPE: Nothing reasonable -- exact exception type check. -def test_assert_call_typed_helper_negative_wrong_args(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_call_typed_helper_negative_wrong_args(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.native_mock.mock_call("libm", "sqrt", returns=6.48) - with bigfoot.sandbox(): + tripwire.native_mock.mock_call("libm", "sqrt", returns=6.48) + with tripwire.sandbox(): lib = ctypes.CDLL("libm") lib.sqrt(42) with pytest.raises(InteractionMismatchError): - bigfoot.native_mock.assert_call("libm", "sqrt", args=(999,)) + tripwire.native_mock.assert_call("libm", "sqrt", args=(999,)) # Assert correctly for teardown - bigfoot.native_mock.assert_call("libm", "sqrt", args=(42,)) + tripwire.native_mock.assert_call("libm", "sqrt", args=(42,)) # ESCAPE: test_assert_call_typed_helper_negative_wrong_function @@ -253,19 +253,19 @@ def test_assert_call_typed_helper_negative_wrong_args(bigfoot_verifier: StrictVe # CHECK: InteractionMismatchError raised. # MUTATION: Not comparing source_id would pass with wrong function. # ESCAPE: Nothing reasonable -- exact exception type check. -def test_assert_call_typed_helper_negative_wrong_function(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_call_typed_helper_negative_wrong_function(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.native_mock.mock_call("libm", "sqrt", returns=6.48) - with bigfoot.sandbox(): + tripwire.native_mock.mock_call("libm", "sqrt", returns=6.48) + with tripwire.sandbox(): lib = ctypes.CDLL("libm") lib.sqrt(42) with pytest.raises(InteractionMismatchError): - bigfoot.native_mock.assert_call("libm", "cos", args=(42,)) + tripwire.native_mock.assert_call("libm", "cos", args=(42,)) # Assert correctly for teardown - bigfoot.native_mock.assert_call("libm", "sqrt", args=(42,)) + tripwire.native_mock.assert_call("libm", "sqrt", args=(42,)) # ESCAPE: test_assert_call_typed_helper_negative_wrong_library @@ -274,19 +274,19 @@ def test_assert_call_typed_helper_negative_wrong_function(bigfoot_verifier: Stri # CHECK: InteractionMismatchError raised. # MUTATION: Not comparing library field would pass with wrong library. # ESCAPE: Nothing reasonable -- exact exception type check. -def test_assert_call_typed_helper_negative_wrong_library(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_assert_call_typed_helper_negative_wrong_library(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.native_mock.mock_call("libm", "sqrt", returns=6.48) - with bigfoot.sandbox(): + tripwire.native_mock.mock_call("libm", "sqrt", returns=6.48) + with tripwire.sandbox(): lib = ctypes.CDLL("libm") lib.sqrt(42) with pytest.raises(InteractionMismatchError): - bigfoot.native_mock.assert_call("libz", "sqrt", args=(42,)) + tripwire.native_mock.assert_call("libz", "sqrt", args=(42,)) # Assert correctly for teardown - bigfoot.native_mock.assert_call("libm", "sqrt", args=(42,)) + tripwire.native_mock.assert_call("libm", "sqrt", args=(42,)) # --------------------------------------------------------------------------- @@ -302,7 +302,7 @@ def test_assert_call_typed_helper_negative_wrong_library(bigfoot_verifier: Stric # MUTATION: Skipping conflict check allows double-patching silently. # ESCAPE: Nothing reasonable -- exact exception type. def test_activate_detects_conflict(monkeypatch: pytest.MonkeyPatch) -> None: - from bigfoot._errors import ConflictError + from tripwire._errors import ConflictError v, p = _make_verifier_with_plugin() @@ -356,7 +356,7 @@ def test_exception_propagation() -> None: # MUTATION: Requiring cffi unconditionally would raise ImportError. # ESCAPE: Nothing reasonable -- test passes or raises ImportError. def test_graceful_degradation_cffi_not_installed(monkeypatch: pytest.MonkeyPatch) -> None: - import bigfoot.plugins.native_plugin as np_mod + import tripwire.plugins.native_plugin as np_mod monkeypatch.setattr(np_mod, "_CFFI_AVAILABLE", False) @@ -522,7 +522,7 @@ def test_serialize_arg_plain_python_passthrough() -> None: def test_cffi_abi_mode_dlopen_returns_proxy() -> None: import cffi # noqa: I001 - from bigfoot.plugins.native_plugin import CffiProxy + from tripwire.plugins.native_plugin import CffiProxy v, p = _make_verifier_with_plugin() p.mock_call("libm", "sqrt", returns=6.48) @@ -551,7 +551,7 @@ def test_cffi_abi_mode_dlopen_returns_proxy() -> None: # MUTATION: Setting True would include it in default set; is False check fails. # ESCAPE: Nothing reasonable -- exact boolean equality. def test_not_default_enabled() -> None: - from bigfoot._registry import PLUGIN_REGISTRY + from tripwire._registry import PLUGIN_REGISTRY native_entry = None for entry in PLUGIN_REGISTRY: @@ -561,7 +561,7 @@ def test_not_default_enabled() -> None: assert native_entry is not None assert native_entry.default_enabled is False - assert native_entry.import_path == "bigfoot.plugins.native_plugin" + assert native_entry.import_path == "tripwire.plugins.native_plugin" assert native_entry.class_name == "NativePlugin" assert native_entry.availability_check == "always" @@ -577,16 +577,16 @@ def test_not_default_enabled() -> None: # CHECK: Interaction details match exact expected values. # MUTATION: Recording wrong library/function/args fails assertion. # ESCAPE: Nothing reasonable -- exact field equality via assert_interaction. -def test_flow_assert_interaction_records_details(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_flow_assert_interaction_records_details(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.native_mock.mock_call("libm", "sqrt", returns=6.48) - with bigfoot.sandbox(): + tripwire.native_mock.mock_call("libm", "sqrt", returns=6.48) + with tripwire.sandbox(): lib = ctypes.CDLL("libm") lib.sqrt(42) # Check the recorded interaction details - timeline = bigfoot_verifier._timeline + timeline = tripwire_verifier._timeline interactions = timeline.all_unasserted() assert len(interactions) == 1 assert interactions[0].source_id == "native:libm:sqrt" @@ -597,7 +597,7 @@ def test_flow_assert_interaction_records_details(bigfoot_verifier: StrictVerifie } # Assert to satisfy teardown - bigfoot.native_mock.assert_call("libm", "sqrt", args=(42,)) + tripwire.native_mock.assert_call("libm", "sqrt", args=(42,)) # ESCAPE: test_flow_interactions_not_auto_asserted @@ -606,20 +606,20 @@ def test_flow_assert_interaction_records_details(bigfoot_verifier: StrictVerifie # CHECK: timeline.all_unasserted() returns 1 interaction. # MUTATION: Auto-asserting in record() would return 0 unasserted. # ESCAPE: Nothing reasonable -- exact count check. -def test_flow_interactions_not_auto_asserted(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_flow_interactions_not_auto_asserted(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.native_mock.mock_call("libm", "sqrt", returns=6.48) - with bigfoot.sandbox(): + tripwire.native_mock.mock_call("libm", "sqrt", returns=6.48) + with tripwire.sandbox(): lib = ctypes.CDLL("libm") lib.sqrt(42) - interactions = bigfoot_verifier._timeline.all_unasserted() + interactions = tripwire_verifier._timeline.all_unasserted() assert len(interactions) == 1 assert interactions[0].source_id == "native:libm:sqrt" # Assert to satisfy teardown - bigfoot.native_mock.assert_call("libm", "sqrt", args=(42,)) + tripwire.native_mock.assert_call("libm", "sqrt", args=(42,)) # --------------------------------------------------------------------------- @@ -696,7 +696,7 @@ def test_format_mock_hint() -> None: plugin=p, ) result = p.format_mock_hint(interaction) - assert result == " bigfoot.native_mock.mock_call('libm', 'sqrt', returns=...)" + assert result == " tripwire.native_mock.mock_call('libm', 'sqrt', returns=...)" # ESCAPE: test_format_unmocked_hint @@ -711,7 +711,7 @@ def test_format_unmocked_hint() -> None: assert result == ( "libm.sqrt(...) was called but no mock was registered.\n" "Register a mock with:\n" - " bigfoot.native_mock.mock_call('libm', 'sqrt', returns=...)" + " tripwire.native_mock.mock_call('libm', 'sqrt', returns=...)" ) @@ -731,7 +731,7 @@ def test_format_assert_hint() -> None: ) result = p.format_assert_hint(interaction) assert result == ( - " bigfoot.native_mock.assert_call(\n" + " tripwire.native_mock.assert_call(\n" " library='libm',\n" " function='sqrt',\n" " args=(42,),\n" @@ -890,57 +890,57 @@ def test_activate_deactivate_reference_counting() -> None: # ESCAPE: test_native_plugin_in_all -# CLAIM: NativePlugin and native_mock are exported from bigfoot.__all__. -# PATH: bigfoot.__all__ includes "NativePlugin" and "native_mock". +# CLAIM: NativePlugin and native_mock are exported from tripwire.__all__. +# PATH: tripwire.__all__ includes "NativePlugin" and "native_mock". # CHECK: Both names present in __all__. # MUTATION: Omitting either from __all__ fails membership. # ESCAPE: Nothing reasonable -- exact membership check. def test_native_plugin_in_all() -> None: - import bigfoot + import tripwire - assert "NativePlugin" in bigfoot.__all__ - assert "native_mock" in bigfoot.__all__ + assert "NativePlugin" in tripwire.__all__ + assert "native_mock" in tripwire.__all__ -# ESCAPE: test_native_plugin_importable_from_bigfoot -# CLAIM: NativePlugin is importable from bigfoot and is the correct class. -# PATH: bigfoot.NativePlugin is bigfoot.plugins.native_plugin.NativePlugin. +# ESCAPE: test_native_plugin_importable_from_tripwire +# CLAIM: NativePlugin is importable from tripwire and is the correct class. +# PATH: tripwire.NativePlugin is tripwire.plugins.native_plugin.NativePlugin. # CHECK: Identity check passes. # MUTATION: Wrong class or missing import fails identity. # ESCAPE: Nothing reasonable -- identity check. -def test_native_plugin_importable_from_bigfoot() -> None: - import bigfoot - from bigfoot.plugins.native_plugin import NativePlugin as _NativePlugin +def test_native_plugin_importable_from_tripwire() -> None: + import tripwire + from tripwire.plugins.native_plugin import NativePlugin as _NativePlugin - assert bigfoot.NativePlugin is _NativePlugin + assert tripwire.NativePlugin is _NativePlugin # ESCAPE: test_native_mock_proxy_type -# CLAIM: bigfoot.native_mock is a _NativeProxy instance. -# PATH: bigfoot.native_mock is a module-level proxy. -# CHECK: type(bigfoot.native_mock).__name__ == "_NativeProxy". +# CLAIM: tripwire.native_mock is a _NativeProxy instance. +# PATH: tripwire.native_mock is a module-level proxy. +# CHECK: type(tripwire.native_mock).__name__ == "_NativeProxy". # MUTATION: Wrong proxy type fails name check. # ESCAPE: Nothing reasonable -- exact string equality on type name. def test_native_mock_proxy_type() -> None: - import bigfoot + import tripwire - assert type(bigfoot.native_mock).__name__ == "_NativeProxy" + assert type(tripwire.native_mock).__name__ == "_NativeProxy" # ESCAPE: test_native_mock_proxy_raises_outside_context -# CLAIM: Accessing bigfoot.native_mock outside test context raises NoActiveVerifierError. +# CLAIM: Accessing tripwire.native_mock outside test context raises NoActiveVerifierError. # PATH: _NativeProxy.__getattr__ -> _get_test_verifier_or_raise -> raises. # CHECK: NoActiveVerifierError raised. # MUTATION: Not raising allows silent use outside tests. # ESCAPE: Nothing reasonable -- exact exception type. def test_native_mock_proxy_raises_outside_context() -> None: - import bigfoot - from bigfoot._errors import NoActiveVerifierError + import tripwire + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.native_mock.mock_call + _ = tripwire.native_mock.mock_call finally: _current_test_verifier.reset(token) @@ -1039,7 +1039,7 @@ def test_serialize_arg_null_pointer() -> None: # MUTATION: Not checking _closed in __getattr__ allows access after close. # ESCAPE: Nothing reasonable -- exact exception type. def test_cffi_proxy_close_blocks_access() -> None: - from bigfoot.plugins.native_plugin import CffiProxy + from tripwire.plugins.native_plugin import CffiProxy v, p = _make_verifier_with_plugin() proxy = CffiProxy("libm", p) @@ -1095,8 +1095,8 @@ def __eq__(self, other: object) -> bool: # MUTATION: Not raising allows silent failure. # ESCAPE: Nothing reasonable -- exact exception type and message. def test_get_native_plugin_raises_without_native_plugin() -> None: - from bigfoot._context import _active_verifier - from bigfoot.plugins.native_plugin import _get_native_plugin + from tripwire._context import _active_verifier + from tripwire.plugins.native_plugin import _get_native_plugin v = StrictVerifier() # Remove any NativePlugin from the verifier's plugins @@ -1107,7 +1107,7 @@ def test_get_native_plugin_raises_without_native_plugin() -> None: with pytest.raises(RuntimeError) as exc_info: _get_native_plugin() assert str(exc_info.value) == ( - "BUG: bigfoot NativePlugin interceptor is active but no " + "BUG: tripwire NativePlugin interceptor is active but no " "NativePlugin is registered on the current verifier." ) finally: diff --git a/tests/unit/test_normalize.py b/tests/unit/test_normalize.py index 19d4b2f..54da658 100644 --- a/tests/unit/test_normalize.py +++ b/tests/unit/test_normalize.py @@ -1,6 +1,6 @@ -"""Tests for bigfoot._normalize -- URL/host/path normalization.""" +"""Tests for tripwire._normalize -- URL/host/path normalization.""" -from bigfoot._normalize import normalize_host, normalize_path, normalize_url +from tripwire._normalize import normalize_host, normalize_path, normalize_url class TestNormalizeHost: diff --git a/tests/unit/test_patching.py b/tests/unit/test_patching.py index 0ee7566..1c96901 100644 --- a/tests/unit/test_patching.py +++ b/tests/unit/test_patching.py @@ -1,6 +1,6 @@ """Tests for PatchSet and PatchTarget.""" -from bigfoot._patching import PatchSet, PatchTarget +from tripwire._patching import PatchSet, PatchTarget class _DummyTarget: diff --git a/tests/unit/test_path_resolution.py b/tests/unit/test_path_resolution.py index 609e930..646c2c6 100644 --- a/tests/unit/test_path_resolution.py +++ b/tests/unit/test_path_resolution.py @@ -2,7 +2,7 @@ import pytest -from bigfoot._path_resolution import resolve_target +from tripwire._path_resolution import resolve_target def test_resolve_simple_module_attr() -> None: diff --git a/tests/unit/test_pika_plugin.py b/tests/unit/test_pika_plugin.py index da5ac59..027816f 100644 --- a/tests/unit/test_pika_plugin.py +++ b/tests/unit/test_pika_plugin.py @@ -5,12 +5,12 @@ import pika as pika_lib import pytest -import bigfoot -from bigfoot._context import _current_test_verifier -from bigfoot._errors import InvalidStateError, UnmockedInteractionError -from bigfoot._state_machine_plugin import ScriptStep -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.pika_plugin import ( +import tripwire +from tripwire._context import _current_test_verifier +from tripwire._errors import InvalidStateError, UnmockedInteractionError +from tripwire._state_machine_plugin import ScriptStep +from tripwire._verifier import StrictVerifier +from tripwire.plugins.pika_plugin import ( _PIKA_AVAILABLE, PikaPlugin, _FakeBlockingConnection, @@ -536,7 +536,7 @@ def test_get_unused_mocks_queued_session_never_bound() -> None: # MUTATION: Returning frozenset() from assertable_fields would skip field validation. # ESCAPE: Nothing reasonable -- exact exception type. def test_assert_interaction_missing_fields_raises() -> None: - from bigfoot._errors import MissingAssertionFieldsError + from tripwire._errors import MissingAssertionFieldsError v, p = _make_verifier_with_plugin() session = p.new_session() @@ -565,19 +565,19 @@ def test_assert_interaction_missing_fields_raises() -> None: # CHECK: No exception raised. # MUTATION: Wrong host/port/virtual_host would raise InteractionMismatchError. # ESCAPE: Nothing reasonable -- helper delegates to assert_interaction with full fields. -def test_assert_connect_helper(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.pika_mock.new_session() +def test_assert_connect_helper(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.pika_mock.new_session() session.expect("connect", returns=None) session.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): conn = pika_lib.BlockingConnection( pika_lib.ConnectionParameters(host="rabbitmq.local", port=5672, virtual_host="/") ) conn.close() - bigfoot.pika_mock.assert_connect(host="rabbitmq.local", port=5672, virtual_host="/") - bigfoot.pika_mock.assert_close() + tripwire.pika_mock.assert_connect(host="rabbitmq.local", port=5672, virtual_host="/") + tripwire.pika_mock.assert_close() # ESCAPE: test_assert_publish_helper @@ -586,14 +586,14 @@ def test_assert_connect_helper(bigfoot_verifier: StrictVerifier) -> None: # CHECK: No exception raised. # MUTATION: Wrong exchange/routing_key/body/properties would raise InteractionMismatchError. # ESCAPE: Nothing reasonable -- helper delegates to assert_interaction with full fields. -def test_assert_publish_helper(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.pika_mock.new_session() +def test_assert_publish_helper(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.pika_mock.new_session() session.expect("connect", returns=None) session.expect("channel", returns=None) session.expect("publish", returns=None) session.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): conn = pika_lib.BlockingConnection( pika_lib.ConnectionParameters(host="localhost") ) @@ -606,15 +606,15 @@ def test_assert_publish_helper(bigfoot_verifier: StrictVerifier) -> None: ) conn.close() - bigfoot.pika_mock.assert_connect(host="localhost", port=5672, virtual_host="/") - bigfoot.pika_mock.assert_channel() - bigfoot.pika_mock.assert_publish( + tripwire.pika_mock.assert_connect(host="localhost", port=5672, virtual_host="/") + tripwire.pika_mock.assert_channel() + tripwire.pika_mock.assert_publish( exchange="amq.direct", routing_key="test.route", body=b"payload", properties=None, ) - bigfoot.pika_mock.assert_close() + tripwire.pika_mock.assert_close() # ESCAPE: test_assert_consume_helper @@ -623,14 +623,14 @@ def test_assert_publish_helper(bigfoot_verifier: StrictVerifier) -> None: # CHECK: No exception raised. # MUTATION: Wrong queue/auto_ack would raise InteractionMismatchError. # ESCAPE: Nothing reasonable -- helper delegates to assert_interaction with full fields. -def test_assert_consume_helper(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.pika_mock.new_session() +def test_assert_consume_helper(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.pika_mock.new_session() session.expect("connect", returns=None) session.expect("channel", returns=None) session.expect("consume", returns="ctag_1") session.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): conn = pika_lib.BlockingConnection( pika_lib.ConnectionParameters(host="localhost") ) @@ -638,10 +638,10 @@ def test_assert_consume_helper(bigfoot_verifier: StrictVerifier) -> None: ch.basic_consume(queue="my_queue", auto_ack=True) conn.close() - bigfoot.pika_mock.assert_connect(host="localhost", port=5672, virtual_host="/") - bigfoot.pika_mock.assert_channel() - bigfoot.pika_mock.assert_consume(queue="my_queue", auto_ack=True) - bigfoot.pika_mock.assert_close() + tripwire.pika_mock.assert_connect(host="localhost", port=5672, virtual_host="/") + tripwire.pika_mock.assert_channel() + tripwire.pika_mock.assert_consume(queue="my_queue", auto_ack=True) + tripwire.pika_mock.assert_close() # --------------------------------------------------------------------------- @@ -688,24 +688,24 @@ def test_pika_available_flag() -> None: # ESCAPE: test_pika_mock_proxy_raises_import_error_when_unavailable -# CLAIM: Accessing bigfoot.pika_mock raises ImportError when pika is not installed. +# CLAIM: Accessing tripwire.pika_mock raises ImportError when pika is not installed. # PATH: _PikaProxy.__getattr__ -> checks _PIKA_AVAILABLE -> raises ImportError. -# CHECK: ImportError raised with message containing "bigfoot[pika]" and "pip install". +# CHECK: ImportError raised with message containing "tripwire[pika]" and "pip install". # MUTATION: Not checking _PIKA_AVAILABLE would defer the error. # ESCAPE: Wrong message would fail the string check. def test_pika_mock_proxy_raises_import_error_when_unavailable( monkeypatch: pytest.MonkeyPatch, ) -> None: - import bigfoot.plugins.pika_plugin as pika_mod + import tripwire.plugins.pika_plugin as pika_mod monkeypatch.setattr(pika_mod, "_PIKA_AVAILABLE", False) with pytest.raises(ImportError) as exc_info: - _ = bigfoot.pika_mock.new_session # noqa: B018 + _ = tripwire.pika_mock.new_session # noqa: B018 assert str(exc_info.value) == ( - "bigfoot[pika] is required to use bigfoot.pika_mock. " - "Install it with: pip install bigfoot[pika]" + "tripwire[pika] is required to use tripwire.pika_mock. " + "Install it with: pip install tripwire[pika]" ) @@ -926,7 +926,7 @@ def test_matches_field_by_field() -> None: # MUTATION: Wrong source_id string fails the equality check. # ESCAPE: Nothing reasonable -- exact string equality on each. def test_sentinel_properties() -> None: - from bigfoot._state_machine_plugin import _StepSentinel + from tripwire._state_machine_plugin import _StepSentinel v, p = _make_verifier_with_plugin() @@ -953,39 +953,39 @@ def test_sentinel_properties() -> None: # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.pika_mock +# Module-level proxy: tripwire.pika_mock # --------------------------------------------------------------------------- # ESCAPE: test_pika_mock_proxy_new_session -# CLAIM: bigfoot.pika_mock.new_session() returns a SessionHandle. +# CLAIM: tripwire.pika_mock.new_session() returns a SessionHandle. # PATH: _PikaProxy.__getattr__("new_session") -> get verifier -> find/create PikaPlugin -> # return plugin.new_session. # CHECK: session is a SessionHandle instance; chaining .expect() does not raise. # MUTATION: Returning None instead of a SessionHandle would fail isinstance check. # ESCAPE: Nothing reasonable -- both the isinstance and the chained .expect() call check it. -def test_pika_mock_proxy_new_session(bigfoot_verifier: StrictVerifier) -> None: - from bigfoot._state_machine_plugin import SessionHandle +def test_pika_mock_proxy_new_session(tripwire_verifier: StrictVerifier) -> None: + from tripwire._state_machine_plugin import SessionHandle - session = bigfoot.pika_mock.new_session() + session = tripwire.pika_mock.new_session() assert isinstance(session, SessionHandle) result = session.expect("connect", returns=None, required=False) assert result is session # expect() returns self for chaining # ESCAPE: test_pika_mock_proxy_raises_outside_context -# CLAIM: Accessing bigfoot.pika_mock outside a test context raises NoActiveVerifierError. +# CLAIM: Accessing tripwire.pika_mock outside a test context raises NoActiveVerifierError. # PATH: _PikaProxy.__getattr__ -> _get_test_verifier_or_raise -> NoActiveVerifierError. # CHECK: NoActiveVerifierError raised. # MUTATION: Silently returning None would not raise and hide context failures. # ESCAPE: Nothing reasonable -- exact exception type. def test_pika_mock_proxy_raises_outside_context() -> None: - from bigfoot._errors import NoActiveVerifierError + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.pika_mock.new_session # noqa: B018 + _ = tripwire.pika_mock.new_session # noqa: B018 finally: _current_test_verifier.reset(token) @@ -1027,14 +1027,14 @@ def test_close_from_connected() -> None: # CHECK: assert_interaction verifies every assertable field for every step. # MUTATION: Wrong detail values in any step fail the assertion. # ESCAPE: Nothing reasonable -- full field coverage on all assertable steps. -def test_full_publish_flow_assertions(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.pika_mock.new_session() +def test_full_publish_flow_assertions(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.pika_mock.new_session() session.expect("connect", returns=None) session.expect("channel", returns=None) session.expect("publish", returns=None) session.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): conn = pika_lib.BlockingConnection( pika_lib.ConnectionParameters(host="localhost") ) @@ -1046,15 +1046,15 @@ def test_full_publish_flow_assertions(bigfoot_verifier: StrictVerifier) -> None: ) conn.close() - bigfoot.pika_mock.assert_connect(host="localhost", port=5672, virtual_host="/") - bigfoot.pika_mock.assert_channel() - bigfoot.pika_mock.assert_publish( + tripwire.pika_mock.assert_connect(host="localhost", port=5672, virtual_host="/") + tripwire.pika_mock.assert_channel() + tripwire.pika_mock.assert_publish( exchange="test_exchange", routing_key="test.key", body=b"hello", properties=None, ) - bigfoot.pika_mock.assert_close() + tripwire.pika_mock.assert_close() # ESCAPE: test_consume_flow_assertions @@ -1063,14 +1063,14 @@ def test_full_publish_flow_assertions(bigfoot_verifier: StrictVerifier) -> None: # CHECK: assert_interaction verifies every assertable field for every step. # MUTATION: Wrong queue or auto_ack values fail the assertion. # ESCAPE: Nothing reasonable -- full field coverage on all assertable steps. -def test_consume_flow_assertions(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.pika_mock.new_session() +def test_consume_flow_assertions(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.pika_mock.new_session() session.expect("connect", returns=None) session.expect("channel", returns=None) session.expect("consume", returns="ctag_1") session.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): conn = pika_lib.BlockingConnection( pika_lib.ConnectionParameters(host="localhost") ) @@ -1080,10 +1080,10 @@ def test_consume_flow_assertions(bigfoot_verifier: StrictVerifier) -> None: assert tag == "ctag_1" - bigfoot.pika_mock.assert_connect(host="localhost", port=5672, virtual_host="/") - bigfoot.pika_mock.assert_channel() - bigfoot.pika_mock.assert_consume(queue="test_queue", auto_ack=True) - bigfoot.pika_mock.assert_close() + tripwire.pika_mock.assert_connect(host="localhost", port=5672, virtual_host="/") + tripwire.pika_mock.assert_channel() + tripwire.pika_mock.assert_consume(queue="test_queue", auto_ack=True) + tripwire.pika_mock.assert_close() # ESCAPE: test_ack_nack_flow_assertions @@ -1092,15 +1092,15 @@ def test_consume_flow_assertions(bigfoot_verifier: StrictVerifier) -> None: # CHECK: assert_interaction verifies delivery_tag and requeue on each step. # MUTATION: Wrong delivery_tag or requeue values fail the assertion. # ESCAPE: Nothing reasonable -- full field coverage on all assertable steps. -def test_ack_nack_flow_assertions(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.pika_mock.new_session() +def test_ack_nack_flow_assertions(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.pika_mock.new_session() session.expect("connect", returns=None) session.expect("channel", returns=None) session.expect("ack", returns=None) session.expect("nack", returns=None) session.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): conn = pika_lib.BlockingConnection( pika_lib.ConnectionParameters(host="localhost") ) @@ -1109,11 +1109,11 @@ def test_ack_nack_flow_assertions(bigfoot_verifier: StrictVerifier) -> None: ch.basic_nack(delivery_tag=2, requeue=True) conn.close() - bigfoot.pika_mock.assert_connect(host="localhost", port=5672, virtual_host="/") - bigfoot.pika_mock.assert_channel() - bigfoot.pika_mock.assert_ack(delivery_tag=1) - bigfoot.pika_mock.assert_nack(delivery_tag=2, requeue=True) - bigfoot.pika_mock.assert_close() + tripwire.pika_mock.assert_connect(host="localhost", port=5672, virtual_host="/") + tripwire.pika_mock.assert_channel() + tripwire.pika_mock.assert_ack(delivery_tag=1) + tripwire.pika_mock.assert_nack(delivery_tag=2, requeue=True) + tripwire.pika_mock.assert_close() # ESCAPE: test_close_from_connected_assertions @@ -1122,19 +1122,19 @@ def test_ack_nack_flow_assertions(bigfoot_verifier: StrictVerifier) -> None: # CHECK: assert_interaction verifies connect fields; close has no assertable fields. # MUTATION: Wrong host/port/virtual_host fail the assertion. # ESCAPE: Nothing reasonable -- full field coverage. -def test_close_from_connected_assertions(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.pika_mock.new_session() +def test_close_from_connected_assertions(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.pika_mock.new_session() session.expect("connect", returns=None) session.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): conn = pika_lib.BlockingConnection( pika_lib.ConnectionParameters(host="rabbitmq.local") ) conn.close() - bigfoot.pika_mock.assert_connect(host="rabbitmq.local", port=5672, virtual_host="/") - bigfoot.pika_mock.assert_close() + tripwire.pika_mock.assert_connect(host="rabbitmq.local", port=5672, virtual_host="/") + tripwire.pika_mock.assert_close() # ESCAPE: test_session_lifecycle_assertions @@ -1143,14 +1143,14 @@ def test_close_from_connected_assertions(bigfoot_verifier: StrictVerifier) -> No # CHECK: assert_interaction verifies all assertable fields for every step. # MUTATION: Wrong detail values fail the assertion. # ESCAPE: Nothing reasonable -- full field coverage. -def test_session_lifecycle_assertions(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.pika_mock.new_session() +def test_session_lifecycle_assertions(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.pika_mock.new_session() session.expect("connect", returns=None) session.expect("channel", returns=None) session.expect("publish", returns=None) session.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): conn = pika_lib.BlockingConnection( pika_lib.ConnectionParameters(host="localhost") ) @@ -1158,12 +1158,12 @@ def test_session_lifecycle_assertions(bigfoot_verifier: StrictVerifier) -> None: ch.basic_publish(exchange="", routing_key="q", body=b"data") conn.close() - bigfoot.pika_mock.assert_connect(host="localhost", port=5672, virtual_host="/") - bigfoot.pika_mock.assert_channel() - bigfoot.pika_mock.assert_publish( + tripwire.pika_mock.assert_connect(host="localhost", port=5672, virtual_host="/") + tripwire.pika_mock.assert_channel() + tripwire.pika_mock.assert_publish( exchange="", routing_key="q", body=b"data", properties=None, ) - bigfoot.pika_mock.assert_close() + tripwire.pika_mock.assert_close() # ESCAPE: test_multiple_sequential_sessions_assertions @@ -1173,22 +1173,22 @@ def test_session_lifecycle_assertions(bigfoot_verifier: StrictVerifier) -> None: # CHECK: assert_interaction verifies fields for every step in both sessions. # MUTATION: Wrong host or routing_key/queue values fail the assertion. # ESCAPE: Nothing reasonable -- full field coverage on both sessions. -def test_multiple_sequential_sessions_assertions(bigfoot_verifier: StrictVerifier) -> None: +def test_multiple_sequential_sessions_assertions(tripwire_verifier: StrictVerifier) -> None: # First session - s1 = bigfoot.pika_mock.new_session() + s1 = tripwire.pika_mock.new_session() s1.expect("connect", returns=None) s1.expect("channel", returns=None) s1.expect("publish", returns=None) s1.expect("close", returns=None) # Second session - s2 = bigfoot.pika_mock.new_session() + s2 = tripwire.pika_mock.new_session() s2.expect("connect", returns=None) s2.expect("channel", returns=None) s2.expect("consume", returns="ctag_2") s2.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): # First connection conn1 = pika_lib.BlockingConnection( pika_lib.ConnectionParameters(host="host1") @@ -1208,18 +1208,18 @@ def test_multiple_sequential_sessions_assertions(bigfoot_verifier: StrictVerifie assert tag == "ctag_2" # Assert first session interactions - bigfoot.pika_mock.assert_connect(host="host1", port=5672, virtual_host="/") - bigfoot.pika_mock.assert_channel() - bigfoot.pika_mock.assert_publish( + tripwire.pika_mock.assert_connect(host="host1", port=5672, virtual_host="/") + tripwire.pika_mock.assert_channel() + tripwire.pika_mock.assert_publish( exchange="", routing_key="q1", body=b"msg1", properties=None, ) - bigfoot.pika_mock.assert_close() + tripwire.pika_mock.assert_close() # Assert second session interactions - bigfoot.pika_mock.assert_connect(host="host2", port=5672, virtual_host="/") - bigfoot.pika_mock.assert_channel() - bigfoot.pika_mock.assert_consume(queue="q2", auto_ack=False) - bigfoot.pika_mock.assert_close() + tripwire.pika_mock.assert_connect(host="host2", port=5672, virtual_host="/") + tripwire.pika_mock.assert_channel() + tripwire.pika_mock.assert_consume(queue="q2", auto_ack=False) + tripwire.pika_mock.assert_close() # --------------------------------------------------------------------------- @@ -1233,14 +1233,14 @@ def test_multiple_sequential_sessions_assertions(bigfoot_verifier: StrictVerifie # CHECK: No exception raised. # MUTATION: Wrong delivery_tag would raise InteractionMismatchError. # ESCAPE: Nothing reasonable -- helper delegates to assert_interaction with full fields. -def test_assert_ack_helper(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.pika_mock.new_session() +def test_assert_ack_helper(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.pika_mock.new_session() session.expect("connect", returns=None) session.expect("channel", returns=None) session.expect("ack", returns=None) session.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): conn = pika_lib.BlockingConnection( pika_lib.ConnectionParameters(host="localhost") ) @@ -1248,10 +1248,10 @@ def test_assert_ack_helper(bigfoot_verifier: StrictVerifier) -> None: ch.basic_ack(delivery_tag=42) conn.close() - bigfoot.pika_mock.assert_connect(host="localhost", port=5672, virtual_host="/") - bigfoot.pika_mock.assert_channel() - bigfoot.pika_mock.assert_ack(delivery_tag=42) - bigfoot.pika_mock.assert_close() + tripwire.pika_mock.assert_connect(host="localhost", port=5672, virtual_host="/") + tripwire.pika_mock.assert_channel() + tripwire.pika_mock.assert_ack(delivery_tag=42) + tripwire.pika_mock.assert_close() # ESCAPE: test_assert_nack_helper @@ -1260,14 +1260,14 @@ def test_assert_ack_helper(bigfoot_verifier: StrictVerifier) -> None: # CHECK: No exception raised. # MUTATION: Wrong delivery_tag or requeue would raise InteractionMismatchError. # ESCAPE: Nothing reasonable -- helper delegates to assert_interaction with full fields. -def test_assert_nack_helper(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.pika_mock.new_session() +def test_assert_nack_helper(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.pika_mock.new_session() session.expect("connect", returns=None) session.expect("channel", returns=None) session.expect("nack", returns=None) session.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): conn = pika_lib.BlockingConnection( pika_lib.ConnectionParameters(host="localhost") ) @@ -1275,10 +1275,10 @@ def test_assert_nack_helper(bigfoot_verifier: StrictVerifier) -> None: ch.basic_nack(delivery_tag=7, requeue=False) conn.close() - bigfoot.pika_mock.assert_connect(host="localhost", port=5672, virtual_host="/") - bigfoot.pika_mock.assert_channel() - bigfoot.pika_mock.assert_nack(delivery_tag=7, requeue=False) - bigfoot.pika_mock.assert_close() + tripwire.pika_mock.assert_connect(host="localhost", port=5672, virtual_host="/") + tripwire.pika_mock.assert_channel() + tripwire.pika_mock.assert_nack(delivery_tag=7, requeue=False) + tripwire.pika_mock.assert_close() # --------------------------------------------------------------------------- @@ -1294,7 +1294,7 @@ def test_assert_nack_helper(bigfoot_verifier: StrictVerifier) -> None: # MUTATION: A no-op assert_connect that never checks would not raise. # ESCAPE: Nothing reasonable -- exact exception type. def test_assert_connect_helper_rejects_wrong_values() -> None: - from bigfoot._errors import InteractionMismatchError + from tripwire._errors import InteractionMismatchError v, p = _make_verifier_with_plugin() session = p.new_session() @@ -1319,7 +1319,7 @@ def test_assert_connect_helper_rejects_wrong_values() -> None: # MUTATION: A no-op assert_publish that never checks would not raise. # ESCAPE: Nothing reasonable -- exact exception type. def test_assert_publish_helper_rejects_wrong_values() -> None: - from bigfoot._errors import InteractionMismatchError + from tripwire._errors import InteractionMismatchError v, p = _make_verifier_with_plugin() session = p.new_session() @@ -1353,7 +1353,7 @@ def test_assert_publish_helper_rejects_wrong_values() -> None: # MUTATION: A no-op assert_ack that never checks would not raise. # ESCAPE: Nothing reasonable -- exact exception type. def test_assert_ack_helper_rejects_wrong_values() -> None: - from bigfoot._errors import InteractionMismatchError + from tripwire._errors import InteractionMismatchError v, p = _make_verifier_with_plugin() session = p.new_session() @@ -1388,7 +1388,7 @@ def test_assert_ack_helper_rejects_wrong_values() -> None: # MUTATION: Wrong format string fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_interaction_connect() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1408,7 +1408,7 @@ def test_format_interaction_connect() -> None: # MUTATION: Wrong format string fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_interaction_channel() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1428,7 +1428,7 @@ def test_format_interaction_channel() -> None: # MUTATION: Wrong format string fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_interaction_publish() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1448,7 +1448,7 @@ def test_format_interaction_publish() -> None: # MUTATION: Wrong format string fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_interaction_consume() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1468,7 +1468,7 @@ def test_format_interaction_consume() -> None: # MUTATION: Wrong format string fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_interaction_ack() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1488,7 +1488,7 @@ def test_format_interaction_ack() -> None: # MUTATION: Wrong format string fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_interaction_nack() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1508,7 +1508,7 @@ def test_format_interaction_nack() -> None: # MUTATION: Wrong format string fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_interaction_close() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1528,7 +1528,7 @@ def test_format_interaction_close() -> None: # MUTATION: Wrong fallback format fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_interaction_unknown() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1548,7 +1548,7 @@ def test_format_interaction_unknown() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_mock_hint() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1558,7 +1558,7 @@ def test_format_mock_hint() -> None: plugin=p, ) result = p.format_mock_hint(interaction) - assert result == " bigfoot.pika_mock.new_session().expect('publish', returns=...)" + assert result == " tripwire.pika_mock.new_session().expect('publish', returns=...)" # ESCAPE: test_format_mock_hint_connect @@ -1568,7 +1568,7 @@ def test_format_mock_hint() -> None: # MUTATION: Wrong method name in hint fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_mock_hint_connect() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1578,7 +1578,7 @@ def test_format_mock_hint_connect() -> None: plugin=p, ) result = p.format_mock_hint(interaction) - assert result == " bigfoot.pika_mock.new_session().expect('connect', returns=...)" + assert result == " tripwire.pika_mock.new_session().expect('connect', returns=...)" # ESCAPE: test_format_unmocked_hint @@ -1593,7 +1593,7 @@ def test_format_unmocked_hint() -> None: assert result == ( "pika.BlockingConnection.connect(...) was called but no session was queued.\n" "Register a session with:\n" - " bigfoot.pika_mock.new_session().expect('connect', returns=...)" + " tripwire.pika_mock.new_session().expect('connect', returns=...)" ) @@ -1609,7 +1609,7 @@ def test_format_unmocked_hint_publish() -> None: assert result == ( "pika.BlockingConnection.publish(...) was called but no session was queued.\n" "Register a session with:\n" - " bigfoot.pika_mock.new_session().expect('publish', returns=...)" + " tripwire.pika_mock.new_session().expect('publish', returns=...)" ) @@ -1620,7 +1620,7 @@ def test_format_unmocked_hint_publish() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_assert_hint_connect() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1630,7 +1630,7 @@ def test_format_assert_hint_connect() -> None: plugin=p, ) result = p.format_assert_hint(interaction) - assert result == " bigfoot.pika_mock.assert_connect(host='localhost', port=5672, virtual_host='/')" + assert result == " tripwire.pika_mock.assert_connect(host='localhost', port=5672, virtual_host='/')" # ESCAPE: test_format_assert_hint_channel @@ -1640,7 +1640,7 @@ def test_format_assert_hint_connect() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_assert_hint_channel() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1650,7 +1650,7 @@ def test_format_assert_hint_channel() -> None: plugin=p, ) result = p.format_assert_hint(interaction) - assert result == " bigfoot.pika_mock.assert_channel()" + assert result == " tripwire.pika_mock.assert_channel()" # ESCAPE: test_format_assert_hint_publish @@ -1660,7 +1660,7 @@ def test_format_assert_hint_channel() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_assert_hint_publish() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1671,7 +1671,7 @@ def test_format_assert_hint_publish() -> None: ) result = p.format_assert_hint(interaction) assert result == ( - " bigfoot.pika_mock.assert_publish(" + " tripwire.pika_mock.assert_publish(" "exchange='amq.direct', routing_key='test', " "body=b'msg', properties=None)" ) @@ -1684,7 +1684,7 @@ def test_format_assert_hint_publish() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_assert_hint_consume() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1694,7 +1694,7 @@ def test_format_assert_hint_consume() -> None: plugin=p, ) result = p.format_assert_hint(interaction) - assert result == " bigfoot.pika_mock.assert_consume(queue='my_queue', auto_ack=True)" + assert result == " tripwire.pika_mock.assert_consume(queue='my_queue', auto_ack=True)" # ESCAPE: test_format_assert_hint_ack @@ -1704,7 +1704,7 @@ def test_format_assert_hint_consume() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_assert_hint_ack() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1714,7 +1714,7 @@ def test_format_assert_hint_ack() -> None: plugin=p, ) result = p.format_assert_hint(interaction) - assert result == " bigfoot.pika_mock.assert_ack(delivery_tag=42)" + assert result == " tripwire.pika_mock.assert_ack(delivery_tag=42)" # ESCAPE: test_format_assert_hint_nack @@ -1724,7 +1724,7 @@ def test_format_assert_hint_ack() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_assert_hint_nack() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1734,7 +1734,7 @@ def test_format_assert_hint_nack() -> None: plugin=p, ) result = p.format_assert_hint(interaction) - assert result == " bigfoot.pika_mock.assert_nack(delivery_tag=5, requeue=False)" + assert result == " tripwire.pika_mock.assert_nack(delivery_tag=5, requeue=False)" # ESCAPE: test_format_assert_hint_close @@ -1744,7 +1744,7 @@ def test_format_assert_hint_nack() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_assert_hint_close() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1754,7 +1754,7 @@ def test_format_assert_hint_close() -> None: plugin=p, ) result = p.format_assert_hint(interaction) - assert result == " bigfoot.pika_mock.assert_close()" + assert result == " tripwire.pika_mock.assert_close()" # ESCAPE: test_format_assert_hint_unknown @@ -1764,7 +1764,7 @@ def test_format_assert_hint_close() -> None: # MUTATION: Wrong fallback format fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_assert_hint_unknown() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1774,7 +1774,7 @@ def test_format_assert_hint_unknown() -> None: plugin=p, ) result = p.format_assert_hint(interaction) - assert result == " # bigfoot.pika_mock: unknown source_id='pika:unknown_op'" + assert result == " # tripwire.pika_mock: unknown source_id='pika:unknown_op'" # ESCAPE: test_format_unused_mock_hint diff --git a/tests/unit/test_popen_plugin.py b/tests/unit/test_popen_plugin.py index 340516a..9ef0c8c 100644 --- a/tests/unit/test_popen_plugin.py +++ b/tests/unit/test_popen_plugin.py @@ -7,12 +7,12 @@ import pytest -import bigfoot -from bigfoot._context import _current_test_verifier -from bigfoot._errors import InvalidStateError, UnmockedInteractionError -from bigfoot._state_machine_plugin import ScriptStep -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.popen_plugin import ( +import tripwire +from tripwire._context import _current_test_verifier +from tripwire._errors import InvalidStateError, UnmockedInteractionError +from tripwire._state_machine_plugin import ScriptStep +from tripwire._verifier import StrictVerifier +from tripwire.plugins.popen_plugin import ( _ORIGINAL_POPEN, PopenPlugin, _FakePopen, @@ -557,40 +557,40 @@ def test_popen_with_empty_queue_raises_unmocked() -> None: # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.popen_mock +# Module-level proxy: tripwire.popen_mock # --------------------------------------------------------------------------- # ESCAPE: test_popen_mock_proxy_new_session -# CLAIM: bigfoot.popen_mock.new_session() returns a SessionHandle that can +# CLAIM: tripwire.popen_mock.new_session() returns a SessionHandle that can # be used to configure a session without importing PopenPlugin directly. # PATH: _PopenProxy.__getattr__("new_session") -> get verifier -> find/create PopenPlugin -> # return plugin.new_session. # CHECK: session is a SessionHandle instance; chaining .expect() does not raise. # MUTATION: Returning None instead of a SessionHandle would fail isinstance check. # ESCAPE: Nothing reasonable -- both the isinstance and the chained .expect() call check it. -def test_popen_mock_proxy_new_session(bigfoot_verifier: StrictVerifier) -> None: - from bigfoot._state_machine_plugin import SessionHandle +def test_popen_mock_proxy_new_session(tripwire_verifier: StrictVerifier) -> None: + from tripwire._state_machine_plugin import SessionHandle - session = bigfoot.popen_mock.new_session() + session = tripwire.popen_mock.new_session() assert isinstance(session, SessionHandle) result = session.expect("spawn", returns=None, required=False) assert result is session # expect() returns self for chaining # ESCAPE: test_popen_mock_proxy_raises_outside_context -# CLAIM: Accessing bigfoot.popen_mock outside a test context raises NoActiveVerifierError. +# CLAIM: Accessing tripwire.popen_mock outside a test context raises NoActiveVerifierError. # PATH: _PopenProxy.__getattr__ -> _get_test_verifier_or_raise -> NoActiveVerifierError. # CHECK: NoActiveVerifierError raised. # MUTATION: Silently returning None would not raise and hide context failures. # ESCAPE: Nothing reasonable -- exact exception type. def test_popen_mock_proxy_raises_outside_context() -> None: - from bigfoot._errors import NoActiveVerifierError + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.popen_mock.new_session + _ = tripwire.popen_mock.new_session finally: _current_test_verifier.reset(token) @@ -613,8 +613,8 @@ def test_popen_mock_proxy_raises_outside_context() -> None: # MUTATION: PopenPlugin clobbering subprocess.run would make run-original check fail. # ESCAPE: Nothing reasonable -- four identity checks cover all four states. def test_popen_and_subprocess_coexist() -> None: - import bigfoot.plugins.subprocess as _sp_mod - from bigfoot.plugins.subprocess import ( + import tripwire.plugins.subprocess as _sp_mod + from tripwire.plugins.subprocess import ( _SUBPROCESS_RUN_ORIGINAL, SubprocessPlugin, ) @@ -623,8 +623,8 @@ def test_popen_and_subprocess_coexist() -> None: saved_count = SubprocessPlugin._install_count saved_original_run = SubprocessPlugin._original_subprocess_run saved_original_which = SubprocessPlugin._original_shutil_which - saved_bigfoot_run = _sp_mod._bigfoot_subprocess_run - saved_bigfoot_which = _sp_mod._bigfoot_shutil_which + saved_tripwire_run = _sp_mod._tripwire_subprocess_run + saved_tripwire_which = _sp_mod._tripwire_shutil_which saved_run = subprocess.run saved_which = shutil.which @@ -655,8 +655,8 @@ def test_popen_and_subprocess_coexist() -> None: SubprocessPlugin._install_count = saved_count SubprocessPlugin._original_subprocess_run = saved_original_run SubprocessPlugin._original_shutil_which = saved_original_which - _sp_mod._bigfoot_subprocess_run = saved_bigfoot_run - _sp_mod._bigfoot_shutil_which = saved_bigfoot_which + _sp_mod._tripwire_subprocess_run = saved_tripwire_run + _sp_mod._tripwire_shutil_which = saved_tripwire_which subprocess.run = saved_run shutil.which = saved_which # type: ignore[assignment] @@ -677,7 +677,7 @@ def test_popen_and_subprocess_coexist() -> None: def test_conflict_error_popen_already_patched() -> None: from unittest.mock import MagicMock - from bigfoot._errors import ConflictError + from tripwire._errors import ConflictError v, p = _make_verifier_with_plugin() foreign_patch = MagicMock() @@ -692,28 +692,28 @@ def test_conflict_error_popen_already_patched() -> None: # --------------------------------------------------------------------------- -# Full session via module-level API: bigfoot.sandbox() +# Full session via module-level API: tripwire.sandbox() # --------------------------------------------------------------------------- # ESCAPE: test_full_session_via_sandbox # CLAIM: A complete Popen session (spawn -> communicate) runs end-to-end through -# the module-level bigfoot.sandbox() API, returning the scripted values. -# PATH: bigfoot.popen_mock.new_session() -> sandbox -> _FakePopen.__init__ -> communicate. +# the module-level tripwire.sandbox() API, returning the scripted values. +# PATH: tripwire.popen_mock.new_session() -> sandbox -> _FakePopen.__init__ -> communicate. # CHECK: stdout == b"build output"; stderr == b""; proc.returncode == 0. # MUTATION: Returning wrong stdout bytes would fail the equality check. # ESCAPE: Nothing reasonable -- exact bytes equality on all three fields. -def test_full_session_via_sandbox(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.popen_mock.new_session() +def test_full_session_via_sandbox(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.popen_mock.new_session() session.expect("spawn", returns=None) session.expect("communicate", returns=(b"build output", b"", 0)) - with bigfoot.sandbox(): + with tripwire.sandbox(): proc = subprocess.Popen(["make", "all"]) stdout, stderr = proc.communicate() - bigfoot.popen_mock.assert_spawn(command=["make", "all"], stdin=None) - bigfoot.popen_mock.assert_communicate(input=None) + tripwire.popen_mock.assert_spawn(command=["make", "all"], stdin=None) + tripwire.popen_mock.assert_communicate(input=None) assert stdout == b"build output" assert stderr == b"" diff --git a/tests/unit/test_project_structure.py b/tests/unit/test_project_structure.py index 7254954..b6e5966 100644 --- a/tests/unit/test_project_structure.py +++ b/tests/unit/test_project_structure.py @@ -17,13 +17,13 @@ PROJECT_ROOT = Path(__file__).parent.parent.parent -def test_bigfoot_init_exists() -> None: - init = PROJECT_ROOT / "src" / "bigfoot" / "__init__.py" +def test_tripwire_init_exists() -> None: + init = PROJECT_ROOT / "src" / "tripwire" / "__init__.py" assert init.exists(), f"Expected {init} to exist" -def test_bigfoot_plugins_init_exists() -> None: - init = PROJECT_ROOT / "src" / "bigfoot" / "plugins" / "__init__.py" +def test_tripwire_plugins_init_exists() -> None: + init = PROJECT_ROOT / "src" / "tripwire" / "plugins" / "__init__.py" assert init.exists(), f"Expected {init} to exist" @@ -60,16 +60,16 @@ def test_pyproject_toml_has_pytest11_entry_point() -> None: data = tomllib.loads(pyproject.read_bytes().decode()) entry_points = data.get("project", {}).get("entry-points", {}) pytest11 = entry_points.get("pytest11", {}) - assert pytest11 == {"bigfoot": "bigfoot.pytest_plugin"}, ( - f"[project.entry-points.pytest11] must be {{'bigfoot': 'bigfoot.pytest_plugin'}}, got {pytest11!r}" + assert pytest11 == {"tripwire": "tripwire.pytest_plugin"}, ( + f"[project.entry-points.pytest11] must be {{'tripwire': 'tripwire.pytest_plugin'}}, got {pytest11!r}" ) -def test_pyproject_toml_package_name_is_bigfoot() -> None: +def test_pyproject_toml_package_name_is_tripwire() -> None: pyproject = PROJECT_ROOT / "pyproject.toml" data = tomllib.loads(pyproject.read_bytes().decode()) name = data.get("project", {}).get("name") - assert name == "bigfoot", f"[project].name must be 'bigfoot', got {name!r}" + assert name == "tripwire", f"[project].name must be 'tripwire', got {name!r}" def test_pyproject_toml_python_requirement() -> None: diff --git a/tests/unit/test_psycopg2_plugin.py b/tests/unit/test_psycopg2_plugin.py index 26ca877..6e411ca 100644 --- a/tests/unit/test_psycopg2_plugin.py +++ b/tests/unit/test_psycopg2_plugin.py @@ -6,12 +6,12 @@ import pytest -import bigfoot -from bigfoot._context import _current_test_verifier -from bigfoot._errors import InvalidStateError, UnmockedInteractionError -from bigfoot._state_machine_plugin import ScriptStep -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.psycopg2_plugin import Psycopg2Plugin +import tripwire +from tripwire._context import _current_test_verifier +from tripwire._errors import InvalidStateError, UnmockedInteractionError +from tripwire._state_machine_plugin import ScriptStep +from tripwire._verifier import StrictVerifier +from tripwire.plugins.psycopg2_plugin import Psycopg2Plugin # --------------------------------------------------------------------------- # Helpers @@ -355,26 +355,26 @@ def test_connect_with_empty_queue_raises_unmocked() -> None: # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.psycopg2_mock +# Module-level proxy: tripwire.psycopg2_mock # --------------------------------------------------------------------------- -def test_psycopg2_mock_proxy_new_session(bigfoot_verifier: StrictVerifier) -> None: - from bigfoot._state_machine_plugin import SessionHandle +def test_psycopg2_mock_proxy_new_session(tripwire_verifier: StrictVerifier) -> None: + from tripwire._state_machine_plugin import SessionHandle - session = bigfoot.psycopg2_mock.new_session() + session = tripwire.psycopg2_mock.new_session() assert isinstance(session, SessionHandle) result = session.expect("execute", returns=[], required=False) assert result is session def test_psycopg2_mock_proxy_raises_outside_context() -> None: - from bigfoot._errors import NoActiveVerifierError + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.psycopg2_mock.new_session + _ = tripwire.psycopg2_mock.new_session finally: _current_test_verifier.reset(token) @@ -657,9 +657,9 @@ def test_execute_with_params() -> None: # --------------------------------------------------------------------------- -# Psycopg2Plugin is exposed as bigfoot.Psycopg2Plugin +# Psycopg2Plugin is exposed as tripwire.Psycopg2Plugin # --------------------------------------------------------------------------- def test_psycopg2_plugin_exported() -> None: - assert bigfoot.Psycopg2Plugin is Psycopg2Plugin + assert tripwire.Psycopg2Plugin is Psycopg2Plugin diff --git a/tests/unit/test_pytest_plugin.py b/tests/unit/test_pytest_plugin.py index 504aef8..439c2a6 100644 --- a/tests/unit/test_pytest_plugin.py +++ b/tests/unit/test_pytest_plugin.py @@ -1,9 +1,9 @@ # tests/unit/test_pytest_plugin.py -"""Unit tests for bigfoot pytest fixtures. +"""Unit tests for tripwire pytest fixtures. Tests verify the structural contracts of both fixtures: -- _bigfoot_auto_verifier: autouse generator, sets ContextVar, calls verify_all() at teardown -- bigfoot_verifier: explicit fixture that returns the auto-verifier +- _tripwire_auto_verifier: autouse generator, sets ContextVar, calls verify_all() at teardown +- tripwire_verifier: explicit fixture that returns the auto-verifier """ from __future__ import annotations @@ -12,19 +12,19 @@ import pytest -from bigfoot._context import _current_test_verifier -from bigfoot._errors import UnassertedInteractionsError -from bigfoot._verifier import StrictVerifier -from bigfoot.pytest_plugin import _bigfoot_auto_verifier, bigfoot_verifier +from tripwire._context import _current_test_verifier +from tripwire._errors import UnassertedInteractionsError +from tripwire._verifier import StrictVerifier +from tripwire.pytest_plugin import _tripwire_auto_verifier, tripwire_verifier # --------------------------------------------------------------------------- -# _bigfoot_auto_verifier fixture contract +# _tripwire_auto_verifier fixture contract # --------------------------------------------------------------------------- -def test_bigfoot_auto_verifier_yields_strict_verifier() -> None: - """_bigfoot_auto_verifier must yield a StrictVerifier instance.""" - gen = _bigfoot_auto_verifier.__wrapped__() # type: ignore[attr-defined] +def test_tripwire_auto_verifier_yields_strict_verifier() -> None: + """_tripwire_auto_verifier must yield a StrictVerifier instance.""" + gen = _tripwire_auto_verifier.__wrapped__() # type: ignore[attr-defined] verifier = next(gen) try: assert isinstance(verifier, StrictVerifier) @@ -36,9 +36,9 @@ def test_bigfoot_auto_verifier_yields_strict_verifier() -> None: pass -def test_bigfoot_auto_verifier_sets_context_var_during_yield() -> None: - """_bigfoot_auto_verifier must set _current_test_verifier while yielded.""" - gen = _bigfoot_auto_verifier.__wrapped__() # type: ignore[attr-defined] +def test_tripwire_auto_verifier_sets_context_var_during_yield() -> None: + """_tripwire_auto_verifier must set _current_test_verifier while yielded.""" + gen = _tripwire_auto_verifier.__wrapped__() # type: ignore[attr-defined] verifier = next(gen) try: assert _current_test_verifier.get() is verifier @@ -49,10 +49,10 @@ def test_bigfoot_auto_verifier_sets_context_var_during_yield() -> None: pass -def test_bigfoot_auto_verifier_resets_context_var_after_teardown() -> None: - """_bigfoot_auto_verifier must reset _current_test_verifier after yield.""" +def test_tripwire_auto_verifier_resets_context_var_after_teardown() -> None: + """_tripwire_auto_verifier must reset _current_test_verifier after yield.""" original = _current_test_verifier.get() - gen = _bigfoot_auto_verifier.__wrapped__() # type: ignore[attr-defined] + gen = _tripwire_auto_verifier.__wrapped__() # type: ignore[attr-defined] next(gen) try: next(gen) @@ -61,9 +61,9 @@ def test_bigfoot_auto_verifier_resets_context_var_after_teardown() -> None: assert _current_test_verifier.get() is original -def test_bigfoot_auto_verifier_calls_verify_all_at_teardown() -> None: - """_bigfoot_auto_verifier must call verifier.verify_all() at teardown.""" - gen = _bigfoot_auto_verifier.__wrapped__() # type: ignore[attr-defined] +def test_tripwire_auto_verifier_calls_verify_all_at_teardown() -> None: + """_tripwire_auto_verifier must call verifier.verify_all() at teardown.""" + gen = _tripwire_auto_verifier.__wrapped__() # type: ignore[attr-defined] verifier = next(gen) verifier.verify_all = MagicMock() try: @@ -73,9 +73,9 @@ def test_bigfoot_auto_verifier_calls_verify_all_at_teardown() -> None: verifier.verify_all.assert_called_once_with() -def test_bigfoot_auto_verifier_teardown_propagates_verify_all_exception() -> None: +def test_tripwire_auto_verifier_teardown_propagates_verify_all_exception() -> None: """If verify_all() raises, the exception must propagate from the generator teardown.""" - gen = _bigfoot_auto_verifier.__wrapped__() # type: ignore[attr-defined] + gen = _tripwire_auto_verifier.__wrapped__() # type: ignore[attr-defined] verifier = next(gen) expected_error = UnassertedInteractionsError( interactions=[object()], @@ -93,24 +93,24 @@ def test_bigfoot_auto_verifier_teardown_propagates_verify_all_exception() -> Non # --------------------------------------------------------------------------- -# bigfoot_verifier explicit fixture contract +# tripwire_verifier explicit fixture contract # --------------------------------------------------------------------------- -def test_bigfoot_verifier_returns_auto_verifier() -> None: - """bigfoot_verifier must return the same StrictVerifier as the auto-verifier.""" - # Simulate: _bigfoot_auto_verifier yielded a verifier, bigfoot_verifier passes it through +def test_tripwire_verifier_returns_auto_verifier() -> None: + """tripwire_verifier must return the same StrictVerifier as the auto-verifier.""" + # Simulate: _tripwire_auto_verifier yielded a verifier, tripwire_verifier passes it through mock_auto_verifier = MagicMock(spec=StrictVerifier) - result = bigfoot_verifier.__wrapped__(mock_auto_verifier) # type: ignore[attr-defined] + result = tripwire_verifier.__wrapped__(mock_auto_verifier) # type: ignore[attr-defined] assert result is mock_auto_verifier -def test_bigfoot_verifier_returns_strict_verifier_instance() -> None: +def test_tripwire_verifier_returns_strict_verifier_instance() -> None: """The explicit fixture must return a StrictVerifier.""" real_verifier = StrictVerifier() - result = bigfoot_verifier.__wrapped__(real_verifier) # type: ignore[attr-defined] + result = tripwire_verifier.__wrapped__(real_verifier) # type: ignore[attr-defined] assert isinstance(result, StrictVerifier) assert result is real_verifier @@ -125,22 +125,22 @@ class TestContextPropagationLifecycle: def test_context_propagation_installed_during_test(self) -> None: """Context propagation should be active during test execution.""" - import bigfoot._context_propagation as cp + import tripwire._context_propagation as cp assert cp._installed is True def test_thread_sees_verifier_during_sandbox( self, - bigfoot_verifier: StrictVerifier, + tripwire_verifier: StrictVerifier, ) -> None: """A child thread inside a sandbox can see the active verifier.""" import threading - from bigfoot._context import _active_verifier + from tripwire._context import _active_verifier captured: list[object] = [] - with bigfoot_verifier.sandbox(): + with tripwire_verifier.sandbox(): verifier_in_parent = _active_verifier.get() def worker() -> None: diff --git a/tests/unit/test_redis_plugin.py b/tests/unit/test_redis_plugin.py index dbe997d..e1c547c 100644 --- a/tests/unit/test_redis_plugin.py +++ b/tests/unit/test_redis_plugin.py @@ -4,13 +4,13 @@ import pytest -from bigfoot._context import _current_test_verifier -from bigfoot._errors import InteractionMismatchError, UnmockedInteractionError -from bigfoot._verifier import StrictVerifier +from tripwire._context import _current_test_verifier +from tripwire._errors import InteractionMismatchError, UnmockedInteractionError +from tripwire._verifier import StrictVerifier redis = pytest.importorskip("redis") -from bigfoot.plugins.redis_plugin import ( # noqa: E402 +from tripwire.plugins.redis_plugin import ( # noqa: E402 _REDIS_AVAILABLE, RedisMockConfig, RedisPlugin, @@ -74,14 +74,14 @@ def test_redis_available_flag() -> None: # MUTATION: Not checking the flag and proceeding normally would not raise. # ESCAPE: Raising ImportError with a different message fails the exact string check. def test_activate_raises_when_redis_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: - import bigfoot.plugins.redis_plugin as _rp + import tripwire.plugins.redis_plugin as _rp v, p = _make_verifier_with_plugin() monkeypatch.setattr(_rp, "_REDIS_AVAILABLE", False) with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install bigfoot[redis] to use RedisPlugin: pip install bigfoot[redis]" + "Install tripwire[redis] to use RedisPlugin: pip install tripwire[redis]" ) @@ -125,7 +125,7 @@ def test_redis_mock_config_defaults() -> None: # ESCAPE: test_activate_installs_patch -# CLAIM: After activate(), redis.Redis.execute_command is replaced with bigfoot interceptor. +# CLAIM: After activate(), redis.Redis.execute_command is replaced with tripwire interceptor. # PATH: activate() -> _install_count == 0 -> store original -> install interceptor. # CHECK: redis.Redis.execute_command is not the original after activate(). # MUTATION: Skipping patch installation leaves original in place; identity check fails. @@ -142,7 +142,7 @@ def test_activate_installs_patch() -> None: # CLAIM: After activate() then deactivate(), redis.Redis.execute_command is restored. # PATH: deactivate() -> _install_count reaches 0 -> restore original. # CHECK: redis.Redis.execute_command is the original after deactivate(). -# MUTATION: Not restoring in deactivate() leaves bigfoot's interceptor in place. +# MUTATION: Not restoring in deactivate() leaves tripwire's interceptor in place. # ESCAPE: Nothing reasonable -- identity comparison against saved original. def test_deactivate_restores_patch() -> None: original = redis.Redis.execute_command @@ -399,7 +399,7 @@ def test_unmocked_error_after_queue_exhausted() -> None: # MUTATION: Returning True always fails the non-matching field check. # ESCAPE: Nothing reasonable -- exact boolean equality on distinct cases. def test_matches_field_comparison() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -425,7 +425,7 @@ def test_matches_field_comparison() -> None: # MUTATION: Returning frozenset() skips completeness enforcement entirely. # ESCAPE: Nothing reasonable -- exact equality. def test_assertable_fields_all_three() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction(source_id="redis:get", sequence=0, details={}, plugin=p) @@ -444,7 +444,7 @@ def test_assertable_fields_all_three() -> None: # MUTATION: Returning wrong format string fails equality check. # ESCAPE: Different order or missing fields in format string fails the equality check. def test_format_interaction() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -464,7 +464,7 @@ def test_format_interaction() -> None: # MUTATION: Crashing on empty args fails the test. # ESCAPE: Returning wrong format fails equality. def test_format_interaction_no_args() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -484,7 +484,7 @@ def test_format_interaction_no_args() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Different format fails the equality check. def test_format_mock_hint() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -494,7 +494,7 @@ def test_format_mock_hint() -> None: plugin=p, ) result = p.format_mock_hint(interaction) - assert result == " bigfoot.redis_mock.mock_command('GET', returns=...)" + assert result == " tripwire.redis_mock.mock_command('GET', returns=...)" # ESCAPE: test_format_unmocked_hint @@ -509,7 +509,7 @@ def test_format_unmocked_hint() -> None: assert result == ( "redis.GET(...) was called but no mock was registered.\n" "Register a mock with:\n" - " bigfoot.redis_mock.mock_command('GET', returns=...)" + " tripwire.redis_mock.mock_command('GET', returns=...)" ) @@ -520,7 +520,7 @@ def test_format_unmocked_hint() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Different format fails the equality check. def test_format_assert_hint() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -531,7 +531,7 @@ def test_format_assert_hint() -> None: ) result = p.format_assert_hint(interaction) assert result == ( - " bigfoot.redis_mock.assert_command(\n" + " tripwire.redis_mock.assert_command(\n" " command='GET',\n" " args=('mykey',),\n" " kwargs={},\n" @@ -556,44 +556,44 @@ def test_format_unused_mock_hint() -> None: # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.redis_mock +# Module-level proxy: tripwire.redis_mock # --------------------------------------------------------------------------- # ESCAPE: test_redis_mock_proxy_mock_command -# CLAIM: bigfoot.redis_mock.mock_command("GET", returns="v") works when verifier is active. +# CLAIM: tripwire.redis_mock.mock_command("GET", returns="v") works when verifier is active. # PATH: _RedisProxy.__getattr__("mock_command") -> get verifier -> # find/create RedisPlugin -> return plugin.mock_command. # CHECK: The proxy call does not raise and the mock is registered. # MUTATION: Returning None instead of the plugin fails with AttributeError on mock_command. # ESCAPE: Nothing reasonable -- call succeeds or raises. -def test_redis_mock_proxy_mock_command(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_redis_mock_proxy_mock_command(tripwire_verifier: StrictVerifier) -> None: + import tripwire - bigfoot.redis_mock.mock_command("GET", returns="proxy_value", required=True) + tripwire.redis_mock.mock_command("GET", returns="proxy_value", required=True) - with bigfoot.sandbox(): + with tripwire.sandbox(): r = redis.Redis() result = r.execute_command("GET", "somekey") assert result == "proxy_value" - bigfoot.redis_mock.assert_command("GET", args=("somekey",), kwargs={}) + tripwire.redis_mock.assert_command("GET", args=("somekey",), kwargs={}) # ESCAPE: test_redis_mock_proxy_raises_outside_context -# CLAIM: Accessing bigfoot.redis_mock outside a test context raises NoActiveVerifierError. +# CLAIM: Accessing tripwire.redis_mock outside a test context raises NoActiveVerifierError. # PATH: _RedisProxy.__getattr__ -> _get_test_verifier_or_raise -> NoActiveVerifierError. # CHECK: NoActiveVerifierError raised. # MUTATION: Silently returning None would not raise. # ESCAPE: Nothing reasonable -- exact exception type. def test_redis_mock_proxy_raises_outside_context() -> None: - import bigfoot - from bigfoot._errors import NoActiveVerifierError + import tripwire + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.redis_mock.mock_command + _ = tripwire.redis_mock.mock_command finally: _current_test_verifier.reset(token) @@ -604,17 +604,17 @@ def test_redis_mock_proxy_raises_outside_context() -> None: # ESCAPE: test_redis_plugin_in_all -# CLAIM: RedisPlugin and redis_mock are exported from bigfoot.__all__. -# PATH: bigfoot.__all__ contains "RedisPlugin" and "redis_mock". -# CHECK: "RedisPlugin" in bigfoot.__all__; "redis_mock" in bigfoot.__all__. +# CLAIM: RedisPlugin and redis_mock are exported from tripwire.__all__. +# PATH: tripwire.__all__ contains "RedisPlugin" and "redis_mock". +# CHECK: "RedisPlugin" in tripwire.__all__; "redis_mock" in tripwire.__all__. # MUTATION: Omitting either from __all__ fails the membership check. # ESCAPE: Nothing reasonable -- exact membership check. def test_redis_plugin_in_all() -> None: - import bigfoot - from bigfoot.plugins.redis_plugin import RedisPlugin as _RedisPlugin + import tripwire + from tripwire.plugins.redis_plugin import RedisPlugin as _RedisPlugin - assert bigfoot.RedisPlugin is _RedisPlugin - assert type(bigfoot.redis_mock).__name__ == "_RedisProxy" + assert tripwire.RedisPlugin is _RedisPlugin + assert type(tripwire.redis_mock).__name__ == "_RedisProxy" # --------------------------------------------------------------------------- @@ -622,43 +622,43 @@ def test_redis_plugin_in_all() -> None: # --------------------------------------------------------------------------- -def test_redis_interactions_not_auto_asserted(bigfoot_verifier: StrictVerifier) -> None: +def test_redis_interactions_not_auto_asserted(tripwire_verifier: StrictVerifier) -> None: """Redis interactions are NOT auto-asserted — they land on the timeline unasserted.""" - import bigfoot + import tripwire - bigfoot.redis_mock.mock_command("GET", returns=b"value") - with bigfoot.sandbox(): + tripwire.redis_mock.mock_command("GET", returns=b"value") + with tripwire.sandbox(): client = redis.Redis() client.execute_command("GET", "key") # At this point the interaction is on the timeline but NOT asserted - timeline = bigfoot_verifier._timeline + timeline = tripwire_verifier._timeline interactions = timeline.all_unasserted() assert len(interactions) == 1 assert interactions[0].source_id == "redis:get" # Assert it so verify_all() at teardown succeeds - bigfoot.redis_mock.assert_command("GET", args=("key",), kwargs={}) + tripwire.redis_mock.assert_command("GET", args=("key",), kwargs={}) -def test_assert_command_typed_helper(bigfoot_verifier: StrictVerifier) -> None: +def test_assert_command_typed_helper(tripwire_verifier: StrictVerifier) -> None: """assert_command() asserts the next Redis interaction.""" - import bigfoot + import tripwire - bigfoot.redis_mock.mock_command("SET", returns=True) - with bigfoot.sandbox(): + tripwire.redis_mock.mock_command("SET", returns=True) + with tripwire.sandbox(): client = redis.Redis() client.execute_command("SET", "key", "value") - bigfoot.redis_mock.assert_command("SET", args=("key", "value"), kwargs={}) + tripwire.redis_mock.assert_command("SET", args=("key", "value"), kwargs={}) -def test_assert_command_wrong_args_raises(bigfoot_verifier: StrictVerifier) -> None: +def test_assert_command_wrong_args_raises(tripwire_verifier: StrictVerifier) -> None: """assert_command() with wrong args raises InteractionMismatchError.""" - import bigfoot + import tripwire - bigfoot.redis_mock.mock_command("GET", returns=b"val") - with bigfoot.sandbox(): + tripwire.redis_mock.mock_command("GET", returns=b"val") + with tripwire.sandbox(): client = redis.Redis() client.execute_command("GET", "key") with pytest.raises(InteractionMismatchError): - bigfoot.redis_mock.assert_command("GET", args=("wrong_key",), kwargs={}) + tripwire.redis_mock.assert_command("GET", args=("wrong_key",), kwargs={}) # Now assert correctly so teardown passes - bigfoot.redis_mock.assert_command("GET", args=("key",), kwargs={}) + tripwire.redis_mock.assert_command("GET", args=("key",), kwargs={}) diff --git a/tests/unit/test_registry.py b/tests/unit/test_registry.py index 8498fde..a1984b7 100644 --- a/tests/unit/test_registry.py +++ b/tests/unit/test_registry.py @@ -1,11 +1,11 @@ -"""Unit tests for bigfoot._registry: plugin registry and config resolution.""" +"""Unit tests for tripwire._registry: plugin registry and config resolution.""" from unittest.mock import patch import pytest -from bigfoot._errors import BigfootConfigError -from bigfoot._registry import ( +from tripwire._errors import TripwireConfigError +from tripwire._registry import ( PLUGIN_REGISTRY, VALID_PLUGIN_NAMES, PluginEntry, @@ -79,14 +79,14 @@ def test_plugin_registry_names_are_unique() -> None: def test_is_available_always_returns_true() -> None: """Plugins with availability_check='always' are always available.""" - entry = PluginEntry("test", "bigfoot.plugins.subprocess", "SubprocessPlugin", "always") + entry = PluginEntry("test", "tripwire.plugins.subprocess", "SubprocessPlugin", "always") assert _is_available(entry) is True def test_is_available_httpx_requests_when_installed() -> None: """HttpPlugin availability check returns True when httpx and requests are installed.""" # httpx and requests are in dev deps, so they should be available - entry = PluginEntry("http", "bigfoot.plugins.http", "HttpPlugin", "httpx+requests") + entry = PluginEntry("http", "tripwire.plugins.http", "HttpPlugin", "httpx+requests") assert _is_available(entry) is True @@ -94,7 +94,7 @@ def test_is_available_websockets_uses_flag() -> None: """Websockets availability check reads _WEBSOCKETS_AVAILABLE flag.""" entry = PluginEntry( "async_websocket", - "bigfoot.plugins.websocket_plugin", + "tripwire.plugins.websocket_plugin", "AsyncWebSocketPlugin", "websockets", ) @@ -105,7 +105,7 @@ def test_is_available_websockets_uses_flag() -> None: def test_is_available_redis_uses_flag() -> None: """Redis availability check reads _REDIS_AVAILABLE flag.""" entry = PluginEntry( - "redis", "bigfoot.plugins.redis_plugin", "RedisPlugin", "redis" + "redis", "tripwire.plugins.redis_plugin", "RedisPlugin", "redis" ) # redis is in dev deps, should be available assert _is_available(entry) is True @@ -113,7 +113,7 @@ def test_is_available_redis_uses_flag() -> None: def test_is_available_unknown_check_returns_false() -> None: """Unknown availability_check values return False.""" - entry = PluginEntry("fake", "bigfoot.plugins.fake", "FakePlugin", "nonexistent_dep") + entry = PluginEntry("fake", "tripwire.plugins.fake", "FakePlugin", "nonexistent_dep") assert _is_available(entry) is False @@ -141,7 +141,7 @@ def test_multi_module_one_missing(self) -> None: assert _is_available(entry) is False def test_flag_based_check(self) -> None: - entry = PluginEntry("test", "x.y", "X", "flag:bigfoot.plugins.redis_plugin:_REDIS_AVAILABLE") + entry = PluginEntry("test", "x.y", "X", "flag:tripwire.plugins.redis_plugin:_REDIS_AVAILABLE") result = _is_available(entry) assert isinstance(result, bool) @@ -153,16 +153,16 @@ def test_flag_based_check(self) -> None: def test_get_plugin_class_returns_correct_class() -> None: """get_plugin_class returns the actual class for a valid entry.""" - from bigfoot.plugins.subprocess import SubprocessPlugin + from tripwire.plugins.subprocess import SubprocessPlugin - entry = PluginEntry("subprocess", "bigfoot.plugins.subprocess", "SubprocessPlugin", "always") + entry = PluginEntry("subprocess", "tripwire.plugins.subprocess", "SubprocessPlugin", "always") cls = get_plugin_class(entry) assert cls is SubprocessPlugin def test_get_plugin_class_import_error_for_bad_path() -> None: """get_plugin_class raises ImportError for a nonexistent module.""" - entry = PluginEntry("fake", "bigfoot.plugins.nonexistent", "FakePlugin", "always") + entry = PluginEntry("fake", "tripwire.plugins.nonexistent", "FakePlugin", "always") with pytest.raises(ImportError): get_plugin_class(entry) @@ -202,8 +202,8 @@ def test_resolve_enabled_plugins_blocklist() -> None: def test_resolve_enabled_plugins_mutual_exclusion() -> None: - """Both keys present raises BigfootConfigError.""" - with pytest.raises(BigfootConfigError, match="mutually exclusive"): + """Both keys present raises TripwireConfigError.""" + with pytest.raises(TripwireConfigError, match="mutually exclusive"): resolve_enabled_plugins({ "enabled_plugins": ["http"], "disabled_plugins": ["subprocess"], @@ -211,26 +211,26 @@ def test_resolve_enabled_plugins_mutual_exclusion() -> None: def test_resolve_enabled_plugins_unknown_name_in_enabled() -> None: - """Unknown name in enabled_plugins raises BigfootConfigError.""" - with pytest.raises(BigfootConfigError, match="Unknown plugin name"): + """Unknown name in enabled_plugins raises TripwireConfigError.""" + with pytest.raises(TripwireConfigError, match="Unknown plugin name"): resolve_enabled_plugins({"enabled_plugins": ["nonexistent"]}) def test_resolve_enabled_plugins_unknown_name_in_disabled() -> None: - """Unknown name in disabled_plugins raises BigfootConfigError.""" - with pytest.raises(BigfootConfigError, match="Unknown plugin name"): + """Unknown name in disabled_plugins raises TripwireConfigError.""" + with pytest.raises(TripwireConfigError, match="Unknown plugin name"): resolve_enabled_plugins({"disabled_plugins": ["nonexistent"]}) def test_resolve_enabled_plugins_invalid_type_string() -> None: - """enabled_plugins as string (not list) raises BigfootConfigError.""" - with pytest.raises(BigfootConfigError, match="must be a list of strings"): + """enabled_plugins as string (not list) raises TripwireConfigError.""" + with pytest.raises(TripwireConfigError, match="must be a list of strings"): resolve_enabled_plugins({"enabled_plugins": "http"}) def test_resolve_enabled_plugins_invalid_type_disabled_string() -> None: - """disabled_plugins as string (not list) raises BigfootConfigError.""" - with pytest.raises(BigfootConfigError, match="must be a list of strings"): + """disabled_plugins as string (not list) raises TripwireConfigError.""" + with pytest.raises(TripwireConfigError, match="must be a list of strings"): resolve_enabled_plugins({"disabled_plugins": "http"}) @@ -247,8 +247,8 @@ class TestDefaultEnabled: def test_default_enabled_false_excluded_from_default(self) -> None: entry = PluginEntry("test_opt", "x.y", "X", "always", default_enabled=False) always_entry = PluginEntry("test_always", "x.y", "Y", "always", default_enabled=True) - with patch("bigfoot._registry.PLUGIN_REGISTRY", (entry, always_entry)): - with patch("bigfoot._registry.VALID_PLUGIN_NAMES", frozenset({"test_opt", "test_always"})): + with patch("tripwire._registry.PLUGIN_REGISTRY", (entry, always_entry)): + with patch("tripwire._registry.VALID_PLUGIN_NAMES", frozenset({"test_opt", "test_always"})): result = resolve_enabled_plugins({}) names = [e.name for e in result] assert "test_opt" not in names @@ -256,15 +256,15 @@ def test_default_enabled_false_excluded_from_default(self) -> None: def test_default_enabled_false_included_when_explicit(self) -> None: entry = PluginEntry("test_opt", "x.y", "X", "always", default_enabled=False) - with patch("bigfoot._registry.PLUGIN_REGISTRY", (entry,)): - with patch("bigfoot._registry.VALID_PLUGIN_NAMES", frozenset({"test_opt"})): + with patch("tripwire._registry.PLUGIN_REGISTRY", (entry,)): + with patch("tripwire._registry.VALID_PLUGIN_NAMES", frozenset({"test_opt"})): result = resolve_enabled_plugins({"enabled_plugins": ["test_opt"]}) assert any(e.name == "test_opt" for e in result) def test_resolve_enabled_plugins_error_lists_valid_names() -> None: """Error message for unknown names includes the list of valid names.""" - with pytest.raises(BigfootConfigError) as exc_info: + with pytest.raises(TripwireConfigError) as exc_info: resolve_enabled_plugins({"enabled_plugins": ["bogus"]}) error_msg = str(exc_info.value) assert "subprocess" in error_msg diff --git a/tests/unit/test_sandbox_mock_activation.py b/tests/unit/test_sandbox_mock_activation.py index 3aa03bf..6440309 100644 --- a/tests/unit/test_sandbox_mock_activation.py +++ b/tests/unit/test_sandbox_mock_activation.py @@ -3,8 +3,8 @@ import sys import types -from bigfoot._mock_plugin import ImportSiteMock, MockPlugin -from bigfoot._verifier import SandboxContext, StrictVerifier +from tripwire._mock_plugin import ImportSiteMock, MockPlugin +from tripwire._verifier import SandboxContext, StrictVerifier def _create_fake_module(name: str, **attrs: object) -> types.ModuleType: @@ -23,15 +23,15 @@ def _drain_unused_mocks(plugin: MockPlugin) -> None: config.required = False -def test_sandbox_activates_mocks_with_enforce_true(bigfoot_verifier: StrictVerifier) -> None: +def test_sandbox_activates_mocks_with_enforce_true(tripwire_verifier: StrictVerifier) -> None: """Sandbox entry activates all registered mocks with enforce=True.""" mod = _create_fake_module("_test_sandbox_act", fn=lambda: "real") try: - plugin = MockPlugin(bigfoot_verifier) + plugin = MockPlugin(tripwire_verifier) mock = ImportSiteMock(path="_test_sandbox_act:fn", plugin=plugin) mock.returns("mocked") - ctx = SandboxContext(bigfoot_verifier) + ctx = SandboxContext(tripwire_verifier) ctx._enter() assert mock._active is True assert mock._enforce is True @@ -41,15 +41,15 @@ def test_sandbox_activates_mocks_with_enforce_true(bigfoot_verifier: StrictVerif del sys.modules["_test_sandbox_act"] -def test_sandbox_deactivates_mocks_on_exit(bigfoot_verifier: StrictVerifier) -> None: +def test_sandbox_deactivates_mocks_on_exit(tripwire_verifier: StrictVerifier) -> None: """Sandbox exit deactivates all registered mocks.""" mod = _create_fake_module("_test_sandbox_deact", fn=lambda: "real") try: - plugin = MockPlugin(bigfoot_verifier) + plugin = MockPlugin(tripwire_verifier) mock = ImportSiteMock(path="_test_sandbox_deact:fn", plugin=plugin) mock.returns("mocked") - ctx = SandboxContext(bigfoot_verifier) + ctx = SandboxContext(tripwire_verifier) ctx._enter() assert mock._active is True ctx._exit() @@ -60,7 +60,7 @@ def test_sandbox_deactivates_mocks_on_exit(bigfoot_verifier: StrictVerifier) -> del sys.modules["_test_sandbox_deact"] -def test_sandbox_deactivates_mocks_in_reverse_order(bigfoot_verifier: StrictVerifier) -> None: +def test_sandbox_deactivates_mocks_in_reverse_order(tripwire_verifier: StrictVerifier) -> None: """Mocks are deactivated in reverse activation order.""" deactivation_order: list[str] = [] @@ -68,7 +68,7 @@ def test_sandbox_deactivates_mocks_in_reverse_order(bigfoot_verifier: StrictVeri "_test_sandbox_order", fn1=lambda: "r1", fn2=lambda: "r2" ) try: - plugin = MockPlugin(bigfoot_verifier) + plugin = MockPlugin(tripwire_verifier) m1 = ImportSiteMock(path="_test_sandbox_order:fn1", plugin=plugin) m1.returns("m1") m2 = ImportSiteMock(path="_test_sandbox_order:fn2", plugin=plugin) @@ -86,7 +86,7 @@ def track2() -> None: deactivation_order.append("m2") orig_deact_2() - ctx = SandboxContext(bigfoot_verifier) + ctx = SandboxContext(tripwire_verifier) ctx._enter() # Monkey-patch deactivate to track order @@ -100,12 +100,12 @@ def track2() -> None: del sys.modules["_test_sandbox_order"] -def test_sandbox_deactivates_mocks_before_plugins(bigfoot_verifier: StrictVerifier) -> None: +def test_sandbox_deactivates_mocks_before_plugins(tripwire_verifier: StrictVerifier) -> None: """Mocks deactivate before plugins during sandbox exit.""" order: list[str] = [] mod = _create_fake_module("_test_sandbox_order2", fn=lambda: "real") try: - plugin = MockPlugin(bigfoot_verifier) + plugin = MockPlugin(tripwire_verifier) orig_deactivate = plugin.deactivate def track_plugin_deactivate() -> None: @@ -123,7 +123,7 @@ def track_mock_deactivate() -> None: order.append("mock") orig_mock_deact() - ctx = SandboxContext(bigfoot_verifier) + ctx = SandboxContext(tripwire_verifier) ctx._enter() mock._deactivate = track_mock_deactivate # type: ignore[assignment] ctx._exit() diff --git a/tests/unit/test_smoke_rename.py b/tests/unit/test_smoke_rename.py new file mode 100644 index 0000000..754f97a --- /dev/null +++ b/tests/unit/test_smoke_rename.py @@ -0,0 +1,119 @@ +"""Smoke tests for the package rename and pytest plugin registration (C1). + +These tests verify the rename's structural preconditions: +- The package imports under its new name and resolves to the on-disk source. +- The pytest entry-point registers under the new name and version. +- No source/test file still references the old name. + +Note on string construction: the forbidden-name needle in C1-T5 is built +character-by-character so a future re-run of the rename sed pass cannot +accidentally rewrite the test's own assertion target. +""" + +from __future__ import annotations + +import importlib.metadata +import subprocess +import sys +from pathlib import Path + +import pytest + +# Rebuild "bigfoot" without writing the literal substring, so any future +# global rename sed pass cannot silently rewrite this assertion target. +_FORBIDDEN_NAME = "b" + "igfoot" + + +# C1-T1 +def test_import_tripwire_resolves() -> None: + """`import tripwire` resolves under src/tripwire/ and metadata version == 0.20.0. + + The codebase audit confirmed no `tripwire.__version__` symbol is exposed, + so version is sourced exclusively from package metadata (pyproject.toml). + """ + import tripwire + + module_path = Path(tripwire.__file__).resolve() + repo_root = Path(__file__).resolve().parents[2] + expected_pkg_dir = (repo_root / "src" / "tripwire").resolve() + assert module_path.parent == expected_pkg_dir, ( + f"tripwire imported from {module_path}, expected under {expected_pkg_dir}" + ) + + assert importlib.metadata.version("tripwire") == "0.20.0" + + +# C1-T2 +@pytest.mark.allow("subprocess") +def test_pytest_entrypoint_registered() -> None: + """The `tripwire` pytest11 entry-point is registered against tripwire 0.20.0 + AND `pytest --trace-config` actually loads `tripwire.pytest_plugin`. + + The two halves together guard against: + - A stale legacy entry-point (e.g., `bigfoot`) still being registered. + - The entry-point existing in metadata but failing to load at pytest start. + """ + # Half 1: the pytest11 entry-point is registered against tripwire 0.20.0. + dist = importlib.metadata.distribution("tripwire") + pytest11_eps = [ep for ep in dist.entry_points if ep.group == "pytest11"] + assert pytest11_eps == [ + importlib.metadata.EntryPoint( + name="tripwire", + value="tripwire.pytest_plugin", + group="pytest11", + ) + ], f"unexpected pytest11 entry-points: {pytest11_eps!r}" + assert dist.version == "0.20.0" + + # Half 2: `pytest --trace-config` actually loads tripwire.pytest_plugin. + result = subprocess.run( + [sys.executable, "-m", "pytest", "--trace-config", "--collect-only", "-q"], + capture_output=True, + text=True, + cwd=Path(__file__).resolve().parents[2], + check=False, + ) + combined = result.stdout + result.stderr + assert "tripwire.pytest_plugin" in combined, ( + "tripwire.pytest_plugin not loaded in pytest --trace-config output:\n" + f"{combined}" + ) + + +# C1-T5 +def test_no_old_package_name_remains_in_source() -> None: + """No .py file in src/ or tests/ contains the lowercased forbidden name, + except for files that legitimately document the migration path. + + The forbidden name is the pre-0.20.0 package name (see _FORBIDDEN_NAME at + module top). CHANGELOG.md and the proposal file are intentionally out of + scope: this scan covers only Python source under src/ and tests/. + + Allowlist (files that MUST reference the old name to do their job): + - src/tripwire/_config.py: implements the `[tool.]` migration check. + - src/tripwire/_errors.py: defines/documents `ConfigMigrationError`. + - tests/unit/test_smoke_rename.py: this file (documents the rename). + - tests/unit/test_bigfoot_migration_error.py: exercises the migration check. + """ + repo_root = Path(__file__).resolve().parents[2] + allowlist = frozenset( + { + "src/tripwire/_config.py", + "src/tripwire/_errors.py", + "tests/unit/test_smoke_rename.py", + "tests/unit/test_bigfoot_migration_error.py", + } + ) + offenders: list[str] = [] + for base in ("src", "tests"): + for py_file in (repo_root / base).rglob("*.py"): + rel_posix = py_file.relative_to(repo_root).as_posix() + if rel_posix in allowlist: + continue + text = py_file.read_text(encoding="utf-8") + if _FORBIDDEN_NAME in text.lower(): + offenders.append(rel_posix) + assert offenders == [], ( + f"Files still reference {_FORBIDDEN_NAME!r} after rename:\n" + + "\n".join(offenders) + ) diff --git a/tests/unit/test_smtp_plugin.py b/tests/unit/test_smtp_plugin.py index 79baa24..3713362 100644 --- a/tests/unit/test_smtp_plugin.py +++ b/tests/unit/test_smtp_plugin.py @@ -6,12 +6,12 @@ import pytest -import bigfoot -from bigfoot._context import _current_test_verifier -from bigfoot._errors import InvalidStateError, UnmockedInteractionError -from bigfoot._state_machine_plugin import ScriptStep -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.smtp_plugin import ( +import tripwire +from tripwire._context import _current_test_verifier +from tripwire._errors import InvalidStateError, UnmockedInteractionError +from tripwire._state_machine_plugin import ScriptStep +from tripwire._verifier import StrictVerifier +from tripwire.plugins.smtp_plugin import ( _ORIGINAL_SMTP, SmtpPlugin, _FakeSMTP, @@ -484,65 +484,65 @@ def test_smtp_with_empty_queue_raises_unmocked() -> None: # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.smtp_mock +# Module-level proxy: tripwire.smtp_mock # --------------------------------------------------------------------------- # ESCAPE: test_smtp_mock_proxy_new_session -# CLAIM: bigfoot.smtp_mock.new_session() returns a SessionHandle that can +# CLAIM: tripwire.smtp_mock.new_session() returns a SessionHandle that can # be used to configure a session without importing SmtpPlugin directly. # PATH: _SmtpProxy.__getattr__("new_session") -> get verifier -> find/create SmtpPlugin -> # return plugin.new_session. # CHECK: session is a SessionHandle instance; chaining .expect() does not raise. # MUTATION: Returning None instead of a SessionHandle would fail isinstance check. # ESCAPE: Nothing reasonable -- both the isinstance and the chained .expect() call check it. -def test_smtp_mock_proxy_new_session(bigfoot_verifier: StrictVerifier) -> None: - from bigfoot._state_machine_plugin import SessionHandle +def test_smtp_mock_proxy_new_session(tripwire_verifier: StrictVerifier) -> None: + from tripwire._state_machine_plugin import SessionHandle - session = bigfoot.smtp_mock.new_session() + session = tripwire.smtp_mock.new_session() assert isinstance(session, SessionHandle) result = session.expect("connect", returns=None, required=False) assert result is session # expect() returns self for chaining # ESCAPE: test_smtp_mock_proxy_raises_outside_context -# CLAIM: Accessing bigfoot.smtp_mock outside a test context raises NoActiveVerifierError. +# CLAIM: Accessing tripwire.smtp_mock outside a test context raises NoActiveVerifierError. # PATH: _SmtpProxy.__getattr__ -> _get_test_verifier_or_raise -> NoActiveVerifierError. # CHECK: NoActiveVerifierError raised. # MUTATION: Silently returning None would not raise and hide context failures. # ESCAPE: Nothing reasonable -- exact exception type. def test_smtp_mock_proxy_raises_outside_context() -> None: - from bigfoot._errors import NoActiveVerifierError + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.smtp_mock.new_session + _ = tripwire.smtp_mock.new_session finally: _current_test_verifier.reset(token) # --------------------------------------------------------------------------- -# Full session via module-level API: bigfoot.sandbox() +# Full session via module-level API: tripwire.sandbox() # --------------------------------------------------------------------------- # ESCAPE: test_full_session_via_sandbox # CLAIM: A complete SMTP session (connect -> ehlo -> sendmail -> quit) runs end-to-end -# through the module-level bigfoot.sandbox() API, returning the scripted values. -# PATH: bigfoot.smtp_mock.new_session() -> sandbox -> _FakeSMTP.__init__ -> +# through the module-level tripwire.sandbox() API, returning the scripted values. +# PATH: tripwire.smtp_mock.new_session() -> sandbox -> _FakeSMTP.__init__ -> # ehlo -> sendmail -> quit. # CHECK: sendmail_result == {}; quit_result == (221, b"Bye"). # MUTATION: Returning wrong sendmail result would fail the equality check. # ESCAPE: Nothing reasonable -- exact equality on both returns. -def test_full_session_via_sandbox(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.smtp_mock.new_session() +def test_full_session_via_sandbox(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.smtp_mock.new_session() session.expect("connect", returns=None) session.expect("ehlo", returns=(250, b"OK")) session.expect("sendmail", returns={}) session.expect("quit", returns=(221, b"Bye")) - with bigfoot.sandbox(): + with tripwire.sandbox(): smtp = smtplib.SMTP("mail.example.com", 25) smtp.ehlo() sendmail_result = smtp.sendmail( @@ -553,11 +553,11 @@ def test_full_session_via_sandbox(bigfoot_verifier: StrictVerifier) -> None: assert sendmail_result == {} assert quit_result == (221, b"Bye") - bigfoot.smtp_mock.assert_connect(host="mail.example.com", port=25) - bigfoot.smtp_mock.assert_ehlo(name="") - bigfoot.smtp_mock.assert_sendmail( + tripwire.smtp_mock.assert_connect(host="mail.example.com", port=25) + tripwire.smtp_mock.assert_ehlo(name="") + tripwire.smtp_mock.assert_sendmail( from_addr="from@example.com", to_addrs=["to@example.com"], msg="Subject: test\r\n\r\ntest", ) - bigfoot.smtp_mock.assert_quit() + tripwire.smtp_mock.assert_quit() diff --git a/tests/unit/test_socket_plugin.py b/tests/unit/test_socket_plugin.py index 5de0467..e017f5d 100644 --- a/tests/unit/test_socket_plugin.py +++ b/tests/unit/test_socket_plugin.py @@ -6,12 +6,12 @@ import pytest -import bigfoot -from bigfoot._context import _current_test_verifier -from bigfoot._errors import InvalidStateError, UnmockedInteractionError -from bigfoot._state_machine_plugin import ScriptStep -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.socket_plugin import ( +import tripwire +from tripwire._context import _current_test_verifier +from tripwire._errors import InvalidStateError, UnmockedInteractionError +from tripwire._state_machine_plugin import ScriptStep +from tripwire._verifier import StrictVerifier +from tripwire.plugins.socket_plugin import ( _SOCKET_CLOSE_ORIGINAL, _SOCKET_CONNECT_ORIGINAL, _SOCKET_RECV_ORIGINAL, @@ -106,7 +106,7 @@ def test_unmocked_source_id() -> None: # ESCAPE: test_activate_installs_patches # CLAIM: After activate(), socket.socket.connect/send/sendall/recv/close are -# replaced with bigfoot interceptors (not the originals anymore). +# replaced with tripwire interceptors (not the originals anymore). # PATH: activate() -> _install_count == 0 -> store originals -> install interceptors. # CHECK: Each method is not the same object as the import-time original. # MUTATION: Skipping patch installation leaves originals in place; identity checks fail. @@ -131,7 +131,7 @@ def test_activate_installs_patches() -> None: # are restored to the import-time originals. # PATH: deactivate() -> _install_count reaches 0 -> restore originals. # CHECK: All five methods are the import-time originals again. -# MUTATION: Not restoring in deactivate() leaves bigfoot's interceptors in place. +# MUTATION: Not restoring in deactivate() leaves tripwire's interceptors in place. # ESCAPE: Nothing reasonable -- identity comparison against import-time constants. def test_deactivate_restores_patches() -> None: v, p = _make_verifier_with_plugin() @@ -384,12 +384,12 @@ def test_connect_with_empty_queue_raises_unmocked() -> None: # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.socket_mock +# Module-level proxy: tripwire.socket_mock # --------------------------------------------------------------------------- # ESCAPE: test_socket_mock_proxy_new_session -# CLAIM: bigfoot.socket_mock.new_session() returns a SessionHandle that can +# CLAIM: tripwire.socket_mock.new_session() returns a SessionHandle that can # be used to configure a session without importing SocketPlugin directly. # PATH: _SocketProxy.__getattr__("new_session") -> get verifier -> find/create SocketPlugin -> # return plugin.new_session. @@ -397,10 +397,10 @@ def test_connect_with_empty_queue_raises_unmocked() -> None: # Chaining .expect() on it does not raise. # MUTATION: Returning None instead of a SessionHandle would fail isinstance check. # ESCAPE: Nothing reasonable -- both the isinstance and the chained .expect() call check it. -def test_socket_mock_proxy_new_session(bigfoot_verifier: StrictVerifier) -> None: - from bigfoot._state_machine_plugin import SessionHandle +def test_socket_mock_proxy_new_session(tripwire_verifier: StrictVerifier) -> None: + from tripwire._state_machine_plugin import SessionHandle - session = bigfoot.socket_mock.new_session() + session = tripwire.socket_mock.new_session() assert isinstance(session, SessionHandle) # Chaining expect() with required=False so it doesn't trigger UnusedMocksError at teardown. result = session.expect("connect", returns=None, required=False) @@ -408,18 +408,18 @@ def test_socket_mock_proxy_new_session(bigfoot_verifier: StrictVerifier) -> None # ESCAPE: test_socket_mock_proxy_raises_outside_context -# CLAIM: Accessing bigfoot.socket_mock outside a test context raises NoActiveVerifierError. +# CLAIM: Accessing tripwire.socket_mock outside a test context raises NoActiveVerifierError. # PATH: _SocketProxy.__getattr__ -> _get_test_verifier_or_raise -> NoActiveVerifierError. # CHECK: NoActiveVerifierError raised. # MUTATION: Silently returning None would not raise and hide context failures. # ESCAPE: Nothing reasonable -- exact exception type. def test_socket_mock_proxy_raises_outside_context() -> None: - from bigfoot._errors import NoActiveVerifierError + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.socket_mock.new_session + _ = tripwire.socket_mock.new_session finally: _current_test_verifier.reset(token) diff --git a/tests/unit/test_source_id_namespace.py b/tests/unit/test_source_id_namespace.py new file mode 100644 index 0000000..13482a9 --- /dev/null +++ b/tests/unit/test_source_id_namespace.py @@ -0,0 +1,57 @@ +"""C1-T6: every plugin's source_id constants match the colon-namespaced shape. + +Per the locked C-4 decision the convention is `:` or +`::` (e.g. `subprocess:run`, `subprocess:popen:spawn`, +`asyncio:subprocess:spawn`). The `tripwire:` prefix is intentionally absent +because the namespace is implicit inside the tripwire package. +""" + +from __future__ import annotations + +import importlib +import re + +from tripwire._registry import PLUGIN_REGISTRY, _is_available + +# The design doc cites `^[a-z_]+:[a-z_]+(:[a-z_]+)?$` for the colon-namespace +# convention. Library names containing digits (e.g., `psycopg2`, `boto3`) are +# part of the existing surface area, so the segment character class accepts +# `[a-z0-9_]` rather than `[a-z_]`. The structural shape (two or three +# colon-separated lowercase segments, no leading `tripwire:` prefix) is what +# the C-4 decision actually constrains. +_SOURCE_ID_PATTERN = re.compile(r"^[a-z_][a-z0-9_]*:[a-z_][a-z0-9_]*(:[a-z_][a-z0-9_]*)?$") + + +def test_source_ids_use_colon_namespace() -> None: + """Each `_SOURCE_*` constant defined in a registered plugin module matches + the `:` (or three-part) regex. + + Failure modes guarded: + - Sentinel restructure missed a plugin. + - The deprecated `tripwire:` prefix was reintroduced. + """ + offenders: list[str] = [] + checked: list[str] = [] + + for entry in PLUGIN_REGISTRY: + if not _is_available(entry): + continue + module = importlib.import_module(entry.import_path) + for attr_name in dir(module): + if not attr_name.startswith("_SOURCE_"): + continue + value = getattr(module, attr_name) + if not isinstance(value, str): + continue + checked.append(f"{entry.import_path}.{attr_name}={value!r}") + if not _SOURCE_ID_PATTERN.match(value): + offenders.append( + f"{entry.import_path}.{attr_name} = {value!r} " + f"(does not match {_SOURCE_ID_PATTERN.pattern})" + ) + + assert checked, "No _SOURCE_* constants were inspected; registry import failed?" + assert offenders == [], ( + "Source IDs violate the colon-namespace convention:\n" + + "\n".join(offenders) + ) diff --git a/tests/unit/test_spy_mode.py b/tests/unit/test_spy_mode.py index 334659a..28ba946 100644 --- a/tests/unit/test_spy_mode.py +++ b/tests/unit/test_spy_mode.py @@ -5,9 +5,9 @@ import pytest -from bigfoot._context import _active_verifier -from bigfoot._mock_plugin import ImportSiteMock, MockPlugin -from bigfoot._verifier import StrictVerifier +from tripwire._context import _active_verifier +from tripwire._mock_plugin import ImportSiteMock, MockPlugin +from tripwire._verifier import StrictVerifier def _create_fake_module(name: str, **attrs: object) -> types.ModuleType: @@ -18,7 +18,7 @@ def _create_fake_module(name: str, **attrs: object) -> types.ModuleType: return mod -def test_spy_calls_original_function(bigfoot_verifier: StrictVerifier) -> None: +def test_spy_calls_original_function(tripwire_verifier: StrictVerifier) -> None: """Spy delegates to the original function.""" calls: list[tuple] = [] @@ -28,11 +28,11 @@ def real_fn(x: int) -> int: mod = _create_fake_module("_test_spy_calls", fn=real_fn) try: - plugin = MockPlugin(bigfoot_verifier) + plugin = MockPlugin(tripwire_verifier) spy = ImportSiteMock(path="_test_spy_calls:fn", plugin=plugin, spy=True) spy._activate(enforce=True) - token = _active_verifier.set(bigfoot_verifier) + token = _active_verifier.set(tripwire_verifier) try: result = mod.fn(5) finally: @@ -43,7 +43,7 @@ def real_fn(x: int) -> int: spy._deactivate() # Assert the interaction so teardown doesn't raise - bigfoot_verifier.assert_interaction( + tripwire_verifier.assert_interaction( spy.__getattr__("__call__"), args=(5,), kwargs={}, @@ -53,27 +53,27 @@ def real_fn(x: int) -> int: del sys.modules["_test_spy_calls"] -def test_spy_records_returned_value(bigfoot_verifier: StrictVerifier) -> None: +def test_spy_records_returned_value(tripwire_verifier: StrictVerifier) -> None: """Spy records 'returned' in interaction details.""" mod = _create_fake_module("_test_spy_returned", fn=lambda x: x + 1) try: - plugin = MockPlugin(bigfoot_verifier) + plugin = MockPlugin(tripwire_verifier) spy = ImportSiteMock(path="_test_spy_returned:fn", plugin=plugin, spy=True) spy._activate(enforce=True) - token = _active_verifier.set(bigfoot_verifier) + token = _active_verifier.set(tripwire_verifier) try: mod.fn(5) finally: _active_verifier.reset(token) - interactions = bigfoot_verifier._timeline._interactions + interactions = tripwire_verifier._timeline._interactions assert len(interactions) == 1 assert interactions[0].details["returned"] == 6 spy._deactivate() # Assert the interaction so teardown doesn't raise - bigfoot_verifier.assert_interaction( + tripwire_verifier.assert_interaction( spy.__getattr__("__call__"), args=(5,), kwargs={}, @@ -83,25 +83,25 @@ def test_spy_records_returned_value(bigfoot_verifier: StrictVerifier) -> None: del sys.modules["_test_spy_returned"] -def test_spy_records_raised_exception(bigfoot_verifier: StrictVerifier) -> None: +def test_spy_records_raised_exception(tripwire_verifier: StrictVerifier) -> None: """Spy records 'raised' in interaction details when original raises.""" def raises_fn() -> None: raise ValueError("boom") mod = _create_fake_module("_test_spy_raised", fn=raises_fn) try: - plugin = MockPlugin(bigfoot_verifier) + plugin = MockPlugin(tripwire_verifier) spy = ImportSiteMock(path="_test_spy_raised:fn", plugin=plugin, spy=True) spy._activate(enforce=True) - token = _active_verifier.set(bigfoot_verifier) + token = _active_verifier.set(tripwire_verifier) try: with pytest.raises(ValueError, match="boom"): mod.fn() finally: _active_verifier.reset(token) - interactions = bigfoot_verifier._timeline._interactions + interactions = tripwire_verifier._timeline._interactions assert len(interactions) == 1 assert "raised" in interactions[0].details assert isinstance(interactions[0].details["raised"], ValueError) @@ -109,7 +109,7 @@ def raises_fn() -> None: # Assert the interaction so teardown doesn't raise from dirty_equals import IsInstance - bigfoot_verifier.assert_interaction( + tripwire_verifier.assert_interaction( spy.__getattr__("__call__"), args=(), kwargs={}, diff --git a/tests/unit/test_ssh_plugin.py b/tests/unit/test_ssh_plugin.py index d8826f2..ab56326 100644 --- a/tests/unit/test_ssh_plugin.py +++ b/tests/unit/test_ssh_plugin.py @@ -5,12 +5,12 @@ import paramiko import pytest -import bigfoot -from bigfoot._context import _current_test_verifier -from bigfoot._errors import InvalidStateError, UnmockedInteractionError -from bigfoot._state_machine_plugin import ScriptStep -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.ssh_plugin import ( +import tripwire +from tripwire._context import _current_test_verifier +from tripwire._errors import InvalidStateError, UnmockedInteractionError +from tripwire._state_machine_plugin import ScriptStep +from tripwire._verifier import StrictVerifier +from tripwire.plugins.ssh_plugin import ( _PARAMIKO_AVAILABLE, SshPlugin, _FakeSFTPClient, @@ -622,7 +622,7 @@ def test_get_unused_mocks_queued_session_never_bound() -> None: # MUTATION: Returning frozenset() from assertable_fields would skip field validation. # ESCAPE: Nothing reasonable -- exact exception type. def test_assert_interaction_missing_fields_raises() -> None: - from bigfoot._errors import MissingAssertionFieldsError + from tripwire._errors import MissingAssertionFieldsError v, p = _make_verifier_with_plugin() session = p.new_session() @@ -650,20 +650,20 @@ def test_assert_interaction_missing_fields_raises() -> None: # CHECK: No exception raised. # MUTATION: Wrong hostname/port/username/auth_method would raise InteractionMismatchError. # ESCAPE: Nothing reasonable -- helper delegates to assert_interaction with full fields. -def test_assert_connect_helper(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.ssh_mock.new_session() +def test_assert_connect_helper(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.ssh_mock.new_session() session.expect("connect", returns=None) session.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): client = paramiko.SSHClient() client.connect("server.example.com", port=22, username="deploy") client.close() - bigfoot.ssh_mock.assert_connect( + tripwire.ssh_mock.assert_connect( hostname="server.example.com", port=22, username="deploy", auth_method="password" ) - bigfoot.ssh_mock.assert_close() + tripwire.ssh_mock.assert_close() # ESCAPE: test_assert_exec_command_helper @@ -672,23 +672,23 @@ def test_assert_connect_helper(bigfoot_verifier: StrictVerifier) -> None: # CHECK: No exception raised. # MUTATION: Wrong command would raise InteractionMismatchError. # ESCAPE: Nothing reasonable -- helper delegates to assert_interaction with full fields. -def test_assert_exec_command_helper(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.ssh_mock.new_session() +def test_assert_exec_command_helper(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.ssh_mock.new_session() session.expect("connect", returns=None) session.expect("exec_command", returns=("stdin", "stdout", "stderr")) session.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): client = paramiko.SSHClient() client.connect("server.example.com", port=22, username="deploy") client.exec_command("uptime") client.close() - bigfoot.ssh_mock.assert_connect( + tripwire.ssh_mock.assert_connect( hostname="server.example.com", port=22, username="deploy", auth_method="password" ) - bigfoot.ssh_mock.assert_exec_command(command="uptime") - bigfoot.ssh_mock.assert_close() + tripwire.ssh_mock.assert_exec_command(command="uptime") + tripwire.ssh_mock.assert_close() # ESCAPE: test_assert_sftp_get_helper @@ -697,26 +697,26 @@ def test_assert_exec_command_helper(bigfoot_verifier: StrictVerifier) -> None: # CHECK: No exception raised. # MUTATION: Wrong remotepath/localpath would raise InteractionMismatchError. # ESCAPE: Nothing reasonable -- helper delegates to assert_interaction with full fields. -def test_assert_sftp_get_helper(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.ssh_mock.new_session() +def test_assert_sftp_get_helper(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.ssh_mock.new_session() session.expect("connect", returns=None) session.expect("open_sftp", returns=None) session.expect("sftp_get", returns=None) session.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): client = paramiko.SSHClient() client.connect("server.example.com", port=22, username="deploy") sftp = client.open_sftp() sftp.get("/remote/data.csv", "/local/data.csv") client.close() - bigfoot.ssh_mock.assert_connect( + tripwire.ssh_mock.assert_connect( hostname="server.example.com", port=22, username="deploy", auth_method="password" ) - bigfoot.ssh_mock.assert_open_sftp() - bigfoot.ssh_mock.assert_sftp_get(remotepath="/remote/data.csv", localpath="/local/data.csv") - bigfoot.ssh_mock.assert_close() + tripwire.ssh_mock.assert_open_sftp() + tripwire.ssh_mock.assert_sftp_get(remotepath="/remote/data.csv", localpath="/local/data.csv") + tripwire.ssh_mock.assert_close() # ESCAPE: test_assert_sftp_put_helper @@ -725,26 +725,26 @@ def test_assert_sftp_get_helper(bigfoot_verifier: StrictVerifier) -> None: # CHECK: No exception raised. # MUTATION: Wrong localpath/remotepath would raise InteractionMismatchError. # ESCAPE: Nothing reasonable -- helper delegates to assert_interaction with full fields. -def test_assert_sftp_put_helper(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.ssh_mock.new_session() +def test_assert_sftp_put_helper(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.ssh_mock.new_session() session.expect("connect", returns=None) session.expect("open_sftp", returns=None) session.expect("sftp_put", returns=None) session.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): client = paramiko.SSHClient() client.connect("server.example.com", port=22, username="deploy") sftp = client.open_sftp() sftp.put("/local/upload.txt", "/remote/upload.txt") client.close() - bigfoot.ssh_mock.assert_connect( + tripwire.ssh_mock.assert_connect( hostname="server.example.com", port=22, username="deploy", auth_method="password" ) - bigfoot.ssh_mock.assert_open_sftp() - bigfoot.ssh_mock.assert_sftp_put(localpath="/local/upload.txt", remotepath="/remote/upload.txt") - bigfoot.ssh_mock.assert_close() + tripwire.ssh_mock.assert_open_sftp() + tripwire.ssh_mock.assert_sftp_put(localpath="/local/upload.txt", remotepath="/remote/upload.txt") + tripwire.ssh_mock.assert_close() # --------------------------------------------------------------------------- @@ -760,7 +760,7 @@ def test_assert_sftp_put_helper(bigfoot_verifier: StrictVerifier) -> None: # MUTATION: A no-op assert_connect that never checks would not raise. # ESCAPE: Nothing reasonable -- exact exception type. def test_assert_interaction_connect_rejects_wrong_values() -> None: - from bigfoot._errors import InteractionMismatchError + from tripwire._errors import InteractionMismatchError v, p = _make_verifier_with_plugin() session = p.new_session() @@ -786,7 +786,7 @@ def test_assert_interaction_connect_rejects_wrong_values() -> None: # MUTATION: A no-op assert_exec_command that never checks would not raise. # ESCAPE: Nothing reasonable -- exact exception type. def test_assert_interaction_exec_command_rejects_wrong_values() -> None: - from bigfoot._errors import InteractionMismatchError + from tripwire._errors import InteractionMismatchError v, p = _make_verifier_with_plugin() session = p.new_session() @@ -815,7 +815,7 @@ def test_assert_interaction_exec_command_rejects_wrong_values() -> None: # MUTATION: A no-op assert_sftp_get that never checks would not raise. # ESCAPE: Nothing reasonable -- exact exception type. def test_assert_interaction_sftp_get_rejects_wrong_values() -> None: - from bigfoot._errors import InteractionMismatchError + from tripwire._errors import InteractionMismatchError v, p = _make_verifier_with_plugin() session = p.new_session() @@ -849,7 +849,7 @@ def test_assert_interaction_sftp_get_rejects_wrong_values() -> None: # MUTATION: A no-op assert_sftp_put that never checks would not raise. # ESCAPE: Nothing reasonable -- exact exception type. def test_assert_interaction_sftp_put_rejects_wrong_values() -> None: - from bigfoot._errors import InteractionMismatchError + from tripwire._errors import InteractionMismatchError v, p = _make_verifier_with_plugin() session = p.new_session() @@ -920,7 +920,7 @@ def test_paramiko_available_flag() -> None: # ESCAPE: test_ssh_mock_proxy_raises_import_error_when_unavailable -# CLAIM: Accessing bigfoot.ssh_mock raises ImportError when paramiko is not installed. +# CLAIM: Accessing tripwire.ssh_mock raises ImportError when paramiko is not installed. # PATH: _SshProxy.__getattr__ -> checks _PARAMIKO_AVAILABLE -> raises ImportError. # CHECK: ImportError raised with exact expected message. # MUTATION: Not checking _PARAMIKO_AVAILABLE would defer the error. @@ -928,16 +928,16 @@ def test_paramiko_available_flag() -> None: def test_ssh_mock_proxy_raises_import_error_when_unavailable( monkeypatch: pytest.MonkeyPatch, ) -> None: - import bigfoot.plugins.ssh_plugin as ssh_mod + import tripwire.plugins.ssh_plugin as ssh_mod monkeypatch.setattr(ssh_mod, "_PARAMIKO_AVAILABLE", False) with pytest.raises(ImportError) as exc_info: - _ = bigfoot.ssh_mock.new_session # noqa: B018 + _ = tripwire.ssh_mock.new_session # noqa: B018 assert str(exc_info.value) == ( - "bigfoot[ssh] is required to use bigfoot.ssh_mock. " - "Install it with: pip install bigfoot[ssh]" + "tripwire[ssh] is required to use tripwire.ssh_mock. " + "Install it with: pip install tripwire[ssh]" ) @@ -1242,7 +1242,7 @@ def test_connect_without_key_sets_auth_method_password() -> None: # MUTATION: Wrong source_id string fails the equality check. # ESCAPE: Nothing reasonable -- exact string equality on each. def test_sentinel_properties() -> None: - from bigfoot._state_machine_plugin import _StepSentinel + from tripwire._state_machine_plugin import _StepSentinel v, p = _make_verifier_with_plugin() @@ -1278,39 +1278,39 @@ def test_sentinel_properties() -> None: # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.ssh_mock +# Module-level proxy: tripwire.ssh_mock # --------------------------------------------------------------------------- # ESCAPE: test_ssh_mock_proxy_new_session -# CLAIM: bigfoot.ssh_mock.new_session() returns a SessionHandle. +# CLAIM: tripwire.ssh_mock.new_session() returns a SessionHandle. # PATH: _SshProxy.__getattr__("new_session") -> get verifier -> find/create SshPlugin -> # return plugin.new_session. # CHECK: session is a SessionHandle instance; chaining .expect() does not raise. # MUTATION: Returning None instead of a SessionHandle would fail isinstance check. # ESCAPE: Nothing reasonable -- both the isinstance and the chained .expect() call check it. -def test_ssh_mock_proxy_new_session(bigfoot_verifier: StrictVerifier) -> None: - from bigfoot._state_machine_plugin import SessionHandle +def test_ssh_mock_proxy_new_session(tripwire_verifier: StrictVerifier) -> None: + from tripwire._state_machine_plugin import SessionHandle - session = bigfoot.ssh_mock.new_session() + session = tripwire.ssh_mock.new_session() assert isinstance(session, SessionHandle) result = session.expect("connect", returns=None, required=False) assert result is session # expect() returns self for chaining # ESCAPE: test_ssh_mock_proxy_raises_outside_context -# CLAIM: Accessing bigfoot.ssh_mock outside a test context raises NoActiveVerifierError. +# CLAIM: Accessing tripwire.ssh_mock outside a test context raises NoActiveVerifierError. # PATH: _SshProxy.__getattr__ -> _get_test_verifier_or_raise -> NoActiveVerifierError. # CHECK: NoActiveVerifierError raised. # MUTATION: Silently returning None would not raise and hide context failures. # ESCAPE: Nothing reasonable -- exact exception type. def test_ssh_mock_proxy_raises_outside_context() -> None: - from bigfoot._errors import NoActiveVerifierError + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.ssh_mock.new_session # noqa: B018 + _ = tripwire.ssh_mock.new_session # noqa: B018 finally: _current_test_verifier.reset(token) @@ -1326,23 +1326,23 @@ def test_ssh_mock_proxy_raises_outside_context() -> None: # CHECK: assert_interaction verifies every assertable field for every step. # MUTATION: Wrong detail values in any step fail the assertion. # ESCAPE: Nothing reasonable -- full field coverage on all assertable steps. -def test_full_exec_command_flow_assertions(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.ssh_mock.new_session() +def test_full_exec_command_flow_assertions(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.ssh_mock.new_session() session.expect("connect", returns=None) session.expect("exec_command", returns=("stdin", "stdout", "stderr")) session.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): client = paramiko.SSHClient() client.connect("server.example.com", port=2222, username="admin") client.exec_command("whoami") client.close() - bigfoot.ssh_mock.assert_connect( + tripwire.ssh_mock.assert_connect( hostname="server.example.com", port=2222, username="admin", auth_method="password" ) - bigfoot.ssh_mock.assert_exec_command(command="whoami") - bigfoot.ssh_mock.assert_close() + tripwire.ssh_mock.assert_exec_command(command="whoami") + tripwire.ssh_mock.assert_close() # ESCAPE: test_sftp_flow_assertions @@ -1351,15 +1351,15 @@ def test_full_exec_command_flow_assertions(bigfoot_verifier: StrictVerifier) -> # CHECK: assert_interaction verifies every assertable field for every step. # MUTATION: Wrong detail values in any step fail the assertion. # ESCAPE: Nothing reasonable -- full field coverage on all assertable steps. -def test_sftp_flow_assertions(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.ssh_mock.new_session() +def test_sftp_flow_assertions(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.ssh_mock.new_session() session.expect("connect", returns=None) session.expect("open_sftp", returns=None) session.expect("sftp_get", returns=None) session.expect("sftp_put", returns=None) session.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): client = paramiko.SSHClient() client.connect("sftp.example.com", port=22, username="transfer") sftp = client.open_sftp() @@ -1367,13 +1367,13 @@ def test_sftp_flow_assertions(bigfoot_verifier: StrictVerifier) -> None: sftp.put("/local/results.csv", "/remote/results.csv") client.close() - bigfoot.ssh_mock.assert_connect( + tripwire.ssh_mock.assert_connect( hostname="sftp.example.com", port=22, username="transfer", auth_method="password" ) - bigfoot.ssh_mock.assert_open_sftp() - bigfoot.ssh_mock.assert_sftp_get(remotepath="/remote/data.csv", localpath="/local/data.csv") - bigfoot.ssh_mock.assert_sftp_put(localpath="/local/results.csv", remotepath="/remote/results.csv") - bigfoot.ssh_mock.assert_close() + tripwire.ssh_mock.assert_open_sftp() + tripwire.ssh_mock.assert_sftp_get(remotepath="/remote/data.csv", localpath="/local/data.csv") + tripwire.ssh_mock.assert_sftp_put(localpath="/local/results.csv", remotepath="/remote/results.csv") + tripwire.ssh_mock.assert_close() # ESCAPE: test_multiple_sequential_sessions_assertions @@ -1383,21 +1383,21 @@ def test_sftp_flow_assertions(bigfoot_verifier: StrictVerifier) -> None: # CHECK: assert_interaction verifies fields for every step in both sessions. # MUTATION: Wrong hostname or command values fail the assertion. # ESCAPE: Nothing reasonable -- full field coverage on both sessions. -def test_multiple_sequential_sessions_assertions(bigfoot_verifier: StrictVerifier) -> None: +def test_multiple_sequential_sessions_assertions(tripwire_verifier: StrictVerifier) -> None: # First session - s1 = bigfoot.ssh_mock.new_session() + s1 = tripwire.ssh_mock.new_session() s1.expect("connect", returns=None) s1.expect("exec_command", returns=("stdin", "stdout", "stderr")) s1.expect("close", returns=None) # Second session - s2 = bigfoot.ssh_mock.new_session() + s2 = tripwire.ssh_mock.new_session() s2.expect("connect", returns=None) s2.expect("open_sftp", returns=None) s2.expect("sftp_get", returns=None) s2.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): # First connection client1 = paramiko.SSHClient() client1.connect("host1", port=22, username="user1") @@ -1412,19 +1412,19 @@ def test_multiple_sequential_sessions_assertions(bigfoot_verifier: StrictVerifie client2.close() # Assert first session interactions - bigfoot.ssh_mock.assert_connect( + tripwire.ssh_mock.assert_connect( hostname="host1", port=22, username="user1", auth_method="password" ) - bigfoot.ssh_mock.assert_exec_command(command="ls") - bigfoot.ssh_mock.assert_close() + tripwire.ssh_mock.assert_exec_command(command="ls") + tripwire.ssh_mock.assert_close() # Assert second session interactions - bigfoot.ssh_mock.assert_connect( + tripwire.ssh_mock.assert_connect( hostname="host2", port=22, username="user2", auth_method="password" ) - bigfoot.ssh_mock.assert_open_sftp() - bigfoot.ssh_mock.assert_sftp_get(remotepath="/remote/file.txt", localpath="/local/file.txt") - bigfoot.ssh_mock.assert_close() + tripwire.ssh_mock.assert_open_sftp() + tripwire.ssh_mock.assert_sftp_get(remotepath="/remote/file.txt", localpath="/local/file.txt") + tripwire.ssh_mock.assert_close() # --------------------------------------------------------------------------- @@ -1439,7 +1439,7 @@ def test_multiple_sequential_sessions_assertions(bigfoot_verifier: StrictVerifie # MUTATION: Wrong format string fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_interaction_connect() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1459,7 +1459,7 @@ def test_format_interaction_connect() -> None: # MUTATION: Wrong format string fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_interaction_exec_command() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1479,7 +1479,7 @@ def test_format_interaction_exec_command() -> None: # MUTATION: Wrong format string fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_interaction_open_sftp() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1499,7 +1499,7 @@ def test_format_interaction_open_sftp() -> None: # MUTATION: Wrong format string fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_interaction_sftp_get() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1519,7 +1519,7 @@ def test_format_interaction_sftp_get() -> None: # MUTATION: Wrong format string fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_interaction_sftp_put() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1539,7 +1539,7 @@ def test_format_interaction_sftp_put() -> None: # MUTATION: Wrong format string fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_interaction_sftp_listdir() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1559,7 +1559,7 @@ def test_format_interaction_sftp_listdir() -> None: # MUTATION: Wrong format string fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_interaction_sftp_stat() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1579,7 +1579,7 @@ def test_format_interaction_sftp_stat() -> None: # MUTATION: Wrong format string fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_interaction_sftp_mkdir() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1599,7 +1599,7 @@ def test_format_interaction_sftp_mkdir() -> None: # MUTATION: Wrong format string fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_interaction_sftp_remove() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1619,7 +1619,7 @@ def test_format_interaction_sftp_remove() -> None: # MUTATION: Wrong format string fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_interaction_close() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1639,7 +1639,7 @@ def test_format_interaction_close() -> None: # MUTATION: Wrong fallback format fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_interaction_unknown() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1659,7 +1659,7 @@ def test_format_interaction_unknown() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_mock_hint() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1669,7 +1669,7 @@ def test_format_mock_hint() -> None: plugin=p, ) result = p.format_mock_hint(interaction) - assert result == " bigfoot.ssh_mock.new_session().expect('exec_command', returns=...)" + assert result == " tripwire.ssh_mock.new_session().expect('exec_command', returns=...)" # ESCAPE: test_format_mock_hint_connect @@ -1679,7 +1679,7 @@ def test_format_mock_hint() -> None: # MUTATION: Wrong method name in hint fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_mock_hint_connect() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1689,7 +1689,7 @@ def test_format_mock_hint_connect() -> None: plugin=p, ) result = p.format_mock_hint(interaction) - assert result == " bigfoot.ssh_mock.new_session().expect('connect', returns=...)" + assert result == " tripwire.ssh_mock.new_session().expect('connect', returns=...)" # ESCAPE: test_format_unmocked_hint @@ -1704,7 +1704,7 @@ def test_format_unmocked_hint() -> None: assert result == ( "paramiko.SSHClient.connect(...) was called but no session was queued.\n" "Register a session with:\n" - " bigfoot.ssh_mock.new_session().expect('connect', returns=...)" + " tripwire.ssh_mock.new_session().expect('connect', returns=...)" ) @@ -1720,7 +1720,7 @@ def test_format_unmocked_hint_exec_command() -> None: assert result == ( "paramiko.SSHClient.exec_command(...) was called but no session was queued.\n" "Register a session with:\n" - " bigfoot.ssh_mock.new_session().expect('exec_command', returns=...)" + " tripwire.ssh_mock.new_session().expect('exec_command', returns=...)" ) @@ -1731,7 +1731,7 @@ def test_format_unmocked_hint_exec_command() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_assert_hint_connect() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1742,7 +1742,7 @@ def test_format_assert_hint_connect() -> None: ) result = p.format_assert_hint(interaction) assert result == ( - " bigfoot.ssh_mock.assert_connect(" + " tripwire.ssh_mock.assert_connect(" "hostname='myhost', port=22, username='user', auth_method='password')" ) @@ -1754,7 +1754,7 @@ def test_format_assert_hint_connect() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_assert_hint_exec_command() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1764,7 +1764,7 @@ def test_format_assert_hint_exec_command() -> None: plugin=p, ) result = p.format_assert_hint(interaction) - assert result == " bigfoot.ssh_mock.assert_exec_command(command='uptime')" + assert result == " tripwire.ssh_mock.assert_exec_command(command='uptime')" # ESCAPE: test_format_assert_hint_open_sftp @@ -1774,7 +1774,7 @@ def test_format_assert_hint_exec_command() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_assert_hint_open_sftp() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1784,7 +1784,7 @@ def test_format_assert_hint_open_sftp() -> None: plugin=p, ) result = p.format_assert_hint(interaction) - assert result == " bigfoot.ssh_mock.assert_open_sftp()" + assert result == " tripwire.ssh_mock.assert_open_sftp()" # ESCAPE: test_format_assert_hint_sftp_get @@ -1794,7 +1794,7 @@ def test_format_assert_hint_open_sftp() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_assert_hint_sftp_get() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1805,7 +1805,7 @@ def test_format_assert_hint_sftp_get() -> None: ) result = p.format_assert_hint(interaction) assert result == ( - " bigfoot.ssh_mock.assert_sftp_get(" + " tripwire.ssh_mock.assert_sftp_get(" "remotepath='/remote/file.txt', localpath='/local/file.txt')" ) @@ -1817,7 +1817,7 @@ def test_format_assert_hint_sftp_get() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_assert_hint_sftp_put() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1828,7 +1828,7 @@ def test_format_assert_hint_sftp_put() -> None: ) result = p.format_assert_hint(interaction) assert result == ( - " bigfoot.ssh_mock.assert_sftp_put(" + " tripwire.ssh_mock.assert_sftp_put(" "localpath='/local/file.txt', remotepath='/remote/file.txt')" ) @@ -1840,7 +1840,7 @@ def test_format_assert_hint_sftp_put() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_assert_hint_close() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1850,7 +1850,7 @@ def test_format_assert_hint_close() -> None: plugin=p, ) result = p.format_assert_hint(interaction) - assert result == " bigfoot.ssh_mock.assert_close()" + assert result == " tripwire.ssh_mock.assert_close()" # ESCAPE: test_format_assert_hint_unknown @@ -1860,7 +1860,7 @@ def test_format_assert_hint_close() -> None: # MUTATION: Wrong fallback format fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_assert_hint_unknown() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1870,7 +1870,7 @@ def test_format_assert_hint_unknown() -> None: plugin=p, ) result = p.format_assert_hint(interaction) - assert result == " # bigfoot.ssh_mock: unknown source_id='ssh:unknown_op'" + assert result == " # tripwire.ssh_mock: unknown source_id='ssh:unknown_op'" # ESCAPE: test_format_unused_mock_hint @@ -1902,7 +1902,7 @@ def test_format_unused_mock_hint() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_assert_hint_sftp_listdir() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1912,7 +1912,7 @@ def test_format_assert_hint_sftp_listdir() -> None: plugin=p, ) result = p.format_assert_hint(interaction) - assert result == " bigfoot.ssh_mock.assert_sftp_listdir(path='/remote/dir')" + assert result == " tripwire.ssh_mock.assert_sftp_listdir(path='/remote/dir')" # ESCAPE: test_format_assert_hint_sftp_stat @@ -1922,7 +1922,7 @@ def test_format_assert_hint_sftp_listdir() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_assert_hint_sftp_stat() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1932,7 +1932,7 @@ def test_format_assert_hint_sftp_stat() -> None: plugin=p, ) result = p.format_assert_hint(interaction) - assert result == " bigfoot.ssh_mock.assert_sftp_stat(path='/remote/file.txt')" + assert result == " tripwire.ssh_mock.assert_sftp_stat(path='/remote/file.txt')" # ESCAPE: test_format_assert_hint_sftp_mkdir @@ -1942,7 +1942,7 @@ def test_format_assert_hint_sftp_stat() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_assert_hint_sftp_mkdir() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1952,7 +1952,7 @@ def test_format_assert_hint_sftp_mkdir() -> None: plugin=p, ) result = p.format_assert_hint(interaction) - assert result == " bigfoot.ssh_mock.assert_sftp_mkdir(path='/remote/newdir')" + assert result == " tripwire.ssh_mock.assert_sftp_mkdir(path='/remote/newdir')" # ESCAPE: test_format_assert_hint_sftp_remove @@ -1962,7 +1962,7 @@ def test_format_assert_hint_sftp_mkdir() -> None: # MUTATION: Wrong hint text fails equality check. # ESCAPE: Nothing reasonable -- exact string equality. def test_format_assert_hint_sftp_remove() -> None: - from bigfoot._timeline import Interaction + from tripwire._timeline import Interaction v, p = _make_verifier_with_plugin() interaction = Interaction( @@ -1972,7 +1972,7 @@ def test_format_assert_hint_sftp_remove() -> None: plugin=p, ) result = p.format_assert_hint(interaction) - assert result == " bigfoot.ssh_mock.assert_sftp_remove(path='/remote/oldfile.txt')" + assert result == " tripwire.ssh_mock.assert_sftp_remove(path='/remote/oldfile.txt')" # --------------------------------------------------------------------------- @@ -1986,26 +1986,26 @@ def test_format_assert_hint_sftp_remove() -> None: # CHECK: No exception raised. # MUTATION: Wrong path would raise InteractionMismatchError. # ESCAPE: Nothing reasonable -- helper delegates to assert_interaction with full fields. -def test_assert_sftp_listdir_helper(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.ssh_mock.new_session() +def test_assert_sftp_listdir_helper(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.ssh_mock.new_session() session.expect("connect", returns=None) session.expect("open_sftp", returns=None) session.expect("sftp_listdir", returns=["file1.txt", "file2.txt"]) session.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): client = paramiko.SSHClient() client.connect("server.example.com", port=22, username="deploy") sftp = client.open_sftp() sftp.listdir("/remote/dir") client.close() - bigfoot.ssh_mock.assert_connect( + tripwire.ssh_mock.assert_connect( hostname="server.example.com", port=22, username="deploy", auth_method="password" ) - bigfoot.ssh_mock.assert_open_sftp() - bigfoot.ssh_mock.assert_sftp_listdir(path="/remote/dir") - bigfoot.ssh_mock.assert_close() + tripwire.ssh_mock.assert_open_sftp() + tripwire.ssh_mock.assert_sftp_listdir(path="/remote/dir") + tripwire.ssh_mock.assert_close() # ESCAPE: test_assert_sftp_stat_helper @@ -2014,26 +2014,26 @@ def test_assert_sftp_listdir_helper(bigfoot_verifier: StrictVerifier) -> None: # CHECK: No exception raised. # MUTATION: Wrong path would raise InteractionMismatchError. # ESCAPE: Nothing reasonable -- helper delegates to assert_interaction with full fields. -def test_assert_sftp_stat_helper(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.ssh_mock.new_session() +def test_assert_sftp_stat_helper(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.ssh_mock.new_session() session.expect("connect", returns=None) session.expect("open_sftp", returns=None) session.expect("sftp_stat", returns="fake_stat_result") session.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): client = paramiko.SSHClient() client.connect("server.example.com", port=22, username="deploy") sftp = client.open_sftp() sftp.stat("/remote/file.txt") client.close() - bigfoot.ssh_mock.assert_connect( + tripwire.ssh_mock.assert_connect( hostname="server.example.com", port=22, username="deploy", auth_method="password" ) - bigfoot.ssh_mock.assert_open_sftp() - bigfoot.ssh_mock.assert_sftp_stat(path="/remote/file.txt") - bigfoot.ssh_mock.assert_close() + tripwire.ssh_mock.assert_open_sftp() + tripwire.ssh_mock.assert_sftp_stat(path="/remote/file.txt") + tripwire.ssh_mock.assert_close() # ESCAPE: test_assert_sftp_mkdir_helper @@ -2042,26 +2042,26 @@ def test_assert_sftp_stat_helper(bigfoot_verifier: StrictVerifier) -> None: # CHECK: No exception raised. # MUTATION: Wrong path would raise InteractionMismatchError. # ESCAPE: Nothing reasonable -- helper delegates to assert_interaction with full fields. -def test_assert_sftp_mkdir_helper(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.ssh_mock.new_session() +def test_assert_sftp_mkdir_helper(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.ssh_mock.new_session() session.expect("connect", returns=None) session.expect("open_sftp", returns=None) session.expect("sftp_mkdir", returns=None) session.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): client = paramiko.SSHClient() client.connect("server.example.com", port=22, username="deploy") sftp = client.open_sftp() sftp.mkdir("/remote/newdir") client.close() - bigfoot.ssh_mock.assert_connect( + tripwire.ssh_mock.assert_connect( hostname="server.example.com", port=22, username="deploy", auth_method="password" ) - bigfoot.ssh_mock.assert_open_sftp() - bigfoot.ssh_mock.assert_sftp_mkdir(path="/remote/newdir") - bigfoot.ssh_mock.assert_close() + tripwire.ssh_mock.assert_open_sftp() + tripwire.ssh_mock.assert_sftp_mkdir(path="/remote/newdir") + tripwire.ssh_mock.assert_close() # ESCAPE: test_assert_sftp_remove_helper @@ -2070,26 +2070,26 @@ def test_assert_sftp_mkdir_helper(bigfoot_verifier: StrictVerifier) -> None: # CHECK: No exception raised. # MUTATION: Wrong path would raise InteractionMismatchError. # ESCAPE: Nothing reasonable -- helper delegates to assert_interaction with full fields. -def test_assert_sftp_remove_helper(bigfoot_verifier: StrictVerifier) -> None: - session = bigfoot.ssh_mock.new_session() +def test_assert_sftp_remove_helper(tripwire_verifier: StrictVerifier) -> None: + session = tripwire.ssh_mock.new_session() session.expect("connect", returns=None) session.expect("open_sftp", returns=None) session.expect("sftp_remove", returns=None) session.expect("close", returns=None) - with bigfoot.sandbox(): + with tripwire.sandbox(): client = paramiko.SSHClient() client.connect("server.example.com", port=22, username="deploy") sftp = client.open_sftp() sftp.remove("/remote/oldfile.txt") client.close() - bigfoot.ssh_mock.assert_connect( + tripwire.ssh_mock.assert_connect( hostname="server.example.com", port=22, username="deploy", auth_method="password" ) - bigfoot.ssh_mock.assert_open_sftp() - bigfoot.ssh_mock.assert_sftp_remove(path="/remote/oldfile.txt") - bigfoot.ssh_mock.assert_close() + tripwire.ssh_mock.assert_open_sftp() + tripwire.ssh_mock.assert_sftp_remove(path="/remote/oldfile.txt") + tripwire.ssh_mock.assert_close() # --------------------------------------------------------------------------- diff --git a/tests/unit/test_state_machine_plugin.py b/tests/unit/test_state_machine_plugin.py index 88494e0..b399b36 100644 --- a/tests/unit/test_state_machine_plugin.py +++ b/tests/unit/test_state_machine_plugin.py @@ -7,10 +7,10 @@ import pytest -from bigfoot._errors import InvalidStateError, UnmockedInteractionError -from bigfoot._state_machine_plugin import ScriptStep, SessionHandle, StateMachinePlugin -from bigfoot._timeline import Interaction -from bigfoot._verifier import StrictVerifier +from tripwire._errors import InvalidStateError, UnmockedInteractionError +from tripwire._state_machine_plugin import ScriptStep, SessionHandle, StateMachinePlugin +from tripwire._timeline import Interaction +from tripwire._verifier import StrictVerifier # --------------------------------------------------------------------------- # Minimal concrete subclass for testing @@ -544,7 +544,7 @@ def test_bind_connection_raises_when_queue_empty() -> None: # CHECK: pytest.raises verifies the exception type exactly. # MUTATION: Raising a different exception type would fail pytest.raises. # ESCAPE: Nothing reasonable. - # IMPACT: Users would get an obscure error instead of a helpful bigfoot message. + # IMPACT: Users would get an obscure error instead of a helpful tripwire message. plugin = _make_plugin() conn = _make_connection_obj() with pytest.raises(UnmockedInteractionError): @@ -742,7 +742,7 @@ def test_execute_step_does_not_auto_mark_interaction_asserted() -> None: # CLAIM: The interaction recorded by _execute_step() has _asserted=False. # PATH: _execute_step() calls self.record() but NOT mark_asserted(). # CHECK: interaction._asserted is False. - # MUTATION: Adding a mark_asserted() call would set _asserted=True, defeating bigfoot's + # MUTATION: Adding a mark_asserted() call would set _asserted=True, defeating tripwire's # certainty guarantee. # ESCAPE: Nothing reasonable. # IMPACT: Test authors could no longer trust that unasserted interactions cause test failures. @@ -856,7 +856,7 @@ def test_execute_step_unasserted_interaction_raises_at_teardown() -> None: # MUTATION: Adding mark_asserted() back to _execute_step() would suppress the error. # ESCAPE: Nothing reasonable. # IMPACT: Tests that forgot assert_interaction() would silently pass instead of failing. - from bigfoot._errors import UnassertedInteractionsError + from tripwire._errors import UnassertedInteractionsError v = _make_verifier() plugin = _TestPlugin(v) diff --git a/tests/unit/test_subprocess_plugin.py b/tests/unit/test_subprocess_plugin.py index 236bffa..5b414be 100644 --- a/tests/unit/test_subprocess_plugin.py +++ b/tests/unit/test_subprocess_plugin.py @@ -1,4 +1,4 @@ -"""Unit tests for bigfoot SubprocessPlugin.""" +"""Unit tests for tripwire SubprocessPlugin.""" import shutil import subprocess @@ -7,18 +7,18 @@ import pytest -import bigfoot -from bigfoot._context import _current_test_verifier -from bigfoot._errors import ( +import tripwire +from tripwire._context import _current_test_verifier +from tripwire._errors import ( ConflictError, InteractionMismatchError, UnassertedInteractionsError, UnmockedInteractionError, UnusedMocksError, ) -from bigfoot._timeline import Interaction -from bigfoot._verifier import StrictVerifier -from bigfoot.plugins.subprocess import ( +from tripwire._timeline import Interaction +from tripwire._verifier import StrictVerifier +from tripwire.plugins.subprocess import ( _SHUTIL_WHICH_ORIGINAL, _SUBPROCESS_RUN_ORIGINAL, SubprocessPlugin, @@ -67,7 +67,7 @@ def clean_install_count(): # ESCAPE: test_activate_installs_patches -# CLAIM: After activate(), subprocess.run is replaced with bigfoot's interceptor. +# CLAIM: After activate(), subprocess.run is replaced with tripwire's interceptor. # PATH: activate() -> _install_count == 0 -> _install_patches() -> subprocess.run = interceptor. # CHECK: subprocess.run is not _SUBPROCESS_RUN_ORIGINAL and shutil.which is not _SHUTIL_WHICH_ORIGINAL. # MUTATION: Skipping _install_patches() leaves originals in place; identity checks fail. @@ -85,7 +85,7 @@ def test_activate_installs_patches() -> None: # CLAIM: After activate() then deactivate(), subprocess.run and shutil.which are originals again. # PATH: deactivate() -> _install_count reaches 0 -> _restore_patches() -> restore originals. # CHECK: Both functions restored to their import-time constants. -# MUTATION: Not restoring in _restore_patches leaves bigfoot's interceptors in place. +# MUTATION: Not restoring in _restore_patches leaves tripwire's interceptors in place. # ESCAPE: Nothing reasonable -- identity comparison against import-time constants. def test_deactivate_restores_patches() -> None: v, p = _make_verifier_with_plugin() @@ -253,22 +253,22 @@ def test_mock_run_raises_exception() -> None: # --------------------------------------------------------------------------- -# mock_run with BigFoot sandbox (module-level API) +# mock_run with Tripwire sandbox (module-level API) # --------------------------------------------------------------------------- # ESCAPE: test_mock_run_in_sandbox -# CLAIM: Using bigfoot.sandbox() context manager with mock_run registered before; +# CLAIM: Using tripwire.sandbox() context manager with mock_run registered before; # assert_interaction passes after sandbox exits. -# PATH: bigfoot.sandbox() -> SandboxContext using _current_test_verifier -> activate; +# PATH: tripwire.sandbox() -> SandboxContext using _current_test_verifier -> activate; # interceptor uses verifier from ContextVar -> assert_interaction checks timeline. # CHECK: assert_interaction does not raise; result has correct fields. # MUTATION: Recording interaction with wrong command would cause assert_interaction to raise. # ESCAPE: Nothing reasonable -- assert_interaction is the definitive check here. -def test_mock_run_in_sandbox(bigfoot_verifier: StrictVerifier) -> None: - bigfoot.subprocess_mock.mock_run(["make", "build"], returncode=0, stdout="ok") +def test_mock_run_in_sandbox(tripwire_verifier: StrictVerifier) -> None: + tripwire.subprocess_mock.mock_run(["make", "build"], returncode=0, stdout="ok") - with bigfoot.sandbox(): + with tripwire.sandbox(): result = subprocess.run(["make", "build"]) assert result.returncode == 0 @@ -276,20 +276,20 @@ def test_mock_run_in_sandbox(bigfoot_verifier: StrictVerifier) -> None: assert result.stderr == "" assert result.args == ["make", "build"] - bigfoot.assert_interaction(bigfoot.subprocess_mock.run, command=["make", "build"], returncode=0, stdout="ok", stderr="") + tripwire.assert_interaction(tripwire.subprocess_mock.run, command=["make", "build"], returncode=0, stdout="ok", stderr="") # ESCAPE: test_unregistered_run_in_sandbox_raises -# CLAIM: subprocess.run inside bigfoot.sandbox() with no mock raises UnmockedInteractionError. +# CLAIM: subprocess.run inside tripwire.sandbox() with no mock raises UnmockedInteractionError. # PATH: interceptor -> _handle_run -> empty queue -> UnmockedInteractionError. # CHECK: UnmockedInteractionError raised; source_id == "subprocess:run". # MUTATION: Returning a default response silently lets unmocked calls through. # ESCAPE: Nothing reasonable -- exact exception type and source_id. -def test_unregistered_run_in_sandbox_raises(bigfoot_verifier: StrictVerifier) -> None: +def test_unregistered_run_in_sandbox_raises(tripwire_verifier: StrictVerifier) -> None: # Access subprocess_mock to ensure SubprocessPlugin is created and registered - bigfoot.subprocess_mock.install() + tripwire.subprocess_mock.install() - with bigfoot.sandbox(): + with tripwire.sandbox(): with pytest.raises(UnmockedInteractionError) as exc_info: subprocess.run(["cargo", "build"]) @@ -432,14 +432,14 @@ def test_unmocked_which_asserted_passes() -> None: # MUTATION: Not recording the interaction would cause assert_interaction to raise # InteractionMismatchError. # ESCAPE: Recording with wrong command would cause field match to fail. -def test_assert_interaction_run(bigfoot_verifier: StrictVerifier) -> None: - bigfoot.subprocess_mock.mock_run(["pytest", "--tb=short"], returncode=0, stdout="passed") +def test_assert_interaction_run(tripwire_verifier: StrictVerifier) -> None: + tripwire.subprocess_mock.mock_run(["pytest", "--tb=short"], returncode=0, stdout="passed") - with bigfoot.sandbox(): + with tripwire.sandbox(): subprocess.run(["pytest", "--tb=short"]) # Must not raise - bigfoot.assert_interaction(bigfoot.subprocess_mock.run, command=["pytest", "--tb=short"], returncode=0, stdout="passed", stderr="") + tripwire.assert_interaction(tripwire.subprocess_mock.run, command=["pytest", "--tb=short"], returncode=0, stdout="passed", stderr="") # ESCAPE: test_assert_interaction_which @@ -449,14 +449,14 @@ def test_assert_interaction_run(bigfoot_verifier: StrictVerifier) -> None: # CHECK: No exception raised. # MUTATION: Recording interaction with wrong name would cause field mismatch. # ESCAPE: Recording source_id as "subprocess:run" instead would fail source_id match. -def test_assert_interaction_which(bigfoot_verifier: StrictVerifier) -> None: - bigfoot.subprocess_mock.mock_which("python3", returns="/usr/bin/python3") +def test_assert_interaction_which(tripwire_verifier: StrictVerifier) -> None: + tripwire.subprocess_mock.mock_which("python3", returns="/usr/bin/python3") - with bigfoot.sandbox(): + with tripwire.sandbox(): shutil.which("python3") # Must not raise - bigfoot.assert_interaction(bigfoot.subprocess_mock.which, name="python3", returns="/usr/bin/python3") + tripwire.assert_interaction(tripwire.subprocess_mock.which, name="python3", returns="/usr/bin/python3") # --------------------------------------------------------------------------- @@ -465,10 +465,10 @@ def test_assert_interaction_which(bigfoot_verifier: StrictVerifier) -> None: # ESCAPE: test_conflict_error_subprocess_run_already_patched -# CLAIM: If subprocess.run is replaced with a MagicMock before bigfoot.sandbox(), +# CLAIM: If subprocess.run is replaced with a MagicMock before tripwire.sandbox(), # ConflictError is raised. # PATH: sandbox -> activate -> _check_conflicts -> subprocess.run is not original -# and not bigfoot's -> ConflictError. +# and not tripwire's -> ConflictError. # CHECK: ConflictError raised (wrapped in BaseExceptionGroup from SandboxContext._enter). # MUTATION: Not checking for foreign patchers in _check_conflicts silently allows conflict. # ESCAPE: Nothing reasonable -- ConflictError is the definitive signal. @@ -487,7 +487,7 @@ def test_conflict_error_subprocess_run_already_patched() -> None: # ESCAPE: test_conflict_error_shutil_which_already_patched # CLAIM: If shutil.which is replaced with a MagicMock before sandbox activation, # ConflictError is raised. -# PATH: _check_conflicts -> shutil.which is not original and not bigfoot's -> ConflictError. +# PATH: _check_conflicts -> shutil.which is not original and not tripwire's -> ConflictError. # CHECK: ConflictError raised. # MUTATION: Not checking shutil.which lets the conflict through silently. # ESCAPE: Nothing reasonable -- exact exception type. @@ -509,21 +509,21 @@ def test_conflict_error_shutil_which_already_patched() -> None: # ESCAPE: test_subprocess_mock_proxy_raises_outside_sandbox -# CLAIM: Accessing bigfoot.subprocess_mock.mock_run outside a pytest test context +# CLAIM: Accessing tripwire.subprocess_mock.mock_run outside a pytest test context # raises NoActiveVerifierError (because _current_test_verifier is not set). # PATH: _SubprocessProxy.__getattr__ -> _get_test_verifier_or_raise -> NoActiveVerifierError. # CHECK: NoActiveVerifierError (or subclass) raised when ContextVar is explicitly cleared. # MUTATION: Returning a dummy plugin instead of raising would hide context failures. # ESCAPE: Nothing reasonable -- exact exception type. def test_subprocess_mock_proxy_raises_outside_sandbox() -> None: - # The autouse _bigfoot_auto_verifier fixture sets _current_test_verifier. + # The autouse _tripwire_auto_verifier fixture sets _current_test_verifier. # We explicitly clear it to simulate "outside any test context". - from bigfoot._errors import NoActiveVerifierError + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.subprocess_mock.mock_run + _ = tripwire.subprocess_mock.mock_run finally: _current_test_verifier.reset(token) @@ -796,7 +796,7 @@ def test_format_assert_hint_run() -> None: plugin=p, ) result = p.format_assert_hint(interaction) - assert "bigfoot.subprocess_mock.assert_run(" in result + assert "tripwire.subprocess_mock.assert_run(" in result assert "command=['git', 'status']" in result assert "returncode=0" in result assert "stdout=''" in result @@ -815,7 +815,7 @@ def test_format_assert_hint_which() -> None: plugin=p, ) result = p.format_assert_hint(interaction) - assert "bigfoot.subprocess_mock.assert_which(" in result + assert "tripwire.subprocess_mock.assert_which(" in result assert "name='gcc'" in result assert "returns='/usr/bin/gcc'" in result # Must NOT contain old assert_interaction pattern diff --git a/tests/unit/test_timeline.py b/tests/unit/test_timeline.py index e7a05c7..e0847b4 100644 --- a/tests/unit/test_timeline.py +++ b/tests/unit/test_timeline.py @@ -3,7 +3,7 @@ import threading from unittest.mock import MagicMock -from bigfoot._timeline import Interaction, Timeline +from tripwire._timeline import Interaction, Timeline def _make_interaction(source_id: str = "mock:Svc.method", seq: int = 0) -> Interaction: @@ -94,7 +94,7 @@ def test_interaction_asserted_flag_defaults_false() -> None: def test_mark_asserted_outside_record_succeeds() -> None: """mark_asserted() called after record() has returned succeeds normally.""" - from bigfoot._timeline import Interaction, Timeline + from tripwire._timeline import Interaction, Timeline # We need a real plugin-like object that uses BasePlugin.record() # Use ConcretePlugin-style stub with a real Timeline @@ -109,7 +109,7 @@ def _register_plugin(self, p: object) -> None: self.verifier = _V() def record(self, interaction: Interaction) -> None: - from bigfoot._recording import _recording_in_progress + from tripwire._recording import _recording_in_progress token = _recording_in_progress.set(True) try: self.verifier._timeline.append(interaction) @@ -128,9 +128,9 @@ def test_mark_asserted_inside_record_raises_auto_assert_error() -> None: """mark_asserted() called while _recording_in_progress is True raises AutoAssertError.""" import pytest - from bigfoot._errors import AutoAssertError - from bigfoot._recording import _recording_in_progress - from bigfoot._timeline import Interaction, Timeline + from tripwire._errors import AutoAssertError + from tripwire._recording import _recording_in_progress + from tripwire._timeline import Interaction, Timeline timeline = Timeline() interaction = Interaction(source_id="test:y", sequence=0, details={}, plugin=MagicMock()) diff --git a/tests/unit/test_verifier.py b/tests/unit/test_verifier.py index 2984d37..1bd97bc 100644 --- a/tests/unit/test_verifier.py +++ b/tests/unit/test_verifier.py @@ -8,16 +8,16 @@ if sys.version_info < (3, 11): from exceptiongroup import BaseExceptionGroup -from bigfoot._context import _active_verifier, _any_order_depth -from bigfoot._errors import ( +from tripwire._context import _active_verifier, _any_order_depth +from tripwire._errors import ( AssertionInsideSandboxError, InteractionMismatchError, UnassertedInteractionsError, UnusedMocksError, VerificationError, ) -from bigfoot._timeline import Interaction -from bigfoot._verifier import StrictVerifier +from tripwire._timeline import Interaction +from tripwire._verifier import StrictVerifier # --- Helpers --- @@ -71,7 +71,7 @@ def test_register_plugin_idempotent() -> None: v = StrictVerifier() initial_count = len(v._plugins) # Create a plugin of a type that's already auto-registered - from bigfoot.plugins.subprocess import SubprocessPlugin + from tripwire.plugins.subprocess import SubprocessPlugin SubprocessPlugin(v) # Should NOT raise, should be silently skipped assert len(v._plugins) == initial_count # count unchanged @@ -80,7 +80,7 @@ def test_register_plugin_idempotent() -> None: def test_verifier_respects_disabled_plugins(monkeypatch: pytest.MonkeyPatch) -> None: """disabled_plugins config excludes named plugins from auto-instantiation.""" monkeypatch.setattr( - "bigfoot._verifier.load_bigfoot_config", + "tripwire._verifier.load_tripwire_config", lambda: {"disabled_plugins": ["subprocess"]}, ) v = StrictVerifier() @@ -93,7 +93,7 @@ def test_verifier_respects_disabled_plugins(monkeypatch: pytest.MonkeyPatch) -> def test_verifier_respects_enabled_plugins(monkeypatch: pytest.MonkeyPatch) -> None: """enabled_plugins config includes only named plugins.""" monkeypatch.setattr( - "bigfoot._verifier.load_bigfoot_config", + "tripwire._verifier.load_tripwire_config", lambda: {"enabled_plugins": ["subprocess"]}, ) v = StrictVerifier() @@ -300,7 +300,7 @@ def test_in_any_order_depth_resets_after_exit() -> None: def test_mock_reuses_existing_mock_plugin() -> None: """verifier.mock() called twice finds the existing MockPlugin and doesn't create a second.""" - from bigfoot._mock_plugin import MockPlugin + from tripwire._mock_plugin import MockPlugin v = StrictVerifier() # First call creates MockPlugin and mock @@ -321,7 +321,7 @@ def test_mock_reuses_existing_mock_plugin() -> None: def test_mock_skips_non_mock_plugins_when_searching() -> None: """verifier.mock() iterates past non-MockPlugin entries to find an existing MockPlugin.""" - from bigfoot._mock_plugin import MockPlugin + from tripwire._mock_plugin import MockPlugin v = StrictVerifier() # Register a non-MockPlugin first (a raw MagicMock that passes type() check) @@ -345,7 +345,7 @@ def test_sandbox_activation_failure_deactivates_already_activated_plugins() -> N p2 = _make_mock_plugin(v) p2.activate.side_effect = RuntimeError("activation failed") - with pytest.raises(BaseExceptionGroup, match="bigfoot sandbox activation failed"): + with pytest.raises(BaseExceptionGroup, match="tripwire sandbox activation failed"): with v.sandbox(): pass # pragma: no cover - never reached @@ -393,7 +393,7 @@ def test_sandbox_deactivation_failure_still_resets_context_var() -> None: p = _make_mock_plugin(v) p.deactivate.side_effect = RuntimeError("deactivate failed") - with pytest.raises(BaseExceptionGroup, match="bigfoot sandbox deactivation failed"): + with pytest.raises(BaseExceptionGroup, match="tripwire sandbox deactivation failed"): with v.sandbox(): pass @@ -500,7 +500,7 @@ def test_verify_all_raises_if_sandbox_active() -> None: def test_assert_interaction_raises_missing_fields_when_assertable_field_omitted() -> None: """assert_interaction() raises MissingAssertionFieldsError when a required field is absent.""" - from bigfoot._errors import MissingAssertionFieldsError + from tripwire._errors import MissingAssertionFieldsError v = StrictVerifier() plugin = _make_mock_plugin(v) @@ -548,7 +548,7 @@ def test_assert_interaction_missing_fields_fires_after_source_id_match() -> None def test_assert_interaction_in_any_order_raises_missing_fields() -> None: """in_any_order path also enforces completeness before field-value matching.""" - from bigfoot._errors import MissingAssertionFieldsError + from tripwire._errors import MissingAssertionFieldsError v = StrictVerifier() plugin = _make_mock_plugin(v) @@ -565,7 +565,7 @@ def test_assert_interaction_in_any_order_raises_missing_fields() -> None: def test_assert_interaction_in_any_order_no_source_raises_mismatch_not_missing() -> None: """In in_any_order, if source_id not found, InteractionMismatchError fires before completeness.""" - from bigfoot._errors import MissingAssertionFieldsError # noqa: F401 — imported for clarity + from tripwire._errors import MissingAssertionFieldsError # noqa: F401 — imported for clarity v = StrictVerifier() plugin = _make_mock_plugin(v) @@ -587,7 +587,7 @@ def test_assert_interaction_in_any_order_no_source_raises_mismatch_not_missing() def test_verifier_spy_returns_import_site_mock_with_spy_flag() -> None: """verifier.spy() creates an ImportSiteMock with spy=True.""" - from bigfoot._mock_plugin import ImportSiteMock + from tripwire._mock_plugin import ImportSiteMock v = StrictVerifier() spy = v.spy("os.path:sep") @@ -635,10 +635,10 @@ class TestEntryPointPluginDiscovery: """_load_entrypoint_plugins discovers 3rd-party plugins via entry points.""" def test_entrypoint_plugin_is_instantiated(self, monkeypatch: pytest.MonkeyPatch) -> None: - """A plugin registered via bigfoot.plugins entry point is auto-instantiated.""" + """A plugin registered via tripwire.plugins entry point is auto-instantiated.""" from unittest.mock import MagicMock - from bigfoot._base_plugin import BasePlugin + from tripwire._base_plugin import BasePlugin class FakeEntryPointPlugin(BasePlugin): activated = False @@ -678,8 +678,8 @@ def format_unused_mock_hint(self, mock_config): fake_ep.load.return_value = FakeEntryPointPlugin monkeypatch.setattr( - "bigfoot._verifier.entry_points", - lambda group: [fake_ep] if group == "bigfoot.plugins" else [], + "tripwire._verifier.entry_points", + lambda group: [fake_ep] if group == "tripwire.plugins" else [], ) v = StrictVerifier() @@ -695,8 +695,8 @@ def test_entrypoint_plugin_failure_is_silent(self, monkeypatch: pytest.MonkeyPat fake_ep.load.side_effect = ImportError("broken") monkeypatch.setattr( - "bigfoot._verifier.entry_points", - lambda group: [fake_ep] if group == "bigfoot.plugins" else [], + "tripwire._verifier.entry_points", + lambda group: [fake_ep] if group == "tripwire.plugins" else [], ) # Should not raise @@ -707,15 +707,15 @@ def test_duplicate_entrypoint_plugin_is_skipped(self, monkeypatch: pytest.Monkey """An entry point plugin of a type already registered is silently skipped.""" from unittest.mock import MagicMock - from bigfoot.plugins.subprocess import SubprocessPlugin + from tripwire.plugins.subprocess import SubprocessPlugin fake_ep = MagicMock() fake_ep.name = "subprocess_again" fake_ep.load.return_value = SubprocessPlugin monkeypatch.setattr( - "bigfoot._verifier.entry_points", - lambda group: [fake_ep] if group == "bigfoot.plugins" else [], + "tripwire._verifier.entry_points", + lambda group: [fake_ep] if group == "tripwire.plugins" else [], ) v = StrictVerifier() diff --git a/tests/unit/test_websocket_plugin.py b/tests/unit/test_websocket_plugin.py index fe26001..228c441 100644 --- a/tests/unit/test_websocket_plugin.py +++ b/tests/unit/test_websocket_plugin.py @@ -9,15 +9,15 @@ import pytest -from bigfoot._context import _current_test_verifier -from bigfoot._errors import InvalidStateError, UnmockedInteractionError -from bigfoot._state_machine_plugin import SessionHandle -from bigfoot._verifier import StrictVerifier +from tripwire._context import _current_test_verifier +from tripwire._errors import InvalidStateError, UnmockedInteractionError +from tripwire._state_machine_plugin import SessionHandle +from tripwire._verifier import StrictVerifier websockets = pytest.importorskip("websockets") websocket = pytest.importorskip("websocket") -from bigfoot.plugins.websocket_plugin import ( # noqa: E402 +from tripwire.plugins.websocket_plugin import ( # noqa: E402 _WEBSOCKET_CLIENT_AVAILABLE, _WEBSOCKETS_AVAILABLE, AsyncWebSocketPlugin, @@ -136,7 +136,7 @@ def test_async_unmocked_source_id() -> None: # ESCAPE: test_async_activate_installs_patches -# CLAIM: After activate(), websockets.connect is replaced with bigfoot interceptor. +# CLAIM: After activate(), websockets.connect is replaced with tripwire interceptor. # PATH: activate() -> _install_count == 0 -> store original -> install interceptor. # CHECK: websockets.connect is not the original after activate(). # MUTATION: Skipping patch installation leaves original in place; identity check fails. @@ -155,7 +155,7 @@ def test_async_activate_installs_patches() -> None: # CLAIM: After activate() then deactivate(), websockets.connect is restored. # PATH: deactivate() -> _install_count reaches 0 -> restore original. # CHECK: websockets.connect is the original after deactivate(). -# MUTATION: Not restoring in deactivate() leaves bigfoot's interceptor in place. +# MUTATION: Not restoring in deactivate() leaves tripwire's interceptor in place. # ESCAPE: Nothing reasonable -- identity comparison against saved original. def test_async_deactivate_restores_patches() -> None: import websockets as _ws @@ -339,14 +339,14 @@ def test_async_importerror_flag() -> None: # MUTATION: Not checking the flag and proceeding normally would not raise. # ESCAPE: Raising ImportError with a different message fails the exact string check. def test_async_activate_raises_when_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: - import bigfoot.plugins.websocket_plugin as _wsp + import tripwire.plugins.websocket_plugin as _wsp v, p = _make_async_verifier_with_plugin() monkeypatch.setattr(_wsp, "_WEBSOCKETS_AVAILABLE", False) with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install bigfoot[websockets] to use AsyncWebSocketPlugin: pip install bigfoot[websockets]" + "Install tripwire[websockets] to use AsyncWebSocketPlugin: pip install tripwire[websockets]" ) @@ -381,28 +381,28 @@ async def test_async_close_releases_session() -> None: # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.async_websocket_mock +# Module-level proxy: tripwire.async_websocket_mock # --------------------------------------------------------------------------- # ESCAPE: test_async_websocket_mock_proxy_new_session -# CLAIM: bigfoot.async_websocket_mock.new_session() returns a SessionHandle. +# CLAIM: tripwire.async_websocket_mock.new_session() returns a SessionHandle. # PATH: _AsyncWebSocketProxy.__getattr__("new_session") -> get verifier -> # find/create AsyncWebSocketPlugin -> return plugin.new_session. # CHECK: session is a SessionHandle instance; chaining .expect() returns self. # MUTATION: Returning None instead of a SessionHandle fails isinstance check. # ESCAPE: Nothing reasonable -- both isinstance and chained .expect() call check it. -def test_async_websocket_mock_proxy_new_session(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_async_websocket_mock_proxy_new_session(tripwire_verifier: StrictVerifier) -> None: + import tripwire - session = bigfoot.async_websocket_mock.new_session() + session = tripwire.async_websocket_mock.new_session() assert isinstance(session, SessionHandle) result = session.expect("connect", returns=None, required=False) assert result is session # ESCAPE: test_async_websocket_mock_proxy_raises_outside_context -# CLAIM: Accessing bigfoot.async_websocket_mock outside a test context raises +# CLAIM: Accessing tripwire.async_websocket_mock outside a test context raises # NoActiveVerifierError. # PATH: _AsyncWebSocketProxy.__getattr__ -> _get_test_verifier_or_raise -> # NoActiveVerifierError. @@ -410,13 +410,13 @@ def test_async_websocket_mock_proxy_new_session(bigfoot_verifier: StrictVerifier # MUTATION: Silently returning None would not raise. # ESCAPE: Nothing reasonable -- exact exception type. def test_async_websocket_mock_proxy_raises_outside_context() -> None: - import bigfoot - from bigfoot._errors import NoActiveVerifierError + import tripwire + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.async_websocket_mock.new_session + _ = tripwire.async_websocket_mock.new_session finally: _current_test_verifier.reset(token) @@ -474,7 +474,7 @@ def test_sync_unmocked_source_id() -> None: # ESCAPE: test_sync_activate_installs_patches -# CLAIM: After activate(), websocket.create_connection is replaced with bigfoot interceptor. +# CLAIM: After activate(), websocket.create_connection is replaced with tripwire interceptor. # PATH: activate() -> _install_count == 0 -> store original -> install interceptor. # CHECK: websocket.create_connection is not the original after activate(). # MUTATION: Skipping patch installation leaves original in place; identity check fails. @@ -493,7 +493,7 @@ def test_sync_activate_installs_patches() -> None: # CLAIM: After activate() then deactivate(), websocket.create_connection is restored. # PATH: deactivate() -> _install_count reaches 0 -> restore original. # CHECK: websocket.create_connection is the original after deactivate(). -# MUTATION: Not restoring in deactivate() leaves bigfoot's interceptor in place. +# MUTATION: Not restoring in deactivate() leaves tripwire's interceptor in place. # ESCAPE: Nothing reasonable -- identity comparison against saved original. def test_sync_deactivate_restores_patches() -> None: import websocket as _wsc @@ -623,15 +623,15 @@ def test_sync_importerror_flag() -> None: # MUTATION: Not checking the flag and proceeding normally would not raise. # ESCAPE: Raising ImportError with a different message fails the exact string check. def test_sync_activate_raises_when_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: - import bigfoot.plugins.websocket_plugin as _wsp + import tripwire.plugins.websocket_plugin as _wsp v, p = _make_sync_verifier_with_plugin() monkeypatch.setattr(_wsp, "_WEBSOCKET_CLIENT_AVAILABLE", False) with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install bigfoot[websocket-client] to use SyncWebSocketPlugin: " - "pip install bigfoot[websocket-client]" + "Install tripwire[websocket-client] to use SyncWebSocketPlugin: " + "pip install tripwire[websocket-client]" ) @@ -734,28 +734,28 @@ def test_sync_fifo_two_sessions() -> None: # --------------------------------------------------------------------------- -# Module-level proxy: bigfoot.sync_websocket_mock +# Module-level proxy: tripwire.sync_websocket_mock # --------------------------------------------------------------------------- # ESCAPE: test_sync_websocket_mock_proxy_new_session -# CLAIM: bigfoot.sync_websocket_mock.new_session() returns a SessionHandle. +# CLAIM: tripwire.sync_websocket_mock.new_session() returns a SessionHandle. # PATH: _SyncWebSocketProxy.__getattr__("new_session") -> get verifier -> # find/create SyncWebSocketPlugin -> return plugin.new_session. # CHECK: session is a SessionHandle instance; chaining .expect() returns self. # MUTATION: Returning None instead of a SessionHandle fails isinstance check. # ESCAPE: Nothing reasonable -- both isinstance and chained .expect() call check it. -def test_sync_websocket_mock_proxy_new_session(bigfoot_verifier: StrictVerifier) -> None: - import bigfoot +def test_sync_websocket_mock_proxy_new_session(tripwire_verifier: StrictVerifier) -> None: + import tripwire - session = bigfoot.sync_websocket_mock.new_session() + session = tripwire.sync_websocket_mock.new_session() assert isinstance(session, SessionHandle) result = session.expect("connect", returns=None, required=False) assert result is session # ESCAPE: test_sync_websocket_mock_proxy_raises_outside_context -# CLAIM: Accessing bigfoot.sync_websocket_mock outside a test context raises +# CLAIM: Accessing tripwire.sync_websocket_mock outside a test context raises # NoActiveVerifierError. # PATH: _SyncWebSocketProxy.__getattr__ -> _get_test_verifier_or_raise -> # NoActiveVerifierError. @@ -763,12 +763,12 @@ def test_sync_websocket_mock_proxy_new_session(bigfoot_verifier: StrictVerifier) # MUTATION: Silently returning None would not raise. # ESCAPE: Nothing reasonable -- exact exception type. def test_sync_websocket_mock_proxy_raises_outside_context() -> None: - import bigfoot - from bigfoot._errors import NoActiveVerifierError + import tripwire + from tripwire._errors import NoActiveVerifierError token = _current_test_verifier.set(None) try: with pytest.raises(NoActiveVerifierError): - _ = bigfoot.sync_websocket_mock.new_session + _ = tripwire.sync_websocket_mock.new_session finally: _current_test_verifier.reset(token) diff --git a/tests/unit/test_wildcard_detection.py b/tests/unit/test_wildcard_detection.py index 9901699..4703549 100644 --- a/tests/unit/test_wildcard_detection.py +++ b/tests/unit/test_wildcard_detection.py @@ -2,10 +2,10 @@ import pytest -import bigfoot -from bigfoot._context import _current_test_verifier -from bigfoot._errors import AllWildcardAssertionError -from bigfoot._verifier import StrictVerifier +import tripwire +from tripwire._context import _current_test_verifier +from tripwire._errors import AllWildcardAssertionError +from tripwire._verifier import StrictVerifier # Only run if dirty-equals is available dirty_equals = pytest.importorskip("dirty_equals") @@ -30,7 +30,7 @@ def _verifier_context(): # --------------------------------------------------------------------------- httpx = pytest.importorskip("httpx") -from bigfoot.plugins.http import HttpPlugin # noqa: E402 +from tripwire.plugins.http import HttpPlugin # noqa: E402 def _reset_http_install(): @@ -48,12 +48,12 @@ def _clean_http(): def test_all_wildcard_assertion_raises(): """All-wildcard assertion must raise AllWildcardAssertionError.""" - bigfoot.http.mock_response("GET", "http://test/api", json={"ok": True}) - with bigfoot: + tripwire.http.mock_response("GET", "http://test/api", json={"ok": True}) + with tripwire: httpx.get("http://test/api") with pytest.raises(AllWildcardAssertionError, match="verifies nothing"): - bigfoot.http.assert_request( + tripwire.http.assert_request( method=AnyThing(), url=AnyThing(), headers=AnyThing(), @@ -64,12 +64,12 @@ def test_all_wildcard_assertion_raises(): def test_partial_wildcard_is_allowed(): """Partial wildcards (some real values, some AnyThing) must work normally.""" - bigfoot.http.mock_response("GET", "http://test/api", json={"ok": True}) - with bigfoot: + tripwire.http.mock_response("GET", "http://test/api", json={"ok": True}) + with tripwire: httpx.get("http://test/api") # This should NOT raise AllWildcardAssertionError - bigfoot.http.assert_request( + tripwire.http.assert_request( method="GET", url=AnyThing(), headers=AnyThing(), @@ -80,12 +80,12 @@ def test_partial_wildcard_is_allowed(): def test_all_wildcard_error_shows_real_values(): """AllWildcardAssertionError should include copy-pasteable real values.""" - bigfoot.http.mock_response("GET", "http://test/api", json={"ok": True}) - with bigfoot: + tripwire.http.mock_response("GET", "http://test/api", json={"ok": True}) + with tripwire: httpx.get("http://test/api") with pytest.raises(AllWildcardAssertionError) as exc_info: - bigfoot.http.assert_request( + tripwire.http.assert_request( method=AnyThing(), url=AnyThing(), headers=AnyThing(), @@ -101,13 +101,13 @@ def test_all_wildcard_error_shows_real_values(): def test_all_wildcard_detection_in_any_order(): """All-wildcard detection works inside in_any_order blocks too.""" - bigfoot.http.mock_response("GET", "http://test/api", json={"ok": True}) - with bigfoot: + tripwire.http.mock_response("GET", "http://test/api", json={"ok": True}) + with tripwire: httpx.get("http://test/api") with pytest.raises(AllWildcardAssertionError, match="verifies nothing"): - with bigfoot.in_any_order(): - bigfoot.http.assert_request( + with tripwire.in_any_order(): + tripwire.http.assert_request( method=AnyThing(), url=AnyThing(), headers=AnyThing(), From 9598c76af304868e32b3796bb77514b561df6456 Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:12:09 -0500 Subject: [PATCH 03/33] Add passthrough_safe; UnsafePassthroughError on warn Replaces BasePlugin.supports_guard with BasePlugin.passthrough_safe (default False; safer-by-default). Per-plugin classification per design Section 4 migration table: 8 plugins get passthrough_safe=True (the 6 prior supports_guard=False plugins, plus MockPlugin and StateMachinePlugin); 20 plugins doing real I/O get passthrough_safe=False. Adds UnsafePassthroughError raised when guard="warn" lets a call through to a passthrough_safe=False plugin outside a sandbox. Removes BasePlugin.supports_guard, the is_guard_eligible() registry helper, its cache, and the _clear_guard_eligible_cache test helper. These are made redundant by the new model. Corrects async_subprocess_plugin type annotations: the cast(_AsyncFakeProcess, await _ORIGINAL_CREATE_SUBPROCESS_EXEC(...)) annotation lied about the runtime type (the runtime returned a real asyncio.subprocess.Process). The cast is removed and the return-type annotation widened to _AsyncFakeProcess | asyncio.subprocess.Process for both _fake_create_subprocess_exec and _fake_create_subprocess_shell. Runtime behavior unchanged; static types now match reality. --- CHANGELOG.md | 6 + src/tripwire/__init__.py | 5 +- src/tripwire/__init__.pyi | 2 +- src/tripwire/_base_plugin.py | 10 +- src/tripwire/_context.py | 146 ++++++++------- src/tripwire/_errors.py | 33 ++++ src/tripwire/_mock_plugin.py | 2 +- src/tripwire/_registry.py | 48 +++-- src/tripwire/_state_machine_plugin.py | 8 +- .../plugins/async_subprocess_plugin.py | 37 +++- src/tripwire/plugins/asyncpg_plugin.py | 3 + src/tripwire/plugins/celery_plugin.py | 2 +- src/tripwire/plugins/crypto_plugin.py | 2 +- src/tripwire/plugins/database_plugin.py | 3 + src/tripwire/plugins/file_io_plugin.py | 2 +- src/tripwire/plugins/jwt_plugin.py | 2 +- src/tripwire/plugins/logging_plugin.py | 2 +- src/tripwire/plugins/native_plugin.py | 2 +- src/tripwire/plugins/pika_plugin.py | 3 + src/tripwire/plugins/popen_plugin.py | 3 + src/tripwire/plugins/psycopg2_plugin.py | 3 + src/tripwire/plugins/smtp_plugin.py | 3 + src/tripwire/plugins/socket_plugin.py | 3 + src/tripwire/plugins/ssh_plugin.py | 3 + src/tripwire/plugins/websocket_plugin.py | 6 + src/tripwire/pytest_plugin.py | 8 +- .../test_async_subprocess_passthrough.py | 69 +++++++ .../test_unsafe_passthrough_warn.py | 108 +++++++++++ tests/unit/test_guard.py | 85 ++++----- tests/unit/test_init.py | 2 +- tests/unit/test_passthrough_safe.py | 168 ++++++++++++++++++ tests/unit/test_unsafe_passthrough_error.py | 35 ++++ 32 files changed, 659 insertions(+), 155 deletions(-) create mode 100644 tests/integration/test_async_subprocess_passthrough.py create mode 100644 tests/integration/test_unsafe_passthrough_warn.py create mode 100644 tests/unit/test_passthrough_safe.py create mode 100644 tests/unit/test_unsafe_passthrough_error.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e7d504a..51088d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Breaking:** Renamed package `bigfoot` to `tripwire`. PyPI distribution, Python import name, public API symbols, exception class names, internal sentinels, pytest fixtures, pytest entry-point, and the `[tool.bigfoot]` config table all rename to `tripwire` / `[tool.tripwire]`. No deprecation alias. A `[tool.bigfoot]` section in pyproject.toml raises `ConfigMigrationError` with a clear rename hint. - **Breaking:** Internal source-id sentinels restructured from underscore-flat (`bigfoot_subprocess_run`) to colon-namespaced `:` (e.g., `subprocess:run`, `httpx:get`, `socket:connect`). User-facing only via `GuardedCallError` messages and the `source_id` argument of plugin APIs. The `tripwire:` prefix is intentionally omitted because the namespace is implicit inside the tripwire package. - **Breaking:** Default `[tool.tripwire] guard` flipped from `"warn"` to `"error"`. New projects fail loud on unmocked I/O outside a sandbox. To preserve prior behavior during legacy migration, set `guard = "warn"` explicitly. +- **Breaking:** Removed `BasePlugin.supports_guard` and the `is_guard_eligible()` registry helper. Replaced by `passthrough_safe`. The 6 plugins that had `supports_guard=False` (celery, crypto, file_io, jwt, logging, native) become `passthrough_safe=True` because their interceptors raise `SandboxNotActiveError` outside sandbox (which is safe). MockPlugin and StateMachinePlugin (passive recorders) also become `passthrough_safe=True`. The remaining real-IO plugins become `passthrough_safe=False`. ### Added - `ConfigMigrationError` (subclass of `TripwireError`) raised when `[tool.bigfoot]` is present in pyproject.toml during config load. +- `BasePlugin.passthrough_safe: ClassVar[bool] = False` declares whether a plugin's outside-sandbox passthrough path is genuinely a no-op or raises a clear error. Plugins with `passthrough_safe=False` cause `UnsafePassthroughError` when `guard="warn"` lets a call through. +- `UnsafePassthroughError` exception (subclass of `TripwireError`). + +### Fixed +- `async_subprocess_plugin` type annotations corrected: the `cast(_AsyncFakeProcess, await _ORIGINAL_CREATE_SUBPROCESS_EXEC(...))` claim was a lie (the runtime returned a real `asyncio.subprocess.Process`). The cast is removed and the return-type annotation widened to `_AsyncFakeProcess | asyncio.subprocess.Process` for both `_fake_create_subprocess_exec` and `_fake_create_subprocess_shell`. Runtime behavior unchanged; static types now match reality. ## [0.19.2] - 2026-04-08 diff --git a/src/tripwire/__init__.py b/src/tripwire/__init__.py index fbfb63f..ddb8e42 100644 --- a/src/tripwire/__init__.py +++ b/src/tripwire/__init__.py @@ -56,6 +56,7 @@ TripwireError, UnassertedInteractionsError, UnmockedInteractionError, + UnsafePassthroughError, UnusedMocksError, VerificationError, ) @@ -64,7 +65,7 @@ from tripwire._guard import allow, deny, restrict from tripwire._match import M from tripwire._mock_plugin import MockPlugin -from tripwire._registry import PluginEntry, is_guard_eligible +from tripwire._registry import PluginEntry from tripwire._timeline import Interaction, Timeline from tripwire._verifier import InAnyOrderContext, SandboxContext, StrictVerifier @@ -241,7 +242,6 @@ "Timeline", "GuardPassThrough", "get_verifier_or_raise", - "is_guard_eligible", "PluginEntry", # Classes "StrictVerifier", @@ -285,6 +285,7 @@ "InvalidStateError", "NoActiveVerifierError", "UnmockedInteractionError", + "UnsafePassthroughError", "UnassertedInteractionsError", "UnusedMocksError", "VerificationError", diff --git a/src/tripwire/__init__.pyi b/src/tripwire/__init__.pyi index 447f549..5574ba9 100644 --- a/src/tripwire/__init__.pyi +++ b/src/tripwire/__init__.pyi @@ -31,6 +31,7 @@ from tripwire._errors import TripwireConfigError as TripwireConfigError from tripwire._errors import TripwireError as TripwireError from tripwire._errors import UnassertedInteractionsError as UnassertedInteractionsError from tripwire._errors import UnmockedInteractionError as UnmockedInteractionError +from tripwire._errors import UnsafePassthroughError as UnsafePassthroughError from tripwire._errors import UnusedMocksError as UnusedMocksError from tripwire._errors import VerificationError as VerificationError from tripwire._guard import allow as allow @@ -38,7 +39,6 @@ from tripwire._guard import deny as deny from tripwire._mock_plugin import ImportSiteMock, ObjectMock from tripwire._mock_plugin import MockPlugin as MockPlugin from tripwire._registry import PluginEntry as PluginEntry -from tripwire._registry import is_guard_eligible as is_guard_eligible from tripwire._timeline import Interaction as Interaction from tripwire._timeline import Timeline as Timeline from tripwire._verifier import InAnyOrderContext as InAnyOrderContext diff --git a/src/tripwire/_base_plugin.py b/src/tripwire/_base_plugin.py index 6f3272a..fc650a9 100644 --- a/src/tripwire/_base_plugin.py +++ b/src/tripwire/_base_plugin.py @@ -35,7 +35,15 @@ class BasePlugin(ABC): ref-counting behavior. """ - supports_guard: ClassVar[bool] = True + # passthrough_safe declares whether this plugin's outside-sandbox + # passthrough path is genuinely a no-op or an explicit failure (e.g., + # raises SandboxNotActiveError) rather than performing real I/O. + # Default is False (safer-by-default): plugins must opt in by setting + # passthrough_safe = True. When guard='warn' lets an unmocked call + # through, plugins with passthrough_safe=False raise + # UnsafePassthroughError instead of warning + passing through to real + # I/O. Replaces the prior ``supports_guard`` attribute. + passthrough_safe: ClassVar[bool] = False guard_prefixes: ClassVar[tuple[str, ...]] = () # Shared patching infrastructure -- each subclass gets its own via __init_subclass__ diff --git a/src/tripwire/_context.py b/src/tripwire/_context.py index e59098d..7fb2f2a 100644 --- a/src/tripwire/_context.py +++ b/src/tripwire/_context.py @@ -69,85 +69,107 @@ def get_verifier_or_raise( Decision tree: - 1. Guard-eligible plugin + guard active + firewall ALLOW: - raise GuardPassThrough (bypasses sandbox -- allowed calls are invisible). - 2. Sandbox active: return verifier. - 3. Guard-eligible plugin (determined by supports_guard ClassVar): - a. Guard active + firewall_request provided: - - DENY + level "warn": warn and raise GuardPassThrough. - - DENY + level "error": raise GuardedCallError. - b. Guard active + no firewall_request (should not happen post-migration, - but safe fallback): raise GuardedCallError. - c. Guard not active but patches installed: raise GuardPassThrough. - 4. Non-guard-eligible plugin: raise SandboxNotActiveError. + 1. Sandbox active: return verifier. + 2. Guard active + firewall_request: + a. ALLOW: raise GuardPassThrough. + b. DENY + level "warn": + - plugin.passthrough_safe is False: raise UnsafePassthroughError + (real I/O would otherwise leak past 'warn'). + - otherwise: emit GuardedCallWarning and raise GuardPassThrough. + c. DENY + level "error": raise GuardedCallError. + 3. Guard active + no firewall_request: raise GuardedCallError (fail-closed). + 4. Guard not active but patches installed: raise GuardPassThrough. + 5. Otherwise: raise SandboxNotActiveError. """ - # Check for active sandbox FIRST: when a sandbox is active, all calls - # should go through the sandbox's mock/intercept pipeline. The firewall - # is only relevant in guard mode (outside a sandbox). + plugin_name = source_id.split(":")[0] + + # === Branch 1: sandbox active === verifier = _active_verifier.get() if verifier is not None: return verifier - # No sandbox active -- check firewall for guard mode. - if firewall_request is not None and _guard_active.get(): - plugin_name = source_id.split(":")[0] - from tripwire._registry import is_guard_eligible # noqa: PLC0415 - - if is_guard_eligible(plugin_name): + # Resolve the plugin class once. ``plugin_cls is None`` means the + # source_id does not belong to a registered plugin (e.g., a test + # exercising get_verifier_or_raise with an arbitrary name). Unknown + # plugins skip every guard branch and fall through to + # SandboxNotActiveError so they preserve the pre-C2 contract. + from tripwire._registry import ( # noqa: PLC0415 + lookup_plugin_class_by_name, + ) + plugin_cls = lookup_plugin_class_by_name(plugin_name) + plugin_is_unsafe_passthrough = ( + plugin_cls is not None and plugin_cls.passthrough_safe is False + ) + + # === Branch 3: guard active === + if plugin_cls is not None and _guard_active.get(): + if firewall_request is not None: from tripwire._firewall import Disposition, get_firewall_stack # noqa: PLC0415 disposition = get_firewall_stack().evaluate(firewall_request) - if disposition == Disposition.ALLOW: - raise GuardPassThrough() - - # Determine guard eligibility from plugin ClassVar, not GUARD_ELIGIBLE_PREFIXES - plugin_name = source_id.split(":")[0] - # Use the new unified eligibility check - from tripwire._registry import is_guard_eligible # noqa: PLC0415 - - if is_guard_eligible(plugin_name): - if _guard_active.get(): - if firewall_request is not None: - from tripwire._firewall import Disposition, get_firewall_stack # noqa: PLC0415 + # === Branch 3a: ALLOW === + if disposition is Disposition.ALLOW: + raise GuardPassThrough() - disposition = get_firewall_stack().evaluate(firewall_request) - # ALLOW was already handled above, so this is DENY - level = _guard_level.get() - if level == "warn": - import warnings # noqa: PLC0415 + # === Branch 3b: DENY === + level = _guard_level.get() - from tripwire._errors import GuardedCallWarning # noqa: PLC0415 + if level == "warn": + # === Branch 3b-warn-unsafe === + # If the plugin's passthrough is NOT safe, raise rather + # than warn-and-pass-through, so real I/O does not leak. + if plugin_is_unsafe_passthrough: + from tripwire._errors import UnsafePassthroughError # noqa: PLC0415 - warnings.warn( - f"{source_id!r} blocked by firewall. " - f"See GuardedCallError docs for fix options.", - GuardedCallWarning, - stacklevel=4, + raise UnsafePassthroughError( + source_id=source_id, + plugin_name=plugin_name, ) - raise GuardPassThrough() - # level == "error" - from tripwire._errors import GuardedCallError # noqa: PLC0415 + # === Branch 3b-warn-safe === + import warnings # noqa: PLC0415 - raise GuardedCallError( - source_id=source_id, - plugin_name=plugin_name, - firewall_request=firewall_request, - ) - else: - # No firewall_request: fail closed - from tripwire._errors import GuardedCallError # noqa: PLC0415 - - raise GuardedCallError( - source_id=source_id, - plugin_name=plugin_name, - firewall_request=None, - ) + from tripwire._errors import GuardedCallWarning # noqa: PLC0415 - if _guard_patches_installed.get(): - raise GuardPassThrough() + warnings.warn( + f"{source_id!r} blocked by firewall. " + f"See GuardedCallError docs for fix options.", + GuardedCallWarning, + stacklevel=4, + ) + raise GuardPassThrough() + # === Branch 3b-error === + from tripwire._errors import GuardedCallError # noqa: PLC0415 + + raise GuardedCallError( + source_id=source_id, + plugin_name=plugin_name, + firewall_request=firewall_request, + ) + + # === Branch 3c: guard active, no firewall_request === + # Fail-closed only for plugins whose passthrough is unsafe (real + # I/O). Unknown plugins and passthrough_safe=True plugins fall + # through to SandboxNotActiveError so their interceptor-level + # error paths can run as before. + if plugin_is_unsafe_passthrough: + from tripwire._errors import GuardedCallError # noqa: PLC0415 + + raise GuardedCallError( + source_id=source_id, + plugin_name=plugin_name, + firewall_request=None, + ) + + # === Branch 4: guard not active but patches installed === + # Only fires for known plugins; unknown source_ids fall through to + # SandboxNotActiveError below. + if plugin_cls is not None and _guard_patches_installed.get(): + raise GuardPassThrough() + + # === Branch 5: nothing active === from tripwire._errors import SandboxNotActiveError # noqa: PLC0415 raise SandboxNotActiveError(source_id=source_id) diff --git a/src/tripwire/_errors.py b/src/tripwire/_errors.py index 19f5a77..69141a8 100644 --- a/src/tripwire/_errors.py +++ b/src/tripwire/_errors.py @@ -438,6 +438,39 @@ def _recommend_fix( ) +class UnsafePassthroughError(TripwireError): + """Raised when guard='warn' lets an unmocked call through to a plugin + whose passthrough is NOT safe (i.e., would cause real I/O). + + The plugin declares this via ``passthrough_safe = False`` on its class. + When guard='warn' would normally let an unmocked call pass through to + the original implementation, plugins that perform real I/O on + passthrough raise this error instead, so the test fails loud rather + than silently performing a side effect. + """ + + def __init__(self, source_id: str, plugin_name: str) -> None: + self.source_id = source_id + self.plugin_name = plugin_name + super().__init__(self._build_message()) + + def _build_message(self) -> str: + return ( + f"UnsafePassthroughError: {self.source_id!r} would have caused " + f"real I/O outside any 'with tripwire:' block.\n" + f"\n" + f"Plugin {self.plugin_name!r} doesn't support outside-sandbox " + f"passthrough (passthrough_safe=False), meaning its passthrough " + f"path is NOT a no-op.\n" + f"\n" + f"Fix one of:\n" + f" - Wrap the call in 'with tripwire:' and add an allow/mock for it.\n" + f" - set guard='error' in [tool.tripwire] to make this fail explicitly.\n" + f" - If you have audited that this plugin's passthrough is safe, " + f"set passthrough_safe=True on the plugin class.\n" + ) + + class GuardedCallWarning(UserWarning): """Emitted when guard mode is set to 'warn' and an I/O call fires outside a sandbox without allow() permission. diff --git a/src/tripwire/_mock_plugin.py b/src/tripwire/_mock_plugin.py index 409b581..160d22b 100644 --- a/src/tripwire/_mock_plugin.py +++ b/src/tripwire/_mock_plugin.py @@ -526,7 +526,7 @@ def __getattr__(self, method_name: str) -> MethodProxy: class MockPlugin(BasePlugin): """Core mock plugin: intercepts method calls on named proxy objects.""" - supports_guard: ClassVar[bool] = False + passthrough_safe: ClassVar[bool] = True def __init__(self, verifier: "StrictVerifier") -> None: super().__init__(verifier) diff --git a/src/tripwire/_registry.py b/src/tripwire/_registry.py index 64fe89d..159a889 100644 --- a/src/tripwire/_registry.py +++ b/src/tripwire/_registry.py @@ -130,35 +130,27 @@ def get_plugin_class(entry: PluginEntry) -> type[BasePlugin]: return cls -def is_guard_eligible(plugin_name: str) -> bool: - """Check if a plugin name corresponds to a guard-eligible plugin. - - Derives eligibility from the plugin's supports_guard ClassVar. - Replaces the old GUARD_ELIGIBLE_PREFIXES set. +def lookup_plugin_class_by_name(plugin_name: str) -> type[BasePlugin] | None: + """Return the plugin class registered under ``plugin_name``, or None. + + Looks up by canonical registry name first, then by any ``guard_prefixes`` + declared on a registered plugin class. Returns None when no plugin + matches or when its optional dependency is missing. Callers use this + from outside any active sandbox to ask "what plugin would receive a + call from this source_id?". """ - # Build a cache on first call - if not hasattr(is_guard_eligible, "_cache"): - eligible: set[str] = set() - for entry in PLUGIN_REGISTRY: - if not entry.default_enabled: - continue - try: - cls = get_plugin_class(entry) - if getattr(cls, "supports_guard", True): - eligible.add(entry.name) - # Also add source_id prefixes that differ from registry name - for prefix in getattr(cls, "guard_prefixes", ()): - eligible.add(prefix) - except Exception: - pass - is_guard_eligible._cache = frozenset(eligible) # type: ignore[attr-defined] - return plugin_name in is_guard_eligible._cache # type: ignore[attr-defined] - - -def _clear_guard_eligible_cache() -> None: - """Clear the is_guard_eligible cache. For testing only.""" - if hasattr(is_guard_eligible, "_cache"): - del is_guard_eligible._cache + for entry in PLUGIN_REGISTRY: + if not _is_available(entry): + continue + try: + cls = get_plugin_class(entry) + except Exception: + continue + if entry.name == plugin_name: + return cls + if plugin_name in getattr(cls, "guard_prefixes", ()): + return cls + return None def resolve_enabled_plugins( diff --git a/src/tripwire/_state_machine_plugin.py b/src/tripwire/_state_machine_plugin.py index 972a678..af37bcb 100644 --- a/src/tripwire/_state_machine_plugin.py +++ b/src/tripwire/_state_machine_plugin.py @@ -5,7 +5,7 @@ from abc import abstractmethod from collections import deque from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar from tripwire._base_plugin import BasePlugin from tripwire._errors import InvalidStateError, UnmockedInteractionError @@ -133,6 +133,12 @@ class StateMachinePlugin(BasePlugin): 4. When the connection closes, the concrete plugin calls _release_session(conn). """ + # Abstract base; the base class itself never installs interceptors and + # performs no real I/O. Concrete subclasses (e.g., RedisPlugin, + # AsyncSubprocessPlugin) override this to False because they patch real + # libraries. + passthrough_safe: ClassVar[bool] = True + def __init__(self, verifier: "StrictVerifier") -> None: super().__init__(verifier) self._session_queue: deque[SessionHandle] = deque() diff --git a/src/tripwire/plugins/async_subprocess_plugin.py b/src/tripwire/plugins/async_subprocess_plugin.py index c68736d..408f966 100644 --- a/src/tripwire/plugins/async_subprocess_plugin.py +++ b/src/tripwire/plugins/async_subprocess_plugin.py @@ -9,7 +9,7 @@ import asyncio import asyncio.subprocess from collections.abc import Callable -from typing import TYPE_CHECKING, Any, ClassVar, cast +from typing import TYPE_CHECKING, Any, ClassVar from tripwire._context import GuardPassThrough, get_verifier_or_raise from tripwire._errors import ConflictError @@ -122,6 +122,15 @@ class AsyncSubprocessPlugin(StateMachinePlugin): States: created -> running -> terminated """ + # Real subprocess fork on passthrough; not safe outside sandbox. + passthrough_safe: ClassVar[bool] = False + + # source_id namespace differs from registry name ("async_subprocess"). + # The interceptor records sources under "asyncio:..." (e.g. + # "asyncio:subprocess:spawn"); register that prefix so dispatch can + # look the plugin up by source_id. + guard_prefixes: ClassVar[tuple[str, ...]] = ("asyncio",) + # Saved originals, restored when count reaches 0. _original_exec: ClassVar[Callable[..., Any] | None] = None _original_shell: ClassVar[Callable[..., Any] | None] = None @@ -176,7 +185,7 @@ async def _fake_create_subprocess_exec( program: str, *args: Any, # noqa: ANN401 **kwargs: Any, # noqa: ANN401 - ) -> _AsyncFakeProcess: + ) -> _AsyncFakeProcess | asyncio.subprocess.Process: try: command = [program, *[str(a) for a in args]] binary = program @@ -184,10 +193,16 @@ async def _fake_create_subprocess_exec( fw_request = SubprocessFirewallRequest(command=command_str, binary=binary) plugin = _find_async_subprocess_plugin(firewall_request=fw_request) except GuardPassThrough: - return cast( - _AsyncFakeProcess, - await _ORIGINAL_CREATE_SUBPROCESS_EXEC(program, *args, **kwargs), + # GuardPassThrough means the firewall ALLOWed the call: defer + # to the original asyncio.create_subprocess_exec, which spawns + # a real child process and returns a real + # asyncio.subprocess.Process. Returning that real Process here + # is intentional; the prior cast(_AsyncFakeProcess, ...) lied + # about the runtime type. + proc_real: asyncio.subprocess.Process = await _ORIGINAL_CREATE_SUBPROCESS_EXEC( + program, *args, **kwargs, ) + return proc_real proc = _AsyncFakeProcess() proc._plugin = plugin plugin._bind_connection(proc) @@ -206,16 +221,20 @@ async def _fake_create_subprocess_exec( async def _fake_create_subprocess_shell( cmd: str, **kwargs: Any, # noqa: ANN401 - ) -> _AsyncFakeProcess: + ) -> _AsyncFakeProcess | asyncio.subprocess.Process: try: binary = cmd.split()[0] if cmd else "" fw_request = SubprocessFirewallRequest(command=cmd, binary=binary) plugin = _find_async_subprocess_plugin(firewall_request=fw_request) except GuardPassThrough: - return cast( - _AsyncFakeProcess, - await _ORIGINAL_CREATE_SUBPROCESS_SHELL(cmd, **kwargs), + # GuardPassThrough means the firewall ALLOWed the call: defer + # to the original asyncio.create_subprocess_shell, which spawns + # a real child process and returns a real + # asyncio.subprocess.Process. + proc_real: asyncio.subprocess.Process = await _ORIGINAL_CREATE_SUBPROCESS_SHELL( + cmd, **kwargs, ) + return proc_real proc = _AsyncFakeProcess() proc._plugin = plugin plugin._bind_connection(proc) diff --git a/src/tripwire/plugins/asyncpg_plugin.py b/src/tripwire/plugins/asyncpg_plugin.py index ff9533e..54f8c08 100644 --- a/src/tripwire/plugins/asyncpg_plugin.py +++ b/src/tripwire/plugins/asyncpg_plugin.py @@ -156,6 +156,9 @@ class AsyncpgPlugin(StateMachinePlugin): States: disconnected -> connected -> closed """ + # Real asyncpg connection on passthrough; not safe outside sandbox. + passthrough_safe: ClassVar[bool] = False + # Saved original, restored when count reaches 0. _original_connect: ClassVar[Callable[..., Any] | None] = None diff --git a/src/tripwire/plugins/celery_plugin.py b/src/tripwire/plugins/celery_plugin.py index 5bf54d8..a6cfa1f 100644 --- a/src/tripwire/plugins/celery_plugin.py +++ b/src/tripwire/plugins/celery_plugin.py @@ -205,7 +205,7 @@ class CeleryPlugin(BasePlugin): Uses reference counting so nested sandboxes work correctly. """ - supports_guard: ClassVar[bool] = False + passthrough_safe: ClassVar[bool] = True _original_delay: ClassVar[Callable[..., Any] | None] = None _original_apply_async: ClassVar[Callable[..., Any] | None] = None diff --git a/src/tripwire/plugins/crypto_plugin.py b/src/tripwire/plugins/crypto_plugin.py index c31ce65..deb1856 100644 --- a/src/tripwire/plugins/crypto_plugin.py +++ b/src/tripwire/plugins/crypto_plugin.py @@ -212,7 +212,7 @@ class CryptoPlugin(BasePlugin): is recorded. """ - supports_guard: ClassVar[bool] = False + passthrough_safe: ClassVar[bool] = True _original_encrypt: ClassVar[Callable[..., Any] | None] = None _original_decrypt: ClassVar[Callable[..., Any] | None] = None diff --git a/src/tripwire/plugins/database_plugin.py b/src/tripwire/plugins/database_plugin.py index 277baae..eb2bf7b 100644 --- a/src/tripwire/plugins/database_plugin.py +++ b/src/tripwire/plugins/database_plugin.py @@ -179,6 +179,9 @@ class DatabasePlugin(StateMachinePlugin): States: connected -> in_transaction -> connected/closed """ + # Real sqlite3.connect on passthrough; not safe outside sandbox. + passthrough_safe: ClassVar[bool] = False + # source_id prefixes that differ from the registry name ("database") guard_prefixes: ClassVar[tuple[str, ...]] = ("db",) diff --git a/src/tripwire/plugins/file_io_plugin.py b/src/tripwire/plugins/file_io_plugin.py index 2176f09..08ff8a9 100644 --- a/src/tripwire/plugins/file_io_plugin.py +++ b/src/tripwire/plugins/file_io_plugin.py @@ -481,7 +481,7 @@ class FileIoPlugin(BasePlugin): NOT default enabled: requires explicit enabled_plugins = ["file_io"]. """ - supports_guard: ClassVar[bool] = False + passthrough_safe: ClassVar[bool] = True # Saved originals, restored when count reaches 0 _original_open: ClassVar[Callable[..., Any] | None] = None diff --git a/src/tripwire/plugins/jwt_plugin.py b/src/tripwire/plugins/jwt_plugin.py index 03d491d..c300616 100644 --- a/src/tripwire/plugins/jwt_plugin.py +++ b/src/tripwire/plugins/jwt_plugin.py @@ -176,7 +176,7 @@ class JwtPlugin(BasePlugin): assertion output. """ - supports_guard: ClassVar[bool] = False + passthrough_safe: ClassVar[bool] = True _original_encode: ClassVar[Callable[..., Any] | None] = None _original_decode: ClassVar[Callable[..., Any] | None] = None diff --git a/src/tripwire/plugins/logging_plugin.py b/src/tripwire/plugins/logging_plugin.py index e2cb333..1ae3c5d 100644 --- a/src/tripwire/plugins/logging_plugin.py +++ b/src/tripwire/plugins/logging_plugin.py @@ -113,7 +113,7 @@ class LoggingPlugin(BasePlugin): so nested sandboxes work correctly, following the SubprocessPlugin pattern. """ - supports_guard: ClassVar[bool] = False + passthrough_safe: ClassVar[bool] = True # Saved original, restored when count reaches 0. _original_logger_log: ClassVar[Callable[..., Any] | None] = None diff --git a/src/tripwire/plugins/native_plugin.py b/src/tripwire/plugins/native_plugin.py index 8191c0b..dc76ad0 100644 --- a/src/tripwire/plugins/native_plugin.py +++ b/src/tripwire/plugins/native_plugin.py @@ -259,7 +259,7 @@ class NativePlugin(BasePlugin): Each library:function pair has its own FIFO deque of NativeMockConfig objects. """ - supports_guard: ClassVar[bool] = False + passthrough_safe: ClassVar[bool] = True # Saved originals, restored when count reaches 0 _original_cdll_init: ClassVar[Callable[..., Any] | None] = None diff --git a/src/tripwire/plugins/pika_plugin.py b/src/tripwire/plugins/pika_plugin.py index ad71eaa..1749afd 100644 --- a/src/tripwire/plugins/pika_plugin.py +++ b/src/tripwire/plugins/pika_plugin.py @@ -214,6 +214,9 @@ class PikaPlugin(StateMachinePlugin): close is also valid from connected (skipping channel_open). """ + # Real RabbitMQ connection on passthrough; not safe outside sandbox. + passthrough_safe: ClassVar[bool] = False + # Saved original, restored when count reaches 0. _original_blocking_connection: ClassVar[type[Any] | None] = None diff --git a/src/tripwire/plugins/popen_plugin.py b/src/tripwire/plugins/popen_plugin.py index 6899b33..d9292f5 100644 --- a/src/tripwire/plugins/popen_plugin.py +++ b/src/tripwire/plugins/popen_plugin.py @@ -193,6 +193,9 @@ class PopenPlugin(StateMachinePlugin): independent names in the subprocess module and restore correctly. """ + # Real subprocess fork on passthrough; not safe outside sandbox. + passthrough_safe: ClassVar[bool] = False + # Saved original, restored when count reaches 0. _original_popen: ClassVar[type[subprocess.Popen[Any]] | None] = None diff --git a/src/tripwire/plugins/psycopg2_plugin.py b/src/tripwire/plugins/psycopg2_plugin.py index 2b10ede..7e8dd5d 100644 --- a/src/tripwire/plugins/psycopg2_plugin.py +++ b/src/tripwire/plugins/psycopg2_plugin.py @@ -206,6 +206,9 @@ class Psycopg2Plugin(StateMachinePlugin): States: disconnected -> connected -> in_transaction -> connected/closed """ + # Real psycopg2.connect on passthrough; not safe outside sandbox. + passthrough_safe: ClassVar[bool] = False + # Saved original, restored when count reaches 0. _original_connect: ClassVar[Callable[..., Any] | None] = None diff --git a/src/tripwire/plugins/smtp_plugin.py b/src/tripwire/plugins/smtp_plugin.py index a5c77da..774938a 100644 --- a/src/tripwire/plugins/smtp_plugin.py +++ b/src/tripwire/plugins/smtp_plugin.py @@ -180,6 +180,9 @@ class SmtpPlugin(StateMachinePlugin): login transitions greeted -> authenticated. """ + # Real SMTP connection on passthrough; not safe outside sandbox. + passthrough_safe: ClassVar[bool] = False + # Saved original, restored when count reaches 0. _original_smtp: ClassVar[type[smtplib.SMTP] | None] = None diff --git a/src/tripwire/plugins/socket_plugin.py b/src/tripwire/plugins/socket_plugin.py index a9a67fe..33fc1b8 100644 --- a/src/tripwire/plugins/socket_plugin.py +++ b/src/tripwire/plugins/socket_plugin.py @@ -63,6 +63,9 @@ class SocketPlugin(StateMachinePlugin): States: disconnected -> connected -> closed """ + # Real socket I/O on passthrough; not safe outside sandbox. + passthrough_safe: ClassVar[bool] = False + # Saved originals, restored when count reaches 0. _original_connect: ClassVar[Callable[..., Any] | None] = None _original_send: ClassVar[Callable[..., Any] | None] = None diff --git a/src/tripwire/plugins/ssh_plugin.py b/src/tripwire/plugins/ssh_plugin.py index 640574c..d3cbf16 100644 --- a/src/tripwire/plugins/ssh_plugin.py +++ b/src/tripwire/plugins/ssh_plugin.py @@ -267,6 +267,9 @@ class SshPlugin(StateMachinePlugin): exec_command, open_sftp, and sftp_* are self-transitions on connected. """ + # Real SSH connection on passthrough; not safe outside sandbox. + passthrough_safe: ClassVar[bool] = False + # Saved original, restored when count reaches 0. _original_ssh_client: ClassVar[type[Any] | None] = None diff --git a/src/tripwire/plugins/websocket_plugin.py b/src/tripwire/plugins/websocket_plugin.py index 732a0df..d7711da 100644 --- a/src/tripwire/plugins/websocket_plugin.py +++ b/src/tripwire/plugins/websocket_plugin.py @@ -166,6 +166,9 @@ class AsyncWebSocketPlugin(StateMachinePlugin): guard_prefixes: ClassVar[tuple[str, ...]] = ("async_websocket", "websocket") + # Real WebSocket connection on passthrough; not safe outside sandbox. + passthrough_safe: ClassVar[bool] = False + # Saved original, restored when count reaches 0. _original_connect: ClassVar[Callable[..., Any] | None] = None @@ -414,6 +417,9 @@ class SyncWebSocketPlugin(StateMachinePlugin): guard_prefixes: ClassVar[tuple[str, ...]] = ("sync_websocket", "websocket") + # Real WebSocket connection on passthrough; not safe outside sandbox. + passthrough_safe: ClassVar[bool] = False + # Saved original, restored when count reaches 0. _original_create_connection: ClassVar[Callable[..., Any] | None] = None diff --git a/src/tripwire/pytest_plugin.py b/src/tripwire/pytest_plugin.py index fe1ef86..ba402d1 100644 --- a/src/tripwire/pytest_plugin.py +++ b/src/tripwire/pytest_plugin.py @@ -116,7 +116,9 @@ def _tripwire_guard_patches() -> Generator[None, None, None]: Only installs patches for plugins that: - Have their dependencies available - - Have supports_guard = True + - Have passthrough_safe = False (i.e., real-IO plugins that need guard + mode to intercept calls outside a sandbox; passthrough_safe=True + plugins self-fail with SandboxNotActiveError and need no guard hook) - Are default_enabled (not opt-in plugins like file_io, native) Uses the existing reference-counting activate/deactivate mechanism. @@ -144,7 +146,9 @@ def _tripwire_guard_patches() -> Generator[None, None, None]: continue try: plugin_cls = get_plugin_class(entry) - if not getattr(plugin_cls, "supports_guard", True): + # Skip plugins whose passthrough is safe (no real I/O outside + # sandbox); they self-fail and need no guard-mode patching. + if getattr(plugin_cls, "passthrough_safe", False): continue # Create minimal plugin instance just for activate/deactivate. # __new__ skips __init__; activate() uses ClassVars for patch diff --git a/tests/integration/test_async_subprocess_passthrough.py b/tests/integration/test_async_subprocess_passthrough.py new file mode 100644 index 0000000..c7bce70 --- /dev/null +++ b/tests/integration/test_async_subprocess_passthrough.py @@ -0,0 +1,69 @@ +"""C2-T5, C2-T6: async_subprocess passthrough returns the real +asyncio.subprocess.Process when guard ALLOW lets the call through. + +The runtime always returned a real Process; the prior `cast(_AsyncFakeProcess, +...)` annotation lied about it. These tests pin the post-fix runtime story +so a future refactor cannot regress it. +""" + +from __future__ import annotations + +import asyncio +import shutil + +import pytest + +pytestmark = pytest.mark.integration + +# Portable path to /bin/true (Linux) vs /usr/bin/true (macOS). +_TRUE_PATH = shutil.which("true") or "/usr/bin/true" + + +@pytest.mark.allow("subprocess") +async def test_async_subprocess_passthrough_returns_real_process() -> None: + """C2-T5: With guard ALLOW for subprocess, asyncio.create_subprocess_exec + outside any sandbox returns a real asyncio.subprocess.Process whose + wait() resolves to the child exit code (0 for /bin/true). + + ESCAPE: test_async_subprocess_passthrough_returns_real_process + CLAIM: When the firewall ALLOWs subprocess and no sandbox is active, + our patched create_subprocess_exec falls into the + GuardPassThrough branch and awaits the original + asyncio.create_subprocess_exec, returning the real Process. + PATH: patched _fake_create_subprocess_exec catches GuardPassThrough, + awaits _ORIGINAL_CREATE_SUBPROCESS_EXEC, returns its result. + CHECK: isinstance(result, asyncio.subprocess.Process); wait() returns 0. + MUTATION: If someone re-wraps the original return value in a + _AsyncFakeProcess (the bug the cast() was hiding), the + isinstance check would fail. If the await is removed and a + _AsyncFakeProcess is returned directly, same failure. + ESCAPE: A return-type annotation mismatch (only mypy catches that) + would not change runtime behavior; the runtime test could + still pass while the static types lied. The annotation is + checked separately by mypy --strict. + """ + proc = await asyncio.create_subprocess_exec(_TRUE_PATH) + assert isinstance(proc, asyncio.subprocess.Process), type(proc) + rc = await proc.wait() + assert rc == 0 + + +@pytest.mark.allow("subprocess") +async def test_async_subprocess_shell_passthrough_returns_real_process() -> None: + """C2-T6: Same as T5 but for asyncio.create_subprocess_shell. + + ESCAPE: test_async_subprocess_shell_passthrough_returns_real_process + CLAIM: Mirrors T5 for the shell variant. + PATH: patched _fake_create_subprocess_shell -> GuardPassThrough -> + await _ORIGINAL_CREATE_SUBPROCESS_SHELL -> real Process. + CHECK: isinstance + wait() == 0. + MUTATION: Same as T5; the shell branch is a near-copy of exec and the + fix applies to both. + ESCAPE: A divergence between exec and shell branches (e.g., shell still + wraps in _AsyncFakeProcess) would silently regress one but not + the other; both tests are needed. + """ + proc = await asyncio.create_subprocess_shell(_TRUE_PATH) + assert isinstance(proc, asyncio.subprocess.Process), type(proc) + rc = await proc.wait() + assert rc == 0 diff --git a/tests/integration/test_unsafe_passthrough_warn.py b/tests/integration/test_unsafe_passthrough_warn.py new file mode 100644 index 0000000..ebbeb5f --- /dev/null +++ b/tests/integration/test_unsafe_passthrough_warn.py @@ -0,0 +1,108 @@ +"""C2-T3, C2-T4: warn-mode behavior under the passthrough_safe gate. + +When guard='warn' and a call would pass through, an UNSAFE plugin +(passthrough_safe=False) raises UnsafePassthroughError; a SAFE plugin +(passthrough_safe=True) emits the existing GuardedCallWarning and raises +GuardPassThrough so the original call proceeds. + +These tests exercise the dispatch path inside get_verifier_or_raise +directly because the high-level subprocess integration goes through the +pytest plugin's session-scoped fixtures, which we do not want to +re-stage here. +""" + +from __future__ import annotations + +import warnings + +import pytest + +from tripwire._context import ( + GuardPassThrough, + _guard_active, + _guard_level, + get_verifier_or_raise, +) +from tripwire._errors import GuardedCallWarning, UnsafePassthroughError +from tripwire._firewall_request import ( + NetworkFirewallRequest, + SubprocessFirewallRequest, +) + +pytestmark = pytest.mark.integration + + +def test_warn_with_unsafe_plugin_raises() -> None: + """C2-T3: guard=warn + DENY + passthrough_safe=False plugin + raises UnsafePassthroughError (NOT a warning + passthrough). + + ESCAPE: test_warn_with_unsafe_plugin_raises + CLAIM: Under guard='warn', an unmocked subprocess.run call (the + SubprocessPlugin is passthrough_safe=False) outside any + sandbox and outside any allow() rule raises + UnsafePassthroughError, never GuardPassThrough. + PATH: get_verifier_or_raise -> Branch 3b-warn-unsafe -> raise. + CHECK: pytest.raises(UnsafePassthroughError) catches; the message + names the plugin. + MUTATION: If Branch 3b-warn-unsafe is missing, the call falls to the + existing warn path and raises GuardPassThrough (not the + unsafe error) - the pytest.raises would fail. If + passthrough_safe is mistakenly True on SubprocessPlugin, + the gate skips and the test fails the same way. + ESCAPE: A bug that raises UnsafePassthroughError for the WRONG plugin + name would still pass pytest.raises but fail the message + check. + """ + req = SubprocessFirewallRequest(command="true", binary="true") + level_token = _guard_level.set("warn") + guard_token = _guard_active.set(True) + try: + with pytest.raises(UnsafePassthroughError) as exc_info: + get_verifier_or_raise("subprocess:run", firewall_request=req) + finally: + _guard_active.reset(guard_token) + _guard_level.reset(level_token) + + err = exc_info.value + assert err.plugin_name == "subprocess" + assert err.source_id == "subprocess:run" + assert "doesn't support outside-sandbox passthrough" in err.args[0] + + +def test_warn_with_safe_plugin_passthroughs() -> None: + """C2-T4: guard=warn + DENY + passthrough_safe=True plugin + emits GuardedCallWarning and raises GuardPassThrough (existing + behavior preserved for safe plugins). + + Uses the 'crypto' plugin source_id - CryptoPlugin is + passthrough_safe=True (it has no real I/O and its interceptors raise + SandboxNotActiveError outside sandboxes, which is a safe failure + mode rather than a real-world side effect). + + ESCAPE: test_warn_with_safe_plugin_passthroughs + CLAIM: Under guard='warn', a safe-passthrough plugin still warns + + passes through; the new gate must NOT over-restrict it. + PATH: get_verifier_or_raise -> Branch 3b-warn-unsafe (skipped because + plugin.passthrough_safe is True) -> Branch 3b-warn-safe -> warn + + raise GuardPassThrough. + CHECK: pytest.raises(GuardPassThrough) catches AND a single + GuardedCallWarning was emitted. + MUTATION: If the gate over-fires on safe plugins, this test would see + UnsafePassthroughError instead of GuardPassThrough. + ESCAPE: A bug that no-ops the warn path entirely (no warning) would + pass GuardPassThrough but fail the warning-count assert. + """ + req = NetworkFirewallRequest(protocol="crypto", host="local", port=0) + level_token = _guard_level.set("warn") + guard_token = _guard_active.set(True) + try: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with pytest.raises(GuardPassThrough): + get_verifier_or_raise("crypto:sign", firewall_request=req) + warning_msgs = [w for w in caught if issubclass(w.category, GuardedCallWarning)] + assert len(warning_msgs) == 1 + assert "'crypto:sign'" in str(warning_msgs[0].message) + finally: + _guard_active.reset(guard_token) + _guard_level.reset(level_token) diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 1d1e29a..b31331b 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -122,63 +122,65 @@ def test_message_with_different_plugin(self) -> None: assert 'with tripwire.allow("dns")' in msg -class TestSupportsGuard: - """Test supports_guard ClassVar on plugins.""" +class TestPassthroughSafe: + """Test passthrough_safe ClassVar on plugins (replaces the prior + supports_guard ClassVar; covered more exhaustively in + tests/unit/test_passthrough_safe.py).""" - def test_base_plugin_default_is_true(self) -> None: + def test_base_plugin_default_is_false(self) -> None: from tripwire._base_plugin import BasePlugin - assert BasePlugin.supports_guard is True + assert BasePlugin.passthrough_safe is False - def test_mock_plugin_is_false(self) -> None: + def test_mock_plugin_is_true(self) -> None: from tripwire._mock_plugin import MockPlugin - assert MockPlugin.supports_guard is False + assert MockPlugin.passthrough_safe is True - def test_logging_plugin_is_false(self) -> None: + def test_logging_plugin_is_true(self) -> None: from tripwire.plugins.logging_plugin import LoggingPlugin - assert LoggingPlugin.supports_guard is False + assert LoggingPlugin.passthrough_safe is True - def test_jwt_plugin_is_false(self) -> None: + def test_jwt_plugin_is_true(self) -> None: from tripwire.plugins.jwt_plugin import JwtPlugin - assert JwtPlugin.supports_guard is False + assert JwtPlugin.passthrough_safe is True - def test_crypto_plugin_is_false(self) -> None: + def test_crypto_plugin_is_true(self) -> None: from tripwire.plugins.crypto_plugin import CryptoPlugin - assert CryptoPlugin.supports_guard is False + assert CryptoPlugin.passthrough_safe is True - def test_native_plugin_is_false(self) -> None: + def test_native_plugin_is_true(self) -> None: from tripwire.plugins.native_plugin import NativePlugin - assert NativePlugin.supports_guard is False + assert NativePlugin.passthrough_safe is True - def test_celery_plugin_is_false(self) -> None: + def test_celery_plugin_is_true(self) -> None: from tripwire.plugins.celery_plugin import CeleryPlugin - assert CeleryPlugin.supports_guard is False + assert CeleryPlugin.passthrough_safe is True - def test_file_io_plugin_is_false(self) -> None: + def test_file_io_plugin_is_true(self) -> None: from tripwire.plugins.file_io_plugin import FileIoPlugin - assert FileIoPlugin.supports_guard is False + assert FileIoPlugin.passthrough_safe is True - def test_dns_plugin_inherits_true(self) -> None: + def test_dns_plugin_is_false(self) -> None: from tripwire.plugins.dns_plugin import DnsPlugin - assert DnsPlugin.supports_guard is True + assert DnsPlugin.passthrough_safe is False - def test_http_plugin_inherits_true(self) -> None: + def test_http_plugin_is_false(self) -> None: from tripwire.plugins.http import HttpPlugin - assert HttpPlugin.supports_guard is True + assert HttpPlugin.passthrough_safe is False - def test_socket_plugin_inherits_true(self) -> None: + def test_socket_plugin_is_false(self) -> None: from tripwire.plugins.socket_plugin import SocketPlugin - assert SocketPlugin.supports_guard is True + assert SocketPlugin.passthrough_safe is False from tripwire._guard import allow @@ -495,11 +497,12 @@ def test_warn_mode_emits_warning(self) -> None: try: with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - # Use http (not dns/socket) so the project-level firewall - # allow = ["dns:*", "socket:*"] does not suppress the warning. - req = NetworkFirewallRequest(protocol="http", host="example.com", port=80) + # Use crypto (passthrough_safe=True) so the warn-unsafe + # gate does not fire; project-level firewall allow rules + # only cover dns/socket so this DENY hits the warn path. + req = NetworkFirewallRequest(protocol="crypto", host="local", port=0) with pytest.raises(GuardPassThrough): - get_verifier_or_raise("http:request", firewall_request=req) + get_verifier_or_raise("crypto:sign", firewall_request=req) assert len(w) == 1 assert issubclass(w[0].category, GuardedCallWarning) finally: @@ -516,9 +519,9 @@ def test_warn_mode_raises_guard_pass_through(self) -> None: try: with warnings.catch_warnings(record=True): warnings.simplefilter("always") - req = NetworkFirewallRequest(protocol="http", host="example.com", port=80) + req = NetworkFirewallRequest(protocol="crypto", host="local", port=0) with pytest.raises(GuardPassThrough): - get_verifier_or_raise("http:request", firewall_request=req) + get_verifier_or_raise("crypto:sign", firewall_request=req) finally: _guard_active.reset(guard_token) _guard_level.reset(level_token) @@ -534,9 +537,9 @@ def test_warn_mode_warning_is_filterable(self) -> None: with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") warnings.filterwarnings("ignore", category=GuardedCallWarning) - req = NetworkFirewallRequest(protocol="http", host="example.com", port=80) + req = NetworkFirewallRequest(protocol="crypto", host="local", port=0) with pytest.raises(GuardPassThrough): - get_verifier_or_raise("http:request", firewall_request=req) + get_verifier_or_raise("crypto:sign", firewall_request=req) assert len(w) == 0 finally: _guard_active.reset(guard_token) @@ -552,10 +555,10 @@ def test_warn_mode_warning_contains_source_id(self) -> None: try: with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - req = NetworkFirewallRequest(protocol="http", host="example.com", port=80) + req = NetworkFirewallRequest(protocol="crypto", host="local", port=0) with pytest.raises(GuardPassThrough): - get_verifier_or_raise("http:request", firewall_request=req) - assert "'http:request'" in str(w[0].message) + get_verifier_or_raise("crypto:sign", firewall_request=req) + assert "'crypto:sign'" in str(w[0].message) finally: _guard_active.reset(guard_token) _guard_level.reset(level_token) @@ -570,9 +573,9 @@ def test_warn_mode_warning_contains_blocked_by_firewall(self) -> None: try: with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - req = NetworkFirewallRequest(protocol="http", host="example.com", port=80) + req = NetworkFirewallRequest(protocol="crypto", host="local", port=0) with pytest.raises(GuardPassThrough): - get_verifier_or_raise("http:request", firewall_request=req) + get_verifier_or_raise("crypto:sign", firewall_request=req) msg = str(w[0].message) assert "blocked by firewall" in msg finally: @@ -1215,18 +1218,18 @@ def test_guard_hook_is_registered(self) -> None: assert hasattr(pytest_plugin, "pytest_runtest_call") def test_guard_hook_skips_non_guard_plugins(self) -> None: - """Guard hook should not activate plugins with supports_guard=False.""" + """Guard hook should not activate plugins with passthrough_safe=True.""" from tripwire._registry import PLUGIN_REGISTRY, _is_available, get_plugin_class for entry in PLUGIN_REGISTRY: if not _is_available(entry): continue plugin_cls = get_plugin_class(entry) - if not getattr(plugin_cls, "supports_guard", True): + if getattr(plugin_cls, "passthrough_safe", False): # These plugins should NOT be activated by guard patches assert entry.name in { "logging", "jwt", "crypto", "celery", "native", "file_io", - }, f"Plugin {entry.name} has supports_guard=False but is not in expected set" + }, f"Plugin {entry.name} has passthrough_safe=True but is not in expected set" def test_guard_hook_skips_opt_in_plugins(self) -> None: """Guard hook should not activate opt-in plugins (default_enabled=False).""" @@ -1635,7 +1638,7 @@ def test_guarded_call_error_message_has_actionable_guidance(self) -> None: assert "with tripwire:" in msg assert "https://tripwire.readthedocs.io/guides/guard-mode/" in msg # Old sections removed - assert "supports_guard" not in msg + assert "passthrough_safe" not in msg assert "Valid plugin names for allow():" not in msg finally: _guard_level.reset(level_token) diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index c0a97dc..db06385 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -21,7 +21,6 @@ def test_all_contains_expected_names() -> None: "Timeline", "GuardPassThrough", "get_verifier_or_raise", - "is_guard_eligible", "PluginEntry", # Classes "StrictVerifier", @@ -67,6 +66,7 @@ def test_all_contains_expected_names() -> None: "InvalidStateError", "NoActiveVerifierError", "UnmockedInteractionError", + "UnsafePassthroughError", "UnassertedInteractionsError", "UnusedMocksError", "VerificationError", diff --git a/tests/unit/test_passthrough_safe.py b/tests/unit/test_passthrough_safe.py new file mode 100644 index 0000000..5069f19 --- /dev/null +++ b/tests/unit/test_passthrough_safe.py @@ -0,0 +1,168 @@ +"""C2-T1, C2-T2: tests for the new BasePlugin.passthrough_safe ClassVar. + +Replaces the old supports_guard ClassVar. Default is False (safer-by-default). +A migration table maps each plugin in the codebase to its expected +passthrough_safe value; the test enumerates all subclasses of BasePlugin +recursively (NOT directory glob, so MockPlugin and StateMachinePlugin +defined outside plugins/ are included) and checks each one. +""" + +from __future__ import annotations + + +def _all_subclasses(cls: type) -> set[type]: + """Return all (recursive) subclasses of cls.""" + result: set[type] = set() + stack = [cls] + while stack: + current = stack.pop() + for sub in current.__subclasses__(): + if sub in result: + continue + result.add(sub) + stack.append(sub) + return result + + +def test_default_is_false() -> None: + """C2-T1: A fresh BasePlugin subclass with no override has + passthrough_safe = False (safer-by-default). + + ESCAPE: test_default_is_false + CLAIM: BasePlugin.passthrough_safe defaults to False; subclasses inherit + that default unless they override it. + PATH: BasePlugin class body declares passthrough_safe: ClassVar[bool] = False. + CHECK: A new subclass declared inline reports False. + MUTATION: If the default were flipped to True (the unsafe direction), this + test would observe True on FreshPlugin and fail. + ESCAPE: A FreshPlugin that overrides passthrough_safe = True would still + be classified True, so any code that uses FreshPlugin specifically + would not regress. But the BasePlugin default itself is the risk + this test pins. + """ + from tripwire._base_plugin import BasePlugin + + class FreshPlugin(BasePlugin): + pass + + assert FreshPlugin.passthrough_safe is False + + +# Migration table from design Section 4 (post-rename plugin paths). Maps +# the plugin class name to its expected passthrough_safe value. +EXPECTED_PASSTHROUGH_SAFE: dict[str, bool] = { + # Real-IO plugins (passthrough_safe=False) + "AsyncSubprocessPlugin": False, + "AsyncpgPlugin": False, + "Boto3Plugin": False, + "DatabasePlugin": False, + "DnsPlugin": False, + "ElasticsearchPlugin": False, + "GrpcPlugin": False, + "HttpPlugin": False, + "McpPlugin": False, + "MemcachePlugin": False, + "MongoPlugin": False, + "PikaPlugin": False, + "PopenPlugin": False, + "Psycopg2Plugin": False, + "RedisPlugin": False, + "SmtpPlugin": False, + "SocketPlugin": False, + "SshPlugin": False, + "SubprocessPlugin": False, + "AsyncWebSocketPlugin": False, + "SyncWebSocketPlugin": False, + # Safe-passthrough plugins (passthrough_safe=True) + "CeleryPlugin": True, + "CryptoPlugin": True, + "FileIoPlugin": True, + "JwtPlugin": True, + "LoggingPlugin": True, + "NativePlugin": True, + # Outside plugins/ directory + "MockPlugin": True, + "StateMachinePlugin": True, +} + + +def test_each_plugin_classification() -> None: + """C2-T2: Every BasePlugin subclass found via recursive __subclasses__() + has the documented passthrough_safe value, AND every entry in the + migration table is present as a live class. + + ESCAPE: test_each_plugin_classification + CLAIM: The live set of BasePlugin subclasses (recursive) matches the + migration table from design Section 4 byte-for-byte: each class + reports the documented passthrough_safe; no live class is missing + from the table; no table entry is missing from the live set. + PATH: Force-import every plugin module so subclasses register, then + walk BasePlugin.__subclasses__() recursively. + CHECK: For each live subclass, its passthrough_safe equals the table + entry. For each table entry, a class with that name was found. + MUTATION: Flipping any plugin's passthrough_safe to the wrong value + fails the per-class assert. Adding a new BasePlugin subclass + without updating the table fails the "unexpected" assert. + ESCAPE: A subclass declared in test code (e.g., FreshPlugin in T1) would + also appear in __subclasses__(); the test ignores anonymous + test-only subclasses by skipping any class name not in the + expected table AND defined under tests/. + """ + # Force-import every plugin module so __subclasses__() finds them. + import importlib + + from tripwire._base_plugin import BasePlugin + from tripwire._mock_plugin import MockPlugin # noqa: F401 - registers subclass + from tripwire._registry import PLUGIN_REGISTRY + from tripwire._state_machine_plugin import StateMachinePlugin # noqa: F401 + + for entry in PLUGIN_REGISTRY: + try: + importlib.import_module(entry.import_path) + except ImportError: + # Optional dependency not installed; skip. + continue + + live_subclasses = _all_subclasses(BasePlugin) + live_by_name: dict[str, type] = {cls.__name__: cls for cls in live_subclasses} + + # Verify each table entry is present and classified correctly. + missing_from_live: list[str] = [] + misclassified: list[tuple[str, bool, bool]] = [] + for name, expected in EXPECTED_PASSTHROUGH_SAFE.items(): + cls = live_by_name.get(name) + if cls is None: + # Optional-dep plugin may be absent in this environment; + # only flag known-always-available ones as missing. + # Conservatively allow absence (optional deps). + missing_from_live.append(name) + continue + actual = cls.passthrough_safe + if actual is not expected: + misclassified.append((name, expected, actual)) + + # Verify every live subclass is in the table (forces table updates). + unexpected: list[str] = [] + for name, cls in live_by_name.items(): + if name in EXPECTED_PASSTHROUGH_SAFE: + continue + # Skip anonymous test-only subclasses defined in tests/ modules. + module = getattr(cls, "__module__", "") + if module.startswith("tests."): + continue + unexpected.append(f"{module}.{name}") + + # Misclassification is the strongest failure - report it loudly. + assert not misclassified, ( + f"Misclassified plugins (name, expected, actual): {misclassified}" + ) + assert not unexpected, ( + f"BasePlugin subclasses not in EXPECTED_PASSTHROUGH_SAFE table: {unexpected}. " + "Add each new plugin to design Section 4 and to this table." + ) + # Allow optional-dep absences; flag only if EVERY entry is missing + # (which would mean test wiring is broken). + assert len(missing_from_live) < len(EXPECTED_PASSTHROUGH_SAFE), ( + f"All migration-table classes missing from live subclass set: " + f"{missing_from_live}. Plugin import wiring broke." + ) diff --git a/tests/unit/test_unsafe_passthrough_error.py b/tests/unit/test_unsafe_passthrough_error.py new file mode 100644 index 0000000..596752c --- /dev/null +++ b/tests/unit/test_unsafe_passthrough_error.py @@ -0,0 +1,35 @@ +"""C2-T7: UnsafePassthroughError pedagogical message. + +The error message must include enough framing for a fresh user to recognize +the problem and have at least one immediate fix to try. +""" + +from __future__ import annotations + + +def test_message_contains_pedagogical_text() -> None: + """C2-T7: The exception message names the plugin, explains the cause, + and offers actionable fixes including switching guard to error. + + ESCAPE: test_message_contains_pedagogical_text + CLAIM: UnsafePassthroughError("subprocess:run", "subprocess").args[0] + contains the plugin name, the phrase + "doesn't support outside-sandbox passthrough", and the suggestion + "set guard='error'". + PATH: UnsafePassthroughError.__init__ -> _build_message(). + CHECK: Each substring is present in the constructed message. + MUTATION: Dropping the plugin name, dropping the pedagogical phrase, or + rewording "set guard='error'" to something else (e.g. + "guard=error") would each fail one of the substring checks. + ESCAPE: A message that contains all three substrings but is otherwise + gibberish would pass; the assertion is intentionally narrow to + the user-recoverable framing rather than the prose. + """ + from tripwire._errors import UnsafePassthroughError + + err = UnsafePassthroughError(source_id="subprocess:run", plugin_name="subprocess") + msg = err.args[0] + + assert "doesn't support outside-sandbox passthrough" in msg, msg + assert "set guard='error'" in msg, msg + assert "subprocess" in msg, msg From 0c32dc7161ecdadec6824534d8a0311335a3a27b Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:24:58 -0500 Subject: [PATCH 04/33] Per-protocol guard levels via [tool.tripwire.guard] Extends config schema to accept either a scalar (`guard = "warn"`) or a nested table (`[tool.tripwire.guard] default = "warn"; subprocess = "error"`). Replaces the singular `_guard_level` ContextVar with `_guard_levels: ContextVar[GuardLevels]` (frozen dataclass with `default` and `overrides` fields). Dispatch in _context.py looks up guard_levels.overrides.get(plugin_name, guard_levels.default) per protocol. Backwards-compatible with the scalar form, the bool form (`guard = false` normalizes to "off"), and the "strict" alias for "error". --- CHANGELOG.md | 1 + src/tripwire/_config.py | 112 ++++++++- src/tripwire/_context.py | 17 +- src/tripwire/pytest_plugin.py | 61 ++--- tests/integration/test_per_protocol_guard.py | 94 ++++++++ .../test_unsafe_passthrough_warn.py | 11 +- tests/unit/test_context_propagation.py | 17 +- tests/unit/test_default_guard_error.py | 4 +- tests/unit/test_guard.py | 228 ++++++++++-------- tests/unit/test_guard_levels.py | 175 ++++++++++++++ 10 files changed, 564 insertions(+), 156 deletions(-) create mode 100644 tests/integration/test_per_protocol_guard.py create mode 100644 tests/unit/test_guard_levels.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 51088d1..a99d441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ConfigMigrationError` (subclass of `TripwireError`) raised when `[tool.bigfoot]` is present in pyproject.toml during config load. - `BasePlugin.passthrough_safe: ClassVar[bool] = False` declares whether a plugin's outside-sandbox passthrough path is genuinely a no-op or raises a clear error. Plugins with `passthrough_safe=False` cause `UnsafePassthroughError` when `guard="warn"` lets a call through. - `UnsafePassthroughError` exception (subclass of `TripwireError`). +- `[tool.tripwire.guard]` nested table form: `default = "warn"; = "error" | "warn" | "off"` per protocol. Backwards-compatible with the scalar form `guard = "warn"`. ### Fixed - `async_subprocess_plugin` type annotations corrected: the `cast(_AsyncFakeProcess, await _ORIGINAL_CREATE_SUBPROCESS_EXEC(...))` claim was a lie (the runtime returned a real `asyncio.subprocess.Process`). The cast is removed and the return-type annotation widened to `_AsyncFakeProcess | asyncio.subprocess.Process` for both `_fake_create_subprocess_exec` and `_fake_create_subprocess_shell`. Runtime behavior unchanged; static types now match reality. diff --git a/src/tripwire/_config.py b/src/tripwire/_config.py index 9f977f9..d826cb1 100644 --- a/src/tripwire/_config.py +++ b/src/tripwire/_config.py @@ -1,7 +1,11 @@ """Config loading for tripwire: reads [tool.tripwire] from pyproject.toml.""" +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import Any, Final from tripwire._compat import tomllib @@ -33,3 +37,109 @@ def load_tripwire_config(start: Path | None = None) -> dict[str, Any]: result: dict[str, Any] = data.get("tool", {}).get("tripwire", {}) return result return {} + + +# --------------------------------------------------------------------------- +# Guard-level config (C3 - per-protocol guard levels) +# --------------------------------------------------------------------------- + +_VALID_LEVELS: Final[frozenset[str]] = frozenset({"warn", "error", "off"}) +_LEVEL_ALIASES: Final[Mapping[str, str]] = {"strict": "error"} + + +@dataclass(frozen=True, slots=True) +class GuardLevels: + """Resolved guard levels: a default plus per-plugin overrides. + + `default` applies when no override is registered for a given plugin. + `overrides` maps plugin name (e.g., ``"subprocess"``, ``"dns"``) to + a per-plugin level. Values in both fields are normalized to one of + ``"warn"``, ``"error"``, or ``"off"``. + """ + + default: str + overrides: Mapping[str, str] = field(default_factory=dict) + + +def _normalize_level(raw: str) -> str: + """Normalize a guard-level string: lowercase, then apply aliases. + + Preserves the prior parser's ``.lower()`` behavior and the + ``"strict"`` -> ``"error"`` alias so existing configs keep working. + """ + lowered = raw.lower() + return _LEVEL_ALIASES.get(lowered, lowered) + + +def _resolve_guard_levels(config: Mapping[str, Any]) -> GuardLevels: + """Parse the ``guard`` config value into a ``GuardLevels`` object. + + Accepts: + - missing key (defaults to ``"error"`` per the 0.20.0 default flip) + - scalar string: ``guard = "warn"`` + - scalar bool: ``guard = false`` (normalized to ``"off"``) + - nested table: ``[tool.tripwire.guard]`` with ``default`` and + per-plugin keys + + Raises ``TripwireConfigError`` for invalid values. + """ + from tripwire._errors import TripwireConfigError # noqa: PLC0415 + + raw = config.get("guard", "error") + + # Bool MUST be checked before int/str because bool is a subclass of + # int. The design pseudocode pins bool first. + if isinstance(raw, bool): + if raw is False: + return GuardLevels(default="off", overrides={}) + raise TripwireConfigError( + '[tool.tripwire] guard = true is not a valid value. ' + 'Use "warn", "error", "off", or false.' + ) + + if isinstance(raw, str): + normalized = _normalize_level(raw) + if normalized not in _VALID_LEVELS: + raise TripwireConfigError( + f"Invalid value {raw!r} for [tool.tripwire] guard. " + f"Expected one of: {sorted(_VALID_LEVELS)}." + ) + return GuardLevels(default=normalized, overrides={}) + + if isinstance(raw, dict): + default_raw = raw.get("default", "error") + default: Any + if isinstance(default_raw, bool): + default = "off" if default_raw is False else default_raw + elif isinstance(default_raw, str): + default = _normalize_level(default_raw) + else: + default = default_raw + if default not in _VALID_LEVELS: + raise TripwireConfigError( + f"Invalid value {default_raw!r} for [tool.tripwire.guard] default. " + f"Expected one of: {sorted(_VALID_LEVELS)}." + ) + overrides: dict[str, str] = {} + for key, value in raw.items(): + if key == "default": + continue + normalized_value: Any + if isinstance(value, bool): + normalized_value = "off" if value is False else value + elif isinstance(value, str): + normalized_value = _normalize_level(value) + else: + normalized_value = value + if normalized_value not in _VALID_LEVELS: + raise TripwireConfigError( + f"Invalid value {value!r} for [tool.tripwire.guard] {key}. " + f"Expected one of: {sorted(_VALID_LEVELS)}." + ) + overrides[key] = normalized_value + return GuardLevels(default=default, overrides=overrides) + + raise TripwireConfigError( + f"[tool.tripwire] guard must be a string, bool, or a table, " + f"got {type(raw).__name__}: {raw!r}" + ) diff --git a/src/tripwire/_context.py b/src/tripwire/_context.py index 7fb2f2a..f4b5578 100644 --- a/src/tripwire/_context.py +++ b/src/tripwire/_context.py @@ -9,6 +9,8 @@ import contextvars from typing import TYPE_CHECKING +from tripwire._config import GuardLevels + if TYPE_CHECKING: from tripwire._firewall_request import FirewallRequest from tripwire._verifier import StrictVerifier @@ -33,8 +35,8 @@ "tripwire_guard_active", default=False ) -_guard_level: contextvars.ContextVar[str] = contextvars.ContextVar( - "tripwire_guard_level", default="warn" +_guard_levels: contextvars.ContextVar[GuardLevels] = contextvars.ContextVar( + "tripwire_guard_levels", default=GuardLevels(default="warn", overrides={}) ) _guard_patches_installed: contextvars.ContextVar[bool] = contextvars.ContextVar( @@ -113,7 +115,16 @@ def get_verifier_or_raise( raise GuardPassThrough() # === Branch 3b: DENY === - level = _guard_level.get() + # Per-protocol or default guard level (C3). + guard_levels = _guard_levels.get() + level = guard_levels.overrides.get(plugin_name, guard_levels.default) + + # === Branch 3b-off (C3) === + # Per-protocol "off" disables the firewall entirely for this + # plugin: no warn, no error, no UnsafePassthroughError. + # MUST run BEFORE the warn-unsafe check. + if level == "off": + raise GuardPassThrough() if level == "warn": # === Branch 3b-warn-unsafe === diff --git a/src/tripwire/pytest_plugin.py b/src/tripwire/pytest_plugin.py index ba402d1..0da4b98 100644 --- a/src/tripwire/pytest_plugin.py +++ b/src/tripwire/pytest_plugin.py @@ -7,11 +7,11 @@ import pytest -from tripwire._config import load_tripwire_config +from tripwire._config import _resolve_guard_levels, load_tripwire_config from tripwire._context import ( _current_test_verifier, _guard_active, - _guard_level, + _guard_levels, _guard_patches_installed, ) from tripwire._context_propagation import ( @@ -20,44 +20,6 @@ ) from tripwire._verifier import StrictVerifier -_VALID_GUARD_LEVELS = frozenset({"warn", "error", "strict"}) - - -def _resolve_guard_level(config: dict[str, object]) -> str: - """Parse the guard config value into a normalized level string. - - Returns one of: "warn", "error", "off". - Raises TripwireConfigError for invalid values. - """ - from tripwire._errors import TripwireConfigError # noqa: PLC0415 - - raw = config.get("guard", "error") # default flipped from "warn" to "error" in 0.20.0 - - if raw is True: - raise TripwireConfigError( - 'guard = true is ambiguous. ' - 'Use guard = "warn", guard = "error", or guard = false.\n' - 'Valid values: "warn", "error", "strict", false' - ) - - if raw is False: - return "off" - - if isinstance(raw, str): - normalized = raw.lower() - if normalized in ("error", "strict"): - return "error" - if normalized == "warn": - return "warn" - raise TripwireConfigError( - f'Invalid guard value: {raw!r}. ' - f'Valid values: "warn", "error", "strict", false' - ) - - raise TripwireConfigError( - f"guard must be a string or false, got {type(raw).__name__}: {raw!r}" - ) - def pytest_configure(config: pytest.Config) -> None: """Register tripwire pytest markers and install context propagation.""" @@ -129,8 +91,12 @@ def _tripwire_guard_patches() -> Generator[None, None, None]: during fixture setup/teardown). """ config = load_tripwire_config() - guard_level = _resolve_guard_level(config) - if guard_level == "off": + guard_levels = _resolve_guard_levels(config) + # Skip patch installation only when ALL protocols are "off" + # (i.e., default is "off" and no override raises any protocol back). + if guard_levels.default == "off" and all( + level == "off" for level in guard_levels.overrides.values() + ): yield return @@ -197,8 +163,11 @@ def pytest_runtest_call(item: pytest.Item) -> Generator[None, None, None]: installed (e.g., via sandbox activation within the test). """ config = load_tripwire_config() - guard_level = _resolve_guard_level(config) - if guard_level == "off": + guard_levels = _resolve_guard_levels(config) + # Skip guard activation only when ALL protocols are "off". + if guard_levels.default == "off" and all( + level == "off" for level in guard_levels.overrides.values() + ): yield return @@ -307,13 +276,13 @@ def pytest_runtest_call(item: pytest.Item) -> Generator[None, None, None]: new_stack = current_stack.push(*frames) if frames else current_stack firewall_token = _firewall_stack.set(new_stack) - level_token = _guard_level.set(guard_level) + levels_token = _guard_levels.set(guard_levels) guard_token = _guard_active.set(True) try: yield finally: _guard_active.reset(guard_token) - _guard_level.reset(level_token) + _guard_levels.reset(levels_token) _firewall_stack.reset(firewall_token) diff --git a/tests/integration/test_per_protocol_guard.py b/tests/integration/test_per_protocol_guard.py new file mode 100644 index 0000000..b6a5388 --- /dev/null +++ b/tests/integration/test_per_protocol_guard.py @@ -0,0 +1,94 @@ +"""C3 integration tests: per-protocol guard level dispatch. + +Verifies that a `[tool.tripwire.guard]` table with `default = "warn"` and +a per-protocol override (e.g., `dns = "error"`) is honored by the +dispatch in `_context.get_verifier_or_raise`: outside-sandbox calls for +the override-protocol raise `GuardedCallError`, while +default-level calls go through the warn path. +""" + +from __future__ import annotations + +import warnings + +import pytest + +from tripwire._config import GuardLevels +from tripwire._context import ( + GuardPassThrough, + _guard_active, + _guard_levels, + get_verifier_or_raise, +) +from tripwire._errors import GuardedCallError, GuardedCallWarning +from tripwire._firewall_request import NetworkFirewallRequest + +pytestmark = pytest.mark.integration + + +def test_dns_strict_http_warn() -> None: + """C3-T4: guard default = "warn" but `crypto = "error"` per-protocol. + + A DENY decision against a `crypto` source raises GuardedCallError + (the per-protocol "error" override). A DENY decision against a + different protocol that is `passthrough_safe=True` (jwt) under the + default level "warn" emits GuardedCallWarning and raises + GuardPassThrough. + + The test isolates dispatch from the project's TOML firewall rules by + pushing an empty firewall stack frame so neither protocol is + allow-listed by the surrounding pyproject.toml. + + ESCAPE: test_dns_strict_http_warn + CLAIM: Per-protocol overrides take precedence over the default; + crypto escalates from "warn" to "error" while jwt uses the + default "warn" path. + PATH: get_verifier_or_raise -> Branch 3b -> overrides.get("crypto") + returns "error" -> raise GuardedCallError; for "jwt" the + override is absent so default "warn" applies and the safe + warn path runs. + CHECK: GuardedCallError is raised for the crypto:sign source_id; + for jwt:encode, GuardedCallWarning is emitted and + GuardPassThrough is raised. + MUTATION: If overrides are ignored (i.e., dispatch always reads + guard_levels.default), crypto would warn instead of + raising and the test would fail. If overrides are read + but the key extraction is wrong (e.g., `source_id` + rather than `plugin_name`), the lookup would miss and + crypto would fall to default "warn". + ESCAPE: A bug that hardcodes "error" for every protocol would fail + the jwt:encode branch (which expects warn behavior). + """ + levels_token = _guard_levels.set( + GuardLevels(default="warn", overrides={"crypto": "error"}) + ) + guard_token = _guard_active.set(True) + try: + # crypto: per-protocol "error" override -> GuardedCallError. + crypto_req = NetworkFirewallRequest( + protocol="crypto", host="local", port=0 + ) + with pytest.raises(GuardedCallError) as exc_info: + get_verifier_or_raise( + "crypto:sign", firewall_request=crypto_req + ) + assert exc_info.value.plugin_name == "crypto" + assert exc_info.value.source_id == "crypto:sign" + + # jwt: no override -> default "warn" applies; JwtPlugin is + # passthrough_safe=True so the warn-safe branch runs. + jwt_req = NetworkFirewallRequest( + protocol="jwt", host="local", port=0 + ) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with pytest.raises(GuardPassThrough): + get_verifier_or_raise("jwt:encode", firewall_request=jwt_req) + warning_msgs = [ + w for w in caught if issubclass(w.category, GuardedCallWarning) + ] + assert len(warning_msgs) == 1 + assert "'jwt:encode'" in str(warning_msgs[0].message) + finally: + _guard_active.reset(guard_token) + _guard_levels.reset(levels_token) diff --git a/tests/integration/test_unsafe_passthrough_warn.py b/tests/integration/test_unsafe_passthrough_warn.py index ebbeb5f..0d10f3f 100644 --- a/tests/integration/test_unsafe_passthrough_warn.py +++ b/tests/integration/test_unsafe_passthrough_warn.py @@ -17,10 +17,11 @@ import pytest +from tripwire._config import GuardLevels from tripwire._context import ( GuardPassThrough, _guard_active, - _guard_level, + _guard_levels, get_verifier_or_raise, ) from tripwire._errors import GuardedCallWarning, UnsafePassthroughError @@ -54,14 +55,14 @@ def test_warn_with_unsafe_plugin_raises() -> None: check. """ req = SubprocessFirewallRequest(command="true", binary="true") - level_token = _guard_level.set("warn") + levels_token = _guard_levels.set(GuardLevels(default="warn", overrides={})) guard_token = _guard_active.set(True) try: with pytest.raises(UnsafePassthroughError) as exc_info: get_verifier_or_raise("subprocess:run", firewall_request=req) finally: _guard_active.reset(guard_token) - _guard_level.reset(level_token) + _guard_levels.reset(levels_token) err = exc_info.value assert err.plugin_name == "subprocess" @@ -93,7 +94,7 @@ def test_warn_with_safe_plugin_passthroughs() -> None: pass GuardPassThrough but fail the warning-count assert. """ req = NetworkFirewallRequest(protocol="crypto", host="local", port=0) - level_token = _guard_level.set("warn") + levels_token = _guard_levels.set(GuardLevels(default="warn", overrides={})) guard_token = _guard_active.set(True) try: with warnings.catch_warnings(record=True) as caught: @@ -105,4 +106,4 @@ def test_warn_with_safe_plugin_passthroughs() -> None: assert "'crypto:sign'" in str(warning_msgs[0].message) finally: _guard_active.reset(guard_token) - _guard_level.reset(level_token) + _guard_levels.reset(levels_token) diff --git a/tests/unit/test_context_propagation.py b/tests/unit/test_context_propagation.py index 062d005..7a82a5f 100644 --- a/tests/unit/test_context_propagation.py +++ b/tests/unit/test_context_propagation.py @@ -255,12 +255,13 @@ def test_uninstall_is_idempotent(self) -> None: # Tripwire-specific ContextVar propagation # --------------------------------------------------------------------------- +from tripwire._config import GuardLevels from tripwire._context import ( _active_verifier, _any_order_depth, _current_test_verifier, _guard_active, - _guard_level, + _guard_levels, _guard_patches_installed, ) from tripwire._recording import _recording_in_progress @@ -277,7 +278,7 @@ class TestTripwireContextVarsPropagation: (_any_order_depth, 3), (_current_test_verifier, object()), (_guard_active, True), - (_guard_level, "error"), + (_guard_levels, GuardLevels(default="error", overrides={})), (_guard_patches_installed, True), (_recording_in_progress, True), (_file_io_bypass, True), @@ -287,7 +288,7 @@ class TestTripwireContextVarsPropagation: "any_order_depth", "current_test_verifier", "guard_active", - "guard_level", + "guard_levels", "guard_patches_installed", "recording_in_progress", "file_io_bypass", @@ -331,7 +332,10 @@ def test_guard_error_propagates_to_child_thread(self) -> None: with contextlib.ExitStack() as stack: stack.callback(_guard_active.reset, _guard_active.set(True)) - stack.callback(_guard_level.reset, _guard_level.set("error")) + stack.callback( + _guard_levels.reset, + _guard_levels.set(GuardLevels(default="error", overrides={})), + ) stack.callback(_guard_patches_installed.reset, _guard_patches_installed.set(True)) errors: list[BaseException] = [] @@ -367,7 +371,10 @@ def test_guard_firewall_allow_propagates_to_child_thread(self) -> None: with contextlib.ExitStack() as stack: stack.callback(_guard_active.reset, _guard_active.set(True)) - stack.callback(_guard_level.reset, _guard_level.set("error")) + stack.callback( + _guard_levels.reset, + _guard_levels.set(GuardLevels(default="error", overrides={})), + ) stack.callback(_guard_patches_installed.reset, _guard_patches_installed.set(True)) stack.callback(_firewall_stack.reset, _firewall_stack.set(allow_stack)) errors: list[BaseException] = [] diff --git a/tests/unit/test_default_guard_error.py b/tests/unit/test_default_guard_error.py index 126a66b..420ea32 100644 --- a/tests/unit/test_default_guard_error.py +++ b/tests/unit/test_default_guard_error.py @@ -2,7 +2,7 @@ from __future__ import annotations -from tripwire.pytest_plugin import _resolve_guard_level +from tripwire._config import GuardLevels, _resolve_guard_levels def test_default_guard_is_error() -> None: @@ -11,4 +11,4 @@ def test_default_guard_is_error() -> None: Replaces the prior default of 'warn'. New projects fail loud on unmocked I/O outside a sandbox; legacy projects must opt back into warn explicitly. """ - assert _resolve_guard_level({}) == "error" + assert _resolve_guard_levels({}) == GuardLevels(default="error", overrides={}) diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index b31331b..e4bd241 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -6,6 +6,7 @@ import pytest +from tripwire._config import GuardLevels, _resolve_guard_levels from tripwire._context import ( GuardPassThrough, _guard_active, @@ -19,7 +20,6 @@ _firewall_stack, ) from tripwire._match import M -from tripwire.pytest_plugin import _resolve_guard_level class TestGuardContextVars: @@ -423,52 +423,66 @@ def test_guarded_call_warning_in_all(self) -> None: class TestResolveGuardLevel: - """Test _resolve_guard_level config parser.""" + """Test _resolve_guard_levels config parser (scalar form).""" def test_absent_key_returns_error(self) -> None: """Missing guard key defaults to 'error' as of 0.20.0 (Proposal 1 default flip).""" - assert _resolve_guard_level({}) == "error" + assert _resolve_guard_levels({}) == GuardLevels(default="error", overrides={}) def test_warn_string_returns_warn(self) -> None: - assert _resolve_guard_level({"guard": "warn"}) == "warn" + assert _resolve_guard_levels({"guard": "warn"}) == GuardLevels( + default="warn", overrides={} + ) def test_error_string_returns_error(self) -> None: - assert _resolve_guard_level({"guard": "error"}) == "error" + assert _resolve_guard_levels({"guard": "error"}) == GuardLevels( + default="error", overrides={} + ) def test_strict_string_returns_error(self) -> None: """'strict' is an alias for 'error'.""" - assert _resolve_guard_level({"guard": "strict"}) == "error" + assert _resolve_guard_levels({"guard": "strict"}) == GuardLevels( + default="error", overrides={} + ) def test_false_returns_off(self) -> None: - assert _resolve_guard_level({"guard": False}) == "off" + assert _resolve_guard_levels({"guard": False}) == GuardLevels( + default="off", overrides={} + ) def test_true_rejected_with_config_error(self) -> None: """guard = true is ambiguous and must be rejected.""" from tripwire._errors import TripwireConfigError - with pytest.raises(TripwireConfigError, match="guard = true is ambiguous"): - _resolve_guard_level({"guard": True}) + with pytest.raises(TripwireConfigError, match="guard = true is not a valid value"): + _resolve_guard_levels({"guard": True}) def test_invalid_string_rejected(self) -> None: from tripwire._errors import TripwireConfigError - with pytest.raises(TripwireConfigError, match="Invalid guard value"): - _resolve_guard_level({"guard": "invalid"}) + with pytest.raises(TripwireConfigError, match="Invalid value 'invalid' for"): + _resolve_guard_levels({"guard": "invalid"}) def test_invalid_type_rejected(self) -> None: from tripwire._errors import TripwireConfigError - with pytest.raises(TripwireConfigError, match="guard must be a string or false"): - _resolve_guard_level({"guard": 42}) + with pytest.raises(TripwireConfigError, match="guard must be a string, bool, or a table"): + _resolve_guard_levels({"guard": 42}) def test_case_insensitive_warn(self) -> None: - assert _resolve_guard_level({"guard": "WARN"}) == "warn" + assert _resolve_guard_levels({"guard": "WARN"}) == GuardLevels( + default="warn", overrides={} + ) def test_case_insensitive_error(self) -> None: - assert _resolve_guard_level({"guard": "ERROR"}) == "error" + assert _resolve_guard_levels({"guard": "ERROR"}) == GuardLevels( + default="error", overrides={} + ) def test_case_insensitive_strict(self) -> None: - assert _resolve_guard_level({"guard": "STRICT"}) == "error" + assert _resolve_guard_levels({"guard": "STRICT"}) == GuardLevels( + default="error", overrides={} + ) class TestGuardedCallWarningClass: @@ -489,10 +503,11 @@ class TestWarnModeBehavior: def test_warn_mode_emits_warning(self) -> None: """Guard in warn mode emits GuardedCallWarning.""" - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._firewall_request import NetworkFirewallRequest - level_token = _guard_level.set("warn") + level_token = _guard_levels.set(GuardLevels(default="warn", overrides={})) guard_token = _guard_active.set(True) try: with warnings.catch_warnings(record=True) as w: @@ -507,14 +522,15 @@ def test_warn_mode_emits_warning(self) -> None: assert issubclass(w[0].category, GuardedCallWarning) finally: _guard_active.reset(guard_token) - _guard_level.reset(level_token) + _guard_levels.reset(level_token) def test_warn_mode_raises_guard_pass_through(self) -> None: """After warning, GuardPassThrough is raised (real call proceeds).""" - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._firewall_request import NetworkFirewallRequest - level_token = _guard_level.set("warn") + level_token = _guard_levels.set(GuardLevels(default="warn", overrides={})) guard_token = _guard_active.set(True) try: with warnings.catch_warnings(record=True): @@ -524,14 +540,15 @@ def test_warn_mode_raises_guard_pass_through(self) -> None: get_verifier_or_raise("crypto:sign", firewall_request=req) finally: _guard_active.reset(guard_token) - _guard_level.reset(level_token) + _guard_levels.reset(level_token) def test_warn_mode_warning_is_filterable(self) -> None: """warnings.filterwarnings('ignore') suppresses GuardedCallWarning.""" - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._firewall_request import NetworkFirewallRequest - level_token = _guard_level.set("warn") + level_token = _guard_levels.set(GuardLevels(default="warn", overrides={})) guard_token = _guard_active.set(True) try: with warnings.catch_warnings(record=True) as w: @@ -543,14 +560,15 @@ def test_warn_mode_warning_is_filterable(self) -> None: assert len(w) == 0 finally: _guard_active.reset(guard_token) - _guard_level.reset(level_token) + _guard_levels.reset(level_token) def test_warn_mode_warning_contains_source_id(self) -> None: """Warning message includes the source_id.""" - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._firewall_request import NetworkFirewallRequest - level_token = _guard_level.set("warn") + level_token = _guard_levels.set(GuardLevels(default="warn", overrides={})) guard_token = _guard_active.set(True) try: with warnings.catch_warnings(record=True) as w: @@ -561,14 +579,15 @@ def test_warn_mode_warning_contains_source_id(self) -> None: assert "'crypto:sign'" in str(w[0].message) finally: _guard_active.reset(guard_token) - _guard_level.reset(level_token) + _guard_levels.reset(level_token) def test_warn_mode_warning_contains_blocked_by_firewall(self) -> None: """Warning message says 'blocked by firewall'.""" - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._firewall_request import NetworkFirewallRequest - level_token = _guard_level.set("warn") + level_token = _guard_levels.set(GuardLevels(default="warn", overrides={})) guard_token = _guard_active.set(True) try: with warnings.catch_warnings(record=True) as w: @@ -580,14 +599,15 @@ def test_warn_mode_warning_contains_blocked_by_firewall(self) -> None: assert "blocked by firewall" in msg finally: _guard_active.reset(guard_token) - _guard_level.reset(level_token) + _guard_levels.reset(level_token) def test_error_mode_raises_guarded_call_error(self) -> None: """Guard in error mode raises GuardedCallError (not a warning).""" - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._firewall_request import NetworkFirewallRequest - level_token = _guard_level.set("error") + level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) guard_token = _guard_active.set(True) try: req = NetworkFirewallRequest(protocol="http", host="example.com", port=80) @@ -595,14 +615,15 @@ def test_error_mode_raises_guarded_call_error(self) -> None: get_verifier_or_raise("http:request", firewall_request=req) finally: _guard_active.reset(guard_token) - _guard_level.reset(level_token) + _guard_levels.reset(level_token) def test_firewall_allow_in_warn_mode_suppresses_warning(self) -> None: """Allowed protocols don't emit warnings even in warn mode.""" - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._firewall_request import NetworkFirewallRequest - level_token = _guard_level.set("warn") + level_token = _guard_levels.set(GuardLevels(default="warn", overrides={})) guard_token = _guard_active.set(True) # Push an ALLOW rule for dns onto the firewall stack with allow("dns"): @@ -616,7 +637,7 @@ def test_firewall_allow_in_warn_mode_suppresses_warning(self) -> None: ] assert len(guarded_warnings) == 0 _guard_active.reset(guard_token) - _guard_level.reset(level_token) + _guard_levels.reset(level_token) class TestHookFirewallStackMerge: @@ -679,9 +700,10 @@ def test_no_sandbox_no_guard_raises_sandbox_not_active(self) -> None: def test_guard_active_not_in_allowlist_raises_guarded_call_error(self) -> None: """Guard active + not allowed + error level = GuardedCallError.""" - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels - level_token = _guard_level.set("error") + level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) token = _guard_active.set(True) try: with pytest.raises(GuardedCallError) as exc_info: @@ -690,7 +712,7 @@ def test_guard_active_not_in_allowlist_raises_guarded_call_error(self) -> None: assert exc_info.value.source_id == "dns:getaddrinfo:example.com" finally: _guard_active.reset(token) - _guard_level.reset(level_token) + _guard_levels.reset(level_token) def test_guard_active_in_allowlist_raises_guard_pass_through(self) -> None: """Guard active + allowed via firewall = GuardPassThrough (interceptor should call original).""" @@ -707,9 +729,10 @@ def test_guard_active_in_allowlist_raises_guard_pass_through(self) -> None: def test_plugin_name_extraction_from_source_id(self) -> None: """Plugin name is the prefix before the first colon.""" - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels - level_token = _guard_level.set("error") + level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) token = _guard_active.set(True) try: with pytest.raises(GuardedCallError) as exc_info: @@ -717,13 +740,14 @@ def test_plugin_name_extraction_from_source_id(self) -> None: assert exc_info.value.plugin_name == "http" finally: _guard_active.reset(token) - _guard_level.reset(level_token) + _guard_levels.reset(level_token) def test_plugin_name_extraction_multi_colon(self) -> None: """Multi-colon source_id: plugin name is still first segment.""" - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels - level_token = _guard_level.set("error") + level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) token = _guard_active.set(True) try: with pytest.raises(GuardedCallError) as exc_info: @@ -731,7 +755,7 @@ def test_plugin_name_extraction_multi_colon(self) -> None: assert exc_info.value.plugin_name == "dns" finally: _guard_active.reset(token) - _guard_level.reset(level_token) + _guard_levels.reset(level_token) class TestGuardPassThroughInDirectPlugins: @@ -748,7 +772,8 @@ def test_dns_getaddrinfo_guard_blocks_when_not_allowed(self) -> None: """Guard blocks dns:getaddrinfo when dns not in allowlist.""" import socket - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._guard import deny from tripwire._verifier import StrictVerifier from tripwire.plugins.dns_plugin import DnsPlugin @@ -757,7 +782,7 @@ def test_dns_getaddrinfo_guard_blocks_when_not_allowed(self) -> None: dns = DnsPlugin(v) dns.activate() try: - level_token = _guard_level.set("error") + level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) guard_token = _guard_active.set(True) try: # Explicitly deny dns to override project-level allow = ["dns:*"] @@ -768,7 +793,7 @@ def test_dns_getaddrinfo_guard_blocks_when_not_allowed(self) -> None: assert exc_info.value.source_id == "dns:lookup" finally: _guard_active.reset(guard_token) - _guard_level.reset(level_token) + _guard_levels.reset(level_token) finally: dns.deactivate() @@ -803,7 +828,8 @@ def test_dns_gethostbyname_guard_blocks_when_not_allowed(self) -> None: """Guard blocks dns:gethostbyname when dns not in allowlist.""" import socket - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._guard import deny from tripwire._verifier import StrictVerifier from tripwire.plugins.dns_plugin import DnsPlugin @@ -812,7 +838,7 @@ def test_dns_gethostbyname_guard_blocks_when_not_allowed(self) -> None: dns = DnsPlugin(v) dns.activate() try: - level_token = _guard_level.set("error") + level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) guard_token = _guard_active.set(True) try: # Explicitly deny dns to override project-level allow = ["dns:*"] @@ -822,7 +848,7 @@ def test_dns_gethostbyname_guard_blocks_when_not_allowed(self) -> None: assert exc_info.value.plugin_name == "dns" finally: _guard_active.reset(guard_token) - _guard_level.reset(level_token) + _guard_levels.reset(level_token) finally: dns.deactivate() @@ -859,7 +885,8 @@ def test_socket_connect_guard_blocks_when_not_allowed(self) -> None: """Guard blocks socket:connect when socket not in allowlist.""" import socket as socket_mod - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._guard import deny from tripwire._verifier import StrictVerifier from tripwire.plugins.socket_plugin import _SOCKET_CLOSE_ORIGINAL, SocketPlugin @@ -868,7 +895,7 @@ def test_socket_connect_guard_blocks_when_not_allowed(self) -> None: sp = SocketPlugin(v) sp.activate() try: - level_token = _guard_level.set("error") + level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) guard_token = _guard_active.set(True) try: # Explicitly deny socket to override project-level allow = ["socket:*"] @@ -883,7 +910,7 @@ def test_socket_connect_guard_blocks_when_not_allowed(self) -> None: _SOCKET_CLOSE_ORIGINAL(sock) finally: _guard_active.reset(guard_token) - _guard_level.reset(level_token) + _guard_levels.reset(level_token) finally: sp.deactivate() @@ -971,7 +998,8 @@ def test_database_connect_guard_blocks_when_not_allowed(self) -> None: """Guard blocks db:connect when db not in allowlist.""" import sqlite3 - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._verifier import StrictVerifier from tripwire.plugins.database_plugin import DatabasePlugin @@ -979,7 +1007,7 @@ def test_database_connect_guard_blocks_when_not_allowed(self) -> None: dp = DatabasePlugin(v) dp.activate() try: - level_token = _guard_level.set("error") + level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) guard_token = _guard_active.set(True) try: with pytest.raises(GuardedCallError) as exc_info: @@ -988,7 +1016,7 @@ def test_database_connect_guard_blocks_when_not_allowed(self) -> None: assert exc_info.value.source_id == "db:connect" finally: _guard_active.reset(guard_token) - _guard_level.reset(level_token) + _guard_levels.reset(level_token) finally: dp.deactivate() @@ -1023,7 +1051,8 @@ def test_smtp_init_guard_blocks_when_not_allowed(self) -> None: """Guard blocks smtp:connect when smtp not in allowlist.""" import smtplib - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._verifier import StrictVerifier from tripwire.plugins.smtp_plugin import SmtpPlugin @@ -1031,7 +1060,7 @@ def test_smtp_init_guard_blocks_when_not_allowed(self) -> None: sp = SmtpPlugin(v) sp.activate() try: - level_token = _guard_level.set("error") + level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) guard_token = _guard_active.set(True) try: with pytest.raises(GuardedCallError) as exc_info: @@ -1039,7 +1068,7 @@ def test_smtp_init_guard_blocks_when_not_allowed(self) -> None: assert exc_info.value.plugin_name == "smtp" finally: _guard_active.reset(guard_token) - _guard_level.reset(level_token) + _guard_levels.reset(level_token) finally: sp.deactivate() @@ -1047,7 +1076,8 @@ def test_popen_init_guard_blocks_when_not_allowed(self) -> None: """Guard blocks subprocess:popen:spawn when subprocess not in allowlist.""" import subprocess - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._verifier import StrictVerifier from tripwire.plugins.popen_plugin import PopenPlugin @@ -1055,7 +1085,7 @@ def test_popen_init_guard_blocks_when_not_allowed(self) -> None: pp = PopenPlugin(v) pp.activate() try: - level_token = _guard_level.set("error") + level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) guard_token = _guard_active.set(True) try: with pytest.raises(GuardedCallError) as exc_info: @@ -1063,7 +1093,7 @@ def test_popen_init_guard_blocks_when_not_allowed(self) -> None: assert exc_info.value.plugin_name == "subprocess" finally: _guard_active.reset(guard_token) - _guard_level.reset(level_token) + _guard_levels.reset(level_token) finally: pp.deactivate() @@ -1080,7 +1110,8 @@ def test_subprocess_run_guard_blocks_when_not_allowed(self) -> None: """Guard blocks subprocess.run when subprocess not in allowlist.""" import subprocess as subprocess_mod - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._verifier import StrictVerifier from tripwire.plugins.subprocess import SubprocessPlugin @@ -1088,7 +1119,7 @@ def test_subprocess_run_guard_blocks_when_not_allowed(self) -> None: sp = SubprocessPlugin(v) sp.activate() try: - level_token = _guard_level.set("error") + level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) guard_token = _guard_active.set(True) try: with pytest.raises(GuardedCallError) as exc_info: @@ -1097,7 +1128,7 @@ def test_subprocess_run_guard_blocks_when_not_allowed(self) -> None: assert exc_info.value.source_id == "subprocess:run" finally: _guard_active.reset(guard_token) - _guard_level.reset(level_token) + _guard_levels.reset(level_token) finally: sp.deactivate() @@ -1128,7 +1159,8 @@ def test_subprocess_which_guard_blocks_when_not_allowed(self) -> None: """Guard blocks shutil.which when subprocess not in allowlist.""" import shutil as shutil_mod - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._verifier import StrictVerifier from tripwire.plugins.subprocess import SubprocessPlugin @@ -1136,7 +1168,7 @@ def test_subprocess_which_guard_blocks_when_not_allowed(self) -> None: sp = SubprocessPlugin(v) sp.activate() try: - level_token = _guard_level.set("error") + level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) guard_token = _guard_active.set(True) try: with pytest.raises(GuardedCallError) as exc_info: @@ -1145,7 +1177,7 @@ def test_subprocess_which_guard_blocks_when_not_allowed(self) -> None: assert exc_info.value.source_id == "subprocess:which" finally: _guard_active.reset(guard_token) - _guard_level.reset(level_token) + _guard_levels.reset(level_token) finally: sp.deactivate() @@ -1174,7 +1206,8 @@ def test_http_sync_guard_blocks_when_not_allowed(self) -> None: """Guard blocks httpx sync transport when http not in allowlist.""" import httpx - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._verifier import StrictVerifier from tripwire.plugins.http import HttpPlugin @@ -1182,7 +1215,7 @@ def test_http_sync_guard_blocks_when_not_allowed(self) -> None: hp = HttpPlugin(v) hp.activate() try: - level_token = _guard_level.set("error") + level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) guard_token = _guard_active.set(True) try: with pytest.raises(GuardedCallError) as exc_info: @@ -1191,7 +1224,7 @@ def test_http_sync_guard_blocks_when_not_allowed(self) -> None: assert exc_info.value.source_id == "http:request" finally: _guard_active.reset(guard_token) - _guard_level.reset(level_token) + _guard_levels.reset(level_token) finally: hp.deactivate() @@ -1312,7 +1345,8 @@ def test_guard_blocks_real_socket_connect_outside_sandbox(self) -> None: """ import socket as socket_mod - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._guard import deny from tripwire._verifier import StrictVerifier from tripwire.plugins.socket_plugin import _SOCKET_CLOSE_ORIGINAL, SocketPlugin @@ -1320,7 +1354,7 @@ def test_guard_blocks_real_socket_connect_outside_sandbox(self) -> None: v = StrictVerifier() sp = SocketPlugin(v) sp.activate() - level_token = _guard_level.set("error") + level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) try: # Explicitly deny socket to override project-level allow = ["socket:*"] with deny("socket"): @@ -1333,7 +1367,7 @@ def test_guard_blocks_real_socket_connect_outside_sandbox(self) -> None: finally: _SOCKET_CLOSE_ORIGINAL(sock) finally: - _guard_level.reset(level_token) + _guard_levels.reset(level_token) sp.deactivate() def test_guard_pass_through_permits_real_socket_operations(self) -> None: @@ -1467,7 +1501,8 @@ def test_guard_blocks_dns_lookup_outside_sandbox(self) -> None: """ import socket as socket_mod - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._guard import deny from tripwire._verifier import StrictVerifier from tripwire.plugins.dns_plugin import DnsPlugin @@ -1475,7 +1510,7 @@ def test_guard_blocks_dns_lookup_outside_sandbox(self) -> None: v = StrictVerifier() dns = DnsPlugin(v) dns.activate() - level_token = _guard_level.set("error") + level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) try: # Explicitly deny dns to override project-level allow = ["dns:*"] with deny("dns"): @@ -1483,7 +1518,7 @@ def test_guard_blocks_dns_lookup_outside_sandbox(self) -> None: socket_mod.getaddrinfo("example.com", 80) assert exc_info.value.plugin_name == "dns" finally: - _guard_level.reset(level_token) + _guard_levels.reset(level_token) dns.deactivate() def test_guard_blocks_subprocess_outside_sandbox(self) -> None: @@ -1494,21 +1529,22 @@ def test_guard_blocks_subprocess_outside_sandbox(self) -> None: """ import subprocess as subprocess_mod - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._verifier import StrictVerifier from tripwire.plugins.subprocess import SubprocessPlugin v = StrictVerifier() sp = SubprocessPlugin(v) sp.activate() - level_token = _guard_level.set("error") + level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) try: with pytest.raises(GuardedCallError) as exc_info: subprocess_mod.run(["echo", "hello"], capture_output=True) assert exc_info.value.plugin_name == "subprocess" assert exc_info.value.source_id == "subprocess:run" finally: - _guard_level.reset(level_token) + _guard_levels.reset(level_token) sp.deactivate() def test_guard_pass_through_permits_real_subprocess(self) -> None: @@ -1543,21 +1579,22 @@ def test_guard_blocks_http_outside_sandbox(self) -> None: """ import httpx - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._verifier import StrictVerifier from tripwire.plugins.http import HttpPlugin v = StrictVerifier() hp = HttpPlugin(v) hp.activate() - level_token = _guard_level.set("error") + level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) try: with pytest.raises(GuardedCallError) as exc_info: httpx.get("https://example.com") assert exc_info.value.plugin_name == "http" assert exc_info.value.source_id == "http:request" finally: - _guard_level.reset(level_token) + _guard_levels.reset(level_token) hp.deactivate() def test_sandbox_takes_precedence_over_firewall_allow(self) -> None: @@ -1609,7 +1646,8 @@ def test_guarded_call_error_message_has_actionable_guidance(self) -> None: """ import socket as socket_mod - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._guard import deny from tripwire._verifier import StrictVerifier from tripwire.plugins.dns_plugin import DnsPlugin @@ -1617,7 +1655,7 @@ def test_guarded_call_error_message_has_actionable_guidance(self) -> None: v = StrictVerifier() dns = DnsPlugin(v) dns.activate() - level_token = _guard_level.set("error") + level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) try: # Explicitly deny dns to override project-level allow = ["dns:*"] with deny("dns"): @@ -1641,7 +1679,7 @@ def test_guarded_call_error_message_has_actionable_guidance(self) -> None: assert "passthrough_safe" not in msg assert "Valid plugin names for allow():" not in msg finally: - _guard_level.reset(level_token) + _guard_levels.reset(level_token) dns.deactivate() @@ -1933,7 +1971,8 @@ def test_close_no_guarded_call_error_even_with_deny(self) -> None: """ import socket as socket_mod - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._guard import deny from tripwire._verifier import StrictVerifier from tripwire.plugins.socket_plugin import SocketPlugin @@ -1942,7 +1981,7 @@ def test_close_no_guarded_call_error_even_with_deny(self) -> None: sp = SocketPlugin(v) sp.activate() try: - level_token = _guard_level.set("error") + level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) guard_token = _guard_active.set(True) try: with deny("socket"): @@ -1951,7 +1990,7 @@ def test_close_no_guarded_call_error_even_with_deny(self) -> None: sock.close() finally: _guard_active.reset(guard_token) - _guard_level.reset(level_token) + _guard_levels.reset(level_token) finally: sp.deactivate() @@ -1964,7 +2003,8 @@ def test_send_no_guarded_call_error_even_with_deny(self) -> None: """ import socket as socket_mod - from tripwire._context import _guard_level + from tripwire._config import GuardLevels + from tripwire._context import _guard_levels from tripwire._guard import deny from tripwire._verifier import StrictVerifier from tripwire.plugins.socket_plugin import _SOCKET_CLOSE_ORIGINAL, SocketPlugin @@ -1973,7 +2013,7 @@ def test_send_no_guarded_call_error_even_with_deny(self) -> None: sp = SocketPlugin(v) sp.activate() try: - level_token = _guard_level.set("error") + level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) guard_token = _guard_active.set(True) try: with deny("socket"): @@ -1986,6 +2026,6 @@ def test_send_no_guarded_call_error_even_with_deny(self) -> None: _SOCKET_CLOSE_ORIGINAL(sock) finally: _guard_active.reset(guard_token) - _guard_level.reset(level_token) + _guard_levels.reset(level_token) finally: sp.deactivate() diff --git a/tests/unit/test_guard_levels.py b/tests/unit/test_guard_levels.py new file mode 100644 index 0000000..cfbf5f0 --- /dev/null +++ b/tests/unit/test_guard_levels.py @@ -0,0 +1,175 @@ +"""C3 unit tests: per-protocol guard levels via [tool.tripwire.guard]. + +Tests the parser `_resolve_guard_levels` (plural) which replaces +`_resolve_guard_level` (singular) and returns a `GuardLevels` frozen +dataclass with `default` and `overrides` fields. +""" + +from __future__ import annotations + +import pytest + + +def test_scalar_form_parses() -> None: + """C3-T1: scalar `guard = "warn"` parses to GuardLevels(default="warn", overrides={}). + + ESCAPE: test_scalar_form_parses + CLAIM: The legacy scalar form is preserved; `_resolve_guard_levels` + returns the new GuardLevels dataclass with empty overrides. + PATH: _resolve_guard_levels -> isinstance(raw, str) branch -> return + GuardLevels(default="warn", overrides={}). + CHECK: Assertion equates the full result to a constructed GuardLevels + with default="warn" and overrides=an empty dict. + MUTATION: If the parser drops the str branch entirely, the call would + hit the dict branch (or the trailing TypeError) and raise. + If overrides is mistakenly populated, the equality check + fails. + ESCAPE: An implementation that swaps default for an unrelated literal + (e.g., always returns "error") would fail the equality check. + """ + from tripwire._config import GuardLevels, _resolve_guard_levels + + result = _resolve_guard_levels({"guard": "warn"}) + assert result == GuardLevels(default="warn", overrides={}) + + +def test_table_form_parses() -> None: + """C3-T2: nested table parses to GuardLevels with per-protocol overrides. + + ESCAPE: test_table_form_parses + CLAIM: A nested-table form `guard = {default = "warn", subprocess = + "error"}` returns GuardLevels(default="warn", + overrides={"subprocess": "error"}). + PATH: _resolve_guard_levels -> isinstance(raw, dict) branch -> loop + populates overrides for keys other than "default" -> return + GuardLevels. + CHECK: Full equality on the returned dataclass. + MUTATION: If the parser drops the dict loop, overrides would be + empty and equality fails. If the parser miscapitalizes the + value (e.g., omits .lower() on table values), "error" + comparisons would still pass but a "Strict" alias variant + of this test would fail (covered separately by case + + alias coverage in T3). + ESCAPE: An implementation that hardcodes the override key as a + specific name (e.g., always "subprocess") would coincidentally + pass this test but fail variants with different protocols; + we add a second protocol below to widen coverage. + """ + from tripwire._config import GuardLevels, _resolve_guard_levels + + result = _resolve_guard_levels( + {"guard": {"default": "warn", "subprocess": "error", "dns": "off"}} + ) + assert result == GuardLevels( + default="warn", + overrides={"subprocess": "error", "dns": "off"}, + ) + + +def test_unknown_protocol_value_rejected() -> None: + """C3-T3: invalid override value raises TripwireConfigError listing valid choices. + + ESCAPE: test_unknown_protocol_value_rejected + CLAIM: A nested-table override with an unknown level value raises + TripwireConfigError, and the message names the offending key + and lists the valid level set. + PATH: _resolve_guard_levels -> dict branch -> _normalize_level for + value -> not in _VALID_LEVELS -> raise. + CHECK: pytest.raises with match validates both the offending value + repr and the valid-values list. + MUTATION: If the validation branch is missing, no error is raised + and pytest.raises fails. If the message format changes to + drop the valid-values list, the match fails. + ESCAPE: A test that only checks for the exception type would miss a + regression where the error names the wrong key; the match + regex below pins both. + """ + from tripwire._config import _resolve_guard_levels + from tripwire._errors import TripwireConfigError + + # "Warn" lowercases to "warn" and is valid; pick a value that does + # NOT normalize to a valid level. Bare "louder" never maps. + with pytest.raises(TripwireConfigError, match=r"Invalid value 'louder' for"): + _resolve_guard_levels({"guard": {"default": "warn", "subprocess": "louder"}}) + + +def test_mixed_scalar_and_table_rejected_by_tomllib() -> None: + """C3 (design 836-838): both `guard = "..."` and `[tool.tripwire.guard]` table + is illegal; tomllib itself raises TOMLDecodeError because the key + conflicts with the table at the same dotted path. + + ESCAPE: test_mixed_scalar_and_table_rejected_by_tomllib + CLAIM: Mixing scalar and table forms at the same TOML path is + rejected by tomllib; tripwire's parser does not need extra + detection logic. + PATH: tomllib.loads on the conflicting source raises before any + tripwire code runs. + CHECK: pytest.raises confirms the TOMLDecodeError surfaces. + MUTATION: If tomllib silently accepted the conflicting forms, this + test would fail (proving the design's assumption that + tomllib is the gatekeeper is wrong). + ESCAPE: None: this test pins the platform contract that the design + relies on. If tomllib changes behavior, we know immediately. + """ + import tomllib + + source = ( + '[tool.tripwire]\n' + 'guard = "warn"\n' + '\n' + '[tool.tripwire.guard]\n' + 'default = "error"\n' + ) + with pytest.raises(tomllib.TOMLDecodeError): + tomllib.loads(source) + + +def test_off_disables_per_protocol() -> None: + """C3-T5: per-protocol `guard. = "off"` short-circuits dispatch + BEFORE the C2 unsafe-passthrough check, so an unsafe plugin neither + raises nor warns. + + ESCAPE: test_off_disables_per_protocol + CLAIM: When `_guard_levels` carries an override of "off" for a + specific plugin, get_verifier_or_raise raises GuardPassThrough + (not UnsafePassthroughError, not GuardedCallError, no warning). + PATH: get_verifier_or_raise -> Branch 3b -> overrides.get returns + "off" -> raise GuardPassThrough BEFORE the warn-unsafe + branch. + CHECK: pytest.raises(GuardPassThrough) catches and no warning is + emitted. + MUTATION: If the "off" check is placed BELOW the warn-unsafe branch, + an unsafe plugin would raise UnsafePassthroughError and + this test would fail. If "off" is missing entirely, the + error/warn path runs. + ESCAPE: A buggy implementation that raises GuardPassThrough but ALSO + emits a warning would be caught by the warning-count check. + """ + import warnings + + from tripwire._config import GuardLevels + from tripwire._context import ( + GuardPassThrough, + _guard_active, + _guard_levels, + get_verifier_or_raise, + ) + from tripwire._firewall_request import SubprocessFirewallRequest + + # SubprocessPlugin is passthrough_safe=False; would normally raise + # UnsafePassthroughError under guard="warn" or GuardedCallError under + # "error". With per-protocol "off", neither fires. + req = SubprocessFirewallRequest(command="true", binary="true") + levels_token = _guard_levels.set( + GuardLevels(default="error", overrides={"subprocess": "off"}) + ) + guard_token = _guard_active.set(True) + try: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with pytest.raises(GuardPassThrough): + get_verifier_or_raise("subprocess:run", firewall_request=req) + assert caught == [] + finally: + _guard_active.reset(guard_token) + _guard_levels.reset(levels_token) From a85ca105ddf17e2225a94c0aac4e243e8db422ea Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:37:16 -0500 Subject: [PATCH 05/33] Distinguish post-sandbox interactions from leaked ones Adds a module-level itertools.count() allocator for sandbox_id. SandboxContext.__init__ allocates one and adds it to a class-level _active_sandbox_ids set. _exit() removes it. Each Interaction records the active sandbox_id as a private _sandbox_id attribute (NOT in interaction.details, preserving the certainty contract). New PostSandboxInteractionError raised when an intercepted call fires outside any active sandbox AND the current execution context carries a sandbox_id from a since-exited sandbox. Distinct from the existing leaked-interaction case (call without ever having entered any sandbox). ContextVar token-save/reset pattern in _enter()/_exit() handles nested sandboxes correctly: an inner sandbox's exit restores the outer sandbox_id automatically. --- CHANGELOG.md | 2 + src/tripwire/__init__.py | 2 + src/tripwire/__init__.pyi | 1 + src/tripwire/_base_plugin.py | 8 + src/tripwire/_context.py | 46 +++ src/tripwire/_errors.py | 29 ++ src/tripwire/_timeline.py | 1 + src/tripwire/_verifier.py | 87 ++++-- .../test_post_sandbox_interaction.py | 285 ++++++++++++++++++ tests/unit/test_sandbox_id.py | 177 +++++++++++ 10 files changed, 618 insertions(+), 20 deletions(-) create mode 100644 tests/integration/test_post_sandbox_interaction.py create mode 100644 tests/unit/test_sandbox_id.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a99d441..2451471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `BasePlugin.passthrough_safe: ClassVar[bool] = False` declares whether a plugin's outside-sandbox passthrough path is genuinely a no-op or raises a clear error. Plugins with `passthrough_safe=False` cause `UnsafePassthroughError` when `guard="warn"` lets a call through. - `UnsafePassthroughError` exception (subclass of `TripwireError`). - `[tool.tripwire.guard]` nested table form: `default = "warn"; = "error" | "warn" | "off"` per protocol. Backwards-compatible with the scalar form `guard = "warn"`. +- `PostSandboxInteractionError` distinguishes "an asyncio Task / thread / future survived `with tripwire:` exit and fired afterward" from the existing leaked-interaction case ("call without ever entering a sandbox"). Async leak debugging is now surface-able. +- `SandboxContext` now stamps each interaction with a private `_sandbox_id` (monotonic counter; not in `interaction.details`, so the certainty contract is preserved). ### Fixed - `async_subprocess_plugin` type annotations corrected: the `cast(_AsyncFakeProcess, await _ORIGINAL_CREATE_SUBPROCESS_EXEC(...))` claim was a lie (the runtime returned a real `asyncio.subprocess.Process`). The cast is removed and the return-type annotation widened to `_AsyncFakeProcess | asyncio.subprocess.Process` for both `_fake_create_subprocess_exec` and `_fake_create_subprocess_shell`. Runtime behavior unchanged; static types now match reality. diff --git a/src/tripwire/__init__.py b/src/tripwire/__init__.py index ddb8e42..37db9c9 100644 --- a/src/tripwire/__init__.py +++ b/src/tripwire/__init__.py @@ -51,6 +51,7 @@ InvalidStateError, MissingAssertionFieldsError, NoActiveVerifierError, + PostSandboxInteractionError, SandboxNotActiveError, TripwireConfigError, TripwireError, @@ -284,6 +285,7 @@ "AutoAssertError", "InvalidStateError", "NoActiveVerifierError", + "PostSandboxInteractionError", "UnmockedInteractionError", "UnsafePassthroughError", "UnassertedInteractionsError", diff --git a/src/tripwire/__init__.pyi b/src/tripwire/__init__.pyi index 5574ba9..cc53381 100644 --- a/src/tripwire/__init__.pyi +++ b/src/tripwire/__init__.pyi @@ -26,6 +26,7 @@ from tripwire._errors import InteractionMismatchError as InteractionMismatchErro from tripwire._errors import InvalidStateError as InvalidStateError from tripwire._errors import MissingAssertionFieldsError as MissingAssertionFieldsError from tripwire._errors import NoActiveVerifierError as NoActiveVerifierError +from tripwire._errors import PostSandboxInteractionError as PostSandboxInteractionError from tripwire._errors import SandboxNotActiveError as SandboxNotActiveError from tripwire._errors import TripwireConfigError as TripwireConfigError from tripwire._errors import TripwireError as TripwireError diff --git a/src/tripwire/_base_plugin.py b/src/tripwire/_base_plugin.py index fc650a9..4c17f25 100644 --- a/src/tripwire/_base_plugin.py +++ b/src/tripwire/_base_plugin.py @@ -228,6 +228,14 @@ def record(self, interaction: "Interaction") -> None: Timeline.mark_asserted() can detect the auto-assert anti-pattern and raise AutoAssertError immediately. """ + # C4: stamp the sandbox_id from the current execution context + # BEFORE the append. This is a private attribute on the + # Interaction dataclass (NOT in interaction.details), so the + # certainty contract is preserved. + from tripwire._verifier import _current_sandbox_id # noqa: PLC0415 + + interaction._sandbox_id = _current_sandbox_id.get() + token = _recording_in_progress.set(True) try: self.verifier._timeline.append(interaction) diff --git a/src/tripwire/_context.py b/src/tripwire/_context.py index f4b5578..1b7744d 100644 --- a/src/tripwire/_context.py +++ b/src/tripwire/_context.py @@ -63,6 +63,32 @@ def get_active_verifier() -> StrictVerifier | None: return _active_verifier.get() +def _detect_post_sandbox() -> int | None: + """Return the sandbox_id of a since-exited sandbox if the current + execution context still carries it; otherwise None. + + In normal control flow, Branch 1 of `get_verifier_or_raise` returns + the verifier when a sandbox is active in the current context. This + helper triggers only when Branch 1 fell through (no active verifier) + but the ContextVar still carries a sandbox_id from a since-exited + sandbox. This is the case Proposal 4 catches: an asyncio task / + thread / future survived the `with tripwire:` exit. + """ + from tripwire._verifier import ( # noqa: PLC0415 + SandboxContext, + _current_sandbox_id, + ) + + sid = _current_sandbox_id.get() + if sid is None: + return None + if sid in SandboxContext._active_sandbox_ids: + # Sandbox is still active in the process; Branch 1 should have + # caught this. Defensive: do not fire post-sandbox here. + return None + return sid + + def get_verifier_or_raise( source_id: str, firewall_request: FirewallRequest | None = None, @@ -85,6 +111,26 @@ def get_verifier_or_raise( """ plugin_name = source_id.split(":")[0] + # === Branch 2: post-sandbox detection (C4) === + # MUST run BEFORE Branch 1: an asyncio task / thread captures the + # parent's ContextVars at creation time, including `_active_verifier`. + # After the parent's `with tripwire:` exits, `_active_verifier.reset()` + # in the parent does NOT propagate into the task's snapshot, so the + # task would otherwise hit Branch 1 with a stale verifier reference. + # `_active_sandbox_ids` is a process-wide ClassVar set, which the + # parent's `_exit()` discards in real time. Detecting a captured + # sandbox_id that is NOT in the active set is the authoritative test + # for "the sandbox this task came from has exited." + closed_sandbox_id = _detect_post_sandbox() + if closed_sandbox_id is not None: + from tripwire._errors import PostSandboxInteractionError # noqa: PLC0415 + + raise PostSandboxInteractionError( + source_id=source_id, + plugin_name=plugin_name, + sandbox_id=closed_sandbox_id, + ) + # === Branch 1: sandbox active === verifier = _active_verifier.get() if verifier is not None: diff --git a/src/tripwire/_errors.py b/src/tripwire/_errors.py index 69141a8..ccd1f33 100644 --- a/src/tripwire/_errors.py +++ b/src/tripwire/_errors.py @@ -471,6 +471,35 @@ def _build_message(self) -> str: ) +class PostSandboxInteractionError(TripwireError): + """Raised when an intercepted call fires from a thread / task / future + that survived the `with tripwire:` exit. Distinct from the leaked- + interaction case (call without ever having entered any sandbox).""" + + def __init__(self, source_id: str, plugin_name: str, sandbox_id: int) -> None: + self.source_id = source_id + self.plugin_name = plugin_name + self.sandbox_id = sandbox_id + super().__init__(self._build_message()) + + def _build_message(self) -> str: + return ( + f"PostSandboxInteractionError: {self.source_id!r} fired from a " + f"context that survived the exit of sandbox #{self.sandbox_id}.\n" + f"\n" + f"This usually means an asyncio Task, thread, or future was " + f"scheduled inside `with tripwire:` and is still running after " + f"the block exited.\n" + f"\n" + f"Fix one of:\n" + f" - Await or cancel all pending tasks before exiting the sandbox.\n" + f" - Use `asyncio.gather(...)` inside the sandbox to ensure " + f"completion.\n" + f" - If the late call is intentional, wrap it in its own " + f"`with tripwire:` block.\n" + ) + + class GuardedCallWarning(UserWarning): """Emitted when guard mode is set to 'warn' and an I/O call fires outside a sandbox without allow() permission. diff --git a/src/tripwire/_timeline.py b/src/tripwire/_timeline.py index a8f3045..a2e473a 100644 --- a/src/tripwire/_timeline.py +++ b/src/tripwire/_timeline.py @@ -22,6 +22,7 @@ class Interaction: plugin: "BasePlugin" _asserted: bool = field(default=False, init=False, repr=False) enforce: bool = field(default=True, init=False, repr=False) + _sandbox_id: int | None = field(default=None, init=False, repr=False) class Timeline: diff --git a/src/tripwire/_verifier.py b/src/tripwire/_verifier.py index 3018415..ac4dd14 100644 --- a/src/tripwire/_verifier.py +++ b/src/tripwire/_verifier.py @@ -1,11 +1,13 @@ # src/tripwire/_verifier.py """StrictVerifier, SandboxContext, and InAnyOrderContext.""" +import contextvars import difflib +import itertools import warnings from importlib.metadata import entry_points from types import TracebackType -from typing import TYPE_CHECKING, Any, Protocol +from typing import TYPE_CHECKING, Any, ClassVar, Protocol from tripwire._compat import BaseExceptionGroup from tripwire._config import load_tripwire_config @@ -24,12 +26,22 @@ _MISSING = object() if TYPE_CHECKING: - import contextvars - from tripwire._base_plugin import BasePlugin from tripwire._mock_plugin import ImportSiteMock, MockPlugin +# Module-level sandbox_id allocator (C4). itertools.count() is thread-safe +# per CPython (single C-level increment); never reuses values. +_sandbox_id_counter: "itertools.count[int]" = itertools.count(start=1) + +# Tracks the active sandbox_id for the current execution context. Tasks +# created via asyncio.create_task capture this value at creation time, +# enabling post-sandbox detection (C4). +_current_sandbox_id: contextvars.ContextVar[int | None] = contextvars.ContextVar( + "tripwire_current_sandbox_id", default=None, +) + + class _HasSourceId(Protocol): """Structural type for objects that can be used as interaction sources.""" @@ -438,12 +450,27 @@ def _format_unused_mocks_error(self, unused: list[tuple["BasePlugin", Any]]) -> class SandboxContext: """Activates all plugins and mocks. Supports both sync (with) and async (async with).""" + # Process-wide set of currently active sandbox_ids. add() / discard() + # of distinct keys are independent under the GIL; no lock required. + _active_sandbox_ids: ClassVar[set[int]] = set() + def __init__(self, verifier: StrictVerifier) -> None: self._verifier = verifier self._token: contextvars.Token[Any] | None = None self._activated_mocks: list[Any] = [] # list[_BaseMock] + # C4: sandbox_id allocated at _enter(); ContextVar token stashed so + # _exit() (and the _enter() error path) can reset cleanly. + self.sandbox_id: int | None = None + self._sandbox_id_token: contextvars.Token[int | None] | None = None def _enter(self) -> StrictVerifier: + # Allocate the sandbox_id BEFORE existing activation so the stamp + # site in BasePlugin.record() sees a consistent ContextVar value + # for any interaction recorded during plugin/mock activation. + self.sandbox_id = next(_sandbox_id_counter) + SandboxContext._active_sandbox_ids.add(self.sandbox_id) + self._sandbox_id_token = _current_sandbox_id.set(self.sandbox_id) + self._token = _active_verifier.set(self._verifier) activated_so_far: list[BasePlugin] = [] errors: list[BaseException] = [] @@ -488,6 +515,14 @@ def _enter(self) -> StrictVerifier: errors.append(cleanup_e) if self._token is not None: _active_verifier.reset(self._token) + # C4: reset sandbox_id ContextVar and discard the id BEFORE + # raising the error group so a failed _enter() leaves no trace + # in `_active_sandbox_ids`. + if self._sandbox_id_token is not None: + _current_sandbox_id.reset(self._sandbox_id_token) + self._sandbox_id_token = None + if self.sandbox_id is not None: + SandboxContext._active_sandbox_ids.discard(self.sandbox_id) raise BaseExceptionGroup("tripwire sandbox activation failed", errors) return self._verifier @@ -495,24 +530,36 @@ def _enter(self) -> StrictVerifier: def _exit(self) -> None: errors: list[BaseException] = [] - # Deactivate mocks in reverse order FIRST (before plugins) - for mock_obj in reversed(self._activated_mocks): - try: - mock_obj._deactivate() - except Exception as e: - errors.append(e) - self._activated_mocks.clear() + try: + # Deactivate mocks in reverse order FIRST (before plugins) + for mock_obj in reversed(self._activated_mocks): + try: + mock_obj._deactivate() + except Exception as e: + errors.append(e) + self._activated_mocks.clear() - # Then deactivate plugins (existing behavior) - for plugin in reversed(self._verifier._plugins): - try: - plugin.deactivate() - except Exception as e: - errors.append(e) - if self._token is not None: - _active_verifier.reset(self._token) - if errors: - raise BaseExceptionGroup("tripwire sandbox deactivation failed", errors) + # Then deactivate plugins (existing behavior) + for plugin in reversed(self._verifier._plugins): + try: + plugin.deactivate() + except Exception as e: + errors.append(e) + if self._token is not None: + _active_verifier.reset(self._token) + if errors: + raise BaseExceptionGroup("tripwire sandbox deactivation failed", errors) + finally: + # C4: ContextVar reset and id discard ALWAYS run, even if + # deactivation raised. This guarantees the post-sandbox + # detection works correctly: `_current_sandbox_id` returns to + # its parent value (or None) and `_active_sandbox_ids` no + # longer contains this sandbox. + if self._sandbox_id_token is not None: + _current_sandbox_id.reset(self._sandbox_id_token) + self._sandbox_id_token = None + if self.sandbox_id is not None: + SandboxContext._active_sandbox_ids.discard(self.sandbox_id) def __enter__(self) -> StrictVerifier: return self._enter() diff --git a/tests/integration/test_post_sandbox_interaction.py b/tests/integration/test_post_sandbox_interaction.py new file mode 100644 index 0000000..ab3d271 --- /dev/null +++ b/tests/integration/test_post_sandbox_interaction.py @@ -0,0 +1,285 @@ +"""C4 integration tests: PostSandboxInteractionError dispatch. + +These tests verify the new dispatch branch in `get_verifier_or_raise`: +when the current execution context still carries a `_current_sandbox_id` +ContextVar value but that sandbox has already exited, the call raises +`PostSandboxInteractionError` (distinct from `SandboxNotActiveError` and +from `GuardedCallError`). + +Tests use the public dispatch entry point directly to avoid coupling to +any specific plugin's interceptor. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from tripwire._context import ( + _guard_active, + _guard_patches_installed, + get_verifier_or_raise, +) +from tripwire._errors import ( + PostSandboxInteractionError, + SandboxNotActiveError, +) +from tripwire._verifier import StrictVerifier + +pytestmark = pytest.mark.integration + + +@pytest.fixture(autouse=True) +def _suppress_direct_warning() -> None: + StrictVerifier._suppress_direct_warning = True + try: + yield + finally: + StrictVerifier._suppress_direct_warning = False + + +@pytest.fixture(autouse=True) +def _disable_guard() -> None: + """These tests verify the post-sandbox dispatch branch in isolation + from guard mode. Disable guard so a fall-through hits Branch 5 + (SandboxNotActiveError) rather than Branch 3 (GuardedCallError).""" + g_token = _guard_active.set(False) + p_token = _guard_patches_installed.set(False) + try: + yield + finally: + _guard_patches_installed.reset(p_token) + _guard_active.reset(g_token) + + +# --------------------------------------------------------------------------- +# C4-T4: asyncio task that survives sandbox exit raises PostSandboxInteractionError +# --------------------------------------------------------------------------- + + +def test_async_task_after_exit_raises_post_sandbox() -> None: + """A task scheduled inside `with v.sandbox():` whose body fires an + intercepted dispatch AFTER the sandbox has exited raises + `PostSandboxInteractionError` (NOT `SandboxNotActiveError`, + NOT `GuardedCallError`).""" + + async def main() -> BaseException | None: + v = StrictVerifier() + captured: list[BaseException] = [] + ready = asyncio.Event() + gate = asyncio.Event() + + async def late_caller() -> None: + ready.set() + await gate.wait() + try: + get_verifier_or_raise(source_id="test:late_call") + except BaseException as exc: # noqa: BLE001 + captured.append(exc) + + async with v.sandbox(): + task = asyncio.create_task(late_caller()) + # Wait until the task is parked at gate.wait() so we know + # ContextVar capture happened with the sandbox active. + await ready.wait() + + # Sandbox is now exited. Release the task; its dispatch call + # MUST raise PostSandboxInteractionError. + gate.set() + await task + + return captured[0] if captured else None + + err = asyncio.run(main()) + assert isinstance(err, PostSandboxInteractionError), ( + f"Expected PostSandboxInteractionError, got {type(err).__name__}: {err!r}" + ) + assert err.source_id == "test:late_call" + assert isinstance(err.sandbox_id, int) + + +# --------------------------------------------------------------------------- +# C4-T5: dispatch outside of any sandbox still raises the leaked-interaction error +# --------------------------------------------------------------------------- + + +def test_call_without_any_sandbox_raises_leaked() -> None: + """A direct dispatch outside any sandbox (no ContextVar value carried) + raises `SandboxNotActiveError`, NOT `PostSandboxInteractionError`. + This guards against the new branch firing when no sandbox was ever + entered in the current context.""" + with pytest.raises(SandboxNotActiveError) as exc_info: + get_verifier_or_raise(source_id="test:never_sandboxed") + assert exc_info.value.source_id == "test:never_sandboxed" + + +# --------------------------------------------------------------------------- +# C4-T6: PostSandboxInteractionError message includes sandbox_id and pedagogy +# --------------------------------------------------------------------------- + + +def test_post_sandbox_message_includes_sandbox_id() -> None: + """The error message must include the offending sandbox_id and a hint + about awaiting/cancelling tasks before sandbox exit.""" + + async def main() -> BaseException: + v = StrictVerifier() + captured: list[BaseException] = [] + ready = asyncio.Event() + gate = asyncio.Event() + + async def late_caller() -> None: + ready.set() + await gate.wait() + try: + get_verifier_or_raise(source_id="test:late_msg") + except BaseException as exc: # noqa: BLE001 + captured.append(exc) + + async with v.sandbox(): + task = asyncio.create_task(late_caller()) + await ready.wait() + + gate.set() + await task + return captured[0] + + err = asyncio.run(main()) + assert isinstance(err, PostSandboxInteractionError) + msg = str(err) + assert f"sandbox #{err.sandbox_id}" in msg + assert "test:late_msg" in msg + # Pedagogical content: must hint at awaiting/cancelling tasks before + # sandbox exit, and at wrapping intentional late calls in their own + # `with tripwire:` block. + assert "Await or cancel all pending tasks before exiting the sandbox." in msg + assert "asyncio.gather" in msg + assert "with tripwire:" in msg + + +# --------------------------------------------------------------------------- +# C4-T7: nested sandbox — task spawned inside inner survives both exits; +# carries OUTER sandbox_id after inner exits but is detected as post-sandbox +# only after BOTH have exited. Per design Section 5: a task spawned inside +# the inner sandbox captures the inner id at creation time; after both +# exits the dispatch reports the inner id as the offending sandbox_id. +# --------------------------------------------------------------------------- + + +def test_nested_sandbox_task_survives_inner_exit() -> None: + """Nested-sandbox handling. Outer enters, inner enters and spawns + `asyncio.create_task(...)` whose body sleeps past the INNER exit but + fires its dispatch only AFTER the OUTER also exits. + + The task captures `_current_sandbox_id` at create_task time (the + INNER id). When it fires after both exits, dispatch sees that + captured inner id is no longer in `SandboxContext._active_sandbox_ids` + and raises `PostSandboxInteractionError` carrying the INNER id. + + This verifies the ContextVar token-save/reset pattern in + _enter()/_exit(): if the inner _exit() failed to reset the ContextVar + via the saved token, the outer sandbox id would not be restored + correctly inside the outer scope, breaking nested correctness for any + other code reading `_current_sandbox_id` between the inner exit and + the outer exit. + """ + from tripwire._verifier import _current_sandbox_id + + async def main() -> tuple[BaseException, int, int, int | None]: + v = StrictVerifier() + captured: list[BaseException] = [] + ready = asyncio.Event() + gate = asyncio.Event() + inner_id_holder: list[int] = [] + outer_id_holder: list[int] = [] + outer_after_inner_holder: list[int | None] = [] + + async def late_caller() -> None: + ready.set() + await gate.wait() + try: + get_verifier_or_raise(source_id="test:nested_late") + except BaseException as exc: # noqa: BLE001 + captured.append(exc) + + outer_ctx = v.sandbox() + async with outer_ctx: + assert outer_ctx.sandbox_id is not None + outer_id_holder.append(outer_ctx.sandbox_id) + inner_ctx = v.sandbox() + async with inner_ctx: + assert inner_ctx.sandbox_id is not None + inner_id_holder.append(inner_ctx.sandbox_id) + task = asyncio.create_task(late_caller()) + await ready.wait() + # Inner exited. The ContextVar token-reset must restore the + # OUTER sandbox_id here (not None, not the inner id). + outer_after_inner_holder.append(_current_sandbox_id.get()) + + # Both sandboxes have exited. Release the task. + gate.set() + await task + return ( + captured[0], + inner_id_holder[0], + outer_id_holder[0], + outer_after_inner_holder[0], + ) + + err, inner_id, outer_id, outer_after_inner = asyncio.run(main()) + + # Token-reset correctness: between inner exit and outer exit, the + # ContextVar must hold the outer sandbox_id. + assert outer_after_inner == outer_id + + # The task captured the INNER id at create_task time. After both + # exits the dispatch reports it as the offending sandbox. + assert isinstance(err, PostSandboxInteractionError) + assert err.source_id == "test:nested_late" + assert err.sandbox_id == inner_id + assert err.sandbox_id != outer_id + + +# --------------------------------------------------------------------------- +# Sanity: GuardPassThrough is NOT raised for post-sandbox path (the new +# branch must run BEFORE Branch 4 / patches-installed fallthrough). +# --------------------------------------------------------------------------- + + +def test_post_sandbox_branch_runs_above_guard_branches() -> None: + """If guard patches are installed (Branch 4) but the current context + carries a since-exited sandbox_id, the post-sandbox branch must fire + first and raise PostSandboxInteractionError, NOT raise GuardPassThrough.""" + + async def main() -> BaseException | None: + v = StrictVerifier() + captured: list[BaseException] = [] + ready = asyncio.Event() + gate = asyncio.Event() + + async def late_caller() -> None: + # Patches "installed" only inside the task scope to mimic guard mode. + patches_token = _guard_patches_installed.set(True) + try: + ready.set() + await gate.wait() + try: + get_verifier_or_raise(source_id="test:late_above_guard") + except BaseException as exc: # noqa: BLE001 + captured.append(exc) + finally: + _guard_patches_installed.reset(patches_token) + + async with v.sandbox(): + task = asyncio.create_task(late_caller()) + await ready.wait() + + gate.set() + await task + return captured[0] if captured else None + + err = asyncio.run(main()) + assert isinstance(err, PostSandboxInteractionError), ( + f"Expected PostSandboxInteractionError, got {type(err).__name__}: {err!r}" + ) diff --git a/tests/unit/test_sandbox_id.py b/tests/unit/test_sandbox_id.py new file mode 100644 index 0000000..5bb59d3 --- /dev/null +++ b/tests/unit/test_sandbox_id.py @@ -0,0 +1,177 @@ +"""C4 unit tests: sandbox_id allocation, active-set tracking, and the +private `_sandbox_id` attribute on Interaction. + +These tests guard the certainty contract: `_sandbox_id` is a PRIVATE +dataclass field on Interaction (declared with `field(default=None, +init=False, repr=False)`) and MUST NOT appear inside `interaction.details`. +""" + +from __future__ import annotations + +import dataclasses +import warnings + +import pytest + +from tripwire._timeline import Interaction +from tripwire._verifier import SandboxContext, StrictVerifier + + +@pytest.fixture(autouse=True) +def _suppress_direct_instantiation_warning() -> None: + """Tests in this module construct StrictVerifier directly. Suppress the + pytest-fixture-wanted warning the verifier emits in that case.""" + StrictVerifier._suppress_direct_warning = True + try: + yield + finally: + StrictVerifier._suppress_direct_warning = False + + +# --------------------------------------------------------------------------- +# C4-T1: counter is monotonic and unique per sandbox +# --------------------------------------------------------------------------- + + +def test_monotonic_unique_per_sandbox() -> None: + """Two consecutive `with v.sandbox():` blocks have distinct, strictly + increasing `sandbox_id` values.""" + from tripwire._verifier import _current_sandbox_id + + v = StrictVerifier() + + ctx_a = v.sandbox() + with ctx_a: + id_a = _current_sandbox_id.get() + ctx_b = v.sandbox() + with ctx_b: + id_b = _current_sandbox_id.get() + + assert isinstance(id_a, int) + assert isinstance(id_b, int) + assert id_a < id_b + assert ctx_a.sandbox_id == id_a + assert ctx_b.sandbox_id == id_b + + +# --------------------------------------------------------------------------- +# C4-T2: certainty contract — _sandbox_id never appears in interaction.details +# --------------------------------------------------------------------------- + + +def test_sandbox_id_not_in_details() -> None: + """Inside a sandbox, an interaction recorded by a plugin has + `_sandbox_id` set to a non-None int but `interaction.details` keys do + NOT contain `_sandbox_id` or `sandbox_id`. The Interaction dataclass + field for `_sandbox_id` is declared with `init=False, repr=False`, + matching the existing private-attribute convention shared by + `_asserted` and `enforce`. This preserves the certainty contract per + CLAUDE.md. + """ + from tripwire._base_plugin import BasePlugin + + # 1. Verify the dataclass field declaration directly. + fields_by_name = {f.name: f for f in dataclasses.fields(Interaction)} + assert "_sandbox_id" in fields_by_name + sid_field = fields_by_name["_sandbox_id"] + assert sid_field.init is False + assert sid_field.repr is False + assert sid_field.default is None + + # 2. Construct an Interaction WITHOUT passing _sandbox_id. The dataclass + # must accept this (init=False keeps the constructor signature clean + # and backwards-compatible). + class _NullPlugin(BasePlugin): + passthrough_safe = True + + def matches(self, interaction, expected): # type: ignore[no-untyped-def] + return True + + def format_interaction(self, interaction): # type: ignore[no-untyped-def] + return "[Null]" + + def format_mock_hint(self, interaction): # type: ignore[no-untyped-def] + return "" + + def format_unmocked_hint(self, source_id, args, kwargs): # type: ignore[no-untyped-def] + return "" + + def format_assert_hint(self, interaction): # type: ignore[no-untyped-def] + return "" + + def get_unused_mocks(self): # type: ignore[no-untyped-def] + return [] + + def format_unused_mock_hint(self, mock_config): # type: ignore[no-untyped-def] + return "" + + v = StrictVerifier() + plugin = _NullPlugin(v) + + # Build an interaction with a real-world details dict. + interaction = Interaction( + source_id="test:thing", + sequence=0, + details={"foo": "bar", "n": 1}, + plugin=plugin, + ) + + # Drive plugin.record() inside an active sandbox so the stamp logic runs. + # _NullPlugin intentionally does not override install_patches; suppress + # the no-op warning from BasePlugin.activate so the test output stays + # clean. + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + with v.sandbox(): + plugin.record(interaction) + sid_observed = interaction._sandbox_id + + # The stamp must be a non-None int (we were inside a sandbox). + assert isinstance(sid_observed, int) + + # The certainty contract: details must NOT contain _sandbox_id or + # sandbox_id under any spelling. + assert "_sandbox_id" not in interaction.details + assert "sandbox_id" not in interaction.details + + # And the original details payload is untouched. + assert interaction.details == {"foo": "bar", "n": 1} + + +# --------------------------------------------------------------------------- +# C4-T3: active-set tracking (LIFO add/remove for nested sandboxes) +# --------------------------------------------------------------------------- + + +def test_active_set_tracking() -> None: + """SandboxContext._enter() adds the sandbox_id to + `SandboxContext._active_sandbox_ids`; `_exit()` removes it. Nested + sandboxes have both ids present, then both are removed in LIFO order.""" + v = StrictVerifier() + + # Snapshot the set so the test isolates from any leakage from earlier + # tests in the same process. + baseline = set(SandboxContext._active_sandbox_ids) + + outer_ctx = v.sandbox() + with outer_ctx: + outer_id = outer_ctx.sandbox_id + assert outer_id is not None + assert outer_id in SandboxContext._active_sandbox_ids + assert SandboxContext._active_sandbox_ids - baseline == {outer_id} + + inner_ctx = v.sandbox() + with inner_ctx: + inner_id = inner_ctx.sandbox_id + assert inner_id is not None + assert inner_id != outer_id + assert SandboxContext._active_sandbox_ids - baseline == { + outer_id, + inner_id, + } + + # Inner exited; only outer remains. + assert SandboxContext._active_sandbox_ids - baseline == {outer_id} + + # Outer exited; nothing extra remains. + assert set(SandboxContext._active_sandbox_ids) == baseline From 7cba2dbcb44458ed7b165543e801e654142757c5 Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:59:14 -0500 Subject: [PATCH 06/33] Pedagogical GuardedCallError messages with call site Reframes GuardedCallError messages for clarity: states "OUTSIDE any 'with tripwire:' block", names the plugin and method, includes the user call site (file:lineno), and lists the two fixes inline. Existing fix sections (@pytest.mark.allow, pyproject, sandbox-with-mock) retained. Captures the user call site via a frame-walking utility in src/tripwire/_frames.py. The walker skips frames whose __module__ equals "tripwire" or starts with "tripwire." and returns the first user frame. Falls back to "" when no user frame is present (e.g., spawned thread). Includes scripts/prototype_frame_walk.py used as the pre-commit validation gate against subprocess.run, socket.connect, httpx.get, aiohttp, and psycopg2.connect. --- CHANGELOG.md | 1 + scripts/prototype_frame_walk.py | 297 ++++++++++++++++++ src/tripwire/_context.py | 8 +- src/tripwire/_errors.py | 21 ++ src/tripwire/_frames.py | 44 +++ .../integration/test_pedagogical_messages.py | 127 ++++++++ tests/unit/test_frame_walk.py | 111 +++++++ tests/unit/test_pedagogical_messages.py | 58 ++++ 8 files changed, 666 insertions(+), 1 deletion(-) create mode 100644 scripts/prototype_frame_walk.py create mode 100644 src/tripwire/_frames.py create mode 100644 tests/integration/test_pedagogical_messages.py create mode 100644 tests/unit/test_frame_walk.py create mode 100644 tests/unit/test_pedagogical_messages.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2451471..12eb06e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Breaking:** Internal source-id sentinels restructured from underscore-flat (`bigfoot_subprocess_run`) to colon-namespaced `:` (e.g., `subprocess:run`, `httpx:get`, `socket:connect`). User-facing only via `GuardedCallError` messages and the `source_id` argument of plugin APIs. The `tripwire:` prefix is intentionally omitted because the namespace is implicit inside the tripwire package. - **Breaking:** Default `[tool.tripwire] guard` flipped from `"warn"` to `"error"`. New projects fail loud on unmocked I/O outside a sandbox. To preserve prior behavior during legacy migration, set `guard = "warn"` explicitly. - **Breaking:** Removed `BasePlugin.supports_guard` and the `is_guard_eligible()` registry helper. Replaced by `passthrough_safe`. The 6 plugins that had `supports_guard=False` (celery, crypto, file_io, jwt, logging, native) become `passthrough_safe=True` because their interceptors raise `SandboxNotActiveError` outside sandbox (which is safe). MockPlugin and StateMachinePlugin (passive recorders) also become `passthrough_safe=True`. The remaining real-IO plugins become `passthrough_safe=False`. +- `GuardedCallError` message reframed for clarity: states "OUTSIDE any `with tripwire:` block", names the plugin and method, includes the user call site (`file:lineno`), and lists the two fixes inline. Existing fix sections (`@pytest.mark.allow`, pyproject, sandbox-with-mock) retained. ### Added - `ConfigMigrationError` (subclass of `TripwireError`) raised when `[tool.bigfoot]` is present in pyproject.toml during config load. diff --git a/scripts/prototype_frame_walk.py b/scripts/prototype_frame_walk.py new file mode 100644 index 0000000..ba86eb9 --- /dev/null +++ b/scripts/prototype_frame_walk.py @@ -0,0 +1,297 @@ +"""Prototype validation script for the frame-walking approach (PRE-C5 GATE). + +This script validates that `walk_to_user_frame()` correctly identifies the +USER call site (the line in this script that issued the call) when invoked +from inside a wrapper around 5 representative call sites: + + 1. subprocess.run (C-extension stdlib) + 2. socket.socket().connect (C-extension stdlib) + 3. httpx.get (pure-Python wrapper; skip if not installed) + 4. aiohttp request (asyncio internals; skip if not installed) + 5. psycopg2.connect (C-extension binding; skip if not installed) + +For each call site, the wrapper installs itself by monkey-patching the target +function. From inside the wrapper, `walk_to_user_frame()` is invoked and the +captured (filename, lineno) is recorded. PASS = the captured frame is THIS +script's filename and the line number is the line where the call originated. +FAIL = the captured frame is tripwire-internal, a stdlib internal frame, an +asyncio frame, or any other unrelated framework frame. + +The walk_to_user_frame() implementation here is INLINE (not imported from +src/tripwire/) so that this script can be run before any production code +exists. It uses the algorithm specified in the design doc Section 7 +lines 846-870: skip frames where module_name == "tripwire" or starts with +"tripwire.", return the first non-tripwire frame. + +To simulate that this prototype's WRAPPER lives in tripwire-internal code, +we inject the wrapper into a synthetic module named "tripwire._proto_wrapper" +so that walk_to_user_frame's skip predicate has something to skip past. +Without this, the wrapper would be in __main__ (the script itself) and the +walk would trivially return the wrapper frame. +""" + +from __future__ import annotations + +import asyncio +import socket +import subprocess +import sys +import types +from types import FrameType + + +# --------------------------------------------------------------------------- +# Inline walk_to_user_frame() per design Section 7 lines 846-870. +# --------------------------------------------------------------------------- +def walk_to_user_frame() -> tuple[str, int, str] | None: + frame: FrameType | None = sys._getframe(1) # skip this function itself + while frame is not None: + module_name = frame.f_globals.get("__name__", "") + if module_name != "tripwire" and not module_name.startswith("tripwire."): + return (frame.f_code.co_filename, frame.f_lineno, frame.f_code.co_name) + frame = frame.f_back + return None + + +# --------------------------------------------------------------------------- +# Synthetic "tripwire._proto_wrapper" module: wrappers live here so that +# walk_to_user_frame() has tripwire-namespaced frames to skip past, mimicking +# the real plugin/proxy layout. +# --------------------------------------------------------------------------- +_wrapper_module = types.ModuleType("tripwire._proto_wrapper") +_wrapper_module.__name__ = "tripwire._proto_wrapper" +sys.modules["tripwire._proto_wrapper"] = _wrapper_module +# Also register a parent "tripwire" module so introspection is consistent. +if "tripwire" not in sys.modules: + _tripwire_pkg = types.ModuleType("tripwire") + _tripwire_pkg.__name__ = "tripwire" + sys.modules["tripwire"] = _tripwire_pkg + + +def _make_wrapper(label: str, original_callable: object) -> object: + """Construct a wrapper whose code object lives in tripwire._proto_wrapper.""" + src = ( + "def _wrapper(*args, **kwargs):\n" + " captured = walk_to_user_frame()\n" + " _captures[label] = captured\n" + " try:\n" + " return original_callable(*args, **kwargs)\n" + " except Exception as exc:\n" + " _exceptions[label] = exc\n" + " return None\n" + ) + code = compile(src, "", "exec") + namespace: dict = { + "__name__": "tripwire._proto_wrapper", + "walk_to_user_frame": walk_to_user_frame, + "original_callable": original_callable, + "_captures": _captures, + "_exceptions": _exceptions, + "label": label, + } + exec(code, namespace) + wrapper = namespace["_wrapper"] + # Force the function's globals to claim the tripwire module name so the + # skip predicate fires on it. + wrapper.__module__ = "tripwire._proto_wrapper" + return wrapper + + +_captures: dict[str, tuple[str, int, str] | None] = {} +_exceptions: dict[str, BaseException] = {} + + +# --------------------------------------------------------------------------- +# Async wrapper variant: same pattern but `async def` for aiohttp. +# --------------------------------------------------------------------------- +def _make_async_wrapper(label: str, original_coro_callable: object) -> object: + src = ( + "async def _wrapper(*args, **kwargs):\n" + " captured = walk_to_user_frame()\n" + " _captures[label] = captured\n" + " try:\n" + " return await original_coro_callable(*args, **kwargs)\n" + " except Exception as exc:\n" + " _exceptions[label] = exc\n" + " return None\n" + ) + code = compile(src, "", "exec") + namespace: dict = { + "__name__": "tripwire._proto_wrapper", + "walk_to_user_frame": walk_to_user_frame, + "original_coro_callable": original_coro_callable, + "_captures": _captures, + "_exceptions": _exceptions, + "label": label, + } + exec(code, namespace) + wrapper = namespace["_wrapper"] + wrapper.__module__ = "tripwire._proto_wrapper" + return wrapper + + +# --------------------------------------------------------------------------- +# Exercise call sites. Each call site records the line number on which the +# call is issued so we can compare against the captured frame. +# --------------------------------------------------------------------------- +THIS_FILE = __file__ + + +def exercise_subprocess_run() -> int: + """Returns the line number on which the patched subprocess.run is called.""" + original = subprocess.run + subprocess.run = _make_wrapper("subprocess.run", original) + try: + # The next non-blank line is the user call site. + call_line = sys._getframe().f_lineno + 1 + subprocess.run(["/bin/true"]) + finally: + subprocess.run = original + return call_line + + +def exercise_socket_connect() -> int: + """Returns the line number on which the patched socket.connect is called. + + socket.socket instances do not allow attribute assignment, so we patch + socket.socket.connect at the class level. The wrapper accepts `self` as + the first positional arg and forwards it to the original unbound method. + """ + original = socket.socket.connect + socket.socket.connect = _make_wrapper("socket.connect", original) + s = socket.socket() + try: + try: + call_line = sys._getframe().f_lineno + 1 + s.connect(("127.0.0.1", 9999)) + except ConnectionRefusedError: + pass + except OSError: + pass + finally: + socket.socket.connect = original + try: + s.close() + except Exception: + pass + return call_line + + +def exercise_httpx_get() -> int | None: + try: + import httpx + except ImportError: + return None + original = httpx.get + httpx.get = _make_wrapper("httpx.get", original) + try: + try: + call_line = sys._getframe().f_lineno + 1 + httpx.get("https://127.0.0.1:1/") # will fail fast, that's fine + except Exception: + pass + finally: + httpx.get = original + return call_line + + +def exercise_aiohttp_get() -> int | None: + try: + import aiohttp + except ImportError: + return None + + # We patch ClientSession._request which is the funnel for all HTTP verbs. + original = aiohttp.ClientSession._request + aiohttp.ClientSession._request = _make_async_wrapper( + "aiohttp.request", original + ) + + async def _runner() -> int: + async with aiohttp.ClientSession() as session: + try: + call_line = sys._getframe().f_lineno + 1 + await session.get("http://127.0.0.1:1/") + except Exception: + pass + return call_line + + try: + return asyncio.run(_runner()) + finally: + aiohttp.ClientSession._request = original + + +def exercise_psycopg2_connect() -> int | None: + try: + import psycopg2 + except ImportError: + return None + original = psycopg2.connect + psycopg2.connect = _make_wrapper("psycopg2.connect", original) + try: + try: + call_line = sys._getframe().f_lineno + 1 + psycopg2.connect("host=127.0.0.1 port=1 dbname=nope user=nope connect_timeout=1") + except psycopg2.OperationalError: + pass + except Exception: + pass + finally: + psycopg2.connect = original + return call_line + + +# --------------------------------------------------------------------------- +# Main: run every site, compare captured frame to expected (file, line). +# --------------------------------------------------------------------------- +def main() -> int: + sites: list[tuple[str, int | None]] = [] + + sites.append(("subprocess.run", exercise_subprocess_run())) + sites.append(("socket.connect", exercise_socket_connect())) + sites.append(("httpx.get", exercise_httpx_get())) + sites.append(("aiohttp.request", exercise_aiohttp_get())) + sites.append(("psycopg2.connect", exercise_psycopg2_connect())) + + print("=" * 72) + print("Prototype Frame-Walk Validation Report") + print("=" * 72) + print(f"Script file: {THIS_FILE}") + print() + + overall_pass = True + for label, expected_line in sites: + if expected_line is None: + print(f"[SKIP] {label}: dependency not installed") + continue + + captured = _captures.get(label) + if captured is None: + print(f"[FAIL] {label}: walk_to_user_frame() returned None") + overall_pass = False + continue + + cap_file, cap_line, cap_func = captured + same_file = cap_file == THIS_FILE + same_line = cap_line == expected_line + status = "PASS" if (same_file and same_line) else "FAIL" + if status == "FAIL": + overall_pass = False + + print(f"[{status}] {label}") + print(f" expected: {THIS_FILE}:{expected_line}") + print(f" captured: {cap_file}:{cap_line} (in {cap_func})") + if label in _exceptions: + exc = _exceptions[label] + print(f" (call raised: {type(exc).__name__})") + + print() + print("=" * 72) + print(f"OVERALL: {'PASS' if overall_pass else 'FAIL'}") + print("=" * 72) + return 0 if overall_pass else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/tripwire/_context.py b/src/tripwire/_context.py index 1b7744d..14cf4f9 100644 --- a/src/tripwire/_context.py +++ b/src/tripwire/_context.py @@ -197,13 +197,16 @@ def get_verifier_or_raise( ) raise GuardPassThrough() - # === Branch 3b-error === + # === Branch 3b-error (C5: enrich with user call site) === from tripwire._errors import GuardedCallError # noqa: PLC0415 + from tripwire._frames import walk_to_user_frame # noqa: PLC0415 + user_frame = walk_to_user_frame() raise GuardedCallError( source_id=source_id, plugin_name=plugin_name, firewall_request=firewall_request, + user_frame=user_frame, ) # === Branch 3c: guard active, no firewall_request === @@ -213,11 +216,14 @@ def get_verifier_or_raise( # error paths can run as before. if plugin_is_unsafe_passthrough: from tripwire._errors import GuardedCallError # noqa: PLC0415 + from tripwire._frames import walk_to_user_frame # noqa: PLC0415 + user_frame = walk_to_user_frame() raise GuardedCallError( source_id=source_id, plugin_name=plugin_name, firewall_request=None, + user_frame=user_frame, ) # === Branch 4: guard not active but patches installed === diff --git a/src/tripwire/_errors.py b/src/tripwire/_errors.py index ccd1f33..56d7af8 100644 --- a/src/tripwire/_errors.py +++ b/src/tripwire/_errors.py @@ -262,17 +262,38 @@ def __init__( source_id: str, plugin_name: str, firewall_request: FirewallRequest | None = None, + user_frame: tuple[str, int, str] | None = None, ) -> None: self.source_id = source_id self.plugin_name = plugin_name self.firewall_request = firewall_request + self.user_frame = user_frame super().__init__(self._build_message()) def _build_message(self) -> str: req = self.firewall_request + # Method-being-called: text after the first ":" in the source_id + # (e.g., "subprocess:run" -> "run", "asyncio:subprocess:spawn" -> + # "subprocess:spawn"). Falls back to "" if the + # source_id is malformed (no colon). + if ":" in self.source_id: + method = self.source_id.split(":", 1)[1] + else: + method = "" + # User call site: rendered as "file:lineno", or "" + # when the frame walker found no user frame (e.g., spawned thread). + if self.user_frame is None: + site = "" + else: + site = f"{self.user_frame[0]}:{self.user_frame[1]}" lines = [ f"GuardedCallError: {self.source_id!r} blocked by tripwire firewall.", "", + ( + f' Called {self.plugin_name}.{method} at {site} ' + f'OUTSIDE any "with tripwire:" block.' + ), + "", ] # Section 1: What was attempted diff --git a/src/tripwire/_frames.py b/src/tripwire/_frames.py new file mode 100644 index 0000000..8e9ca4d --- /dev/null +++ b/src/tripwire/_frames.py @@ -0,0 +1,44 @@ +"""Frame-walking utility for pedagogical error messages (Proposal 5 / C5). + +Captures the user call site (file, lineno, function name) by walking the +Python call stack from this function's caller upward, skipping frames whose +``__module__`` is exactly ``"tripwire"`` or starts with ``"tripwire."``. The +former clause catches proxy functions defined directly in +``src/tripwire/__init__.py`` (whose ``__name__`` is exactly ``"tripwire"``); +the latter catches all submodules. Both clauses are required: dropping the +first would leak top-level proxy frames as the reported user call site. + +Returns ``None`` when no user frame is found (e.g., called from a thread +spawned without any user frame in the stack). Callers must render this case +as ``""`` per Section 7 of the design doc. +""" + +from __future__ import annotations + +import sys +from types import FrameType + + +def walk_to_user_frame() -> tuple[str, int, str] | None: + """Walk the call stack from this function's caller upward, skipping + frames whose ``__module__`` is ``"tripwire"`` or starts with + ``"tripwire."``, and return ``(filename, lineno, function_name)`` of the + first user frame found. + + The skip predicate is + ``module_name == "tripwire" or module_name.startswith("tripwire.")``, + not just ``module_name.startswith("tripwire.")``: the former catches + proxy functions defined directly in ``src/tripwire/__init__.py`` (whose + ``__name__`` is exactly ``"tripwire"``); the latter alone would leak + them. C5-T9 locks this in. + + Returns ``None`` if no user frame is found (e.g., called from a thread + spawned without any user frame in the stack). + """ + frame: FrameType | None = sys._getframe(1) # skip this function itself + while frame is not None: + module_name = frame.f_globals.get("__name__", "") + if module_name != "tripwire" and not module_name.startswith("tripwire."): + return (frame.f_code.co_filename, frame.f_lineno, frame.f_code.co_name) + frame = frame.f_back + return None diff --git a/tests/integration/test_pedagogical_messages.py b/tests/integration/test_pedagogical_messages.py new file mode 100644 index 0000000..4c75f38 --- /dev/null +++ b/tests/integration/test_pedagogical_messages.py @@ -0,0 +1,127 @@ +"""C5-T6, C5-T7, C5-T8: live GuardedCallError message includes the user's +file:line for outside-sandbox calls hitting C-extension stdlib + pure-Python +wrappers. Asserts the frame walk identifies THIS test file and the line that +issued the call. + +Each test uses guard='error' (the post-C1 default) and triggers the dispatch +path that constructs GuardedCallError via walk_to_user_frame(). +""" + +from __future__ import annotations + +import sys + +import pytest + +from tripwire._config import GuardLevels +from tripwire._context import _guard_active, _guard_levels, get_verifier_or_raise +from tripwire._errors import GuardedCallError +from tripwire._firewall import FirewallStack, _firewall_stack +from tripwire._firewall_request import ( + NetworkFirewallRequest, + SubprocessFirewallRequest, +) + +pytestmark = pytest.mark.integration + + +def _enable_guard_error() -> tuple[object, object, object]: + """Activate guard with default level 'error' for the duration of the test. + + Resets the firewall stack to empty so the project-level + ``[tool.tripwire.firewall] allow = ["dns:*", "socket:*"]`` rules do not + pre-allow these test calls (the empty stack returns DENY by default). + + Returns the ContextVar tokens so the caller can reset them. We avoid + going through the pytest plugin path here because we want a direct, + deterministic dispatch into ``get_verifier_or_raise`` regardless of + project-level configuration. + """ + levels = GuardLevels(default="error", overrides={}) + tok_levels = _guard_levels.set(levels) + tok_active = _guard_active.set(True) + tok_stack = _firewall_stack.set(FirewallStack()) + return tok_levels, tok_active, tok_stack + + +def _disable_guard(tokens: tuple[object, object, object]) -> None: + tok_levels, tok_active, tok_stack = tokens + _firewall_stack.reset(tok_stack) # type: ignore[arg-type] + _guard_active.reset(tok_active) # type: ignore[arg-type] + _guard_levels.reset(tok_levels) # type: ignore[arg-type] + + +def test_message_for_subprocess_call() -> None: + """C5-T6: an unmocked ``subprocess.run`` outside sandbox produces a + GuardedCallError whose message identifies this test's file:line.""" + tokens = _enable_guard_error() + try: + with pytest.raises(GuardedCallError) as exc_info: + expected_lineno = sys._getframe().f_lineno + 1 + get_verifier_or_raise( + "subprocess:run", + firewall_request=SubprocessFirewallRequest( + command="/bin/true", + binary="/bin/true", + ), + ) + finally: + _disable_guard(tokens) + + msg = str(exc_info.value) + assert f"at {__file__}:{expected_lineno}" in msg + + +def test_message_for_socket_connect() -> None: + """C5-T7: an unmocked ``socket.connect`` outside sandbox produces a + GuardedCallError whose message identifies this test's file:line.""" + tokens = _enable_guard_error() + try: + with pytest.raises(GuardedCallError) as exc_info: + expected_lineno = sys._getframe().f_lineno + 1 + get_verifier_or_raise( + "socket:connect", + firewall_request=NetworkFirewallRequest( + protocol="socket", + host="example.com", + port=9999, + ), + ) + finally: + _disable_guard(tokens) + + msg = str(exc_info.value) + assert f"at {__file__}:{expected_lineno}" in msg + + +def test_message_for_httpx_get() -> None: + """C5-T8: an unmocked HTTP-shaped call outside sandbox produces a + GuardedCallError whose message identifies this test's file:line. + + Uses the live ``http`` plugin name (``http:request`` is the source_id + HttpPlugin uses for httpx requests). Skipped if httpx is not installed + so the plugin registration may not have run. + """ + pytest.importorskip("httpx") + + from tripwire._firewall_request import HttpFirewallRequest + + tokens = _enable_guard_error() + try: + with pytest.raises(GuardedCallError) as exc_info: + expected_lineno = sys._getframe().f_lineno + 1 + get_verifier_or_raise( + "http:request", + firewall_request=HttpFirewallRequest( + method="GET", + scheme="https", + host="example.com", + port=443, + path="/", + ), + ) + finally: + _disable_guard(tokens) + + msg = str(exc_info.value) + assert f"at {__file__}:{expected_lineno}" in msg diff --git a/tests/unit/test_frame_walk.py b/tests/unit/test_frame_walk.py new file mode 100644 index 0000000..2a24a71 --- /dev/null +++ b/tests/unit/test_frame_walk.py @@ -0,0 +1,111 @@ +"""C5-T4, C5-T5, C5-T9: walk_to_user_frame() unit tests. + +Verifies the frame-walking algorithm: +- Walks past tripwire-internal frames (any frame whose ``__name__`` starts + with ``"tripwire."``). +- Walks past frames whose ``__name__`` is exactly ``"tripwire"`` (the + top-level proxy case in ``src/tripwire/__init__.py``). +- Returns None when no user frame exists. +""" + +from __future__ import annotations + +import sys +import types +from typing import Any +from unittest.mock import patch + +from tripwire._frames import walk_to_user_frame + + +def _call_via_synthetic_module(module_name: str) -> tuple[str, int, str] | None: + """Invoke walk_to_user_frame() from inside a synthetic module so its frame + is skipped by the predicate, returning the next frame up (this caller). + + Saves and restores any pre-existing entry in ``sys.modules[module_name]`` + so that registering a synthetic ``"tripwire"`` (or other live name) does + not corrupt the live module cache for subsequent tests. + """ + mod = types.ModuleType(module_name) + code = ( + "def inner():\n" + " from tripwire._frames import walk_to_user_frame\n" + " return walk_to_user_frame()\n" + ) + exec(code, mod.__dict__) + saved = sys.modules.get(module_name) + sys.modules[module_name] = mod + try: + return mod.inner() # type: ignore[no-any-return] + finally: + if saved is None: + sys.modules.pop(module_name, None) + else: + sys.modules[module_name] = saved + + +def test_walks_past_tripwire_frames() -> None: + """C5-T4: from inside a synthetic ``tripwire.foo`` frame, returns a user + frame (the test module helper), not the synthetic tripwire frame. + + The walker must skip the synthetic ``tripwire.synthetic_internal`` frame + and return the next non-tripwire frame, which is ``_call_via_synthetic_module`` + in this test module. + """ + result = _call_via_synthetic_module("tripwire.synthetic_internal") + assert result is not None + filename, lineno, funcname = result + assert filename == __file__ + assert funcname == "_call_via_synthetic_module" + + +def test_skips_top_level_tripwire_proxy_frame() -> None: + """C5-T9: synthetic frame whose ``__name__`` is exactly ``"tripwire"`` + (a proxy in ``src/tripwire/__init__.py``) MUST be skipped. Verifies the + skip predicate is ``module_name == "tripwire" or module_name.startswith("tripwire.")``, + not just ``startswith("tripwire.")`` which would leak the top-level frame. + + If the predicate were missing the equality clause, the synthetic + ``"tripwire"`` frame would be returned as the user frame instead of the + test module helper. + """ + result = _call_via_synthetic_module("tripwire") + assert result is not None + filename, lineno, funcname = result + assert filename == __file__ + assert funcname == "_call_via_synthetic_module" + + +def test_returns_none_when_no_user_frame() -> None: + """C5-T5: when no user frame exists in the stack (every frame is a + tripwire-internal frame), the walker returns None and the message would + render ````. + + Simulated by constructing a synthetic chain of fake frame objects whose + ``f_globals["__name__"]`` is always ``"tripwire.synthetic_internal"``, + then patching ``sys._getframe`` to return the head of the chain. The + walker's loop terminates when it reaches a frame whose ``f_back`` is + ``None`` without finding any user frame. + """ + + class _FakeCode: + co_filename = "/tripwire/synthetic.py" + co_name = "synthetic_func" + + class _FakeFrame: + def __init__(self, back: Any) -> None: + self.f_back = back + self.f_globals = {"__name__": "tripwire.synthetic_internal"} + self.f_lineno = 1 + self.f_code = _FakeCode() + + # Build a 3-frame chain entirely inside tripwire-internal namespace, + # terminated by None. + f3 = _FakeFrame(back=None) + f2 = _FakeFrame(back=f3) + f1 = _FakeFrame(back=f2) + + with patch.object(sys, "_getframe", lambda depth=0: f1): + result = walk_to_user_frame() + + assert result is None diff --git a/tests/unit/test_pedagogical_messages.py b/tests/unit/test_pedagogical_messages.py new file mode 100644 index 0000000..b44c3db --- /dev/null +++ b/tests/unit/test_pedagogical_messages.py @@ -0,0 +1,58 @@ +"""C5-T1, C5-T2, C5-T3: Pedagogical GuardedCallError message framing + call site. + +These tests are unit-level: they construct a GuardedCallError directly and +assert message content. The integration-level tests in +``tests/integration/test_pedagogical_messages.py`` exercise the live frame +walk through the dispatch path. +""" + +from __future__ import annotations + +from tripwire._errors import GuardedCallError +from tripwire._firewall_request import SubprocessFirewallRequest + + +def _build_err_with_frame( + user_frame: tuple[str, int, str] | None, + plugin: str = "subprocess", + method: str = "run", +) -> GuardedCallError: + return GuardedCallError( + source_id=f"{plugin}:{method}", + plugin_name=plugin, + firewall_request=SubprocessFirewallRequest( + command="/bin/true", + binary="/bin/true", + ), + user_frame=user_frame, + ) + + +def test_message_contains_outside_framing() -> None: + """C5-T1: framing line names 'OUTSIDE any "with tripwire:" block' literally.""" + err = _build_err_with_frame(("/path/to/test.py", 42, "test_x")) + assert 'OUTSIDE any "with tripwire:" block' in str(err) + + +def test_message_names_plugin_and_method() -> None: + """C5-T2: message contains plugin name and method-being-called.""" + err = _build_err_with_frame( + ("/path/to/test.py", 42, "test_x"), + plugin="subprocess", + method="run", + ) + msg = str(err) + assert "subprocess" in msg + assert "run" in msg + + +def test_message_includes_user_call_site() -> None: + """C5-T3: message renders ``at :`` for the user frame.""" + err = _build_err_with_frame(("/abs/path/test_caller.py", 137, "test_caller")) + assert "at /abs/path/test_caller.py:137" in str(err) + + +def test_message_renders_unknown_call_site_when_frame_is_none() -> None: + """When user_frame is None, message renders ``at ``.""" + err = _build_err_with_frame(None) + assert "at " in str(err) From 92608192e357196ed0742cdbb19df3d7ec7070aa Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:00:37 -0500 Subject: [PATCH 07/33] Fix mypy: add PostSandboxInteractionError to test_init expected_all The C4 commit added PostSandboxInteractionError to tripwire.__all__ but tests/unit/test_init.py:test_all_contains_expected_names asserts __all__ matches a hardcoded set; the new export was not added to that set. This commit closes the gap. --- tests/unit/test_init.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index db06385..41ee1e3 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -67,6 +67,7 @@ def test_all_contains_expected_names() -> None: "NoActiveVerifierError", "UnmockedInteractionError", "UnsafePassthroughError", + "PostSandboxInteractionError", "UnassertedInteractionsError", "UnusedMocksError", "VerificationError", From 1b3bc8b4a81c518da7975c2041a2b81fdfa42772 Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:11:55 -0500 Subject: [PATCH 08/33] Add @pytest.mark.guard for per-test override Registers the unprefixed `guard` marker (matches the existing `allow` and `deny` convention). Marker accepts a single string ("error" / "warn" / "off") OR a dict matching the GuardLevels shape (works with C3's per-protocol form). Hookwrapper pytest_runtest_call reads the marker, sets the _guard_levels ContextVar override (set token, yield, reset token), scoped to the test's lifetime. --- CHANGELOG.md | 1 + README.md | 8 + src/tripwire/pytest_plugin.py | 28 ++- tests/integration/test_guard_marker.py | 308 +++++++++++++++++++++++++ tests/unit/test_guard_marker.py | 51 ++++ 5 files changed, 394 insertions(+), 2 deletions(-) create mode 100644 tests/integration/test_guard_marker.py create mode 100644 tests/unit/test_guard_marker.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 12eb06e..978584a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `[tool.tripwire.guard]` nested table form: `default = "warn"; = "error" | "warn" | "off"` per protocol. Backwards-compatible with the scalar form `guard = "warn"`. - `PostSandboxInteractionError` distinguishes "an asyncio Task / thread / future survived `with tripwire:` exit and fired afterward" from the existing leaked-interaction case ("call without ever entering a sandbox"). Async leak debugging is now surface-able. - `SandboxContext` now stamps each interaction with a private `_sandbox_id` (monotonic counter; not in `interaction.details`, so the certainty contract is preserved). +- `@pytest.mark.guard("error" | "warn" | "off" | {default: ..., overrides: {...}})` per-test override of the project guard levels. Set token / yield / reset token pattern, scoped to the test's lifetime. ### Fixed - `async_subprocess_plugin` type annotations corrected: the `cast(_AsyncFakeProcess, await _ORIGINAL_CREATE_SUBPROCESS_EXEC(...))` claim was a lie (the runtime returned a real `asyncio.subprocess.Process`). The cast is removed and the return-type annotation widened to `_AsyncFakeProcess | asyncio.subprocess.Process` for both `_fake_create_subprocess_exec` and `_fake_create_subprocess_shell`. Runtime behavior unchanged; static types now match reality. diff --git a/README.md b/README.md index 38a812d..2b03cac 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,14 @@ with tripwire.restrict("http", "subprocess"): Configure project-wide allow/deny rules in `[tool.tripwire.firewall]` in your `pyproject.toml`. +Override the guard level for a single test with `@pytest.mark.guard(...)`. The marker accepts a level string (`"error"`, `"warn"`, `"off"`) or a dict matching the per-protocol shape from `[tool.tripwire.guard]`. + +```python +@pytest.mark.guard("error") +def test_strict(): + ... +``` + ## Quick Start ```python diff --git a/src/tripwire/pytest_plugin.py b/src/tripwire/pytest_plugin.py index 0da4b98..f678f16 100644 --- a/src/tripwire/pytest_plugin.py +++ b/src/tripwire/pytest_plugin.py @@ -7,7 +7,7 @@ import pytest -from tripwire._config import _resolve_guard_levels, load_tripwire_config +from tripwire._config import GuardLevels, _resolve_guard_levels, load_tripwire_config from tripwire._context import ( _current_test_verifier, _guard_active, @@ -31,6 +31,11 @@ def pytest_configure(config: pytest.Config) -> None: "markers", "deny(*rules): deny protocols/patterns (str or M()) in guard mode", ) + config.addinivalue_line( + "markers", + "guard(level_or_dict): override guard level for this test. " + "Accepts \"error\", \"warn\", \"off\", or a dict {default: ..., : ...}.", + ) install_context_propagation() @@ -276,7 +281,26 @@ def pytest_runtest_call(item: pytest.Item) -> Generator[None, None, None]: new_stack = current_stack.push(*frames) if frames else current_stack firewall_token = _firewall_stack.set(new_stack) - levels_token = _guard_levels.set(guard_levels) + project_levels = guard_levels + + # Read the per-test marker (last one wins if multiple). + marker_levels: GuardLevels | None = None + for mark in item.iter_markers("guard"): + arg = mark.args[0] if mark.args else None + if isinstance(arg, str): + marker_levels = GuardLevels(default=arg, overrides={}) + elif isinstance(arg, dict): + marker_levels = _resolve_guard_levels({"guard": arg}) + else: + from tripwire._errors import TripwireConfigError # noqa: PLC0415 + + raise TripwireConfigError( + f"@pytest.mark.guard expects a string or a dict, got {type(arg).__name__}" + ) + + effective_levels = marker_levels if marker_levels is not None else project_levels + + levels_token = _guard_levels.set(effective_levels) guard_token = _guard_active.set(True) try: yield diff --git a/tests/integration/test_guard_marker.py b/tests/integration/test_guard_marker.py new file mode 100644 index 0000000..7a39103 --- /dev/null +++ b/tests/integration/test_guard_marker.py @@ -0,0 +1,308 @@ +"""C6 integration tests: @pytest.mark.guard per-test override. + +Verifies that the `guard` marker overrides the project's resolved guard +levels for the marked test only. Uses pytester subprocesses to isolate +project config from each scenario; tripwire's `pytest_unconfigure` +unconditionally tears down global context propagation, so an in-process +pytester would corrupt the parent session. +""" + +from __future__ import annotations + +import textwrap + +import pytest + +pytest_plugins = ["pytester"] + + +@pytest.mark.allow("subprocess") +def test_marker_string_form(pytester: pytest.Pytester) -> None: + """C6-T1: `@pytest.mark.guard("error")` raises on unmocked call when project default is "warn". + + ESCAPE: test_marker_string_form + CLAIM: The string form override escalates the test from project + default "warn" to "error", raising GuardedCallError. + PATH: pytest_runtest_call -> iter_markers("guard") -> string arg + -> GuardLevels(default="error", overrides={}) -> _guard_levels.set(...) + -> subprocess.run intercepted -> get_verifier_or_raise -> raise. + CHECK: Inner test passes because pytest.raises(GuardedCallError) matches. + MUTATION: If the marker is ignored (no override applied), the project + default "warn" would emit only a warning and the call would + not raise; pytest.raises would fail and outcomes would be + failed=1 instead of passed=1. + ESCAPE: If the marker is read but the levels token is never set, the + ContextVar default applies; with project default "warn", the + call would warn rather than raise. + """ + pytester.makepyprojecttoml( + textwrap.dedent( + """ + [project] + name = "client" + version = "0.0.0" + + [tool.tripwire] + guard = "warn" + """ + ) + ) + pytester.makepyfile( + test_string_form=textwrap.dedent( + """ + import subprocess + + import pytest + + from tripwire import GuardedCallError + + + @pytest.mark.guard("error") + def test_strict(): + with pytest.raises(GuardedCallError): + subprocess.run(["true"]) + """ + ) + ) + result = pytester.runpytest_subprocess("-q") + result.assert_outcomes(passed=1) + + +@pytest.mark.allow("subprocess") +def test_marker_warn_form(pytester: pytest.Pytester) -> None: + """C6-T2: `@pytest.mark.guard("warn")` warns instead of raising when project default is "error". + + Under guard="warn" the dispatch enters the warn branch instead of + the error branch. For an unsafe plugin (subprocess) this surfaces + as `UnsafePassthroughError` (the warn-branch gate for unsafe + plugins), not `GuardedCallError` (the error-branch behavior). The + branch flip is the observable signal. + + ESCAPE: test_marker_warn_form + CLAIM: The string form override demotes dispatch from the error + branch to the warn branch; observable as + UnsafePassthroughError instead of GuardedCallError. + PATH: pytest_runtest_call -> iter_markers("guard") -> string arg + -> GuardLevels(default="warn", overrides={}) -> _guard_levels.set(...) + -> subprocess.run intercepted -> warn-branch unsafe gate. + CHECK: Inner test asserts UnsafePassthroughError is raised (NOT + GuardedCallError); the type difference is what proves the + branch flipped. + MUTATION: If the marker is ignored, project default "error" would + raise GuardedCallError and pytest.raises(UnsafePassthroughError) + would fail with the wrong exception type. + ESCAPE: A bug that always raises UnsafePassthroughError regardless + of level would pass this test in isolation but fail T1 + (which expects GuardedCallError under guard="error"). + """ + pytester.makepyprojecttoml( + textwrap.dedent( + """ + [project] + name = "client" + version = "0.0.0" + + [tool.tripwire] + guard = "error" + """ + ) + ) + pytester.makepyfile( + test_warn_form=textwrap.dedent( + """ + import subprocess + + import pytest + + from tripwire._errors import UnsafePassthroughError + + + @pytest.mark.guard("warn") + def test_lenient(): + with pytest.raises(UnsafePassthroughError): + subprocess.run(["true"]) + """ + ) + ) + result = pytester.runpytest_subprocess("-q") + result.assert_outcomes(passed=1) + + +@pytest.mark.allow("subprocess") +def test_marker_off_form(pytester: pytest.Pytester) -> None: + """C6-T3: `@pytest.mark.guard("off")` disables guard for the test. + + ESCAPE: test_marker_off_form + CLAIM: The "off" form disables guard entirely for the marked test; + the unmocked call neither raises nor warns. + PATH: pytest_runtest_call -> iter_markers("guard") -> "off" + -> GuardLevels(default="off", overrides={}) -> dispatch + returns GuardPassThrough -> intercept passes through. + CHECK: Inner test asserts no GuardedCallWarning was emitted and + the call did not raise. + MUTATION: If the marker is ignored, project default "error" would + raise and the inner test would fail. + ESCAPE: A bug where "off" still warns but does not raise would be + caught by the warning-empty assertion. + """ + pytester.makepyprojecttoml( + textwrap.dedent( + """ + [project] + name = "client" + version = "0.0.0" + + [tool.tripwire] + guard = "error" + """ + ) + ) + pytester.makepyfile( + test_off_form=textwrap.dedent( + """ + import subprocess + import warnings + + import pytest + + from tripwire import GuardedCallError, GuardedCallWarning + + + @pytest.mark.guard("off") + def test_no_guard(): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + try: + subprocess.run(["true"]) + except GuardedCallError: + raise AssertionError("guard('off') must not raise") + guard_warnings = [ + w for w in caught if issubclass(w.category, GuardedCallWarning) + ] + assert guard_warnings == [] + """ + ) + ) + result = pytester.runpytest_subprocess("-q") + result.assert_outcomes(passed=1) + + +@pytest.mark.allow("subprocess") +def test_marker_dict_form(pytester: pytest.Pytester) -> None: + """C6-T4: `@pytest.mark.guard({"default": "warn", "subprocess": "error"})` overrides per-protocol. + + Project default is "warn"; the marker dict escalates the + `subprocess` protocol specifically to "error". This proves both the + dict shape is parsed via `_resolve_guard_levels({"guard": arg})` + AND the per-protocol override is honored at the per-test level. + Without the override, the call would fall to the warn branch and + raise UnsafePassthroughError instead of GuardedCallError. + + ESCAPE: test_marker_dict_form + CLAIM: The dict form is parsed via `_resolve_guard_levels` and the + per-protocol override escalates subprocess to "error" while + the rest of the test stays at default "warn". + PATH: pytest_runtest_call -> iter_markers("guard") -> dict arg + -> _resolve_guard_levels({"guard": {...}}) -> GuardLevels with + overrides={"subprocess": "error"} -> dispatch reads + override -> error branch -> GuardedCallError. + CHECK: Inner test asserts GuardedCallError is raised (the + error-branch behavior); UnsafePassthroughError would not + match (that's the warn-branch behavior). + MUTATION: If the dict form is treated as a string, the call to + `_resolve_guard_levels` would raise TripwireConfigError + and the test would fail with that exception. If + overrides dict is dropped, subprocess inherits default + "warn" and the call raises UnsafePassthroughError, not + GuardedCallError. + ESCAPE: A bug that always escalates regardless of marker arg + would still pass this test but fail T2 (warn-form must + NOT raise GuardedCallError). + """ + pytester.makepyprojecttoml( + textwrap.dedent( + """ + [project] + name = "client" + version = "0.0.0" + + [tool.tripwire] + guard = "warn" + """ + ) + ) + pytester.makepyfile( + test_dict_form=textwrap.dedent( + """ + import subprocess + + import pytest + + from tripwire import GuardedCallError + + + @pytest.mark.guard({"default": "warn", "subprocess": "error"}) + def test_per_protocol(): + with pytest.raises(GuardedCallError): + subprocess.run(["true"]) + """ + ) + ) + result = pytester.runpytest_subprocess("-q") + result.assert_outcomes(passed=1) + + +@pytest.mark.allow("subprocess") +def test_marker_resets_after_test(pytester: pytest.Pytester) -> None: + """C6-T5: After a `guard("off")` test, the next test sees the project default. + + ESCAPE: test_marker_resets_after_test + CLAIM: The marker override is scoped to the marked test's lifetime; + the next test in the same session sees the project default. + PATH: pytest_runtest_call sets levels_token, yield, reset on the + "off" test -> next test enters the hookwrapper fresh -> + re-resolves project levels -> default "error" applies. + CHECK: Inner test_first (off) does not raise; test_second (no marker) + raises GuardedCallError under project default "error". + MUTATION: If the reset is omitted, the "off" override would persist + via the ContextVar default and test_second would not raise. + ESCAPE: A bug that sets the override on a module-level variable + instead of via ContextVar would leak across tests. + """ + pytester.makepyprojecttoml( + textwrap.dedent( + """ + [project] + name = "client" + version = "0.0.0" + + [tool.tripwire] + guard = "error" + """ + ) + ) + pytester.makepyfile( + test_reset=textwrap.dedent( + """ + import subprocess + + import pytest + + from tripwire import GuardedCallError + + + @pytest.mark.guard("off") + def test_first(): + # off override -> no raise. + subprocess.run(["true"]) + + + def test_second(): + # No marker -> project default "error" -> must raise. + with pytest.raises(GuardedCallError): + subprocess.run(["true"]) + """ + ) + ) + result = pytester.runpytest_subprocess("-q") + result.assert_outcomes(passed=2) diff --git a/tests/unit/test_guard_marker.py b/tests/unit/test_guard_marker.py new file mode 100644 index 0000000..066639d --- /dev/null +++ b/tests/unit/test_guard_marker.py @@ -0,0 +1,51 @@ +"""C6 unit tests: @pytest.mark.guard marker registration. + +Verifies that the `guard` marker is registered alongside the existing +`allow` and `deny` markers, so `pytest --markers` lists it. +""" + +from __future__ import annotations + +import textwrap + +import pytest + +pytest_plugins = ["pytester"] + + +@pytest.mark.allow("subprocess") +def test_marker_registered_in_configure(pytester: pytest.Pytester) -> None: + """C6-T6: `pytest --markers` output contains `guard(...)`. + + The tripwire pytest plugin must register an unprefixed `guard` marker + in `pytest_configure` via `addinivalue_line`. Run an inner pytest in + a subprocess so tripwire's `pytest_unconfigure` does not tear down the + parent session's global context propagation. + + ESCAPE: test_marker_registered_in_configure + CLAIM: The `guard` marker is registered with the documented help text. + PATH: pytest_configure -> config.addinivalue_line("markers", "guard(...)") + -> pytest collects markers -> `pytest --markers` lists it. + CHECK: stdout contains the literal `@pytest.mark.guard(level_or_dict)` + header AND the documented help text describing accepted shapes. + MUTATION: If the addinivalue_line call is removed, the marker would + not appear in `--markers` output and `assert "guard("` would + fail. If the help string is mutated (wrong protocol shape + description), the substring check on accepted args would fail. + ESCAPE: A bug that registers the marker under a different name (e.g., + `tripwire_guard`) would fail the unprefixed-name assertion. + """ + pytester.makepyprojecttoml('[project]\nname = "client"\nversion = "0.0.0"\n') + pytester.makepyfile( + test_noop=textwrap.dedent( + """ + def test_noop(): + pass + """ + ) + ) + result = pytester.runpytest_subprocess("--markers") + stdout = "\n".join(result.outlines) + assert "@pytest.mark.guard(level_or_dict)" in stdout + assert "override guard level for this test" in stdout + assert '"error", "warn", "off"' in stdout From 32c9ee2ed3654347a7e0a524ea4fc43e319e4311 Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:20:26 -0500 Subject: [PATCH 09/33] Strict TOML validation with typo suggestions Validates [tool.tripwire] against a closed top-level schema. Unknown keys raise TripwireConfigError with difflib.get_close_matches suggestions. Per-plugin sub-table validation tightened: each plugin's load_config validates its own keys; central code rejects sub-table names that don't match a registered plugin name. Strict validation extended to [tool.tripwire.guard] per-protocol keys: every override key must match a registered plugin name; unknown keys produce typo suggestions. The existing parser's .lower() normalization and "strict" -> "error" aliasing are preserved as zero-cost backward compat. --- CHANGELOG.md | 2 + src/tripwire/_base_plugin.py | 8 ++ src/tripwire/_config.py | 82 ++++++++++++++++++++ src/tripwire/pytest_plugin.py | 9 ++- tests/unit/test_config_validation.py | 109 +++++++++++++++++++++++++++ 5 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_config_validation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 978584a..c3e81a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `PostSandboxInteractionError` distinguishes "an asyncio Task / thread / future survived `with tripwire:` exit and fired afterward" from the existing leaked-interaction case ("call without ever entering a sandbox"). Async leak debugging is now surface-able. - `SandboxContext` now stamps each interaction with a private `_sandbox_id` (monotonic counter; not in `interaction.details`, so the certainty contract is preserved). - `@pytest.mark.guard("error" | "warn" | "off" | {default: ..., overrides: {...}})` per-test override of the project guard levels. Set token / yield / reset token pattern, scoped to the test's lifetime. +- Strict TOML validation: unknown keys under `[tool.tripwire]` and unknown plugin sub-tables raise `TripwireConfigError` with typo suggestions (via `difflib.get_close_matches`). +- Strict validation extended to `[tool.tripwire.guard]` per-protocol keys: every override key must match a registered plugin name; unknown keys produce typo suggestions. ### Fixed - `async_subprocess_plugin` type annotations corrected: the `cast(_AsyncFakeProcess, await _ORIGINAL_CREATE_SUBPROCESS_EXEC(...))` claim was a lie (the runtime returned a real `asyncio.subprocess.Process`). The cast is removed and the return-type annotation widened to `_AsyncFakeProcess | asyncio.subprocess.Process` for both `_fake_create_subprocess_exec` and `_fake_create_subprocess_shell`. Runtime behavior unchanged; static types now match reality. diff --git a/src/tripwire/_base_plugin.py b/src/tripwire/_base_plugin.py index 4c17f25..5a11cc1 100644 --- a/src/tripwire/_base_plugin.py +++ b/src/tripwire/_base_plugin.py @@ -46,6 +46,14 @@ class BasePlugin(ABC): passthrough_safe: ClassVar[bool] = False guard_prefixes: ClassVar[tuple[str, ...]] = () + # Optional closed schema for the plugin's [tool.tripwire.] + # sub-table. When non-empty, the strict-validation pass will reject + # unknown keys in the plugin's sub-table with a difflib typo + # suggestion. Default is the empty frozenset, which means the plugin + # opts out of central per-key validation (its own load_config remains + # the source of truth for accepted options). + config_schema: ClassVar[frozenset[str]] = frozenset() + # Shared patching infrastructure -- each subclass gets its own via __init_subclass__ _install_count: ClassVar[int] = 0 _install_lock: ClassVar[threading.Lock] = threading.Lock() diff --git a/src/tripwire/_config.py b/src/tripwire/_config.py index d826cb1..24252af 100644 --- a/src/tripwire/_config.py +++ b/src/tripwire/_config.py @@ -2,6 +2,7 @@ from __future__ import annotations +import difflib from collections.abc import Mapping from dataclasses import dataclass, field from pathlib import Path @@ -47,6 +48,78 @@ def load_tripwire_config(start: Path | None = None) -> dict[str, Any]: _LEVEL_ALIASES: Final[Mapping[str, str]] = {"strict": "error"} +# --------------------------------------------------------------------------- +# Strict schema validation (C7) +# --------------------------------------------------------------------------- + +# Scalar / table keys that may appear directly under [tool.tripwire] and are +# not plugin sub-tables. Plugin sub-table names are added dynamically from +# the registry by ``_allowed_top_level_keys``. +_TOP_LEVEL_NON_PLUGIN_KEYS: Final[frozenset[str]] = frozenset( + {"guard", "firewall", "enabled_plugins", "disabled_plugins"} +) + + +def _allowed_top_level_keys() -> frozenset[str]: + """Return the closed schema of allowed [tool.tripwire] top-level keys. + + Built from the union of fixed non-plugin keys and the registered plugin + names (each plugin may have a [tool.tripwire.] sub-table). The + registry is the single source of truth — never hardcode the plugin list. + """ + from tripwire._registry import VALID_PLUGIN_NAMES # noqa: PLC0415 + + return _TOP_LEVEL_NON_PLUGIN_KEYS | VALID_PLUGIN_NAMES + + +def _format_suggestion(unknown: str, candidates: frozenset[str]) -> str: + """Format a 'did you mean' hint via difflib.get_close_matches. + + Returns an empty string when difflib finds no close matches. Uses + difflib's default cutoff (0.6). + """ + matches = difflib.get_close_matches(unknown, sorted(candidates), n=3) + if not matches: + return "" + if len(matches) == 1: + return f" (did you mean: {matches[0]}?)" + return f" (did you mean: {', '.join(matches)}?)" + + +# Legacy keys removed in 0.20.0 that need a tailored migration message +# instead of the generic "unknown key" + difflib suggestion. Handled by +# downstream code (e.g., pytest_plugin.py for ``guard_allow``); skipped +# here so the specific message wins. +_LEGACY_MIGRATED_KEYS: Final[frozenset[str]] = frozenset({"guard_allow"}) + + +def validate_top_level_schema(config: Mapping[str, Any]) -> None: + """Validate the top-level [tool.tripwire] table against the closed schema. + + Raises :class:`TripwireConfigError` for any unknown key. The allowed-key + set is the union of the fixed non-plugin keys (``guard``, ``firewall``, + ``enabled_plugins``, ``disabled_plugins``) and every plugin name from + ``PLUGIN_REGISTRY``. Unknown keys produce ``difflib.get_close_matches`` + typo suggestions when a close candidate exists. + + This validator does NOT validate the contents of each sub-table — that + is each plugin's ``load_config`` responsibility. It only enforces that + the top-level keys themselves are recognised. Legacy keys with their + own migration message (see ``_LEGACY_MIGRATED_KEYS``) are skipped so + downstream code can emit the tailored message. + """ + from tripwire._errors import TripwireConfigError # noqa: PLC0415 + + allowed = _allowed_top_level_keys() + for key in config: + if key in allowed or key in _LEGACY_MIGRATED_KEYS: + continue + suggestion = _format_suggestion(key, allowed) + raise TripwireConfigError( + f"Unknown key {key!r} in [tool.tripwire].{suggestion}" + ) + + @dataclass(frozen=True, slots=True) class GuardLevels: """Resolved guard levels: a default plus per-plugin overrides. @@ -120,10 +193,19 @@ def _resolve_guard_levels(config: Mapping[str, Any]) -> GuardLevels: f"Invalid value {default_raw!r} for [tool.tripwire.guard] default. " f"Expected one of: {sorted(_VALID_LEVELS)}." ) + from tripwire._registry import VALID_PLUGIN_NAMES # noqa: PLC0415 + overrides: dict[str, str] = {} for key, value in raw.items(): if key == "default": continue + # C7: every override key must match a registered plugin name. + if key not in VALID_PLUGIN_NAMES: + suggestion = _format_suggestion(key, VALID_PLUGIN_NAMES) + raise TripwireConfigError( + f"Unknown protocol {key!r} in [tool.tripwire.guard]." + f"{suggestion}" + ) normalized_value: Any if isinstance(value, bool): normalized_value = "off" if value is False else value diff --git a/src/tripwire/pytest_plugin.py b/src/tripwire/pytest_plugin.py index f678f16..7618b1c 100644 --- a/src/tripwire/pytest_plugin.py +++ b/src/tripwire/pytest_plugin.py @@ -7,7 +7,12 @@ import pytest -from tripwire._config import GuardLevels, _resolve_guard_levels, load_tripwire_config +from tripwire._config import ( + GuardLevels, + _resolve_guard_levels, + load_tripwire_config, + validate_top_level_schema, +) from tripwire._context import ( _current_test_verifier, _guard_active, @@ -96,6 +101,7 @@ def _tripwire_guard_patches() -> Generator[None, None, None]: during fixture setup/teardown). """ config = load_tripwire_config() + validate_top_level_schema(config) guard_levels = _resolve_guard_levels(config) # Skip patch installation only when ALL protocols are "off" # (i.e., default is "off" and no override raises any protocol back). @@ -168,6 +174,7 @@ def pytest_runtest_call(item: pytest.Item) -> Generator[None, None, None]: installed (e.g., via sandbox activation within the test). """ config = load_tripwire_config() + validate_top_level_schema(config) guard_levels = _resolve_guard_levels(config) # Skip guard activation only when ALL protocols are "off". if guard_levels.default == "off" and all( diff --git a/tests/unit/test_config_validation.py b/tests/unit/test_config_validation.py new file mode 100644 index 0000000..625205d --- /dev/null +++ b/tests/unit/test_config_validation.py @@ -0,0 +1,109 @@ +"""C7: Strict TOML schema validation with typo suggestions. + +Tests that unknown top-level keys, unknown plugin sub-tables, and unknown +per-protocol guard keys are rejected with TripwireConfigError. Suggestions +are produced via difflib.get_close_matches. + +Note: the legacy-table migration error is C1's responsibility (covered by +the rename smoke/migration tests), not C7. +""" + +from __future__ import annotations + +import pytest + +from tripwire._config import ( + _resolve_guard_levels, + validate_top_level_schema, +) +from tripwire._errors import TripwireConfigError + +# --------------------------------------------------------------------------- +# C7-T1: unknown top-level key rejected +# --------------------------------------------------------------------------- + + +def test_unknown_top_level_key_rejected() -> None: + """[tool.tripwire] foobar = 1 raises TripwireConfigError mentioning the key.""" + config = {"foobar": 1} + with pytest.raises(TripwireConfigError) as exc_info: + validate_top_level_schema(config) + assert "foobar" in str(exc_info.value) + + +# --------------------------------------------------------------------------- +# C7-T2: typo suggestion via difflib +# --------------------------------------------------------------------------- + + +def test_typo_suggestion() -> None: + """[tool.tripwire] gaurd = "warn" raises with 'did you mean: guard' hint.""" + config = {"gaurd": "warn"} + with pytest.raises(TripwireConfigError) as exc_info: + validate_top_level_schema(config) + message = str(exc_info.value) + assert "gaurd" in message + assert "did you mean: guard" in message + + +# --------------------------------------------------------------------------- +# C7-T3: invalid guard value rejected; .lower() preserved +# --------------------------------------------------------------------------- + + +def test_invalid_guard_value_rejected() -> None: + """guard = "Warn" lowercases to "warn" and is accepted; bogus values raise.""" + # Capital W normalized via .lower() and accepted (preserves existing behavior). + levels = _resolve_guard_levels({"guard": "Warn"}) + assert levels.default == "warn" + assert dict(levels.overrides) == {} + + # A genuinely invalid value (after lowering) still raises with the + # allowed-values list. + with pytest.raises(TripwireConfigError) as exc_info: + _resolve_guard_levels({"guard": "loud"}) + message = str(exc_info.value) + assert "loud" in message + assert "warn" in message + assert "error" in message + assert "off" in message + + +def test_strict_alias_accepted() -> None: + """guard = "strict" is accepted as an alias for "error" (preserves existing behavior).""" + levels = _resolve_guard_levels({"guard": "strict"}) + assert levels.default == "error" + assert dict(levels.overrides) == {} + + # Also case-insensitive: "Strict" -> lower -> "strict" -> alias -> "error". + levels_capital = _resolve_guard_levels({"guard": "Strict"}) + assert levels_capital.default == "error" + assert dict(levels_capital.overrides) == {} + + +# --------------------------------------------------------------------------- +# C7-T5: unknown plugin sub-table rejected +# --------------------------------------------------------------------------- + + +def test_unknown_plugin_subtable_rejected() -> None: + """[tool.tripwire.notarealplugin] x = 1 raises with the offending name.""" + config = {"notarealplugin": {"x": 1}} + with pytest.raises(TripwireConfigError) as exc_info: + validate_top_level_schema(config) + assert "notarealplugin" in str(exc_info.value) + + +# --------------------------------------------------------------------------- +# C7-T6: unknown per-protocol guard key rejected with typo suggestion +# --------------------------------------------------------------------------- + + +def test_unknown_per_protocol_guard_key_rejected() -> None: + """[tool.tripwire.guard] subprocesss = "error" raises with difflib suggestion.""" + config = {"guard": {"default": "error", "subprocesss": "error"}} + with pytest.raises(TripwireConfigError) as exc_info: + _resolve_guard_levels(config) + message = str(exc_info.value) + assert "subprocesss" in message + assert "did you mean: subprocess" in message From 6e2e5e3eb3f13de09f165e9b1cd12130845bb736 Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:24:39 -0500 Subject: [PATCH 10/33] README: when to pick which guard default Adds a "Picking the right guard default" section after the Firewall mode section. Three-bullet structure: new projects (keep `error`), legacy-migration suites (`warn` temporarily), mixed CI (per-protocol via [tool.tripwire.guard]). --- CHANGELOG.md | 1 + README.md | 13 +++++++++++++ tests/dogfood/test_readme_sections.py | 26 ++++++++++++++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 tests/dogfood/test_readme_sections.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c3e81a0..9fa2804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Breaking:** Default `[tool.tripwire] guard` flipped from `"warn"` to `"error"`. New projects fail loud on unmocked I/O outside a sandbox. To preserve prior behavior during legacy migration, set `guard = "warn"` explicitly. - **Breaking:** Removed `BasePlugin.supports_guard` and the `is_guard_eligible()` registry helper. Replaced by `passthrough_safe`. The 6 plugins that had `supports_guard=False` (celery, crypto, file_io, jwt, logging, native) become `passthrough_safe=True` because their interceptors raise `SandboxNotActiveError` outside sandbox (which is safe). MockPlugin and StateMachinePlugin (passive recorders) also become `passthrough_safe=True`. The remaining real-IO plugins become `passthrough_safe=False`. - `GuardedCallError` message reframed for clarity: states "OUTSIDE any `with tripwire:` block", names the plugin and method, includes the user call site (`file:lineno`), and lists the two fixes inline. Existing fix sections (`@pytest.mark.allow`, pyproject, sandbox-with-mock) retained. +- README: added a "Picking the right guard default" section covering new projects, legacy-migration suites, and mixed-CI per-protocol overrides. ### Added - `ConfigMigrationError` (subclass of `TripwireError`) raised when `[tool.bigfoot]` is present in pyproject.toml during config load. diff --git a/README.md b/README.md index 2b03cac..ba45d76 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,19 @@ def test_strict(): ... ``` +## Picking the right guard default + +- **New projects**: keep `guard = "error"` (the default since 0.20.0). Loud failures catch unmocked I/O early. +- **Legacy migration**: set `guard = "warn"` temporarily while you add `tripwire.allow` and mocks to existing tests. +- **Mixed CI**: use `[tool.tripwire.guard]` for per-protocol levels. Example: + + ```toml + [tool.tripwire.guard] + default = "warn" + subprocess = "error" + dns = "error" + ``` + ## Quick Start ```python diff --git a/tests/dogfood/test_readme_sections.py b/tests/dogfood/test_readme_sections.py new file mode 100644 index 0000000..4a4595f --- /dev/null +++ b/tests/dogfood/test_readme_sections.py @@ -0,0 +1,26 @@ +"""Dogfood tests asserting README documentation sections exist. + +These tests guard against accidental removal of documentation that +downstream users (and the design plan) depend on. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +README_PATH = Path(__file__).resolve().parents[2] / "README.md" + + +def test_readme_has_pick_default_section() -> None: + """README contains a "Picking the right guard default" section heading. + + Guards against the C8 README addition being silently removed in a + future README rewrite. + """ + text = README_PATH.read_text(encoding="utf-8") + pattern = re.compile(r"^##\s+Picking the right guard default\s*$", re.MULTILINE) + assert pattern.search(text) is not None, ( + "README.md missing required section header " + '"## Picking the right guard default"' + ) From b0a4975e3493073df60f24048229bfe524b42811 Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:25:49 -0500 Subject: [PATCH 11/33] Fix README: correct stale guard=warn default-mode prose C1 flipped the default from "warn" to "error" but two prose passages in README.md still described "warn" as the default and "error" as the opt-in. Updates the Firewall mode paragraph and the migration ladder to match the post-0.20.0 default. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ba45d76..d4e1c3b 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ def test_payment(): Firewall mode is on by default. When your test session starts, tripwire installs interceptors that catch any real I/O call happening outside a sandbox. -In `"warn"` mode (the default), accidental calls emit a `GuardedCallWarning` and proceed normally, so your existing suite keeps working while showing you exactly which calls are unguarded. Set `guard = "error"` under `[tool.tripwire]` in your `pyproject.toml` for strict enforcement. +In `"error"` mode (the default since 0.20.0), an accidental call raises `GuardedCallError` and stops the test on the spot. Set `guard = "warn"` under `[tool.tripwire]` in your `pyproject.toml` for the legacy non-blocking behavior, where calls emit a `GuardedCallWarning` and proceed. ```python from tripwire import M @@ -277,9 +277,9 @@ def test_cached_lookup(): You do not have to migrate your entire test suite at once. tripwire and `unittest.mock` can coexist in the same project: -1. **Start with guard mode.** Install tripwire and run your suite. Guard mode (default `"warn"`) will show you every real I/O call across all tests without breaking anything. +1. **Start in warn mode.** Install tripwire and set `guard = "warn"` in `[tool.tripwire]`; the suite keeps running while every real I/O call shows up as a `GuardedCallWarning` you can triage. 2. **Migrate test by test.** Pick tests that touch HTTP, subprocess, or database calls first -- these benefit most from tripwire's strict enforcement. -3. **Escalate to strict guard mode.** Once coverage is high, set `guard = "error"` in `pyproject.toml` to catch any remaining leaks. +3. **Drop the override.** Once coverage is high, remove `guard = "warn"` to fall back to the 0.20.0 default of `"error"` and catch any remaining leaks. ## Plugins From c97afc1692ea8ddeccc8db49dfce467d78867d4e Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:47:03 -0500 Subject: [PATCH 12/33] Raise from allow/restrict outside sandbox tripwire.allow(...), tripwire.restrict(...), and tripwire.deny(...) called outside any active sandbox now raise TripwireError with a message pointing the user at [tool.tripwire.firewall] for module-scoped rules. Previously these silently no-op'd (frame pushed and popped without effect). The check fires inside the generator body BEFORE _firewall_stack.set(...), so the error raises on __enter__ and leaves _firewall_stack unchanged. Uses _active_verifier.get() is not None only (does NOT include _guard_active.get()), which preserves the documented behavior of allowing tripwire.allow(...) inside guard mode. --- CHANGELOG.md | 1 + src/tripwire/_guard.py | 22 ++ .../test_allow_restrict_outside_sandbox.py | 117 +++++++ tests/unit/test_guard.py | 301 +++++++++++------- tests/unit/test_guard_new.py | 32 +- tests/unit/test_http_plugin.py | 12 +- 6 files changed, 369 insertions(+), 116 deletions(-) create mode 100644 tests/unit/test_allow_restrict_outside_sandbox.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fa2804..7756e01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Breaking:** Removed `BasePlugin.supports_guard` and the `is_guard_eligible()` registry helper. Replaced by `passthrough_safe`. The 6 plugins that had `supports_guard=False` (celery, crypto, file_io, jwt, logging, native) become `passthrough_safe=True` because their interceptors raise `SandboxNotActiveError` outside sandbox (which is safe). MockPlugin and StateMachinePlugin (passive recorders) also become `passthrough_safe=True`. The remaining real-IO plugins become `passthrough_safe=False`. - `GuardedCallError` message reframed for clarity: states "OUTSIDE any `with tripwire:` block", names the plugin and method, includes the user call site (`file:lineno`), and lists the two fixes inline. Existing fix sections (`@pytest.mark.allow`, pyproject, sandbox-with-mock) retained. - README: added a "Picking the right guard default" section covering new projects, legacy-migration suites, and mixed-CI per-protocol overrides. +- `tripwire.allow(...)` and `tripwire.restrict(...)` now raise `TripwireError` when called outside any active sandbox, with a message pointing the user at `[tool.tripwire.firewall]` for module-scoped rules. ### Added - `ConfigMigrationError` (subclass of `TripwireError`) raised when `[tool.bigfoot]` is present in pyproject.toml during config load. diff --git a/src/tripwire/_guard.py b/src/tripwire/_guard.py index 6801d4a..233188f 100644 --- a/src/tripwire/_guard.py +++ b/src/tripwire/_guard.py @@ -5,6 +5,7 @@ from collections.abc import Generator from contextlib import contextmanager +from tripwire._errors import TripwireError from tripwire._firewall import ( Disposition, FirewallRule, @@ -41,6 +42,13 @@ def allow(*rules: str | M) -> Generator[None, None, None]: if not rules: raise ValueError("allow() requires at least one rule") + from tripwire._context import _active_verifier # noqa: PLC0415 + if _active_verifier.get() is None: + raise TripwireError( + "tripwire.allow(...) was called outside any active sandbox. " + "For module-scoped firewall rules, use [tool.tripwire.firewall] in pyproject.toml." + ) + frames = tuple( FirewallRule(pattern=_coerce_to_m(r), disposition=Disposition.ALLOW) for r in rules @@ -68,6 +76,13 @@ def deny(*rules: str | M) -> Generator[None, None, None]: if not rules: raise ValueError("deny() requires at least one rule") + from tripwire._context import _active_verifier # noqa: PLC0415 + if _active_verifier.get() is None: + raise TripwireError( + "tripwire.deny(...) was called outside any active sandbox. " + "For module-scoped firewall rules, use [tool.tripwire.firewall] in pyproject.toml." + ) + frames = tuple( FirewallRule(pattern=_coerce_to_m(r), disposition=Disposition.DENY) for r in rules @@ -104,6 +119,13 @@ def restrict(*rules: str | M) -> Generator[None, None, None]: if not rules: raise ValueError("restrict() requires at least one rule") + from tripwire._context import _active_verifier # noqa: PLC0415 + if _active_verifier.get() is None: + raise TripwireError( + "tripwire.restrict(...) was called outside any active sandbox. " + "For module-scoped firewall rules, use [tool.tripwire.firewall] in pyproject.toml." + ) + if len(rules) == 1: pattern = _coerce_to_m(rules[0]) else: diff --git a/tests/unit/test_allow_restrict_outside_sandbox.py b/tests/unit/test_allow_restrict_outside_sandbox.py new file mode 100644 index 0000000..55a28c2 --- /dev/null +++ b/tests/unit/test_allow_restrict_outside_sandbox.py @@ -0,0 +1,117 @@ +"""C9: tripwire.allow / tripwire.deny / tripwire.restrict raise outside sandbox. + +When called with no active sandbox (no `with tripwire:` and no marker / +fixture path that sets `_active_verifier`), these context managers used to +silently push a frame onto `_firewall_stack` and pop it on exit, which made +the rule a no-op. Now they raise a `TripwireError` whose message points the +user at `[tool.tripwire.firewall]` for module-scoped rules. + +The check fires INSIDE the generator body BEFORE `_firewall_stack.set(...)`, +so the error is raised on `__enter__`. The check uses +`_active_verifier.get() is not None` ONLY (not `_guard_active.get()`), which +preserves the documented behavior of allowing `tripwire.allow(...)` inside +guard mode (the marker / fixture path that sets `_active_verifier` keeps +working). + +The same check applies to `deny` for symmetry: its rule frame is equally +meaningless without an active sandbox or guard context. +""" + +from __future__ import annotations + +import pytest + +import tripwire +from tripwire._errors import TripwireError +from tripwire._firewall import _firewall_stack +from tripwire._guard import restrict as _restrict + + +def test_allow_outside_sandbox_raises() -> None: + """C9-T1: `tripwire.allow("dns")` outside any sandbox raises + TripwireError whose message references `[tool.tripwire.firewall]`. + """ + with pytest.raises(TripwireError) as exc_info: + with tripwire.allow("dns"): + pass + + expected_message = ( + "tripwire.allow(...) was called outside any active sandbox. " + "For module-scoped firewall rules, use [tool.tripwire.firewall] in pyproject.toml." + ) + assert str(exc_info.value) == expected_message + + +def test_restrict_outside_sandbox_raises() -> None: + """C9-T2: `tripwire.restrict(...)` outside any sandbox raises + TripwireError whose message references `[tool.tripwire.firewall]`. + """ + with pytest.raises(TripwireError) as exc_info: + with _restrict("dns"): + pass + + expected_message = ( + "tripwire.restrict(...) was called outside any active sandbox. " + "For module-scoped firewall rules, use [tool.tripwire.firewall] in pyproject.toml." + ) + assert str(exc_info.value) == expected_message + + +def test_deny_outside_sandbox_raises() -> None: + """C9 (symmetry): `tripwire.deny(...)` outside any sandbox raises + TripwireError whose message references `[tool.tripwire.firewall]`. + + Although the I-3 finding only enumerates `allow` and `restrict`, the + same check applies to `deny` for consistency. Its rule frame is + equally meaningless without an active sandbox or guard context. + """ + with pytest.raises(TripwireError) as exc_info: + with tripwire.deny("dns"): + pass + + expected_message = ( + "tripwire.deny(...) was called outside any active sandbox. " + "For module-scoped firewall rules, use [tool.tripwire.firewall] in pyproject.toml." + ) + assert str(exc_info.value) == expected_message + + +def test_allow_inside_sandbox_works() -> None: + """C9-T3: Sanity check: `with tripwire: tripwire.allow("dns")` does + not raise. The sandbox sets `_active_verifier`, so the C9 check + passes and the existing allow body runs normally. + """ + entered_body = False + with tripwire.sandbox(): + with tripwire.allow("dns"): + entered_body = True + assert entered_body is True + + +def test_allow_raises_on_enter_not_exit() -> None: + """C9-T4: `with tripwire.allow("dns"): pass` raises TripwireError on + __enter__, not on __exit__. The `pass` body never executes. + + If the C9 check were accidentally moved AFTER `_firewall_stack.set(...)` + (or the body raised instead of __enter__), this test would catch it. + """ + body_executed = False + + with pytest.raises(TripwireError): + with tripwire.allow("dns"): + body_executed = True + + assert body_executed is False + + +def test_allow_failed_enter_leaves_firewall_stack_unchanged() -> None: + """C9-T4 companion: after a failed `__enter__`, `_firewall_stack.get()` + is unchanged. Confirms the check fires BEFORE `_firewall_stack.set(...)`, + so no stale frame is left on the stack when the error raises. + """ + stack_before = _firewall_stack.get() + with pytest.raises(TripwireError): + with tripwire.allow("dns"): + pass + stack_after = _firewall_stack.get() + assert stack_after is stack_before diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index e4bd241..fc050eb 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -3,12 +3,15 @@ from __future__ import annotations import warnings +from collections.abc import Generator +from contextlib import contextmanager import pytest from tripwire._config import GuardLevels, _resolve_guard_levels from tripwire._context import ( GuardPassThrough, + _active_verifier, _guard_active, get_verifier_or_raise, ) @@ -22,6 +25,78 @@ from tripwire._match import M +@contextmanager +def _with_active_verifier() -> Generator[None, None, None]: + """Set `_active_verifier` to a sentinel for the duration of the block. + + C9 added a check that `tripwire.allow/deny/restrict` raise outside any + active sandbox (i.e., when `_active_verifier` is None). Tests in this + file exercise the firewall stack mechanics or guard-mode pathways + without going through `with tripwire.sandbox():`; setting + `_active_verifier` directly satisfies the C9 gate without standing up + a full sandbox. + """ + from tripwire._verifier import StrictVerifier + + StrictVerifier._suppress_direct_warning = True + try: + sentinel = StrictVerifier() + finally: + StrictVerifier._suppress_direct_warning = False + token = _active_verifier.set(sentinel) + try: + yield + finally: + _active_verifier.reset(token) + + +@contextmanager +def _push_deny_direct(*protocols: str) -> Generator[None, None, None]: + """Push DENY frames onto the firewall stack directly, bypassing C9. + + Tests of GUARD-mode behavior (no sandbox, no verifier) need to add a + DENY rule without going through `tripwire.deny(...)` (which now requires + an active verifier per C9). This helper duplicates the post-check body + of `tripwire.deny(...)` for tests that exercise guard-mode dispatch + while `_active_verifier` is intentionally None. + """ + from tripwire._firewall import Disposition, FirewallRule + + frames = tuple( + FirewallRule(pattern=M(protocol=p), disposition=Disposition.DENY) + for p in protocols + ) + current = _firewall_stack.get() + new_stack = current.push(*frames) + token = _firewall_stack.set(new_stack) + try: + yield + finally: + _firewall_stack.reset(token) + + +@contextmanager +def _push_allow_direct(*protocols: str) -> Generator[None, None, None]: + """Push ALLOW frames onto the firewall stack directly, bypassing C9. + + Companion to `_push_deny_direct`. Same rationale: guard-mode-only tests + need to add an ALLOW rule without an active verifier. + """ + from tripwire._firewall import Disposition, FirewallRule + + frames = tuple( + FirewallRule(pattern=M(protocol=p), disposition=Disposition.ALLOW) + for p in protocols + ) + current = _firewall_stack.get() + new_stack = current.push(*frames) + token = _firewall_stack.set(new_stack) + try: + yield + finally: + _firewall_stack.reset(token) + + class TestGuardContextVars: """Test guard mode ContextVars exist and have correct defaults. @@ -192,36 +267,38 @@ class TestAllow: def test_pushes_allow_rules_and_resets(self) -> None: from tripwire._firewall_request import NetworkFirewallRequest - stack_before = _firewall_stack.get() - with allow("dns", "socket"): - stack_inside = _firewall_stack.get() - # Two new ALLOW frames pushed - assert len(stack_inside.frames) == len(stack_before.frames) + 2 - # DNS request should be ALLOW'd - dns_req = NetworkFirewallRequest(protocol="dns", host="example.com", port=53) - assert stack_inside.evaluate(dns_req) == Disposition.ALLOW - # Socket request should be ALLOW'd - sock_req = NetworkFirewallRequest(protocol="socket", host="127.0.0.1", port=80) - assert stack_inside.evaluate(sock_req) == Disposition.ALLOW - # After exit, stack is restored - assert _firewall_stack.get() is stack_before + with _with_active_verifier(): + stack_before = _firewall_stack.get() + with allow("dns", "socket"): + stack_inside = _firewall_stack.get() + # Two new ALLOW frames pushed + assert len(stack_inside.frames) == len(stack_before.frames) + 2 + # DNS request should be ALLOW'd + dns_req = NetworkFirewallRequest(protocol="dns", host="example.com", port=53) + assert stack_inside.evaluate(dns_req) == Disposition.ALLOW + # Socket request should be ALLOW'd + sock_req = NetworkFirewallRequest(protocol="socket", host="127.0.0.1", port=80) + assert stack_inside.evaluate(sock_req) == Disposition.ALLOW + # After exit, stack is restored + assert _firewall_stack.get() is stack_before def test_nestable_stacks_rules(self) -> None: from tripwire._firewall_request import NetworkFirewallRequest - stack_before = _firewall_stack.get() - with allow("dns"): - stack_dns = _firewall_stack.get() - dns_req = NetworkFirewallRequest(protocol="dns", host="example.com", port=53) - assert stack_dns.evaluate(dns_req) == Disposition.ALLOW - with allow("socket"): - stack_both = _firewall_stack.get() - sock_req = NetworkFirewallRequest(protocol="socket", host="127.0.0.1", port=80) - assert stack_both.evaluate(dns_req) == Disposition.ALLOW - assert stack_both.evaluate(sock_req) == Disposition.ALLOW - # socket rule removed after inner exit - assert _firewall_stack.get() is stack_dns - assert _firewall_stack.get() is stack_before + with _with_active_verifier(): + stack_before = _firewall_stack.get() + with allow("dns"): + stack_dns = _firewall_stack.get() + dns_req = NetworkFirewallRequest(protocol="dns", host="example.com", port=53) + assert stack_dns.evaluate(dns_req) == Disposition.ALLOW + with allow("socket"): + stack_both = _firewall_stack.get() + sock_req = NetworkFirewallRequest(protocol="socket", host="127.0.0.1", port=80) + assert stack_both.evaluate(dns_req) == Disposition.ALLOW + assert stack_both.evaluate(sock_req) == Disposition.ALLOW + # socket rule removed after inner exit + assert _firewall_stack.get() is stack_dns + assert _firewall_stack.get() is stack_before def test_requires_at_least_one_rule(self) -> None: with pytest.raises(ValueError, match="allow\\(\\) requires at least one rule"): @@ -231,17 +308,19 @@ def test_requires_at_least_one_rule(self) -> None: def test_single_protocol_name(self) -> None: from tripwire._firewall_request import NetworkFirewallRequest - with allow("http"): - stack = _firewall_stack.get() - http_req = NetworkFirewallRequest(protocol="http", host="example.com", port=80) - assert stack.evaluate(http_req) == Disposition.ALLOW + with _with_active_verifier(): + with allow("http"): + stack = _firewall_stack.get() + http_req = NetworkFirewallRequest(protocol="http", host="example.com", port=80) + assert stack.evaluate(http_req) == Disposition.ALLOW def test_resets_on_exception(self) -> None: - stack_before = _firewall_stack.get() - with pytest.raises(ValueError, match="boom"): - with allow("dns"): - raise ValueError("boom") - assert _firewall_stack.get() is stack_before + with _with_active_verifier(): + stack_before = _firewall_stack.get() + with pytest.raises(ValueError, match="boom"): + with allow("dns"): + raise ValueError("boom") + assert _firewall_stack.get() is stack_before class TestDeny: @@ -254,15 +333,16 @@ def test_deny_blocks_allowed_protocol(self) -> None: dns_req = NetworkFirewallRequest(protocol="dns", host="example.com", port=53) sock_req = NetworkFirewallRequest(protocol="socket", host="127.0.0.1", port=80) - with allow("dns", "socket"): - assert _firewall_stack.get().evaluate(dns_req) == Disposition.ALLOW - assert _firewall_stack.get().evaluate(sock_req) == Disposition.ALLOW - with deny("socket"): - # socket should now be DENY'd, dns still ALLOW'd + with _with_active_verifier(): + with allow("dns", "socket"): assert _firewall_stack.get().evaluate(dns_req) == Disposition.ALLOW - assert _firewall_stack.get().evaluate(sock_req) == Disposition.DENY - # After exiting deny, socket allowed again - assert _firewall_stack.get().evaluate(sock_req) == Disposition.ALLOW + assert _firewall_stack.get().evaluate(sock_req) == Disposition.ALLOW + with deny("socket"): + # socket should now be DENY'd, dns still ALLOW'd + assert _firewall_stack.get().evaluate(dns_req) == Disposition.ALLOW + assert _firewall_stack.get().evaluate(sock_req) == Disposition.DENY + # After exiting deny, socket allowed again + assert _firewall_stack.get().evaluate(sock_req) == Disposition.ALLOW def test_deny_without_allow_keeps_deny(self) -> None: from tripwire._firewall_request import NetworkFirewallRequest @@ -270,18 +350,20 @@ def test_deny_without_allow_keeps_deny(self) -> None: dns_req = NetworkFirewallRequest(protocol="dns", host="example.com", port=53) # Default disposition is DENY, so deny on top of empty stack still denies - with deny("dns"): - assert _firewall_stack.get().evaluate(dns_req) == Disposition.DENY + with _with_active_verifier(): + with deny("dns"): + assert _firewall_stack.get().evaluate(dns_req) == Disposition.DENY def test_deny_resets_on_exception(self) -> None: from tripwire._guard import deny - stack_before = _firewall_stack.get() - with pytest.raises(ValueError, match="boom"): - with allow("dns", "socket"): - with deny("socket"): - raise ValueError("boom") - assert _firewall_stack.get() is stack_before + with _with_active_verifier(): + stack_before = _firewall_stack.get() + with pytest.raises(ValueError, match="boom"): + with allow("dns", "socket"): + with deny("socket"): + raise ValueError("boom") + assert _firewall_stack.get() is stack_before def test_deny_requires_at_least_one_rule(self) -> None: from tripwire._guard import deny @@ -298,17 +380,18 @@ def test_nested_deny(self) -> None: sock_req = NetworkFirewallRequest(protocol="socket", host="127.0.0.1", port=80) http_req = NetworkFirewallRequest(protocol="http", host="example.com", port=80) - with allow("dns", "socket", "http"): - with deny("socket"): - assert _firewall_stack.get().evaluate(dns_req) == Disposition.ALLOW - assert _firewall_stack.get().evaluate(http_req) == Disposition.ALLOW - assert _firewall_stack.get().evaluate(sock_req) == Disposition.DENY - with deny("dns"): + with _with_active_verifier(): + with allow("dns", "socket", "http"): + with deny("socket"): + assert _firewall_stack.get().evaluate(dns_req) == Disposition.ALLOW assert _firewall_stack.get().evaluate(http_req) == Disposition.ALLOW - assert _firewall_stack.get().evaluate(dns_req) == Disposition.DENY assert _firewall_stack.get().evaluate(sock_req) == Disposition.DENY - assert _firewall_stack.get().evaluate(dns_req) == Disposition.ALLOW - assert _firewall_stack.get().evaluate(sock_req) == Disposition.ALLOW + with deny("dns"): + assert _firewall_stack.get().evaluate(http_req) == Disposition.ALLOW + assert _firewall_stack.get().evaluate(dns_req) == Disposition.DENY + assert _firewall_stack.get().evaluate(sock_req) == Disposition.DENY + assert _firewall_stack.get().evaluate(dns_req) == Disposition.ALLOW + assert _firewall_stack.get().evaluate(sock_req) == Disposition.ALLOW class TestRestrict: @@ -321,23 +404,25 @@ def test_restrict_blocks_non_matching_protocols(self) -> None: http_req = NetworkFirewallRequest(protocol="http", host="example.com", port=80) redis_req = NetworkFirewallRequest(protocol="redis", host="localhost", port=6379) - with allow("http", "redis"): - # Both allowed before restrict - assert _firewall_stack.get().evaluate(http_req) == Disposition.ALLOW - assert _firewall_stack.get().evaluate(redis_req) == Disposition.ALLOW - with restrict("http"): - # Only HTTP passes the restrict ceiling - with allow("http"): - assert _firewall_stack.get().evaluate(http_req) == Disposition.ALLOW - assert _firewall_stack.get().evaluate(redis_req) == Disposition.DENY + with _with_active_verifier(): + with allow("http", "redis"): + # Both allowed before restrict + assert _firewall_stack.get().evaluate(http_req) == Disposition.ALLOW + assert _firewall_stack.get().evaluate(redis_req) == Disposition.ALLOW + with restrict("http"): + # Only HTTP passes the restrict ceiling + with allow("http"): + assert _firewall_stack.get().evaluate(http_req) == Disposition.ALLOW + assert _firewall_stack.get().evaluate(redis_req) == Disposition.DENY def test_restrict_resets_on_exit(self) -> None: from tripwire._guard import restrict - stack_before = _firewall_stack.get() - with restrict("http"): - pass - assert _firewall_stack.get() is stack_before + with _with_active_verifier(): + stack_before = _firewall_stack.get() + with restrict("http"): + pass + assert _firewall_stack.get() is stack_before def test_restrict_requires_at_least_one_rule(self) -> None: from tripwire._guard import restrict @@ -354,12 +439,13 @@ def test_restrict_multiple_protocols_ored(self) -> None: dns_req = NetworkFirewallRequest(protocol="dns", host="example.com", port=53) redis_req = NetworkFirewallRequest(protocol="redis", host="localhost", port=6379) - with restrict("http", "dns"): - with allow("http", "dns", "redis"): - assert _firewall_stack.get().evaluate(http_req) == Disposition.ALLOW - assert _firewall_stack.get().evaluate(dns_req) == Disposition.ALLOW - # Redis is not in the restrict set, so blocked by ceiling - assert _firewall_stack.get().evaluate(redis_req) == Disposition.DENY + with _with_active_verifier(): + with restrict("http", "dns"): + with allow("http", "dns", "redis"): + assert _firewall_stack.get().evaluate(http_req) == Disposition.ALLOW + assert _firewall_stack.get().evaluate(dns_req) == Disposition.ALLOW + # Redis is not in the restrict set, so blocked by ceiling + assert _firewall_stack.get().evaluate(redis_req) == Disposition.DENY def test_restrict_inner_allow_cannot_widen_ceiling(self) -> None: from tripwire._firewall_request import NetworkFirewallRequest @@ -367,10 +453,11 @@ def test_restrict_inner_allow_cannot_widen_ceiling(self) -> None: redis_req = NetworkFirewallRequest(protocol="redis", host="localhost", port=6379) - with restrict("http"): - # Inner allow("redis") should NOT widen past the HTTP ceiling - with allow("redis"): - assert _firewall_stack.get().evaluate(redis_req) == Disposition.DENY + with _with_active_verifier(): + with restrict("http"): + # Inner allow("redis") should NOT widen past the HTTP ceiling + with allow("redis"): + assert _firewall_stack.get().evaluate(redis_req) == Disposition.DENY class TestPublicExports: @@ -625,8 +712,10 @@ def test_firewall_allow_in_warn_mode_suppresses_warning(self) -> None: level_token = _guard_levels.set(GuardLevels(default="warn", overrides={})) guard_token = _guard_active.set(True) - # Push an ALLOW rule for dns onto the firewall stack - with allow("dns"): + # Push an ALLOW rule for dns onto the firewall stack (direct push: + # tripwire.allow(...) requires an active verifier per C9, but this + # test exercises guard-mode dispatch without a sandbox). + with _push_allow_direct("dns"): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") req = NetworkFirewallRequest(protocol="dns", host="example.com", port=53) @@ -720,7 +809,10 @@ def test_guard_active_in_allowlist_raises_guard_pass_through(self) -> None: guard_token = _guard_active.set(True) try: - with allow("dns"): + # Direct push: this test exercises guard-mode dispatch without + # a sandbox, and tripwire.allow(...) now requires an active + # verifier per C9. + with _push_allow_direct("dns"): req = NetworkFirewallRequest(protocol="dns", host="example.com", port=53) with pytest.raises(GuardPassThrough): get_verifier_or_raise("dns:getaddrinfo:example.com", firewall_request=req) @@ -774,7 +866,6 @@ def test_dns_getaddrinfo_guard_blocks_when_not_allowed(self) -> None: from tripwire._config import GuardLevels from tripwire._context import _guard_levels - from tripwire._guard import deny from tripwire._verifier import StrictVerifier from tripwire.plugins.dns_plugin import DnsPlugin @@ -786,7 +877,7 @@ def test_dns_getaddrinfo_guard_blocks_when_not_allowed(self) -> None: guard_token = _guard_active.set(True) try: # Explicitly deny dns to override project-level allow = ["dns:*"] - with deny("dns"): + with _push_deny_direct("dns"): with pytest.raises(GuardedCallError) as exc_info: socket.getaddrinfo("example.com", 80) assert exc_info.value.plugin_name == "dns" @@ -830,7 +921,6 @@ def test_dns_gethostbyname_guard_blocks_when_not_allowed(self) -> None: from tripwire._config import GuardLevels from tripwire._context import _guard_levels - from tripwire._guard import deny from tripwire._verifier import StrictVerifier from tripwire.plugins.dns_plugin import DnsPlugin @@ -842,7 +932,7 @@ def test_dns_gethostbyname_guard_blocks_when_not_allowed(self) -> None: guard_token = _guard_active.set(True) try: # Explicitly deny dns to override project-level allow = ["dns:*"] - with deny("dns"): + with _push_deny_direct("dns"): with pytest.raises(GuardedCallError) as exc_info: socket.gethostbyname("example.com") assert exc_info.value.plugin_name == "dns" @@ -887,7 +977,6 @@ def test_socket_connect_guard_blocks_when_not_allowed(self) -> None: from tripwire._config import GuardLevels from tripwire._context import _guard_levels - from tripwire._guard import deny from tripwire._verifier import StrictVerifier from tripwire.plugins.socket_plugin import _SOCKET_CLOSE_ORIGINAL, SocketPlugin @@ -899,7 +988,7 @@ def test_socket_connect_guard_blocks_when_not_allowed(self) -> None: guard_token = _guard_active.set(True) try: # Explicitly deny socket to override project-level allow = ["socket:*"] - with deny("socket"): + with _push_deny_direct("socket"): sock = socket_mod.socket(socket_mod.AF_INET, socket_mod.SOCK_STREAM) try: with pytest.raises(GuardedCallError) as exc_info: @@ -1347,7 +1436,6 @@ def test_guard_blocks_real_socket_connect_outside_sandbox(self) -> None: from tripwire._config import GuardLevels from tripwire._context import _guard_levels - from tripwire._guard import deny from tripwire._verifier import StrictVerifier from tripwire.plugins.socket_plugin import _SOCKET_CLOSE_ORIGINAL, SocketPlugin @@ -1357,7 +1445,7 @@ def test_guard_blocks_real_socket_connect_outside_sandbox(self) -> None: level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) try: # Explicitly deny socket to override project-level allow = ["socket:*"] - with deny("socket"): + with _push_deny_direct("socket"): sock = socket_mod.socket(socket_mod.AF_INET, socket_mod.SOCK_STREAM) try: with pytest.raises(GuardedCallError) as exc_info: @@ -1448,7 +1536,7 @@ def test_allow_context_manager_adds_to_marker_rules(self) -> None: assert stack_mark.evaluate(dns_req) == Disposition.ALLOW assert stack_mark.evaluate(redis_req) == Disposition.DENY - with allow("redis"): + with _with_active_verifier(), allow("redis"): stack_both = _firewall_stack.get() assert stack_both.evaluate(dns_req) == Disposition.ALLOW assert stack_both.evaluate(redis_req) == Disposition.ALLOW @@ -1503,7 +1591,6 @@ def test_guard_blocks_dns_lookup_outside_sandbox(self) -> None: from tripwire._config import GuardLevels from tripwire._context import _guard_levels - from tripwire._guard import deny from tripwire._verifier import StrictVerifier from tripwire.plugins.dns_plugin import DnsPlugin @@ -1513,7 +1600,7 @@ def test_guard_blocks_dns_lookup_outside_sandbox(self) -> None: level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) try: # Explicitly deny dns to override project-level allow = ["dns:*"] - with deny("dns"): + with _push_deny_direct("dns"): with pytest.raises(GuardedCallError) as exc_info: socket_mod.getaddrinfo("example.com", 80) assert exc_info.value.plugin_name == "dns" @@ -1622,11 +1709,12 @@ def test_sandbox_takes_precedence_over_firewall_allow(self) -> None: "localhost", returns=[(2, 1, 6, "", ("127.0.0.1", 80))], ) - with allow("dns"): - with v.sandbox(): - # Even with allow("dns"), sandbox intercepts the call - result = socket.getaddrinfo("localhost", 80) - assert result == [(2, 1, 6, "", ("127.0.0.1", 80))] + with _with_active_verifier(): + with allow("dns"): + with v.sandbox(): + # Even with allow("dns"), sandbox intercepts the call + result = socket.getaddrinfo("localhost", 80) + assert result == [(2, 1, 6, "", ("127.0.0.1", 80))] # The interaction IS recorded because sandbox takes precedence v.assert_interaction( @@ -1648,7 +1736,6 @@ def test_guarded_call_error_message_has_actionable_guidance(self) -> None: from tripwire._config import GuardLevels from tripwire._context import _guard_levels - from tripwire._guard import deny from tripwire._verifier import StrictVerifier from tripwire.plugins.dns_plugin import DnsPlugin @@ -1658,7 +1745,7 @@ def test_guarded_call_error_message_has_actionable_guidance(self) -> None: level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) try: # Explicitly deny dns to override project-level allow = ["dns:*"] - with deny("dns"): + with _push_deny_direct("dns"): with pytest.raises(GuardedCallError) as exc_info: socket_mod.getaddrinfo("example.com", 80) msg = str(exc_info.value) @@ -1973,7 +2060,6 @@ def test_close_no_guarded_call_error_even_with_deny(self) -> None: from tripwire._config import GuardLevels from tripwire._context import _guard_levels - from tripwire._guard import deny from tripwire._verifier import StrictVerifier from tripwire.plugins.socket_plugin import SocketPlugin @@ -1984,7 +2070,7 @@ def test_close_no_guarded_call_error_even_with_deny(self) -> None: level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) guard_token = _guard_active.set(True) try: - with deny("socket"): + with _push_deny_direct("socket"): sock = socket_mod.socket(socket_mod.AF_INET, socket_mod.SOCK_STREAM) # close passes through even with deny("socket") active sock.close() @@ -2005,7 +2091,6 @@ def test_send_no_guarded_call_error_even_with_deny(self) -> None: from tripwire._config import GuardLevels from tripwire._context import _guard_levels - from tripwire._guard import deny from tripwire._verifier import StrictVerifier from tripwire.plugins.socket_plugin import _SOCKET_CLOSE_ORIGINAL, SocketPlugin @@ -2016,7 +2101,7 @@ def test_send_no_guarded_call_error_even_with_deny(self) -> None: level_token = _guard_levels.set(GuardLevels(default="error", overrides={})) guard_token = _guard_active.set(True) try: - with deny("socket"): + with _push_deny_direct("socket"): sock = socket_mod.socket(socket_mod.AF_INET, socket_mod.SOCK_STREAM) try: # Real send raises OSError, not GuardedCallError diff --git a/tests/unit/test_guard_new.py b/tests/unit/test_guard_new.py index 33e4ffb..b0c316f 100644 --- a/tests/unit/test_guard_new.py +++ b/tests/unit/test_guard_new.py @@ -1,11 +1,40 @@ -"""Tests for tripwire._guard -- new allow/deny/restrict context managers.""" +"""Tests for tripwire._guard -- new allow/deny/restrict context managers. +C9 added a check that `tripwire.allow/deny/restrict` raise outside any +active sandbox. The autouse `_set_active_verifier` fixture below sets +`_active_verifier` to a sentinel for each test in this module so that +the firewall stack mechanics can be exercised directly without standing +up a full sandbox. +""" + +from collections.abc import Generator + +import pytest + +from tripwire._context import _active_verifier from tripwire._firewall import Disposition, get_firewall_stack from tripwire._firewall_request import HttpFirewallRequest, RedisFirewallRequest from tripwire._guard import allow, deny, restrict from tripwire._match import M +@pytest.fixture(autouse=True) +def _set_active_verifier() -> Generator[None, None, None]: + """Satisfy C9 (`_active_verifier` must be set) without a real sandbox.""" + from tripwire._verifier import StrictVerifier + + StrictVerifier._suppress_direct_warning = True + try: + sentinel = StrictVerifier() + finally: + StrictVerifier._suppress_direct_warning = False + token = _active_verifier.set(sentinel) + try: + yield + finally: + _active_verifier.reset(token) + + class TestAllow: def test_allow_string_pushes_protocol_rule(self) -> None: with allow("http"): @@ -29,7 +58,6 @@ def test_allow_restores_stack(self) -> None: assert len(before.frames) == len(after.frames) def test_allow_empty_raises(self) -> None: - import pytest with pytest.raises(ValueError, match="at least one rule"): with allow(): pass diff --git a/tests/unit/test_http_plugin.py b/tests/unit/test_http_plugin.py index 06f2482..7f4351e 100644 --- a/tests/unit/test_http_plugin.py +++ b/tests/unit/test_http_plugin.py @@ -379,7 +379,7 @@ def test_httpx_interceptor_raises_unmocked_when_no_config() -> None: # ESCAPE: Nothing reasonable -- type check plus attribute check. def test_requests_interceptor_raises_unmocked_when_no_config() -> None: v, p = _make_verifier_with_plugin() - with tripwire.allow("dns"), v.sandbox(): + with v.sandbox(), tripwire.allow("dns"): with pytest.raises(UnmockedInteractionError) as exc_info: requests.get("https://api.example.com/no-mock") assert exc_info.value.source_id == "http:request" @@ -439,7 +439,7 @@ def test_requests_configured_response_returned() -> None: v, p = _make_verifier_with_plugin() p.mock_response("GET", "https://api.example.com/items", json={"items": [1, 2, 3]}) - with tripwire.allow("dns"), v.sandbox(): + with v.sandbox(), tripwire.allow("dns"): response = requests.get("https://api.example.com/items") assert response.status_code == 200 @@ -456,7 +456,7 @@ def test_requests_configured_response_custom_status() -> None: v, p = _make_verifier_with_plugin() p.mock_response("GET", "https://api.example.com/missing", status=404) - with tripwire.allow("dns"), v.sandbox(): + with v.sandbox(), tripwire.allow("dns"): response = requests.get("https://api.example.com/missing") assert response.status_code == 404 @@ -498,7 +498,7 @@ def test_interaction_recorded_after_requests_request() -> None: v, p = _make_verifier_with_plugin() p.mock_response("POST", "https://api.example.com/submit", json={"ok": True}) - with tripwire.allow("dns"), v.sandbox(): + with v.sandbox(), tripwire.allow("dns"): requests.post("https://api.example.com/submit", json={"data": 1}) interactions = v._timeline.all_unasserted() @@ -961,7 +961,7 @@ def test_requests_interceptor_records_str_body() -> None: v, p = _make_verifier_with_plugin() p.mock_response("POST", "https://api.example.com/str-body", json={"ok": True}) - with tripwire.allow("dns"), v.sandbox(): + with v.sandbox(), tripwire.allow("dns"): # Sending a string body directly via prepared request req = requests.Request("POST", "https://api.example.com/str-body", data="raw string") prepared = req.prepare() @@ -2044,7 +2044,7 @@ def test_requests_handler_raises_error_config() -> None: exc = requests.ConnectionError("DNS resolution failed") p.mock_error("GET", "https://api.example.com/data", raises=exc) - with tripwire.allow("dns"), v.sandbox(): + with v.sandbox(), tripwire.allow("dns"): with pytest.raises(requests.ConnectionError, match="DNS resolution failed"): requests.get("https://api.example.com/data") From 33aab91229847db71922b4dd9dafbe3326f2b5bd Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:05:46 -0500 Subject: [PATCH 13/33] Test contextvars across asyncio + threadpool boundaries Adds tests verifying `with tripwire:` state propagates correctly through asyncio.to_thread, asyncio.create_task, loop.run_in_executor, asyncio.gather, and concurrent.futures.ThreadPoolExecutor.submit. Documents in the README that ProcessPoolExecutor does not propagate `with tripwire:` state (separate-process boundary). To use tripwire across process pools, enter `with tripwire:` inside the worker function itself. --- CHANGELOG.md | 2 + README.md | 4 + .../test_contextvar_propagation.py | 269 ++++++++++++++++++ 3 files changed, 275 insertions(+) create mode 100644 tests/integration/test_contextvar_propagation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7756e01..4b618b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `GuardedCallError` message reframed for clarity: states "OUTSIDE any `with tripwire:` block", names the plugin and method, includes the user call site (`file:lineno`), and lists the two fixes inline. Existing fix sections (`@pytest.mark.allow`, pyproject, sandbox-with-mock) retained. - README: added a "Picking the right guard default" section covering new projects, legacy-migration suites, and mixed-CI per-protocol overrides. - `tripwire.allow(...)` and `tripwire.restrict(...)` now raise `TripwireError` when called outside any active sandbox, with a message pointing the user at `[tool.tripwire.firewall]` for module-scoped rules. +- README: documented that `ProcessPoolExecutor` does not propagate `with tripwire:` state (separate-process boundary). ### Added - `ConfigMigrationError` (subclass of `TripwireError`) raised when `[tool.bigfoot]` is present in pyproject.toml during config load. @@ -26,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `@pytest.mark.guard("error" | "warn" | "off" | {default: ..., overrides: {...}})` per-test override of the project guard levels. Set token / yield / reset token pattern, scoped to the test's lifetime. - Strict TOML validation: unknown keys under `[tool.tripwire]` and unknown plugin sub-tables raise `TripwireConfigError` with typo suggestions (via `difflib.get_close_matches`). - Strict validation extended to `[tool.tripwire.guard]` per-protocol keys: every override key must match a registered plugin name; unknown keys produce typo suggestions. +- Tests covering contextvar propagation of `with tripwire:` state across `asyncio.to_thread`, `asyncio.create_task`, `loop.run_in_executor`, `asyncio.gather`, and `concurrent.futures.ThreadPoolExecutor.submit`. ### Fixed - `async_subprocess_plugin` type annotations corrected: the `cast(_AsyncFakeProcess, await _ORIGINAL_CREATE_SUBPROCESS_EXEC(...))` claim was a lie (the runtime returned a real `asyncio.subprocess.Process`). The cast is removed and the return-type annotation widened to `_AsyncFakeProcess | asyncio.subprocess.Process` for both `_fake_create_subprocess_exec` and `_fake_create_subprocess_shell`. Runtime behavior unchanged; static types now match reality. diff --git a/README.md b/README.md index d4e1c3b..be7b57f 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,10 @@ The error output includes every field with its actual value, so you can usually Since tripwire uses a module-level API, there are no fixtures to set up or inject. You just import it. +### Concurrency boundaries + +Sandbox state is carried via Python ContextVars, so it propagates through `asyncio.to_thread`, `asyncio.create_task`, `loop.run_in_executor`, `asyncio.gather`, and `concurrent.futures.ThreadPoolExecutor.submit` — code dispatched into worker threads or tasks still hits the active verifier. `ProcessPoolExecutor` does not propagate `with tripwire:` state because each worker is a separate Python process with its own (empty) ContextVar set. To use tripwire across process pools, enter `with tripwire:` inside the worker function itself. + ## Coming from unittest.mock ### Concepts mapping diff --git a/tests/integration/test_contextvar_propagation.py b/tests/integration/test_contextvar_propagation.py new file mode 100644 index 0000000..8533160 --- /dev/null +++ b/tests/integration/test_contextvar_propagation.py @@ -0,0 +1,269 @@ +"""C10 integration tests: contextvar propagation across asyncio + threadpool boundaries. + +These tests verify that `with tripwire:` (sandbox) state — carried via +the public ContextVars `_active_verifier` and `_current_sandbox_id` — is +propagated through the standard concurrency primitives so that a worker +thread / task can dispatch through `get_verifier_or_raise(...)` and +reach the active verifier instead of falling through to +`SandboxNotActiveError`. + +Boundaries covered: + +- T1: ``asyncio.to_thread`` +- T2: ``asyncio.create_task`` +- T3: ``loop.run_in_executor`` +- T4: ``asyncio.gather`` +- T5: ``concurrent.futures.ThreadPoolExecutor.submit`` +- T6: ``concurrent.futures.ProcessPoolExecutor`` (negative test: + documented separate-process boundary) + +Strategy: each positive test enters `verifier.sandbox()`, schedules a +worker that calls `get_verifier_or_raise(source_id="test:c10")`, and +asserts the worker received the same `StrictVerifier` instance the +parent set. If the ContextVars did not propagate, the worker call +would raise `SandboxNotActiveError` (or `PostSandboxInteractionError` +if the boundary captured a stale id). Either failure mode fails the +test. + +For T6 (ProcessPoolExecutor) the assertion is the *opposite*: each +worker is a separate Python process so contextvars cannot cross. The +test asserts one of the two documented boundary outcomes: + +(a) the worker raised because tripwire state was not propagated + (no `_active_verifier`, so the dispatch sees no sandbox), OR +(b) `concurrent.futures.process` raised `PicklingError` (a subclass + of `pickle.PickleError`) at submit time because some part of the + payload — for example a closure capturing the parent's verifier + or a non-picklable intercept hook — could not be marshalled + across the process boundary. + +Either outcome confirms the documented boundary; any other outcome +(e.g. the worker silently succeeded as if the sandbox had crossed) +fails the test. +""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +import pickle +from typing import Any + +import pytest + +from tripwire import StrictVerifier +from tripwire._context import _active_verifier, get_verifier_or_raise + +pytestmark = pytest.mark.integration + + +# A neutral source_id that no real plugin owns. Reaches Branch 5 +# (SandboxNotActiveError) when no verifier is in scope; otherwise the +# active verifier is returned. +_C10_SOURCE_ID = "test:c10_propagation" + + +def _check_active_verifier_in_worker() -> StrictVerifier | None: + """Return the verifier that the dispatch resolves for this context. + + Runs `get_verifier_or_raise(...)` and returns the resolved verifier + (or re-raises if dispatch concludes there is no active sandbox). + The worker context is whatever the boundary primitive provides. + """ + return get_verifier_or_raise(_C10_SOURCE_ID) + + +# --------------------------------------------------------------------------- +# C10-T1: asyncio.to_thread propagates `with tripwire:` state. +# --------------------------------------------------------------------------- + + +def test_asyncio_to_thread_propagates() -> None: + """`asyncio.to_thread(f)` runs `f` in the default executor's thread + pool, but the active verifier ContextVar must propagate so the + worker resolves the same verifier the parent set.""" + + async def main() -> tuple[StrictVerifier, StrictVerifier]: + v = StrictVerifier() + async with v.sandbox(): + parent_seen = _active_verifier.get() + worker_seen = await asyncio.to_thread( + _check_active_verifier_in_worker + ) + assert parent_seen is v + assert worker_seen is not None + return v, worker_seen + + v, worker_seen = asyncio.run(main()) + assert worker_seen is v + + +# --------------------------------------------------------------------------- +# C10-T2: asyncio.create_task propagates `with tripwire:` state. +# --------------------------------------------------------------------------- + + +def test_asyncio_create_task_propagates() -> None: + """A task spawned with `asyncio.create_task` inside the sandbox + captures the active ContextVar set; the dispatch inside the + coroutine resolves the parent verifier.""" + + async def main() -> tuple[StrictVerifier, StrictVerifier]: + v = StrictVerifier() + async with v.sandbox(): + + async def worker() -> StrictVerifier: + return _check_active_verifier_in_worker() # type: ignore[return-value] + + task = asyncio.create_task(worker()) + worker_seen = await task + return v, worker_seen + + v, worker_seen = asyncio.run(main()) + assert worker_seen is v + + +# --------------------------------------------------------------------------- +# C10-T3: loop.run_in_executor propagates `with tripwire:` state. +# --------------------------------------------------------------------------- + + +def test_run_in_executor_propagates() -> None: + """`loop.run_in_executor(None, f)` schedules `f` on the default + executor; the active verifier ContextVar must propagate.""" + + async def main() -> tuple[StrictVerifier, StrictVerifier]: + v = StrictVerifier() + async with v.sandbox(): + loop = asyncio.get_running_loop() + worker_seen = await loop.run_in_executor( + None, _check_active_verifier_in_worker + ) + assert worker_seen is not None + return v, worker_seen + + v, worker_seen = asyncio.run(main()) + assert worker_seen is v + + +# --------------------------------------------------------------------------- +# C10-T4: asyncio.gather propagates `with tripwire:` state to each child. +# --------------------------------------------------------------------------- + + +def test_asyncio_gather_propagates() -> None: + """`asyncio.gather(...)` schedules each coroutine as a task; each + child task must inherit the ContextVar set so both dispatch calls + resolve the parent verifier.""" + + async def main() -> tuple[StrictVerifier, StrictVerifier, StrictVerifier]: + v = StrictVerifier() + async with v.sandbox(): + + async def call_a() -> StrictVerifier: + return _check_active_verifier_in_worker() # type: ignore[return-value] + + async def call_b() -> StrictVerifier: + return _check_active_verifier_in_worker() # type: ignore[return-value] + + results = await asyncio.gather(call_a(), call_b()) + a, b = results + return v, a, b + + v, a, b = asyncio.run(main()) + assert a is v + assert b is v + + +# --------------------------------------------------------------------------- +# C10-T5: concurrent.futures.ThreadPoolExecutor.submit propagates state. +# --------------------------------------------------------------------------- + + +def test_threadpool_submit_propagates() -> None: + """A bare-thread-pool `submit(f).result()` from inside the sandbox + must propagate the ContextVar set so the worker thread resolves the + parent verifier.""" + v = StrictVerifier() + with v.sandbox(): + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(_check_active_verifier_in_worker) + worker_seen = future.result(timeout=5) + + assert worker_seen is v + + +# --------------------------------------------------------------------------- +# C10-T6: ProcessPoolExecutor does NOT propagate sandbox state. +# +# The documented boundary: each worker is a separate Python process +# with its own (empty) ContextVar set. Acceptable outcomes: +# +# (a) submit() succeeds, the worker runs, and dispatch raises +# because no verifier is in its context, OR +# (b) submit() raises pickle.PickleError because some part of the +# payload (the verifier object, an intercept hook, or anything +# else captured) cannot be pickled across the process boundary. +# +# Anything else — in particular, the parent verifier somehow appearing +# in the child process — would mean the documented boundary has +# shifted, and the test fails. +# --------------------------------------------------------------------------- + + +def _processpool_worker(_payload: str) -> Any: + """Top-level picklable worker for ProcessPoolExecutor. + + Runs in a fresh Python interpreter where no `with tripwire:` state + exists. Calling `get_verifier_or_raise(...)` here must raise the + standard "no sandbox" error. Returns the resolved verifier on the + *unexpected* success path so the test can detect a boundary shift. + """ + return get_verifier_or_raise("test:c10_processpool") + + +def test_processpool_does_NOT_propagate() -> None: # noqa: N802 + """ProcessPoolExecutor submission either fails to pickle the payload + or executes the worker in a fresh process where no `with tripwire:` + state exists. Either is the documented boundary.""" + v = StrictVerifier() + + submit_error: BaseException | None = None + worker_error: BaseException | None = None + worker_result: Any = None + + with v.sandbox(): + with concurrent.futures.ProcessPoolExecutor(max_workers=1) as pool: + try: + future = pool.submit(_processpool_worker, "payload") + except (pickle.PickleError, TypeError, AttributeError) as exc: + # `concurrent.futures.process` raises PicklingError (a + # pickle.PickleError) at submit() if the payload is + # unpicklable. TypeError / AttributeError are the other + # observed shapes when pickling intercept hooks. + submit_error = exc + else: + try: + worker_result = future.result(timeout=30) + except BaseException as exc: # noqa: BLE001 + worker_error = exc + + if submit_error is not None: + # Outcome (b): submit failed to pickle. Documented boundary. + assert isinstance( + submit_error, (pickle.PickleError, TypeError, AttributeError) + ) + assert worker_result is None + assert worker_error is None + return + + # Outcome (a): submit succeeded. The worker must have raised + # because no sandbox state was inherited. The exception comes back + # from .result() either as the original class or wrapped. + assert worker_error is not None, ( + "ProcessPoolExecutor unexpectedly inherited `with tripwire:` " + f"state: worker returned {worker_result!r}. The documented " + "separate-process boundary has shifted; update README and " + "CHANGELOG." + ) + assert worker_result is None From c7b3c58a8b33a5869ecded41dc9aa8010d4fdcf4 Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:29:19 -0500 Subject: [PATCH 14/33] Fix audit findings: green-mirage test, missing deny in CHANGELOG, em-dash in README - tests/unit/test_pedagogical_messages.py: C5-T2 used substring checks on 'subprocess' and 'run' against a message that already contains source_id 'subprocess:run'; both substrings were tautologically present. Switched to sentinel plugin/method names and assert the human-prose 'PLUGIN.METHOD' rendering joined. - CHANGELOG.md line 17: C9 added the active-sandbox check to tripwire.deny() in addition to allow() and restrict(); the bullet only mentioned allow and restrict. - README.md line 174: replaced em-dash in the C10 'Concurrency boundaries' subsection with a plain comma (project disallows em-dashes in user-facing prose). --- CHANGELOG.md | 2 +- README.md | 2 +- tests/unit/test_pedagogical_messages.py | 17 ++++++++++++----- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b618b8..b94c383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Breaking:** Removed `BasePlugin.supports_guard` and the `is_guard_eligible()` registry helper. Replaced by `passthrough_safe`. The 6 plugins that had `supports_guard=False` (celery, crypto, file_io, jwt, logging, native) become `passthrough_safe=True` because their interceptors raise `SandboxNotActiveError` outside sandbox (which is safe). MockPlugin and StateMachinePlugin (passive recorders) also become `passthrough_safe=True`. The remaining real-IO plugins become `passthrough_safe=False`. - `GuardedCallError` message reframed for clarity: states "OUTSIDE any `with tripwire:` block", names the plugin and method, includes the user call site (`file:lineno`), and lists the two fixes inline. Existing fix sections (`@pytest.mark.allow`, pyproject, sandbox-with-mock) retained. - README: added a "Picking the right guard default" section covering new projects, legacy-migration suites, and mixed-CI per-protocol overrides. -- `tripwire.allow(...)` and `tripwire.restrict(...)` now raise `TripwireError` when called outside any active sandbox, with a message pointing the user at `[tool.tripwire.firewall]` for module-scoped rules. +- `tripwire.allow(...)`, `tripwire.deny(...)`, and `tripwire.restrict(...)` now raise `TripwireError` when called outside any active sandbox, with a message pointing the user at `[tool.tripwire.firewall]` for module-scoped rules. - README: documented that `ProcessPoolExecutor` does not propagate `with tripwire:` state (separate-process boundary). ### Added diff --git a/README.md b/README.md index be7b57f..902d83f 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ Since tripwire uses a module-level API, there are no fixtures to set up or injec ### Concurrency boundaries -Sandbox state is carried via Python ContextVars, so it propagates through `asyncio.to_thread`, `asyncio.create_task`, `loop.run_in_executor`, `asyncio.gather`, and `concurrent.futures.ThreadPoolExecutor.submit` — code dispatched into worker threads or tasks still hits the active verifier. `ProcessPoolExecutor` does not propagate `with tripwire:` state because each worker is a separate Python process with its own (empty) ContextVar set. To use tripwire across process pools, enter `with tripwire:` inside the worker function itself. +Sandbox state is carried via Python ContextVars, so it propagates through `asyncio.to_thread`, `asyncio.create_task`, `loop.run_in_executor`, `asyncio.gather`, and `concurrent.futures.ThreadPoolExecutor.submit`, so code dispatched into worker threads or tasks still hits the active verifier. `ProcessPoolExecutor` does not propagate `with tripwire:` state because each worker is a separate Python process with its own (empty) ContextVar set. To use tripwire across process pools, enter `with tripwire:` inside the worker function itself. ## Coming from unittest.mock diff --git a/tests/unit/test_pedagogical_messages.py b/tests/unit/test_pedagogical_messages.py index b44c3db..4006bfb 100644 --- a/tests/unit/test_pedagogical_messages.py +++ b/tests/unit/test_pedagogical_messages.py @@ -35,15 +35,22 @@ def test_message_contains_outside_framing() -> None: def test_message_names_plugin_and_method() -> None: - """C5-T2: message contains plugin name and method-being-called.""" + """C5-T2: message contains plugin name and method-being-called. + + Uses sentinel values that would not naturally appear elsewhere in the + rendered message (the literal ``source_id`` echo, the ``Attempted:`` + block, etc.), so the assertion catches a regression that drops the + human-prose ``.`` rendering. + """ err = _build_err_with_frame( ("/path/to/test.py", 42, "test_x"), - plugin="subprocess", - method="run", + plugin="ZPLUG_SENTINEL", + method="ZMETH_SENTINEL", ) msg = str(err) - assert "subprocess" in msg - assert "run" in msg + assert "ZPLUG_SENTINEL.ZMETH_SENTINEL" in msg, ( + "expected plugin.method joined in human prose; got:\n" + msg + ) def test_message_includes_user_call_site() -> None: From 25d4750eca8b561c13401199407635f5cb576467 Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:33:21 -0500 Subject: [PATCH 15/33] Use compat shim for tomllib in test_guard_levels tomllib is stdlib only on Python 3.11+. The test_mixed_scalar_and_table_rejected_by_tomllib test had a bare import inside the function body that broke collection on Python 3.10 (both ubuntu and windows). Switch to the existing tripwire._compat.tomllib shim that the rest of the suite uses, hoisted to a module-level import. --- tests/unit/test_guard_levels.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_guard_levels.py b/tests/unit/test_guard_levels.py index cfbf5f0..fd52d7f 100644 --- a/tests/unit/test_guard_levels.py +++ b/tests/unit/test_guard_levels.py @@ -9,6 +9,8 @@ import pytest +from tripwire._compat import tomllib + def test_scalar_form_parses() -> None: """C3-T1: scalar `guard = "warn"` parses to GuardLevels(default="warn", overrides={}). @@ -111,8 +113,6 @@ def test_mixed_scalar_and_table_rejected_by_tomllib() -> None: ESCAPE: None: this test pins the platform contract that the design relies on. If tomllib changes behavior, we know immediately. """ - import tomllib - source = ( '[tool.tripwire]\n' 'guard = "warn"\n' From ec6b73f17d028c64ccbbaf7cb59d43ef2a86e51d Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:33:32 -0500 Subject: [PATCH 16/33] Force spawn context in processpool propagation test On Python 3.14, multiprocessing's default start method on Linux is 'forkserver'. Forkserver setup creates a Unix-domain listener socket *in the parent process* (multiprocessing/forkserver.py: socket.socket(AF_UNIX)). When that listener exits its with-block, socket.close() fires, which the SocketPlugin intercepts inside the parent's active sandbox, raising UnmockedInteractionError before the worker process is even spawned. The test only cares about the documented separate-process boundary. Forcing 'spawn' bypasses the parent-side socket dance and makes the contract explicit: every worker is a fresh interpreter that inherits no tripwire state. macOS already defaults to spawn so behavior there is unchanged; Linux 3.14 now matches. --- tests/integration/test_contextvar_propagation.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_contextvar_propagation.py b/tests/integration/test_contextvar_propagation.py index 8533160..677224a 100644 --- a/tests/integration/test_contextvar_propagation.py +++ b/tests/integration/test_contextvar_propagation.py @@ -46,6 +46,7 @@ import asyncio import concurrent.futures +import multiprocessing import pickle from typing import Any @@ -232,8 +233,17 @@ def test_processpool_does_NOT_propagate() -> None: # noqa: N802 worker_error: BaseException | None = None worker_result: Any = None + # Force the "spawn" start method. On Linux the multiprocessing default + # is "forkserver" (and on Python 3.14 the forkserver setup creates a + # Unix-domain socket *in the parent* via socket.socket(AF_UNIX), which + # tripwire's socket plugin intercepts on close()). Spawning bypasses + # that parent-side socket dance and makes the boundary explicit: every + # worker is a fresh interpreter that inherits no tripwire state. + spawn_ctx = multiprocessing.get_context("spawn") with v.sandbox(): - with concurrent.futures.ProcessPoolExecutor(max_workers=1) as pool: + with concurrent.futures.ProcessPoolExecutor( + max_workers=1, mp_context=spawn_ctx + ) as pool: try: future = pool.submit(_processpool_worker, "payload") except (pickle.PickleError, TypeError, AttributeError) as exc: From a77066ef93e01934b286ff1ed7e9c17ccac4c9b6 Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:33:43 -0500 Subject: [PATCH 17/33] Use jwt instead of crypto in guard dispatch tests The free-threaded build (3.14t) has no cryptography wheel, so CryptoPlugin is not registered there. Guard dispatch in get_verifier_or_raise routes through lookup_plugin_class_by_name(); when the plugin is absent the lookup returns None and Branch 3 (guard active) is skipped, sending the call to Branch 5 (SandboxNotActiveError). That broke seven tests on 3.14t that used 'crypto:sign' purely as a stand-in for 'any passthrough_safe=True plugin'. Replace the stand-in with 'jwt' (JwtPlugin is also passthrough_safe and pyjwt IS in the all-ft extra). For test_dns_strict_http_warn, which needs two distinct plugins, use 'logging' (always-available, safe) as the per-protocol 'error' override target and keep 'jwt' for the default 'warn' case. Test intent is preserved across all Python builds. --- tests/integration/test_per_protocol_guard.py | 33 +++++++++++-------- .../test_unsafe_passthrough_warn.py | 13 +++++--- tests/unit/test_guard.py | 27 ++++++++------- 3 files changed, 42 insertions(+), 31 deletions(-) diff --git a/tests/integration/test_per_protocol_guard.py b/tests/integration/test_per_protocol_guard.py index b6a5388..123a2b9 100644 --- a/tests/integration/test_per_protocol_guard.py +++ b/tests/integration/test_per_protocol_guard.py @@ -27,53 +27,58 @@ def test_dns_strict_http_warn() -> None: - """C3-T4: guard default = "warn" but `crypto = "error"` per-protocol. + """C3-T4: guard default = "warn" but `logging = "error"` per-protocol. - A DENY decision against a `crypto` source raises GuardedCallError + A DENY decision against a `logging` source raises GuardedCallError (the per-protocol "error" override). A DENY decision against a different protocol that is `passthrough_safe=True` (jwt) under the default level "warn" emits GuardedCallWarning and raises GuardPassThrough. + The override target uses `logging` (always available) and the + default-warn target uses `jwt` (in `[all-ft]`) so the test runs on + the free-threaded 3.14t build where the `cryptography` wheel is + absent. + The test isolates dispatch from the project's TOML firewall rules by pushing an empty firewall stack frame so neither protocol is allow-listed by the surrounding pyproject.toml. ESCAPE: test_dns_strict_http_warn CLAIM: Per-protocol overrides take precedence over the default; - crypto escalates from "warn" to "error" while jwt uses the + logging escalates from "warn" to "error" while jwt uses the default "warn" path. - PATH: get_verifier_or_raise -> Branch 3b -> overrides.get("crypto") + PATH: get_verifier_or_raise -> Branch 3b -> overrides.get("logging") returns "error" -> raise GuardedCallError; for "jwt" the override is absent so default "warn" applies and the safe warn path runs. - CHECK: GuardedCallError is raised for the crypto:sign source_id; + CHECK: GuardedCallError is raised for the logging:emit source_id; for jwt:encode, GuardedCallWarning is emitted and GuardPassThrough is raised. MUTATION: If overrides are ignored (i.e., dispatch always reads - guard_levels.default), crypto would warn instead of + guard_levels.default), logging would warn instead of raising and the test would fail. If overrides are read but the key extraction is wrong (e.g., `source_id` rather than `plugin_name`), the lookup would miss and - crypto would fall to default "warn". + logging would fall to default "warn". ESCAPE: A bug that hardcodes "error" for every protocol would fail the jwt:encode branch (which expects warn behavior). """ levels_token = _guard_levels.set( - GuardLevels(default="warn", overrides={"crypto": "error"}) + GuardLevels(default="warn", overrides={"logging": "error"}) ) guard_token = _guard_active.set(True) try: - # crypto: per-protocol "error" override -> GuardedCallError. - crypto_req = NetworkFirewallRequest( - protocol="crypto", host="local", port=0 + # logging: per-protocol "error" override -> GuardedCallError. + logging_req = NetworkFirewallRequest( + protocol="logging", host="local", port=0 ) with pytest.raises(GuardedCallError) as exc_info: get_verifier_or_raise( - "crypto:sign", firewall_request=crypto_req + "logging:emit", firewall_request=logging_req ) - assert exc_info.value.plugin_name == "crypto" - assert exc_info.value.source_id == "crypto:sign" + assert exc_info.value.plugin_name == "logging" + assert exc_info.value.source_id == "logging:emit" # jwt: no override -> default "warn" applies; JwtPlugin is # passthrough_safe=True so the warn-safe branch runs. diff --git a/tests/integration/test_unsafe_passthrough_warn.py b/tests/integration/test_unsafe_passthrough_warn.py index 0d10f3f..f0b79da 100644 --- a/tests/integration/test_unsafe_passthrough_warn.py +++ b/tests/integration/test_unsafe_passthrough_warn.py @@ -75,10 +75,13 @@ def test_warn_with_safe_plugin_passthroughs() -> None: emits GuardedCallWarning and raises GuardPassThrough (existing behavior preserved for safe plugins). - Uses the 'crypto' plugin source_id - CryptoPlugin is + Uses the 'jwt' plugin source_id - JwtPlugin is passthrough_safe=True (it has no real I/O and its interceptors raise SandboxNotActiveError outside sandboxes, which is a safe failure - mode rather than a real-world side effect). + mode rather than a real-world side effect). 'jwt' is chosen over + 'crypto' because the 'cryptography' wheel is unavailable on the + free-threaded (3.14t) build, so CryptoPlugin would not be importable + in the registry there and dispatch would fall through to Branch 5. ESCAPE: test_warn_with_safe_plugin_passthroughs CLAIM: Under guard='warn', a safe-passthrough plugin still warns + @@ -93,17 +96,17 @@ def test_warn_with_safe_plugin_passthroughs() -> None: ESCAPE: A bug that no-ops the warn path entirely (no warning) would pass GuardPassThrough but fail the warning-count assert. """ - req = NetworkFirewallRequest(protocol="crypto", host="local", port=0) + req = NetworkFirewallRequest(protocol="jwt", host="local", port=0) levels_token = _guard_levels.set(GuardLevels(default="warn", overrides={})) guard_token = _guard_active.set(True) try: with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") with pytest.raises(GuardPassThrough): - get_verifier_or_raise("crypto:sign", firewall_request=req) + get_verifier_or_raise("jwt:encode", firewall_request=req) warning_msgs = [w for w in caught if issubclass(w.category, GuardedCallWarning)] assert len(warning_msgs) == 1 - assert "'crypto:sign'" in str(warning_msgs[0].message) + assert "'jwt:encode'" in str(warning_msgs[0].message) finally: _guard_active.reset(guard_token) _guard_levels.reset(levels_token) diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index fc050eb..21600be 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -599,12 +599,15 @@ def test_warn_mode_emits_warning(self) -> None: try: with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - # Use crypto (passthrough_safe=True) so the warn-unsafe + # Use jwt (passthrough_safe=True) so the warn-unsafe # gate does not fire; project-level firewall allow rules # only cover dns/socket so this DENY hits the warn path. - req = NetworkFirewallRequest(protocol="crypto", host="local", port=0) + # 'jwt' is preferred over 'crypto' so this test runs on + # the 3.14t free-threaded build, where the cryptography + # wheel is unavailable. + req = NetworkFirewallRequest(protocol="jwt", host="local", port=0) with pytest.raises(GuardPassThrough): - get_verifier_or_raise("crypto:sign", firewall_request=req) + get_verifier_or_raise("jwt:encode", firewall_request=req) assert len(w) == 1 assert issubclass(w[0].category, GuardedCallWarning) finally: @@ -622,9 +625,9 @@ def test_warn_mode_raises_guard_pass_through(self) -> None: try: with warnings.catch_warnings(record=True): warnings.simplefilter("always") - req = NetworkFirewallRequest(protocol="crypto", host="local", port=0) + req = NetworkFirewallRequest(protocol="jwt", host="local", port=0) with pytest.raises(GuardPassThrough): - get_verifier_or_raise("crypto:sign", firewall_request=req) + get_verifier_or_raise("jwt:encode", firewall_request=req) finally: _guard_active.reset(guard_token) _guard_levels.reset(level_token) @@ -641,9 +644,9 @@ def test_warn_mode_warning_is_filterable(self) -> None: with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") warnings.filterwarnings("ignore", category=GuardedCallWarning) - req = NetworkFirewallRequest(protocol="crypto", host="local", port=0) + req = NetworkFirewallRequest(protocol="jwt", host="local", port=0) with pytest.raises(GuardPassThrough): - get_verifier_or_raise("crypto:sign", firewall_request=req) + get_verifier_or_raise("jwt:encode", firewall_request=req) assert len(w) == 0 finally: _guard_active.reset(guard_token) @@ -660,10 +663,10 @@ def test_warn_mode_warning_contains_source_id(self) -> None: try: with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - req = NetworkFirewallRequest(protocol="crypto", host="local", port=0) + req = NetworkFirewallRequest(protocol="jwt", host="local", port=0) with pytest.raises(GuardPassThrough): - get_verifier_or_raise("crypto:sign", firewall_request=req) - assert "'crypto:sign'" in str(w[0].message) + get_verifier_or_raise("jwt:encode", firewall_request=req) + assert "'jwt:encode'" in str(w[0].message) finally: _guard_active.reset(guard_token) _guard_levels.reset(level_token) @@ -679,9 +682,9 @@ def test_warn_mode_warning_contains_blocked_by_firewall(self) -> None: try: with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - req = NetworkFirewallRequest(protocol="crypto", host="local", port=0) + req = NetworkFirewallRequest(protocol="jwt", host="local", port=0) with pytest.raises(GuardPassThrough): - get_verifier_or_raise("crypto:sign", firewall_request=req) + get_verifier_or_raise("jwt:encode", firewall_request=req) msg = str(w[0].message) assert "blocked by firewall" in msg finally: From bc8300083ddae79f51ab01f305a54c0794c01be5 Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:56:35 -0500 Subject: [PATCH 18/33] Lock _active_sandbox_ids for free-threaded safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The class-level set SandboxContext._active_sandbox_ids was mutated and read without explicit synchronization. The previous comment claimed "add() / discard() of distinct keys are independent under the GIL; no lock required" — true on stock CPython, but PEP 703 (free-threaded 3.14t) removes that implicit serialization. Concurrent set.add / set.discard / `in` against a shared set on the free-threaded build can corrupt the hash table, making `__contains__` loop forever. This caused the 3.14t CI jobs on ubuntu and windows to hang. Wrap all three mutation sites (the add in _enter, the discard in the _enter error path, and the discard in _exit) and the `in` check in _detect_post_sandbox with a class-level threading.Lock. The lock is uncontended on the GIL build, so this is effectively a no-op there. Verified: - Stress test with 32 threads x 500 iterations on 3.14t (PYTHON_GIL=0) completes cleanly with the active set drained back to empty. - Full test suite passes on stock CPython 3.14 (1414 passed). - Free-threaded 3.14t test suite (PYTHON_GIL=0) passes (1297 passed, 5 skipped) in ~14s. --- src/tripwire/_context.py | 8 +++++++- src/tripwire/_verifier.py | 20 +++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/tripwire/_context.py b/src/tripwire/_context.py index 14cf4f9..018d13a 100644 --- a/src/tripwire/_context.py +++ b/src/tripwire/_context.py @@ -82,7 +82,13 @@ def _detect_post_sandbox() -> int | None: sid = _current_sandbox_id.get() if sid is None: return None - if sid in SandboxContext._active_sandbox_ids: + # Hold the lock for the membership check: under PEP 703 free-threaded + # CPython, an unsynchronized `in` check against a `set` being mutated + # on another thread can corrupt the hash table and hang. The lock is + # uncontended on the GIL build, so this is effectively a no-op there. + with SandboxContext._active_sandbox_ids_lock: + active = sid in SandboxContext._active_sandbox_ids + if active: # Sandbox is still active in the process; Branch 1 should have # caught this. Defensive: do not fire post-sandbox here. return None diff --git a/src/tripwire/_verifier.py b/src/tripwire/_verifier.py index ac4dd14..cf4fe54 100644 --- a/src/tripwire/_verifier.py +++ b/src/tripwire/_verifier.py @@ -4,6 +4,7 @@ import contextvars import difflib import itertools +import threading import warnings from importlib.metadata import entry_points from types import TracebackType @@ -450,9 +451,15 @@ def _format_unused_mocks_error(self, unused: list[tuple["BasePlugin", Any]]) -> class SandboxContext: """Activates all plugins and mocks. Supports both sync (with) and async (async with).""" - # Process-wide set of currently active sandbox_ids. add() / discard() - # of distinct keys are independent under the GIL; no lock required. + # Process-wide set of currently active sandbox_ids. ALL reads and + # mutations MUST be performed while holding _active_sandbox_ids_lock. + # Stock CPython's GIL implicitly serializes set operations, but the + # free-threaded build (PEP 703) does not — concurrent set.add / + # set.discard / `in` checks against a shared `set` can corrupt its + # internal hash table and hang in `__contains__`. The lock is + # uncontended on the GIL build, so this is effectively a no-op there. _active_sandbox_ids: ClassVar[set[int]] = set() + _active_sandbox_ids_lock: ClassVar[threading.Lock] = threading.Lock() def __init__(self, verifier: StrictVerifier) -> None: self._verifier = verifier @@ -468,7 +475,8 @@ def _enter(self) -> StrictVerifier: # site in BasePlugin.record() sees a consistent ContextVar value # for any interaction recorded during plugin/mock activation. self.sandbox_id = next(_sandbox_id_counter) - SandboxContext._active_sandbox_ids.add(self.sandbox_id) + with SandboxContext._active_sandbox_ids_lock: + SandboxContext._active_sandbox_ids.add(self.sandbox_id) self._sandbox_id_token = _current_sandbox_id.set(self.sandbox_id) self._token = _active_verifier.set(self._verifier) @@ -522,7 +530,8 @@ def _enter(self) -> StrictVerifier: _current_sandbox_id.reset(self._sandbox_id_token) self._sandbox_id_token = None if self.sandbox_id is not None: - SandboxContext._active_sandbox_ids.discard(self.sandbox_id) + with SandboxContext._active_sandbox_ids_lock: + SandboxContext._active_sandbox_ids.discard(self.sandbox_id) raise BaseExceptionGroup("tripwire sandbox activation failed", errors) return self._verifier @@ -559,7 +568,8 @@ def _exit(self) -> None: _current_sandbox_id.reset(self._sandbox_id_token) self._sandbox_id_token = None if self.sandbox_id is not None: - SandboxContext._active_sandbox_ids.discard(self.sandbox_id) + with SandboxContext._active_sandbox_ids_lock: + SandboxContext._active_sandbox_ids.discard(self.sandbox_id) def __enter__(self) -> StrictVerifier: return self._enter() From 8e18bc26498c808d7b15704d2add04c73d74fe65 Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:38:31 -0500 Subject: [PATCH 19/33] Skip processpool propagation test on Windows free-threaded build ProcessPoolExecutor spawn deadlocks on Windows free-threaded Python 3.14t, hanging the executor's shutdown indefinitely. This is an upstream CPython multiprocessing/free-threading interaction unrelated to tripwire. The documented separate-process boundary that this test validates is still exercised on every other platform, including Linux 3.14t. --- tests/integration/test_contextvar_propagation.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/integration/test_contextvar_propagation.py b/tests/integration/test_contextvar_propagation.py index 677224a..c76a2c5 100644 --- a/tests/integration/test_contextvar_propagation.py +++ b/tests/integration/test_contextvar_propagation.py @@ -48,6 +48,8 @@ import concurrent.futures import multiprocessing import pickle +import sys +import sysconfig from typing import Any import pytest @@ -57,6 +59,10 @@ pytestmark = pytest.mark.integration +_WIN_FREETHREADED = sys.platform == "win32" and bool( + sysconfig.get_config_var("Py_GIL_DISABLED") +) + # A neutral source_id that no real plugin owns. Reaches Branch 5 # (SandboxNotActiveError) when no verifier is in scope; otherwise the @@ -223,6 +229,15 @@ def _processpool_worker(_payload: str) -> Any: return get_verifier_or_raise("test:c10_processpool") +@pytest.mark.skipif( + _WIN_FREETHREADED, + reason=( + "ProcessPoolExecutor spawn deadlocks on Windows free-threaded 3.14 " + "(upstream CPython multiprocessing/free-threading interaction). The " + "documented boundary is still validated on every other platform, " + "including Linux 3.14t." + ), +) def test_processpool_does_NOT_propagate() -> None: # noqa: N802 """ProcessPoolExecutor submission either fails to pickle the payload or executes the worker in a fresh process where no `with tripwire:` From 74c8116c146a0b68304b35e18928e1b6b2f64bf9 Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:55:36 -0500 Subject: [PATCH 20/33] Skip whole contextvar propagation file on Windows free-threaded The full module exercises asyncio/threading boundary primitives (asyncio.to_thread, ProcessPoolExecutor, ThreadPoolExecutor, etc.). On Windows free-threaded 3.14 these primitives deadlock, blocking the runner with no useful diagnostic. The propagation contracts are still validated on every other platform, including Linux 3.14t. Replaces the per-test processpool skip with a module-level skipif. --- .../test_contextvar_propagation.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/tests/integration/test_contextvar_propagation.py b/tests/integration/test_contextvar_propagation.py index c76a2c5..a5c6372 100644 --- a/tests/integration/test_contextvar_propagation.py +++ b/tests/integration/test_contextvar_propagation.py @@ -57,12 +57,23 @@ from tripwire import StrictVerifier from tripwire._context import _active_verifier, get_verifier_or_raise -pytestmark = pytest.mark.integration - _WIN_FREETHREADED = sys.platform == "win32" and bool( sysconfig.get_config_var("Py_GIL_DISABLED") ) +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif( + _WIN_FREETHREADED, + reason=( + "asyncio/threading boundary primitives deadlock on Windows " + "free-threaded 3.14 (upstream CPython issue). The propagation " + "contracts are still validated on every other platform, " + "including Linux 3.14t." + ), + ), +] + # A neutral source_id that no real plugin owns. Reaches Branch 5 # (SandboxNotActiveError) when no verifier is in scope; otherwise the @@ -229,15 +240,6 @@ def _processpool_worker(_payload: str) -> Any: return get_verifier_or_raise("test:c10_processpool") -@pytest.mark.skipif( - _WIN_FREETHREADED, - reason=( - "ProcessPoolExecutor spawn deadlocks on Windows free-threaded 3.14 " - "(upstream CPython multiprocessing/free-threading interaction). The " - "documented boundary is still validated on every other platform, " - "including Linux 3.14t." - ), -) def test_processpool_does_NOT_propagate() -> None: # noqa: N802 """ProcessPoolExecutor submission either fails to pickle the payload or executes the worker in a fresh process where no `with tripwire:` From 1d8c6960ca6d3c018d376fd9c8a0adab3453c0e0 Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:50:22 -0500 Subject: [PATCH 21/33] Thread canonical plugin name through get_verifier_or_raise Per-protocol guard overrides under [tool.tripwire.guard] silently failed to apply when the plugin's source_id prefix differed from its canonical registry name. The dispatch in get_verifier_or_raise keyed the override lookup on source_id.split(":")[0] (the prefix), but overrides are validated and stored under the canonical registry name. Affected plugins: database (prefix "db"), async_subprocess (prefix "asyncio"), async_websocket and sync_websocket (overlapping prefix "websocket"). A user writing database = "off" with a db:query call saw UnsafePassthroughError instead of the expected suppression, and GuardedCallError / UnsafePassthroughError / PostSandboxInteractionError all reported plugin_name as the prefix instead of the canonical name. Change lookup_plugin_class_by_name to return (cls, canonical_name) so callers can distinguish the lookup key from the registry name. Hoist the lookup in get_verifier_or_raise to the top of the function and plumb canonical_name into every override lookup and error constructor that previously used the prefix. When the source_id does not match a registered plugin, fall back to the prefix as the displayed name to preserve the pre-C2 contract for unknown source_ids. Update test_database_connect_guard_blocks_when_not_allowed which had asserted the buggy behavior (plugin_name == "db") to assert the canonical form (plugin_name == "database"). --- src/tripwire/_context.py | 52 ++++++++++++++++++++++++--------------- src/tripwire/_registry.py | 22 ++++++++++++----- tests/unit/test_guard.py | 6 ++++- 3 files changed, 53 insertions(+), 27 deletions(-) diff --git a/src/tripwire/_context.py b/src/tripwire/_context.py index 018d13a..4d3cadb 100644 --- a/src/tripwire/_context.py +++ b/src/tripwire/_context.py @@ -115,7 +115,28 @@ def get_verifier_or_raise( 4. Guard not active but patches installed: raise GuardPassThrough. 5. Otherwise: raise SandboxNotActiveError. """ - plugin_name = source_id.split(":")[0] + prefix = source_id.split(":")[0] + + # Resolve the plugin class once, up front, so the canonical registry + # name is available to every downstream branch. ``plugin_cls is None`` + # means the source_id does not belong to a registered plugin (e.g., + # a test exercising get_verifier_or_raise with an arbitrary name); + # in that case, fall back to the source_id prefix as the displayed + # plugin_name so unknown source_ids preserve the pre-C2 contract. + # + # When the plugin IS registered, ``canonical_name`` is the registry + # name (e.g., ``"database"``) even when ``prefix`` matched via a + # ``guard_prefix`` like ``"db"``. This is what per-protocol guard + # overrides under ``[tool.tripwire.guard]`` key on, and what error + # messages report. + from tripwire._registry import ( # noqa: PLC0415 + lookup_plugin_class_by_name, + ) + res = lookup_plugin_class_by_name(prefix) + plugin_cls, canonical_name = res if res is not None else (None, prefix) + plugin_is_unsafe_passthrough = ( + plugin_cls is not None and plugin_cls.passthrough_safe is False + ) # === Branch 2: post-sandbox detection (C4) === # MUST run BEFORE Branch 1: an asyncio task / thread captures the @@ -133,7 +154,7 @@ def get_verifier_or_raise( raise PostSandboxInteractionError( source_id=source_id, - plugin_name=plugin_name, + plugin_name=canonical_name, sandbox_id=closed_sandbox_id, ) @@ -142,19 +163,6 @@ def get_verifier_or_raise( if verifier is not None: return verifier - # Resolve the plugin class once. ``plugin_cls is None`` means the - # source_id does not belong to a registered plugin (e.g., a test - # exercising get_verifier_or_raise with an arbitrary name). Unknown - # plugins skip every guard branch and fall through to - # SandboxNotActiveError so they preserve the pre-C2 contract. - from tripwire._registry import ( # noqa: PLC0415 - lookup_plugin_class_by_name, - ) - plugin_cls = lookup_plugin_class_by_name(plugin_name) - plugin_is_unsafe_passthrough = ( - plugin_cls is not None and plugin_cls.passthrough_safe is False - ) - # === Branch 3: guard active === if plugin_cls is not None and _guard_active.get(): if firewall_request is not None: @@ -167,9 +175,13 @@ def get_verifier_or_raise( raise GuardPassThrough() # === Branch 3b: DENY === - # Per-protocol or default guard level (C3). + # Per-protocol or default guard level (C3). Key on the + # canonical registry name so an override like ``database = + # "off"`` applies to a ``db:query`` source_id (DatabasePlugin + # registers ``database`` and exposes ``"db"`` as a + # guard_prefix). guard_levels = _guard_levels.get() - level = guard_levels.overrides.get(plugin_name, guard_levels.default) + level = guard_levels.overrides.get(canonical_name, guard_levels.default) # === Branch 3b-off (C3) === # Per-protocol "off" disables the firewall entirely for this @@ -187,7 +199,7 @@ def get_verifier_or_raise( raise UnsafePassthroughError( source_id=source_id, - plugin_name=plugin_name, + plugin_name=canonical_name, ) # === Branch 3b-warn-safe === @@ -210,7 +222,7 @@ def get_verifier_or_raise( user_frame = walk_to_user_frame() raise GuardedCallError( source_id=source_id, - plugin_name=plugin_name, + plugin_name=canonical_name, firewall_request=firewall_request, user_frame=user_frame, ) @@ -227,7 +239,7 @@ def get_verifier_or_raise( user_frame = walk_to_user_frame() raise GuardedCallError( source_id=source_id, - plugin_name=plugin_name, + plugin_name=canonical_name, firewall_request=None, user_frame=user_frame, ) diff --git a/src/tripwire/_registry.py b/src/tripwire/_registry.py index 159a889..03ee9a8 100644 --- a/src/tripwire/_registry.py +++ b/src/tripwire/_registry.py @@ -130,14 +130,24 @@ def get_plugin_class(entry: PluginEntry) -> type[BasePlugin]: return cls -def lookup_plugin_class_by_name(plugin_name: str) -> type[BasePlugin] | None: - """Return the plugin class registered under ``plugin_name``, or None. +def lookup_plugin_class_by_name( + plugin_name: str, +) -> tuple[type[BasePlugin], str] | None: + """Return ``(plugin_class, canonical_registry_name)`` registered under + ``plugin_name``, or None. Looks up by canonical registry name first, then by any ``guard_prefixes`` declared on a registered plugin class. Returns None when no plugin matches or when its optional dependency is missing. Callers use this from outside any active sandbox to ask "what plugin would receive a call from this source_id?". + + The canonical name is ``entry.name`` (the registry name, e.g. + ``"database"``). It may differ from ``plugin_name`` when ``plugin_name`` + matches a ``guard_prefix`` instead (e.g., ``plugin_name="db"`` resolves + to ``("DatabasePlugin", "database")``). Callers MUST use the canonical + name when looking up per-protocol guard overrides and when populating + ``plugin_name`` on errors so the user sees the registry name. """ for entry in PLUGIN_REGISTRY: if not _is_available(entry): @@ -146,10 +156,10 @@ def lookup_plugin_class_by_name(plugin_name: str) -> type[BasePlugin] | None: cls = get_plugin_class(entry) except Exception: continue - if entry.name == plugin_name: - return cls - if plugin_name in getattr(cls, "guard_prefixes", ()): - return cls + if entry.name == plugin_name or plugin_name in getattr( + cls, "guard_prefixes", () + ): + return cls, entry.name return None diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 21600be..99438a9 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -1104,7 +1104,11 @@ def test_database_connect_guard_blocks_when_not_allowed(self) -> None: try: with pytest.raises(GuardedCallError) as exc_info: sqlite3.connect(":memory:") - assert exc_info.value.plugin_name == "db" + # plugin_name reports the canonical registry name + # ("database"), not the source_id prefix ("db"), so it + # matches the key users write under [tool.tripwire.guard] + # and the same name shown by enabled_plugins/disabled_plugins. + assert exc_info.value.plugin_name == "database" assert exc_info.value.source_id == "db:connect" finally: _guard_active.reset(guard_token) From c49cffd703549738348e710ee67e8db4b00a2af8 Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:50:33 -0500 Subject: [PATCH 22/33] Normalize @pytest.mark.guard argument via _resolve_guard_levels The marker handler in pytest_runtest_call did GuardLevels(default=arg, overrides={}) for string arguments, with no case folding, no alias resolution, and no validation. Mixed-case strings like "Warn" or aliases like "STRICT" slipped past the marker handler and only failed later in dispatch with an opaque error (the level == "warn" / level == "error" arms in get_verifier_or_raise would not match, and behavior fell through unpredictably). Route every accepted marker shape (string, bool, dict) through _resolve_guard_levels so the marker uses the same normalization, alias mapping (strict -> error), case folding, and validation as [tool.tripwire.guard] in pyproject.toml. The string case is now a proper subset of the dict case and no longer needs its own branch. --- src/tripwire/pytest_plugin.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/tripwire/pytest_plugin.py b/src/tripwire/pytest_plugin.py index 7618b1c..f48eebb 100644 --- a/src/tripwire/pytest_plugin.py +++ b/src/tripwire/pytest_plugin.py @@ -290,19 +290,24 @@ def pytest_runtest_call(item: pytest.Item) -> Generator[None, None, None]: project_levels = guard_levels - # Read the per-test marker (last one wins if multiple). + # Read the per-test marker (last one wins if multiple). Route every + # accepted shape (string, bool, dict) through ``_resolve_guard_levels`` + # so the marker uses the same normalization, alias mapping, and + # validation as ``[tool.tripwire.guard]`` in pyproject.toml. Without + # this, mixed-case strings like ``"Warn"`` or ``"STRICT"`` would slip + # past the marker handler and only fail later in dispatch with a less + # actionable error. marker_levels: GuardLevels | None = None for mark in item.iter_markers("guard"): arg = mark.args[0] if mark.args else None - if isinstance(arg, str): - marker_levels = GuardLevels(default=arg, overrides={}) - elif isinstance(arg, dict): + if isinstance(arg, (str, bool, dict)): marker_levels = _resolve_guard_levels({"guard": arg}) else: from tripwire._errors import TripwireConfigError # noqa: PLC0415 raise TripwireConfigError( - f"@pytest.mark.guard expects a string or a dict, got {type(arg).__name__}" + "@pytest.mark.guard expects a string, bool, or a dict, " + f"got {type(arg).__name__}" ) effective_levels = marker_levels if marker_levels is not None else project_levels From d0a8f12e37bb8e5868efde9695f1519789bcca6c Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:50:48 -0500 Subject: [PATCH 23/33] Test canonical-name override and marker case normalization Five regression tests covering the two fixes: - test_override_on_canonical_name_applies_to_prefix_source_id: database = "off" suppresses a db:query call. Without the fix this would raise UnsafePassthroughError (DatabasePlugin is passthrough_safe=False, so warn-default raises rather than passes through). - test_error_reports_canonical_plugin_name_not_prefix: GuardedCallError.plugin_name reports "database", not "db", so users can match it against the same name they wrote in [tool.tripwire.guard]. - test_config_rejects_prefix_as_override_key: Asserts the inverse direction is also enforced: writing db = "off" is rejected by _resolve_guard_levels (only canonical names are valid override keys). - test_marker_normalizes_mixed_case_warn: @pytest.mark.guard("Warn") is normalized to "warn" via _resolve_guard_levels. Verified by observing the warn-branch signal (UnsafePassthroughError on subprocess) instead of the error-branch signal (GuardedCallError) that the project default "error" would produce. - test_marker_normalizes_strict_alias: @pytest.mark.guard("STRICT") lowercases and aliases to "error"; proven by GuardedCallError under a project default of "warn". The first three tests fail without the canonical-name fix in get_verifier_or_raise. The fourth fails without the marker fix in pytest_runtest_call. --- .../integration/test_guard_canonical_name.py | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 tests/integration/test_guard_canonical_name.py diff --git a/tests/integration/test_guard_canonical_name.py b/tests/integration/test_guard_canonical_name.py new file mode 100644 index 0000000..ee3e6c7 --- /dev/null +++ b/tests/integration/test_guard_canonical_name.py @@ -0,0 +1,217 @@ +"""Regression tests: per-protocol guard overrides key on the canonical +plugin registry name, not on the source_id prefix. + +Several plugins declare ``guard_prefixes`` that differ from their canonical +registry name. Examples in the registry today: + +- ``database`` (registry) / ``db`` (prefix) -- DatabasePlugin +- ``async_subprocess`` (registry) / ``asyncio`` (prefix) +- ``async_websocket`` (registry) / ``websocket`` (prefix) +- ``sync_websocket`` (registry) / ``websocket`` (prefix) + +A user writing ``[tool.tripwire.guard]\\ndatabase = "off"`` and triggering a +``db:query`` source_id MUST see the override applied. Before the canonical +-name fix, dispatch in ``get_verifier_or_raise`` keyed on the source_id +prefix (``"db"``), so the override silently failed and surfaced as an +``UnsafePassthroughError`` / ``GuardedCallError`` reporting ``plugin_name`` +``"db"`` instead of ``"database"``. + +The marker normalization regression covers a related papercut: feeding a +mixed-case string like ``"Warn"`` or ``"STRICT"`` into +``@pytest.mark.guard(...)`` previously bypassed normalization (the marker +handler did ``GuardLevels(default=arg, overrides={})`` with no validation), +so the misspelled level slipped through to dispatch and failed there with +a less actionable error. Routing through ``_resolve_guard_levels`` gives +the marker the same alias mapping (``"strict"`` -> ``"error"``), case +folding, and validation as the TOML loader. +""" + +from __future__ import annotations + +import textwrap + +import pytest + +from tripwire._config import GuardLevels +from tripwire._context import ( + GuardPassThrough, + _guard_active, + _guard_levels, + get_verifier_or_raise, +) +from tripwire._errors import GuardedCallError +from tripwire._firewall_request import NetworkFirewallRequest + +pytestmark = pytest.mark.integration + +pytest_plugins = ["pytester"] + + +def test_override_on_canonical_name_applies_to_prefix_source_id() -> None: + """Setting ``database = "off"`` MUST suppress a ``db:query`` call. + + DatabasePlugin's canonical registry name is ``"database"`` and its + ``guard_prefixes`` is ``("db",)``. The dispatch in + ``get_verifier_or_raise`` must look up the per-protocol override by + canonical name, not by the source_id prefix. + + PATH: get_verifier_or_raise -> lookup_plugin_class_by_name("db") + returns (DatabasePlugin, "database") -> Branch 3b -> level = + overrides["database"] == "off" -> raise GuardPassThrough. + CHECK: GuardPassThrough is raised (no error, no warning). + MUTATION: Restoring the bug (overrides.get(prefix, ...)) makes the + override invisible; DatabasePlugin is passthrough_safe=False + so the warn-default branch raises UnsafePassthroughError + and the test fails. + """ + levels_token = _guard_levels.set( + GuardLevels(default="warn", overrides={"database": "off"}) + ) + guard_token = _guard_active.set(True) + try: + req = NetworkFirewallRequest(protocol="db", host="local", port=0) + with pytest.raises(GuardPassThrough): + get_verifier_or_raise("db:query", firewall_request=req) + finally: + _guard_active.reset(guard_token) + _guard_levels.reset(levels_token) + + +def test_error_reports_canonical_plugin_name_not_prefix() -> None: + """``GuardedCallError.plugin_name`` reports the canonical registry name. + + A ``db:query`` source_id under ``database = "error"`` raises + GuardedCallError. Before the fix, ``plugin_name`` would have been + ``"db"`` (the source_id prefix). After the fix it is ``"database"`` + so users can match it against the same name they wrote in + ``[tool.tripwire.guard]``. + """ + levels_token = _guard_levels.set( + GuardLevels(default="warn", overrides={"database": "error"}) + ) + guard_token = _guard_active.set(True) + try: + req = NetworkFirewallRequest(protocol="db", host="local", port=0) + with pytest.raises(GuardedCallError) as exc_info: + get_verifier_or_raise("db:query", firewall_request=req) + assert exc_info.value.plugin_name == "database" + assert exc_info.value.source_id == "db:query" + finally: + _guard_active.reset(guard_token) + _guard_levels.reset(levels_token) + + +def test_config_rejects_prefix_as_override_key() -> None: + """The inverse: writing ``db = "off"`` is rejected by config validation. + + ``[tool.tripwire.guard]`` validates override keys against + ``VALID_PLUGIN_NAMES`` (the canonical registry names). ``"db"`` is a + guard_prefix, not a registry name, so feeding it through + ``_resolve_guard_levels`` raises TripwireConfigError. This guards + against a "fix" that silently accepts the prefix form and creates a + second source of truth. + """ + from tripwire._config import _resolve_guard_levels + from tripwire._errors import TripwireConfigError + + with pytest.raises(TripwireConfigError, match="Unknown protocol 'db'"): + _resolve_guard_levels({"guard": {"default": "warn", "db": "off"}}) + + +@pytest.mark.allow("subprocess") +def test_marker_normalizes_mixed_case_warn(pytester: pytest.Pytester) -> None: + """``@pytest.mark.guard("Warn")`` MUST normalize to ``"warn"``. + + Before the fix, the marker handler ran + ``GuardLevels(default=arg, overrides={})`` directly; ``"Warn"`` would + propagate to dispatch as-is and fail later with an opaque error + (``"Warn"`` does not match any of the dispatch's ``level == "..."`` + arms, so the warn-vs-error decision falls through unpredictably). + After the fix, the marker is routed through ``_resolve_guard_levels`` + which applies ``.lower()`` and validates against ``_VALID_LEVELS``. + + Observable signal: under guard="warn" with an unsafe plugin like + subprocess (passthrough_safe=False), dispatch enters the warn branch + which raises ``UnsafePassthroughError``. Under guard="error" the + same call would raise ``GuardedCallError``. The error type proves + the level resolved to "warn" not "error". Project default is "error" + so any failure to apply the marker would surface as GuardedCallError + instead. + """ + pytester.makepyprojecttoml( + textwrap.dedent( + """ + [project] + name = "client" + version = "0.0.0" + + [tool.tripwire] + guard = "error" + """ + ) + ) + pytester.makepyfile( + test_warn_mixed_case=textwrap.dedent( + """ + import subprocess + + import pytest + + from tripwire._errors import UnsafePassthroughError + + + @pytest.mark.guard("Warn") + def test_warn_mixed_case(): + with pytest.raises(UnsafePassthroughError): + subprocess.run(["true"]) + """ + ) + ) + result = pytester.runpytest_subprocess("-q") + result.assert_outcomes(passed=1) + + +@pytest.mark.allow("subprocess") +def test_marker_normalizes_strict_alias(pytester: pytest.Pytester) -> None: + """``@pytest.mark.guard("STRICT")`` MUST normalize to ``"error"``. + + ``_normalize_level`` lowercases then applies ``_LEVEL_ALIASES`` + (``"strict"`` -> ``"error"``). The marker must use the same path as + the TOML loader so the alias resolves. + + The inner test asserts that an unmocked ``subprocess.run`` raises + GuardedCallError under ``"STRICT"``, proving (1) the marker handler + accepted the mixed-case input, (2) it lowercased to ``"strict"``, + and (3) it aliased to ``"error"``. + """ + pytester.makepyprojecttoml( + textwrap.dedent( + """ + [project] + name = "client" + version = "0.0.0" + + [tool.tripwire] + guard = "warn" + """ + ) + ) + pytester.makepyfile( + test_strict_alias=textwrap.dedent( + """ + import subprocess + + import pytest + + from tripwire import GuardedCallError + + + @pytest.mark.guard("STRICT") + def test_strict_alias(): + with pytest.raises(GuardedCallError): + subprocess.run(["true"]) + """ + ) + ) + result = pytester.runpytest_subprocess("-q") + result.assert_outcomes(passed=1) From 50bee68847d2bcae147f964bcfcbef8bd6db72b3 Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:01:03 -0500 Subject: [PATCH 24/33] Spawn TPE workers with empty context to fix asyncio hang on 3.14t On PEP 703 free-threaded CPython 3.14+, sys.flags.thread_inherit_context is True and a new thread inherits its base context from the spawning thread. When a ThreadPoolExecutor worker was spawned during a sandbox, its base context carried _active_verifier (and other tripwire ContextVars) for the rest of its life. concurrent.futures._WorkItem.run invokes future.set_result() AFTER ctx.run(self.task) returns, so future done-callbacks run in the worker's BASE context, not the captured caller context. asyncio's _call_set_state done-callback chains to loop.call_soon_threadsafe -> loop._write_to_self -> csock.send(b'\0') on the loop's internal self-pipe socket. With the verifier still active in the worker's base context, our SocketPlugin patched send raised UnmockedInteractionError. Future._invoke_callbacks swallowed the exception, the wakeup byte was never written, and the asyncio event loop hung in selector.select() forever. Fix: run _original_submit inside an empty contextvars.Context so any worker thread spawned by _adjust_thread_count starts with a clean base context. The work item itself still sees the captured caller context because ctx.run(fn) is what executes the user's function; only the post-work-item bookkeeping (set_result, done-callbacks, idle wait) runs in the empty base context. Manifested as intermittent ~30%-rate hangs of tests/integration/test_contextvar_propagation.py on Linux 3.14t CI. Locally reproduced at the same rate on macOS arm64 3.14t prior to the fix; 50/50 passes after. Adds a regression test that asserts a TPE worker's done-callback sees the default ContextVar value, not the value the parent set before submit. The test catches the bug on both stock and free-threaded builds (the inheritance behaviour is identical for done-callbacks). --- CHANGELOG.md | 1 + src/tripwire/_context_propagation.py | 36 +++++++++++- tests/unit/test_context_propagation.py | 76 ++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2464cd..0495465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - `async_subprocess_plugin` type annotations corrected: the `cast(_AsyncFakeProcess, await _ORIGINAL_CREATE_SUBPROCESS_EXEC(...))` claim was a lie (the runtime returned a real `asyncio.subprocess.Process`). The cast is removed and the return-type annotation widened to `_AsyncFakeProcess | asyncio.subprocess.Process` for both `_fake_create_subprocess_exec` and `_fake_create_subprocess_shell`. Runtime behavior unchanged; static types now match reality. +- `_context_propagation` `ThreadPoolExecutor.submit` patch now spawns worker threads with an empty base context on PEP 703 free-threaded CPython 3.14+. Previously, when a `ThreadPoolExecutor` worker was spawned during a sandbox, the worker's base context inherited `_active_verifier`. After the work item finished, `Future.set_result` invoked done-callbacks (asyncio's `_call_set_state` -> `loop.call_soon_threadsafe` -> `loop._write_to_self` -> `csock.send(b'\0')`) in that base context, so the socket interceptor saw an active verifier on asyncio's internal self-pipe socket and raised `UnmockedInteractionError`. `Future._invoke_callbacks` swallowed the exception, the wakeup was dropped, and the asyncio event loop hung in `selector.select()` forever. Manifested as intermittent ~30%-rate hangs of `tests/integration/test_contextvar_propagation.py` on Linux 3.14t CI. Fix: run `_original_submit` inside an empty `contextvars.Context` so worker threads start with no inherited tripwire state; the captured caller context is still applied via `ctx.run` for the work item itself. ## [0.19.2] - 2026-04-08 diff --git a/src/tripwire/_context_propagation.py b/src/tripwire/_context_propagation.py index e4b2b6c..d5d1932 100644 --- a/src/tripwire/_context_propagation.py +++ b/src/tripwire/_context_propagation.py @@ -111,8 +111,42 @@ def _patched_submit( *args: Any, # noqa: ANN401 **kwargs: Any, # noqa: ANN401 ) -> Any: # noqa: ANN401 + # Capture the caller's context so the work item runs with the + # caller's ContextVars (active verifier, sandbox id, guard, etc.). ctx = contextvars.copy_context() - return _original_submit(self, ctx.run, fn, *args, **kwargs) + + # Run the underlying submit() inside an empty context so any + # newly spawned worker thread starts with a CLEAN base context + # rather than inheriting our ContextVars. This matters on PEP + # 703 free-threaded CPython 3.14+, where ``sys.flags + # .thread_inherit_context`` is True and a new thread inherits + # its base context from the spawning thread. Without the empty + # wrapper, a long-lived worker thread spawned during a sandbox + # carries ``_active_verifier`` (et al.) in its base context for + # the rest of its life. ``concurrent.futures._WorkItem.run`` + # invokes ``future.set_result()`` AFTER ``ctx.run(self.task)`` + # returns, so the future done-callbacks (notably asyncio's + # ``_call_set_state`` -> ``loop.call_soon_threadsafe`` -> + # ``loop._write_to_self`` -> ``csock.send(b'\\0')``) execute in + # the worker's BASE context. If that context still carries an + # active verifier, our socket/logging/etc. interceptors fire + # against asyncio's internal self-pipe socket, raise + # ``UnmockedInteractionError``, and the wakeup is silently + # dropped by ``Future._invoke_callbacks``. The asyncio loop + # then sleeps in ``selector.select()`` forever. + # + # By spawning the worker in an empty context, the work item + # itself still sees the captured context (because ``ctx.run`` + # is what executes ``fn``), but post-work-item bookkeeping + # (set_result, done-callbacks, idle wait) runs in a clean + # context where no verifier is active and our patched + # interceptors fall through to the originals. + empty_ctx = contextvars.Context() + + def _do_submit() -> Any: # noqa: ANN401 + return _original_submit(self, ctx.run, fn, *args, **kwargs) + + return empty_ctx.run(_do_submit) ThreadPoolExecutor.submit = _patched_submit # type: ignore[assignment] diff --git a/tests/unit/test_context_propagation.py b/tests/unit/test_context_propagation.py index 7a82a5f..a310087 100644 --- a/tests/unit/test_context_propagation.py +++ b/tests/unit/test_context_propagation.py @@ -204,6 +204,82 @@ def test_worker_reuse_gets_independent_snapshots(self) -> None: assert result1 == "first_submit" assert result2 == "second_submit" + def test_worker_base_context_does_not_inherit_parent_contextvars(self) -> None: + """Regression: worker thread's BASE context (the one that runs + ``set_result``, future done-callbacks, and the idle ``work_queue.get`` + loop) MUST NOT inherit the spawning thread's ContextVars. + + On PEP 703 free-threaded CPython 3.14+ with + ``sys.flags.thread_inherit_context``, a new thread inherits its + base context from the spawning thread. Without the empty-context + wrapper around ``_original_submit``, a long-lived ThreadPoolExecutor + worker spawned during a sandbox would carry ``_active_verifier`` (or + any other tripwire ContextVar) in its base context for the rest of + its life. + + The work item itself still sees the captured caller context (because + ``ctx.run`` is what executes the user's function), but post-work + bookkeeping like ``Future.set_result`` and its done-callbacks runs + in the worker's BASE context. If that context held an active + verifier, our patched stdlib interceptors (socket, logging, etc.) + would fire on asyncio's internal self-pipe socket, raise + ``UnmockedInteractionError``, and the wakeup would be silently + dropped by ``Future._invoke_callbacks``, hanging the asyncio loop + in ``selector.select()`` forever. + + This test asserts the invariant: a callback added to a future + completed by a TPE worker thread runs in a context where + ``_test_var.get()`` is the default, NOT the value the parent set + before submitting. + """ + install_context_propagation() + + # Pre-create the executor and warm a worker thread BEFORE setting + # the ContextVar, so that the callback test below can attach a + # done-callback to a future whose set_result will fire on a + # worker thread that is already waiting on the work queue. + captured: list[str] = [] + callback_started = threading.Event() + gate = threading.Event() + + def worker_blocks() -> str: + # Block until the test attaches the callback and signals. + assert gate.wait(timeout=5), "test never released the gate" + return _test_var.get() + + def callback(_fut: concurrent.futures.Future[str]) -> None: + # This runs on the worker thread inside set_result(), AFTER + # ctx.run(work) returns. It executes in the worker's BASE + # context (not the captured caller context). With the fix, + # the base context is empty and _test_var.get() returns the + # default. + captured.append(_test_var.get()) + callback_started.set() + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + # Set ContextVar AFTER pool exists; submit captures THIS context. + token = _test_var.set("parent_value") + future = pool.submit(worker_blocks) + future.add_done_callback(callback) + # Now release the worker so set_result + callback fire while + # the worker is still on its base context. + gate.set() + worker_saw = future.result(timeout=5) + assert callback_started.wait(timeout=5), "callback never ran" + _test_var.reset(token) + + # The work item itself ran in the captured context. + assert worker_saw == "parent_value" + # The done-callback ran in the worker's BASE context, which must + # be empty (default value), not the parent's "parent_value". + assert captured == ["unset"], ( + f"Worker thread base context leaked parent ContextVars: " + f"callback saw {captured[0]!r} instead of 'unset'. This is the " + f"asyncio-hang regression: future done-callbacks running in the " + f"worker's base context will see the parent's tripwire state and " + f"trigger interceptor failures on asyncio's self-pipe socket." + ) + # --------------------------------------------------------------------------- # Install / uninstall idempotency From 3fbde61b15abaec0d7e822afb443237856dd099e Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:39:27 -0500 Subject: [PATCH 25/33] Cache plugin lookup by name for hot-path dispatch lookup_plugin_class_by_name runs on every intercepted call from a sandbox via get_verifier_or_raise. The uncached implementation iterates ~27 registry entries, runs availability checks (which import modules), and imports the plugin class on every dispatch, which is wasteful for high-frequency interceptors like logging and socket. Add a module-level dict cache keyed by the queried name, with negative caching via a sentinel so unknown names are not retried. Cache access is wrapped in a threading.Lock so free-threaded Python (3.14t) cannot observe a torn dict mutation under concurrent first-fill pressure. The plugin registry is effectively immutable for the life of the process (entries are added at decorator import time; availability flips require installing a new package and restarting), so a lazy populate-once cache is sound. Tests that monkeypatch PLUGIN_REGISTRY can call _clear_lookup_cache() to flush stale entries. Add unit tests covering tuple identity on cache hit, negative caching, cache invalidation, guard_prefix resolution, and a 20-thread x 100-iteration concurrent-lookup safety check. --- src/tripwire/_registry.py | 57 ++++++++++++++++++++-- tests/unit/test_registry.py | 94 +++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 3 deletions(-) diff --git a/src/tripwire/_registry.py b/src/tripwire/_registry.py index 03ee9a8..9313836 100644 --- a/src/tripwire/_registry.py +++ b/src/tripwire/_registry.py @@ -2,8 +2,9 @@ from __future__ import annotations +import threading from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final if TYPE_CHECKING: from tripwire._base_plugin import BasePlugin @@ -130,6 +131,39 @@ def get_plugin_class(entry: PluginEntry) -> type[BasePlugin]: return cls +# --------------------------------------------------------------------------- +# Hot-path lookup cache +# +# ``lookup_plugin_class_by_name`` runs on EVERY intercepted call from a +# sandbox (every subprocess.run, socket.send, logging.debug, etc.) via +# ``get_verifier_or_raise``. The uncached implementation iterates the +# registry, runs availability checks (which import modules), and imports +# the plugin class on every call: O(N) work over ~27 plugins per dispatch. +# +# The registry is effectively immutable for the life of the process +# (entries are added at module import; availability can flip only by +# installing a new package, which requires a process restart). This +# makes a lazy populate-once cache correct. +# +# Tests that monkeypatch ``PLUGIN_REGISTRY`` MUST clear the cache via +# ``_clear_lookup_cache()`` for their patch to take effect. +# --------------------------------------------------------------------------- + +_UNSET: Final[object] = object() +_lookup_cache: dict[str, tuple[type[BasePlugin], str] | None] = {} +_lookup_cache_lock: Final[threading.Lock] = threading.Lock() + + +def _clear_lookup_cache() -> None: + """Drop all cached ``lookup_plugin_class_by_name`` results. + + Call this from tests that monkeypatch ``PLUGIN_REGISTRY`` so their + substitute registry is consulted instead of stale cache entries. + """ + with _lookup_cache_lock: + _lookup_cache.clear() + + def lookup_plugin_class_by_name( plugin_name: str, ) -> tuple[type[BasePlugin], str] | None: @@ -148,7 +182,20 @@ def lookup_plugin_class_by_name( to ``("DatabasePlugin", "database")``). Callers MUST use the canonical name when looking up per-protocol guard overrides and when populating ``plugin_name`` on errors so the user sees the registry name. + + Results are cached at module level: this function is on the hot path + for every intercepted call, and the registry is effectively immutable + after import. Tests that mutate ``PLUGIN_REGISTRY`` must call + ``_clear_lookup_cache()`` for changes to be observed. """ + with _lookup_cache_lock: + cached = _lookup_cache.get(plugin_name, _UNSET) + if cached is not _UNSET: + # mypy cannot narrow through the sentinel object; we know the + # cached value is the tuple-or-None payload, not the sentinel. + return cached # type: ignore[return-value] + + result: tuple[type[BasePlugin], str] | None = None for entry in PLUGIN_REGISTRY: if not _is_available(entry): continue @@ -159,8 +206,12 @@ def lookup_plugin_class_by_name( if entry.name == plugin_name or plugin_name in getattr( cls, "guard_prefixes", () ): - return cls, entry.name - return None + result = (cls, entry.name) + break + + with _lookup_cache_lock: + _lookup_cache[plugin_name] = result + return result def resolve_enabled_plugins( diff --git a/tests/unit/test_registry.py b/tests/unit/test_registry.py index a1984b7..62c8f41 100644 --- a/tests/unit/test_registry.py +++ b/tests/unit/test_registry.py @@ -1,5 +1,6 @@ """Unit tests for tripwire._registry: plugin registry and config resolution.""" +import threading from unittest.mock import patch import pytest @@ -9,8 +10,10 @@ PLUGIN_REGISTRY, VALID_PLUGIN_NAMES, PluginEntry, + _clear_lookup_cache, _is_available, get_plugin_class, + lookup_plugin_class_by_name, resolve_enabled_plugins, ) @@ -269,3 +272,94 @@ def test_resolve_enabled_plugins_error_lists_valid_names() -> None: error_msg = str(exc_info.value) assert "subprocess" in error_msg assert "http" in error_msg + + +# --------------------------------------------------------------------------- +# lookup_plugin_class_by_name + cache +# --------------------------------------------------------------------------- + + +class TestLookupPluginClassByNameCache: + """The hot-path lookup is cached; verify shape and invalidation.""" + + def setup_method(self) -> None: + # Each test starts with a clean cache so prior cached entries do + # not influence identity assertions. + _clear_lookup_cache() + + def teardown_method(self) -> None: + # Leave no cached entries from monkeypatched registries. + _clear_lookup_cache() + + def test_known_name_returns_tuple(self) -> None: + """A known canonical name resolves to (cls, canonical_name).""" + from tripwire.plugins.subprocess import SubprocessPlugin + + result = lookup_plugin_class_by_name("subprocess") + assert result is not None + cls, canonical = result + assert cls is SubprocessPlugin + assert canonical == "subprocess" + + def test_repeated_lookup_returns_identical_tuple(self) -> None: + """Cached results return the same tuple object (identity, not just equality).""" + first = lookup_plugin_class_by_name("subprocess") + second = lookup_plugin_class_by_name("subprocess") + assert first is not None + assert first is second + + def test_unknown_name_returns_none(self) -> None: + """An unregistered name resolves to None.""" + assert lookup_plugin_class_by_name("nonexistent_xyz") is None + + def test_unknown_name_negative_cache(self) -> None: + """Unknown names are negatively cached: second lookup is also None.""" + assert lookup_plugin_class_by_name("nonexistent_xyz") is None + assert lookup_plugin_class_by_name("nonexistent_xyz") is None + + def test_clear_cache_invalidates(self) -> None: + """_clear_lookup_cache forces the next lookup to recompute.""" + first = lookup_plugin_class_by_name("subprocess") + _clear_lookup_cache() + second = lookup_plugin_class_by_name("subprocess") + # The plugin class itself is module-level and stable, so the inner + # type identity persists. The tuple object is freshly constructed, + # so it should NOT be the same tuple instance after invalidation. + assert first is not None + assert second is not None + assert first is not second + assert first == second + + def test_guard_prefix_resolves_to_canonical_name(self) -> None: + """A guard_prefix lookup returns the canonical registry name, not the prefix.""" + # DatabasePlugin registers a guard_prefix of "db". + result = lookup_plugin_class_by_name("db") + assert result is not None + _cls, canonical = result + assert canonical == "database" + + def test_concurrent_lookup_safe(self) -> None: + """Concurrent lookups return the same cached tuple under a lock.""" + results: list[tuple[type, str] | None] = [] + results_lock = threading.Lock() + + def worker() -> None: + local: list[tuple[type, str] | None] = [] + for _ in range(100): + local.append(lookup_plugin_class_by_name("subprocess")) + with results_lock: + results.extend(local) + + threads = [threading.Thread(target=worker) for _ in range(20)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(results) == 20 * 100 + first = results[0] + assert first is not None + # All 2000 results must be the same tuple instance: the first + # successful populate wins and every subsequent lookup serves the + # cached identity. + assert all(r is first for r in results) From a5eda1de6b1f2acc1592a9745c53f1f5257c6545 Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Tue, 28 Apr 2026 03:22:04 -0500 Subject: [PATCH 26/33] docs: address Gemini review feedback on PR #57 Replace hardcoded absolute paths in adding-plugins SKILL.md with [PROJECT_ROOT] placeholder so the skill is portable across machines. Split WebSocket entry in README plugins table into AsyncWebSocketPlugin and SyncWebSocketPlugin so the table row count (27) matches the prose "ships with 27 plugins" claim and the plugin classes in src/tripwire/plugins/. --- .claude/skills/adding-plugins/SKILL.md | 8 ++++---- README.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.claude/skills/adding-plugins/SKILL.md b/.claude/skills/adding-plugins/SKILL.md index c9c5924..f7ea7bf 100644 --- a/.claude/skills/adding-plugins/SKILL.md +++ b/.claude/skills/adding-plugins/SKILL.md @@ -245,7 +245,7 @@ def clean_plugin_counts() -> None: Run the test file and confirm all tests fail (no implementation yet): ```bash -cd /Users/elijahrutschman/Development/tripwire && uv run pytest tests/unit/test_[name]_plugin.py -v +cd [PROJECT_ROOT] && uv run pytest tests/unit/test_[name]_plugin.py -v ``` ### 3.3 Write Plugin Implementation @@ -412,12 +412,12 @@ And add to the `all` extra. ### 3.5 Run Tests ```bash -cd /Users/elijahrutschman/Development/tripwire && uv run pytest tests/unit/test_[name]_plugin.py tests/unit/test_init.py -v +cd [PROJECT_ROOT] && uv run pytest tests/unit/test_[name]_plugin.py tests/unit/test_init.py -v ``` Then full suite: ```bash -cd /Users/elijahrutschman/Development/tripwire && uv run pytest tests/ -x +cd [PROJECT_ROOT] && uv run pytest tests/ -x ``` --- @@ -466,7 +466,7 @@ If `auditing-green-mirage` skill is available, invoke it. Otherwise, verify: ### Gate 5: Full Test Suite ```bash -cd /Users/elijahrutschman/Development/tripwire && uv run pytest tests/ -x +cd [PROJECT_ROOT] && uv run pytest tests/ -x ``` ALL tests must pass. diff --git a/README.md b/README.md index cdce1e0..9c4aec4 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ tripwire ships with 27 plugins covering the most common external dependencies: | **Subprocess** | [SubprocessPlugin](https://axiomantic.github.io/tripwire/guides/subprocess-plugin/), [PopenPlugin](https://axiomantic.github.io/tripwire/guides/popen-plugin/), [AsyncSubprocessPlugin](https://axiomantic.github.io/tripwire/guides/async-subprocess-plugin/) | `subprocess.run`, `shutil.which`, `Popen`, `asyncio.create_subprocess_*` | | **Database** | [DatabasePlugin](https://axiomantic.github.io/tripwire/guides/database-plugin/), [Psycopg2Plugin](https://axiomantic.github.io/tripwire/guides/psycopg2-plugin/), [AsyncpgPlugin](https://axiomantic.github.io/tripwire/guides/asyncpg-plugin/), [MongoPlugin](https://axiomantic.github.io/tripwire/guides/mongo-plugin/), [ElasticsearchPlugin](https://axiomantic.github.io/tripwire/guides/elasticsearch-plugin/) | `sqlite3`, `psycopg2`, `asyncpg`, `pymongo`, `elasticsearch` | | **Cache** | [RedisPlugin](https://axiomantic.github.io/tripwire/guides/redis-plugin/), [MemcachePlugin](https://axiomantic.github.io/tripwire/guides/memcache-plugin/) | `redis`, `pymemcache` | -| **Network** | [SmtpPlugin](https://axiomantic.github.io/tripwire/guides/smtp-plugin/), [SocketPlugin](https://axiomantic.github.io/tripwire/guides/socket-plugin/), [WebSocket](https://axiomantic.github.io/tripwire/guides/websocket-plugin/), [DnsPlugin](https://axiomantic.github.io/tripwire/guides/dns-plugin/), [SshPlugin](https://axiomantic.github.io/tripwire/guides/ssh-plugin/), [GrpcPlugin](https://axiomantic.github.io/tripwire/guides/grpc-plugin/) | `smtplib`, `socket`, `websockets`, `websocket-client`, DNS resolution, `paramiko`, `grpcio` | +| **Network** | [SmtpPlugin](https://axiomantic.github.io/tripwire/guides/smtp-plugin/), [SocketPlugin](https://axiomantic.github.io/tripwire/guides/socket-plugin/), [AsyncWebSocketPlugin](https://axiomantic.github.io/tripwire/guides/websocket-plugin/), [SyncWebSocketPlugin](https://axiomantic.github.io/tripwire/guides/websocket-plugin/), [DnsPlugin](https://axiomantic.github.io/tripwire/guides/dns-plugin/), [SshPlugin](https://axiomantic.github.io/tripwire/guides/ssh-plugin/), [GrpcPlugin](https://axiomantic.github.io/tripwire/guides/grpc-plugin/) | `smtplib`, `socket`, `websockets`, `websocket-client`, DNS resolution, `paramiko`, `grpcio` | | **Cloud & Messaging** | [Boto3Plugin](https://axiomantic.github.io/tripwire/guides/boto3-plugin/), [CeleryPlugin](https://axiomantic.github.io/tripwire/guides/celery-plugin/), [PikaPlugin](https://axiomantic.github.io/tripwire/guides/pika-plugin/) | `boto3` (AWS), `celery` tasks, `pika` (RabbitMQ) | | **Crypto & Auth** | [JwtPlugin](https://axiomantic.github.io/tripwire/guides/jwt-plugin/), [CryptoPlugin](https://axiomantic.github.io/tripwire/guides/crypto-plugin/) | `PyJWT`, `cryptography` | | **System** | [FileIoPlugin](https://axiomantic.github.io/tripwire/guides/file-io-plugin/), [NativePlugin](https://axiomantic.github.io/tripwire/guides/native-plugin/) | `open`, `pathlib`, `os`; `ctypes`, `cffi` | From 3c6306f4bd6d3db240d3edb6662e262a181a05fb Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Tue, 28 Apr 2026 03:39:00 -0500 Subject: [PATCH 27/33] Drop subprocess_mock proxy references in docs reference The _mock suffix was dropped from plugin proxy names; index.md still showed subprocess_mock for the proxy and in two SubprocessRunSentinel/ SubprocessWhichSentinel description lines. --- docs/reference/index.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/reference/index.md b/docs/reference/index.md index 748b8e9..a3432ed 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -12,7 +12,7 @@ All public symbols are importable from `tripwire` directly. `HttpPlugin` require | `MockPlugin` | class | Intercepts method calls on named proxy objects. Created automatically by `verifier.mock()`. | | `HttpPlugin` | class | Intercepts `httpx`, `requests`, and `urllib` HTTP calls. Requires `tripwire[http]`. Import from `tripwire.plugins.http`. | | `SubprocessPlugin` | class | Intercepts `subprocess.run` and `shutil.which`. Included in core tripwire. Import from `tripwire.plugins.subprocess`. | -| `subprocess_mock` | proxy | Module-level proxy to `SubprocessPlugin` for the current test. Auto-creates the plugin on first access. | +| `subprocess` | proxy | Module-level proxy to `SubprocessPlugin` for the current test. Auto-creates the plugin on first access. | | `TripwireError` | exception | Base class for all tripwire exceptions. | | `UnmockedInteractionError` | exception | Raised at call time when an intercepted call has no matching mock. | | `UnassertedInteractionsError` | exception | Raised at teardown when interactions were recorded but never asserted. | @@ -32,8 +32,8 @@ These types appear in error messages, docstrings, and plugin implementations but | `MethodProxy` | `tripwire._mock_plugin` | Per-method interceptor with `.returns()`, `.raises()`, `.calls()`, `.required()`. | | `MockConfig` | `tripwire._mock_plugin` | Internal record of one configured side effect. | | `HttpRequestSentinel` | `tripwire.plugins.http` | Opaque object returned by `http.request`. Used as source in `assert_interaction()`. | -| `SubprocessRunSentinel` | `tripwire.plugins.subprocess` | Opaque handle returned by `subprocess_mock.run`. Used as source in `assert_interaction()`. | -| `SubprocessWhichSentinel` | `tripwire.plugins.subprocess` | Opaque handle returned by `subprocess_mock.which`. Used as source in `assert_interaction()`. | +| `SubprocessRunSentinel` | `tripwire.plugins.subprocess` | Opaque handle returned by `subprocess.run`. Used as source in `assert_interaction()`. | +| `SubprocessWhichSentinel` | `tripwire.plugins.subprocess` | Opaque handle returned by `subprocess.which`. Used as source in `assert_interaction()`. | | `RunMockConfig` | `tripwire.plugins.subprocess` | Internal record of a registered `mock_run` configuration. | | `WhichMockConfig` | `tripwire.plugins.subprocess` | Internal record of a registered `mock_which` configuration. | | `HttpMockConfig` | `tripwire.plugins.http` | Internal record of a registered mock response. | From be90f46eb64aaca399ab49758b38e58e68890366 Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:44:20 -0500 Subject: [PATCH 28/33] Rename PyPI dist to python-tripwire The PyPI name `tripwire` is unavailable (likely Tripwire Inc. trademark reservation). Switch the dist name to `python-tripwire` while keeping the Python import name `tripwire` and all internal identifiers unchanged. Changes: - pyproject.toml: name = "python-tripwire"; self-references in extras and dev groups; project.urls switched to axiomantic/python-tripwire - README, CONTRIBUTING, SECURITY, CODE_OF_CONDUCT, mkdocs.yml: GitHub URLs switched to axiomantic/python-tripwire; README PyPI badge points at the new dist - All `pip install tripwire[...]` and `Install tripwire[...]` strings in docs, AGENTS.md, .claude skills, src/ ImportError messages, and matching test assertions changed to `python-tripwire[...]` - writing-plugins.md: 3rd-party plugin example keeps `tripwire-myservice` (hypothetical 3rd-party name) but installs `python-tripwire` as the dep - CHANGELOG: add a 0.20.0 Changed bullet documenting the dist-name choice - test_project_structure: assert name == "python-tripwire" Unchanged: src/tripwire/ directory, all `import tripwire` / `from tripwire`, [tool.tripwire] / [tool.tripwire.guard] config tables, pytest fixtures, sentinel source_ids, and pytest11 entry-point name. --- .claude/skills/adding-plugins/SKILL.md | 4 +- AGENTS.md | 12 ++-- CHANGELOG.md | 1 + CODE_OF_CONDUCT.md | 2 +- CONTRIBUTING.md | 4 +- README.md | 48 +++++++------- SECURITY.md | 2 +- docs/guides/asyncpg-plugin.md | 2 +- docs/guides/boto3-plugin.md | 2 +- docs/guides/celery-plugin.md | 2 +- docs/guides/crypto-plugin.md | 2 +- docs/guides/elasticsearch-plugin.md | 2 +- docs/guides/grpc-plugin.md | 2 +- docs/guides/http-plugin.md | 12 ++-- docs/guides/installation.md | 24 +++---- docs/guides/jwt-plugin.md | 2 +- docs/guides/mcp-plugin.md | 2 +- docs/guides/memcache-plugin.md | 2 +- docs/guides/mongo-plugin.md | 2 +- docs/guides/pika-plugin.md | 2 +- docs/guides/psycopg2-plugin.md | 2 +- docs/guides/redis-plugin.md | 2 +- docs/guides/ssh-plugin.md | 2 +- docs/guides/stateful-plugins.md | 6 +- docs/guides/websocket-plugin.md | 4 +- docs/guides/writing-plugins.md | 6 +- docs/reference/index.md | 4 +- mkdocs.yml | 4 +- pyproject.toml | 18 +++--- src/tripwire/__init__.py | 68 ++++++++++---------- src/tripwire/_registry.py | 2 +- src/tripwire/_verifier.py | 2 +- src/tripwire/plugins/boto3_plugin.py | 3 +- src/tripwire/plugins/celery_plugin.py | 3 +- src/tripwire/plugins/crypto_plugin.py | 3 +- src/tripwire/plugins/elasticsearch_plugin.py | 4 +- src/tripwire/plugins/grpc_plugin.py | 2 +- src/tripwire/plugins/http.py | 6 +- src/tripwire/plugins/jwt_plugin.py | 2 +- src/tripwire/plugins/mcp_plugin.py | 2 +- src/tripwire/plugins/memcache_plugin.py | 4 +- src/tripwire/plugins/mongo_plugin.py | 3 +- src/tripwire/plugins/redis_plugin.py | 3 +- src/tripwire/plugins/websocket_plugin.py | 8 +-- tests/unit/test_boto3_plugin.py | 2 +- tests/unit/test_celery_plugin.py | 2 +- tests/unit/test_crypto_plugin.py | 2 +- tests/unit/test_elasticsearch_plugin.py | 4 +- tests/unit/test_explicit_enable_error.py | 2 +- tests/unit/test_grpc_plugin.py | 2 +- tests/unit/test_init.py | 8 +-- tests/unit/test_jwt_plugin.py | 2 +- tests/unit/test_mcp_plugin.py | 2 +- tests/unit/test_memcache_plugin.py | 2 +- tests/unit/test_mongo_plugin.py | 2 +- tests/unit/test_pika_plugin.py | 6 +- tests/unit/test_project_structure.py | 2 +- tests/unit/test_redis_plugin.py | 2 +- tests/unit/test_ssh_plugin.py | 4 +- tests/unit/test_websocket_plugin.py | 6 +- 60 files changed, 175 insertions(+), 169 deletions(-) diff --git a/.claude/skills/adding-plugins/SKILL.md b/.claude/skills/adding-plugins/SKILL.md index f7ea7bf..4395629 100644 --- a/.claude/skills/adding-plugins/SKILL.md +++ b/.claude/skills/adding-plugins/SKILL.md @@ -199,7 +199,7 @@ from tripwire._context import _current_test_verifier from tripwire._errors import InteractionMismatchError, UnmockedInteractionError from tripwire._verifier import StrictVerifier -# Import the library directly -- all optional deps are in tripwire[dev]. +# Import the library directly -- all optional deps are in python-tripwire[dev]. # Never use pytest.importorskip (green mirage). import [lib] @@ -490,7 +490,7 @@ mock responses and assert exactly what your code sent. ## Installation ```bash -pip install tripwire[[name]] +pip install python-tripwire[[name]] ``` ## Quick Start diff --git a/AGENTS.md b/AGENTS.md index bb91e00..b14c830 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,7 @@ def test_something(): - **Never** put inline code examples in guide "Full example" sections. Always use snippet includes from `examples/`. - **Every** new plugin guide must have a corresponding `examples/` directory with working tests. - If a library generates DEBUG logs (boto3, pymongo, celery, etc.), add an autouse fixture to silence them so they don't interfere with LoggingPlugin. -- **Never** use `pytest.importorskip()` in tests. All optional dependencies are included in `tripwire[dev]` and are expected to be installed. Skipping on missing imports is a green mirage. +- **Never** use `pytest.importorskip()` in tests. All optional dependencies are included in `python-tripwire[dev]` and are expected to be installed. Skipping on missing imports is a green mirage. - The `.claude/skills/adding-plugins/SKILL.md` skill automates the full plugin creation lifecycle including examples and docs. ## Selective Installation @@ -116,10 +116,10 @@ def test_something(): Core plugins (subprocess, logging, database, socket, file-io, native, dns) require no extras. Optional plugins need: ```bash -pip install tripwire[all] # Everything -pip install tripwire[http] # httpx, requests, urllib -pip install tripwire[redis] # redis -pip install tripwire[boto3] # botocore -pip install tripwire[pymongo] # pymongo +pip install python-tripwire[all] # Everything +pip install python-tripwire[http] # httpx, requests, urllib +pip install python-tripwire[redis] # redis +pip install python-tripwire[boto3] # botocore +pip install python-tripwire[pymongo] # pymongo # ... see pyproject.toml [project.optional-dependencies] for full list ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 0495465..a80c482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **Breaking:** Renamed package `bigfoot` to `tripwire`. PyPI distribution, Python import name, public API symbols, exception class names, internal sentinels, pytest fixtures, pytest entry-point, and the `[tool.bigfoot]` config table all rename to `tripwire` / `[tool.tripwire]`. No deprecation alias. A `[tool.bigfoot]` section in pyproject.toml raises `ConfigMigrationError` with a clear rename hint. +- **Breaking:** PyPI dist name is `python-tripwire` (the name `tripwire` was unavailable on PyPI). The Python import name remains `import tripwire`. To install: `pip install python-tripwire`. - **Breaking:** Plugin proxy attributes drop the `_mock` suffix. The 26 plugin proxy singletons on the `tripwire` module are now un-suffixed: `tripwire.subprocess`, `tripwire.popen`, `tripwire.smtp`, `tripwire.socket`, `tripwire.db`, `tripwire.async_websocket`, `tripwire.sync_websocket`, `tripwire.redis`, `tripwire.mongo`, `tripwire.dns`, `tripwire.memcache`, `tripwire.celery`, `tripwire.log`, `tripwire.async_subprocess`, `tripwire.psycopg2`, `tripwire.asyncpg`, `tripwire.boto3`, `tripwire.elasticsearch`, `tripwire.jwt`, `tripwire.crypto`, `tripwire.file_io`, `tripwire.pika`, `tripwire.ssh`, `tripwire.grpc`, `tripwire.mcp`, `tripwire.native`. `tripwire.http` was already un-suffixed. Because the package itself was renamed, no `_mock`-suffixed deprecation aliases are retained: migration from `bigfoot._mock.method(...)` becomes `tripwire..method(...)` in a single pass. - **Breaking:** Internal source-id sentinels restructured from underscore-flat (`bigfoot_subprocess_run`) to colon-namespaced `:` (e.g., `subprocess:run`, `httpx:get`, `socket:connect`). User-facing only via `GuardedCallError` messages and the `source_id` argument of plugin APIs. The `tripwire:` prefix is intentionally omitted because the namespace is implicit inside the tripwire package. - **Breaking:** Default `[tool.tripwire] guard` flipped from `"warn"` to `"error"`. New projects fail loud on unmocked I/O outside a sandbox. To preserve prior behavior during legacy migration, set `guard = "warn"` explicitly. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 4c15c8b..ee95658 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -37,7 +37,7 @@ This Code of Conduct applies within all community spaces, and also applies when ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at https://github.com/axiomantic/tripwire/issues. All complaints will be reviewed and investigated promptly and fairly. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at https://github.com/axiomantic/python-tripwire/issues. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7451331..aa454e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Thanks for your interest in contributing to tripwire! This guide will help you g ```bash # Clone the repo -git clone https://github.com/axiomantic/tripwire.git +git clone https://github.com/axiomantic/python-tripwire.git cd tripwire # Create a virtual environment @@ -80,7 +80,7 @@ See the [Writing Plugins](https://axiomantic.github.io/tripwire/guides/writing-p ## Reporting Issues -- Use the [issue tracker](https://github.com/axiomantic/tripwire/issues). +- Use the [issue tracker](https://github.com/axiomantic/python-tripwire/issues). - For bugs, include: Python version, tripwire version, minimal reproduction, and full traceback. - For feature requests, describe the use case and why existing plugins don't cover it. diff --git a/README.md b/README.md index 9c4aec4..1d82072 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # tripwire -[![CI](https://github.com/axiomantic/tripwire/actions/workflows/ci.yml/badge.svg)](https://github.com/axiomantic/tripwire/actions/workflows/ci.yml) -[![PyPI](https://img.shields.io/pypi/v/tripwire)](https://pypi.org/project/tripwire/) +[![CI](https://github.com/axiomantic/python-tripwire/actions/workflows/ci.yml/badge.svg)](https://github.com/axiomantic/python-tripwire/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/python-tripwire)](https://pypi.org/project/python-tripwire/) [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) @@ -15,7 +15,7 @@ This is what testing with `unittest.mock` is like. It gives you the tools to moc tripwire replaces `unittest.mock` with mocking that actually enforces correctness. ```bash -pip install tripwire[all] +pip install python-tripwire[all] ``` ## The three guarantees @@ -445,29 +445,29 @@ Per-call arguments override project-level settings. See the [configuration guide ## Selective Installation -`tripwire[all]` installs everything. For a smaller footprint, pick only what you need: +`python-tripwire[all]` installs everything. For a smaller footprint, pick only what you need: ```bash -pip install tripwire # Core plugins (no optional deps) -pip install tripwire[http] # + httpx, requests, urllib -pip install tripwire[aiohttp] # + aiohttp -pip install tripwire[redis] # + Redis -pip install tripwire[pymemcache] # + Memcached -pip install tripwire[pymongo] # + MongoDB -pip install tripwire[elasticsearch] # + Elasticsearch/OpenSearch -pip install tripwire[psycopg2] # + PostgreSQL (psycopg2) -pip install tripwire[asyncpg] # + PostgreSQL (asyncpg) -pip install tripwire[boto3] # + AWS SDK -pip install tripwire[pika] # + RabbitMQ -pip install tripwire[celery] # + Celery tasks -pip install tripwire[grpc] # + gRPC -pip install tripwire[paramiko] # + SSH -pip install tripwire[jwt] # + PyJWT -pip install tripwire[crypto] # + cryptography -pip install tripwire[cffi] # + cffi (C FFI) -pip install tripwire[websockets] # + async WebSocket -pip install tripwire[websocket-client] # + sync WebSocket -pip install tripwire[matchers] # + dirty-equals matchers +pip install python-tripwire # Core plugins (no optional deps) +pip install python-tripwire[http] # + httpx, requests, urllib +pip install python-tripwire[aiohttp] # + aiohttp +pip install python-tripwire[redis] # + Redis +pip install python-tripwire[pymemcache] # + Memcached +pip install python-tripwire[pymongo] # + MongoDB +pip install python-tripwire[elasticsearch] # + Elasticsearch/OpenSearch +pip install python-tripwire[psycopg2] # + PostgreSQL (psycopg2) +pip install python-tripwire[asyncpg] # + PostgreSQL (asyncpg) +pip install python-tripwire[boto3] # + AWS SDK +pip install python-tripwire[pika] # + RabbitMQ +pip install python-tripwire[celery] # + Celery tasks +pip install python-tripwire[grpc] # + gRPC +pip install python-tripwire[paramiko] # + SSH +pip install python-tripwire[jwt] # + PyJWT +pip install python-tripwire[crypto] # + cryptography +pip install python-tripwire[cffi] # + cffi (C FFI) +pip install python-tripwire[websockets] # + async WebSocket +pip install python-tripwire[websocket-client] # + sync WebSocket +pip install python-tripwire[matchers] # + dirty-equals matchers ``` ## Documentation diff --git a/SECURITY.md b/SECURITY.md index d031d52..8caedd8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,7 +20,7 @@ tripwire is a **testing library** that runs in development and CI environments. **Do not open a public GitHub issue for security vulnerabilities.** -Instead, use [GitHub's private security advisory feature](https://github.com/axiomantic/tripwire/security/advisories/new) to report the issue confidentially. +Instead, use [GitHub's private security advisory feature](https://github.com/axiomantic/python-tripwire/security/advisories/new) to report the issue confidentially. Include: diff --git a/docs/guides/asyncpg-plugin.md b/docs/guides/asyncpg-plugin.md index 806dd6d..411718a 100644 --- a/docs/guides/asyncpg-plugin.md +++ b/docs/guides/asyncpg-plugin.md @@ -5,7 +5,7 @@ ## Installation ```bash -pip install tripwire[asyncpg] +pip install python-tripwire[asyncpg] ``` ## Setup diff --git a/docs/guides/boto3-plugin.md b/docs/guides/boto3-plugin.md index 36bf770..7079734 100644 --- a/docs/guides/boto3-plugin.md +++ b/docs/guides/boto3-plugin.md @@ -5,7 +5,7 @@ ## Installation ```bash -pip install tripwire[boto3] +pip install python-tripwire[boto3] ``` This installs `botocore`. diff --git a/docs/guides/celery-plugin.md b/docs/guides/celery-plugin.md index 297d28a..9620766 100644 --- a/docs/guides/celery-plugin.md +++ b/docs/guides/celery-plugin.md @@ -5,7 +5,7 @@ ## Installation ```bash -pip install tripwire[celery] +pip install python-tripwire[celery] ``` This installs `celery`. diff --git a/docs/guides/crypto-plugin.md b/docs/guides/crypto-plugin.md index f59a35d..e0cbfad 100644 --- a/docs/guides/crypto-plugin.md +++ b/docs/guides/crypto-plugin.md @@ -5,7 +5,7 @@ ## Installation ```bash -pip install tripwire[crypto] +pip install python-tripwire[crypto] ``` This installs `cryptography`. diff --git a/docs/guides/elasticsearch-plugin.md b/docs/guides/elasticsearch-plugin.md index 117cddb..de3e6cb 100644 --- a/docs/guides/elasticsearch-plugin.md +++ b/docs/guides/elasticsearch-plugin.md @@ -5,7 +5,7 @@ ## Installation ```bash -pip install tripwire[elasticsearch] +pip install python-tripwire[elasticsearch] ``` This installs `elasticsearch`. diff --git a/docs/guides/grpc-plugin.md b/docs/guides/grpc-plugin.md index 412a015..d48f70b 100644 --- a/docs/guides/grpc-plugin.md +++ b/docs/guides/grpc-plugin.md @@ -5,7 +5,7 @@ ## Installation ```bash -pip install tripwire[grpc] +pip install python-tripwire[grpc] ``` This installs `grpcio`. diff --git a/docs/guides/http-plugin.md b/docs/guides/http-plugin.md index 0a42baa..4b6c81f 100644 --- a/docs/guides/http-plugin.md +++ b/docs/guides/http-plugin.md @@ -1,16 +1,16 @@ # HttpPlugin Guide -`HttpPlugin` intercepts HTTP calls made through `httpx` (sync and async), `requests`, `urllib`, and `aiohttp` (if installed). It requires the `tripwire[http]` extra. For aiohttp support, also install `tripwire[aiohttp]`. +`HttpPlugin` intercepts HTTP calls made through `httpx` (sync and async), `requests`, `urllib`, and `aiohttp` (if installed). It requires the `python-tripwire[http]` extra. For aiohttp support, also install `python-tripwire[aiohttp]`. ## Installation ```bash -pip install tripwire[http] # httpx, requests, urllib -pip install tripwire[aiohttp] # + aiohttp support -pip install tripwire[http,aiohttp] # both +pip install python-tripwire[http] # httpx, requests, urllib +pip install python-tripwire[aiohttp] # + aiohttp support +pip install python-tripwire[http,aiohttp] # both ``` -`tripwire[http]` installs `httpx>=0.25.0` and `requests>=2.31.0`. `tripwire[aiohttp]` installs `aiohttp>=3.9.0`. +`python-tripwire[http]` installs `httpx>=0.25.0` and `requests>=2.31.0`. `python-tripwire[aiohttp]` installs `aiohttp>=3.9.0`. ## Setup @@ -363,7 +363,7 @@ See the [Configuration Guide](configuration.md) for full details on `[tool.tripw ## Using with aiohttp -Requires `tripwire[aiohttp]`. If aiohttp is not installed, `HttpPlugin` works normally for other transports. +Requires `python-tripwire[aiohttp]`. If aiohttp is not installed, `HttpPlugin` works normally for other transports. ```python import tripwire, aiohttp diff --git a/docs/guides/installation.md b/docs/guides/installation.md index f57afd3..e6d9d8f 100644 --- a/docs/guides/installation.md +++ b/docs/guides/installation.md @@ -5,7 +5,7 @@ Install everything: ```bash -pip install tripwire[all] +pip install python-tripwire[all] ``` This includes all plugins and their optional dependencies (httpx, requests, aiohttp, websockets, websocket-client, redis, psycopg2, asyncpg, dirty-equals). @@ -15,20 +15,20 @@ This includes all plugins and their optional dependencies (httpx, requests, aioh For a more compact installation, pick only the extras you need: ```bash -pip install tripwire # Core plugins (no extra deps) -pip install tripwire[http] # + HttpPlugin (httpx, requests, urllib) -pip install tripwire[aiohttp] # + aiohttp support for HttpPlugin -pip install tripwire[psycopg2] # + Psycopg2Plugin (PostgreSQL) -pip install tripwire[asyncpg] # + AsyncpgPlugin (async PostgreSQL) -pip install tripwire[websockets] # + AsyncWebSocketPlugin -pip install tripwire[websocket-client] # + SyncWebSocketPlugin -pip install tripwire[redis] # + RedisPlugin -pip install tripwire[matchers] # + dirty-equals matchers +pip install python-tripwire # Core plugins (no extra deps) +pip install python-tripwire[http] # + HttpPlugin (httpx, requests, urllib) +pip install python-tripwire[aiohttp] # + aiohttp support for HttpPlugin +pip install python-tripwire[psycopg2] # + Psycopg2Plugin (PostgreSQL) +pip install python-tripwire[asyncpg] # + AsyncpgPlugin (async PostgreSQL) +pip install python-tripwire[websockets] # + AsyncWebSocketPlugin +pip install python-tripwire[websocket-client] # + SyncWebSocketPlugin +pip install python-tripwire[redis] # + RedisPlugin +pip install python-tripwire[matchers] # + dirty-equals matchers ``` ### Core plugins (no extra dependencies) -These plugins are always available with a bare `pip install tripwire`: +These plugins are always available with a bare `pip install python-tripwire`: - `MockPlugin` -- general-purpose mock objects - `SubprocessPlugin` -- `subprocess.run` and `shutil.which` @@ -44,7 +44,7 @@ These plugins are always available with a bare `pip install tripwire`: [dirty-equals](https://dirty-equals.helpmanual.io/) matchers can be used as expected field values in assertions: ```bash -pip install tripwire[matchers] +pip install python-tripwire[matchers] ``` ## pytest fixture diff --git a/docs/guides/jwt-plugin.md b/docs/guides/jwt-plugin.md index 909deca..15ca61d 100644 --- a/docs/guides/jwt-plugin.md +++ b/docs/guides/jwt-plugin.md @@ -5,7 +5,7 @@ ## Installation ```bash -pip install tripwire[jwt] +pip install python-tripwire[jwt] ``` This installs `PyJWT`. diff --git a/docs/guides/mcp-plugin.md b/docs/guides/mcp-plugin.md index f349a4c..91d5376 100644 --- a/docs/guides/mcp-plugin.md +++ b/docs/guides/mcp-plugin.md @@ -5,7 +5,7 @@ ## Installation ```bash -pip install tripwire[mcp] +pip install python-tripwire[mcp] ``` This installs the `mcp` SDK. diff --git a/docs/guides/memcache-plugin.md b/docs/guides/memcache-plugin.md index bd8c2ea..aa694d3 100644 --- a/docs/guides/memcache-plugin.md +++ b/docs/guides/memcache-plugin.md @@ -5,7 +5,7 @@ ## Installation ```bash -pip install tripwire[pymemcache] +pip install python-tripwire[pymemcache] ``` This installs `pymemcache`. diff --git a/docs/guides/mongo-plugin.md b/docs/guides/mongo-plugin.md index 57a98c2..8fe7a3d 100644 --- a/docs/guides/mongo-plugin.md +++ b/docs/guides/mongo-plugin.md @@ -5,7 +5,7 @@ ## Installation ```bash -pip install tripwire[pymongo] +pip install python-tripwire[pymongo] ``` This installs `pymongo`. diff --git a/docs/guides/pika-plugin.md b/docs/guides/pika-plugin.md index e815cb5..934ec24 100644 --- a/docs/guides/pika-plugin.md +++ b/docs/guides/pika-plugin.md @@ -5,7 +5,7 @@ ## Installation ```bash -pip install tripwire[pika] +pip install python-tripwire[pika] ``` This installs `pika`. diff --git a/docs/guides/psycopg2-plugin.md b/docs/guides/psycopg2-plugin.md index cc226b0..7ff60e5 100644 --- a/docs/guides/psycopg2-plugin.md +++ b/docs/guides/psycopg2-plugin.md @@ -5,7 +5,7 @@ ## Installation ```bash -pip install tripwire[psycopg2] +pip install python-tripwire[psycopg2] ``` ## Setup diff --git a/docs/guides/redis-plugin.md b/docs/guides/redis-plugin.md index 958661f..69eb170 100644 --- a/docs/guides/redis-plugin.md +++ b/docs/guides/redis-plugin.md @@ -5,7 +5,7 @@ ## Installation ```bash -pip install tripwire[redis] +pip install python-tripwire[redis] ``` This installs `redis>=4.0.0`. diff --git a/docs/guides/ssh-plugin.md b/docs/guides/ssh-plugin.md index 447d856..ab3944c 100644 --- a/docs/guides/ssh-plugin.md +++ b/docs/guides/ssh-plugin.md @@ -5,7 +5,7 @@ ## Installation ```bash -pip install tripwire[paramiko] +pip install python-tripwire[paramiko] ``` This installs `paramiko`. diff --git a/docs/guides/stateful-plugins.md b/docs/guides/stateful-plugins.md index 116b65a..eadade2 100644 --- a/docs/guides/stateful-plugins.md +++ b/docs/guides/stateful-plugins.md @@ -215,7 +215,7 @@ assert exc_info.value.valid_states == frozenset({"in_transaction"}) `AsyncWebSocketPlugin` intercepts `websockets.connect` and returns an async context manager that drives the session script. -**Requires:** `pip install tripwire[websockets]` +**Requires:** `pip install python-tripwire[websockets]` **State machine:** @@ -284,7 +284,7 @@ async def test_two_ws_connections(): `SyncWebSocketPlugin` intercepts `websocket.create_connection` from the `websocket-client` library and returns a fake connection object. -**Requires:** `pip install tripwire[websocket-client]` +**Requires:** `pip install python-tripwire[websocket-client]` **State machine:** @@ -493,7 +493,7 @@ The state machine validates that `sendmail` is called from `greeted` (after `ehl `RedisPlugin` intercepts `redis.Redis.execute_command` at the class level. Unlike the other stateful plugins, Redis commands carry no inherent ordering constraint — GET and SET do not depend on each other's state. `RedisPlugin` therefore extends `BasePlugin` directly and uses a per-command FIFO queue rather than a session handle. -**Requires:** `pip install tripwire[redis]` +**Requires:** `pip install python-tripwire[redis]` **Proxy:** `tripwire.redis` diff --git a/docs/guides/websocket-plugin.md b/docs/guides/websocket-plugin.md index 545c50d..88a277f 100644 --- a/docs/guides/websocket-plugin.md +++ b/docs/guides/websocket-plugin.md @@ -12,13 +12,13 @@ Both use the same state machine and assertion pattern. === "Async (websockets)" ```bash - pip install tripwire[websockets] + pip install python-tripwire[websockets] ``` === "Sync (websocket-client)" ```bash - pip install tripwire[websocket-client] + pip install python-tripwire[websocket-client] ``` ## State machine diff --git a/docs/guides/writing-plugins.md b/docs/guides/writing-plugins.md index 9107790..ab00980 100644 --- a/docs/guides/writing-plugins.md +++ b/docs/guides/writing-plugins.md @@ -609,7 +609,7 @@ If you use Claude Code or another AI coding assistant, tripwire includes a proje ## 1st party vs 3rd party plugins -tripwire plugins don't depend on the libraries they intercept at install time. All library dependencies are optional extras (`pip install tripwire[http]`, `pip install tripwire[redis]`, etc.), so a 1st party plugin for any library costs nothing to users who don't install that extra. This means the usual "heavy dependencies" argument for splitting into a separate package doesn't apply. +tripwire plugins don't depend on the libraries they intercept at install time. All library dependencies are optional extras (`pip install python-tripwire[http]`, `pip install python-tripwire[redis]`, etc.), so a 1st party plugin for any library costs nothing to users who don't install that extra. This means the usual "heavy dependencies" argument for splitting into a separate package doesn't apply. ### When to contribute a 1st party plugin @@ -630,10 +630,10 @@ Create a separate package when: ### Packaging a 3rd party plugin -A 3rd party plugin is a standard Python package that depends on `tripwire`. Users install it alongside tripwire: +A 3rd party plugin is a standard Python package that depends on `python-tripwire`. Users install it alongside tripwire: ```bash -pip install tripwire tripwire-myservice +pip install python-tripwire tripwire-myservice ``` **Project structure:** diff --git a/docs/reference/index.md b/docs/reference/index.md index a3432ed..15e2138 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -1,6 +1,6 @@ # API Reference -All public symbols are importable from `tripwire` directly. `HttpPlugin` requires the `tripwire[http]` extra and is imported from `tripwire.plugins.http`. +All public symbols are importable from `tripwire` directly. `HttpPlugin` requires the `python-tripwire[http]` extra and is imported from `tripwire.plugins.http`. ## Public symbols @@ -10,7 +10,7 @@ All public symbols are importable from `tripwire` directly. `HttpPlugin` require | `SandboxContext` | class | Context manager returned by `verifier.sandbox()`. Activates all plugins for the duration of the `with` block. Supports both sync and async. | | `InAnyOrderContext` | class | Context manager returned by `verifier.in_any_order()`. Inside this block, `assert_interaction()` matches any unasserted interaction regardless of timeline order. | | `MockPlugin` | class | Intercepts method calls on named proxy objects. Created automatically by `verifier.mock()`. | -| `HttpPlugin` | class | Intercepts `httpx`, `requests`, and `urllib` HTTP calls. Requires `tripwire[http]`. Import from `tripwire.plugins.http`. | +| `HttpPlugin` | class | Intercepts `httpx`, `requests`, and `urllib` HTTP calls. Requires `python-tripwire[http]`. Import from `tripwire.plugins.http`. | | `SubprocessPlugin` | class | Intercepts `subprocess.run` and `shutil.which`. Included in core tripwire. Import from `tripwire.plugins.subprocess`. | | `subprocess` | proxy | Module-level proxy to `SubprocessPlugin` for the current test. Auto-creates the plugin on first access. | | `TripwireError` | exception | Base class for all tripwire exceptions. | diff --git a/mkdocs.yml b/mkdocs.yml index 2d61594..e882533 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,8 +1,8 @@ site_name: tripwire site_description: "Full-certainty test mocking for Python." site_url: https://axiomantic.github.io/tripwire/ -repo_url: https://github.com/axiomantic/tripwire -repo_name: axiomantic/tripwire +repo_url: https://github.com/axiomantic/python-tripwire +repo_name: axiomantic/python-tripwire theme: name: material diff --git a/pyproject.toml b/pyproject.toml index 752a7ce..317047a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "tripwire" +name = "python-tripwire" version = "0.20.0" description = "Full-certainty test mocking: every call recorded and verified" requires-python = ">=3.10" @@ -33,10 +33,10 @@ dependencies = [ ] [project.urls] -Homepage = "https://github.com/axiomantic/tripwire" -Repository = "https://github.com/axiomantic/tripwire" -Issues = "https://github.com/axiomantic/tripwire/issues" -Changelog = "https://github.com/axiomantic/tripwire/blob/main/CHANGELOG.md" +Homepage = "https://github.com/axiomantic/python-tripwire" +Repository = "https://github.com/axiomantic/python-tripwire" +Issues = "https://github.com/axiomantic/python-tripwire/issues" +Changelog = "https://github.com/axiomantic/python-tripwire/blob/main/CHANGELOG.md" [project.optional-dependencies] http = [ @@ -105,10 +105,10 @@ mcp = [ "mcp>=1.0.0", ] all = [ - "tripwire[http,matchers,websockets,websocket-client,redis,aiohttp,psycopg2,asyncpg,boto3,pika,celery,grpc,pymemcache,pymongo,cffi,jwt,crypto,elasticsearch,dnspython,paramiko,mcp]", + "python-tripwire[http,matchers,websockets,websocket-client,redis,aiohttp,psycopg2,asyncpg,boto3,pika,celery,grpc,pymemcache,pymongo,cffi,jwt,crypto,elasticsearch,dnspython,paramiko,mcp]", ] all-ft = [ - "tripwire[http,matchers,websockets,websocket-client,redis,boto3,jwt,dnspython,pymemcache,elasticsearch]", + "python-tripwire[http,matchers,websockets,websocket-client,redis,boto3,jwt,dnspython,pymemcache,elasticsearch]", ] docs = [ "mkdocs>=1.6", @@ -117,7 +117,7 @@ docs = [ "mike>=2.1", ] dev = [ - "tripwire[all,docs]", + "python-tripwire[all,docs]", "pytest>=7.4.0", "pytest-asyncio>=0.23.0", "pytest-cov>=4.1.0", @@ -125,7 +125,7 @@ dev = [ "ruff>=0.1.0", ] dev-ft = [ - "tripwire[all-ft]", + "python-tripwire[all-ft]", "pytest>=7.4.0", "pytest-asyncio>=0.23.0", "pytest-cov>=4.1.0", diff --git a/src/tripwire/__init__.py b/src/tripwire/__init__.py index a86f576..b265542 100644 --- a/src/tripwire/__init__.py +++ b/src/tripwire/__init__.py @@ -450,8 +450,8 @@ def __getattr__(self, name: str) -> object: from tripwire.plugins.http import HttpPlugin as _HttpPlugin except ImportError: raise ImportError( - "tripwire[http] is required to use tripwire.http. " - "Install it with: pip install tripwire[http]" + "python-tripwire[http] is required to use tripwire.http. " + "Install it with: pip install python-tripwire[http]" ) from None verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _HttpPlugin) @@ -578,8 +578,8 @@ def __getattr__(self, name: str) -> object: if not _WEBSOCKETS_AVAILABLE: raise ImportError( - "tripwire[websockets] is required to use tripwire.async_websocket. " - "Install it with: pip install tripwire[websockets]" + "python-tripwire[websockets] is required to use tripwire.async_websocket. " + "Install it with: pip install python-tripwire[websockets]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _AsyncWebSocketPlugin) @@ -606,8 +606,8 @@ def __getattr__(self, name: str) -> object: if not _WEBSOCKET_CLIENT_AVAILABLE: raise ImportError( - "tripwire[websocket-client] is required to use tripwire.sync_websocket. " - "Install it with: pip install tripwire[websocket-client]" + "python-tripwire[websocket-client] is required to use tripwire.sync_websocket. " + "Install it with: pip install python-tripwire[websocket-client]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _SyncWebSocketPlugin) @@ -634,8 +634,8 @@ def __getattr__(self, name: str) -> object: if not _REDIS_AVAILABLE: raise ImportError( - "tripwire[redis] is required to use tripwire.redis. " - "Install it with: pip install tripwire[redis]" + "python-tripwire[redis] is required to use tripwire.redis. " + "Install it with: pip install python-tripwire[redis]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _RedisPlugin) @@ -704,8 +704,8 @@ def __getattr__(self, name: str) -> object: if not _PIKA_AVAILABLE: raise ImportError( - "tripwire[pika] is required to use tripwire.pika. " - "Install it with: pip install tripwire[pika]" + "python-tripwire[pika] is required to use tripwire.pika. " + "Install it with: pip install python-tripwire[pika]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _PikaPlugin) @@ -732,8 +732,8 @@ def __getattr__(self, name: str) -> object: if not _PARAMIKO_AVAILABLE: raise ImportError( - "tripwire[ssh] is required to use tripwire.ssh. " - "Install it with: pip install tripwire[ssh]" + "python-tripwire[ssh] is required to use tripwire.ssh. " + "Install it with: pip install python-tripwire[ssh]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _SshPlugin) @@ -760,8 +760,8 @@ def __getattr__(self, name: str) -> object: if not _GRPC_AVAILABLE: raise ImportError( - "tripwire[grpc] is required to use tripwire.grpc. " - "Install it with: pip install tripwire[grpc]" + "python-tripwire[grpc] is required to use tripwire.grpc. " + "Install it with: pip install python-tripwire[grpc]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _GrpcPlugin) @@ -788,8 +788,8 @@ def __getattr__(self, name: str) -> object: if not _MCP_AVAILABLE: raise ImportError( - "tripwire[mcp] is required to use tripwire.mcp. " - "Install it with: pip install tripwire[mcp]" + "python-tripwire[mcp] is required to use tripwire.mcp. " + "Install it with: pip install python-tripwire[mcp]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _McpPlugin) @@ -816,8 +816,8 @@ def __getattr__(self, name: str) -> object: if not _PYMONGO_AVAILABLE: raise ImportError( - "tripwire[mongo] is required to use tripwire.mongo. " - "Install it with: pip install tripwire[mongo]" + "python-tripwire[mongo] is required to use tripwire.mongo. " + "Install it with: pip install python-tripwire[mongo]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _MongoPlugin) @@ -865,8 +865,8 @@ def __getattr__(self, name: str) -> object: if not _PYMEMCACHE_AVAILABLE: raise ImportError( - "tripwire[pymemcache] is required to use tripwire.memcache. " - "Install it with: pip install tripwire[pymemcache]" + "python-tripwire[pymemcache] is required to use tripwire.memcache. " + "Install it with: pip install python-tripwire[pymemcache]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _MemcachePlugin) @@ -893,8 +893,8 @@ def __getattr__(self, name: str) -> object: if not _CELERY_AVAILABLE: raise ImportError( - "tripwire[celery] is required to use tripwire.celery. " - "Install it with: pip install tripwire[celery]" + "python-tripwire[celery] is required to use tripwire.celery. " + "Install it with: pip install python-tripwire[celery]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _CeleryPlugin) @@ -941,8 +941,8 @@ def __getattr__(self, name: str) -> object: if not _PSYCOPG2_AVAILABLE: raise ImportError( - "tripwire[psycopg2] is required to use tripwire.psycopg2. " - "Install it with: pip install tripwire[psycopg2]" + "python-tripwire[psycopg2] is required to use tripwire.psycopg2. " + "Install it with: pip install python-tripwire[psycopg2]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _Psycopg2Plugin) @@ -969,8 +969,8 @@ def __getattr__(self, name: str) -> object: if not _ASYNCPG_AVAILABLE: raise ImportError( - "tripwire[asyncpg] is required to use tripwire.asyncpg. " - "Install it with: pip install tripwire[asyncpg]" + "python-tripwire[asyncpg] is required to use tripwire.asyncpg. " + "Install it with: pip install python-tripwire[asyncpg]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _AsyncpgPlugin) @@ -997,8 +997,8 @@ def __getattr__(self, name: str) -> object: if not _BOTO3_AVAILABLE: raise ImportError( - "tripwire[boto3] is required to use tripwire.boto3. " - "Install it with: pip install tripwire[boto3]" + "python-tripwire[boto3] is required to use tripwire.boto3. " + "Install it with: pip install python-tripwire[boto3]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _Boto3Plugin) @@ -1025,8 +1025,8 @@ def __getattr__(self, name: str) -> object: if not _ELASTICSEARCH_AVAILABLE: raise ImportError( - "tripwire[elasticsearch] is required to use tripwire.elasticsearch. " - "Install it with: pip install tripwire[elasticsearch]" + "python-tripwire[elasticsearch] is required to use tripwire.elasticsearch. " + "Install it with: pip install python-tripwire[elasticsearch]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _ElasticsearchPlugin) @@ -1053,8 +1053,8 @@ def __getattr__(self, name: str) -> object: if not _JWT_AVAILABLE: raise ImportError( - "tripwire[jwt] is required to use tripwire.jwt. " - "Install it with: pip install tripwire[jwt]" + "python-tripwire[jwt] is required to use tripwire.jwt. " + "Install it with: pip install python-tripwire[jwt]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _JwtPlugin) @@ -1081,8 +1081,8 @@ def __getattr__(self, name: str) -> object: if not _CRYPTOGRAPHY_AVAILABLE: raise ImportError( - "tripwire[crypto] is required to use tripwire.crypto. " - "Install it with: pip install tripwire[crypto]" + "python-tripwire[crypto] is required to use tripwire.crypto. " + "Install it with: pip install python-tripwire[crypto]" ) verifier = _get_test_verifier_or_raise() plugin = _get_or_create_plugin(verifier, _CryptoPlugin) diff --git a/src/tripwire/_registry.py b/src/tripwire/_registry.py index 9313836..a29686b 100644 --- a/src/tripwire/_registry.py +++ b/src/tripwire/_registry.py @@ -264,7 +264,7 @@ def resolve_enabled_plugins( raise TripwireConfigError( f"Plugin '{e.name}' is in enabled_plugins but its " f"dependency '{e.availability_check}' is not installed. " - f"Install with: pip install tripwire[{e.name}]" + f"Install with: pip install python-tripwire[{e.name}]" ) result.append(e) return result diff --git a/src/tripwire/_verifier.py b/src/tripwire/_verifier.py index cf4fe54..398e055 100644 --- a/src/tripwire/_verifier.py +++ b/src/tripwire/_verifier.py @@ -116,7 +116,7 @@ def _auto_instantiate_plugins(self) -> None: raise TripwireConfigError( f"Plugin '{entry.name}' is in enabled_plugins but failed " f"to import. Ensure its dependencies are installed: " - f"pip install tripwire[{entry.name}]" + f"pip install python-tripwire[{entry.name}]" ) # Silent skip only for default-enabled (not explicitly listed) plugins diff --git a/src/tripwire/plugins/boto3_plugin.py b/src/tripwire/plugins/boto3_plugin.py index 9dab3c1..5c04058 100644 --- a/src/tripwire/plugins/boto3_plugin.py +++ b/src/tripwire/plugins/boto3_plugin.py @@ -257,7 +257,8 @@ def install_patches(self) -> None: """ if not _BOTO3_AVAILABLE: raise ImportError( - "Install tripwire[boto3] to use Boto3Plugin: pip install tripwire[boto3]" + "Install python-tripwire[boto3] to use Boto3Plugin: " + "pip install python-tripwire[boto3]" ) # Save current env values and inject dummy credentials for key, value in self._CREDENTIAL_ENV_VARS.items(): diff --git a/src/tripwire/plugins/celery_plugin.py b/src/tripwire/plugins/celery_plugin.py index 6cfcae7..954fe15 100644 --- a/src/tripwire/plugins/celery_plugin.py +++ b/src/tripwire/plugins/celery_plugin.py @@ -271,7 +271,8 @@ def install_patches(self) -> None: """Install Celery Task.delay and Task.apply_async patches.""" if not _CELERY_AVAILABLE: raise ImportError( - "Install tripwire[celery] to use CeleryPlugin: pip install tripwire[celery]" + "Install python-tripwire[celery] to use CeleryPlugin: " + "pip install python-tripwire[celery]" ) from celery.app.task import Task diff --git a/src/tripwire/plugins/crypto_plugin.py b/src/tripwire/plugins/crypto_plugin.py index 5278e44..bdc0710 100644 --- a/src/tripwire/plugins/crypto_plugin.py +++ b/src/tripwire/plugins/crypto_plugin.py @@ -283,7 +283,8 @@ def install_patches(self) -> None: """Install cryptography Fernet and RSA patches.""" if not _CRYPTOGRAPHY_AVAILABLE: raise ImportError( - "Install tripwire[crypto] to use CryptoPlugin: pip install tripwire[crypto]" + "Install python-tripwire[crypto] to use CryptoPlugin: " + "pip install python-tripwire[crypto]" ) CryptoPlugin._original_encrypt = _Fernet.encrypt CryptoPlugin._original_decrypt = _Fernet.decrypt diff --git a/src/tripwire/plugins/elasticsearch_plugin.py b/src/tripwire/plugins/elasticsearch_plugin.py index 20d58aa..da8a8da 100644 --- a/src/tripwire/plugins/elasticsearch_plugin.py +++ b/src/tripwire/plugins/elasticsearch_plugin.py @@ -222,8 +222,8 @@ def install_patches(self) -> None: """Install Elasticsearch method patches.""" if not _ELASTICSEARCH_AVAILABLE: raise ImportError( - "Install tripwire[elasticsearch] to use ElasticsearchPlugin: " - "pip install tripwire[elasticsearch]" + "Install python-tripwire[elasticsearch] to use ElasticsearchPlugin: " + "pip install python-tripwire[elasticsearch]" ) es_cls = es_lib.Elasticsearch diff --git a/src/tripwire/plugins/grpc_plugin.py b/src/tripwire/plugins/grpc_plugin.py index 4310fec..4bdf7b2 100644 --- a/src/tripwire/plugins/grpc_plugin.py +++ b/src/tripwire/plugins/grpc_plugin.py @@ -396,7 +396,7 @@ def install_patches(self) -> None: """Install gRPC channel patches.""" if not _GRPC_AVAILABLE: raise ImportError( - "Install tripwire[grpc] to use GrpcPlugin: pip install tripwire[grpc]" + "Install python-tripwire[grpc] to use GrpcPlugin: pip install python-tripwire[grpc]" ) GrpcPlugin._original_insecure_channel = grpc_lib.insecure_channel GrpcPlugin._original_secure_channel = grpc_lib.secure_channel diff --git a/src/tripwire/plugins/http.py b/src/tripwire/plugins/http.py index 0492ff7..bfb48c9 100644 --- a/src/tripwire/plugins/http.py +++ b/src/tripwire/plugins/http.py @@ -17,8 +17,8 @@ import requests.adapters except ImportError as exc: # pragma: no cover raise ImportError( - "tripwire[http] extra is required to use HttpPlugin. " - "Install with: pip install tripwire[http]" + "python-tripwire[http] extra is required to use HttpPlugin. " + "Install with: pip install python-tripwire[http]" ) from exc try: @@ -285,7 +285,7 @@ def _identify_patcher(method: object) -> str: class HttpPlugin(BasePlugin): - """HTTP interception plugin. Requires tripwire[http] extra. + """HTTP interception plugin. Requires python-tripwire[http] extra. Patches httpx sync/async transports, requests HTTPAdapter, urllib openers, and aiohttp ClientSession (if installed) at the class level. Uses reference diff --git a/src/tripwire/plugins/jwt_plugin.py b/src/tripwire/plugins/jwt_plugin.py index 1d80efc..c770bb2 100644 --- a/src/tripwire/plugins/jwt_plugin.py +++ b/src/tripwire/plugins/jwt_plugin.py @@ -230,7 +230,7 @@ def install_patches(self) -> None: """Install jwt.encode and jwt.decode patches.""" if not _JWT_AVAILABLE: raise ImportError( - "Install tripwire[jwt] to use JwtPlugin: pip install tripwire[jwt]" + "Install python-tripwire[jwt] to use JwtPlugin: pip install python-tripwire[jwt]" ) JwtPlugin._original_encode = jwt_lib.encode JwtPlugin._original_decode = jwt_lib.decode diff --git a/src/tripwire/plugins/mcp_plugin.py b/src/tripwire/plugins/mcp_plugin.py index 1339b2d..1c97bdb 100644 --- a/src/tripwire/plugins/mcp_plugin.py +++ b/src/tripwire/plugins/mcp_plugin.py @@ -513,7 +513,7 @@ def install_patches(self) -> None: """Install MCP client/server patches.""" if not _MCP_AVAILABLE: raise ImportError( - "Install tripwire[mcp] to use McpPlugin: pip install tripwire[mcp]" + "Install python-tripwire[mcp] to use McpPlugin: pip install python-tripwire[mcp]" ) McpPlugin._original_call_tool = _ClientSession.call_tool McpPlugin._original_read_resource = _ClientSession.read_resource diff --git a/src/tripwire/plugins/memcache_plugin.py b/src/tripwire/plugins/memcache_plugin.py index 1da568d..dca5d06 100644 --- a/src/tripwire/plugins/memcache_plugin.py +++ b/src/tripwire/plugins/memcache_plugin.py @@ -231,8 +231,8 @@ def install_patches(self) -> None: """Install pymemcache Client method patches.""" if not _PYMEMCACHE_AVAILABLE: raise ImportError( - "Install tripwire[pymemcache] to use MemcachePlugin: " - "pip install tripwire[pymemcache]" + "Install python-tripwire[pymemcache] to use MemcachePlugin: " + "pip install python-tripwire[pymemcache]" ) from pymemcache.client.base import Client diff --git a/src/tripwire/plugins/mongo_plugin.py b/src/tripwire/plugins/mongo_plugin.py index 971ebc9..6c098ca 100644 --- a/src/tripwire/plugins/mongo_plugin.py +++ b/src/tripwire/plugins/mongo_plugin.py @@ -307,7 +307,8 @@ def install_patches(self) -> None: """Install pymongo Collection method patches.""" if not _PYMONGO_AVAILABLE: raise ImportError( - "Install tripwire[mongo] to use MongoPlugin: pip install tripwire[mongo]" + "Install python-tripwire[mongo] to use MongoPlugin: " + "pip install python-tripwire[mongo]" ) # Patch MongoClient.__init__ to capture connection metadata diff --git a/src/tripwire/plugins/redis_plugin.py b/src/tripwire/plugins/redis_plugin.py index 1c0a6ab..cea50f1 100644 --- a/src/tripwire/plugins/redis_plugin.py +++ b/src/tripwire/plugins/redis_plugin.py @@ -200,7 +200,8 @@ def install_patches(self) -> None: """Install Redis.execute_command patch.""" if not _REDIS_AVAILABLE: raise ImportError( - "Install tripwire[redis] to use RedisPlugin: pip install tripwire[redis]" + "Install python-tripwire[redis] to use RedisPlugin: " + "pip install python-tripwire[redis]" ) # Patch __init__ to capture connection metadata if RedisPlugin._original_init is None: diff --git a/src/tripwire/plugins/websocket_plugin.py b/src/tripwire/plugins/websocket_plugin.py index a4045a4..a6fe6ad 100644 --- a/src/tripwire/plugins/websocket_plugin.py +++ b/src/tripwire/plugins/websocket_plugin.py @@ -228,8 +228,8 @@ def _unmocked_source_id(self) -> str: def install_patches(self) -> None: if not _WEBSOCKETS_AVAILABLE: raise ImportError( - "Install tripwire[websockets] to use AsyncWebSocketPlugin: " - "pip install tripwire[websockets]" + "Install python-tripwire[websockets] to use AsyncWebSocketPlugin: " + "pip install python-tripwire[websockets]" ) import websockets as _ws @@ -479,8 +479,8 @@ def _unmocked_source_id(self) -> str: def install_patches(self) -> None: if not _WEBSOCKET_CLIENT_AVAILABLE: raise ImportError( - "Install tripwire[websocket-client] to use SyncWebSocketPlugin: " - "pip install tripwire[websocket-client]" + "Install python-tripwire[websocket-client] to use SyncWebSocketPlugin: " + "pip install python-tripwire[websocket-client]" ) import websocket as _wsc diff --git a/tests/unit/test_boto3_plugin.py b/tests/unit/test_boto3_plugin.py index 6b17859..0317905 100644 --- a/tests/unit/test_boto3_plugin.py +++ b/tests/unit/test_boto3_plugin.py @@ -73,7 +73,7 @@ def test_activate_raises_when_boto3_unavailable(monkeypatch: pytest.MonkeyPatch) with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install tripwire[boto3] to use Boto3Plugin: pip install tripwire[boto3]" + "Install python-tripwire[boto3] to use Boto3Plugin: pip install python-tripwire[boto3]" ) diff --git a/tests/unit/test_celery_plugin.py b/tests/unit/test_celery_plugin.py index faa86b7..fd4ecc1 100644 --- a/tests/unit/test_celery_plugin.py +++ b/tests/unit/test_celery_plugin.py @@ -81,7 +81,7 @@ def test_activate_raises_when_celery_unavailable(monkeypatch: pytest.MonkeyPatch with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install tripwire[celery] to use CeleryPlugin: pip install tripwire[celery]" + "Install python-tripwire[celery] to use CeleryPlugin: pip install python-tripwire[celery]" ) diff --git a/tests/unit/test_crypto_plugin.py b/tests/unit/test_crypto_plugin.py index 33b687d..ebc1589 100644 --- a/tests/unit/test_crypto_plugin.py +++ b/tests/unit/test_crypto_plugin.py @@ -64,7 +64,7 @@ def test_activate_raises_when_cryptography_unavailable(monkeypatch: pytest.Monke with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install tripwire[crypto] to use CryptoPlugin: pip install tripwire[crypto]" + "Install python-tripwire[crypto] to use CryptoPlugin: pip install python-tripwire[crypto]" ) diff --git a/tests/unit/test_elasticsearch_plugin.py b/tests/unit/test_elasticsearch_plugin.py index fdafd5e..9ba150a 100644 --- a/tests/unit/test_elasticsearch_plugin.py +++ b/tests/unit/test_elasticsearch_plugin.py @@ -64,8 +64,8 @@ def test_activate_raises_when_elasticsearch_unavailable(monkeypatch: pytest.Monk with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install tripwire[elasticsearch] to use ElasticsearchPlugin: " - "pip install tripwire[elasticsearch]" + "Install python-tripwire[elasticsearch] to use ElasticsearchPlugin: " + "pip install python-tripwire[elasticsearch]" ) diff --git a/tests/unit/test_explicit_enable_error.py b/tests/unit/test_explicit_enable_error.py index ceb26aa..d5f44b4 100644 --- a/tests/unit/test_explicit_enable_error.py +++ b/tests/unit/test_explicit_enable_error.py @@ -33,7 +33,7 @@ def test_explicit_enable_missing_dep_error_message_contains_install_hint(self) - entry = _fake_entry("fakeplugin", "nonexistent_module_xyz") with patch("tripwire._registry.PLUGIN_REGISTRY", (entry,)): with patch("tripwire._registry.VALID_PLUGIN_NAMES", frozenset({"fakeplugin"})): - with pytest.raises(TripwireConfigError, match=r"pip install tripwire\[fakeplugin\]"): + with pytest.raises(TripwireConfigError, match=r"pip install python-tripwire\[fakeplugin\]"): resolve_enabled_plugins({"enabled_plugins": ["fakeplugin"]}) def test_default_enable_missing_dep_silent_skip(self) -> None: diff --git a/tests/unit/test_grpc_plugin.py b/tests/unit/test_grpc_plugin.py index 12ae207..d187ca3 100644 --- a/tests/unit/test_grpc_plugin.py +++ b/tests/unit/test_grpc_plugin.py @@ -84,7 +84,7 @@ def test_activate_raises_when_grpc_unavailable(monkeypatch: pytest.MonkeyPatch) with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install tripwire[grpc] to use GrpcPlugin: pip install tripwire[grpc]" + "Install python-tripwire[grpc] to use GrpcPlugin: pip install python-tripwire[grpc]" ) diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index aafa0ab..d07c65a 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -385,7 +385,7 @@ def test_async_websocket_raises_import_error_when_websockets_unavailable( CLAIM: Accessing any attribute on async_websocket raises ImportError with instructions when tripwire.plugins.websocket_plugin._WEBSOCKETS_AVAILABLE is False. PATH: _AsyncWebSocketProxy.__getattr__ -> checks _WEBSOCKETS_AVAILABLE -> raises ImportError. - CHECK: Raises ImportError with message containing "tripwire[websockets]" and "pip install". + CHECK: Raises ImportError with message containing "python-tripwire[websockets]" and "pip install". MUTATION: If __getattr__ does not check _WEBSOCKETS_AVAILABLE, the error is deferred to activate() time (inside a test context), and the message will be different or absent. ESCAPE: A proxy that checks availability but emits a wrong message would still pass the @@ -399,7 +399,7 @@ def test_async_websocket_raises_import_error_when_websockets_unavailable( with pytest.raises(ImportError) as exc_info: _ = tripwire.async_websocket.new_session # noqa: B018 - assert "tripwire[websockets]" in str(exc_info.value) + assert "python-tripwire[websockets]" in str(exc_info.value) assert "pip install" in str(exc_info.value) @@ -499,7 +499,7 @@ def test_sync_websocket_raises_import_error_when_websocket_client_unavailable( CLAIM: Accessing any attribute on sync_websocket raises ImportError with instructions when tripwire.plugins.websocket_plugin._WEBSOCKET_CLIENT_AVAILABLE is False. PATH: _SyncWebSocketProxy.__getattr__ -> checks _WEBSOCKET_CLIENT_AVAILABLE -> raises ImportError. - CHECK: Raises ImportError with message containing "tripwire[websocket-client]" and "pip install". + CHECK: Raises ImportError with message containing "python-tripwire[websocket-client]" and "pip install". MUTATION: If __getattr__ does not check _WEBSOCKET_CLIENT_AVAILABLE, the error is deferred to activate() time (inside a test context), and the message will be different or absent. ESCAPE: A proxy that checks availability but emits a wrong message would still pass the @@ -513,7 +513,7 @@ def test_sync_websocket_raises_import_error_when_websocket_client_unavailable( with pytest.raises(ImportError) as exc_info: _ = tripwire.sync_websocket.new_session # noqa: B018 - assert "tripwire[websocket-client]" in str(exc_info.value) + assert "python-tripwire[websocket-client]" in str(exc_info.value) assert "pip install" in str(exc_info.value) diff --git a/tests/unit/test_jwt_plugin.py b/tests/unit/test_jwt_plugin.py index 0b8350b..03c294c 100644 --- a/tests/unit/test_jwt_plugin.py +++ b/tests/unit/test_jwt_plugin.py @@ -64,7 +64,7 @@ def test_activate_raises_when_jwt_unavailable(monkeypatch: pytest.MonkeyPatch) - with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install tripwire[jwt] to use JwtPlugin: pip install tripwire[jwt]" + "Install python-tripwire[jwt] to use JwtPlugin: pip install python-tripwire[jwt]" ) diff --git a/tests/unit/test_mcp_plugin.py b/tests/unit/test_mcp_plugin.py index 9e8292a..b306c14 100644 --- a/tests/unit/test_mcp_plugin.py +++ b/tests/unit/test_mcp_plugin.py @@ -71,7 +71,7 @@ def test_activate_raises_when_mcp_unavailable(monkeypatch: pytest.MonkeyPatch) - with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install tripwire[mcp] to use McpPlugin: pip install tripwire[mcp]" + "Install python-tripwire[mcp] to use McpPlugin: pip install python-tripwire[mcp]" ) diff --git a/tests/unit/test_memcache_plugin.py b/tests/unit/test_memcache_plugin.py index ddddfb0..169162a 100644 --- a/tests/unit/test_memcache_plugin.py +++ b/tests/unit/test_memcache_plugin.py @@ -67,7 +67,7 @@ def test_activate_raises_when_pymemcache_unavailable(monkeypatch: pytest.MonkeyP with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install tripwire[pymemcache] to use MemcachePlugin: pip install tripwire[pymemcache]" + "Install python-tripwire[pymemcache] to use MemcachePlugin: pip install python-tripwire[pymemcache]" ) diff --git a/tests/unit/test_mongo_plugin.py b/tests/unit/test_mongo_plugin.py index 79b313c..a578ca3 100644 --- a/tests/unit/test_mongo_plugin.py +++ b/tests/unit/test_mongo_plugin.py @@ -103,7 +103,7 @@ def test_activate_raises_when_pymongo_unavailable(monkeypatch: pytest.MonkeyPatc with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install tripwire[mongo] to use MongoPlugin: pip install tripwire[mongo]" + "Install python-tripwire[mongo] to use MongoPlugin: pip install python-tripwire[mongo]" ) diff --git a/tests/unit/test_pika_plugin.py b/tests/unit/test_pika_plugin.py index c9be5cf..1f8a60a 100644 --- a/tests/unit/test_pika_plugin.py +++ b/tests/unit/test_pika_plugin.py @@ -690,7 +690,7 @@ def test_pika_available_flag() -> None: # ESCAPE: test_pika_mock_proxy_raises_import_error_when_unavailable # CLAIM: Accessing tripwire.pika raises ImportError when pika is not installed. # PATH: _PikaProxy.__getattr__ -> checks _PIKA_AVAILABLE -> raises ImportError. -# CHECK: ImportError raised with message containing "tripwire[pika]" and "pip install". +# CHECK: ImportError raised with message containing "python-tripwire[pika]" and "pip install". # MUTATION: Not checking _PIKA_AVAILABLE would defer the error. # ESCAPE: Wrong message would fail the string check. def test_pika_mock_proxy_raises_import_error_when_unavailable( @@ -704,8 +704,8 @@ def test_pika_mock_proxy_raises_import_error_when_unavailable( _ = tripwire.pika.new_session # noqa: B018 assert str(exc_info.value) == ( - "tripwire[pika] is required to use tripwire.pika. " - "Install it with: pip install tripwire[pika]" + "python-tripwire[pika] is required to use tripwire.pika. " + "Install it with: pip install python-tripwire[pika]" ) diff --git a/tests/unit/test_project_structure.py b/tests/unit/test_project_structure.py index b6e5966..6bbb130 100644 --- a/tests/unit/test_project_structure.py +++ b/tests/unit/test_project_structure.py @@ -69,7 +69,7 @@ def test_pyproject_toml_package_name_is_tripwire() -> None: pyproject = PROJECT_ROOT / "pyproject.toml" data = tomllib.loads(pyproject.read_bytes().decode()) name = data.get("project", {}).get("name") - assert name == "tripwire", f"[project].name must be 'tripwire', got {name!r}" + assert name == "python-tripwire", f"[project].name must be 'python-tripwire', got {name!r}" def test_pyproject_toml_python_requirement() -> None: diff --git a/tests/unit/test_redis_plugin.py b/tests/unit/test_redis_plugin.py index 3aa8800..af36b37 100644 --- a/tests/unit/test_redis_plugin.py +++ b/tests/unit/test_redis_plugin.py @@ -81,7 +81,7 @@ def test_activate_raises_when_redis_unavailable(monkeypatch: pytest.MonkeyPatch) with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install tripwire[redis] to use RedisPlugin: pip install tripwire[redis]" + "Install python-tripwire[redis] to use RedisPlugin: pip install python-tripwire[redis]" ) diff --git a/tests/unit/test_ssh_plugin.py b/tests/unit/test_ssh_plugin.py index 8e91c39..4940bea 100644 --- a/tests/unit/test_ssh_plugin.py +++ b/tests/unit/test_ssh_plugin.py @@ -936,8 +936,8 @@ def test_ssh_mock_proxy_raises_import_error_when_unavailable( _ = tripwire.ssh.new_session # noqa: B018 assert str(exc_info.value) == ( - "tripwire[ssh] is required to use tripwire.ssh. " - "Install it with: pip install tripwire[ssh]" + "python-tripwire[ssh] is required to use tripwire.ssh. " + "Install it with: pip install python-tripwire[ssh]" ) diff --git a/tests/unit/test_websocket_plugin.py b/tests/unit/test_websocket_plugin.py index 18aee3a..0efa680 100644 --- a/tests/unit/test_websocket_plugin.py +++ b/tests/unit/test_websocket_plugin.py @@ -346,7 +346,7 @@ def test_async_activate_raises_when_unavailable(monkeypatch: pytest.MonkeyPatch) with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install tripwire[websockets] to use AsyncWebSocketPlugin: pip install tripwire[websockets]" + "Install python-tripwire[websockets] to use AsyncWebSocketPlugin: pip install python-tripwire[websockets]" ) @@ -630,8 +630,8 @@ def test_sync_activate_raises_when_unavailable(monkeypatch: pytest.MonkeyPatch) with pytest.raises(ImportError) as exc_info: p.activate() assert str(exc_info.value) == ( - "Install tripwire[websocket-client] to use SyncWebSocketPlugin: " - "pip install tripwire[websocket-client]" + "Install python-tripwire[websocket-client] to use SyncWebSocketPlugin: " + "pip install python-tripwire[websocket-client]" ) From 809af40880dae38fbaefa76078a9cd020a81f378 Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:51:43 -0500 Subject: [PATCH 29/33] Look up dist metadata by python-tripwire in smoke test importlib.metadata.distribution() / version() resolve by PyPI dist name, not import name. After the dist rename to python-tripwire, the smoke test assertions had to follow. --- tests/unit/test_smoke_rename.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_smoke_rename.py b/tests/unit/test_smoke_rename.py index 754f97a..eb249a2 100644 --- a/tests/unit/test_smoke_rename.py +++ b/tests/unit/test_smoke_rename.py @@ -40,7 +40,7 @@ def test_import_tripwire_resolves() -> None: f"tripwire imported from {module_path}, expected under {expected_pkg_dir}" ) - assert importlib.metadata.version("tripwire") == "0.20.0" + assert importlib.metadata.version("python-tripwire") == "0.20.0" # C1-T2 @@ -53,8 +53,8 @@ def test_pytest_entrypoint_registered() -> None: - A stale legacy entry-point (e.g., `bigfoot`) still being registered. - The entry-point existing in metadata but failing to load at pytest start. """ - # Half 1: the pytest11 entry-point is registered against tripwire 0.20.0. - dist = importlib.metadata.distribution("tripwire") + # Half 1: the pytest11 entry-point is registered against python-tripwire 0.20.0. + dist = importlib.metadata.distribution("python-tripwire") pytest11_eps = [ep for ep in dist.entry_points if ep.group == "pytest11"] assert pytest11_eps == [ importlib.metadata.EntryPoint( From c78b2742ac263e5f9c8872d7462360d76041adcc Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:14:49 -0500 Subject: [PATCH 30/33] Eager-populate plugin lookup cache; rsplit source_id for method Address Gemini review feedback (sixth pass) on PR #57. _errors.py: GuardedCallError._build_message used split(':', 1) to extract the method name from source_id, which produced 'subprocess:spawn' for a multi-colon id like 'asyncio:subprocess:spawn'. Switch to rsplit(':', 1) so the trailing segment is always treated as the method name regardless of how many leading namespace segments precede it. _registry.py: PLUGIN_REGISTRY is module-frozen at import time, so the hot-path lookup cache can be eagerly populated once during module load and read lock-free thereafter. Add _populate_lookup_cache(), call it exactly once at the bottom of the module, and rewrite lookup_plugin_class_by_name so the read path is a plain dict.get with no lock acquisition for known canonical names and guard prefixes. The lock is only taken for never-before-seen names to memoize the negative result. _clear_lookup_cache re-populates the eager entries after clearing so test teardown leaves the cache in the same lock-free state. Document the thread-safety contract in the cache comment block. _verifier.py: SandboxContext._active_sandbox_ids stays process-wide. Add a comment block explaining that post-sandbox detection (Branch 2 of get_verifier_or_raise) must see across thread boundaries: worker threads spawned inside a sandbox can outlive its __exit__, and a per-thread tracker would silently misclassify their late interactions as 'no active sandbox' instead of 'post-sandbox', defeating PostSandboxInteractionError's diagnostic purpose. Sandbox enter/exit runs at per-test boundaries, so lock contention is microsecond-scale and effectively theoretical. Tests: - test_errors.py: regression tests covering multi-colon, single-colon, and no-colon source_id rendering for GuardedCallError. - test_registry.py: new tests verifying the read path is lock-free for known names (patches the lock with a sentinel that fails on acquire), that unknown names take the lock exactly once for negative caching, and that every available canonical name is eagerly seeded after import. Quality gates locally: mypy --strict clean, ruff clean, full pytest suite passes (1883 tests). --- src/tripwire/_errors.py | 16 +++-- src/tripwire/_registry.py | 113 +++++++++++++++++++++++++++--------- src/tripwire/_verifier.py | 13 ++++- tests/unit/test_errors.py | 48 +++++++++++++++ tests/unit/test_registry.py | 109 ++++++++++++++++++++++++++++++++++ 5 files changed, 267 insertions(+), 32 deletions(-) diff --git a/src/tripwire/_errors.py b/src/tripwire/_errors.py index 56d7af8..1e98aaa 100644 --- a/src/tripwire/_errors.py +++ b/src/tripwire/_errors.py @@ -272,12 +272,20 @@ def __init__( def _build_message(self) -> str: req = self.firewall_request - # Method-being-called: text after the first ":" in the source_id + # Method-being-called: text after the LAST ":" in the source_id # (e.g., "subprocess:run" -> "run", "asyncio:subprocess:spawn" -> - # "subprocess:spawn"). Falls back to "" if the - # source_id is malformed (no colon). + # "spawn"). Using rsplit means the trailing segment is always + # treated as the method name regardless of how many leading + # plugin/namespace segments precede it. Falls back to + # "" if the source_id is malformed (no colon). + # + # Performance note: rsplit on a short source_id is one C-level + # string scan per intercepted call, on the order of tens of + # nanoseconds. Combined with the lock-free plugin lookup cache + # in `_registry.py`, the per-call dispatch overhead is negligible + # compared to the C-call interception itself. if ":" in self.source_id: - method = self.source_id.split(":", 1)[1] + method = self.source_id.rsplit(":", 1)[1] else: method = "" # User call site: rendered as "file:lineno", or "" diff --git a/src/tripwire/_registry.py b/src/tripwire/_registry.py index a29686b..68dde11 100644 --- a/src/tripwire/_registry.py +++ b/src/tripwire/_registry.py @@ -142,8 +142,19 @@ def get_plugin_class(entry: PluginEntry) -> type[BasePlugin]: # # The registry is effectively immutable for the life of the process # (entries are added at module import; availability can flip only by -# installing a new package, which requires a process restart). This -# makes a lazy populate-once cache correct. +# installing a new package, which requires a process restart). We +# therefore eagerly populate the cache exactly once at module import time +# (see ``_populate_lookup_cache()`` below), seeding both canonical names +# and every declared ``guard_prefix``. +# +# Thread-safety contract: after import-time population, the read path is +# LOCK-FREE. Only the never-before-seen-name slow path takes +# ``_lookup_cache_lock`` to memoize a None result. Hot-path callers +# (canonical names + registered guard_prefixes) hit a plain +# ``dict.get`` and return immediately. Stock CPython's GIL serializes +# dict reads; the free-threaded build (PEP 703) makes ``dict.get`` of a +# stable key atomic, and the cache is effectively immutable for known +# names after import. # # Tests that monkeypatch ``PLUGIN_REGISTRY`` MUST clear the cache via # ``_clear_lookup_cache()`` for their patch to take effect. @@ -154,14 +165,56 @@ def get_plugin_class(entry: PluginEntry) -> type[BasePlugin]: _lookup_cache_lock: Final[threading.Lock] = threading.Lock() +def _populate_lookup_cache() -> None: + """Eagerly seed ``_lookup_cache`` from ``PLUGIN_REGISTRY``. + + For every available entry, this imports the plugin class and inserts + cache entries keyed by both the canonical registry name and every + declared ``guard_prefixes`` value. After this runs, all known plugin + names and prefixes resolve via a lock-free ``dict.get`` on the read + path. Unknown names still fall through to the locked negative-cache + slow path in ``lookup_plugin_class_by_name``. + + Called exactly once at module import time. Also called by + ``_clear_lookup_cache`` to restore the eager state after a test + invalidates the cache. + + Failures to import a single plugin class are swallowed: the entry is + simply not seeded, and a subsequent lookup will go through the slow + path (and likely return None for that name). This preserves the + original lazy behavior for broken/partially-installed plugins. + """ + for entry in PLUGIN_REGISTRY: + if not _is_available(entry): + continue + try: + cls = get_plugin_class(entry) + except Exception: + continue + payload: tuple[type[BasePlugin], str] = (cls, entry.name) + _lookup_cache[entry.name] = payload + for prefix in getattr(cls, "guard_prefixes", ()): + # Canonical name takes precedence if a prefix collides with + # another plugin's canonical name; do not overwrite. + _lookup_cache.setdefault(prefix, payload) + + def _clear_lookup_cache() -> None: - """Drop all cached ``lookup_plugin_class_by_name`` results. + """Drop all cached ``lookup_plugin_class_by_name`` results, then + re-seed the eager entries. Call this from tests that monkeypatch ``PLUGIN_REGISTRY`` so their substitute registry is consulted instead of stale cache entries. + The re-population step ensures that the lock-free read path remains + valid for normal (unpatched) registry entries after the test + teardown clears the cache. Tests that monkeypatch ``PLUGIN_REGISTRY`` + will pick up the patched registry on the next ``_populate_lookup_cache`` + invocation, since this function reads ``PLUGIN_REGISTRY`` at call + time rather than at import time. """ with _lookup_cache_lock: _lookup_cache.clear() + _populate_lookup_cache() def lookup_plugin_class_by_name( @@ -183,35 +236,34 @@ def lookup_plugin_class_by_name( name when looking up per-protocol guard overrides and when populating ``plugin_name`` on errors so the user sees the registry name. - Results are cached at module level: this function is on the hot path - for every intercepted call, and the registry is effectively immutable - after import. Tests that mutate ``PLUGIN_REGISTRY`` must call - ``_clear_lookup_cache()`` for changes to be observed. + Read path is lock-free: ``_lookup_cache`` is eagerly populated at + module import time, so all known plugin names and guard prefixes + resolve via a plain dict ``get``. Only never-before-seen names take + ``_lookup_cache_lock`` to negatively cache the None result. Tests + that mutate ``PLUGIN_REGISTRY`` must call ``_clear_lookup_cache()`` + for changes to be observed. """ + # Lock-free fast path. After import-time population, every known + # canonical name and guard_prefix is a hit; only unseen names miss. + cached = _lookup_cache.get(plugin_name, _UNSET) + if cached is not _UNSET: + # mypy cannot narrow through the sentinel object; we know the + # cached value is the tuple-or-None payload, not the sentinel. + return cached # type: ignore[return-value] + + # Slow path: name not in cache. Take the lock for negative caching + # so concurrent callers don't all repeat the (None) computation. The + # eager populator already covered all canonical names and declared + # prefixes, so reaching here for an available plugin is impossible + # in practice; this branch exists to memoize unknown-name lookups. with _lookup_cache_lock: + # Re-check: another thread may have raced us through the lock + # and populated the entry already. cached = _lookup_cache.get(plugin_name, _UNSET) if cached is not _UNSET: - # mypy cannot narrow through the sentinel object; we know the - # cached value is the tuple-or-None payload, not the sentinel. return cached # type: ignore[return-value] - - result: tuple[type[BasePlugin], str] | None = None - for entry in PLUGIN_REGISTRY: - if not _is_available(entry): - continue - try: - cls = get_plugin_class(entry) - except Exception: - continue - if entry.name == plugin_name or plugin_name in getattr( - cls, "guard_prefixes", () - ): - result = (cls, entry.name) - break - - with _lookup_cache_lock: - _lookup_cache[plugin_name] = result - return result + _lookup_cache[plugin_name] = None + return None def resolve_enabled_plugins( @@ -283,3 +335,10 @@ def resolve_enabled_plugins( # Default: all available plugins that are default-enabled return [e for e in PLUGIN_REGISTRY if e.default_enabled and _is_available(e)] + + +# Eagerly seed ``_lookup_cache`` exactly once at module import time. After +# this runs, every known canonical name and registered ``guard_prefix`` +# is a lock-free dict hit on the hot path. See the module-level cache +# comment block above for the full thread-safety contract. +_populate_lookup_cache() diff --git a/src/tripwire/_verifier.py b/src/tripwire/_verifier.py index 398e055..a036195 100644 --- a/src/tripwire/_verifier.py +++ b/src/tripwire/_verifier.py @@ -454,10 +454,21 @@ class SandboxContext: # Process-wide set of currently active sandbox_ids. ALL reads and # mutations MUST be performed while holding _active_sandbox_ids_lock. # Stock CPython's GIL implicitly serializes set operations, but the - # free-threaded build (PEP 703) does not — concurrent set.add / + # free-threaded build (PEP 703) does not: concurrent set.add / # set.discard / `in` checks against a shared `set` can corrupt its # internal hash table and hang in `__contains__`. The lock is # uncontended on the GIL build, so this is effectively a no-op there. + # + # Process-wide set: post-sandbox detection (Branch 2 of + # get_verifier_or_raise) must see across thread boundaries; worker + # threads can outlive their parent sandbox. A per-thread tracker + # would silently misclassify late interactions from those workers + # as "no active sandbox" instead of "post-sandbox", losing the + # diagnostic precision that PostSandboxInteractionError exists to + # provide. Sandbox enter/exit happens at `with verifier.sandbox():` + # boundaries (per-test, not per-call), so the lock is acquired + # rarely on the write path; the hot-path read in Branch 2 is a + # microsecond-scale `set.__contains__` under a brief lock. _active_sandbox_ids: ClassVar[set[int]] = set() _active_sandbox_ids_lock: ClassVar[threading.Lock] = threading.Lock() diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index 79e2e94..d4baaec 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -652,3 +652,51 @@ def test_tripwire_config_error_exported_from_tripwire() -> None: from tripwire import TripwireConfigError assert TripwireConfigError is not None + + +# --------------------------------------------------------------------------- +# GuardedCallError source_id parsing — regression tests +# --------------------------------------------------------------------------- + + +def test_guarded_call_error_method_uses_last_segment_for_multi_colon_source_id() -> None: + """A source_id with multiple colons must render the LAST segment as the + method name. + + Regression: the prior implementation used ``split(":", 1)[1]`` which + treated everything after the FIRST colon as the method, producing + ``"subprocess:spawn"`` for ``"asyncio:subprocess:spawn"`` instead of + the intended trailing token ``"spawn"``. ``rsplit(":", 1)[1]`` makes + the trailing segment authoritative regardless of how many leading + plugin/namespace segments precede it. + """ + from tripwire._errors import GuardedCallError + + err = GuardedCallError( + source_id="asyncio:subprocess:spawn", + plugin_name="asyncio", + ) + msg = str(err) + # The last segment ("spawn") must be the rendered method. + assert "asyncio.spawn" in msg + # The middle segment must NOT leak into the method position. + assert "asyncio.subprocess:spawn" not in msg + assert "asyncio.subprocess" not in msg + + +def test_guarded_call_error_method_for_single_colon_source_id() -> None: + """The classic 'plugin:method' form still resolves to the method tail.""" + from tripwire._errors import GuardedCallError + + err = GuardedCallError(source_id="subprocess:run", plugin_name="subprocess") + msg = str(err) + assert "subprocess.run" in msg + + +def test_guarded_call_error_method_falls_back_when_no_colon() -> None: + """A malformed source_id with no colon falls back to ''.""" + from tripwire._errors import GuardedCallError + + err = GuardedCallError(source_id="malformed_no_colon", plugin_name="x") + msg = str(err) + assert "" in msg diff --git a/tests/unit/test_registry.py b/tests/unit/test_registry.py index 62c8f41..ed4bc70 100644 --- a/tests/unit/test_registry.py +++ b/tests/unit/test_registry.py @@ -363,3 +363,112 @@ def worker() -> None: # successful populate wins and every subsequent lookup serves the # cached identity. assert all(r is first for r in results) + + def test_known_name_lookup_is_lock_free(self) -> None: + """Looking up a known plugin name MUST NOT acquire the cache lock. + + After import-time eager population, all canonical names and + guard prefixes are cached. The hot-path read is a plain + ``dict.get`` and must not touch ``_lookup_cache_lock``. We + enforce this by patching the lock with a sentinel that fails + loudly if anyone attempts to acquire it. + """ + # Sanity: the eager populator should have already seeded + # "subprocess". Do NOT call _clear_lookup_cache here — that would + # re-populate but also exercise the lock during teardown setup. + # The class-level setup_method already cleared+repopulated. + + class _LockMustNotBeAcquired: + def __enter__(self) -> "_LockMustNotBeAcquired": + raise AssertionError( + "lookup_plugin_class_by_name acquired the cache lock " + "for a known plugin name; the read path must be " + "lock-free after eager population." + ) + + def __exit__(self, *exc: object) -> None: # pragma: no cover + pass + + def acquire(self, *args: object, **kwargs: object) -> bool: + raise AssertionError( + "lookup_plugin_class_by_name acquired the cache lock " + "for a known plugin name; the read path must be " + "lock-free after eager population." + ) + + def release(self) -> None: # pragma: no cover + pass + + from tripwire.plugins.subprocess import SubprocessPlugin + + with patch("tripwire._registry._lookup_cache_lock", _LockMustNotBeAcquired()): + # Canonical name: must NOT acquire the lock. + result = lookup_plugin_class_by_name("subprocess") + assert result is not None + cls, canonical = result + assert cls is SubprocessPlugin + assert canonical == "subprocess" + + # Guard prefix: must also be eagerly cached and lock-free. + result_prefix = lookup_plugin_class_by_name("db") + assert result_prefix is not None + _cls, canonical_prefix = result_prefix + assert canonical_prefix == "database" + + def test_unknown_name_takes_lock_once_for_negative_cache(self) -> None: + """An unregistered name takes the lock exactly once, then is + memoized so subsequent lookups are lock-free. + + Regression: confirms the slow path (unknown name) still + negatively caches under the lock so concurrent callers don't + repeatedly walk the registry for the same missing name. + """ + from tripwire import _registry + + real_lock = _registry._lookup_cache_lock + + class _CountingLock: + def __init__(self) -> None: + self.acquire_count = 0 + + def __enter__(self) -> "_CountingLock": + self.acquire_count += 1 + real_lock.acquire() + return self + + def __exit__(self, *exc: object) -> None: + real_lock.release() + + counter = _CountingLock() + with patch("tripwire._registry._lookup_cache_lock", counter): + # First lookup: unknown name, must acquire the lock to seed + # the negative cache entry. + assert lookup_plugin_class_by_name("nonexistent_xyz_unique") is None + assert counter.acquire_count == 1 + + # Second lookup: now negatively cached, must be lock-free. + assert lookup_plugin_class_by_name("nonexistent_xyz_unique") is None + assert counter.acquire_count == 1 + + def test_eager_population_seeds_all_available_canonical_names(self) -> None: + """Every available registry entry's canonical name must be in the + cache immediately after import (and after _clear_lookup_cache). + + This is the structural invariant that makes the lock-free read + path correct: if any canonical name were missing from the eager + seed, that name would fall through to the locked slow path on + every call. + """ + from tripwire._registry import PLUGIN_REGISTRY, _is_available, _lookup_cache + + for entry in PLUGIN_REGISTRY: + if not _is_available(entry): + continue + assert entry.name in _lookup_cache, ( + f"canonical name {entry.name!r} missing from eager-populated " + f"cache; lookup would take the slow path" + ) + cached = _lookup_cache[entry.name] + assert cached is not None + _cls, canonical = cached + assert canonical == entry.name From 4f844524cc96df337cf9d94bdd1a006559f0445b Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:39:41 -0500 Subject: [PATCH 31/33] Extend user_frame to PostSandbox/UnsafePassthrough errors; fix punctuation UnsafePassthroughError and PostSandboxInteractionError now accept an optional user_frame and render 'at :' (or '') in the same shape as GuardedCallError. Dispatch in _context.py walks to the user frame via walk_to_user_frame() at both raise sites so async-leak and outside-sandbox failures point at the actual call site. Also fixes a stray period in the unknown-key TripwireConfigError messages: the trailing period now closes the sentence only when there is no difflib suggestion; with a suggestion the suggestion replaces the period. --- src/tripwire/_config.py | 6 +- src/tripwire/_context.py | 4 + src/tripwire/_errors.py | 49 +++++++++++- tests/unit/test_config_validation.py | 56 ++++++++++++++ tests/unit/test_pedagogical_messages.py | 83 +++++++++++++++++++++ tests/unit/test_unsafe_passthrough_error.py | 4 + 6 files changed, 197 insertions(+), 5 deletions(-) diff --git a/src/tripwire/_config.py b/src/tripwire/_config.py index 24252af..b2c4842 100644 --- a/src/tripwire/_config.py +++ b/src/tripwire/_config.py @@ -116,7 +116,7 @@ def validate_top_level_schema(config: Mapping[str, Any]) -> None: continue suggestion = _format_suggestion(key, allowed) raise TripwireConfigError( - f"Unknown key {key!r} in [tool.tripwire].{suggestion}" + f"Unknown key {key!r} in [tool.tripwire]{suggestion or '.'}" ) @@ -203,8 +203,8 @@ def _resolve_guard_levels(config: Mapping[str, Any]) -> GuardLevels: if key not in VALID_PLUGIN_NAMES: suggestion = _format_suggestion(key, VALID_PLUGIN_NAMES) raise TripwireConfigError( - f"Unknown protocol {key!r} in [tool.tripwire.guard]." - f"{suggestion}" + f"Unknown protocol {key!r} in [tool.tripwire.guard]" + f"{suggestion or '.'}" ) normalized_value: Any if isinstance(value, bool): diff --git a/src/tripwire/_context.py b/src/tripwire/_context.py index 4d3cadb..c3cfb50 100644 --- a/src/tripwire/_context.py +++ b/src/tripwire/_context.py @@ -151,11 +151,13 @@ def get_verifier_or_raise( closed_sandbox_id = _detect_post_sandbox() if closed_sandbox_id is not None: from tripwire._errors import PostSandboxInteractionError # noqa: PLC0415 + from tripwire._frames import walk_to_user_frame # noqa: PLC0415 raise PostSandboxInteractionError( source_id=source_id, plugin_name=canonical_name, sandbox_id=closed_sandbox_id, + user_frame=walk_to_user_frame(), ) # === Branch 1: sandbox active === @@ -196,10 +198,12 @@ def get_verifier_or_raise( # than warn-and-pass-through, so real I/O does not leak. if plugin_is_unsafe_passthrough: from tripwire._errors import UnsafePassthroughError # noqa: PLC0415 + from tripwire._frames import walk_to_user_frame # noqa: PLC0415 raise UnsafePassthroughError( source_id=source_id, plugin_name=canonical_name, + user_frame=walk_to_user_frame(), ) # === Branch 3b-warn-safe === diff --git a/src/tripwire/_errors.py b/src/tripwire/_errors.py index 1e98aaa..d345d22 100644 --- a/src/tripwire/_errors.py +++ b/src/tripwire/_errors.py @@ -478,16 +478,38 @@ class UnsafePassthroughError(TripwireError): than silently performing a side effect. """ - def __init__(self, source_id: str, plugin_name: str) -> None: + def __init__( + self, + source_id: str, + plugin_name: str, + user_frame: tuple[str, int, str] | None = None, + ) -> None: self.source_id = source_id self.plugin_name = plugin_name + self.user_frame = user_frame super().__init__(self._build_message()) def _build_message(self) -> str: + # Method-being-called: text after the LAST ":" in the source_id. + # Mirrors ``GuardedCallError._build_message`` so the framing prose + # is uniform across the three pedagogical errors. + if ":" in self.source_id: + method = self.source_id.rsplit(":", 1)[1] + else: + method = "" + # User call site: rendered as "file:lineno", or "" + # when the frame walker found no user frame (e.g., spawned thread). + if self.user_frame is None: + site = "" + else: + site = f"{self.user_frame[0]}:{self.user_frame[1]}" return ( f"UnsafePassthroughError: {self.source_id!r} would have caused " f"real I/O outside any 'with tripwire:' block.\n" f"\n" + f" Called {self.plugin_name}.{method} at {site} " + f'OUTSIDE any "with tripwire:" block.\n' + f"\n" f"Plugin {self.plugin_name!r} doesn't support outside-sandbox " f"passthrough (passthrough_safe=False), meaning its passthrough " f"path is NOT a no-op.\n" @@ -505,17 +527,40 @@ class PostSandboxInteractionError(TripwireError): that survived the `with tripwire:` exit. Distinct from the leaked- interaction case (call without ever having entered any sandbox).""" - def __init__(self, source_id: str, plugin_name: str, sandbox_id: int) -> None: + def __init__( + self, + source_id: str, + plugin_name: str, + sandbox_id: int, + user_frame: tuple[str, int, str] | None = None, + ) -> None: self.source_id = source_id self.plugin_name = plugin_name self.sandbox_id = sandbox_id + self.user_frame = user_frame super().__init__(self._build_message()) def _build_message(self) -> str: + # Method-being-called: text after the LAST ":" in the source_id. + # Mirrors ``GuardedCallError._build_message`` so the framing prose + # is uniform across the three pedagogical errors. + if ":" in self.source_id: + method = self.source_id.rsplit(":", 1)[1] + else: + method = "" + # User call site: rendered as "file:lineno", or "" + # when the frame walker found no user frame (e.g., spawned thread). + if self.user_frame is None: + site = "" + else: + site = f"{self.user_frame[0]}:{self.user_frame[1]}" return ( f"PostSandboxInteractionError: {self.source_id!r} fired from a " f"context that survived the exit of sandbox #{self.sandbox_id}.\n" f"\n" + f" Called {self.plugin_name}.{method} at {site} " + f"AFTER the sandbox exited.\n" + f"\n" f"This usually means an asyncio Task, thread, or future was " f"scheduled inside `with tripwire:` and is still running after " f"the block exited.\n" diff --git a/tests/unit/test_config_validation.py b/tests/unit/test_config_validation.py index 625205d..51623a1 100644 --- a/tests/unit/test_config_validation.py +++ b/tests/unit/test_config_validation.py @@ -107,3 +107,59 @@ def test_unknown_per_protocol_guard_key_rejected() -> None: message = str(exc_info.value) assert "subprocesss" in message assert "did you mean: subprocess" in message + + +# --------------------------------------------------------------------------- +# Punctuation: unknown-key error messages must not produce a stray +# trailing dot before the typo suggestion. With a suggestion, the +# suggestion replaces the period; without a suggestion, the period +# closes the sentence. +# --------------------------------------------------------------------------- + + +def test_unknown_top_level_key_punctuation_without_suggestion() -> None: + """No close match -> message ends with a single period, no stray dot.""" + # ``zzqqxx`` has no close match in the allowed set, so difflib returns + # an empty suggestion. The message must end with a single period. + config = {"zzqqxx": 1} + with pytest.raises(TripwireConfigError) as exc_info: + validate_top_level_schema(config) + message = str(exc_info.value) + assert message.endswith("[tool.tripwire].") + # And specifically NOT the double-dot shape that the old code produced. + assert "[tool.tripwire].." not in message + + +def test_unknown_top_level_key_punctuation_with_suggestion() -> None: + """Close match -> suggestion replaces the period; no double dot.""" + config = {"gaurd": "warn"} + with pytest.raises(TripwireConfigError) as exc_info: + validate_top_level_schema(config) + message = str(exc_info.value) + # The suggestion supplies its own leading space; no period precedes it. + assert "[tool.tripwire] (did you mean: guard?)" in message + assert "[tool.tripwire]." not in message + + +def test_unknown_per_protocol_guard_key_punctuation_without_suggestion() -> None: + """No close match in [tool.tripwire.guard] -> ends with a single period.""" + config = {"guard": {"default": "error", "zzqqxx": "error"}} + with pytest.raises(TripwireConfigError) as exc_info: + _resolve_guard_levels(config) + message = str(exc_info.value) + assert message.endswith("[tool.tripwire.guard].") + assert "[tool.tripwire.guard].." not in message + + +def test_unknown_per_protocol_guard_key_punctuation_with_suggestion() -> None: + """Close match in [tool.tripwire.guard] -> suggestion replaces the period.""" + config = {"guard": {"default": "error", "subprocesss": "error"}} + with pytest.raises(TripwireConfigError) as exc_info: + _resolve_guard_levels(config) + message = str(exc_info.value) + # The suggestion supplies its own leading space; no period precedes + # it. ``difflib.get_close_matches`` may return multiple candidates + # (e.g., ``subprocess`` and ``async_subprocess``) so the assertion + # only pins the prefix shape, not the full candidate list. + assert "[tool.tripwire.guard] (did you mean: subprocess" in message + assert "[tool.tripwire.guard]." not in message diff --git a/tests/unit/test_pedagogical_messages.py b/tests/unit/test_pedagogical_messages.py index 4006bfb..5137b0a 100644 --- a/tests/unit/test_pedagogical_messages.py +++ b/tests/unit/test_pedagogical_messages.py @@ -63,3 +63,86 @@ def test_message_renders_unknown_call_site_when_frame_is_none() -> None: """When user_frame is None, message renders ``at ``.""" err = _build_err_with_frame(None) assert "at " in str(err) + + +# --------------------------------------------------------------------------- +# Pedagogical user_frame coverage for UnsafePassthroughError and +# PostSandboxInteractionError. The two newer errors mirror GuardedCallError's +# "at :" rendering so async-leak / outside-sandbox failures +# point at the user's actual call site. +# --------------------------------------------------------------------------- + + +def test_unsafe_passthrough_message_includes_user_call_site() -> None: + """UnsafePassthroughError renders ``at :`` for a real frame.""" + from tripwire._errors import UnsafePassthroughError + + err = UnsafePassthroughError( + source_id="subprocess:run", + plugin_name="subprocess", + user_frame=("/abs/path/test_caller.py", 42, "test_caller"), + ) + assert "at /abs/path/test_caller.py:42" in str(err) + + +def test_unsafe_passthrough_message_renders_unknown_call_site_when_frame_is_none() -> None: + """UnsafePassthroughError with user_frame=None renders ````.""" + from tripwire._errors import UnsafePassthroughError + + err = UnsafePassthroughError( + source_id="subprocess:run", + plugin_name="subprocess", + user_frame=None, + ) + assert "at " in str(err) + + +def test_unsafe_passthrough_message_contains_outside_framing() -> None: + """UnsafePassthroughError names ``OUTSIDE any "with tripwire:" block``.""" + from tripwire._errors import UnsafePassthroughError + + err = UnsafePassthroughError( + source_id="subprocess:run", + plugin_name="subprocess", + user_frame=("/abs/path/test_caller.py", 42, "test_caller"), + ) + assert 'OUTSIDE any "with tripwire:" block' in str(err) + + +def test_post_sandbox_message_includes_user_call_site() -> None: + """PostSandboxInteractionError renders ``at :`` for a real frame.""" + from tripwire._errors import PostSandboxInteractionError + + err = PostSandboxInteractionError( + source_id="test:late", + plugin_name="test", + sandbox_id=7, + user_frame=("/abs/path/test_caller.py", 137, "test_caller"), + ) + assert "at /abs/path/test_caller.py:137" in str(err) + + +def test_post_sandbox_message_renders_unknown_call_site_when_frame_is_none() -> None: + """PostSandboxInteractionError with user_frame=None renders ````.""" + from tripwire._errors import PostSandboxInteractionError + + err = PostSandboxInteractionError( + source_id="test:late", + plugin_name="test", + sandbox_id=7, + user_frame=None, + ) + assert "at " in str(err) + + +def test_post_sandbox_message_contains_after_sandbox_framing() -> None: + """PostSandboxInteractionError names ``AFTER the sandbox exited``.""" + from tripwire._errors import PostSandboxInteractionError + + err = PostSandboxInteractionError( + source_id="test:late", + plugin_name="test", + sandbox_id=7, + user_frame=("/abs/path/test_caller.py", 137, "test_caller"), + ) + assert "AFTER the sandbox exited" in str(err) diff --git a/tests/unit/test_unsafe_passthrough_error.py b/tests/unit/test_unsafe_passthrough_error.py index 596752c..f100010 100644 --- a/tests/unit/test_unsafe_passthrough_error.py +++ b/tests/unit/test_unsafe_passthrough_error.py @@ -33,3 +33,7 @@ def test_message_contains_pedagogical_text() -> None: assert "doesn't support outside-sandbox passthrough" in msg, msg assert "set guard='error'" in msg, msg assert "subprocess" in msg, msg + # C5: When user_frame is omitted the message renders the canonical + # "" placeholder, mirroring GuardedCallError. + assert "at " in msg, msg + assert 'OUTSIDE any "with tripwire:" block' in msg, msg From 045bcb471fd28ab4954c672bd86d60ac8207f493 Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:38:06 -0500 Subject: [PATCH 32/33] Discover entry-point plugins in lookup cache; lift deferred imports; cache config - _registry: lookup_plugin_class_by_name now probes importlib.metadata.entry_points("tripwire.plugins") on cache miss and seeds the result under the entry-point's canonical name and every declared guard_prefix. Eager population from PLUGIN_REGISTRY at import time still covers built-ins on the lock-free fast path; third-party plugins are observed lazily on first lookup. Fixes a regression where guard mode and per-protocol overrides silently broke for entry-point plugins because the eager seed never saw them. - _context: lift PostSandboxInteractionError, UnsafePassthroughError, GuardedCallError, GuardedCallWarning, SandboxNotActiveError, NoActiveVerifierError, walk_to_user_frame, Disposition, and get_firewall_stack to module-level imports. SandboxContext, _current_sandbox_id, and lookup_plugin_class_by_name remain deferred with explanatory comments because tripwire._verifier and the plugin modules transitively re-enter tripwire._context during their own module init. - _config: cache load_tripwire_config via lru_cache(maxsize=8) keyed on the resolved search root (cwd at call time when start is None) so chdir-based tests get fresh entries. Expose cache_clear() and cache_info() on the public function so tests that rewrite an already-observed pyproject.toml mid-session can bust the cache. - tests: add three regression tests in test_registry.py covering entry-point discovery on cache miss, cached lookup after first discovery (no re-walk), and negative caching for unknown names that do not match any built-in or entry-point plugin. --- src/tripwire/_config.py | 56 ++++++++++++--- src/tripwire/_context.py | 39 +++++------ src/tripwire/_registry.py | 133 ++++++++++++++++++++++++++++-------- tests/unit/test_registry.py | 109 +++++++++++++++++++++++++++++ 4 files changed, 282 insertions(+), 55 deletions(-) diff --git a/src/tripwire/_config.py b/src/tripwire/_config.py index b2c4842..7197420 100644 --- a/src/tripwire/_config.py +++ b/src/tripwire/_config.py @@ -3,6 +3,7 @@ from __future__ import annotations import difflib +import functools from collections.abc import Mapping from dataclasses import dataclass, field from pathlib import Path @@ -11,20 +12,27 @@ from tripwire._compat import tomllib -def load_tripwire_config(start: Path | None = None) -> dict[str, Any]: - """Walk up from start (default: Path.cwd()) to find pyproject.toml. +@functools.lru_cache(maxsize=8) +def _load_tripwire_config_cached(search: Path) -> dict[str, Any]: + """Cached implementation of ``load_tripwire_config``. - Returns the [tool.tripwire] table as a dict, or {} if: - - no pyproject.toml found in start or any ancestor directory - - pyproject.toml found but has no [tool.tripwire] section + Cached because this function is called multiple times per test (once + by the session-scoped guard fixture, once per ``StrictVerifier`` + instantiation). ``pyproject.toml`` is expected to be stable during a + test run, so re-reading and re-parsing it on every call is wasted + work. - Raises tomllib.TOMLDecodeError if pyproject.toml is malformed. - This is intentional: a malformed pyproject.toml is a user error that - must not silently produce empty config. + ``maxsize=8`` rather than ``1`` because different search roots must + each cache independently — tests may invoke from different cwds or + pass explicit start paths, and we do not want a path with a + different ancestor chain to evict another entry. + + NOTE: ``search`` is the already-resolved root (never ``None``), so + the cache key reflects the actual filesystem location rather than a + sentinel that captures only the FIRST cwd a process called from. """ from tripwire._errors import ConfigMigrationError # noqa: PLC0415 - search = start or Path.cwd() for directory in (search, *search.parents): candidate = directory / "pyproject.toml" if candidate.is_file(): @@ -40,6 +48,36 @@ def load_tripwire_config(start: Path | None = None) -> dict[str, Any]: return {} +def load_tripwire_config(start: Path | None = None) -> dict[str, Any]: + """Walk up from start (default: Path.cwd()) to find pyproject.toml. + + Returns the [tool.tripwire] table as a dict, or {} if: + - no pyproject.toml found in start or any ancestor directory + - pyproject.toml found but has no [tool.tripwire] section + + Raises tomllib.TOMLDecodeError if pyproject.toml is malformed. + This is intentional: a malformed pyproject.toml is a user error that + must not silently produce empty config. + + Results are cached per-resolved-search-path (``start`` if provided, + else ``Path.cwd()`` at call time). The cache survives across calls + in the same process; tests that REWRITE an already-observed + ``pyproject.toml`` mid-session must call + ``load_tripwire_config.cache_clear()`` in their teardown to bust the + cache. Tests that simply ``chdir`` between cases get fresh entries + because the resolved cwd is the cache key. + """ + search = start if start is not None else Path.cwd() + return _load_tripwire_config_cached(search) + + +# Expose the cache controls on the public function so callers can write +# ``load_tripwire_config.cache_clear()`` without poking at the private +# helper. ``cache_info`` is included for symmetry / debug. +load_tripwire_config.cache_clear = _load_tripwire_config_cached.cache_clear # type: ignore[attr-defined] +load_tripwire_config.cache_info = _load_tripwire_config_cached.cache_info # type: ignore[attr-defined] + + # --------------------------------------------------------------------------- # Guard-level config (C3 - per-protocol guard levels) # --------------------------------------------------------------------------- diff --git a/src/tripwire/_context.py b/src/tripwire/_context.py index c3cfb50..7314af6 100644 --- a/src/tripwire/_context.py +++ b/src/tripwire/_context.py @@ -10,6 +10,21 @@ from typing import TYPE_CHECKING from tripwire._config import GuardLevels +from tripwire._errors import ( + GuardedCallError, + GuardedCallWarning, + NoActiveVerifierError, + PostSandboxInteractionError, + SandboxNotActiveError, + UnsafePassthroughError, +) +from tripwire._firewall import Disposition, get_firewall_stack +from tripwire._frames import walk_to_user_frame + +# Deferred to break circular import: _registry's import-time +# _populate_lookup_cache() imports plugin modules, which import +# tripwire._context. Pulling lookup_plugin_class_by_name to module +# level here would re-enter _context mid-initialization. if TYPE_CHECKING: from tripwire._firewall_request import FirewallRequest @@ -129,6 +144,11 @@ def get_verifier_or_raise( # ``guard_prefix`` like ``"db"``. This is what per-protocol guard # overrides under ``[tool.tripwire.guard]`` key on, and what error # messages report. + # + # Deferred to break circular import: _registry's import-time + # _populate_lookup_cache() imports plugin modules, which import + # tripwire._context. Pulling lookup_plugin_class_by_name to module + # level here would re-enter _context mid-initialization. from tripwire._registry import ( # noqa: PLC0415 lookup_plugin_class_by_name, ) @@ -150,9 +170,6 @@ def get_verifier_or_raise( # for "the sandbox this task came from has exited." closed_sandbox_id = _detect_post_sandbox() if closed_sandbox_id is not None: - from tripwire._errors import PostSandboxInteractionError # noqa: PLC0415 - from tripwire._frames import walk_to_user_frame # noqa: PLC0415 - raise PostSandboxInteractionError( source_id=source_id, plugin_name=canonical_name, @@ -168,8 +185,6 @@ def get_verifier_or_raise( # === Branch 3: guard active === if plugin_cls is not None and _guard_active.get(): if firewall_request is not None: - from tripwire._firewall import Disposition, get_firewall_stack # noqa: PLC0415 - disposition = get_firewall_stack().evaluate(firewall_request) # === Branch 3a: ALLOW === @@ -197,9 +212,6 @@ def get_verifier_or_raise( # If the plugin's passthrough is NOT safe, raise rather # than warn-and-pass-through, so real I/O does not leak. if plugin_is_unsafe_passthrough: - from tripwire._errors import UnsafePassthroughError # noqa: PLC0415 - from tripwire._frames import walk_to_user_frame # noqa: PLC0415 - raise UnsafePassthroughError( source_id=source_id, plugin_name=canonical_name, @@ -209,8 +221,6 @@ def get_verifier_or_raise( # === Branch 3b-warn-safe === import warnings # noqa: PLC0415 - from tripwire._errors import GuardedCallWarning # noqa: PLC0415 - warnings.warn( f"{source_id!r} blocked by firewall. " f"See GuardedCallError docs for fix options.", @@ -220,9 +230,6 @@ def get_verifier_or_raise( raise GuardPassThrough() # === Branch 3b-error (C5: enrich with user call site) === - from tripwire._errors import GuardedCallError # noqa: PLC0415 - from tripwire._frames import walk_to_user_frame # noqa: PLC0415 - user_frame = walk_to_user_frame() raise GuardedCallError( source_id=source_id, @@ -237,9 +244,6 @@ def get_verifier_or_raise( # through to SandboxNotActiveError so their interceptor-level # error paths can run as before. if plugin_is_unsafe_passthrough: - from tripwire._errors import GuardedCallError # noqa: PLC0415 - from tripwire._frames import walk_to_user_frame # noqa: PLC0415 - user_frame = walk_to_user_frame() raise GuardedCallError( source_id=source_id, @@ -255,7 +259,6 @@ def get_verifier_or_raise( raise GuardPassThrough() # === Branch 5: nothing active === - from tripwire._errors import SandboxNotActiveError # noqa: PLC0415 raise SandboxNotActiveError(source_id=source_id) @@ -266,8 +269,6 @@ def _get_test_verifier_or_raise() -> StrictVerifier: Called by module-level API functions (mock, sandbox, assert_interaction, etc.) when no test verifier is active. """ - from tripwire._errors import NoActiveVerifierError - verifier = _current_test_verifier.get() if verifier is None: raise NoActiveVerifierError() diff --git a/src/tripwire/_registry.py b/src/tripwire/_registry.py index 68dde11..9fc8672 100644 --- a/src/tripwire/_registry.py +++ b/src/tripwire/_registry.py @@ -140,23 +140,28 @@ def get_plugin_class(entry: PluginEntry) -> type[BasePlugin]: # registry, runs availability checks (which import modules), and imports # the plugin class on every call: O(N) work over ~27 plugins per dispatch. # -# The registry is effectively immutable for the life of the process -# (entries are added at module import; availability can flip only by -# installing a new package, which requires a process restart). We -# therefore eagerly populate the cache exactly once at module import time -# (see ``_populate_lookup_cache()`` below), seeding both canonical names -# and every declared ``guard_prefix``. +# Built-in plugins from ``PLUGIN_REGISTRY`` are eagerly seeded at module +# import time (see ``_populate_lookup_cache()`` below) so canonical names +# and every declared ``guard_prefix`` resolve via a lock-free +# ``dict.get`` on the read path. Third-party plugins registered via the +# ``tripwire.plugins`` entry-point group are NOT eagerly seeded — doing +# so would cost an ``importlib.metadata.entry_points`` call on every +# import, and the entry-point list cannot be enumerated to extract +# canonical names without loading every plugin class. They are +# discovered lazily on the first cache miss for an unknown name. # -# Thread-safety contract: after import-time population, the read path is -# LOCK-FREE. Only the never-before-seen-name slow path takes -# ``_lookup_cache_lock`` to memoize a None result. Hot-path callers -# (canonical names + registered guard_prefixes) hit a plain -# ``dict.get`` and return immediately. Stock CPython's GIL serializes -# dict reads; the free-threaded build (PEP 703) makes ``dict.get`` of a -# stable key atomic, and the cache is effectively immutable for known -# names after import. +# Thread-safety contract: after import-time population, the built-in +# read path is LOCK-FREE. Only never-before-seen names take +# ``_lookup_cache_lock``, which serves two purposes: (a) probing +# entry-point plugins and seeding their canonical name + guard_prefixes +# on first observation, and (b) negatively caching a None result so +# concurrent callers don't all repeat the discovery walk. Stock +# CPython's GIL serializes dict reads; the free-threaded build (PEP +# 703) makes ``dict.get`` of a stable key atomic, and the cache is +# effectively immutable for known names after first observation. # -# Tests that monkeypatch ``PLUGIN_REGISTRY`` MUST clear the cache via +# Tests that monkeypatch ``PLUGIN_REGISTRY`` or stub +# ``importlib.metadata.entry_points`` MUST clear the cache via # ``_clear_lookup_cache()`` for their patch to take effect. # --------------------------------------------------------------------------- @@ -217,6 +222,60 @@ def _clear_lookup_cache() -> None: _populate_lookup_cache() +def _discover_entrypoint_plugin( + plugin_name: str, +) -> tuple[type[BasePlugin], str] | None: + """Probe ``tripwire.plugins`` entry points for ``plugin_name``. + + Iterates ``importlib.metadata.entry_points(group="tripwire.plugins")`` + and, for each entry point, loads the plugin class and checks whether + its canonical name (``entry_point.name``) or any ``guard_prefixes`` + on the class match ``plugin_name``. Returns the first match's + ``(plugin_class, canonical_name)`` tuple, or None if no entry point + matches. + + On match, the caller is responsible for seeding ``_lookup_cache`` + under both the canonical name and every declared ``guard_prefix`` so + subsequent lookups hit the cache without re-walking entry points. + + Failures to load an individual entry point (ImportError, broken + plugin) are swallowed: the entry point is simply skipped. This + matches the behavior of ``StrictVerifier._load_entrypoint_plugins`` + so a broken third-party plugin does not poison built-in lookups. + """ + from importlib.metadata import entry_points # noqa: PLC0415 + + for ep in entry_points(group="tripwire.plugins"): + try: + plugin_cls = ep.load() + except Exception: + continue + canonical = ep.name + if plugin_name == canonical: + return (plugin_cls, canonical) + if plugin_name in getattr(plugin_cls, "guard_prefixes", ()): + return (plugin_cls, canonical) + return None + + +def _seed_entrypoint_match( + payload: tuple[type[BasePlugin], str], +) -> None: + """Insert ``payload`` under its canonical name and every declared + ``guard_prefix`` in ``_lookup_cache``. + + MUST be called with ``_lookup_cache_lock`` held. Canonical name + takes precedence: a ``guard_prefix`` that collides with another + plugin's canonical name does NOT overwrite the existing entry, to + preserve the same precedence rule used for built-in plugins in + ``_populate_lookup_cache``. + """ + cls, canonical = payload + _lookup_cache[canonical] = payload + for prefix in getattr(cls, "guard_prefixes", ()): + _lookup_cache.setdefault(prefix, payload) + + def lookup_plugin_class_by_name( plugin_name: str, ) -> tuple[type[BasePlugin], str] | None: @@ -236,14 +295,24 @@ def lookup_plugin_class_by_name( name when looking up per-protocol guard overrides and when populating ``plugin_name`` on errors so the user sees the registry name. - Read path is lock-free: ``_lookup_cache`` is eagerly populated at - module import time, so all known plugin names and guard prefixes - resolve via a plain dict ``get``. Only never-before-seen names take - ``_lookup_cache_lock`` to negatively cache the None result. Tests - that mutate ``PLUGIN_REGISTRY`` must call ``_clear_lookup_cache()`` - for changes to be observed. + Resolution order: + + 1. Built-in plugins seeded from ``PLUGIN_REGISTRY`` at import time. + Hit on a lock-free ``dict.get``. + 2. Third-party plugins registered via the ``tripwire.plugins`` entry + point group. Discovered lazily on the first cache miss for a + given name; once observed, the canonical name and every declared + ``guard_prefix`` are seeded into ``_lookup_cache`` so subsequent + lookups also hit the lock-free fast path. + 3. Negative cache: if neither built-in nor entry-point discovery + finds a match, the name is seeded with ``None`` to avoid + re-walking entry points on every miss. + + Tests that mutate ``PLUGIN_REGISTRY`` or stub + ``importlib.metadata.entry_points`` must call + ``_clear_lookup_cache()`` for changes to be observed. """ - # Lock-free fast path. After import-time population, every known + # Lock-free fast path. After import-time population, every built-in # canonical name and guard_prefix is a hit; only unseen names miss. cached = _lookup_cache.get(plugin_name, _UNSET) if cached is not _UNSET: @@ -251,17 +320,27 @@ def lookup_plugin_class_by_name( # cached value is the tuple-or-None payload, not the sentinel. return cached # type: ignore[return-value] - # Slow path: name not in cache. Take the lock for negative caching - # so concurrent callers don't all repeat the (None) computation. The - # eager populator already covered all canonical names and declared - # prefixes, so reaching here for an available plugin is impossible - # in practice; this branch exists to memoize unknown-name lookups. + # Slow path: name not in cache. Take the lock so concurrent callers + # don't all repeat the entry-point walk for the same name. with _lookup_cache_lock: # Re-check: another thread may have raced us through the lock # and populated the entry already. cached = _lookup_cache.get(plugin_name, _UNSET) if cached is not _UNSET: return cached # type: ignore[return-value] + + # Discover third-party plugins via entry points. This is the + # path that fixes "third-party plugins are invisible to + # ``get_verifier_or_raise`` outside a sandbox" — without it, + # guard mode and per-protocol overrides for entry-point plugins + # would silently break. + match = _discover_entrypoint_plugin(plugin_name) + if match is not None: + _seed_entrypoint_match(match) + return match + + # No built-in, no entry-point match: negatively cache so + # subsequent unknown-name lookups stay lock-free. _lookup_cache[plugin_name] = None return None diff --git a/tests/unit/test_registry.py b/tests/unit/test_registry.py index ed4bc70..23d3200 100644 --- a/tests/unit/test_registry.py +++ b/tests/unit/test_registry.py @@ -450,6 +450,115 @@ def __exit__(self, *exc: object) -> None: assert lookup_plugin_class_by_name("nonexistent_xyz_unique") is None assert counter.acquire_count == 1 + def test_entrypoint_plugin_discovered_on_cache_miss(self) -> None: + """A third-party plugin registered via the ``tripwire.plugins`` + entry-point group must be discovered on first lookup of its + canonical name when not already in the cache. + + Regression: a previous round of the eager-populate optimization + seeded ``_lookup_cache`` exclusively from ``PLUGIN_REGISTRY``, + which caused ``get_verifier_or_raise`` to silently treat all + third-party plugins as unknown when called outside a sandbox. + That broke guard mode and per-protocol overrides for every + entry-point-registered plugin. + """ + from tripwire import _registry + + class _FakeThirdPartyPlugin: + guard_prefixes = ("fake_thirdparty_xyz_alias",) + passthrough_safe = False + + class _FakeEntryPoint: + def __init__(self, name: str, cls: type) -> None: + self.name = name + self._cls = cls + + def load(self) -> type: + return self._cls + + fake_eps = [_FakeEntryPoint("fake_thirdparty_xyz", _FakeThirdPartyPlugin)] + + def _fake_entry_points(*, group: str) -> list[_FakeEntryPoint]: + assert group == "tripwire.plugins" + return fake_eps + + _registry._clear_lookup_cache() + with patch("importlib.metadata.entry_points", _fake_entry_points): + # Lookup by canonical entry-point name resolves to the class. + result = lookup_plugin_class_by_name("fake_thirdparty_xyz") + assert result is not None + cls, canonical = result + assert cls is _FakeThirdPartyPlugin + assert canonical == "fake_thirdparty_xyz" + + # Lookup by guard_prefix also resolves. + alias_result = lookup_plugin_class_by_name( + "fake_thirdparty_xyz_alias" + ) + assert alias_result is not None + alias_cls, alias_canonical = alias_result + assert alias_cls is _FakeThirdPartyPlugin + # Canonical name is the entry-point name, not the prefix. + assert alias_canonical == "fake_thirdparty_xyz" + + def test_entrypoint_lookup_cached_after_first_discovery(self) -> None: + """After the first entry-point discovery, repeated lookups MUST + be lock-free hits on the cache, not re-walks of entry points. + """ + from tripwire import _registry + + class _FakeThirdPartyPlugin: + guard_prefixes = () + passthrough_safe = False + + class _FakeEntryPoint: + def __init__(self, name: str, cls: type) -> None: + self.name = name + self._cls = cls + + def load(self) -> type: + return self._cls + + call_count = 0 + + def _counting_entry_points(*, group: str) -> list[_FakeEntryPoint]: + nonlocal call_count + call_count += 1 + return [_FakeEntryPoint("fake_cached_xyz", _FakeThirdPartyPlugin)] + + _registry._clear_lookup_cache() + with patch("importlib.metadata.entry_points", _counting_entry_points): + first = lookup_plugin_class_by_name("fake_cached_xyz") + assert first is not None + assert call_count == 1 + + # Second lookup must hit the lock-free cache, not re-walk + # entry points. + second = lookup_plugin_class_by_name("fake_cached_xyz") + assert second is first + assert call_count == 1 + + def test_unknown_name_negatively_cached_after_entrypoint_walk(self) -> None: + """An unknown name walks entry points once, then is negatively + cached so subsequent lookups don't re-walk. + """ + from tripwire import _registry + + call_count = 0 + + def _counting_entry_points(*, group: str) -> list[object]: + nonlocal call_count + call_count += 1 + return [] + + _registry._clear_lookup_cache() + with patch("importlib.metadata.entry_points", _counting_entry_points): + assert lookup_plugin_class_by_name("absolutely_nothing_xyz") is None + assert call_count == 1 + # Second lookup is negatively cached: must NOT re-walk. + assert lookup_plugin_class_by_name("absolutely_nothing_xyz") is None + assert call_count == 1 + def test_eager_population_seeds_all_available_canonical_names(self) -> None: """Every available registry entry's canonical name must be in the cache immediately after import (and after _clear_lookup_cache). From 33daddf54927628984e875925799a07a093c7c95 Mon Sep 17 00:00:00 2001 From: elijahr <153711+elijahr@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:43:58 -0500 Subject: [PATCH 33/33] Mark file_io, celery, native plugins as passthrough_safe=False Each of these plugins' passthrough path calls the underlying real-world operation: file_io_plugin invokes builtins.open / Path.read_bytes / shutil.copy, celery_plugin invokes Task.delay / Task.apply_async (which enqueue real broker messages), and native_plugin invokes ctypes.CDLL / cffi.FFI.dlopen (which load real native libraries with side effects). Their previous passthrough_safe=True classification was inaccurate: an un-mocked call outside a sandbox was performing real I/O without flagging the gap. Updated the migration table in test_passthrough_safe.py and the per-plugin assertions in test_guard.py accordingly. --- src/tripwire/plugins/celery_plugin.py | 4 +++- src/tripwire/plugins/file_io_plugin.py | 4 +++- src/tripwire/plugins/native_plugin.py | 4 +++- tests/unit/test_guard.py | 16 ++++++++++------ tests/unit/test_passthrough_safe.py | 8 +++++--- 5 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/tripwire/plugins/celery_plugin.py b/src/tripwire/plugins/celery_plugin.py index 954fe15..2d2002a 100644 --- a/src/tripwire/plugins/celery_plugin.py +++ b/src/tripwire/plugins/celery_plugin.py @@ -205,7 +205,9 @@ class CeleryPlugin(BasePlugin): Uses reference counting so nested sandboxes work correctly. """ - passthrough_safe: ClassVar[bool] = True + # Passthrough calls the original Task.delay / Task.apply_async, which + # enqueue real broker messages, so un-mocked calls would dispatch tasks. + passthrough_safe: ClassVar[bool] = False _original_delay: ClassVar[Callable[..., Any] | None] = None _original_apply_async: ClassVar[Callable[..., Any] | None] = None diff --git a/src/tripwire/plugins/file_io_plugin.py b/src/tripwire/plugins/file_io_plugin.py index 963c941..e69226d 100644 --- a/src/tripwire/plugins/file_io_plugin.py +++ b/src/tripwire/plugins/file_io_plugin.py @@ -481,7 +481,9 @@ class FileIoPlugin(BasePlugin): NOT default enabled: requires explicit enabled_plugins = ["file_io"]. """ - passthrough_safe: ClassVar[bool] = True + # Passthrough calls the original (real-disk) file operations, so an + # un-mocked call outside a sandbox WOULD perform real I/O. + passthrough_safe: ClassVar[bool] = False # Saved originals, restored when count reaches 0 _original_open: ClassVar[Callable[..., Any] | None] = None diff --git a/src/tripwire/plugins/native_plugin.py b/src/tripwire/plugins/native_plugin.py index 185ad7f..746e1d8 100644 --- a/src/tripwire/plugins/native_plugin.py +++ b/src/tripwire/plugins/native_plugin.py @@ -259,7 +259,9 @@ class NativePlugin(BasePlugin): Each library:function pair has its own FIFO deque of NativeMockConfig objects. """ - passthrough_safe: ClassVar[bool] = True + # Passthrough calls the original ctypes.CDLL / cffi.FFI.dlopen, which + # actually load native libraries (and trigger their side effects). + passthrough_safe: ClassVar[bool] = False # Saved originals, restored when count reaches 0 _original_cdll_init: ClassVar[Callable[..., Any] | None] = None diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 99438a9..c2716a7 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -227,20 +227,24 @@ def test_crypto_plugin_is_true(self) -> None: assert CryptoPlugin.passthrough_safe is True - def test_native_plugin_is_true(self) -> None: + def test_native_plugin_is_false(self) -> None: + # Passthrough loads real native libraries (ctypes.CDLL / cffi.FFI.dlopen). from tripwire.plugins.native_plugin import NativePlugin - assert NativePlugin.passthrough_safe is True + assert NativePlugin.passthrough_safe is False - def test_celery_plugin_is_true(self) -> None: + def test_celery_plugin_is_false(self) -> None: + # Passthrough enqueues real broker messages via Task.delay / + # Task.apply_async. from tripwire.plugins.celery_plugin import CeleryPlugin - assert CeleryPlugin.passthrough_safe is True + assert CeleryPlugin.passthrough_safe is False - def test_file_io_plugin_is_true(self) -> None: + def test_file_io_plugin_is_false(self) -> None: + # Passthrough performs real disk I/O via builtins.open / Path methods. from tripwire.plugins.file_io_plugin import FileIoPlugin - assert FileIoPlugin.passthrough_safe is True + assert FileIoPlugin.passthrough_safe is False def test_dns_plugin_is_false(self) -> None: from tripwire.plugins.dns_plugin import DnsPlugin diff --git a/tests/unit/test_passthrough_safe.py b/tests/unit/test_passthrough_safe.py index 5069f19..be020e5 100644 --- a/tests/unit/test_passthrough_safe.py +++ b/tests/unit/test_passthrough_safe.py @@ -73,13 +73,15 @@ class FreshPlugin(BasePlugin): "SubprocessPlugin": False, "AsyncWebSocketPlugin": False, "SyncWebSocketPlugin": False, + # Passthrough calls the original (broker-dispatch / disk / native loader), + # so an un-mocked call performs real I/O. + "CeleryPlugin": False, + "FileIoPlugin": False, + "NativePlugin": False, # Safe-passthrough plugins (passthrough_safe=True) - "CeleryPlugin": True, "CryptoPlugin": True, - "FileIoPlugin": True, "JwtPlugin": True, "LoggingPlugin": True, - "NativePlugin": True, # Outside plugins/ directory "MockPlugin": True, "StateMachinePlugin": True,