From bac83ad8003e8928326098cb4fa835ccdb81f327 Mon Sep 17 00:00:00 2001 From: Catherine Date: Tue, 5 Sep 2023 21:35:34 +0000 Subject: [PATCH] amaranth._cli: prototype. (WIP) --- amaranth_cli/__init__.py | 118 +++++++++++++++++++++++++++++++++++++++ pyproject.toml | 14 ++--- 2 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 amaranth_cli/__init__.py diff --git a/amaranth_cli/__init__.py b/amaranth_cli/__init__.py new file mode 100644 index 0000000000..f7d3db2400 --- /dev/null +++ b/amaranth_cli/__init__.py @@ -0,0 +1,118 @@ +""" +This file is not a part of the Amaranth module tree because the CLI needs to emit Make-style +dependency files as a part of the generation process. In order for `from amaranth import *` +to work as a prelude, it has to load several of the files under `amaranth/`, which means +these will not be loaded later in the process, and not recorded as dependencies. +""" + +import importlib +import argparse +import stat +import sys +import os +import re + + +def _build_parser(): + def component(reference): + from amaranth import Elaboratable + + if m := re.match(r"(\w+(?:\.\w+)*):(\w+(?:\.\w+)*)", reference, re.IGNORECASE|re.ASCII): + mod_name, qual_name = m[1], m[2] + try: + obj = importlib.import_module(mod_name) + except ImportError as e: + raise argparse.ArgumentTypeError(f"{mod_name!r} does not refer to " + "an importable Python module") from e + try: + for attr in qual_name.split("."): + obj = getattr(obj, attr) + except AttributeError as e: + raise argparse.ArgumentTypeError(f"{qual_name!r} does not refer to an object " + f"within the {mod_name!r} module") from e + if not issubclass(obj, Elaboratable): + raise argparse.ArgumentTypeError(f"'{qual_name}:{mod_name}' refers to an object that is not elaboratable") + return obj + else: + raise argparse.ArgumentTypeError(f"{reference!r} is not a Python object reference") + + parser = argparse.ArgumentParser( + "amaranth", description=""" + Amaranth HDL command line interface. + """) + operation = parser.add_subparsers( + metavar="OPERATION", help="operation to perform", + dest="operation", required=True) + + op_generate = operation.add_parser( + "generate", help="generate code in a different language from Amaranth code") + op_generate.add_argument( + metavar="COMPONENT", help="Amaranth component to convert, e.g. `pkg.mod:Cls`", + dest="component", type=component) + gen_language = op_generate.add_subparsers( + metavar="LANGUAGE", help="language to generate code in", + dest="language", required=True) + + lang_verilog = gen_language.add_parser( + "verilog", help="generate Verilog code") + lang_verilog.add_argument( + "-v", metavar="VERILOG-FILE", help="Verilog file to write", + dest="verilog_file", type=argparse.FileType("w")) + lang_verilog.add_argument( + "-d", metavar="DEP-FILE", help="Make-style dependency file to write", + dest="dep_file", type=argparse.FileType("w")) + + return parser + + +def main(args=None): + # Hook the `open()` function to find out which files are being opened by Amaranth code. + files_being_opened = set() + special_file_opened = False + def dep_audit_hook(event, args): + nonlocal special_file_opened + if files_being_opened is not None and event == "open": + filename, mode, flags = args + if mode is None or "r" in mode or "+" in mode: + if isinstance(filename, bytes): + filename = filename.decode("utf-8") + if isinstance(filename, str) and stat.S_ISREG(os.stat(filename).st_mode): + files_being_opened.add(filename) + else: + special_file_opened = True + sys.addaudithook(dep_audit_hook) + + # Parse arguments and instantiate components + args = _build_parser().parse_args(args) + if args.operation == "generate": + component = args.component() + + # Capture the set of opened files, as well as the loaded Python modules. + files_opened, files_being_opened = files_being_opened, None + modules_after = list(sys.modules.values()) + + # Remove *.pyc files from the set of open files and replace them with their *.py equivalents. + dep_files = set() + dep_files.update(files_opened) + for module in modules_after: + if getattr(module, "__spec__", None) is None: + continue + if module.__spec__.cached in dep_files: + dep_files.discard(module.__spec__.cached) + dep_files.add(module.__spec__.origin) + + if args.operation == "generate": + if args.language == "verilog": + # Generate Verilog file. + from amaranth.back import verilog + args.verilog_file.write(verilog.convert(component)) + + # Generate dependency file. + if args.verilog_file and args.dep_file: + args.dep_file.write(f"{args.verilog_file.name}:") + if not special_file_opened: + for file in sorted(dep_files): + args.dep_file.write(f" \\\n {file}") + args.dep_file.write("\n") + else: + args.dep_file.write(f"\n.PHONY: {args.verilog_file.name}\n") diff --git a/pyproject.toml b/pyproject.toml index 45c8041ff3..e41de992f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,26 +16,24 @@ dependencies = [ ] [project.optional-dependencies] -# this version requirement needs to be synchronized with the one in amaranth.back.verilog! +# This version requirement needs to be synchronized with the one in amaranth.back.verilog! builtin-yosys = ["amaranth-yosys>=0.10"] remote-build = ["paramiko~=2.7"] [project.scripts] +amaranth = "amaranth_cli:main" amaranth-rpc = "amaranth.rpc:main" +[tool.setuptools] +# The docstring in `amaranth_cli/__init__.py` explains why it is not under `amaranth/`. +packages = ["amaranth", "amaranth_cli"] + # Build system configuration [build-system] requires = ["wheel", "setuptools>=67.0", "setuptools_scm[toml]>=6.2"] build-backend = "setuptools.build_meta" -[tool.setuptools] -# If amaranth 0.3 is checked out with git (e.g. as a part of a persistent editable install or -# a git worktree cached by tools like poetry), it can have an empty `nmigen` directory left over, -# which causes a hard error because setuptools cannot determine the top-level package. -# Add a workaround to improve experience for people upgrading from old checkouts. -packages = ["amaranth"] - [tool.setuptools_scm] local_scheme = "node-and-timestamp"