back.verilog: fall back to nmigen_yosys package.
authorwhitequark <whitequark@whitequark.org>
Fri, 22 May 2020 16:50:45 +0000 (16:50 +0000)
committerwhitequark <whitequark@whitequark.org>
Fri, 22 May 2020 16:51:00 +0000 (16:51 +0000)
The nmigen-yosys PyPI package provides a custom, minimal build of
Yosys that uses (at the moment) wasmtime-py to deliver a single
WASM binary that can run on many platforms, and eliminates the need
to build Yosys from source.

Not only does this lower barrier to entry for new nMigen developers,
but also decouples nMigen from Yosys' yearly release cycle, which
lets us use new features and drop workarounds for Yosys bugs earlier.

The source for the nmigen-yosys package is provided at:
  https://github.com/nmigen/nmigen-yosys
The package is built from upstream source and released automatically
with no manual steps.

Fixes #371.

nmigen/_yosys.py [new file with mode: 0644]
nmigen/back/verilog.py
setup.py

diff --git a/nmigen/_yosys.py b/nmigen/_yosys.py
new file mode 100644 (file)
index 0000000..913d5cc
--- /dev/null
@@ -0,0 +1,176 @@
+import os
+import sys
+import re
+import subprocess
+try:
+    from importlib import metadata as importlib_metadata # py3.8+ stdlib
+except ImportError:
+    try:
+        import importlib_metadata # py3.7- shim
+    except ImportError:
+        importlib_metadata = None # not installed
+
+from ._toolchain import has_tool, require_tool
+
+
+__all__ = ["YosysError", "YosysBinary", "find_yosys"]
+
+
+class YosysError(Exception):
+    pass
+
+
+class YosysBinary:
+    @classmethod
+    def available(cls):
+        """Check for Yosys availability.
+
+        Returns
+        -------
+        available : bool
+            ``True`` if Yosys is installed, ``False`` otherwise. Installed binary may still not
+            be runnable, or might be too old to be useful.
+        """
+        raise NotImplementedError
+
+    @classmethod
+    def version(cls):
+        """Get Yosys version.
+
+        Returns
+        -------
+        major : int
+            Major version.
+        minor : int
+            Minor version.
+        distance : int
+            Distance to last tag per ``git describe``. May not be exact for system Yosys.
+        """
+        raise NotImplementedError
+
+    @classmethod
+    def run(cls, args, stdin=""):
+        """Run Yosys process.
+
+        Parameters
+        ----------
+        args : list of str
+            Arguments, not including the program name.
+        stdin : str
+            Standard input.
+
+        Returns
+        -------
+        stdout : str
+            Standard output.
+
+        Exceptions
+        ----------
+        YosysError
+            Raised if Yosys returns a non-zero code. The exception message is the standard error
+            output.
+        """
+        raise NotImplementedError
+
+
+class _BuiltinYosys(YosysBinary):
+    YOSYS_PACKAGE = "nmigen_yosys"
+
+    @classmethod
+    def available(cls):
+        if importlib_metadata is None:
+            return False
+        try:
+            importlib_metadata.version(cls.YOSYS_PACKAGE)
+            return True
+        except importlib_metadata.PackageNotFoundError:
+            return False
+
+    @classmethod
+    def version(cls):
+        version = importlib_metadata.version(cls.YOSYS_PACKAGE)
+        match = re.match(r"^(\d+)\.(\d+)(?:\.post(\d+))?", version)
+        return (int(match[1]), int(match[2]), int(match[3] or 0))
+
+    @classmethod
+    def run(cls, args, stdin=""):
+        popen = subprocess.Popen([sys.executable, "-m", cls.YOSYS_PACKAGE, *args],
+            stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+            encoding="utf-8")
+        stdout, stderr = popen.communicate(stdin)
+        if popen.returncode:
+            raise YosysError(stderr.strip())
+        else:
+            return stdout
+
+
+class _SystemYosys(YosysBinary):
+    YOSYS_BINARY = "yosys"
+
+    @classmethod
+    def available(cls):
+        return has_tool(cls.YOSYS_BINARY)
+
+    @classmethod
+    def version(cls):
+        version = cls.run(["-V"])
+        match = re.match(r"^Yosys (\d+)\.(\d+)(?:\+(\d+))?", version)
+        return (int(match[1]), int(match[2]), int(match[3] or 0))
+
+    @classmethod
+    def run(cls, args, stdin=""):
+        popen = subprocess.Popen([require_tool(cls.YOSYS_BINARY), *args],
+            stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+            encoding="utf-8")
+        stdout, stderr = popen.communicate(stdin)
+        # If Yosys is built with an evaluation version of Verific, then Verific license
+        # information is printed first. It consists of empty lines and lines starting with `--`,
+        # which are not normally a part of Yosys output, and can be fairly safely removed.
+        #
+        # This is not ideal, but Verific license conditions rule out any other solution.
+        stdout = re.sub(r"\A(-- .+\n|\n)*", "", stdout)
+        if popen.returncode:
+            raise YosysError(stderr.strip())
+        else:
+            return stdout
+
+
+def find_yosys(requirement):
+    """Find an available Yosys executable of required version.
+
+    Parameters
+    ----------
+    requirement : function
+        Version check. Should return ``True`` if the version is acceptable, ``False`` otherwise.
+
+    Returns
+    -------
+    yosys_binary : subclass of YosysBinary
+        Proxy for running the requested version of Yosys.
+
+    Exceptions
+    ----------
+    YosysError
+        Raised if required Yosys version is not found.
+    """
+    proxies = []
+    clauses = os.environ.get("NMIGEN_USE_YOSYS", "system,builtin").split(",")
+    for clause in clauses:
+        if clause == "builtin":
+            proxies.append(_BuiltinYosys)
+        elif clause == "system":
+            proxies.append(_SystemYosys)
+        else:
+            raise YosysError("The NMIGEN_USE_YOSYS environment variable contains "
+                             "an unrecognized clause {!r}"
+                             .format(clause))
+    for proxy in proxies:
+        if proxy.available() and requirement(proxy.version()):
+            return proxy
+    else:
+        if "NMIGEN_USE_YOSYS" in os.environ:
+            raise YosysError("Could not find an acceptable Yosys binary. Searched: {}"
+                             .format(", ".join(clauses)))
+        else:
+            raise YosysError("Could not find an acceptable Yosys binary. The `nmigen_yosys` PyPI "
+                             "package, if available for this platform, can be used as fallback")
index 04e19b6a682b3da1c59be4a7f532d6face510b90..3c7be2b10aefda5fe16a362ba7469f24ca692c4e 100644 (file)
@@ -3,31 +3,17 @@ import re
 import subprocess
 import itertools
 
