Skip to content

Commit ae6ccf2

Browse files
committed
amaranth._cli: prototype. (WIP)
1 parent d32ca04 commit ae6ccf2

File tree

2 files changed

+129
-6
lines changed

2 files changed

+129
-6
lines changed

amaranth_cli/__init__.py

+125
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""
2+
This file is not a part of the Amaranth module tree because the CLI needs to emit Make-style
3+
dependency files as a part of the generation process. In order for `from amaranth import *`
4+
to work as a prelude, it has to load several of the files under `amaranth/`, which means
5+
these will not be loaded later in the process, and not recorded as dependencies.
6+
"""
7+
8+
import importlib
9+
import argparse
10+
import stat
11+
import sys
12+
import os
13+
import re
14+
15+
16+
def _build_parser():
17+
def component(reference):
18+
from amaranth import Elaboratable
19+
20+
if m := re.match(r"(\w+(?:\.\w+)*):(\w+(?:\.\w+)*)", reference, re.IGNORECASE|re.ASCII):
21+
mod_name, qual_name = m[1], m[2]
22+
try:
23+
obj = importlib.import_module(mod_name)
24+
except ImportError as e:
25+
raise argparse.ArgumentTypeError(f"{mod_name!r} does not refer to "
26+
"an importable Python module") from e
27+
try:
28+
for attr in qual_name.split("."):
29+
obj = getattr(obj, attr)
30+
except AttributeError as e:
31+
raise argparse.ArgumentTypeError(f"{qual_name!r} does not refer to an object "
32+
f"within the {mod_name!r} module") from e
33+
if not issubclass(obj, Elaboratable):
34+
raise argparse.ArgumentTypeError(f"'{qual_name}:{mod_name}' refers to an object that is not elaboratable")
35+
return obj
36+
else:
37+
raise argparse.ArgumentTypeError(f"{reference!r} is not a Python object reference")
38+
39+
parser = argparse.ArgumentParser(
40+
"amaranth", description="""
41+
Amaranth HDL command line interface.
42+
""")
43+
operation = parser.add_subparsers(
44+
metavar="OPERATION", help="operation to perform",
45+
dest="operation", required=True)
46+
47+
op_generate = operation.add_parser(
48+
"generate", help="generate code in a different language from Amaranth code",
49+
aliases=("gen", "g"))
50+
op_generate.add_argument(
51+
metavar="COMPONENT", help="Amaranth component to convert, e.g. `pkg.mod:Cls`",
52+
dest="component", type=component)
53+
op_generate.add_argument(
54+
"-p", "--param", metavar=("NAME", "VALUE"), help="parameter(s) for the component",
55+
dest="params", nargs=2, type=str, action="append")
56+
gen_language = op_generate.add_subparsers(
57+
metavar="LANGUAGE", help="language to generate code in",
58+
dest="language", required=True)
59+
60+
lang_verilog = gen_language.add_parser(
61+
"verilog", help="generate Verilog code")
62+
lang_verilog.add_argument(
63+
"-v", metavar="VERILOG-FILE", help="Verilog file to write",
64+
dest="verilog_file", type=argparse.FileType("w"))
65+
lang_verilog.add_argument(
66+
"-d", metavar="DEP-FILE", help="Make-style dependency file to write",
67+
dest="dep_file", type=argparse.FileType("w"))
68+
69+
return parser
70+
71+
72+
def main(args=None):
73+
# Hook the `open()` function to find out which files are being opened by Amaranth code.
74+
files_being_opened = set()
75+
special_file_opened = False
76+
def dep_audit_hook(event, args):
77+
nonlocal special_file_opened
78+
if files_being_opened is not None and event == "open":
79+
filename, mode, flags = args
80+
if mode is None or "r" in mode or "+" in mode:
81+
if isinstance(filename, bytes):
82+
filename = filename.decode("utf-8")
83+
if isinstance(filename, str) and stat.S_ISREG(os.stat(filename).st_mode):
84+
files_being_opened.add(filename)
85+
else:
86+
special_file_opened = True
87+
sys.addaudithook(dep_audit_hook)
88+
89+
# Parse arguments and instantiate components
90+
args = _build_parser().parse_args(args)
91+
if args.operation in ("generate", "gen", "g"):
92+
params = dict(args.params)
93+
params = {name: cls(params[name])
94+
for name, cls in args.component.__init__.__annotations__.items()}
95+
component = args.component(**params)
96+
97+
# Capture the set of opened files, as well as the loaded Python modules.
98+
files_opened, files_being_opened = files_being_opened, None
99+
modules_after = list(sys.modules.values())
100+
101+
# Remove *.pyc files from the set of open files and replace them with their *.py equivalents.
102+
dep_files = set()
103+
dep_files.update(files_opened)
104+
for module in modules_after:
105+
if getattr(module, "__spec__", None) is None:
106+
continue
107+
if module.__spec__.cached in dep_files:
108+
dep_files.discard(module.__spec__.cached)
109+
dep_files.add(module.__spec__.origin)
110+
111+
if args.operation in ("generate", "gen", "g"):
112+
if args.language == "verilog":
113+
# Generate Verilog file.
114+
from amaranth.back import verilog
115+
args.verilog_file.write(verilog.convert(component))
116+
117+
# Generate dependency file.
118+
if args.verilog_file and args.dep_file:
119+
args.dep_file.write(f"{args.verilog_file.name}:")
120+
if not special_file_opened:
121+
for file in sorted(dep_files):
122+
args.dep_file.write(f" \\\n {file}")
123+
args.dep_file.write("\n")
124+
else:
125+
args.dep_file.write(f"\n.PHONY: {args.verilog_file.name}\n")

pyproject.toml

+4-6
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@ dependencies = [
1919
]
2020

2121
[project.optional-dependencies]
22-
# this version requirement needs to be synchronized with the one in amaranth.back.verilog!
22+
# This version requirement needs to be synchronized with the one in amaranth.back.verilog!
2323
builtin-yosys = ["amaranth-yosys>=0.10"]
2424
remote-build = ["paramiko~=2.7"]
2525

2626
[project.scripts]
27+
amaranth = "amaranth_cli:main"
2728
amaranth-rpc = "amaranth.rpc:main"
2829

2930
[project.urls]
@@ -39,11 +40,8 @@ requires = ["pdm-backend"]
3940
build-backend = "pdm.backend"
4041

4142
[tool.pdm.build]
42-
# If amaranth 0.3 is checked out with git (e.g. as a part of a persistent editable install or
43-
# a git worktree cached by tools like poetry), it can have an empty `nmigen` directory left over,
44-
# which causes a hard error because setuptools cannot determine the top-level package.
45-
# Add a workaround to improve experience for people upgrading from old checkouts.
46-
includes = ["amaranth/"]
43+
# The docstring in `amaranth_cli/__init__.py` explains why it is not under `amaranth/`.
44+
packages = ["amaranth", "amaranth_cli"]
4745

4846
# Development workflow configuration
4947

0 commit comments

Comments
 (0)