tools/manifestfile.py: Add support for publishing packages to PyPI.
This adds a new MODE_PYPROJECT, which gives basic support to allow packaging a small subset of micropython-lib packages to PyPI. This change allows a package in micropython-lib to: - Add a "pypi" name to its metadata indicating that it's based on a PyPI package. - Add "stdlib" to its metadata indicating that it's a micropython version of a stdlib package. - Add a "pypi_publish" name to its metadata to indicate that it can be published to PyPI (this can be different to the package name, e.g. "foo" might want to be published as "micropython-foo"). When a package requires() another one, if it's in MODE_PYPROJECT then if the package is from pypi then it will record that as a pypi dependency instead (or no dependency at all if it's from stdlib). Also allows require() to explicitly specify the pypi name. Signed-off-by: Jim Mussared <jim.mussared@gmail.com>
This commit is contained in:
parent
c046b23ea2
commit
cfd3b70934
@ -39,7 +39,8 @@ __all__ = ["ManifestFileError", "ManifestFile"]
|
|||||||
MODE_FREEZE = 1
|
MODE_FREEZE = 1
|
||||||
# Only allow include/require/module/package.
|
# Only allow include/require/module/package.
|
||||||
MODE_COMPILE = 2
|
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 compile mode, .py -> KIND_COMPILE_AS_MPY
|
||||||
# In freeze mode, .py -> KIND_FREEZE_AS_MPY, .mpy->KIND_FREEZE_MPY
|
# In freeze mode, .py -> KIND_FREEZE_AS_MPY, .mpy->KIND_FREEZE_MPY
|
||||||
@ -66,6 +67,15 @@ class ManifestFileError(Exception):
|
|||||||
pass
|
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.
|
# The set of files that this manifest references.
|
||||||
ManifestOutput = namedtuple(
|
ManifestOutput = namedtuple(
|
||||||
"ManifestOutput",
|
"ManifestOutput",
|
||||||
@ -81,23 +91,75 @@ ManifestOutput = namedtuple(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Represent the metadata for a package.
|
# Represents the metadata for a package.
|
||||||
class ManifestMetadata:
|
class ManifestPackageMetadata:
|
||||||
def __init__(self):
|
def __init__(self, is_require=False):
|
||||||
|
self._is_require = is_require
|
||||||
|
self._initialised = False
|
||||||
|
|
||||||
self.version = None
|
self.version = None
|
||||||
self.description = None
|
self.description = None
|
||||||
self.license = None
|
self.license = None
|
||||||
self.author = None
|
self.author = None
|
||||||
|
|
||||||
def update(self, description=None, version=None, license=None, author=None):
|
# Annotate a package as being from the python standard library.
|
||||||
if description:
|
self.stdlib = False
|
||||||
self.description = description
|
|
||||||
if version:
|
# Allows a python-ecosys package to be annotated with the
|
||||||
self.version = version
|
# corresponding name in PyPI. e.g. micropython-lib/urequests is based
|
||||||
if license:
|
# on pypi/requests.
|
||||||
self.license = version
|
self.pypi = None
|
||||||
if author:
|
# For a micropython package, this is the name that we will publish it
|
||||||
self.author = author
|
# 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 = version
|
||||||
|
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
|
# Turns a dict of options into a object with attributes used to turn the
|
||||||
@ -120,16 +182,18 @@ class IncludeOptions:
|
|||||||
|
|
||||||
class ManifestFile:
|
class ManifestFile:
|
||||||
def __init__(self, mode, path_vars=None):
|
def __init__(self, mode, path_vars=None):
|
||||||
# Either MODE_FREEZE or MODE_COMPILE.
|
# See MODE_* constants above.
|
||||||
self._mode = mode
|
self._mode = mode
|
||||||
# Path substition variables.
|
# Path substitution variables.
|
||||||
self._path_vars = path_vars or {}
|
self._path_vars = path_vars or {}
|
||||||
# List of files (as ManifestFileResult) references by this manifest.
|
# List of files (as ManifestFileResult) references by this manifest.
|
||||||
self._manifest_files = []
|
self._manifest_files = []
|
||||||
|
# List of PyPI dependencies (when mode=MODE_PYPROJECT).
|
||||||
|
self._pypi_dependencies = []
|
||||||
# Don't allow including the same file twice.
|
# Don't allow including the same file twice.
|
||||||
self._visited = set()
|
self._visited = set()
|
||||||
# Stack of metadata for each level.
|
# Stack of metadata for each level.
|
||||||
self._metadata = [ManifestMetadata()]
|
self._metadata = [ManifestPackageMetadata()]
|
||||||
|
|
||||||
def _resolve_path(self, path):
|
def _resolve_path(self, path):
|
||||||
# Convert path to an absolute path, applying variable substitutions.
|
# Convert path to an absolute path, applying variable substitutions.
|
||||||
@ -140,26 +204,39 @@ class ManifestFile:
|
|||||||
|
|
||||||
def _manifest_globals(self, kwargs):
|
def _manifest_globals(self, kwargs):
|
||||||
# This is the "API" available to a manifest file.
|
# This is the "API" available to a manifest file.
|
||||||
return {
|
g = {
|
||||||
"metadata": self.metadata,
|
"metadata": self.metadata,
|
||||||
"include": self.include,
|
"include": self.include,
|
||||||
"require": self.require,
|
"require": self.require,
|
||||||
"package": self.package,
|
"package": self.package,
|
||||||
"module": self.module,
|
"module": self.module,
|
||||||
"freeze": self.freeze,
|
|
||||||
"freeze_as_str": self.freeze_as_str,
|
|
||||||
"freeze_as_mpy": self.freeze_as_mpy,
|
|
||||||
"freeze_mpy": self.freeze_mpy,
|
|
||||||
"options": IncludeOptions(**kwargs),
|
"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):
|
def files(self):
|
||||||
return self._manifest_files
|
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):
|
def execute(self, manifest_file):
|
||||||
if manifest_file.endswith(".py"):
|
if manifest_file.endswith(".py"):
|
||||||
# Execute file from filesystem.
|
# Execute file from filesystem.
|
||||||
self.include(manifest_file, top_level=True)
|
self.include(manifest_file)
|
||||||
else:
|
else:
|
||||||
# Execute manifest code snippet.
|
# Execute manifest code snippet.
|
||||||
try:
|
try:
|
||||||
@ -173,7 +250,7 @@ class ManifestFile:
|
|||||||
stat = os.stat(full_path)
|
stat = os.stat(full_path)
|
||||||
timestamp = stat.st_mtime
|
timestamp = stat.st_mtime
|
||||||
except OSError:
|
except OSError:
|
||||||
raise ManifestFileError("cannot stat {}".format(full_path))
|
raise ManifestFileError("Cannot stat {}".format(full_path))
|
||||||
|
|
||||||
# Map the AUTO kinds to their actual kind based on mode and extension.
|
# Map the AUTO kinds to their actual kind based on mode and extension.
|
||||||
_, ext = os.path.splitext(full_path)
|
_, ext = os.path.splitext(full_path)
|
||||||
@ -231,19 +308,21 @@ class ManifestFile:
|
|||||||
if base_path:
|
if base_path:
|
||||||
os.chdir(prev_cwd)
|
os.chdir(prev_cwd)
|
||||||
|
|
||||||
def metadata(self, description=None, version=None, license=None, author=None):
|
def metadata(self, **kwargs):
|
||||||
"""
|
"""
|
||||||
From within a manifest file, use this to set the metadata for the
|
From within a manifest file, use this to set the metadata for the
|
||||||
package described by current manifest.
|
package described by current manifest.
|
||||||
|
|
||||||
After executing a manifest file (via execute()), call this
|
After executing a manifest file (via execute()), call this
|
||||||
to obtain the metadata for the top-level manifest file.
|
to obtain the metadata for the top-level manifest file.
|
||||||
"""
|
|
||||||
|
|
||||||
self._metadata[-1].update(description, version, license, author)
|
See ManifestPackageMetadata.update() for valid kwargs.
|
||||||
|
"""
|
||||||
|
if kwargs:
|
||||||
|
self._metadata[-1].update(self._mode, **kwargs)
|
||||||
return self._metadata[-1]
|
return self._metadata[-1]
|
||||||
|
|
||||||
def include(self, manifest_path, top_level=False, **kwargs):
|
def include(self, manifest_path, is_require=False, **kwargs):
|
||||||
"""
|
"""
|
||||||
Include another manifest.
|
Include another manifest.
|
||||||
|
|
||||||
@ -269,9 +348,12 @@ class ManifestFile:
|
|||||||
if options.extra_features:
|
if options.extra_features:
|
||||||
# freeze extra modules.
|
# freeze extra modules.
|
||||||
"""
|
"""
|
||||||
|
if is_require:
|
||||||
|
self._metadata[-1].check_initialised(self._mode)
|
||||||
|
|
||||||
if not isinstance(manifest_path, str):
|
if not isinstance(manifest_path, str):
|
||||||
for m in manifest_path:
|
for m in manifest_path:
|
||||||
self.include(m)
|
self.include(m, **kwargs)
|
||||||
else:
|
else:
|
||||||
manifest_path = self._resolve_path(manifest_path)
|
manifest_path = self._resolve_path(manifest_path)
|
||||||
# Including a directory grabs the manifest.py inside it.
|
# Including a directory grabs the manifest.py inside it.
|
||||||
@ -280,29 +362,50 @@ class ManifestFile:
|
|||||||
if manifest_path in self._visited:
|
if manifest_path in self._visited:
|
||||||
return
|
return
|
||||||
self._visited.add(manifest_path)
|
self._visited.add(manifest_path)
|
||||||
if not top_level:
|
if is_require:
|
||||||
self._metadata.append(ManifestMetadata())
|
# This include is the result of require("name"), so push a new
|
||||||
with open(manifest_path) as f:
|
# package metadata onto the stack.
|
||||||
# Make paths relative to this manifest file while processing it.
|
self._metadata.append(ManifestPackageMetadata(is_require=True))
|
||||||
# Applies to includes and input files.
|
try:
|
||||||
prev_cwd = os.getcwd()
|
with open(manifest_path) as f:
|
||||||
os.chdir(os.path.dirname(manifest_path))
|
# Make paths relative to this manifest file while processing it.
|
||||||
try:
|
# Applies to includes and input files.
|
||||||
exec(f.read(), self._manifest_globals(kwargs))
|
prev_cwd = os.getcwd()
|
||||||
except Exception as er:
|
os.chdir(os.path.dirname(manifest_path))
|
||||||
raise ManifestFileError(
|
try:
|
||||||
"Error in manifest file: {}: {}".format(manifest_path, er)
|
exec(f.read(), self._manifest_globals(kwargs))
|
||||||
)
|
finally:
|
||||||
os.chdir(prev_cwd)
|
os.chdir(prev_cwd)
|
||||||
if not top_level:
|
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()
|
self._metadata.pop()
|
||||||
|
|
||||||
def require(self, name, version=None, unix_ffi=False, **kwargs):
|
def require(self, name, version=None, unix_ffi=False, pypi=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
Require a module by name from micropython-lib.
|
Require a module by name from micropython-lib.
|
||||||
|
|
||||||
Optionally specify unix_ffi=True to use a module from the unix-ffi directory.
|
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.
|
||||||
"""
|
"""
|
||||||
|
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 self._path_vars["MPY_LIB_DIR"]:
|
if self._path_vars["MPY_LIB_DIR"]:
|
||||||
lib_dirs = ["micropython", "python-stdlib", "python-ecosys"]
|
lib_dirs = ["micropython", "python-stdlib", "python-ecosys"]
|
||||||
if unix_ffi:
|
if unix_ffi:
|
||||||
@ -316,7 +419,7 @@ class ManifestFile:
|
|||||||
os.path.join(self._path_vars["MPY_LIB_DIR"], lib_dir)
|
os.path.join(self._path_vars["MPY_LIB_DIR"], lib_dir)
|
||||||
):
|
):
|
||||||
if os.path.basename(root) == name and "manifest.py" in filenames:
|
if os.path.basename(root) == name and "manifest.py" in filenames:
|
||||||
self.include(root, **kwargs)
|
self.include(root, is_require=True, **kwargs)
|
||||||
return
|
return
|
||||||
|
|
||||||
raise ValueError("Library not found in local micropython-lib: {}".format(name))
|
raise ValueError("Library not found in local micropython-lib: {}".format(name))
|
||||||
@ -338,6 +441,8 @@ class ManifestFile:
|
|||||||
To restrict to certain files in the package use files (note: paths should be relative to the package):
|
To restrict to certain files in the package use files (note: paths should be relative to the package):
|
||||||
package("foo", files=["bar/baz.py"])
|
package("foo", files=["bar/baz.py"])
|
||||||
"""
|
"""
|
||||||
|
self._metadata[-1].check_initialised(self._mode)
|
||||||
|
|
||||||
# Include "base_path/package_path/**/*.py" --> "package_path/**/*.py"
|
# Include "base_path/package_path/**/*.py" --> "package_path/**/*.py"
|
||||||
self._search(base_path, package_path, files, exts=(".py",), kind=KIND_AUTO, opt=opt)
|
self._search(base_path, package_path, files, exts=(".py",), kind=KIND_AUTO, opt=opt)
|
||||||
|
|
||||||
@ -351,6 +456,8 @@ class ManifestFile:
|
|||||||
Otherwise use base_path to locate the file:
|
Otherwise use base_path to locate the file:
|
||||||
module("foo.py", "src/drivers")
|
module("foo.py", "src/drivers")
|
||||||
"""
|
"""
|
||||||
|
self._metadata[-1].check_initialised(self._mode)
|
||||||
|
|
||||||
# Include "base_path/module_path" --> "module_path"
|
# Include "base_path/module_path" --> "module_path"
|
||||||
base_path = self._resolve_path(base_path)
|
base_path = self._resolve_path(base_path)
|
||||||
_, ext = os.path.splitext(module_path)
|
_, ext = os.path.splitext(module_path)
|
||||||
@ -454,6 +561,7 @@ def main():
|
|||||||
cmd_parser = argparse.ArgumentParser(description="List the files referenced by a manifest.")
|
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("--freeze", action="store_true", help="freeze mode")
|
||||||
cmd_parser.add_argument("--compile", action="store_true", help="compile 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(
|
cmd_parser.add_argument(
|
||||||
"--lib",
|
"--lib",
|
||||||
default=os.path.join(os.path.dirname(__file__), "../lib/micropython-lib"),
|
default=os.path.join(os.path.dirname(__file__), "../lib/micropython-lib"),
|
||||||
@ -481,6 +589,8 @@ def main():
|
|||||||
mode = MODE_FREEZE
|
mode = MODE_FREEZE
|
||||||
elif args.compile:
|
elif args.compile:
|
||||||
mode = MODE_COMPILE
|
mode = MODE_COMPILE
|
||||||
|
elif args.pyproject:
|
||||||
|
mode = MODE_PYPROJECT
|
||||||
else:
|
else:
|
||||||
print("Error: No mode specified.", file=sys.stderr)
|
print("Error: No mode specified.", file=sys.stderr)
|
||||||
exit(1)
|
exit(1)
|
||||||
@ -492,8 +602,12 @@ def main():
|
|||||||
except ManifestFileError as er:
|
except ManifestFileError as er:
|
||||||
print(er, file=sys.stderr)
|
print(er, file=sys.stderr)
|
||||||
exit(1)
|
exit(1)
|
||||||
|
print(m.metadata())
|
||||||
for f in m.files():
|
for f in m.files():
|
||||||
print(f)
|
print(f)
|
||||||
|
if mode == MODE_PYPROJECT:
|
||||||
|
for r in m.pypi_dependencies():
|
||||||
|
print("pypi-require:", r)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
Loading…
Reference in New Issue
Block a user