Skip to main content

PyPI - Version PyPI - Python Version PyPI - Downloads codecov

ledgercore

Generic, typed storage, project-layout, and reference primitives for ledger-like Python applications.

ledgercore is a small Python library for projects that store structured records as files. It provides reusable primitives for atomic writes, YAML front matter, deterministic JSON/YAML storage, safe relative paths, Ledger-family project layout discovery and resolution, numeric and UUIDv7 IDs, and cross-ledger references.

It has no CLI and no dependency on any downstream ledger application.

Why ledgercore exists

Ledger-like tools (task trackers, architecture logs, spec registries) share the same low-level problems: safely writing files, formatting IDs, validating paths, and linking records across namespaces. ledgercore extracts those shared primitives into one typed, zero-surprise package so downstream projects do not reinvent them.

What is included

  • Atomic UTF-8 text writes and create-only writes.
  • YAML front matter read/write helpers.
  • Deterministic JSON, JSONL, and YAML file I/O.
  • Safe relative POSIX path validation.
  • Canonical Ledger-family project layout discovery and resolution.
  • Generic content fingerprints and path-text normalization.
  • Upward config discovery.
  • Prefixed numeric ID formatting.
  • Distributed UUIDv7 ID generation and validation.
  • Cross-ledger references such as tl:task-0001 and tl:task-<uuidv7>.
  • A typed public API and shared exception hierarchy.

What is not included

  • No command-line interface.
  • No database layer.
  • No sync protocol.
  • No task, architecture, or project-specific schema.
  • No dependency on taskledger, archledger, or another product package.

Installation

pip install ledgercore

Requirements:

  • Python 3.10+
  • PyYAML
  • platformdirs
  • uuid6 (used by the UUIDv7 facade)

Quick start

from pathlib import Path

from ledgercore.frontmatter import write_front_matter_document
from ledgercore.ids import LedgerIdFormat
from ledgercore.refs import parse_resource_ref

task_ids = LedgerIdFormat(prefix="task")
task_id = task_ids.next(["task-0001", "task-0002"])

write_front_matter_document(
    Path(f"records/{task_id}.md"),
    {"id": task_id, "status": "open"},
    "# New task\n",
)

ref = parse_resource_ref("tl:task-0003")
assert ref.local_id == "task-0003"
assert ref.global_ref == "tl:task-0003"

Cross-ledger references

Ledgercore supports both numeric and UUIDv7 resource IDs. Numeric IDs are short and human friendly, but require coordinated allocation. UUIDv7 IDs are longer, include an approximate creation timestamp, and can be generated independently without a shared counter.

Inside a single ledger, keep local IDs short:

task-0001
adr-0002

When linking records across ledgers, use canonical global refs:

<ledger>:<kind>-<number>
<ledger>:<kind>-<uuidv7>

Examples:

tl:task-0001
al:adr-0002
sw:spec-0003
tl:task-0199a1b2-3c4d-7e5f-8a90-123456789abc

A cross-ledger link can then store both endpoints unambiguously:

source: tl:task-0001
target: al:adr-0002
relation: implements

For filenames or systems that cannot use :, use the file-safe alias:

tl-task-0001
al-adr-0002
from ledgercore.refs import parse_resource_ref

ref = parse_resource_ref("tl:task-0001")

assert ref.ledger == "tl"
assert ref.kind == "task"
assert ref.number == 1
assert ref.local_id == "task-0001"
assert ref.global_ref == "tl:task-0001"
assert ref.file_ref == "tl-task-0001"

ID formatting

Use LedgerIdFormat as the primary numeric ID formatter:

from ledgercore.ids import LedgerIdFormat

ids = LedgerIdFormat(prefix="task")

assert ids.format(1) == "task-0001"
assert ids.parse("task-0007") == 7
assert ids.next(["task-0001", "task-0002"]) == "task-0003"

For segmented, legacy-compatible IDs:

from ledgercore.ids import LedgerIdFormat

adr_ids = LedgerIdFormat(prefix="adr", separator="-", segment_separator="-")

assert adr_ids.format(13, segment="content") == "adr-content-0013"

NumericIdFormat remains available as a simpler compatibility wrapper.

For independently generated, time-ordered IDs, use Uuid7IdFormat:

from ledgercore import Uuid7IdFormat

task_ids = Uuid7IdFormat(prefix="task")
task_id = task_ids.new()
assert task_id.startswith("task-")
assert task_ids.is_valid(task_id)

Persist the complete task-<uuidv7> value. Short suffix selectors belong to a downstream application such as Taskledger and must not replace canonical IDs in front matter or cross-ledger references. UUIDv7 ordering is useful creation-time ordering, not proof of global causal order. UUIDv7 values expose an approximate creation timestamp and are identifiers, not secrets or authorization tokens.

Front matter documents

from pathlib import Path
from ledgercore.frontmatter import (
    read_front_matter_document,
    write_front_matter_document,
)

path = Path("records/task-0001.md")

write_front_matter_document(
    path,
    {"id": "task-0001", "status": "open"},
    "# Implement parser\n",
    body_mode="ensure-single-final-newline",
)

