Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ concurrency:
jobs:
run_tests:
runs-on: ubuntu-22.04
timeout-minutes: 10
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
Expand Down
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.7.0]

### Added

- `PDSContainer.sync_get(method, params=, auth=)` — raw sync endpoint query returning binary bytes instead of JSON, for `com.atproto.sync.*` endpoints
- `Account.export_repo()` — export the account's repository as raw CAR bytes via `com.atproto.sync.getRepo`
- `Account.get_blob(cid)` — retrieve an uploaded blob by CID via `com.atproto.sync.getBlob`
- `car` module with `parse_car(data)` utility function for decoding CAR v1 archives into `CarFile` and `CarBlock` dataclasses (requires `cbor2`, available via the new `sync` extra)
- `CarFile` and `CarBlock` frozen dataclasses for representing parsed CAR contents
- `sync` optional dependency extra (`cbor2>=5.0`) for CAR parsing support
- `CarFile`, `CarBlock`, and `parse_car` added to top-level package exports
- Integration tests: repo export, blob round-trip, CAR parsing, cross-account isolation

## [0.6.0]

### Added
Expand Down Expand Up @@ -137,7 +150,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- GitHub Actions `publish` workflow (main → Test PyPI, tags → Test+Prod PyPI + GitHub Release with changelog-extracted notes)

<!-- Links -->
[Unreleased]: https://github.com/withtwoemms/testcontainers-atproto/compare/0.6.0...HEAD
[Unreleased]: https://github.com/withtwoemms/testcontainers-atproto/compare/0.7.0...HEAD
[0.7.0]: https://github.com/withtwoemms/testcontainers-atproto/compare/0.6.0...0.7.0
[0.6.0]: https://github.com/withtwoemms/testcontainers-atproto/compare/0.5.0...0.6.0
[0.5.0]: https://github.com/withtwoemms/testcontainers-atproto/compare/0.4.0...0.5.0
[0.4.0]: https://github.com/withtwoemms/testcontainers-atproto/compare/0.3.0...0.4.0
Expand Down
8 changes: 8 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
prune .github
prune docs
prune tests
exclude .gitignore
exclude Makefile
exclude ROADMAP.md
exclude CHANGELOG.md
exclude uv.lock
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ Requires Python 3.10+ and a running Docker daemon.
| 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]` | Both of the above |
| `testcontainers-atproto[all]` | All of the above |

---

Expand Down Expand Up @@ -97,6 +98,35 @@ with PDSContainer() as pds:

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

### Repo sync

Export repositories and retrieve blobs for relay and indexer testing:

```python
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:
Expand Down
16 changes: 9 additions & 7 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ testcontainers-atproto is a testing infrastructure module for anyone building on
| v0.4.0 | Declarative Seeding | Complete |
| v0.5.0 | Email Verification + Password Reset | Complete |
| v0.6.0 | Account Lifecycle + Admin Operations | Complete |
| v0.7.0 | Repo Sync | Planned |
| v0.7.0 | Repo Sync | Complete |
| v1.0.0 | Hermeticity + Federation | Planned |

---
Expand Down Expand Up @@ -201,17 +201,19 @@ Production apps must handle accounts that are deactivated, deleted, or taken dow

---

## v0.7.0 — Repo Sync (Planned)
## v0.7.0 — Repo Sync (Complete)

**Theme:** Repository export and blob retrieval for data consumption pipelines.

The firehose (v0.3.0) provides incremental event notification. Repo sync provides the complementary full-state retrieval path that relays and indexers use for initial backfill and recovery. Together they cover both data consumption patterns in AT Protocol.

