Skip to content

withtwoemms/testcontainers-atproto

Repository files navigation

testcontainers-atproto

tests codecov publish

Spin up ephemeral PDS instances in your Python test suite. Lexicon-agnostic — works with any application built on AT Protocol, not just Bluesky.


Installation

pip install testcontainers-atproto

Requires Python 3.10+ and a running Docker daemon.

Extras

Extra What it adds
testcontainers-atproto[firehose] websockets, cbor2 for firehose subscription
testcontainers-atproto[sync] cbor2 for CAR file parsing
testcontainers-atproto[sdk] atproto (MarshalX SDK) for high-level record ops
testcontainers-atproto[all] All of the above

Quick start

from testcontainers_atproto import PDSContainer

with PDSContainer() as pds:
    account = pds.create_account("alice.test")
    print(pds.base_url)       # http://localhost:<port>
    print(account.did)         # did:plc:...
    print(account.handle)      # alice.test

A local PLC directory runs alongside the PDS on a shared Docker network — no public internet required. For Postgres-backed PLC parity with production, pass plc_mode="real":

with PDSContainer(plc_mode="real") as pds:
    account = pds.create_account("alice.test")

Record operations

with PDSContainer() as pds:
    alice = pds.create_account("alice.test")

    # Create
    ref = alice.create_record("app.bsky.feed.post", {
        "$type": "app.bsky.feed.post",
        "text": "hello from testcontainers",
        "createdAt": "2026-01-01T00:00:00Z",
    })

    # Read
    record = alice.get_record("app.bsky.feed.post", ref.rkey)

    # Update
    alice.put_record("app.bsky.feed.post", ref.rkey, {
        "$type": "app.bsky.feed.post",
        "text": "updated text",
        "createdAt": "2026-01-01T00:00:00Z",
    })

    # List & delete
    records = alice.list_records("app.bsky.feed.post")
    alice.delete_record("app.bsky.feed.post", ref.rkey)

Firehose subscription

Observe real-time repository events via the AT Protocol firehose:

from testcontainers_atproto import PDSContainer

with PDSContainer() as pds:
    alice = pds.create_account("alice.test")
    alice.create_record("app.bsky.feed.post", {
        "$type": "app.bsky.feed.post",
        "text": "hello firehose",
        "createdAt": "2026-01-01T00:00:00Z",
    })

    sub = pds.subscribe()
    events = sub.collect(count=10, timeout=5.0)

    commits = [e for e in events if e["header"].get("t") == "#commit"]
    print(commits[-1]["body"]["ops"])  # [{"action": "create", ...}]

Requires the firehose extra: pip install testcontainers-atproto[firehose]

Repo sync

Export repositories and retrieve blobs for relay and indexer testing:

with PDSContainer() as pds:
    alice = pds.create_account("alice.test")
    alice.create_record("app.bsky.feed.post", {
        "$type": "app.bsky.feed.post",
        "text": "sync test",
        "createdAt": "2026-01-01T00:00:00Z",
    })

    # Export full repository as CAR bytes
    car_bytes = alice.export_repo()

    # Parse the CAR to inspect blocks
    from testcontainers_atproto import parse_car
    car = parse_car(car_bytes)
    print(f"{len(car.blocks)} blocks, {len(car.roots)} roots")

    # Retrieve a specific blob
    blob_ref = alice.upload_blob(b"test data", "image/png")
    blob_data = alice.get_blob(blob_ref["ref"]["$link"])
    assert blob_data == b"test data"

CAR parsing requires the sync extra: pip install testcontainers-atproto[sync]

Declarative seeding

Describe the world, materialize it in one call — no boilerplate account/record setup:

from testcontainers_atproto import PDSContainer, Seed

with PDSContainer() as pds:
    world = (
        Seed(pds)
        .account("alice.test")
            .post("Hello from Alice")
            .post("Another post")
        .account("bob.test")
            .post("Bob's first post")
            .follow("alice.test")
            .like("alice.test", 0)   # like Alice's first post
        .apply()
    )

    alice = world.accounts["alice.test"]
    bob = world.accounts["bob.test"]
    assert len(world.records["alice.test"]) == 2