metadata, body = read_front_matter_document(path)

Front matter documents must start with --- followed by a newline and contain a YAML mapping. The body follows the closing --- delimiter.

For in-memory content, use split_front_matter_text, render_front_matter_text, and update_front_matter_text. Permissive parsing, timestamp-as-string loading, template placeholders, key ordering, and body normalization are explicit options.

Use scalar_style="minimal" for deterministic simple front matter:

from ledgercore.frontmatter import render_front_matter_text

text = render_front_matter_text(
    {"title": "Example", "tags": ["one", "two"], "empty": ""},
    scalar_style="minimal",
    sequence_indent="  ",
    empty_string_style="double",
)

The default remains PyYAML-compatible. Render options also pass through update and file-writing helpers. Template parsing supports whole-value placeholders and a conservative "anywhere" mode for simple scalar values.

JSON and YAML stores

from pathlib import Path
from ledgercore.jsonio import dumps_json, load_json_object, write_json
from ledgercore.yamlio import load_yaml_object, write_yaml

state_path = Path("state.json")
write_json(state_path, {"next": 4})
state = load_json_object(state_path, missing="empty")
compact = dumps_json(state, compact=True)

JSON output uses indent 2, sorted keys, and a final newline. YAML uses block style and can sort keys when requested.

canonical_json produces compact deterministic JSON for hashing. load_jsonl_object_rows retains source lines. load_jsonl_object_map builds a keyed manifest while reporting missing, invalid, and duplicate keys. write_jsonl_objects writes one compact object per line atomically.

Timestamp output supports precision and suffix control:

from ledgercore.time import utc_now_iso

timestamp = utc_now_iso(timespec="microseconds", timezone_style="offset")

Safe paths and config discovery

from pathlib import Path
from ledgercore.config import locate_ledger_config
from ledgercore.paths import resolve_config_relative_path

locator = locate_ledger_config(Path.cwd())
if locator is not None:
    records_dir = resolve_config_relative_path(
        locator.config_path,
        "records",
        field_name="records_dir",
    )

locate_ledger_config prefers .ledger.toml, then ledger.toml, and returns a ConfigLocator with workspace_root, config_path, and source fields. Path helpers reject absolute paths, .., . segments, backslashes, and paths escaping the base directory.

Use ensure_inside_base, relative_to_base, and resolve_under_base when converting between resolved paths and safe base-relative paths. The separate normalize_path_text helper is for matching human-authored path text; it does not authorize filesystem access. It supports "basic", "wide", and "none" punctuation profiles plus custom translations.

Ledger project layout

ledgercore 0.5.0 uses schema 3 for one deterministic storage model. TOML parsing, writing, ownership markers, and explicit migration are Ledgercore APIs. The normal configuration is:

schema_version = 3

[project]
uuid = "081c7c05-2d10-42b7-9b37-3d814c2f400a"
name = "taskledger"

[ledgers.taskledger.mounts.data]
storage = "external"
root = "../ledger"

[ledgers.taskledger.mounts.indexes]
storage = "cache"

The config path is always .ledger/taskledger/config.toml. Mount paths are derived:

project:   .ledger/<tool>/<mount>
external:  <root>/<tool>/<project-uuid>/<mount>
user-data: <user-data>/ledgerwerk/<tool>/<project-uuid>/<mount>
cache:     <user-cache>/ledgerwerk/<tool>/<project-uuid>/<checkout-id>/<mount>

A committed external root needs no local file. A machine-local override can change one existing mount:

schema_version = 3

[ledgers.taskledger.mounts.data]
storage = "user-data"

Load and resolve the project through Ledgercore:

from pathlib import Path

from ledgercore import load_ledger_project, resolve_ledger_layout

project = load_ledger_project(Path.cwd())
layout = resolve_ledger_layout(
    project.locator,
    project.manifest,
    "taskledger",
    local_overrides=project.local_overrides,
)

The four storage kinds are project, external, user-data, and cache. Schema 3 has no provider, namespace, configurable mount path, config location, or generic scope. External roots may be project-relative; absolute roots are intended for local overrides. Resolution and ordinary reads never move or create data.

Every config directory and mount can be explicitly initialized with a .ledger-project.toml marker. External roots use .ledger-store.toml; legacy .ledger-store is accepted only for compatibility. Mismatched markers and unbound non-empty directories are rejected.

Storage changes use plan_storage_migration and execute_storage_migration. Planning is side-effect free. Execution is copy-only, validates bindings and fingerprints, refuses foreign/colliding/unsafe paths, uses same-filesystem temporary destinations and durable atomic activation, and preserves source storage. Pass StorageMigrationHooks through hooks= for quiescence, staged and activated validation, and finalization. Every externally meaningful step is recorded in an atomic schema-3 journal. recover_storage_migration supports inspection, dry-run assessment, idempotent resume, and ownership-proven rollback; ambiguous or foreign state fails closed and requests manual intervention. Destructive mode="move" is rejected before mutation.

