Skip to content

Commit 9b5f4d7

Browse files
jimmodpgeorge
authored andcommitted
tools/makepyproject.py: Add tool to generate PyPI package.
This tool makes a buildable package (including pyproject.toml) from supported micropython-lib packages, suitable for publishing to PyPI and using from CPython. Signed-off-by: Jim Mussared <[email protected]>
1 parent afc9d0a commit 9b5f4d7

File tree

2 files changed

+224
-9
lines changed

2 files changed

+224
-9
lines changed

tools/build.py

+9-9
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,6 @@
112112

113113
# mip (or other tools) should request /package/{mpy_version}/{package_name}/{version}.json.
114114

115-
import argparse
116115
import glob
117116
import hashlib
118117
import json
@@ -132,7 +131,7 @@
132131

133132

134133
# Create all directories in the path (such that the file can be created).
135-
def _ensure_path_exists(file_path):
134+
def ensure_path_exists(file_path):
136135
path = os.path.dirname(file_path)
137136
if not os.path.isdir(path):
138137
os.makedirs(path)
@@ -155,7 +154,7 @@ def _identical_files(path_a, path_b):
155154
# Helper to write the object as json to the specified path, creating any
156155
# directories as required.
157156
def _write_json(obj, path, minify=False):
158-
_ensure_path_exists(path)
157+
ensure_path_exists(path)
159158
with open(path, "w") as f:
160159
json.dump(
161160
obj, f, indent=(None if minify else 2), separators=((",", ":") if minify else None)
@@ -173,7 +172,7 @@ def _write_package_json(
173172

174173

175174
# Format s with bold red.
176-
def _error_color(s):
175+
def error_color(s):
177176
return _COLOR_ERROR_ON + s + _COLOR_ERROR_OFF
178177

179178

@@ -191,7 +190,7 @@ def _write_hashed_file(package_name, src, target_path, out_file_dir, hash_prefix
191190
# that it's actually the same file.
192191
if not _identical_files(src.name, output_file_path):
193192
print(
194-
_error_color("Hash collision processing:"),
193+
error_color("Hash collision processing:"),
195194
package_name,
196195
file=sys.stderr,
197196
)
@@ -204,7 +203,7 @@ def _write_hashed_file(package_name, src, target_path, out_file_dir, hash_prefix
204203
sys.exit(1)
205204
else:
206205
# Create new file.
207-
_ensure_path_exists(output_file_path)
206+
ensure_path_exists(output_file_path)
208207
shutil.copyfile(src.name, output_file_path)
209208

210209
return short_file_hash
@@ -235,7 +234,7 @@ def _compile_as_mpy(
235234
)
236235
except mpy_cross.CrossCompileError as e:
237236
print(
238-
_error_color("Error:"),
237+
error_color("Error:"),
239238
"Unable to compile",
240239
target_path,
241240
"in package",
@@ -329,7 +328,7 @@ def build(output_path, hash_prefix_len, mpy_cross_path):
329328

330329
# Append this package to the index.
331330
if not manifest.metadata().version:
332-
print(_error_color("Warning:"), package_name, "doesn't have a version.")
331+
print(error_color("Warning:"), package_name, "doesn't have a version.")
333332

334333
# Try to find this package in the previous index.json.
335334
for p in index_json["packages"]:
@@ -360,11 +359,12 @@ def build(output_path, hash_prefix_len, mpy_cross_path):
360359
for result in manifest.files():
361360
# This isn't allowed in micropython-lib anyway.
362361
if result.file_type != manifestfile.FILE_TYPE_LOCAL:
363-
print("Non-local file not supported.", file=sys.stderr)
362+
print(error_color("Error:"), "Non-local file not supported.", file=sys.stderr)
364363
sys.exit(1)
365364

366365
if not result.target_path.endswith(".py"):
367366
print(
367+
error_color("Error:"),
368368
"Target path isn't a .py file:",
369369
result.target_path,
370370
file=sys.stderr,

tools/makepyproject.py

+215
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
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

Comments
 (0)