DEV Community

Dakota Wu
Dakota Wu

Posted on

Freeze pyproject.toml Before the Model Opens a Weekend API

A same-day indie API stays at a zero invoice when pyproject.toml is frozen before any model is allowed to edit application code. Paid SDKs almost never arrive as architecture decisions. They arrive as new lines in a dependency file. The freeze is a hash, a read-only bit, and a boot checker that refuses to start.

Solo founders now let models write handlers for an evening slice. The model cannot see a card statement. It can see a missing package. Cache clients, object storage, mail kits, and error vendors show up as ordinary imports. The tree still looks small. The bill does not.

The workflow below is for a single operator who must ship today and accept limits. It stays useful if the generation tool is swapped. SQLite, one process, no mailer, and no worker are the intended ceiling.

The failure the freeze targets

AI-assisted patches optimize for a green import. They do not optimize for next month's invoice. A session store "should be shared" pulls a hosted cache client. A confirm flow "should be real" pulls a mail SDK. Each step is locally reasonable. The stack that results is not the stack the founder chose.

Grep after the fact is late. The cheaper move is to stop the dependency file from changing, then stop imports that sit outside the frozen wheel list. The two checks belong on the boot path, not in a weekly review.

Artifact: seal, allowlist, boot wrapper

Four files sit beside the app. They are small on purpose.

  1. pyproject.toml — the only declared dependencies.
  2. lock.sha256 — the committed digest of that file.
  3. allowed_modules.txt — top-level import names recorded from a known-good boot.
  4. tools/check_seal.py — hash check plus an AST walk.

The dependency list is a proposal for a Friday slice, not a measured benchmark.

# pyproject.toml — proposal for a zero-invoice notes API
[project]
name = "weekend-notes"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
  "fastapi",
  "uvicorn",
]
Enter fullscreen mode Exit fullscreen mode

sqlite3 stays in the stdlib. No driver package. No cloud kit. The application may import framework code and the standard library. Everything else waits for a human to unseal the file.

Numbered workflow

1. Install once, then seal the file

The founder installs the tiny list on a clean interpreter. The digest is recorded next. The file is then marked read-only in the workspace.

python -m pip install -e .
sha256sum pyproject.toml > lock.sha256
chmod a-w pyproject.toml
git add pyproject.toml lock.sha256
Enter fullscreen mode Exit fullscreen mode

A model can still propose handler patches. It cannot quietly restack the product if the editor honors the write bit. The checker does not trust the write bit. It trusts the hash.

2. Record the real import surface from a cold boot

An allowlist written from memory will be wrong. Frameworks import transitive modules. The honest list is sys.modules after importing the application once on the sealed environment.

# tools/record_allowlist.py — run only on a known-good tree
from __future__ import annotations

import sys
from pathlib import Path

import weekend_notes.main  # noqa: F401

ROOT = Path(__file__).resolve().parents[1]
STDLIB = set(sys.stdlib_module_names)
LOCAL = {"weekend_notes", "tools"}

names = sorted(
    {
        mod.split(".")[0]
        for mod in sys.modules
        if mod.split(".")[0] not in STDLIB
        and mod.split(".")[0] not in LOCAL
        and not mod.split(".")[0].startswith("_")
    }
)
(ROOT / "allowed_modules.txt").write_text("\n".join(names) + "\n", encoding="utf-8")
print("\n".join(names))
Enter fullscreen mode Exit fullscreen mode
python tools/record_allowlist.py
git add allowed_modules.txt
Enter fullscreen mode Exit fullscreen mode

Re-run this script only when a human upgrades the framework. Do not let a model regenerate it during the same session that writes features. An allowlist that updates itself is not a freeze.

3. Fail boot when the seal or the imports drift

AST beats grep. Comments and string literals do not count as imports. The checker walks every .py file outside virtualenv directories, then compares top-level names to stdlib plus the recorded allowlist.

# tools/check_seal.py
from __future__ import annotations

import ast
import hashlib
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
SEAL = ROOT / "lock.sha256"
PYPROJECT = ROOT / "pyproject.toml"
ALLOW = {
    line.strip()
    for line in (ROOT / "allowed_modules.txt").read_text(encoding="utf-8").splitlines()
    if line.strip() and not line.startswith("#")
}
STDLIB = set(sys.stdlib_module_names)
SKIP_DIRS = {".git", ".venv", "venv", "__pycache__", "tools"}


