Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
"""Project scaffolding engine: which KiCad files a directory is missing, and
how to put them there without ever half-writing a project.
:mod:`.writers.blank` knows what a blank KiCad document looks like; this module
knows *which* documents a project needs, *whether* each one is already there,
and how to land the whole set atomically. ``akcli project new`` and ``akcli
project init`` are two argument sets over the same two functions:
* :func:`plan` is **pure** — it reads the directory (and, for ``init``, the
existing root schematic's UUID) and returns a :class:`Plan` describing what
each file's fate would be. It touches nothing. ``--dry-run`` is therefore
not a second code path pretending to be the first: it is the first one,
stopped before :func:`apply`.
* :func:`apply` writes, all-or-nothing.
Why all-or-nothing
------------------
A KiCad project is a *set* of files that reference each other: the
``.kicad_pro``'s ``sheets[0][0]`` must be the root ``.kicad_sch``'s own
``(uuid ...)``. A partial scaffold — pro written, schematic not — is worse
than no scaffold at all, because KiCad opens it and silently repairs it into
something that no longer matches what akcli recorded. So:
1. **Preflight the whole set.** Any planned ``conflict`` refuses with
``PROJECT_EXISTS`` before a single byte is written (and before ``mkdir``).
2. **Stage every file, then promote.** Each write goes through
:func:`.writers.atomic.prepare` (binary temp in the target's own directory,
fsync'd) and only then through ``commit`` — the two-phase-commit shape the
multi-sheet write transaction already uses.
3. **Roll back on a partial commit.** If commit N fails, the ``created`` files
already promoted are unlinked and the ``overwritten`` ones are restored
from the backup ``commit`` itself just took. The directory ends up as it
started, and the original error propagates.
This is NOT ``writers/txn.py``: that transaction is bound to an op-list run
over a sheet tree (net gate, per-file snapshots, run ids). Scaffolding has no
op-list, no nets and no prior content to diff — sharing the machinery would
mean teaching it that a ``.gitignore`` is a schematic.
Never-overwrite rules
---------------------
``.gitignore`` is user-managed content: an existing one is ``kept`` even under
``--force`` (same rule :func:`.safety.ensure_state_root_ignore` follows). An
existing ``.kicad_pro`` is ``kept`` by ``init`` too — but it is *validated*
(``json.loads``) first, so ``init`` never reports a healthy project over a
truncated file; a malformed one is ``PROJECT_FILE_INVALID``.
"""
from __future__ import annotations
import hashlib
import json
import os
import uuid as _uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Iterable, Literal, Sequence
from . import examples as _examples
from . import journal, project, safety
from .errors import AkcliError, fail
from .writers import atomic, blank, identity
__all__ = [
"ACTIONS",
"FILE_KINDS",
"Plan",
"PlannedFile",
"apply",
"plan",
"record",
"sheet_names",
"status_of",
]
# Commit order, and the order files are reported in. The project file goes
# first so that a crash between commits leaves at worst a project pointing at
# a schematic that is about to appear (KiCad recreates a missing sheet), never
# a schematic no project knows about.
FILE_KINDS: tuple[str, ...] = (
"pro", "sch", "sheet", "pcb", "sym-lib-table", "fp-lib-table", "ops",
"gitignore",
)
_KIND_RANK = {kind: i for i, kind in enumerate(FILE_KINDS)}
# A planned file's fate. ``kept`` and ``conflict`` are both "it is already
# there"; the difference is whether this mode is allowed to leave it alone.
ACTIONS: tuple[str, ...] = ("created", "kept", "overwritten", "conflict")
Mode = Literal["new", "init"]
@dataclass(frozen=True)
class PlannedFile:
"""One file's fate: its name, what kind of KiCad file it is, and its bytes.
``content`` is ``None`` for ``kept``/``conflict`` entries — nothing will be
written, so nothing was rendered.
"""
name: str
kind: str
action: str
content: str | None = None
def to_json(self, directory: Path) -> dict:
return {
"path": (directory / self.name).as_posix(),
"name": self.name,
"kind": self.kind,
"action": self.action,
}
@dataclass
class Plan:
"""What :func:`apply` would do to ``directory`` — the whole set, at once."""
directory: Path
name: str
mode: str
force: bool
project_file: Path
root_sch: Path
root_uuid: str
files: list[PlannedFile] = field(default_factory=list)
def _names(self, action: str) -> list[str]:
return [f.name for f in self.files if f.action == action]
@property
def created(self) -> list[str]:
return self._names("created")
@property
def kept(self) -> list[str]:
return self._names("kept")
@property
def overwritten(self) -> list[str]:
return self._names("overwritten")
@property
def conflicts(self) -> list[str]:
return self._names("conflict")
@property
def writes(self) -> list[PlannedFile]:
"""The files :func:`apply` would actually write, in commit order."""
return [f for f in self.files if f.action in ("created", "overwritten")]
def to_json(self) -> dict:
"""JSON view. Every path is ``as_posix()`` — a Windows backslash in a
payload breaks byte-stable snapshots and JSON-escapes into ``\\\\``."""
return {
"mode": self.mode,
"directory": self.directory.as_posix(),
"name": self.name,
"project_file": self.project_file.as_posix(),
"root_sch": self.root_sch.as_posix(),
"root_uuid": self.root_uuid,
"files": [f.to_json(self.directory) for f in self.files],
"created": self.created,
"kept": self.kept,
"overwritten": self.overwritten,
"conflicts": self.conflicts,
}
# --------------------------------------------------------------------------- #
# planning
# --------------------------------------------------------------------------- #
def _keep_sch(p: Path) -> bool:
"""Ignore KiCad's own churn when looking for the root schematic."""
name = p.name
return (p.is_file()
and not name.startswith("~")
and not name.startswith("_autosave-")
and not name.endswith("-save.kicad_sch"))
def _existing_pros(directory: Path) -> list[Path]:
return sorted(p for p in directory.glob("*.kicad_pro") if p.is_file())
def _existing_schs(directory: Path) -> list[Path]:
return sorted(p for p in directory.glob("*.kicad_sch") if _keep_sch(p))
def _read_pro(path: Path) -> dict:
"""Parse an existing ``.kicad_pro``; a malformed one is fatal, not ignored.
``init`` reports an existing project file as ``kept``, which is a claim
about the *project*, not just about a filename — so the claim is checked.
"""
text = safety.read_text_guarded(path)
try:
doc = json.loads(text)
except json.JSONDecodeError as exc:
fail("PROJECT_FILE_INVALID",
f"{path.name} is not valid JSON ({exc.msg} at line {exc.lineno})")
if not isinstance(doc, dict):
fail("PROJECT_FILE_INVALID",
f"{path.name} is not a KiCad project object (got "
f"{type(doc).__name__})")
return doc
def _child_emitter(paper: str, child_uuid: str) -> Callable[[], str]:
"""Bind the per-child uuid eagerly (a bare lambda in the loop would not)."""
return lambda: blank.blank_sch(paper, None, uuid=child_uuid)
def _root_uuid_of(sch: Path) -> str:
"""The root-sheet UUID of an existing schematic (``PROJECT_FILE_INVALID``
when the file is not a ``kicad_sch`` document at all)."""
from .sexpr import parse as _sexpr_parse
from .sexpr import query as _q
doc = _sexpr_parse(safety.read_text_guarded(sch))
if doc.tag != "kicad_sch":
fail("PROJECT_FILE_INVALID",
f"{sch.name} is not a schematic document (root tag "
f"{doc.tag!r}, expected 'kicad_sch')")
return _q.require_root_uuid(doc)
def _resolve_root_sch(directory: Path, explicit: Path | None) -> Path:
"""The root schematic ``init`` should bind the project to."""
if explicit is not None:
root = explicit if explicit.is_absolute() else directory / explicit
if not root.is_file():
fail("PROJECT_FILE_MISSING", f"--root {root} does not exist")
return root
candidates = _existing_schs(directory)
if not candidates:
fail("PROJECT_FILE_MISSING",
f"no .kicad_sch in {directory} — `akcli project new` creates a "
"project from scratch, `project init` only completes an existing one")
if len(candidates) == 1:
return candidates[0]
roots = [c for c in candidates if project.resolve_root(c) == c.resolve()]
if len(roots) == 1:
return roots[0]
names = ", ".join(sorted(c.name for c in (roots or candidates)))
fail("PROJECT_ROOT_AMBIGUOUS",
f"{directory} holds several root schematics ({names}); pass "
"--root FILE to name the one the project should open")
def _resolve_name(directory: Path, mode: str, explicit: str | None,
root_sch: Path | None) -> str:
"""Project name: ``--name`` > the existing project/root stem > dir name."""
if explicit:
return explicit
if mode == "init":
pros = _existing_pros(directory)
if len(pros) == 1:
return pros[0].stem
if root_sch is not None:
return root_sch.stem
return directory.name or "project"
def _fate(path: Path, *, mode: str, force: bool, never_overwrite: bool = False) -> str:
"""What happens to ``path``: created / kept / overwritten / conflict."""
if not path.exists():
return "created"
if mode == "init" or never_overwrite:
return "kept"
return "overwritten" if force else "conflict"
def plan(
directory: Path | str,
*,
mode: Mode = "new",
name: str | None = None,
paper: str = "A4",
title: str | None = None,
force: bool = False,
no_pcb: bool = False,
no_lib_tables: bool = False,
no_gitignore: bool = False,
sheets: Sequence[str] = (),
root: Path | None = None,
example: str | None = None,
) -> Plan:
"""Decide the fate of every file in a scaffolded project. Writes nothing.
``mode="new"`` renders a fresh project (a fresh root UUID); ``mode="init"``
adopts what is already there — the existing root schematic keeps its UUID,
so the ``.kicad_pro`` this creates points at the design the user already
has instead of orphaning it.
``example`` names a packaged op-list (:mod:`.examples`) to drop in as
``ops.json``. It is planned like every other file — an existing ``ops.json``
is a ``conflict``, not a silent overwrite — because that file is the user's
design intent, and the one thing a scaffolding command must never do is
replace it.
"""
directory = Path(directory).resolve()
root_sch: Path
root_uuid: str
if mode == "init":
root_sch = _resolve_root_sch(directory, root)
root_uuid = _root_uuid_of(root_sch)
name = _resolve_name(directory, mode, name, root_sch)
else:
name = _resolve_name(directory, mode, name, None)
root_sch = directory / f"{name}.kicad_sch"
# An existing root schematic keeps its identity even under --force:
# re-minting the UUID would orphan every `instances` path already
# written into the design's child sheets. Best-effort on purpose —
# `new` is authoritative, so an unreadable file there must not change
# WHICH error the caller sees (a conflict, or nothing at all under
# --force) into a parse error about a file being replaced anyway.
root_uuid = str(_uuid.uuid4())
if root_sch.is_file():
try:
root_uuid = _root_uuid_of(root_sch)
except AkcliError:
pass
project_file = directory / f"{name}.kicad_pro"
files: list[PlannedFile] = []
seen: set[str] = set()
def add(path: Path, kind: str, render: Callable[[], str], *,
never_overwrite: bool = False) -> None:
# One entry per FILE, first claim wins: `--sheets demo` on project
# `demo` names the root sheet, and planning it twice would stage two
# writes to one path (the second clobbering the first mid-commit).
if path.name in seen:
return
seen.add(path.name)
action = _fate(path, mode=mode, force=force,
never_overwrite=never_overwrite)
content = render() if action in ("created", "overwritten") else None
files.append(PlannedFile(path.name, kind, action, content))
# An existing .kicad_pro that `init` keeps is validated, never assumed.
if mode == "init" and project_file.is_file():
_read_pro(project_file)
add(project_file, "pro",
lambda: blank.blank_pro(project_file.name, root_uuid))
add(root_sch, "sch", lambda: blank.blank_sch(paper, title, uuid=root_uuid))
for sheet in sheets:
child = directory / f"{sheet}.kicad_sch"
# Content-seeded like every other identity akcli mints: the same seed
# `add_sheet {"create": true}` uses, so a child pre-created here and one
# created in-transaction from the same root carry the same root uuid.
child_uuid = identity.node_uuid(
root_uuid, identity.seed_sheet_file(child.name))
add(child, "sheet", _child_emitter(paper, child_uuid))
if not no_pcb:
add(directory / f"{name}.kicad_pcb", "pcb", lambda: blank.blank_pcb(paper))
if not no_lib_tables:
add(directory / "sym-lib-table", "sym-lib-table",
lambda: blank.blank_lib_table("sym"))
add(directory / "fp-lib-table", "fp-lib-table",
lambda: blank.blank_lib_table("fp"))
if example:
add(directory / "ops.json", "ops",
lambda: _examples.ops_text(str(example)))
if not no_gitignore:
# user-managed content: never overwritten, not even by --force
add(directory / ".gitignore", "gitignore", blank.kicad_gitignore,
never_overwrite=True)
files.sort(key=lambda f: (_KIND_RANK[f.kind], f.name))
return Plan(directory=directory, name=name, mode=mode, force=force,
project_file=project_file, root_sch=root_sch,
root_uuid=root_uuid, files=files)
# --------------------------------------------------------------------------- #
# applying
# --------------------------------------------------------------------------- #
def status_of(p: Plan, *, dry_run: bool = False) -> str:
"""``dry-run`` / ``unchanged`` / ``applied`` for one plan."""
if dry_run:
return "dry-run"
return "applied" if p.writes else "unchanged"
def record(p: Plan, status: str, *, dry_run: bool = False) -> None:
"""Append the workspace-journal entry for this scaffold run.
Skipped when the target directory does not exist yet: the journal lives
INSIDE the workspace (``<dir>/.akcli/``), so recording a ``--dry-run`` of a
not-yet-created project would be the one thing a dry run must never do —
create it. There is no workspace to journal into until :func:`apply` runs.
"""
if not p.directory.is_dir():
return
journal.record(
p.project_file, f"project {p.mode}", status,
created=p.created or None,
kept=p.kept or None,
overwritten=p.overwritten or None,
conflicts=p.conflicts or None,
root_uuid=p.root_uuid,
dry_run=True if dry_run else None,
)
def _restore(path: Path, backup_dir: Path) -> None:
"""Put back the pre-write bytes ``commit`` snapshotted for ``path``."""
import shutil
bak = backup_dir / atomic.backup_name(path.name, 1)
if bak.is_file():
shutil.copy2(bak, path)
def _rollback(done: list[tuple[PlannedFile, Path]], backup_dir: Path | None) -> None:
"""Undo the commits that DID land, newest first."""
for planned, path in reversed(done):
try:
if planned.action == "created":
os.unlink(path)
elif backup_dir is not None:
_restore(path, backup_dir)
except OSError:
# Best-effort: the caller re-raises the original failure, which is
# the one that explains the state. Backups stay on disk either way.
pass
def apply(p: Plan, *, backup_depth: int = atomic.DEFAULT_BACKUP_DEPTH) -> None:
"""Write the plan, all-or-nothing.
Refuses the whole run on any ``conflict`` (``PROJECT_EXISTS``) before
touching the disk, refuses to race the KiCad GUI when it would overwrite a
file it has open, stages every write before promoting the first, and rolls
back to the starting state if a promote fails partway.
"""
if p.conflicts:
fail("PROJECT_EXISTS",
f"{p.directory} already has {', '.join(p.conflicts)}")
writes = p.writes
overwrites = [f for f in writes if f.action == "overwritten"]
if overwrites:
safety.refuse_if_gui_locked(
[p.directory / f.name for f in overwrites],
what=f"project {p.mode}")
p.directory.mkdir(parents=True, exist_ok=True)
if not writes:
record(p, "unchanged")
return
backup_dir: Path | None = None
if overwrites:
backup_dir = journal.ensure_backups_dir(p.project_file)
prepared: list[tuple[PlannedFile, Path, atomic.PreparedWrite]] = []
try:
for planned in writes:
path = p.directory / planned.name
assert planned.content is not None # writes always carry bytes
prepared.append((planned, path,
atomic.prepare(path, planned.content,
_snapshot(path))))
except BaseException:
for _planned, _path, pw in prepared:
atomic.abort(pw)
raise
done: list[tuple[PlannedFile, Path]] = []
try:
for planned, path, pw in prepared:
atomic.commit(
pw,
backup_dir if planned.action == "overwritten" else None,
backup_depth)
done.append((planned, path))
except BaseException:
for planned, _path, pw in prepared:
if not any(planned is d[0] for d in done):
atomic.abort(pw)
_rollback(done, backup_dir)
raise
record(p, "applied")
def _snapshot(path: Path) -> str:
"""``sha256`` of ``path``'s current bytes (empty when it does not exist).
:func:`.writers.atomic.prepare` re-checks this immediately before writing,
so a file that changed between :func:`plan` and :func:`apply` is refused
(``VERIFY_FAILED``) instead of silently clobbered.
"""
try:
data = path.read_bytes()
except OSError:
data = b""
return hashlib.sha256(data).hexdigest()
def sheet_names(raw: Iterable[str] | str | None) -> list[str]:
"""Parse ``--sheets a,b`` into stems, dropping blanks and duplicates."""
if raw is None:
return []
items = raw.split(",") if isinstance(raw, str) else list(raw)
out: list[str] = []
for item in items:
for part in str(item).split(","):
stem = part.strip()
if stem.lower().endswith(".kicad_sch"):
stem = stem[: -len(".kicad_sch")]
if stem and stem not in out:
out.append(stem)
return out
2126c3284This file is pinned to the selected release. Download the original to open it in your design tool.