coopy
Persistence for plain Python objects. No database, no ORM, no schema - one line of code.
import coopy
with coopy.control(coopy.open(TodoList)) as todo: # your domain class
todo.add("write the README") # journal this change
Reopen the same data directory to restore your TodoList from its snapshot
and journal, provided your methods replay deterministically and storage is intact.
Why coopy
- One line. Wrap your object once. Keep calling its methods as usual.
- Your code stays yours. No base class, no decorators required, no mapping layer. Your domain classes are plain Python.
- Memory speed. Reads are ordinary attribute access - no queries, no network round trips, no serialization.
- Journaled methods. Recover recorded changes by replaying method calls after the latest snapshot.
- Typed.
init_persistent_system(TodoList)returns aTodoListto your type checker, so editors autocomplete methods and mypy checks arguments. - Serialized calls. Method calls through the proxy take a lock by default.
- Experimental replication. Bootstrap read-only replicas and stream subsequent changes over TCP or Unix sockets.
The idea: object prevalence
Most applications load data from a database, change it, and write it back. coopy flips this around: your objects live in memory, and the disk is just a log. This technique is called object prevalence.
- Every change is a method call. coopy wraps your object in a proxy. When you call a method that changes state, the call itself - method name and arguments - is appended to a journal on disk.
- Snapshots save time. Request or schedule a snapshot of the whole object to reduce replay work. Old snapshots and journals are retained; there is no automatic pruning.
- Restart = snapshot + replay. On startup coopy loads the latest snapshot and re-runs the journaled calls made after it. Same calls, same order, same state.
your code ──► proxy ──► your object (in memory)
│
└──► journal: add("milk"), add("eggs"), done(0), ...
restart: latest snapshot ──► replay journal ──► same object as before
The tradeoff: your data has to fit in memory. For a huge range of apps - internal tools, games, bots, prototypes, configuration and catalog services - it does, and you get a much simpler stack in exchange.
Quick start
python -m pip install coopy
Requires Python 3.10 or newer. Save your domain class in todo_domain.py so
another process can import it during recovery:
from coopy.decorators import readonly
class TodoList:
def __init__(self):
self.items = []
def add(self, text):
self.items.append({"text": text, "done": False,
"created": self._clock.now()})
def done(self, index):
self.items[index]["done"] = True
@readonly
def pending(self):
return [item["text"] for item in self.items if not item["done"]]
From the same directory, run this once to create and reopen a persistent list:
import coopy
from todo_domain import TodoList
with coopy.control(coopy.open(TodoList)) as todo:
todo.add("buy milk")
todo.add("write the README")
todo.done(0)
print(todo.pending()) # ['write the README'] on the first run
with coopy.control(coopy.open(TodoList)) as todo:
print(todo.pending()) # the same list, recovered from disk
Data is stored in a directory named after your class (todolist/ here).
Pass basedir="some/dir" or a pathlib.Path to choose another location;
coopy creates missing parent directories.
Use one process and one persistent proxy per data directory: a writable open
locks the directory, and a second one raises LockError until the first is
closed (network filesystems may not honor the lock). The directory's
coopy.json manifest records its format version, serializer and signing, and
reopening with different settings or with an older coopy than the format needs
raises FormatError before anything is decoded. Stop writers
before leaving the context, which closes the proxy. Closing takes no snapshot
unless you set coopy.control(todo).snapshot_on_close = True.
Running the writes above again adds more items to the existing list.
Logging is application-controlled; opening a system does not create a log file.
The rules
Replaying calls only works if they give the same result the second time. That means:
- Change state only through methods called on the wrapped object. If you modify an object returned by a method directly, that change is not journaled.
- Use
self._clockfor time. Callself._clock.now(),utcnow()ortoday()instead ofdatetime.now(). Timezone-awarenow(tz),time()andmonotonic()are also supported. coopy records the value and hands back the same one on replay. Source validation flags direct clock,time,random,uuid,os.urandomandsecretscalls (passvalidation="strict"to reject them all at open time), but cannot prove that your methods are deterministic. - Use
self._envfor random values.self._env.random(),randint(),choice(),shuffle(),uuid4(),token_hex()and friends are recorded and replayed like the clock (annotate_env: coopy.Env). Recorded tokens are stored in the journal, so keep real secrets out of it. - No other outside input inside methods - no network calls or file reads. Do those outside and pass the results in as arguments.
- Arguments and state must be serializable. Pickle is the default; opt-in JSON requires a type registry for domain objects. See the serialization guide.
Type hints
coopy ships type information (py.typed). The proxy is typed as your domain
class, decorators keep method signatures, and persistence controls go
through coopy.control() so they don't clash with your own method names:
# todo_domain.py
from datetime import datetime
from typing import TypedDict
import coopy
from coopy.decorators import readonly
class Item(TypedDict):
text: str
done: bool
created: datetime
class TodoList:
_clock: coopy.Clock # injected by coopy
def __init__(self) -> None:
self.items: list[Item] = []
def add(self, text: str) -> int:
self.items.append({"text": text, "done": False, "created": self._clock.now()})
return len(self.items) - 1
def done(self, index: int) -> None:
self.items[index]["done"] = True
@readonly
def pending(self) -> list[str]:
return [item["text"] for item in self.items if not item["done"]]
# app.py
import coopy
from todo_domain import TodoList
with coopy.control(coopy.open(TodoList, "data")) as todo: # todo: TodoList
todo.done(todo.add("buy milk"))
print(todo.pending()) # list[str]
# todo.add(42) # mypy would reject int; add() expects str
coopy.open is an alias of init_persistent_system. coopy.control(todo)
gives typed access to take_snapshot(), close(), start_snapshot_manager()
and replication, and works as a context manager. recovery="salvage" is typed
as RecoveryResult[TodoList]. See the
typed API guide for details and current limits.
More features
Decorators fine-tune individual methods:
| Decorator | Effect |
|---|---|
@readonly |
Not journaled. Use for queries. |
@abort_exception |
If the method raises, the call is not journaled. No rollback is performed. |
@transactional |
If the method raises, its changes are rolled back and the call is not journaled. Copies the whole state on every call. |
@unlocked |
Skips coopy's lock; you handle thread safety yourself. |
Snapshots keep restarts fast:
with coopy.control(coopy.open(TodoList)) as todo:
persistence = coopy.control(todo)
persistence.take_snapshot() # take one now
persistence.start_snapshot_manager(3600) # first snapshot after an hour
# Run your application here.
# Context exit closes storage and stops the snapshot timer.
coopy.replay(TodoList, "todolist", until=2) rebuilds the state after the
first two actions (or up to a datetime) without writing anything; it replays
from the oldest kept checkpoint, so earlier moments stay reachable until pruned.
Old snapshots and journals remain until explicitly pruned. See the
checkpoint and retention guide before using
coopy.control(todo).prune(keep=3) on an open system.
Replication is experimental. A new replica receives the primary's state and queued actions before becoming readable. From a source checkout, run the complete local example:
python docs/examples/replication.py
Wait for connection.wait_until_synchronized(timeout=10) before querying a
replica, and handle a false result. Replication is asynchronous and does not
provide durable replica journaling, automatic reconnect, or failover. See the
replication guide for lifecycle and protocol limits.
Recovery and durability
init_persistent_system defaults to recovery="strict" and
durability="buffered". Strict recovery raises coopy.restore.RecoveryError
on detected corruption or replay divergence; the exception's report identifies
where replay stopped. A recovered proxy exposes recovery_report.
Buffered writes flush to the operating system, but do not sync every action to
disk. For per-action os.fsync before acknowledgment, open with:
with coopy.control(coopy.open(TodoList, durability="sync")) as todo:
todo.add("persist before acknowledging")
Choose the policy on each open and close the proxy as above. Synchronous mode
requires POSIX directory-sync support and remains subject to filesystem and
device guarantees. A coopy.journal.PersistenceError can occur after memory
has changed or a record has persisted. Rollback is limited to
@transactional calls whose failed record is confirmed discarded; see
decorator semantics. Stop writers and inspect
recovery before retrying a failed mutation.
For explicit inspection of damaged, trusted storage, recovery="salvage"
returns a RecoveryResult containing system and report, rather than a
writable proxy. It stops at the first defect and does not modify persistence
files. Preserve a backup first; salvage is not a repair operation. Read the
persistence and recovery guide for the full contract.
Good to know
- Security: the default disk serializer and all replication use
pickle, which can run arbitrary code when loaded. Only restore data directories you trust. JSON storage does not change the replication protocol; use trusted hosts on a trusted network. Optional HMAC signing authenticates storage but does not encrypt it or make untrusted pickle safe. - Replay: keep external effects outside journaled methods; recovery can execute those methods again. Use compatible, importable domain classes.
- Python: 3.10 or newer. Python 2 support ended with coopy 0.4.x.
Documentation
Full docs: https://coopy.readthedocs.io
Want to see it working first? Run the interactive todo-list demo from a
source checkout and try add, journal, snapshot and reopen:
python docs/examples/todo_demo.py ./my-todos
The checkout includes an executable tutorial, decorator semantics, and checkpoint guarantees.
For other workflows, see the typed API and serializers, async callers and concurrency, schema migrations, and command-line inspection, verification and conversion.
Development
git clone https://github.com/felipecruz/coopy.git
cd coopy
python -m venv .venv
. .venv/bin/activate
python -m pip install -e ".[dev,test,docs,zstd,lz4]"
python -m pytest --verbose -W error::DeprecationWarning
python -m pytest --cov-report html --cov coopy # report in htmlcov/
python -m sphinx -W --keep-going -E -a -b html docs build/docs
Open build/docs/index.html for the local guide. See
testing documentation for focused example checks. make test
also runs the tests, but first removes build outputs, coverage reports, and
coopy logs through make clean.
The development guide covers linting, type checking,
pre-commit hooks, and the CI coverage gate.
Contributions are welcome - fork the repo and open a pull request.
License
BSD 3-Clause. See LICENSE.
Contact: felipecruz@gmail.com
Metadata
Release files for coopy 0.7.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| coopy-0.7.0.tar.gz | 64.8 kB | With provenance | Details for coopy-0.7.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| coopy-0.7.0-py3-none-any.whl | Python 3 | none | any | Details for coopy-0.7.0-py3-none-any.whl |
Total release size: 133.6 kB
Release files / coopy-0.7.0.tar.gz
| Download URL | coopy-0.7.0.tar.gz |
|---|---|
| Size | 64.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3473f6115dbbed6320c594c823da80462093457b4bbfbf645f4c9b0bd52c3bc9
|
|
BLAKE2b-256 checksum How to use checksums |
b33f2a3409dedc2b6a9b916d0e69327dbddd6f36083cd129b351cc79cec09360
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 12, 2026.
Transparency logRelease files / coopy-0.7.0-py3-none-any.whl
| Download URL | coopy-0.7.0-py3-none-any.whl |
|---|---|
| Size | 68.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8c0981bf364f1e07dde7d7239eb9caf372f651795b23a5c336c74ae7049b4bdd
|
|
BLAKE2b-256 checksum How to use checksums |
0c248d421f54f39e3d3a8e3a0e0e92826251d73d45c8c5ee70a6436f93ada7cb
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 12, 2026.
Transparency logSecurity for coopy 0.7.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for coopy-0.7.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 12, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.