Skip to content

Commit 22e1d38

Browse files
committed
compile: Experimental support for multi-file projects.
This makes use of recent firmware changes to allow the main script to import modules in the same folder. The modules can also import other modules. To ensure a smooth transition until a firmware with this feature is released, scripts without local imports will be downloaded in the old format (a single raw mpy data blob).
1 parent 08d2026 commit 22e1d38

File tree

7 files changed

+135
-3
lines changed

7 files changed

+135
-3
lines changed

.vscode/launch.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"args": [
1414
"run",
1515
"ble",
16-
"${workspaceFolder}/demo/shortdemo.py"
16+
"${workspaceFolder}/demo/multidemo.py"
1717
]
1818
},
1919
{

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88

99
### Added
1010
- Added `fw_version` attribute to `pybricksdev.connections.pybricks.PybricksHub`.
11+
- Experimental support for multi-file projects.
1112

1213
### Fixed
1314
- Fixed running programs on hubs with firmware with MPY ABI v5.

demo/module1.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from pybricks.parameters import Color
2+
3+
nice_color = Color.GREEN
4+
5+
print("I am", __name__)
6+
7+
if __name__ == "__main__":
8+
print("module1 is main")

demo/module2.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from module1 import nice_color
2+
3+
print("I am", __name__)
4+
print(nice_color)
5+
6+
if __name__ == "__main__":
7+
print("module 2 is main")

demo/multidemo.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from pybricks.hubs import ThisHub
2+
from pybricks.parameters import Color
3+
from pybricks.tools import wait
4+
from module2 import nice_color
5+
6+
hub = ThisHub()
7+
hub.light.on(Color.RED)
8+
wait(2000)
9+
print("I am", __name__)
10+
hub.light.on(nice_color)
11+
wait(2000)

pybricksdev/compile.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
import asyncio
55
import logging
66
import os
7+
import re
8+
9+
from pathlib import Path
710
from typing import List, Optional
811

912
import mpy_cross_v5
@@ -75,6 +78,108 @@ async def compile_file(path: str, abi: int, compile_args: Optional[List[str]] =
7578
return mpy
7679

7780

81+
async def compile_multi_file(
82+
path: str, abi: int, compile_args: Optional[List[str]] = None
83+
):
84+
"""Compiles a Python file and its dependencies with ``mpy-cross``.
85+
86+
All dependencies must be Python modules from the same folder.
87+
88+
The returned bytes format is of the form:
89+
90+
- first script size (uint32 little endian)
91+
- first script name (no extension, zero terminated string)
92+
- first script mpy data
93+
- second script size
94+
- second script name
95+
- second script mpy data
96+
- ...
97+
98+
All components are 0-padded to a size with multiple of 4.
99+
100+
If the main script does not import any local module, it returns only the
101+
first script mpy data (without size and name) for backwards compatibility
102+
with older firmware.
103+
104+
Arguments:
105+
path:
106+
Path to script that is to be compiled.
107+
abi:
108+
Expected MPY ABI version.
109+
compile_args:
110+
Extra arguments for ``mpy-cross``.
111+
112+
Returns:
113+
Concatenation of all compiled files in the format given above.
114+
115+
Raises:
116+
RuntimeError: if there is not a running event loop.
117+
ValueError if MPY ABI version is not 5 or 6.
118+
subprocess.CalledProcessError: if executing the ``mpy-cross` tool failed.
119+
"""
120+
121+
# Make the build directory
122+
make_build_dir()
123+
124+
# Directory where main and dependencies are located
125+
source_dir = Path(path).parent
126+
127+
# Set of all dependencies
128+
dependencies = set()
129+
not_found = set()
130+
131+
# Find all dependencies recursively
132+
def find_dependencies(module_name):
133+
try:
134+
with open(source_dir / (module_name + ".py")) as source:
135+
# Search non-recursively through current module
136+
local_dependencies = set()
137+
for line in source:
138+
# from my_module import thing1, thing2 ---> my_module
139+
if result := re.search("from (.*) import (.*)", line):
140+
local_dependencies.add(result.group(1))
141+
# import my_module ---> my_module
142+
elif result := re.search("import (.*)", line):
143+
local_dependencies.add(result.group(1))
144+
145+
# If each file that wasn't already done, add it and find its
146+
# dependencies too.
147+
for dep in local_dependencies.difference(dependencies):
148+
if dep not in dependencies:
149+
dependencies.add(dep)
150+
find_dependencies(dep)
151+
# Some modules are stored on the hub so we can't find them here.
152+
except FileNotFoundError:
153+
not_found.add(module_name)
154+
155+
# Start searching from the top level.
156+
main_module = Path(path).stem
157+
find_dependencies(main_module)
158+
159+
# Subtract the (builtin or missing) modules we won't upload.
160+
dependencies = dependencies.difference(not_found)
161+
162+
print("Uploading:", path)
163+
# If there are no dependencies, it is an old-style single file script.
164+
# For backwards compatibility, upload just the mpy data. Once the new
165+
# firmware is stable, we can remove this special case.
166+
if not dependencies:
167+
return await compile_file(path, abi)
168+
169+
# Get the total tuple of main programs and module
170+
print("Included modules:", dependencies)
171+
modules = [main_module] + sorted(tuple(dependencies))
172+
173+
# Get a data blob with all scripts.
174+
blob = bytearray([])
175+
for module in modules:
176+
name = module.encode() + b"\x00"
177+
mpy = await compile_file(source_dir / (module + ".py"), abi)
178+
size = len(mpy).to_bytes(4, "little")
179+
blob += size + name + mpy
180+
return blob
181+
182+
78183
def save_script(py_string):
79184
"""Save a MicroPython one-liner to a file."""
80185
# Make the build directory.

pybricksdev/connections/pybricks.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
StatusFlag,
2929
unpack_pnp_id,
3030
)
31-
from ..compile import compile_file
31+
from ..compile import compile_multi_file
3232
from ..tools import chunk
3333
from ..tools.checksum import xor_bytes
3434

@@ -264,7 +264,7 @@ async def run(self, py_path, wait=True, print_output=True):
264264

265265
# Compile the script to mpy format
266266
self.script_dir, _ = os.path.split(py_path)
267-
mpy = await compile_file(py_path, self._mpy_abi_version)
267+
mpy = await compile_multi_file(py_path, self._mpy_abi_version)
268268

269269
try:
270270
self.loading = True

0 commit comments

Comments
 (0)