- [ ] `Account.export_repo()` — call `com.atproto.sync.getRepo`, return raw CAR bytes
- [ ] `Account.get_blob(cid)` — call `com.atproto.sync.getBlob`, return raw blob bytes
- [ ] Optional CAR parsing utility: decode CAR bytes into blocks and root CID (guarded behind an extra or lightweight built-in)
- [ ] `PDSContainer.sync_get(method, params)` — low-level sync endpoint access for methods not covered by helpers
- [ ] Integration tests: create records → export repo → verify CAR contains expected blocks; upload blob → retrieve blob → verify round-trip; sync endpoint coverage
- [x] `Account.export_repo()` — call `com.atproto.sync.getRepo`, return raw CAR bytes
- [x] `Account.get_blob(cid)` — call `com.atproto.sync.getBlob`, return raw blob bytes
- [x] `parse_car(data)` — CAR v1 parsing utility: decode CAR bytes into `CarFile` with roots and `CarBlock` list (guarded behind `sync` extra providing `cbor2`)
- [x] `PDSContainer.sync_get(method, params, auth)` — low-level sync endpoint access returning raw bytes for methods not covered by helpers
- [x] `sync` optional dependency extra (`cbor2>=5.0`) for CAR parsing support
- [x] `CarFile`, `CarBlock`, and `parse_car` added to top-level package exports
- [x] Integration tests: create records → export repo → verify CAR contains expected blocks; upload blob → retrieve blob → verify round-trip; cross-account isolation; sync endpoint coverage