def digest(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def check_hash() -> list[str]:
    expected = SEAL.read_text(encoding="utf-8").split()[0].strip()
    actual = digest(PYPROJECT)
    if actual != expected:
        return [f"pyproject.toml hash {actual} != sealed {expected}"]
    return []


def top_module(name: str | None) -> str | None:
    if not name:
        return None
    return name.split(".")[0]


def imports_in(path: Path) -> set[str]:
    tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
    found: set[str] = set()
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            for alias in node.names:
                top = top_module(alias.name)
                if top:
                    found.add(top)
        elif isinstance(node, ast.ImportFrom):
            top = top_module(node.module)
            if top:
                found.add(top)
    return found


def check_imports() -> list[str]:
    errors: list[str] = []
    for path in ROOT.rglob("*.py"):
        if any(part in SKIP_DIRS for part in path.parts):
            continue
        for mod in sorted(imports_in(path)):
            if mod in STDLIB or mod in ALLOW:
                continue
            errors.append(f"{path.relative_to(ROOT)}: disallowed import {mod}")
    return errors


def main() -> int:
    problems = check_hash() + check_imports()
    if problems:
        print("seal check failed:")
        print("\n".join(problems))
        return 1
    print("seal check passed")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Wire it in front of the process. A checker that is optional will be skipped on the night it is needed.

# Makefile
.PHONY: run check

check:
    python tools/check_seal.py

run: check
    uvicorn weekend_notes.main:app --host 127.0.0.1 --port 8000
Enter fullscreen mode Exit fullscreen mode
make run
Enter fullscreen mode Exit fullscreen mode

4. Keep the coding loop off a billed account

The freeze is wasted when the generation environment already holds cloud keys. Models copy whatever credentials they can see. Empty env is part of the seal.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode provides free model access and a free server option. Those two availability claims are operator-supplied. No model names, quotas, hardware, or duration are stated here. For this workflow the value is placement: the model and tools/check_seal.py can run on the same machine with vendor keys unset. A founder who wants that loop without attaching a cloud invoice can run the session there.

env -i HOME="$HOME" PATH="$PATH" PYTHONPATH=. \
  python tools/check_seal.py
Enter fullscreen mode Exit fullscreen mode

If a patch requires AWS_SECRET_ACCESS_KEY at import time, the API has already left the zero-invoice path. Do not export the key to make the import succeed. Delete the import.

5. Ship a handler that cannot grow a vendor

Notes live in SQLite. Bodies are capped. There is no object store and no mailer. The handler is a proposal for a same-day demo, not a production design.

# weekend_notes/main.py
from __future__ import annotations

import sqlite3
from pathlib import Path

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field

DB = Path("notes.db")
app = FastAPI()
MAX_BODY = 500


def connect() -> sqlite3.Connection:
    conn = sqlite3.connect(DB)
    conn.execute(
        "CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT NOT NULL)"
    )
    return conn


class NoteIn(BaseModel):
    body: str = Field(min_length=1, max_length=MAX_BODY)


@app.post("/notes")
def create_note(payload: NoteIn) -> dict[str, int | str]:
    with connect() as conn:
        cur = conn.execute("INSERT INTO notes(body) VALUES (?)", (payload.body,))
        note_id = int(cur.lastrowid)
    return {"id": note_id, "body": payload.body}


@app.get("/notes/{note_id}")
def read_note(note_id: int) -> dict[str, int | str]:
    with connect() as conn:
        row = conn.execute("SELECT id, body FROM notes WHERE id = ?", (note_id,)).fetchone()
    if row is None:
        raise HTTPException(status_code=404, detail="missing")
    return {"id": int(row[0]), "body": str(row[1])}
Enter fullscreen mode Exit fullscreen mode

Bind to 127.0.0.1. A public bind is a different product. Attachments, background thumbnails, and "just email me the note" are out of scope for the freeze.

6. Wire a test so the seal is not decoration

One passing test on the current tree is not enough. A second test should prove the checker fails on a banned import.

# tests/test_seal.py
import subprocess
import sys
from pathlib import Path

from tools.check_seal import imports_in

ROOT = Path(__file__).resolve().parents[1]


def test_seal_passes_on_this_tree() -> None:
    proc = subprocess.run(
        [sys.executable, str(ROOT / "tools" / "check_seal.py")],
        check=False,
        capture_output=True,
        text=True,
    )
    assert proc.returncode == 0, proc.stdout + proc.stderr


def test_seal_flags_stripe(tmp_path: Path) -> None:
    sample = tmp_path / "evil.py"
    sample.write_text("import stripe\n", encoding="utf-8")
    assert "stripe" in imports_in(sample)
Enter fullscreen mode Exit fullscreen mode
python -m pytest tests/test_seal.py
Enter fullscreen mode Exit fullscreen mode

Label both tests as unexecuted examples until they are run on the operator's tree. The point is the hook, not a published coverage number.

Decision table

Signal in the patch Action Bill risk if ignored
pyproject.toml hash changes Fail boot; human unseals only High
Import of stripe, boto3, redis, celery Fail boot High
New stdlib module (hmac, pathlib) Allow None
New transitive framework module Human re-runs record_allowlist.py Low
Extra worker or queue process Reject; stay request-scoped Medium
Env var for a cloud key Do not export; fail the session High
Dynamic importlib of a vendor name Out of scope; see limitations High

Limitations

The AST walk does not catch dynamic imports built from strings. importlib.import_module("st" + "ripe") bypasses it. A patch can also shell out to pip. The seal assumes the model is sloppy about vendors, not adversarial.

sys.stdlib_module_names is a convenience set, not a security boundary. Some stdlib modules still open sockets. The freeze does not replace a network policy. It only blocks the common path where a paid SDK is added as a normal dependency.

Transitive allowlists drift when the framework is upgraded. Upgrades are a human event. They require a new hash and a new allowed_modules.txt. Do not let the model bump versions during a Friday session.

The notes API stores plaintext on a local file. That is unacceptable for secrets, identity documents, or multi-tenant data. Same-day indie scope only.

Free model access and a free server option are availability claims with unspecified capacity and duration. They can change. Do not plan payroll, quotas, or an SLA around them.

Who should not use this

Teams with a real billing account, a shared cache, and a compliance mailer should not freeze the lockfile this hard. Multi-service products that already pay for vendors gain little from a weekend seal.

Founders who need guaranteed uptime, signed production artifacts, or isolated CI runners need a stronger pipeline than a hash file in git. If the model must install packages to finish the task, the task is not a same-day zero-invoice slice. Split the task.

The seal does not make the model a senior engineer. It does not prove the API is correct. It proves the tree did not grow a vendor between the last human decision and the next local boot. For a solo founder who wants to ship today and read an empty invoice tomorrow, that proof is the whole point.

Top comments (0)