Schema 2 remains readable for explicit migration and emits a deprecation warning. The old provider and sibling-ledger vocabulary is compatibility input only.

Shared ledger config convention

Ledgercore-based tools may still use the schema-version-1 shared config convention as a compatibility surface. In that mode, shared project metadata belongs under [project]; tool-specific configuration belongs under [tools.<tool-name>] in .ledger.toml or ledger.toml.

schema_version = 1

[project]
uuid = "565c0312-b531-4d07-aa1f-32c796f58dae"
name = "example"

[tools.example]
config_version = 1
state_dir = ".example"

Ledgercore standardizes discovery and generic table selection; it does not parse TOML or define downstream schemas. Applications remain responsible for parsing the selected file and may pass legacy names as fallbacks:

from ledgercore.config import (
    locate_ledger_config,
    select_project_config,
    select_tool_config,
)

locator = locate_ledger_config(
    Path.cwd(),
    legacy_filenames=(".example.toml", "example.toml"),
)

Canonical shared-config files win over legacy fallbacks. Applications should not implicitly merge both live configs. For the newer layout API, prefer locate_ledger_project and .ledger/ledger.toml.

Atomic writes

from pathlib import Path
from ledgercore.atomic import atomic_create_text, atomic_write_text

atomic_create_text(Path("records/task-0001.md"), "---\nid: task-0001\n---\n")
atomic_write_text(Path("index.json"), "{}\n")
  • atomic_create_text: create only; fails if target exists.
  • atomic_write_text: replace target atomically via temp file and os.replace.

Error model

All package-specific errors inherit from LedgerCoreError.

from ledgercore.errors import (
    LedgerCoreError,
    LedgerConfigError,
    StorageError,
    AtomicWriteError,
    FrontMatterError,
    JsonStoreError,
    YamlStoreError,
    PathValidationError,
    IdFormatError,
)

try:
    ...
except LedgerCoreError as exc:
    print(exc.code, str(exc))

Each exception carries a stable code attribute for programmatic handling.

Using ledgercore from a CLI application

ledgercore does not depend on a CLI framework. Adapt its errors at the application boundary:

from ledgercore.errors import LedgerCoreError


def to_usage_error(exc: LedgerCoreError) -> UsageError:
    return UsageError(str(exc))


try:
    load_application_state()
except LedgerCoreError as exc:
    raise to_usage_error(exc) from exc

This keeps exit codes, terminal formatting, and framework-specific exception types in the downstream application.

Type checking

ledgercore ships a py.typed marker. It is fully typed and passes strict mypy with strict = true.

Development

python -m pip install -e ".[dev,docs]"
python -m pytest -q
python -m ruff check .
python -m mypy ledgercore
python -m sphinx -W -b html docs docs/_build/html

Release checklist

Versions are derived from VCS tags; there is no static version in pyproject.toml to update.

  1. Update CHANGELOG.md and create or sign the target tag, such as v0.4.0.
  2. Run the test, coverage, lint, formatting, typing, example, and docs gates in docs/release.md.
  3. Run python -m build and python -m twine check dist/*.
  4. Verify the wheel and sdist contain LICENSE and generated _version.py.
  5. Smoke-test the built wheel in a clean virtualenv.

For a supported non-git source archive, provide the intended version:

SETUPTOOLS_SCM_PRETEND_VERSION=X.Y.Z python -m build

Stability

ledgercore is pre-1.0. Patch releases preserve the current minor API and may add backward-compatible APIs and fixes. Minor releases may intentionally evolve existing public APIs before 1.0; such changes require changelog and migration guidance. The 0.5.0 release adds schema-3 storage simplification while retaining schema-2 compatibility for migration.

License

Apache-2.0.

Metadata

Release files for ledgercore 0.6.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for ledgercore 0.6.2
File Size Uploaded
ledgercore-0.6.2.tar.gz 189.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for ledgercore 0.6.2
File Interpreter ABI Platform
ledgercore-0.6.2-py3-none-any.whl Python 3 none any Details

Total release size: 280.2 kB

Release files / ledgercore-0.6.2.tar.gz

Download URL ledgercore-0.6.2.tar.gz
Size 189.5 kB
Tags Source
SHA-256 checksum
How to use checksums
9b9960e5c75e1a7631c223a0c5c2bba57653705e8a3da57a8f0fe0540d317595
BLAKE2b-256 checksum
How to use checksums
a916fe9f8f4726733160db32793cc4f36b6d39f69f5e0221355b729e0bbfd167
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.1.0 CPython/3.13.7

Release files / ledgercore-0.6.2-py3-none-any.whl

Download URL ledgercore-0.6.2-py3-none-any.whl
Size 90.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7acba6a5329cdd75ef72ab49ab53a0a643f4730f5b265882a7aef06393ef4376
BLAKE2b-256 checksum
How to use checksums
c3f0121cfb73985915301e20c7f5247457f5ac5decc388c3b1792cc6b6324bdb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.1.0 CPython/3.13.7

Release history Release notifications | RSS feed

This release

0.6.2 This release

2 release files

0.6.1

2 release files

0.6.0

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page