fenolite 0.0.1.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fenolite/__init__.py +7 -0
- fenolite/__main__.py +8 -0
- fenolite/cli/__init__.py +3 -0
- fenolite/cli/api.py +101 -0
- fenolite/cli/cmd__echo.py +50 -0
- fenolite/cli/cmd_capabilities.py +112 -0
- fenolite/cli/errors.py +120 -0
- fenolite/cli/exitcodes.py +23 -0
- fenolite/cli/main.py +215 -0
- fenolite/cli/output.py +201 -0
- fenolite/core/__init__.py +3 -0
- fenolite/core/coords.py +31 -0
- fenolite/core/errors.py +64 -0
- fenolite/core/evidence.py +71 -0
- fenolite/core/ids.py +89 -0
- fenolite/core/io.py +84 -0
- fenolite/core/provenance.py +23 -0
- fenolite/core/units.py +143 -0
- fenolite/model/__init__.py +74 -0
- fenolite/model/base.py +54 -0
- fenolite/model/board.py +250 -0
- fenolite/model/canonical.py +248 -0
- fenolite/model/circuit.py +100 -0
- fenolite/model/design.py +206 -0
- fenolite/model/findings.py +19 -0
- fenolite/model/manufacturing.py +51 -0
- fenolite/model/rules.py +102 -0
- fenolite/model/schema.py +20 -0
- fenolite/py.typed +0 -0
- fenolite-0.0.1.dev0.dist-info/METADATA +117 -0
- fenolite-0.0.1.dev0.dist-info/RECORD +35 -0
- fenolite-0.0.1.dev0.dist-info/WHEEL +4 -0
- fenolite-0.0.1.dev0.dist-info/entry_points.txt +2 -0
- fenolite-0.0.1.dev0.dist-info/licenses/LICENSE +202 -0
- fenolite-0.0.1.dev0.dist-info/licenses/NOTICE +1 -0
fenolite/__init__.py
ADDED
fenolite/__main__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026 Fenolite contributors
|
|
3
|
+
"""Allow ``python -m fenolite``; behaves exactly like the ``fenolite`` console script."""
|
|
4
|
+
|
|
5
|
+
from fenolite.cli.main import main
|
|
6
|
+
|
|
7
|
+
if __name__ == "__main__":
|
|
8
|
+
raise SystemExit(main())
|
fenolite/cli/__init__.py
ADDED
fenolite/cli/api.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026 Fenolite contributors
|
|
3
|
+
"""What a command module provides, what it receives and what it returns.
|
|
4
|
+
|
|
5
|
+
A command lives in ``fenolite/cli/cmd_<name>.py`` (dashes become underscores) and exposes a
|
|
6
|
+
module-level ``COMMAND`` (:class:`Command`). Commands never write files themselves: they return
|
|
7
|
+
:class:`PlannedWrite` objects and the dispatcher applies the mutation protocol
|
|
8
|
+
(``--dry-run`` / ``--confirm``, atomic writes, backups, receipts).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import importlib
|
|
15
|
+
import pkgutil
|
|
16
|
+
import random
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from fenolite.cli.output import Evidence, InputRef, Issue, OutputMode
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Context:
|
|
28
|
+
"""Everything a command may depend on besides its own arguments."""
|
|
29
|
+
|
|
30
|
+
mode: OutputMode
|
|
31
|
+
seed: int | None
|
|
32
|
+
timestamp: datetime
|
|
33
|
+
rng: random.Random
|
|
34
|
+
no_backup: bool
|
|
35
|
+
cwd: Path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class PlannedWrite:
|
|
40
|
+
"""A file a mutating command wants to write. ``path`` is relative to the working directory."""
|
|
41
|
+
|
|
42
|
+
path: str
|
|
43
|
+
data: bytes
|
|
44
|
+
kind: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class Result:
|
|
49
|
+
"""What a command returns to the dispatcher."""
|
|
50
|
+
|
|
51
|
+
result: dict[str, Any] = field(default_factory=lambda: {})
|
|
52
|
+
issues: tuple[Issue, ...] = ()
|
|
53
|
+
evidence: Evidence = Evidence()
|
|
54
|
+
input: InputRef | None = None
|
|
55
|
+
writes: tuple[PlannedWrite, ...] = ()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class Command:
|
|
60
|
+
"""A registered sub-command. ``help=None`` hides it from ``--help``."""
|
|
61
|
+
|
|
62
|
+
name: str
|
|
63
|
+
help: str | None
|
|
64
|
+
mutates: bool
|
|
65
|
+
register: Callable[[argparse.ArgumentParser], None]
|
|
66
|
+
run: Callable[[argparse.Namespace, Context], Result]
|
|
67
|
+
example_args: tuple[str, ...] = ()
|
|
68
|
+
mutation_example_args: tuple[str, ...] | None = None
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def hidden(self) -> bool:
|
|
72
|
+
return self.help is None
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def schema(self) -> str:
|
|
76
|
+
return f"fenolite.{self.name}.v0"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def module_name_for(command_name: str) -> str:
|
|
80
|
+
return "cmd_" + command_name.replace("-", "_")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def discover() -> dict[str, Command]:
|
|
84
|
+
"""Import every ``fenolite.cli.cmd_*`` module and return its ``COMMAND`` keyed by name."""
|
|
85
|
+
import fenolite.cli as package
|
|
86
|
+
|
|
87
|
+
found: dict[str, Command] = {}
|
|
88
|
+
for info in sorted(pkgutil.iter_modules(package.__path__), key=lambda m: m.name):
|
|
89
|
+
if not info.name.startswith("cmd_"):
|
|
90
|
+
continue
|
|
91
|
+
module = importlib.import_module(f"{package.__name__}.{info.name}")
|
|
92
|
+
command = getattr(module, "COMMAND", None)
|
|
93
|
+
if not isinstance(command, Command):
|
|
94
|
+
raise TypeError(f"{module.__name__} does not define COMMAND as fenolite.cli.api.Command")
|
|
95
|
+
if module_name_for(command.name) != info.name:
|
|
96
|
+
raise ValueError(f"{module.__name__} declares command {command.name!r}; module name must match")
|
|
97
|
+
found[command.name] = command
|
|
98
|
+
return found
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
__all__ = ["Command", "Context", "PlannedWrite", "Result", "discover", "module_name_for"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026 Fenolite contributors
|
|
3
|
+
"""Hidden ``_echo`` command: exercises every branch of the CLI contract for the consistency tests."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fenolite.cli.api import Command, Context, PlannedWrite, Result
|
|
12
|
+
from fenolite.cli.output import Issue
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _register(parser: argparse.ArgumentParser) -> None:
|
|
16
|
+
parser.add_argument("--issue", action="append", choices=["error", "warning", "info"], default=[],
|
|
17
|
+
help="emit an issue of this severity (repeatable)") # fmt: skip
|
|
18
|
+
parser.add_argument("--raise", dest="raise_", action="store_true", help="raise an internal exception")
|
|
19
|
+
parser.add_argument("--write", metavar="PATH", help="plan a write of --content to PATH")
|
|
20
|
+
parser.add_argument("--content", default="echo\n", help="content for --write")
|
|
21
|
+
parser.add_argument("--gen-id", action="store_true", help="generate an id and a timestamp")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _run(args: argparse.Namespace, ctx: Context) -> Result:
|
|
25
|
+
if args.raise_:
|
|
26
|
+
raise RuntimeError("requested failure (--raise)")
|
|
27
|
+
severities: list[str] = args.issue
|
|
28
|
+
issues = tuple(
|
|
29
|
+
Issue(code=f"echo.{sev}", severity=sev, message=f"requested {sev} issue", where="_echo") # type: ignore[arg-type]
|
|
30
|
+
for sev in severities
|
|
31
|
+
)
|
|
32
|
+
result: dict[str, Any] = {"echo": {"issues": severities, "write": args.write}}
|
|
33
|
+
if args.gen_id:
|
|
34
|
+
result["id"] = str(uuid.UUID(int=ctx.rng.getrandbits(128), version=4))
|
|
35
|
+
result["timestamp"] = ctx.timestamp.isoformat()
|
|
36
|
+
writes: tuple[PlannedWrite, ...] = ()
|
|
37
|
+
if args.write:
|
|
38
|
+
writes = (PlannedWrite(path=args.write, data=str(args.content).encode("utf-8"), kind="text"),)
|
|
39
|
+
return Result(result=result, issues=issues, writes=writes)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
COMMAND = Command(
|
|
43
|
+
name="_echo",
|
|
44
|
+
help=None,
|
|
45
|
+
mutates=True,
|
|
46
|
+
register=_register,
|
|
47
|
+
run=_run,
|
|
48
|
+
example_args=(),
|
|
49
|
+
mutation_example_args=("--write", "echo.txt"),
|
|
50
|
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026 Fenolite contributors
|
|
3
|
+
"""``fenolite capabilities``: what this installation can do. Agents should call it first."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import importlib.metadata
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
from functools import cache
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from fenolite import __version__
|
|
18
|
+
from fenolite.cli.api import Command, Context, Result, discover
|
|
19
|
+
|
|
20
|
+
_MACOS_KICAD_CLI = "/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli"
|
|
21
|
+
_EXTRA_MARKER = re.compile(r"extra\s*==\s*['\"]([^'\"]+)['\"]")
|
|
22
|
+
_DIST_NAME = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _extras() -> dict[str, dict[str, Any]]:
|
|
26
|
+
"""Each optional extra of the installed distribution and whether its packages are installed."""
|
|
27
|
+
try:
|
|
28
|
+
requires = importlib.metadata.metadata("fenolite").get_all("Requires-Dist") or []
|
|
29
|
+
except importlib.metadata.PackageNotFoundError:
|
|
30
|
+
return {}
|
|
31
|
+
wanted: dict[str, list[str]] = {}
|
|
32
|
+
for requirement in requires:
|
|
33
|
+
extra = _EXTRA_MARKER.search(requirement)
|
|
34
|
+
name = _DIST_NAME.match(requirement)
|
|
35
|
+
if extra and name:
|
|
36
|
+
wanted.setdefault(extra.group(1), []).append(name.group(1))
|
|
37
|
+
report: dict[str, dict[str, Any]] = {}
|
|
38
|
+
for extra, dists in sorted(wanted.items()):
|
|
39
|
+
missing: list[str] = []
|
|
40
|
+
for dist in sorted(dists):
|
|
41
|
+
try:
|
|
42
|
+
importlib.metadata.version(dist)
|
|
43
|
+
except importlib.metadata.PackageNotFoundError:
|
|
44
|
+
missing.append(dist)
|
|
45
|
+
report[extra] = {"installed": not missing, "missing": missing}
|
|
46
|
+
return report
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _run_version(argv: list[str]) -> str | None:
|
|
50
|
+
try:
|
|
51
|
+
proc = subprocess.run(argv, capture_output=True, text=True, timeout=15, check=False)
|
|
52
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
53
|
+
return None
|
|
54
|
+
if proc.returncode != 0:
|
|
55
|
+
return None
|
|
56
|
+
text = (proc.stdout or proc.stderr).strip()
|
|
57
|
+
return text.splitlines()[0].strip() if text else None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _kicad_cli_path() -> str | None:
|
|
61
|
+
override = os.environ.get("FENOLITE_KICAD_CLI")
|
|
62
|
+
if override:
|
|
63
|
+
return override if Path(override).is_file() else None
|
|
64
|
+
found = shutil.which("kicad-cli")
|
|
65
|
+
if found:
|
|
66
|
+
return found
|
|
67
|
+
return _MACOS_KICAD_CLI if Path(_MACOS_KICAD_CLI).is_file() else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@cache
|
|
71
|
+
def detect_tools() -> dict[str, dict[str, str] | None]:
|
|
72
|
+
"""External tools Fenolite can drive, with their version, or ``None`` when absent or unusable."""
|
|
73
|
+
tools: dict[str, dict[str, str] | None] = {}
|
|
74
|
+
probes: dict[str, tuple[str | None, list[str]]] = {
|
|
75
|
+
"kicad-cli": (_kicad_cli_path(), ["version"]),
|
|
76
|
+
"java": (shutil.which("java"), ["-version"]),
|
|
77
|
+
"docker": (shutil.which("docker"), ["--version"]),
|
|
78
|
+
}
|
|
79
|
+
for name, (path, version_args) in probes.items():
|
|
80
|
+
version = _run_version([path, *version_args]) if path else None
|
|
81
|
+
tools[name] = {"path": path, "version": version} if path and version else None
|
|
82
|
+
return tools
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _register(parser: argparse.ArgumentParser) -> None:
|
|
86
|
+
parser.add_argument("--no-tools", action="store_true", help="skip external tool detection")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _run(args: argparse.Namespace, ctx: Context) -> Result:
|
|
90
|
+
commands = [
|
|
91
|
+
{"name": c.name, "mutates": c.mutates, "schema": c.schema, "hidden": c.hidden}
|
|
92
|
+
for c in sorted(discover().values(), key=lambda c: c.name)
|
|
93
|
+
]
|
|
94
|
+
result: dict[str, Any] = {
|
|
95
|
+
"fenolite_version": __version__,
|
|
96
|
+
"commands": commands,
|
|
97
|
+
"backends": [],
|
|
98
|
+
"extras": _extras(),
|
|
99
|
+
"tools": {} if args.no_tools else detect_tools(),
|
|
100
|
+
"sends_data_offsite": False,
|
|
101
|
+
}
|
|
102
|
+
return Result(result=result)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
COMMAND = Command(
|
|
106
|
+
name="capabilities",
|
|
107
|
+
help="list commands, backends, extras and external tools available here",
|
|
108
|
+
mutates=False,
|
|
109
|
+
register=_register,
|
|
110
|
+
run=_run,
|
|
111
|
+
example_args=("--no-tools",),
|
|
112
|
+
)
|
fenolite/cli/errors.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026 Fenolite contributors
|
|
3
|
+
"""Typed CLI errors: the ``FEN-NNNN`` registry, the stderr error object and :class:`CliError`."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from fenolite.cli.exitcodes import ExitCode
|
|
11
|
+
|
|
12
|
+
ERROR_CODE = re.compile(r"^FEN-[1-7][0-9]{3}$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class ErrorSpec:
|
|
17
|
+
"""Registry entry for one error code."""
|
|
18
|
+
|
|
19
|
+
code: str
|
|
20
|
+
exit_code: ExitCode
|
|
21
|
+
message: str
|
|
22
|
+
hint: str
|
|
23
|
+
retryable: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_SPECS = (
|
|
27
|
+
ErrorSpec(
|
|
28
|
+
"FEN-1001", ExitCode.INTERNAL, "internal error", "this is a bug; report it with the command line"
|
|
29
|
+
),
|
|
30
|
+
ErrorSpec("FEN-2001", ExitCode.USAGE, "invalid command line", "run 'fenolite --help'"),
|
|
31
|
+
ErrorSpec(
|
|
32
|
+
"FEN-2002", ExitCode.USAGE, "unknown field in --fields", "list the result keys with --json first"
|
|
33
|
+
),
|
|
34
|
+
ErrorSpec(
|
|
35
|
+
"FEN-2003", ExitCode.USAGE, "--dry-run and --confirm are mutually exclusive", "use one of them"
|
|
36
|
+
),
|
|
37
|
+
ErrorSpec("FEN-2004", ExitCode.USAGE, "invalid --timestamp", "use ISO 8601, e.g. 2026-01-01T00:00:00Z"),
|
|
38
|
+
ErrorSpec(
|
|
39
|
+
"FEN-3001", ExitCode.INPUT, "input file missing or unreadable", "check the path and permissions"
|
|
40
|
+
),
|
|
41
|
+
ErrorSpec(
|
|
42
|
+
"FEN-3002", ExitCode.INPUT, "input uses a newer format version than supported", "upgrade fenolite"
|
|
43
|
+
),
|
|
44
|
+
ErrorSpec(
|
|
45
|
+
"FEN-4001",
|
|
46
|
+
ExitCode.CONFIRM_REQUIRED,
|
|
47
|
+
"confirmation required; nothing was written",
|
|
48
|
+
"review result.plan, then re-run with --confirm (or --dry-run to only preview)",
|
|
49
|
+
),
|
|
50
|
+
ErrorSpec(
|
|
51
|
+
"FEN-5001",
|
|
52
|
+
ExitCode.FINDINGS,
|
|
53
|
+
"verification produced findings of severity error",
|
|
54
|
+
"read 'issues' in the envelope",
|
|
55
|
+
),
|
|
56
|
+
ErrorSpec(
|
|
57
|
+
"FEN-6001",
|
|
58
|
+
ExitCode.TOOL,
|
|
59
|
+
"external tool not found",
|
|
60
|
+
"run 'fenolite capabilities' to see what is missing",
|
|
61
|
+
retryable=True,
|
|
62
|
+
),
|
|
63
|
+
ErrorSpec(
|
|
64
|
+
"FEN-6002", ExitCode.TOOL, "external tool version not supported", "install a supported version"
|
|
65
|
+
),
|
|
66
|
+
ErrorSpec(
|
|
67
|
+
"FEN-7001",
|
|
68
|
+
ExitCode.LOSSY,
|
|
69
|
+
"operation would lose information",
|
|
70
|
+
"re-run with --allow-lossy to accept the loss",
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
REGISTRY: dict[str, ErrorSpec] = {spec.code: spec for spec in _SPECS}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class ErrorInfo:
|
|
79
|
+
"""The single error object written to stderr when the exit code is not 0."""
|
|
80
|
+
|
|
81
|
+
code: str = field(metadata={"pattern": ERROR_CODE.pattern})
|
|
82
|
+
message: str
|
|
83
|
+
hint: str
|
|
84
|
+
retryable: bool
|
|
85
|
+
where: str
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CliError(Exception):
|
|
89
|
+
"""An error with a registered ``FEN-NNNN`` code; the dispatcher maps it to an exit code."""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
code: str,
|
|
94
|
+
message: str | None = None,
|
|
95
|
+
*,
|
|
96
|
+
hint: str | None = None,
|
|
97
|
+
where: str = "",
|
|
98
|
+
retryable: bool | None = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
if code not in REGISTRY:
|
|
101
|
+
raise ValueError(f"unregistered error code {code!r}")
|
|
102
|
+
spec = REGISTRY[code]
|
|
103
|
+
self.code = code
|
|
104
|
+
self.message = message or spec.message
|
|
105
|
+
self.hint = spec.hint if hint is None else hint
|
|
106
|
+
self.where = where
|
|
107
|
+
self.retryable = spec.retryable if retryable is None else retryable
|
|
108
|
+
super().__init__(f"{code}: {self.message}")
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def exit_code(self) -> ExitCode:
|
|
112
|
+
return REGISTRY[self.code].exit_code
|
|
113
|
+
|
|
114
|
+
def info(self) -> ErrorInfo:
|
|
115
|
+
return ErrorInfo(
|
|
116
|
+
code=self.code, message=self.message, hint=self.hint, retryable=self.retryable, where=self.where
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
__all__ = ["ERROR_CODE", "REGISTRY", "CliError", "ErrorInfo", "ErrorSpec"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026 Fenolite contributors
|
|
3
|
+
"""The fixed exit-code vocabulary of the ``fenolite`` command (frozen at 1.0)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from enum import IntEnum
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExitCode(IntEnum):
|
|
11
|
+
"""Process exit codes. The first digit of every ``FEN-NNNN`` error code equals one of these."""
|
|
12
|
+
|
|
13
|
+
OK = 0
|
|
14
|
+
INTERNAL = 1
|
|
15
|
+
USAGE = 2
|
|
16
|
+
INPUT = 3
|
|
17
|
+
CONFIRM_REQUIRED = 4
|
|
18
|
+
FINDINGS = 5
|
|
19
|
+
TOOL = 6
|
|
20
|
+
LOSSY = 7
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__all__ = ["ExitCode"]
|
fenolite/cli/main.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026 Fenolite contributors
|
|
3
|
+
"""The ``fenolite`` dispatcher: global flags, command discovery, errors, exit codes and the
|
|
4
|
+
mutation protocol. See ``docs/cli-contract.md``."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import random
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import NoReturn, TextIO
|
|
16
|
+
|
|
17
|
+
from fenolite import __version__
|
|
18
|
+
from fenolite.cli.api import Command, Context, Result, discover
|
|
19
|
+
from fenolite.cli.errors import CliError, ErrorInfo
|
|
20
|
+
from fenolite.cli.exitcodes import ExitCode
|
|
21
|
+
from fenolite.cli.output import (
|
|
22
|
+
Envelope,
|
|
23
|
+
FieldNotFoundError,
|
|
24
|
+
OutputMode,
|
|
25
|
+
Receipt,
|
|
26
|
+
WrittenFile,
|
|
27
|
+
parse_fields,
|
|
28
|
+
project_fields,
|
|
29
|
+
render_json,
|
|
30
|
+
render_text,
|
|
31
|
+
resolve_mode,
|
|
32
|
+
write_error,
|
|
33
|
+
)
|
|
34
|
+
from fenolite.core.io import atomic_write, sha256_bytes
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _Parser(argparse.ArgumentParser):
|
|
38
|
+
"""argparse that raises typed usage errors instead of printing and exiting."""
|
|
39
|
+
|
|
40
|
+
def error(self, message: str) -> NoReturn:
|
|
41
|
+
raise CliError("FEN-2001", message, hint=f"run '{self.prog} --help'")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _add_global_options(parser: argparse.ArgumentParser, *, top_level: bool) -> None:
|
|
45
|
+
default: object = argparse.SUPPRESS
|
|
46
|
+
group = parser.add_argument_group("output and determinism")
|
|
47
|
+
group.add_argument("--json", action="store_true", default=False if top_level else default,
|
|
48
|
+
help="force JSON output") # fmt: skip
|
|
49
|
+
group.add_argument("--text", action="store_true", default=False if top_level else default,
|
|
50
|
+
help="force human-readable output") # fmt: skip
|
|
51
|
+
group.add_argument("--fields", default=None if top_level else default, metavar="A,B.C",
|
|
52
|
+
help="keep only these dotted paths of 'result'") # fmt: skip
|
|
53
|
+
group.add_argument("--seed", type=int, default=None if top_level else default,
|
|
54
|
+
help="seed for generated ids (reproducible output)") # fmt: skip
|
|
55
|
+
group.add_argument("--timestamp", default=None if top_level else default, metavar="ISO8601",
|
|
56
|
+
help="fixed timestamp for generated dates") # fmt: skip
|
|
57
|
+
group.add_argument("--no-backup", action="store_true", default=False if top_level else default,
|
|
58
|
+
help="do not keep .bak copies of overwritten files") # fmt: skip
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_parser(commands: dict[str, Command]) -> argparse.ArgumentParser:
|
|
62
|
+
parser = _Parser(prog="fenolite", description="Headless, agent-first PCB design automation.")
|
|
63
|
+
parser.add_argument("--version", action="version", version=f"fenolite {__version__}")
|
|
64
|
+
_add_global_options(parser, top_level=True)
|
|
65
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND", parser_class=_Parser)
|
|
66
|
+
for command in sorted(commands.values(), key=lambda c: c.name):
|
|
67
|
+
if command.help is None: # hidden: not listed in --help
|
|
68
|
+
child = sub.add_parser(command.name)
|
|
69
|
+
else:
|
|
70
|
+
child = sub.add_parser(command.name, help=command.help, description=command.help)
|
|
71
|
+
_add_global_options(child, top_level=False)
|
|
72
|
+
if command.mutates:
|
|
73
|
+
protocol = child.add_argument_group("writing files")
|
|
74
|
+
protocol.add_argument("--dry-run", action="store_true", help="show the plan; write nothing")
|
|
75
|
+
protocol.add_argument("--confirm", action="store_true", help="perform the writes")
|
|
76
|
+
command.register(child)
|
|
77
|
+
return parser
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _timestamp(value: str | None) -> datetime:
|
|
81
|
+
if value is None:
|
|
82
|
+
return datetime.now(UTC)
|
|
83
|
+
try:
|
|
84
|
+
parsed = datetime.fromisoformat(value)
|
|
85
|
+
except ValueError as exc:
|
|
86
|
+
raise CliError("FEN-2004", f"invalid --timestamp {value!r}") from exc
|
|
87
|
+
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _context(args: argparse.Namespace, mode: OutputMode) -> Context:
|
|
91
|
+
seed: int | None = args.seed
|
|
92
|
+
rng = random.Random(seed) if seed is not None else random.Random()
|
|
93
|
+
return Context(
|
|
94
|
+
mode=mode,
|
|
95
|
+
seed=seed,
|
|
96
|
+
timestamp=_timestamp(args.timestamp),
|
|
97
|
+
rng=rng,
|
|
98
|
+
no_backup=bool(args.no_backup),
|
|
99
|
+
cwd=Path.cwd(),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _emit(envelope: Envelope, mode: OutputMode, out: TextIO) -> None:
|
|
104
|
+
out.write((render_json(envelope) if mode == "json" else render_text(envelope)) + "\n")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _envelope(command: Command, outcome: Result, result: dict[str, object], *, ok: bool,
|
|
108
|
+
receipt: Receipt | None, started: float) -> Envelope: # fmt: skip
|
|
109
|
+
return Envelope(
|
|
110
|
+
ok=ok,
|
|
111
|
+
command=command.name,
|
|
112
|
+
schema=command.schema,
|
|
113
|
+
input=outcome.input,
|
|
114
|
+
result=dict(result),
|
|
115
|
+
issues=outcome.issues,
|
|
116
|
+
evidence=outcome.evidence,
|
|
117
|
+
receipt=receipt,
|
|
118
|
+
elapsed_ms=max(0, round((time.perf_counter() - started) * 1000)),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _dispatch(command: Command, args: argparse.Namespace, ctx: Context, started: float,
|
|
123
|
+
out: TextIO, err: TextIO) -> int: # fmt: skip
|
|
124
|
+
dry_run = bool(getattr(args, "dry_run", False))
|
|
125
|
+
confirm = bool(getattr(args, "confirm", False))
|
|
126
|
+
if dry_run and confirm:
|
|
127
|
+
raise CliError("FEN-2003")
|
|
128
|
+
fields = parse_fields(args.fields) if args.fields else []
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
outcome = command.run(args, ctx)
|
|
132
|
+
if outcome.writes and not command.mutates:
|
|
133
|
+
raise RuntimeError(f"command {command.name!r} returned writes but is not declared mutating")
|
|
134
|
+
except CliError as exc:
|
|
135
|
+
_emit(_envelope(command, Result(), {}, ok=False, receipt=None, started=started), ctx.mode, out)
|
|
136
|
+
write_error(exc.info(), ctx.mode, err)
|
|
137
|
+
return int(exc.exit_code)
|
|
138
|
+
except Exception as exc: # any bug inside a command becomes exit 1 with FEN-1001
|
|
139
|
+
_emit(_envelope(command, Result(), {}, ok=False, receipt=None, started=started), ctx.mode, out)
|
|
140
|
+
write_error(
|
|
141
|
+
CliError("FEN-1001", f"{type(exc).__name__}: {exc}", where=command.name).info(), ctx.mode, err
|
|
142
|
+
)
|
|
143
|
+
return int(ExitCode.INTERNAL)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
result = project_fields(outcome.result, fields) if fields else dict(outcome.result)
|
|
147
|
+
except FieldNotFoundError as exc:
|
|
148
|
+
raise CliError("FEN-2002", f"unknown field {exc.args[0]!r} in --fields") from exc
|
|
149
|
+
|
|
150
|
+
code = ExitCode.OK
|
|
151
|
+
error: ErrorInfo | None = None
|
|
152
|
+
receipt: Receipt | None = None
|
|
153
|
+
if outcome.writes:
|
|
154
|
+
plan = [
|
|
155
|
+
{"path": w.path, "kind": w.kind, "bytes": len(w.data), "sha256": sha256_bytes(w.data),
|
|
156
|
+
"overwrite": (ctx.cwd / w.path).exists()}
|
|
157
|
+
for w in outcome.writes
|
|
158
|
+
] # fmt: skip
|
|
159
|
+
if dry_run:
|
|
160
|
+
result["plan"] = plan
|
|
161
|
+
elif not confirm:
|
|
162
|
+
result["plan"] = plan
|
|
163
|
+
code = ExitCode.CONFIRM_REQUIRED
|
|
164
|
+
error = CliError("FEN-4001", where=command.name).info()
|
|
165
|
+
else:
|
|
166
|
+
written: list[WrittenFile] = []
|
|
167
|
+
backups: list[str] = []
|
|
168
|
+
for w in outcome.writes:
|
|
169
|
+
receipt_io = atomic_write(ctx.cwd / w.path, w.data, backup=not ctx.no_backup)
|
|
170
|
+
written.append(WrittenFile(path=w.path, sha256=receipt_io.sha256))
|
|
171
|
+
if receipt_io.backup_path is not None:
|
|
172
|
+
backups.append(w.path + ".bak")
|
|
173
|
+
receipt = Receipt(written=tuple(written), backup=tuple(backups))
|
|
174
|
+
|
|
175
|
+
n_errors = sum(1 for issue in outcome.issues if issue.severity == "error")
|
|
176
|
+
if code is ExitCode.OK and n_errors:
|
|
177
|
+
code = ExitCode.FINDINGS
|
|
178
|
+
error = CliError("FEN-5001", f"{n_errors} finding(s) of severity error", where=command.name).info()
|
|
179
|
+
|
|
180
|
+
_emit(
|
|
181
|
+
_envelope(command, outcome, result, ok=code is ExitCode.OK, receipt=receipt, started=started),
|
|
182
|
+
ctx.mode,
|
|
183
|
+
out,
|
|
184
|
+
)
|
|
185
|
+
if error is not None:
|
|
186
|
+
write_error(error, ctx.mode, err)
|
|
187
|
+
return int(code)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
191
|
+
"""Run ``fenolite`` with ``argv`` (defaults to ``sys.argv[1:]``) and return the exit code."""
|
|
192
|
+
started = time.perf_counter()
|
|
193
|
+
raw = list(sys.argv[1:] if argv is None else argv)
|
|
194
|
+
out, err = sys.stdout, sys.stderr
|
|
195
|
+
mode = resolve_mode(force_json="--json" in raw, force_text="--text" in raw, stream=out)
|
|
196
|
+
try:
|
|
197
|
+
commands = discover()
|
|
198
|
+
parser = build_parser(commands)
|
|
199
|
+
try:
|
|
200
|
+
args = parser.parse_args(raw)
|
|
201
|
+
except SystemExit as exc: # --help / --version print and exit 0
|
|
202
|
+
return exc.code if isinstance(exc.code, int) else 0
|
|
203
|
+
if args.json and args.text:
|
|
204
|
+
raise CliError("FEN-2001", "--json and --text are mutually exclusive")
|
|
205
|
+
if args.command is None:
|
|
206
|
+
parser.print_help(out)
|
|
207
|
+
return int(ExitCode.OK)
|
|
208
|
+
ctx = _context(args, mode)
|
|
209
|
+
return _dispatch(commands[args.command], args, ctx, started, out, err)
|
|
210
|
+
except CliError as exc:
|
|
211
|
+
write_error(exc.info(), mode, err)
|
|
212
|
+
return int(exc.exit_code)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
__all__ = ["build_parser", "main"]
|