micropython/tools/manifestfile.py

648 lines
24 KiB
Python

#!/usr/bin/env python3
#
# This file is part of the MicroPython project, http://micropython.org/
#
# The MIT License (MIT)
#
# Copyright (c) 2022 Jim Mussared
# Copyright (c) 2019 Damien P. George
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
from __future__ import print_function
import contextlib
import os
import sys
import glob
import tempfile
from collections import namedtuple
__all__ = ["ManifestFileError", "ManifestFile"]
# Allow freeze*() etc.
MODE_FREEZE = 1
# Only allow include/require/module/package.
MODE_COMPILE = 2
# Same as compile, but handles require(..., pypi="name") as a requirements.txt entry.
MODE_PYPROJECT = 3
# In compile mode, .py -> KIND_COMPILE_AS_MPY
# In freeze mode, .py -> KIND_FREEZE_AS_MPY, .mpy->KIND_FREEZE_MPY
KIND_AUTO = 1
# Freeze-mode only, .py -> KIND_FREEZE_AS_MPY, .mpy->KIND_FREEZE_MPY
KIND_FREEZE_AUTO = 2
# Freeze-mode only, The .py file will be frozen as text.
KIND_FREEZE_AS_STR = 3
# Freeze-mode only, The .py file will be compiled and frozen as bytecode.
KIND_FREEZE_AS_MPY = 4
# Freeze-mode only, The .mpy file will be frozen directly.
KIND_FREEZE_MPY = 5
# Compile mode only, the .py file should be compiled to .mpy.
KIND_COMPILE_AS_MPY = 6
# File on the local filesystem.
FILE_TYPE_LOCAL = 1
# URL to file. (TODO)
FILE_TYPE_HTTP = 2
class ManifestFileError(Exception):
pass
class ManifestIgnoreException(Exception):
pass
class ManifestUsePyPIException(Exception):
def __init__(self, pypi_name):
self.pypi_name = pypi_name
# The set of files that this manifest references.
ManifestOutput = namedtuple(
"ManifestOutput",
[
"file_type", # FILE_TYPE_*.
"full_path", # The input file full path.
"target_path", # The target path on the device.
"timestamp", # Last modified date of the input file.
"kind", # KIND_*.
"metadata", # Metadata for the containing package.
"opt", # Optimisation level (or None).
],
)
# Represents the metadata for a package.
class ManifestPackageMetadata:
def __init__(self, is_require=False):
self._is_require = is_require
self._initialised = False
self.version = None
self.description = None
self.license = None
self.author = None
# Annotate a package as being from the python standard library.
self.stdlib = False
# Allows a python-ecosys package to be annotated with the
# corresponding name in PyPI. e.g. micropython-lib/requests is based
# on pypi/requests.
self.pypi = None
# For a micropython package, this is the name that we will publish it
# to PyPI as. e.g. micropython-lib/senml publishes as
# pypi/micropython-senml.
self.pypi_publish = None
def update(
self,
mode,
description=None,
version=None,
license=None,
author=None,
stdlib=False,
pypi=None,
pypi_publish=None,
):
if self._initialised:
raise ManifestFileError("Duplicate call to metadata().")
# In MODE_PYPROJECT, if this manifest is being evaluated as a result
# of a require(), then figure out if it should be replaced by a PyPI
# dependency instead.
if mode == MODE_PYPROJECT and self._is_require:
if stdlib:
# No dependency required at all for CPython.
raise ManifestIgnoreException
if pypi_publish or pypi:
# In the case where a package is both based on a PyPI package and
# provides one, preference depending on the published one.
# (This should be pretty rare).
raise ManifestUsePyPIException(pypi_publish or pypi)
self.description = description
self.version = version
self.license = license
self.author = author
self.pypi = pypi
self.pypi_publish = pypi_publish
self._initialised = True
def check_initialised(self, mode):
# Ensure that metadata() is the first thing a manifest.py does.
# This is to ensure that we early-exit if it should be replaced by a pypi dependency.
if mode in (MODE_COMPILE, MODE_PYPROJECT):
if not self._initialised:
raise ManifestFileError("metadata() must be the first command in a manifest file.")
def __str__(self):
return "version={} description={} license={} author={} pypi={} pypi_publish={}".format(
self.version, self.description, self.license, self.author, self.pypi, self.pypi_publish
)
# Turns a dict of options into a object with attributes used to turn the
# kwargs passed to include() and require into the "options" global in the
# included manifest.
# options = IncludeOptions(foo="bar", blah="stuff")
# options.foo # "bar"
# options.blah # "stuff"
class IncludeOptions:
def __init__(self, **kwargs):
self._kwargs = kwargs
self._defaults = {}
def defaults(self, **kwargs):
self._defaults = kwargs
def __getattr__(self, name):
return self._kwargs.get(name, self._defaults.get(name, None))
class ManifestFile:
def __init__(self, mode, path_vars=None):
# See MODE_* constants above.
self._mode = mode
# Path substitution variables.
self._path_vars = path_vars or {}
# List of files (as ManifestFileResult) references by this manifest.
self._manifest_files = []
# List of PyPI dependencies (when mode=MODE_PYPROJECT).
self._pypi_dependencies = []
# Don't allow including the same file twice.
self._visited = set()
# Stack of metadata for each level.
self._metadata = [ManifestPackageMetadata()]
# Registered external libraries.
self._libraries = {}
def _resolve_path(self, path):
# Convert path to an absolute path, applying variable substitutions.
for name, value in self._path_vars.items():
if value is not None:
path = path.replace("$({})".format(name), value)
return os.path.abspath(path)
def _manifest_globals(self, kwargs):
# This is the "API" available to a manifest file.
g = {
"metadata": self.metadata,
"include": self.include,
"require": self.require,
"add_library": self.add_library,
"package": self.package,
"module": self.module,
"options": IncludeOptions(**kwargs),
}
# Extra legacy functions only for freeze mode.
if self._mode == MODE_FREEZE:
g.update(
{
"freeze": self.freeze,
"freeze_as_str": self.freeze_as_str,
"freeze_as_mpy": self.freeze_as_mpy,
"freeze_mpy": self.freeze_mpy,
}
)
return g
def files(self):
return self._manifest_files
def pypi_dependencies(self):
# In MODE_PYPROJECT, this will return a list suitable for requirements.txt.
return self._pypi_dependencies
def execute(self, manifest_file):
if manifest_file.endswith(".py"):
# Execute file from filesystem.
self.include(manifest_file)
else:
# Execute manifest code snippet.
try:
exec(manifest_file, self._manifest_globals({}))
except Exception as er:
raise ManifestFileError("Error in manifest: {}".format(er))
def _add_file(self, full_path, target_path, kind=KIND_AUTO, opt=None):
# Check file exists and get timestamp.
try:
stat = os.stat(full_path)
timestamp = stat.st_mtime
except OSError:
raise ManifestFileError("Cannot stat {}".format(full_path))
# Map the AUTO kinds to their actual kind based on mode and extension.
_, ext = os.path.splitext(full_path)
if self._mode == MODE_FREEZE:
if kind in (
KIND_AUTO,
KIND_FREEZE_AUTO,
):
if ext.lower() == ".py":
kind = KIND_FREEZE_AS_MPY
elif ext.lower() == ".mpy":
kind = KIND_FREEZE_MPY
else:
if kind != KIND_AUTO:
raise ManifestFileError("Not in freeze mode")
if ext.lower() != ".py":
raise ManifestFileError("Expected .py file")
kind = KIND_COMPILE_AS_MPY
self._manifest_files.append(
ManifestOutput(
FILE_TYPE_LOCAL, full_path, target_path, timestamp, kind, self._metadata[-1], opt
)
)
def _search(self, base_path, package_path, files, exts, kind, opt=None, strict=False):
base_path = self._resolve_path(base_path)
if files:
# Use explicit list of files (relative to package_path).
for file in files:
if package_path:
file = os.path.join(package_path, file)
self._add_file(os.path.join(base_path, file), file, kind=kind, opt=opt)
else:
if base_path:
prev_cwd = os.getcwd()
os.chdir(self._resolve_path(base_path))
# Find all candidate files.
for dirpath, _, filenames in os.walk(package_path or ".", followlinks=True):
for file in filenames:
file = os.path.relpath(os.path.join(dirpath, file), ".")
_, ext = os.path.splitext(file)
if ext.lower() in exts:
self._add_file(
os.path.join(base_path, file),
file,
kind=kind,
opt=opt,
)
elif strict:
raise ManifestFileError("Unexpected file type")
if base_path:
os.chdir(prev_cwd)
def metadata(self, **kwargs):
"""
From within a manifest file, use this to set the metadata for the
package described by current manifest.
After executing a manifest file (via execute()), call this
to obtain the metadata for the top-level manifest file.
See ManifestPackageMetadata.update() for valid kwargs.
"""
if kwargs:
self._metadata[-1].update(self._mode, **kwargs)
return self._metadata[-1]
def include(self, manifest_path, is_require=False, **kwargs):
"""
Include another manifest.
The manifest argument can be a string (filename) or an iterable of
strings.
Relative paths are resolved with respect to the current manifest file.
If the path is to a directory, then it implicitly includes the
manifest.py file inside that directory.
Optional kwargs can be provided which will be available to the
included script via the `options` variable.
e.g. include("path.py", extra_features=True)
in path.py:
options.defaults(standard_features=True)
# freeze minimal modules.
if options.standard_features:
# freeze standard modules.
if options.extra_features:
# freeze extra modules.
"""
if is_require:
self._metadata[-1].check_initialised(self._mode)
if not isinstance(manifest_path, str):
for m in manifest_path:
self.include(m, **kwargs)
else:
manifest_path = self._resolve_path(manifest_path)
# Including a directory grabs the manifest.py inside it.
if os.path.isdir(manifest_path):
manifest_path = os.path.join(manifest_path, "manifest.py")
if manifest_path in self._visited:
return
self._visited.add(manifest_path)
if is_require:
# This include is the result of require("name"), so push a new
# package metadata onto the stack.
self._metadata.append(ManifestPackageMetadata(is_require=True))
try:
with open(manifest_path) as f:
# Make paths relative to this manifest file while processing it.
# Applies to includes and input files.
prev_cwd = os.getcwd()
os.chdir(os.path.dirname(manifest_path))
try:
exec(f.read(), self._manifest_globals(kwargs))
finally:
os.chdir(prev_cwd)
except ManifestIgnoreException:
# e.g. MODE_PYPROJECT and this was a stdlib dependency. No-op.
pass
except ManifestUsePyPIException as e:
# e.g. MODE_PYPROJECT and this was a package from
# python-ecosys. Add PyPI dependency instead.
self._pypi_dependencies.append(e.pypi_name)
except Exception as e:
raise ManifestFileError("Error in manifest file: {}: {}".format(manifest_path, e))
if is_require:
self._metadata.pop()
def _require_from_path(self, library_path, name, version, extra_kwargs):
for root, dirnames, filenames in os.walk(library_path):
if os.path.basename(root) == name and "manifest.py" in filenames:
self.include(root, is_require=True, **extra_kwargs)
return True
return False
def require(self, name, version=None, unix_ffi=False, pypi=None, library=None, **kwargs):
"""
Require a package by name from micropython-lib.
Optionally specify unix_ffi=True to use a module from the unix-ffi directory.
Optionally specify pipy="package-name" to indicate that this should
use the named package from PyPI when building for CPython.
Optionally specify library="name" to reference a package from a
library that has been previously registered with add_library(). Otherwise
micropython-lib will be used.
"""
self._metadata[-1].check_initialised(self._mode)
if self._mode == MODE_PYPROJECT and pypi:
# In PYPROJECT mode, allow overriding the PyPI dependency name
# explicitly. Otherwise if the dependent package has metadata
# (pypi_publish) or metadata(pypi) we will use that.
self._pypi_dependencies.append(pypi)
return
if library is not None:
# Find package in external library.
if library not in self._libraries:
raise ValueError("Unknown library '{}' for require('{}').".format(library, name))
library_path = self._libraries[library]
# Search for {library_path}/**/{name}/manifest.py.
if not self._require_from_path(library_path, name, version, kwargs):
raise ValueError(
"Package '{}' not found in external library '{}' ({}).".format(
name, library, library_path
)
)
elif self._path_vars["MPY_LIB_DIR"]:
# Find package in micropython-lib, in one of the three top-level directories.
lib_dirs = ["micropython", "python-stdlib", "python-ecosys"]
if unix_ffi:
# Additionally search unix-ffi only if unix_ffi=True, and make unix-ffi modules
# take precedence.
lib_dirs = ["unix-ffi"] + lib_dirs
for lib_dir in lib_dirs:
# Search for {lib_dir}/**/{name}/manifest.py.
if self._require_from_path(
os.path.join(self._path_vars["MPY_LIB_DIR"], lib_dir), name, version, kwargs
):
return
raise ValueError("Package '{}' not found in local micropython-lib.".format(name))
else:
# TODO: HTTP request to obtain URLs from manifest.json.
raise ValueError("micropython-lib not available for require('{}').", name)
def add_library(self, library, library_path):
"""
Register the path to an external named library.
This allows require("name", library="library") to find packages in that library.
"""
self._libraries[library] = self._resolve_path(library_path)
def package(self, package_path, files=None, base_path=".", opt=None):
"""
Define a package, optionally restricting to a set of files.
Simple case, a package in the current directory:
package("foo")
will include all .py files in foo, and will be stored as foo/bar/baz.py.
If the package isn't in the current directory, use base_path:
package("foo", base_path="src")
To restrict to certain files in the package use files (note: paths should be relative to the package):
package("foo", files=["bar/baz.py"])
"""
self._metadata[-1].check_initialised(self._mode)
# Include "base_path/package_path/**/*.py" --> "package_path/**/*.py"
self._search(base_path, package_path, files, exts=(".py",), kind=KIND_AUTO, opt=opt)
def module(self, module_path, base_path=".", opt=None):
"""
Include a single Python file as a module.
If the file is in the current directory:
module("foo.py")
Otherwise use base_path to locate the file:
module("foo.py", "src/drivers")
"""
self._metadata[-1].check_initialised(self._mode)
# Include "base_path/module_path" --> "module_path"
base_path = self._resolve_path(base_path)
_, ext = os.path.splitext(module_path)
if ext.lower() != ".py":
raise ManifestFileError("module must be .py file")
# TODO: version None
self._add_file(os.path.join(base_path, module_path), module_path, opt=opt)
def _freeze_internal(self, path, script, exts, kind, opt):
if script is None:
self._search(path, None, None, exts=exts, kind=kind, opt=opt)
elif isinstance(script, str) and os.path.isdir(os.path.join(path, script)):
self._search(path, script, None, exts=exts, kind=kind, opt=opt)
elif not isinstance(script, str):
self._search(path, None, script, exts=exts, kind=kind, opt=opt)
else:
self._search(path, None, (script,), exts=exts, kind=kind, opt=opt)
def freeze(self, path, script=None, opt=None):
"""
Freeze the input, automatically determining its type. A .py script
will be compiled to a .mpy first then frozen, and a .mpy file will be
frozen directly.
`path` must be a directory, which is the base directory to _search for
files from. When importing the resulting frozen modules, the name of
the module will start after `path`, ie `path` is excluded from the
module name.
If `path` is relative, it is resolved to the current manifest.py.
Use $(MPY_DIR), $(MPY_LIB_DIR), $(PORT_DIR), $(BOARD_DIR) if you need
to access specific paths.
If `script` is None all files in `path` will be frozen.
If `script` is an iterable then freeze() is called on all items of the
iterable (with the same `path` and `opt` passed through).
If `script` is a string then it specifies the file or directory to
freeze, and can include extra directories before the file or last
directory. The file or directory will be _searched for in `path`. If
`script` is a directory then all files in that directory will be frozen.
`opt` is the optimisation level to pass to mpy-cross when compiling .py
to .mpy.
"""
self._freeze_internal(
path,
script,
exts=(
".py",
".mpy",
),
kind=KIND_FREEZE_AUTO,
opt=opt,
)
def freeze_as_str(self, path):
"""
Freeze the given `path` and all .py scripts within it as a string,
which will be compiled upon import.
"""
self._search(path, None, None, exts=(".py",), kind=KIND_FREEZE_AS_STR)
def freeze_as_mpy(self, path, script=None, opt=None):
"""
Freeze the input (see above) by first compiling the .py scripts to
.mpy files, then freezing the resulting .mpy files.
"""
self._freeze_internal(path, script, exts=(".py",), kind=KIND_FREEZE_AS_MPY, opt=opt)
def freeze_mpy(self, path, script=None, opt=None):
"""
Freeze the input (see above), which must be .mpy files that are
frozen directly.
"""
self._freeze_internal(path, script, exts=(".mpy",), kind=KIND_FREEZE_MPY, opt=opt)
# Generate a temporary file with a line appended to the end that adds __version__.
@contextlib.contextmanager
def tagged_py_file(path, metadata):
dest_fd, dest_path = tempfile.mkstemp(suffix=".py", text=True)
try:
with os.fdopen(dest_fd, "w") as dest:
with open(path, "r") as src:
contents = src.read()
dest.write(contents)
# Don't overwrite a version definition if the file already has one in it.
if metadata.version and "__version__ =" not in contents:
dest.write("\n\n__version__ = {}\n".format(repr(metadata.version)))
yield dest_path
finally:
os.unlink(dest_path)
def main():
import argparse
cmd_parser = argparse.ArgumentParser(description="List the files referenced by a manifest.")
cmd_parser.add_argument("--freeze", action="store_true", help="freeze mode")
cmd_parser.add_argument("--compile", action="store_true", help="compile mode")
cmd_parser.add_argument("--pyproject", action="store_true", help="pyproject mode")
cmd_parser.add_argument(
"--lib",
default=os.path.join(os.path.dirname(__file__), "../lib/micropython-lib"),
help="path to micropython-lib repo",
)
cmd_parser.add_argument("--port", default=None, help="path to port dir")
cmd_parser.add_argument("--board", default=None, help="path to board dir")
cmd_parser.add_argument(
"--top",
default=os.path.join(os.path.dirname(__file__), ".."),
help="path to micropython repo",
)
cmd_parser.add_argument("files", nargs="+", help="input manifest.py")
args = cmd_parser.parse_args()
path_vars = {
"MPY_DIR": os.path.abspath(args.top) if args.top else None,
"BOARD_DIR": os.path.abspath(args.board) if args.board else None,
"PORT_DIR": os.path.abspath(args.port) if args.port else None,
"MPY_LIB_DIR": os.path.abspath(args.lib) if args.lib else None,
}
mode = None
if args.freeze:
mode = MODE_FREEZE
elif args.compile:
mode = MODE_COMPILE
elif args.pyproject:
mode = MODE_PYPROJECT
else:
print("Error: No mode specified.", file=sys.stderr)
exit(1)
m = ManifestFile(mode, path_vars)
for manifest_file in args.files:
try:
m.execute(manifest_file)
except ManifestFileError as er:
print(er, file=sys.stderr)
exit(1)
print(m.metadata())
for f in m.files():
print(f)
if mode == MODE_PYPROJECT:
for r in m.pypi_dependencies():
print("pypi-require:", r)
if __name__ == "__main__":
main()