Source code for fabulous.tools.tool
"""Abstract base for external EDA tool wrappers.
`Tool` is the root of the tool catalogue. It is never instantiated: every tool is
used as a singleton through classmethods (e.g. ``YosysTool.run(...)``). The base
owns the single subprocess entry point (`run`), so every concrete wrapper (Yosys,
OpenSTA, GHDL, and future tools such as nextpnr or OpenROAD) shares one place for
invocation and error handling, and no business-logic code calls `subprocess`
directly. Each subclass resolves its own executable from the FABulous context via
`executable`.
"""
import subprocess
from abc import ABC, abstractmethod
from functools import cache
from pathlib import Path
from typing import NoReturn
from jinja2 import Environment, PackageLoader, StrictUndefined
from loguru import logger
@cache
def _template_env() -> Environment:
"""Return the shared Jinja environment for tool script templates.
Templates live in the ``fabulous/template`` package directory.
`StrictUndefined` makes a missing variable a render error rather than an
empty string, so a malformed template surfaces immediately.
Returns
-------
Environment
The cached Jinja environment.
"""
return Environment(
loader=PackageLoader("fabulous", "template"),
undefined=StrictUndefined,
trim_blocks=True,
lstrip_blocks=True,
keep_trailing_newline=True,
)
[docs]
class Tool(ABC):
"""Abstract base for every external tool wrapper.
Tools are stateless singletons used through classmethods only; neither `Tool`
nor any subclass can be instantiated (see `__new__`). A subclass implements
`executable` to resolve its command, then builds its argument list and stdin
and calls `run`.
"""
def __new__(cls, *_args: object, **_kwargs: object) -> NoReturn:
"""Reject instantiation; tools are used only through their classmethods.
Parameters
----------
*_args : object
Ignored positional arguments from the rejected constructor call.
**_kwargs : object
Ignored keyword arguments from the rejected constructor call.
Raises
------
TypeError
Always, because tool wrappers are stateless singletons.
"""
raise TypeError(
f"{cls.__name__} is a stateless tool wrapper used through its "
f"classmethods and cannot be instantiated."
)
@classmethod
[docs]
def render_template(cls, template_name: str, **context: object) -> str:
"""Render a tool script template into the command string to feed `run`.
Parameters
----------
template_name : str
Template file name within the ``fabulous/template`` directory.
**context : object
Variables made available to the template.
Returns
-------
str
The rendered script.
"""
return _template_env().get_template(template_name).render(**context)
@classmethod
@abstractmethod
[docs]
def executable(cls) -> Path | str:
"""Return the path to (or name of) this tool's executable.
Returns
-------
Path | str
The resolved executable, typically read from the FABulous context.
"""
@classmethod
[docs]
def run(
cls,
args: list[str] | None = None,
stdin_data: str = "",
) -> subprocess.CompletedProcess:
"""Run the tool executable, capturing output and raising on failure.
Parameters
----------
args : list[str] | None
Arguments passed to the executable.
stdin_data : str
Data piped to the executable's stdin.
Returns
-------
subprocess.CompletedProcess
The completed subprocess result.
Raises
------
RuntimeError
If the command exits with a non-zero return code.
"""
if args is None:
args = []
command: list[str] = [str(cls.executable()), *args]
logger.debug("Debug mode enabled for external command.")
logger.debug(f"Calling external command: {' '.join(command)}")
logger.debug(f"With stdin data:\n{stdin_data}")
result = subprocess.run(
command,
input=stdin_data,
text=True,
capture_output=True,
check=False,
)
logger.debug(f"Command stdout:\n{result.stdout}")
logger.debug(f"Command stderr:\n{result.stderr}")
if result.returncode != 0:
raise RuntimeError(
f"Command {' '.join(command)!r} failed with error: {result.stderr}"
)
return result