Placeholders let custom records reference other accounts' DIDs and records — resolved at apply() time:

with PDSContainer() as pds:
    world = (
        Seed(pds)
        .account("alice.test")
            .record("com.example.project", {
                "$type": "com.example.project",
                "name": "My Project",
            })
        .account("bob.test")
            .record("com.example.review", {
                "$type": "com.example.review",
                "reviewer": Seed.did("bob.test"),
                "project": Seed.ref("alice.test", 0),
            })
        .apply()
    )

Accounts can be revisited to interleave records (e.g. conversation threads):

world = (
    Seed(pds)
    .account("alice.test")
        .post("alice first")
    .account("bob.test")
        .post("bob replies")
    .account("alice.test")
        .post("alice continues")
    .apply()
)

Also available as a dict-based API for data-driven fixtures:

world = pds.seed({
    "accounts": [
        {"handle": "alice.test", "posts": ["Hello from Alice"]},
        {"handle": "bob.test", "follows": ["alice.test"]},
    ],
})

Email verification

Test email verification and password reset flows with a local Mailpit SMTP server:

with PDSContainer(email_mode="capture") as pds:
    alice = pds.create_account("alice.test")

    # Request verification email
    alice.request_email_confirmation()

    # Retrieve it from Mailpit
    message = pds.await_email(alice.email)

    # Extract token and confirm (token format is PDS-version-dependent)
    token = extract_token(message)  # your extraction logic
    alice.confirm_email(token)

Password reset follows the same pattern:

    alice.request_password_reset()
    message = pds.await_email(alice.email)
    token = extract_token(message)
    alice.reset_password(token, "new-password")

When email_mode="none" (the default), email verification is bypassed and no Mailpit container is started.

Account lifecycle

Deactivate, reactivate, and delete accounts to test how your app handles state changes:

with PDSContainer() as pds:
    alice = pds.create_account("alice.test")

    # Deactivate — account becomes inaccessible
    alice.deactivate()

    # Re-activate — access restored
    alice.activate()

    # Check status
    status = alice.check_account_status()
    assert status["activated"] is True

Admin operations let you test moderation flows:

with PDSContainer() as pds:
    alice = pds.create_account("alice.test")

    # Takedown — blocks access
    pds.takedown(alice)

    # Restore — unblocks access
    pds.restore(alice)

    # Query status
    status = pds.get_subject_status(alice)

Account deletion requires email_mode="capture" to retrieve the deletion token:

with PDSContainer(email_mode="capture") as pds:
    alice = pds.create_account("alice.test", password="s3cret")

    # ... confirm email first ...

    alice.request_account_delete()
    message = pds.await_email(alice.email)
    token = extract_token(message)  # your extraction logic
    alice.delete_account("s3cret", token)

Error handling

XRPC failures raise XrpcError with structured fields:

from testcontainers_atproto import PDSContainer, XrpcError

with PDSContainer() as pds:
    try:
        pds.create_account("alice.invalid")
    except XrpcError as e:
        print(e.status_code)  # 400
        print(e.error)        # "InvalidHandle"
        print(e.message)      # human-readable detail

Pytest fixtures

After installing the package, these fixtures are available automatically via the pytest11 entry point:

Fixture Scope Description
pds function Fresh PDS instance per test
pds_module module Shared PDS instance within a test module
pds_pair function Two PDS instances for federation testing
pds_image session PDS image tag (override via ATP_PDS_IMAGE env var)
def test_create_account(pds):
    account = pds.create_account("bob.test")
    assert account.did.startswith("did:plc:")

Development

make venv                                       # Create virtual environment
source .testcontainers-atproto-3.12/bin/activate # Activate
make test                                        # Run tests
make test-all                                    # Run across all supported Python versions

Glossary

AT Protocol introduces many domain-specific terms. See docs/glossary.md for definitions of PDS, DID, PLC, XRPC, and other initialisms used in this project.


License

Apache-2.0. See LICENSE.

About

testcontainers module for AT Protocol. spin up ephemeral PDS instances in your Python test suite

Resources

License

Stars

Watchers

Forks

Packages