#!/usr/bin/env python3 # # This file is part of the MicroPython project, http://micropython.org/ # # The MIT License (MIT) # # Copyright (c) 2023 Jim Mussared # # 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. # This script makes a CPython-compatible package from a micropython-lib package # with a pyproject.toml that can be built (via hatch) and deployed to PyPI. # Requires that the project sets the pypi_publish= kwarg in its metadata(). # Usage: # ./tools/makepyproject.py --output /tmp/foo micropython/foo # python -m build /tmp/foo # python -m twine upload /tmp/foo/dist/*.whl from email.utils import parseaddr import os import re import shutil import sys from build import error_color, ensure_path_exists DEFAULT_AUTHOR = "micropython-lib " DEFAULT_LICENSE = "MIT" def quoted_escape(s): return s.replace('"', '\\"') def build(manifest_path, output_path): import manifestfile if not manifest_path.endswith(".py"): # Allow specifying either the directory or the manifest file explicitly. manifest_path = os.path.join(manifest_path, "manifest.py") print("Generating pyproject for {} in {}...".format(manifest_path, output_path)) toml_path = os.path.join(output_path, "pyproject.toml") ensure_path_exists(toml_path) path_vars = { "MPY_LIB_DIR": os.path.abspath(os.path.join(os.path.dirname(__file__), "..")), } # .../foo/manifest.py -> foo package_name = os.path.basename(os.path.dirname(manifest_path)) # Compile the manifest. manifest = manifestfile.ManifestFile(manifestfile.MODE_PYPROJECT, path_vars) manifest.execute(manifest_path) # If a package doesn't have a pypi name, then assume it isn't intended to # be publishable. if not manifest.metadata().pypi_publish: print(error_color("Error:"), package_name, "doesn't have a pypi_publish name.") sys.exit(1) # These should be in all packages eventually. if not manifest.metadata().version: print(error_color("Error:"), package_name, "doesn't have a version.") sys.exit(1) if not manifest.metadata().description: print(error_color("Error:"), package_name, "doesn't have a description.") sys.exit(1) # This is the root path of all .py files that are copied. We ensure that # they all match. top_level_package = None for result in manifest.files(): # This isn't allowed in micropython-lib anyway. if result.file_type != manifestfile.FILE_TYPE_LOCAL: print(error_color("Error:"), "Non-local file not supported.", file=sys.stderr) sys.exit(1) # "foo/bar/baz.py" --> "foo" # "baz.py" --> "" result_package = os.path.split(result.target_path)[0] if not result_package: # This is a standalone .py file. print( error_color("Error:"), "Unsupported single-file module: {}".format(result.target_path), file=sys.stderr, ) sys.exit(1) if top_level_package and result_package != top_level_package: # This likely suggests that something needs to use require(..., pypi="..."). print( error_color("Error:"), "More than one top-level package: {}, {}.".format( result_package, top_level_package ), file=sys.stderr, ) sys.exit(1) top_level_package = result_package # Tag each file with the package metadata and copy the .py directly. with manifestfile.tagged_py_file(result.full_path, result.metadata) as tagged_path: dest_path = os.path.join(output_path, result.target_path) ensure_path_exists(dest_path) shutil.copyfile(tagged_path, dest_path) # Copy README.md if it exists readme_path = os.path.join(os.path.dirname(manifest_path), "README.md") readme_toml = "" if os.path.exists(readme_path): shutil.copyfile(readme_path, os.path.join(output_path, "README.md")) readme_toml = 'readme = "README.md"' # Apply default author and license, otherwise use the package metadata. license_toml = 'license = {{ text = "{}" }}'.format( quoted_escape(manifest.metadata().license or DEFAULT_LICENSE) ) author_name, author_email = parseaddr(manifest.metadata().author or DEFAULT_AUTHOR) author_toml = 'authors = [ {{ name = "{}", email = "{}"}} ]'.format( quoted_escape(author_name), quoted_escape(author_email) ) # Write pyproject.toml. with open(toml_path, "w") as toml_file: print("# Generated by makepyproject.py", file=toml_file) print( """ [build-system] requires = [ "hatchling" ] build-backend = "hatchling.build" """, file=toml_file, ) print( """ [project] name = "{}" description = "{}" {} {} version = "{}" dependencies = [{}] urls = {{ Homepage = "https://github.com/micropython/micropython-lib" }} {} """.format( quoted_escape(manifest.metadata().pypi_publish), quoted_escape(manifest.metadata().description), author_toml, license_toml, quoted_escape(manifest.metadata().version), ", ".join('"{}"'.format(quoted_escape(r)) for r in manifest.pypi_dependencies()), readme_toml, ), file=toml_file, ) print( """ [tool.hatch.build] packages = ["{}"] """.format(top_level_package), file=toml_file, ) print("Done.") def main(): import argparse cmd_parser = argparse.ArgumentParser( description="Generate a project that can be pushed to PyPI." ) cmd_parser.add_argument("--output", required=True, help="output directory") cmd_parser.add_argument("--micropython", default=None, help="path to micropython repo") cmd_parser.add_argument("manifest", help="input package path") args = cmd_parser.parse_args() if args.micropython: sys.path.append(os.path.join(args.micropython, "tools")) # for manifestfile build(args.manifest, args.output) if __name__ == "__main__": main()