-from .._toolchain import *
+from .._yosys import *
 from . import rtlil
 
 
 __all__ = ["YosysError", "convert", "convert_fragment"]
 
 
-class YosysError(Exception):
-    pass
-
-
-def _yosys_version():
-    yosys_path = require_tool("yosys")
-    version = subprocess.check_output([yosys_path, "-V"], encoding="utf-8")
-    # If Yosys is built with Verific, then Verific license information is printed first.
-    # See below for details.
-    m = re.search(r"^Yosys ([\d.]+)(?:\+(\d+))?", version, flags=re.M)
-    tag, offset = m[1], m[2] or 0
-    return tuple(map(int, tag.split("."))), offset
-
-
 def _convert_rtlil_text(rtlil_text, *, strip_internal_attrs=False, write_verilog_opts=()):
-    version, offset = _yosys_version()
-    if version < (0, 9):
-        raise YosysError("Yosys {}.{} is not supported".format(*version))
+    # this version requirement needs to be synchronized with the one in setup.py!
+    yosys = find_yosys(lambda ver: ver >= (0, 9))
+    yosys_version = yosys.version()
 
     attr_map = []
     if strip_internal_attrs:
@@ -37,7 +23,7 @@ def _convert_rtlil_text(rtlil_text, *, strip_internal_attrs=False, write_verilog
         attr_map.append("-remove nmigen.hierarchy")
         attr_map.append("-remove nmigen.decoding")
 
-    script = """
+    return yosys.run(["-q", "-"], """
 # Convert nMigen's RTLIL to readable Verilog.
 read_ilang <<rtlil
 {}
@@ -53,28 +39,11 @@ attrmap {attr_map}
 attrmap -modattr {attr_map}
 write_verilog -norename {write_verilog_opts}
 """.format(rtlil_text,
-        prune="# " if version == (0, 9) and offset == 0 else "",
+        # Yosys 0.9 release has buggy proc_prune.
+        prune="# " if yosys_version < (0, 9, 231) else "",
         attr_map=" ".join(attr_map),
         write_verilog_opts=" ".join(write_verilog_opts),
-    )
-
-    popen = subprocess.Popen([require_tool("yosys"), "-q", "-"],
-        stdin=subprocess.PIPE,
-        stdout=subprocess.PIPE,
-        stderr=subprocess.PIPE,
-        encoding="utf-8")
-    verilog_text, error = popen.communicate(script)
-    if popen.returncode:
-        raise YosysError(error.strip())
-    else:
-        # If Yosys is built with an evaluation version of Verific, then Verific license information 
-        # is printed first. It consists of empty lines and lines starting with `--`, which are not
-        # valid at the start of a Verilog file, and thus may be reliably removed.
-        verilog_text = "\n".join(itertools.dropwhile(
-            lambda x: x == "" or x.startswith("--"),
-            verilog_text.splitlines()
-        ))
-        return verilog_text
+    ))
 
 
 def convert_fragment(*args, strip_internal_attrs=False, **kwargs):
index dd524f091e3f65feb93dbe4531e0dbbdd72fc572..3cd0699c33239c9927a231e9e9ebde4c558ca773 100644 (file)
--- a/setup.py
+++ b/setup.py
@@ -23,12 +23,16 @@ setup(
     #long_description="""TODO""",
     license="BSD",
     python_requires="~=3.6",
-    setup_requires=["setuptools_scm"],
+    setup_requires=["setuptools", "setuptools_scm"],
     install_requires=[
-        "setuptools",
+        "importlib_metadata; python_version<'3.8'", # for nmigen._yosys
         "pyvcd~=0.2.0", # for nmigen.pysim
         "Jinja2~=2.11", # for nmigen.build
     ],
+    extras_require = {
+        # this version requirement needs to be synchronized with the one in nmigen.back.verilog!
+        "builtin-yosys": ["nmigen-yosys>=0.9.*"],
+    },
     packages=find_packages(),
     entry_points={
         "console_scripts": [