|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# |
| 3 | +# This file is part of the MicroPython project, http://micropython.org/ |
| 4 | +# |
| 5 | +# The MIT License (MIT) |
| 6 | +# |
| 7 | +# Copyright (c) 2023 Jim Mussared |
| 8 | +# |
| 9 | +# Permission is hereby granted, free of charge, to any person obtaining a copy |
| 10 | +# of this software and associated documentation files (the "Software"), to deal |
| 11 | +# in the Software without restriction, including without limitation the rights |
| 12 | +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| 13 | +# copies of the Software, and to permit persons to whom the Software is |
| 14 | +# furnished to do so, subject to the following conditions: |
| 15 | +# |
| 16 | +# The above copyright notice and this permission notice shall be included in |
| 17 | +# all copies or substantial portions of the Software. |
| 18 | +# |
| 19 | +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| 20 | +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| 21 | +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| 22 | +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| 23 | +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| 24 | +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN |
| 25 | +# THE SOFTWARE. |
| 26 | + |
| 27 | +# This script makes a CPython-compatible package from a micropython-lib package |
| 28 | +# with a pyproject.toml that can be built (via hatch) and deployed to PyPI. |
| 29 | +# Requires that the project sets the pypi_publish= kwarg in its metadata(). |
| 30 | + |
| 31 | +# Usage: |
| 32 | +# ./tools/makepyproject.py --output /tmp/foo micropython/foo |
| 33 | +# python -m build /tmp/foo |
| 34 | +# python -m twine upload /tmp/foo/dist/*.whl |
| 35 | + |
| 36 | +from email.utils import parseaddr |
| 37 | +import os |
| 38 | +import re |
| 39 | +import shutil |
| 40 | +import sys |
| 41 | + |
| 42 | +from build import error_color, ensure_path_exists |
| 43 | + |
| 44 | + |
| 45 | +DEFAULT_AUTHOR = "micropython-lib <[email protected]>" |
| 46 | +DEFAULT_LICENSE = "MIT" |
| 47 | + |
| 48 | + |
| 49 | +def quoted_escape(s): |
| 50 | + return s.replace('"', '\\"') |
| 51 | + |
| 52 | + |
| 53 | +def build(manifest_path, output_path): |
| 54 | + import manifestfile |
| 55 | + |
| 56 | + if not manifest_path.endswith(".py"): |
| 57 | + # Allow specifying either the directory or the manifest file explicitly. |
| 58 | + manifest_path = os.path.join(manifest_path, "manifest.py") |
| 59 | + |
| 60 | + print("Generating pyproject for {} in {}...".format(manifest_path, output_path)) |
| 61 | + |
| 62 | + toml_path = os.path.join(output_path, "pyproject.toml") |
| 63 | + ensure_path_exists(toml_path) |
| 64 | + |
| 65 | + path_vars = { |
| 66 | + "MPY_LIB_DIR": os.path.abspath(os.path.join(os.path.dirname(__file__), "..")), |
| 67 | + } |
| 68 | + |
| 69 | + # .../foo/manifest.py -> foo |
| 70 | + package_name = os.path.basename(os.path.dirname(manifest_path)) |
| 71 | + |
| 72 | + # Compile the manifest. |
| 73 | + manifest = manifestfile.ManifestFile(manifestfile.MODE_PYPROJECT, path_vars) |
| 74 | + manifest.execute(manifest_path) |
| 75 | + |
| 76 | + # If a package doesn't have a pypi name, then assume it isn't intended to |
| 77 | + # be publishable. |
| 78 | + if not manifest.metadata().pypi_publish: |
| 79 | + print(error_color("Error:"), package_name, "doesn't have a pypi_publish name.") |
| 80 | + sys.exit(1) |
| 81 | + |
| 82 | + # These should be in all packages eventually. |
| 83 | + if not manifest.metadata().version: |
| 84 | + print(error_color("Error:"), package_name, "doesn't have a version.") |
| 85 | + sys.exit(1) |
| 86 | + if not manifest.metadata().description: |
| 87 | + print(error_color("Error:"), package_name, "doesn't have a description.") |
| 88 | + sys.exit(1) |
| 89 | + |
| 90 | + # This is the root path of all .py files that are copied. We ensure that |
| 91 | + # they all match. |
| 92 | + top_level_package = None |
| 93 | + |
| 94 | + for result in manifest.files(): |
| 95 | + # This isn't allowed in micropython-lib anyway. |
| 96 | + if result.file_type != manifestfile.FILE_TYPE_LOCAL: |
| 97 | + print(error_color("Error:"), "Non-local file not supported.", file=sys.stderr) |
| 98 | + sys.exit(1) |
| 99 | + |
| 100 | + # "foo/bar/baz.py" --> "foo" |
| 101 | + # "baz.py" --> "" |
| 102 | + result_package = os.path.split(result.target_path)[0] |
| 103 | + |
| 104 | + if not result_package: |
| 105 | + # This is a standalone .py file. |
| 106 | + print( |
| 107 | + error_color("Error:"), |
| 108 | + "Unsupported single-file module: {}".format(result.target_path), |
| 109 | + file=sys.stderr, |
| 110 | + ) |
| 111 | + sys.exit(1) |
| 112 | + if top_level_package and result_package != top_level_package: |
| 113 | + # This likely suggests that something needs to use require(..., pypi="..."). |
| 114 | + print( |
| 115 | + error_color("Error:"), |
| 116 | + "More than one top-level package: {}, {}.".format( |
| 117 | + result_package, top_level_package |
| 118 | + ), |
| 119 | + file=sys.stderr, |
| 120 | + ) |
| 121 | + sys.exit(1) |
| 122 | + top_level_package = result_package |
| 123 | + |
| 124 | + # Tag each file with the package metadata and copy the .py directly. |
| 125 | + with manifestfile.tagged_py_file(result.full_path, result.metadata) as tagged_path: |
| 126 | + dest_path = os.path.join(output_path, result.target_path) |
| 127 | + ensure_path_exists(dest_path) |
| 128 | + shutil.copyfile(tagged_path, dest_path) |
| 129 | + |
| 130 | + # Copy README.md if it exists |
| 131 | + readme_path = os.path.join(os.path.dirname(manifest_path), "README.md") |
| 132 | + readme_toml = "" |
| 133 | + if os.path.exists(readme_path): |
| 134 | + shutil.copyfile(readme_path, os.path.join(output_path, "README.md")) |
| 135 | + readme_toml = 'readme = "README.md"' |
| 136 | + |
| 137 | + # Apply default author and license, otherwise use the package metadata. |
| 138 | + license_toml = 'license = {{ text = "{}" }}'.format( |
| 139 | + quoted_escape(manifest.metadata().license or DEFAULT_LICENSE) |
| 140 | + ) |
| 141 | + author_name, author_email = parseaddr(manifest.metadata().author or DEFAULT_AUTHOR) |
| 142 | + author_toml = 'authors = [ {{ name = "{}", email = "{}"}} ]'.format( |
| 143 | + quoted_escape(author_name), quoted_escape(author_email) |
| 144 | + ) |
| 145 | + |
| 146 | + # Write pyproject.toml. |
| 147 | + with open(toml_path, "w") as toml_file: |
| 148 | + print("# Generated by makepyproject.py", file=toml_file) |
| 149 | + |
| 150 | + print( |
| 151 | + """ |
| 152 | +[build-system] |
| 153 | +requires = [ |
| 154 | + "hatchling" |
| 155 | +] |
| 156 | +build-backend = "hatchling.build" |
| 157 | +""", |
| 158 | + file=toml_file, |
| 159 | + ) |
| 160 | + |
| 161 | + print( |
| 162 | + """ |
| 163 | +[project] |
| 164 | +name = "{}" |
| 165 | +description = "{}" |
| 166 | +{} |
| 167 | +{} |
| 168 | +version = "{}" |
| 169 | +dependencies = [{}] |
| 170 | +urls = {{ Homepage = "https://github.com/micropython/micropython-lib" }} |
| 171 | +{} |
| 172 | +""".format( |
| 173 | + quoted_escape(manifest.metadata().pypi_publish), |
| 174 | + quoted_escape(manifest.metadata().description), |
| 175 | + author_toml, |
| 176 | + license_toml, |
| 177 | + quoted_escape(manifest.metadata().version), |
| 178 | + ", ".join('"{}"'.format(quoted_escape(r)) for r in manifest.pypi_dependencies()), |
| 179 | + readme_toml, |
| 180 | + ), |
| 181 | + file=toml_file, |
| 182 | + ) |
| 183 | + |
| 184 | + print( |
| 185 | + """ |
| 186 | +[tool.hatch.build] |
| 187 | +packages = ["{}"] |
| 188 | +""".format( |
| 189 | + top_level_package |
| 190 | + ), |
| 191 | + file=toml_file, |
| 192 | + ) |
| 193 | + |
| 194 | + print("Done.") |
| 195 | + |
| 196 | + |
| 197 | +def main(): |
| 198 | + import argparse |
| 199 | + |
| 200 | + cmd_parser = argparse.ArgumentParser( |
| 201 | + description="Generate a project that can be pushed to PyPI." |
| 202 | + ) |
| 203 | + cmd_parser.add_argument("--output", required=True, help="output directory") |
| 204 | + cmd_parser.add_argument("--micropython", default=None, help="path to micropython repo") |
| 205 | + cmd_parser.add_argument("manifest", help="input package path") |
| 206 | + args = cmd_parser.parse_args() |
| 207 | + |
| 208 | + if args.micropython: |
| 209 | + sys.path.append(os.path.join(args.micropython, "tools")) # for manifestfile |
| 210 | + |
| 211 | + build(args.manifest, args.output) |
| 212 | + |
| 213 | + |
| 214 | +if __name__ == "__main__": |
| 215 | + main() |
0 commit comments