Lockfile

esker.lock — format, lifecycle, hand-emitted TOML.

esker.lock is the project-scoped pin file, sibling to pyproject.toml. It records the exact version, content hash, and lineage hash of each bound dataset, so a clean clone + esker sync reproduces the cache byte-for-byte.

Created and maintained by esker add, esker upgrade, esker remove. Read by esker sync. Hand-edits are lossy on the next mutation.

Format

# generated; do not edit

[[dataset]]
name           = "us.treasury.yields"
owner          = "archie"
version        = "1.0.0"
schema_version = "1.0.0"
content_hash   = "sha256:79eeb5cb20248d4efa4fc516cb517f0afcd2e2d83bcdef63d9860de7ee3b613e"
lineage_hash   = "sha256:5ef35cc43463ca5624f299a4bfd2a23a67327639072eb4af2008912667e523da"
resolved_at    = "2026-04-27T15:20:08.878000+00:00"

Conventions:

  • Comment header is literal # generated; do not edit. Always rewritten.
  • One [[dataset]] block per binding. Sorted alphabetically by name.
  • Padding aligns the = (looks like name = with the value flush).
  • lineage_hash line omitted if None.
  • Trailing blank line, then a final newline.

Reading uses tomllib (stdlib, Python 3.11+). Writing is hand-emitted — no TOML write dependency in scope.

Per-entry shape

field source what
name binding key dataset domain
owner binding value's owner publisher handle
version manifest's schema_version resolved version
schema_version manifest's schema_version record-shape contract
content_hash manifest's content_hash sha256 of parquet bytes
lineage_hash manifest's lineage_hash sha256 of lineage.json
resolved_at manifest's published_at when the version was resolved

version and schema_version are currently always equal — the dataset's evolution sequence and its record-shape contract use the same number. They're stored as separate fields for forward-compat; if they diverge in a future release, both will be tracked.

Lifecycle

event mutation
esker add <owner>/<name> inserts/replaces [[dataset]] row from the manifest
esker remove <name> drops the row
esker upgrade <name> replaces the row with the latest manifest's data
esker sync reads only — never mutates

Project root

The lockfile lives next to the nearest pyproject.toml in the cwd's parent chain. So cd-ing into a subdirectory and running esker add writes to the project root, not the subdir.

Hand-edits are lossy

Manual edits to esker.lock (extra fields, comments other than the generated header, custom keys) are dropped on the next add/upgrade/remove rewrite. The header # generated; do not edit is the warning.

If you want to change a pin, edit the binding in pyproject.toml instead — the lockfile follows.

Sync semantics

esker sync reads the lockfile and reconciles the local cache. Per entry:

  1. Fetch the manifest from the hub.
  2. If the hub's content_hash differs from the lockfile entry → report drift, exit non-zero. Does not auto-upgrade.
  3. Otherwise, ensure the parquet is in ~/.esker/cache/<owner>/<name>/<version>/data.parquet and content-hash verifies.

Hash drift is treated as a real error, not a silent recover. The fix is esker upgrade <name> — explicit, per-name. That's intentional: drift is a security signal, not a routine event.

Pinned bindings vs lockfile

A versioned binding (<owner>/<name>@<version> in pyproject.toml) short-circuits the lockfile entirely for that dataset — pyproject.toml is the durable intent.

An unversioned binding consults the lock for the pin. The lock is the auto-tracked latest, written by add and upgrade.

Global bindings have no lockfile. They always resolve to "latest" at fetch time.

See also

  • Bindings — the project / global / lockfile resolution order
  • CLI bindingsadd, remove, sync, upgrade
  • Manifests — source of truth for the lock entry's hashes