**Outcomes:**
- Relay and indexer developers can test full backfill pipelines: `getRepo` → parse CAR → verify records
Expand Down
110 changes: 102 additions & 8 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ Spin up ephemeral [PDS](./glossary.md) instances in your Python test suite. [Lex
6. [Raw XRPC Access](#raw-xrpc-access)
7. [Declarative Seeding](#declarative-seeding)
8. [Firehose Subscription](#firehose-subscription)
9. [Email Verification and Password Reset](#email-verification-and-password-reset)
10. [Account Lifecycle and Admin Operations](#account-lifecycle-and-admin-operations)
11. [Pytest Fixtures](#pytest-fixtures)
12. [Error Handling](#error-handling)
13. [API Reference](#api-reference)
9. [Repo Sync](#repo-sync)
10. [Email Verification and Password Reset](#email-verification-and-password-reset)
11. [Account Lifecycle and Admin Operations](#account-lifecycle-and-admin-operations)
12. [Pytest Fixtures](#pytest-fixtures)
13. [Error Handling](#error-handling)
14. [API Reference](#api-reference)

---

Expand All @@ -35,8 +36,9 @@ Requires Python 3.10+ and a running Docker daemon.
| 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]` | Both of the above |
| `testcontainers-atproto[all]` | All of the above |

---

Expand Down Expand Up @@ -447,6 +449,87 @@ Supports both sync (`with sub:`) and async (`async with sub:`) context managers.

---

## Repo Sync

Export repositories and retrieve blobs for relay and indexer testing. The firehose (see above) provides incremental event notification; repo sync provides the complementary full-state retrieval path used for initial backfill and recovery.

### Exporting a repository

```python
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()
```

Returns the raw binary CAR (Content Addressable aRchive) response from `com.atproto.sync.getRepo`.

### Retrieving blobs

```python
blob_ref = alice.upload_blob(b"test data", "image/png")
cid = blob_ref["ref"]["$link"]

# Retrieve by CID
blob_data = alice.get_blob(cid)
assert blob_data == b"test data"
```

### Parsing CAR files

The optional `parse_car` utility decodes CAR v1 archives into structured Python objects. Requires `cbor2` (available via the `sync` or `firehose` extra).

```python
from testcontainers_atproto import parse_car

car = parse_car(car_bytes)
print(car.version) # 1
print(len(car.roots)) # number of root CIDs
print(len(car.blocks)) # number of blocks
```

Install with: `pip install testcontainers-atproto[sync]`

### Low-level sync access

For sync endpoints not covered by helpers, use `sync_get` directly. It returns raw bytes instead of JSON.

```python
car_bytes = pds.sync_get(
"com.atproto.sync.getRepo",
params={"did": alice.did},
)
```

### Account sync methods

| Method | Description |
|--------|-------------|
| `export_repo()` | Export repository as raw CAR bytes |
| `get_blob(cid)` | Retrieve a blob by CID |

### Container sync methods

| Method | Description |
|--------|-------------|
| `sync_get(method, params=, auth=)` | Raw sync endpoint query returning bytes |

### CAR parsing types

| Type | Description |
|------|-------------|
| `CarFile` | Frozen dataclass: `version`, `roots`, `blocks` |
| `CarBlock` | Frozen dataclass: `cid` (bytes), `data` (bytes) |
| `parse_car(data)` | Parse CAR v1 bytes into a `CarFile` |

---

## Email Verification and Password Reset

Test email verification and password reset flows with a local Mailpit SMTP server. When `email_mode="capture"`, the [PDS](./glossary.md) sends real emails through Mailpit, and test code retrieves them via Mailpit's HTTP API.
Expand Down Expand Up @@ -718,20 +801,31 @@ with PDSContainer() as pds:
| Class | Module | Description |
|-------|--------|-------------|
| `PDSContainer` | `container` | Ephemeral [PDS](./glossary.md) with companion containers |
| `Account` | `account` | Authenticated [ATP](./glossary.md) account with record and email operations |
| `Account` | `account` | Authenticated [ATP](./glossary.md) account with record, email, and sync operations |
| `RecordRef` | `ref` | Frozen dataclass referencing a created/updated record (AT URI + [CID](./glossary.md)) |
| `CarFile` | `car` | Frozen dataclass representing a parsed CAR v1 archive |
| `CarBlock` | `car` | Frozen dataclass representing a single block in a CAR file |
| `Seed` | `seed` | Fluent builder for declarative PDS state |
| `World` | `world` | Frozen dataclass of materialized seed state |
| `FirehoseSubscription` | `firehose` | WebSocket client for `subscribeRepos` with [CBOR](./glossary.md) decoding |
| `XrpcError` | `errors` | Structured exception for [XRPC](./glossary.md) failures |

All classes are exported from `testcontainers_atproto`:
### Functions

| Function | Module | Description |
|----------|--------|-------------|
| `parse_car` | `car` | Parse CAR v1 bytes into a `CarFile` (requires `cbor2`) |

All classes and functions are exported from `testcontainers_atproto`:

```python
from testcontainers_atproto import (
PDSContainer,
Account,
RecordRef,
CarFile,
CarBlock,
parse_car,
Seed,
World,
FirehoseSubscription,
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ dependencies = [

[project.optional-dependencies]
firehose = ["websockets>=12.0", "cbor2>=5.0"]
sync = ["cbor2>=5.0"]
sdk = ["atproto>=0.0.50"]
all = ["testcontainers-atproto[firehose,sdk]"]
all = ["testcontainers-atproto[firehose,sync,sdk]"]
test = [
"pytest>=7.0",
"coverage>=7.0",
Expand Down
4 changes: 4 additions & 0 deletions src/testcontainers_atproto/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from importlib.metadata import version as _pkg_version

from testcontainers_atproto.account import Account
from testcontainers_atproto.car import CarBlock, CarFile, parse_car
from testcontainers_atproto.container import PDSContainer
from testcontainers_atproto.errors import XrpcError
from testcontainers_atproto.firehose import FirehoseSubscription
Expand All @@ -12,12 +13,15 @@

__all__ = [
"Account",
"CarBlock",
"CarFile",
"FirehoseSubscription",
"PDSContainer",
"RecordRef",
"Seed",
"World",
"XrpcError",
"parse_car",
]

#: Derived by setuptools_scm from the latest git tag at build time.
Expand Down
28 changes: 28 additions & 0 deletions src/testcontainers_atproto/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,31 @@ def delete_account(self, password: str, token: str) -> None:
"token": token,
},
)

# --- Repo Sync ---

def export_repo(self) -> bytes:
"""Export this account's repository as raw CAR bytes.

Calls ``com.atproto.sync.getRepo`` and returns the binary
CAR (Content Addressable aRchive) response.
"""
return self._pds.sync_get(
"com.atproto.sync.getRepo",
params={"did": self._did},
)

def get_blob(self, cid: str) -> bytes:
"""Retrieve a blob by CID from this account's repository.

Calls ``com.atproto.sync.getBlob`` and returns the raw blob bytes.

Args:
cid: The content identifier of the blob (the ``$link``
value from the blob reference returned by
:meth:`upload_blob`).
"""
return self._pds.sync_get(
"com.atproto.sync.getBlob",
params={"did": self._did, "cid": cid},
)
Loading
Loading