# Subprocess VCR
A Video Cassette Recorder (VCR) for subprocess commands that dramatically speeds
up test execution by recording and replaying subprocess calls.
## Quick Start
```python
import subprocess
import pytest
# Mark test to use VCR - that's it!
@pytest.mark.subprocess_vcr
def test_with_vcr():
result = subprocess.run(["echo", "hello"], capture_output=True, text=True)
assert result.stdout == "hello\n"
```
Run tests:
```bash
# Record new interactions, replay existing ones
pytest --subprocess-vcr=record
# Replay only - fails if subprocess call not in cassette (for CI)
pytest --subprocess-vcr=replay
```
## Recording Modes
Subprocess VCR supports several recording modes:
- **`record`** - Replays existing recordings, records new ones. For each
subprocess call, it first checks if a recording exists. If found, it replays
that recording. If not found, it executes and records the new subprocess call.
Useful for incremental test development.
- **`replay`** - Replay only. Fails if a subprocess call is not found in the
cassette. Ensures deterministic test execution in CI.
- **`reset`** - Always record, replacing any existing cassettes and their
metadata. Use this to refresh all recordings or when library behavior has
changed.
- **`replay+reset`** - Attempts to replay from existing cassettes, but on any
test failure or missing recording, automatically retries the ENTIRE test in
reset mode. This has the benefit over `reset` of only resetting the cassette
_where necessary_: when replay succeeds, the existing cassette and metadata
are preserved.
- **`disable`** - No VCR, subprocess calls execute normally (default).
## Filters for Normalization and Redaction
Subprocess VCR provides a powerful filter system to normalize dynamic values and
redact sensitive information in your recordings. This ensures cassettes are
portable, secure, and deterministic.
### Built-in Filters
#### PathFilter
Normalizes filesystem paths that change between test runs, including paths
relative to the current working directory:
```python
from subprocess_vcr.filters import PathFilter
# Default normalization (pytest paths, home dirs, CWD, etc.)
@pytest.mark.subprocess_vcr(filters=[PathFilter()])
def test_with_paths():
# Pytest temp paths
subprocess.run(["ls", "/tmp/pytest-of-user/pytest-123/test_dir"])
# Recorded as: ["ls", "<TMP>/test_dir"]
# Current working directory paths
subprocess.run(["cat", "/home/user/project/data/file.txt"], cwd="/home/user/project")
# Recorded as: ["cat", "<CWD>/data/file.txt"] with cwd: "<CWD>"
# Custom path replacements
filter = PathFilter(replacements={
r"/opt/myapp": "<APP_ROOT>",
r"/var/log/\w+": "<LOG_DIR>",
})
```
#### RedactFilter
Removes sensitive information:
```python
from subprocess_vcr.filters import RedactFilter
# Redact by patterns
filter = RedactFilter(
patterns=[r"api_key=\w+", r"Bearer \w+"],
env_vars=["API_KEY", "DATABASE_URL"],
)
@pytest.mark.subprocess_vcr(filters=[filter])
def test_with_secrets():
subprocess.run(["curl", "-H", "Authorization: Bearer abc123"])
# Recorded as: ["curl", "-H", "Authorization: <REDACTED>"]
```
### Combining Filters
#### Using Multiple Filters
Filters are applied in order:
```python
@pytest.mark.subprocess_vcr(filters=[
PathFilter(), # Handles all path normalization including CWD
RedactFilter(env_vars=["API_KEY", "DATABASE_URL"]),
])
def test_complex_command():
subprocess.run(["docker", "build", "-t", "myapp:latest", "."])
```
#### Global Configuration
Set filters for all tests in `conftest.py`:
```python
@pytest.fixture(scope="session")
def subprocess_vcr_config():
return {
"filters": [
PathFilter(), # Handles all path normalization
RedactFilter(env_vars=["API_KEY"]),
]
}
```
### Creating Custom Filters
Inherit from `BaseFilter`:
```python
from subprocess_vcr.filters import BaseFilter
class MyCustomFilter(BaseFilter):
def before_record(self, interaction: dict) -> dict:
"""Modify interaction before saving to cassette."""
# Example: normalize custom IDs in output
if interaction.get("stdout"):
interaction["stdout"] = re.sub(
r"request-id: \w+",
"request-id: <REQUEST_ID>",
interaction["stdout"]
)
return interaction
def before_playback(self, interaction: dict) -> dict:
"""Modify interaction when loading from cassette."""
# Usually just return unchanged
return interaction
# Use the custom filter
@pytest.mark.subprocess_vcr(filters=[MyCustomFilter()])
def test_with_custom_filter():
subprocess.run(["myapp", "process"])
```
## VCR Context in Test Reports
When tests fail while using subprocess VCR, pytest shows additional context in
the test report to help with debugging:
```
----------------------------- subprocess-vcr -----------------------------
This test replayed subprocess calls from VCR cassette: test_example.yaml
To re-record this test, run with: --subprocess-vcr=reset
```
This context appears for ANY test failure when VCR is replaying, helping you
understand whether the failure might be due to outdated recordings.
## Example Cassette
```yaml
version: "1.0"
interactions:
- args:
- echo
- hello world
kwargs:
stdout: PIPE
stderr: PIPE
text: true
duration: 0.005
returncode: 0
stdout: |
hello world
stderr: ""
pid: 12345
```
Raw data
{
"_id": null,
"home_page": null,
"name": "subprocess-vcr",
"maintainer": null,
"docs_url": null,
"requires_python": ">=3.9",
"maintainer_email": null,
"keywords": "testing, subprocess, vcr, mock, pytest, recording, replay",
"author": null,
"author_email": "Maximilian Roos <m@maxroos.com>",
"download_url": "https://files.pythonhosted.org/packages/1c/a0/796b850f9fb6bb8c148130887f4dde565fa717f39686c706fdef55ffd515/subprocess_vcr-0.1.0.tar.gz",
"platform": null,
"description": "# Subprocess VCR\n\nA Video Cassette Recorder (VCR) for subprocess commands that dramatically speeds\nup test execution by recording and replaying subprocess calls.\n\n## Quick Start\n\n```python\nimport subprocess\nimport pytest\n\n# Mark test to use VCR - that's it!\n@pytest.mark.subprocess_vcr\ndef test_with_vcr():\n result = subprocess.run([\"echo\", \"hello\"], capture_output=True, text=True)\n assert result.stdout == \"hello\\n\"\n```\n\nRun tests:\n\n```bash\n# Record new interactions, replay existing ones\npytest --subprocess-vcr=record\n\n# Replay only - fails if subprocess call not in cassette (for CI)\npytest --subprocess-vcr=replay\n```\n\n## Recording Modes\n\nSubprocess VCR supports several recording modes:\n\n- **`record`** - Replays existing recordings, records new ones. For each\n subprocess call, it first checks if a recording exists. If found, it replays\n that recording. If not found, it executes and records the new subprocess call.\n Useful for incremental test development.\n\n- **`replay`** - Replay only. Fails if a subprocess call is not found in the\n cassette. Ensures deterministic test execution in CI.\n\n- **`reset`** - Always record, replacing any existing cassettes and their\n metadata. Use this to refresh all recordings or when library behavior has\n changed.\n\n- **`replay+reset`** - Attempts to replay from existing cassettes, but on any\n test failure or missing recording, automatically retries the ENTIRE test in\n reset mode. This has the benefit over `reset` of only resetting the cassette\n _where necessary_: when replay succeeds, the existing cassette and metadata\n are preserved.\n\n- **`disable`** - No VCR, subprocess calls execute normally (default).\n\n## Filters for Normalization and Redaction\n\nSubprocess VCR provides a powerful filter system to normalize dynamic values and\nredact sensitive information in your recordings. This ensures cassettes are\nportable, secure, and deterministic.\n\n### Built-in Filters\n\n#### PathFilter\n\nNormalizes filesystem paths that change between test runs, including paths\nrelative to the current working directory:\n\n```python\nfrom subprocess_vcr.filters import PathFilter\n\n# Default normalization (pytest paths, home dirs, CWD, etc.)\n@pytest.mark.subprocess_vcr(filters=[PathFilter()])\ndef test_with_paths():\n # Pytest temp paths\n subprocess.run([\"ls\", \"/tmp/pytest-of-user/pytest-123/test_dir\"])\n # Recorded as: [\"ls\", \"<TMP>/test_dir\"]\n\n # Current working directory paths\n subprocess.run([\"cat\", \"/home/user/project/data/file.txt\"], cwd=\"/home/user/project\")\n # Recorded as: [\"cat\", \"<CWD>/data/file.txt\"] with cwd: \"<CWD>\"\n\n# Custom path replacements\nfilter = PathFilter(replacements={\n r\"/opt/myapp\": \"<APP_ROOT>\",\n r\"/var/log/\\w+\": \"<LOG_DIR>\",\n})\n```\n\n#### RedactFilter\n\nRemoves sensitive information:\n\n```python\nfrom subprocess_vcr.filters import RedactFilter\n\n# Redact by patterns\nfilter = RedactFilter(\n patterns=[r\"api_key=\\w+\", r\"Bearer \\w+\"],\n env_vars=[\"API_KEY\", \"DATABASE_URL\"],\n)\n\n@pytest.mark.subprocess_vcr(filters=[filter])\ndef test_with_secrets():\n subprocess.run([\"curl\", \"-H\", \"Authorization: Bearer abc123\"])\n # Recorded as: [\"curl\", \"-H\", \"Authorization: <REDACTED>\"]\n```\n\n### Combining Filters\n\n#### Using Multiple Filters\n\nFilters are applied in order:\n\n```python\n@pytest.mark.subprocess_vcr(filters=[\n PathFilter(), # Handles all path normalization including CWD\n RedactFilter(env_vars=[\"API_KEY\", \"DATABASE_URL\"]),\n])\ndef test_complex_command():\n subprocess.run([\"docker\", \"build\", \"-t\", \"myapp:latest\", \".\"])\n```\n\n#### Global Configuration\n\nSet filters for all tests in `conftest.py`:\n\n```python\n@pytest.fixture(scope=\"session\")\ndef subprocess_vcr_config():\n return {\n \"filters\": [\n PathFilter(), # Handles all path normalization\n RedactFilter(env_vars=[\"API_KEY\"]),\n ]\n }\n```\n\n### Creating Custom Filters\n\nInherit from `BaseFilter`:\n\n```python\nfrom subprocess_vcr.filters import BaseFilter\n\nclass MyCustomFilter(BaseFilter):\n def before_record(self, interaction: dict) -> dict:\n \"\"\"Modify interaction before saving to cassette.\"\"\"\n # Example: normalize custom IDs in output\n if interaction.get(\"stdout\"):\n interaction[\"stdout\"] = re.sub(\n r\"request-id: \\w+\",\n \"request-id: <REQUEST_ID>\",\n interaction[\"stdout\"]\n )\n return interaction\n\n def before_playback(self, interaction: dict) -> dict:\n \"\"\"Modify interaction when loading from cassette.\"\"\"\n # Usually just return unchanged\n return interaction\n\n# Use the custom filter\n@pytest.mark.subprocess_vcr(filters=[MyCustomFilter()])\ndef test_with_custom_filter():\n subprocess.run([\"myapp\", \"process\"])\n```\n\n## VCR Context in Test Reports\n\nWhen tests fail while using subprocess VCR, pytest shows additional context in\nthe test report to help with debugging:\n\n```\n----------------------------- subprocess-vcr -----------------------------\nThis test replayed subprocess calls from VCR cassette: test_example.yaml\nTo re-record this test, run with: --subprocess-vcr=reset\n```\n\nThis context appears for ANY test failure when VCR is replaying, helping you\nunderstand whether the failure might be due to outdated recordings.\n\n## Example Cassette\n\n```yaml\nversion: \"1.0\"\ninteractions:\n - args:\n - echo\n - hello world\n kwargs:\n stdout: PIPE\n stderr: PIPE\n text: true\n duration: 0.005\n returncode: 0\n stdout: |\n hello world\n stderr: \"\"\n pid: 12345\n```\n",
"bugtrack_url": null,
"license": null,
"summary": "VCR for subprocess - record and replay subprocess calls for testing",
"version": "0.1.0",
"project_urls": {
"Homepage": "https://github.com/max-sixty/subprocess-vcr",
"Issues": "https://github.com/max-sixty/subprocess-vcr/issues",
"Repository": "https://github.com/max-sixty/subprocess-vcr"
},
"split_keywords": [
"testing",
" subprocess",
" vcr",
" mock",
" pytest",
" recording",
" replay"
],
"urls": [
{
"comment_text": null,
"digests": {
"blake2b_256": "aacb3bddbdb1cfb89dd346a42fe073d6c53f69fdbaa88fa0125d26000810c373",
"md5": "94c04a98108500f0ccc9c4bc90146e79",
"sha256": "c4c2e663a7cd8866b54aa4a267205e8b4f5554d1f08604ec8834b18eda5ae86a"
},
"downloads": -1,
"filename": "subprocess_vcr-0.1.0-py3-none-any.whl",
"has_sig": false,
"md5_digest": "94c04a98108500f0ccc9c4bc90146e79",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": ">=3.9",
"size": 22161,
"upload_time": "2025-08-17T21:43:22",
"upload_time_iso_8601": "2025-08-17T21:43:22.221081Z",
"url": "https://files.pythonhosted.org/packages/aa/cb/3bddbdb1cfb89dd346a42fe073d6c53f69fdbaa88fa0125d26000810c373/subprocess_vcr-0.1.0-py3-none-any.whl",
"yanked": false,
"yanked_reason": null
},
{
"comment_text": null,
"digests": {
"blake2b_256": "1ca0796b850f9fb6bb8c148130887f4dde565fa717f39686c706fdef55ffd515",
"md5": "851a368fddec5ff5025f9c9a7e35e714",
"sha256": "323771e8755ce67ec889c31e12b5a1b7892c7ad97397b4cae0752a6db2ab3317"
},
"downloads": -1,
"filename": "subprocess_vcr-0.1.0.tar.gz",
"has_sig": false,
"md5_digest": "851a368fddec5ff5025f9c9a7e35e714",
"packagetype": "sdist",
"python_version": "source",
"requires_python": ">=3.9",
"size": 57754,
"upload_time": "2025-08-17T21:43:23",
"upload_time_iso_8601": "2025-08-17T21:43:23.291985Z",
"url": "https://files.pythonhosted.org/packages/1c/a0/796b850f9fb6bb8c148130887f4dde565fa717f39686c706fdef55ffd515/subprocess_vcr-0.1.0.tar.gz",
"yanked": false,
"yanked_reason": null
}
],
"upload_time": "2025-08-17 21:43:23",
"github": true,
"gitlab": false,
"bitbucket": false,
"codeberg": false,
"github_user": "max-sixty",
"github_project": "subprocess-vcr",
"travis_ci": false,
"coveralls": false,
"github_actions": true,
"lcname": "subprocess-vcr"
}