Skip to content

Commit 0e6378b

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

File tree

2 files changed

+134
-6
lines changed

2 files changed

+134
-6
lines changed

amaranth_cli/__init__.py

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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+
"-n", "--name", metavar="NAME", help="name of the toplevel module, also prefixed to others",
55+
dest="name", type=str, default=None)
56+
op_generate.add_argument(
57+
"-p", "--param", metavar=("NAME", "VALUE"), help="parameter(s) for the component",
58+
dest="params", nargs=2, type=str, action="append", default=[])
59+
gen_language = op_generate.add_subparsers(
60+
metavar="LANGUAGE", help="language to generate code in",
61+
dest="language", required=True)
62+
63+
lang_verilog = gen_language.add_parser(
64+
"verilog", help="generate Verilog code")
65+
lang_verilog.add_argument(
66+
"-v", metavar="VERILOG-FILE", help="Verilog file to write",
67+
dest="verilog_file", type=argparse.FileType("w"))
68+
lang_verilog.add_argument(
69+
"-d", metavar="DEP-FILE", help="Make-style dependency file to write",
70+
dest="dep_file", type=argparse.FileType("w"))
71+
72+
return parser
73+
74+
75+
def main(args=None):
76+
# Hook the `open()` function to find out which files are being opened by Amaranth code.
77+
files_being_opened = set()
78+
special_file_opened = False
79+
def dep_audit_hook(event, args):
80+
nonlocal special_file_opened
81+
if files_being_opened is not None and event == "open":
82+
filename, mode, flags = args
83+
if mode is None or "r" in mode or "+" in mode:
84+
if isinstance(filename, bytes):
85+
filename = filename.decode("utf-8")
86+
if isinstance(filename, str) and stat.S_ISREG(os.stat(filename).st_mode):
87+
files_being_opened.add(filename)
88+
else:
89+
special_file_opened = True
90+
sys.addaudithook(dep_audit_hook)
91+
92+
# Parse arguments and instantiate components
93+
args = _build_parser().parse_args(args)
94+
if args.operation in ("generate", "gen", "g"):
95+
params = dict(args.params)
96+
params = {name: cls(params[name])
97+
for name, cls in args.component.__init__.__annotations__.items()}
98+
component = args.component(**params)
99+
100+
# Capture the set of opened files, as well as the loaded Python modules.
101+
files_opened, files_being_opened = files_being_opened, None
102+
modules_after = list(sys.modules.values())
103+
104+
# Remove *.pyc files from the set of open files and replace them with their *.py equivalents.
105+
dep_files = set()
106+
dep_files.update(files_opened)
107+
for module in modules_after:
108+
if getattr(module, "__spec__", None) is None:
109+
continue
110+
if module.__spec__.cached in dep_files:
111+
dep_files.discard(module.__spec__.cached)
112+
dep_files.add(module.__spec__.origin)
113+
114+
if args.operation in ("generate", "gen", "g"):
115+
if args.language == "verilog":
116+
# Generate Verilog file with `-v` or without arguments.
117+
if args.verilog_file or not (args.verilog_file or args.dep_file):
118+
from amaranth.back.verilog import convert
119+
code = convert(component, name=(args.name or args.component.__name__),)
120+
(args.verilog_file or sys.stdout).write(code)
121+
122+
# Generate dependency file with `-d`.
123+
if args.verilog_file and args.dep_file:
124+
args.dep_file.write(f"{args.verilog_file.name}:")
125+
if not special_file_opened:
126+
for file in sorted(dep_files):
127+
args.dep_file.write(f" \\\n {file}")
128+
args.dep_file.write("\n")
129+
else:
130+
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)