coopy

Persistence for plain Python objects. No database, no ORM, no schema: wrap an object once, keep calling its methods, and it survives restarts.

What is it?

Most applications keep their data in a database and copy it into objects when they need it. coopy turns this around: your objects live in memory, and the disk is just a log of what happened to them. The technique is called object prevalence.

coopy wraps your object in a proxy. Every time you call a method that changes state, the call itself (method name and arguments) is appended to a journal on disk before the result is returned. Every so often, coopy saves a snapshot of the whole object so the journal stays short. When your program starts again, coopy loads the latest snapshot and replays the calls made after it: same calls, same order, same state.

your code ──► proxy ──► your object (in memory)
                │
                └──► journal:  deposit("alice", 100)
                               transfer("alice", "bob", 30)
                               ...

restart:   latest snapshot ──► replay the journal ──► the same object

A complete example

A bank whose accounts survive a restart. Bank is an ordinary class; the only coopy-specific line is coopy.open:

import tempfile

import coopy
from coopy.decorators import readonly


class Bank:
    """Plain Python: no base class, no ORM, no schema."""

    _clock: coopy.Clock  # injected by coopy

    def __init__(self) -> None:
        self.accounts: dict[str, int] = {}
        self.history: list[tuple[str, str, int]] = []

    def deposit(self, name: str, amount: int) -> None:
        self.accounts[name] = self.accounts.get(name, 0) + amount
        self.history.append((self._clock.now().isoformat(), name, amount))

    def transfer(self, source: str, target: str, amount: int) -> None:
        if self.accounts.get(source, 0) < amount:
            raise ValueError("insufficient funds")
        self.accounts[source] -= amount
        self.accounts[target] = self.accounts.get(target, 0) + amount

    @readonly
    def balance(self, name: str) -> int:
        return self.accounts.get(name, 0)


data = tempfile.mkdtemp()

# First run: every method call is appended to a journal on disk.
with coopy.control(coopy.open(Bank, data)) as bank:
    bank.deposit("alice", 100)
    bank.transfer("alice", "bob", 30)
    print(bank.balance("alice"), bank.balance("bob"))  # 70 30

# Later (a restart, a crash, a deploy): the same object comes back.
with coopy.control(coopy.open(Bank, data)) as bank:
    print(bank.balance("alice"), bank.balance("bob"))  # 70 30
    print(coopy.control(bank).recovery_report.replayed, "calls replayed")  # 2 calls replayed

Run it with python docs/examples/quickstart.py. Install coopy with python -m pip install coopy (Python 3.10 or newer); see Installation.

Why coopy

  • Your code stays yours. Domain classes are plain Python: no base class, no mapping layer, no queries. Type checkers see the proxy as your class.

  • Memory speed. Reads are attribute access. A query is a Python expression over your own objects.

  • History for free. The journal records every change as a named command with its arguments and recorded time. Rebuild any earlier moment or inspect it with the command-line tool.

  • Safe by default. Calls are serialized under a lock, a data directory has one writer, recovery stops on corruption instead of guessing, and durability="sync" acknowledges a call only after it is on disk.

  • Grows with you. Snapshots, pruning, migrations, signed storage, JSON storage, async callers, @transactional rollback and experimental Primary/replica replication.

The one rule: replay must give the same result

Recovery runs your methods again, so they must be deterministic:

  • Change state only by calling methods on the proxy.

  • Use self._clock.now() instead of datetime.now(), and self._env for random numbers, UUIDs and tokens. coopy records the values and returns the same ones on replay (see Recorded clock values and Recorded random numbers, UUIDs and tokens).

  • Do network calls, file reads and other side effects outside, and pass the results in as arguments.

coopy checks your methods for the usual mistakes when it opens a system.

When not to use it

Your data must fit in memory, one process writes to a data directory, and snapshots and journals use pickle by default, so only load data you trust. If you need ad-hoc SQL from other teams or data larger than RAM, use a database.

Where next

coopy is released under the BSD License. Source and issues: https://github.com/felipecruz/coopy