Skip to main content

Testcontainers module for AT Protocol PDS integration testing

Project description

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[sdk] atproto (MarshalX SDK) for high-level record ops
testcontainers-atproto[all] Both 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]

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.

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.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

testcontainers_atproto-0.5.0.tar.gz (156.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

testcontainers_atproto-0.5.0-py3-none-any.whl (22.8 kB view details)

Uploaded Python 3

File details

Details for the file testcontainers_atproto-0.5.0.tar.gz.

File metadata

  • Download URL: testcontainers_atproto-0.5.0.tar.gz
  • Upload date:
  • Size: 156.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for testcontainers_atproto-0.5.0.tar.gz
Algorithm Hash digest
SHA256 d31089759b982f6796ad7926fdc004022f82a3bd00bfe94aee097c05c6488471
MD5 cc3278f410992a9b9dc1507749a68a70
BLAKE2b-256 991951ba643d9c62361e4c99a5410899e0ceb73356664612b71523f42b71ad46

See more details on using hashes here.

File details

Details for the file testcontainers_atproto-0.5.0-py3-none-any.whl.

File metadata

File hashes

Hashes for testcontainers_atproto-0.5.0-py3-none-any.whl
Algorithm Hash digest
SHA256 0e4040ffd4e7403d3e550a1e661ca9a865e6c52f8c6ad0acc7265b3ea9793712
MD5 253a91cd7fa13e0f05c60d976fcbdcfd
BLAKE2b-256 832952260dfb9797aacadcf9b217710ca5b80e368e6199f80590e50eeb7ba71a

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page