mpytool 2.2.3__tar.gz → 2.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mpytool-2.2.3 → mpytool-2.2.4}/PKG-INFO +11 -1
- {mpytool-2.2.3 → mpytool-2.2.4}/README.md +10 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/mpy.py +3 -0
- mpytool-2.2.4/mpytool/mpy_cross.py +155 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/mpytool.py +47 -9
- {mpytool-2.2.3 → mpytool-2.2.4}/mpytool.egg-info/PKG-INFO +11 -1
- {mpytool-2.2.3 → mpytool-2.2.4}/mpytool.egg-info/SOURCES.txt +1 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/pyproject.toml +1 -1
- {mpytool-2.2.3 → mpytool-2.2.4}/tests/test_mpytool.py +254 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/LICENSE +0 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/__init__.py +0 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/conn.py +0 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/conn_serial.py +0 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/conn_socket.py +0 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/logger.py +0 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/mpy_comm.py +0 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/speedtest.py +0 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/terminal.py +0 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/terminal_unix.py +0 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/terminal_win.py +0 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/utils.py +0 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/mpytool.egg-info/dependency_links.txt +0 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/mpytool.egg-info/entry_points.txt +0 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/mpytool.egg-info/requires.txt +0 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/mpytool.egg-info/top_level.txt +0 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/setup.cfg +0 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/tests/test_errors.py +0 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/tests/test_integration.py +0 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/tests/test_mpy.py +0 -0
- {mpytool-2.2.3 → mpytool-2.2.4}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mpytool
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.4
|
|
4
4
|
Summary: MPY tool - manage files on devices running MicroPython
|
|
5
5
|
Author-email: Pavel Revak <pavel.revak@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -103,6 +103,7 @@ $ mpytool cp :/main.py ./ # download file to current directory
|
|
|
103
103
|
$ mpytool cp :/ ./backup/ # download entire device to backup/
|
|
104
104
|
$ mpytool cp :/old.py :/new.py # copy file on device
|
|
105
105
|
$ mpytool cp -f main.py :/ # force upload even if unchanged
|
|
106
|
+
$ mpytool cp -m myapp :/ # compile .py to .mpy via mpy-cross
|
|
106
107
|
```
|
|
107
108
|
|
|
108
109
|
Path semantics: `:` = device CWD, `:/` = device root. Trailing `/` on source = copy contents only.
|
|
@@ -114,11 +115,20 @@ Use `-f` or `--force` to upload all files regardless.
|
|
|
114
115
|
$ mpytool cp -z main.py :/ # force compression (auto-detected by default)
|
|
115
116
|
$ mpytool cp --no-compress data.bin :/ # disable compression
|
|
116
117
|
$ mpytool -c 8K cp main.py :/ # set chunk size (512, 1K, 2K, 4K, 8K, 16K, 32K)
|
|
118
|
+
$ mpytool cp -m myapp :/ # compile .py to .mpy before upload
|
|
119
|
+
$ mpytool cp -fm myapp :/ # force upload + compile .mpy
|
|
117
120
|
```
|
|
118
121
|
|
|
119
122
|
Compression is auto-detected based on device RAM and deflate module availability.
|
|
120
123
|
Chunk size is auto-detected based on free RAM (larger chunks = faster transfer).
|
|
121
124
|
|
|
125
|
+
The `-m`/`--mpy` flag compiles `.py` files to `.mpy` bytecode using `mpy-cross` before upload.
|
|
126
|
+
Compiled files are cached in `__pycache__/` (recompiled only when source changes).
|
|
127
|
+
`boot.py` and `main.py` are always uploaded as `.py` (firmware requirement).
|
|
128
|
+
Requires `mpy-cross` in PATH (`pip install mpy-cross`). If mpy-cross version differs
|
|
129
|
+
from device, automatically uses `-b` flag to target the correct bytecode version.
|
|
130
|
+
If `mpy-cross` is not found, falls back to uploading `.py` with a warning.
|
|
131
|
+
|
|
122
132
|
### Move/rename on device
|
|
123
133
|
```
|
|
124
134
|
$ mpytool mv :/old.py :/new.py # rename file
|
|
@@ -89,6 +89,7 @@ $ mpytool cp :/main.py ./ # download file to current directory
|
|
|
89
89
|
$ mpytool cp :/ ./backup/ # download entire device to backup/
|
|
90
90
|
$ mpytool cp :/old.py :/new.py # copy file on device
|
|
91
91
|
$ mpytool cp -f main.py :/ # force upload even if unchanged
|
|
92
|
+
$ mpytool cp -m myapp :/ # compile .py to .mpy via mpy-cross
|
|
92
93
|
```
|
|
93
94
|
|
|
94
95
|
Path semantics: `:` = device CWD, `:/` = device root. Trailing `/` on source = copy contents only.
|
|
@@ -100,11 +101,20 @@ Use `-f` or `--force` to upload all files regardless.
|
|
|
100
101
|
$ mpytool cp -z main.py :/ # force compression (auto-detected by default)
|
|
101
102
|
$ mpytool cp --no-compress data.bin :/ # disable compression
|
|
102
103
|
$ mpytool -c 8K cp main.py :/ # set chunk size (512, 1K, 2K, 4K, 8K, 16K, 32K)
|
|
104
|
+
$ mpytool cp -m myapp :/ # compile .py to .mpy before upload
|
|
105
|
+
$ mpytool cp -fm myapp :/ # force upload + compile .mpy
|
|
103
106
|
```
|
|
104
107
|
|
|
105
108
|
Compression is auto-detected based on device RAM and deflate module availability.
|
|
106
109
|
Chunk size is auto-detected based on free RAM (larger chunks = faster transfer).
|
|
107
110
|
|
|
111
|
+
The `-m`/`--mpy` flag compiles `.py` files to `.mpy` bytecode using `mpy-cross` before upload.
|
|
112
|
+
Compiled files are cached in `__pycache__/` (recompiled only when source changes).
|
|
113
|
+
`boot.py` and `main.py` are always uploaded as `.py` (firmware requirement).
|
|
114
|
+
Requires `mpy-cross` in PATH (`pip install mpy-cross`). If mpy-cross version differs
|
|
115
|
+
from device, automatically uses `-b` flag to target the correct bytecode version.
|
|
116
|
+
If `mpy-cross` is not found, falls back to uploading `.py` with a warning.
|
|
117
|
+
|
|
108
118
|
### Move/rename on device
|
|
109
119
|
```
|
|
110
120
|
$ mpytool mv :/old.py :/new.py # rename file
|
|
@@ -568,9 +568,11 @@ def _mt_pfind(label):
|
|
|
568
568
|
mpy_int = self._mpy_comm.exec_eval("sys.implementation._mpy")
|
|
569
569
|
mpy_ver = mpy_int & 0xFF
|
|
570
570
|
mpy_sub = (mpy_int >> 8) & 3
|
|
571
|
+
mpy_arch = (mpy_int >> 10) & 0x0F
|
|
571
572
|
except (_mpy_comm.CmdError, AttributeError):
|
|
572
573
|
mpy_ver = None
|
|
573
574
|
mpy_sub = None
|
|
575
|
+
mpy_arch = 0
|
|
574
576
|
|
|
575
577
|
return {
|
|
576
578
|
'platform': platform,
|
|
@@ -580,6 +582,7 @@ def _mt_pfind(label):
|
|
|
580
582
|
'machine': machine,
|
|
581
583
|
'mpy_ver': mpy_ver,
|
|
582
584
|
'mpy_sub': mpy_sub,
|
|
585
|
+
'mpy_arch': mpy_arch,
|
|
583
586
|
}
|
|
584
587
|
|
|
585
588
|
def memory(self):
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Mpy-cross compilation support for MicroPython .mpy files"""
|
|
2
|
+
|
|
3
|
+
import fnmatch as _fnmatch
|
|
4
|
+
import os as _os
|
|
5
|
+
import re as _re
|
|
6
|
+
import shutil as _shutil
|
|
7
|
+
import subprocess as _subprocess
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Native arch index from sys.implementation._mpy bits 10-13
|
|
11
|
+
_MPY_ARCH_NAMES = (
|
|
12
|
+
None, 'x86', 'x64', 'armv6', 'armv6m', 'armv7m', 'armv7em',
|
|
13
|
+
'armv7emsp', 'armv7emdp', 'xtensa', 'xtensawin', 'rv32imc',
|
|
14
|
+
'rv64imc',
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_BOOT_FILES = frozenset(('boot.py', 'main.py'))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MpyCross:
|
|
21
|
+
"""Mpy-cross compiler wrapper with caching
|
|
22
|
+
|
|
23
|
+
Compiles .py files to .mpy bytecode via mpy-cross.
|
|
24
|
+
Cached in __pycache__/name.mpy-X.Y-arch.mpy with mtime check.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, log, verbose_fn=None):
|
|
28
|
+
self._log = log
|
|
29
|
+
self._verbose_fn = verbose_fn
|
|
30
|
+
self.active = False
|
|
31
|
+
self._ver = None # (major, sub) device mpy version
|
|
32
|
+
self._arch = None # architecture name for cache key
|
|
33
|
+
self._args = [] # extra mpy-cross args (-b, -march)
|
|
34
|
+
self.compiled = {} # {src_path: cache_path}
|
|
35
|
+
|
|
36
|
+
def _verbose(self, msg, level=1):
|
|
37
|
+
if self._verbose_fn:
|
|
38
|
+
self._verbose_fn(msg, level)
|
|
39
|
+
|
|
40
|
+
def init(self, platform_info):
|
|
41
|
+
"""Initialize: find mpy-cross, check version, detect arch
|
|
42
|
+
|
|
43
|
+
Arguments:
|
|
44
|
+
platform_info: dict from Mpy.platform()
|
|
45
|
+
|
|
46
|
+
Sets self.active = True on success, False on failure.
|
|
47
|
+
"""
|
|
48
|
+
self.active = False
|
|
49
|
+
mpy_cross_bin = _shutil.which('mpy-cross')
|
|
50
|
+
if not mpy_cross_bin:
|
|
51
|
+
self._log.warning(
|
|
52
|
+
'mpy-cross not found in PATH (pip install mpy-cross),'
|
|
53
|
+
' uploading .py files')
|
|
54
|
+
return
|
|
55
|
+
# Get mpy-cross version
|
|
56
|
+
result = _subprocess.run(
|
|
57
|
+
[mpy_cross_bin, '--version'],
|
|
58
|
+
capture_output=True, text=True, timeout=10)
|
|
59
|
+
output = result.stdout + result.stderr
|
|
60
|
+
match = _re.search(r'mpy v(\d+)\.(\d+)', output)
|
|
61
|
+
if not match:
|
|
62
|
+
self._log.warning(
|
|
63
|
+
f'Cannot parse mpy-cross version: {output.strip()},'
|
|
64
|
+
' uploading .py files')
|
|
65
|
+
return
|
|
66
|
+
cross_ver = int(match.group(1))
|
|
67
|
+
cross_sub = int(match.group(2))
|
|
68
|
+
# Get device mpy version and architecture
|
|
69
|
+
dev_ver = platform_info.get('mpy_ver')
|
|
70
|
+
dev_sub = platform_info.get('mpy_sub')
|
|
71
|
+
if dev_ver is None:
|
|
72
|
+
self._log.warning(
|
|
73
|
+
'Device does not report mpy version, uploading .py files')
|
|
74
|
+
return
|
|
75
|
+
self._ver = (dev_ver, dev_sub)
|
|
76
|
+
self._args = ['-O2']
|
|
77
|
+
# Bytecode version targeting
|
|
78
|
+
cross_str = f'v{cross_ver}.{cross_sub}'
|
|
79
|
+
if cross_ver != dev_ver or cross_sub != dev_sub:
|
|
80
|
+
self._args = ['-b', f'{dev_ver}.{dev_sub}']
|
|
81
|
+
cross_str += f' -> v{dev_ver}.{dev_sub}'
|
|
82
|
+
# Native architecture for @native/@viper support
|
|
83
|
+
dev_arch = platform_info.get('mpy_arch', 0)
|
|
84
|
+
arch_name = None
|
|
85
|
+
if dev_arch and dev_arch < len(_MPY_ARCH_NAMES):
|
|
86
|
+
arch_name = _MPY_ARCH_NAMES[dev_arch]
|
|
87
|
+
self._arch = arch_name
|
|
88
|
+
if arch_name:
|
|
89
|
+
self._args.append('-march=' + arch_name)
|
|
90
|
+
dev_version = platform_info.get('version', '')
|
|
91
|
+
arch_str = f' arch {arch_name}' if arch_name else ''
|
|
92
|
+
self._verbose(
|
|
93
|
+
f'device v{dev_version} mpy v{dev_ver}.{dev_sub}{arch_str},'
|
|
94
|
+
f' mpy-cross {cross_str}', 2)
|
|
95
|
+
self.active = True
|
|
96
|
+
|
|
97
|
+
def compile(self, src_path):
|
|
98
|
+
"""Compile .py file to .mpy, return cache path or None
|
|
99
|
+
|
|
100
|
+
Skips boot.py, main.py, and non-.py files.
|
|
101
|
+
Uses cache if fresh (mtime check).
|
|
102
|
+
"""
|
|
103
|
+
basename = _os.path.basename(src_path)
|
|
104
|
+
if basename in _BOOT_FILES:
|
|
105
|
+
self._verbose(f'mpy: skip {basename} (boot file)', 2)
|
|
106
|
+
return None
|
|
107
|
+
if not basename.endswith('.py'):
|
|
108
|
+
return None
|
|
109
|
+
stem = basename[:-3]
|
|
110
|
+
ver, sub = self._ver
|
|
111
|
+
arch_suffix = f'-{self._arch}' if self._arch else ''
|
|
112
|
+
cache_dir = _os.path.join(_os.path.dirname(src_path), '__pycache__')
|
|
113
|
+
cache_path = _os.path.join(
|
|
114
|
+
cache_dir, f'{stem}.mpy-{ver}.{sub}{arch_suffix}.mpy')
|
|
115
|
+
# Check if cache is fresh
|
|
116
|
+
if _os.path.exists(cache_path):
|
|
117
|
+
if _os.path.getmtime(cache_path) >= _os.path.getmtime(src_path):
|
|
118
|
+
self.compiled[src_path] = cache_path
|
|
119
|
+
return cache_path
|
|
120
|
+
# Compile
|
|
121
|
+
_os.makedirs(cache_dir, exist_ok=True)
|
|
122
|
+
cmd = ['mpy-cross'] + self._args + ['-o', cache_path, src_path]
|
|
123
|
+
self._log.warning('$ %s', ' '.join(cmd))
|
|
124
|
+
result = _subprocess.run(
|
|
125
|
+
cmd, capture_output=True, text=True, timeout=30)
|
|
126
|
+
if result.returncode != 0:
|
|
127
|
+
err = (result.stderr or result.stdout).strip()
|
|
128
|
+
self._log.warning(f'mpy-cross failed for {basename}: {err}')
|
|
129
|
+
return None
|
|
130
|
+
self.compiled[src_path] = cache_path
|
|
131
|
+
return cache_path
|
|
132
|
+
|
|
133
|
+
def compile_sources(self, sources, is_excluded=None):
|
|
134
|
+
"""Pre-compile all .py source files to .mpy cache
|
|
135
|
+
|
|
136
|
+
Arguments:
|
|
137
|
+
sources: list of source paths (files or directories)
|
|
138
|
+
is_excluded: optional callback(name) -> bool for exclusion check
|
|
139
|
+
"""
|
|
140
|
+
for src in sources:
|
|
141
|
+
if src.startswith(':'):
|
|
142
|
+
continue
|
|
143
|
+
src_path = src.rstrip('/')
|
|
144
|
+
if _os.path.isfile(src_path):
|
|
145
|
+
self.compile(src_path)
|
|
146
|
+
elif _os.path.isdir(src_path):
|
|
147
|
+
for root, dirs, files in _os.walk(src_path, topdown=True):
|
|
148
|
+
if is_excluded:
|
|
149
|
+
dirs[:] = sorted(
|
|
150
|
+
d for d in dirs if not is_excluded(d))
|
|
151
|
+
else:
|
|
152
|
+
dirs.sort()
|
|
153
|
+
for f in sorted(files):
|
|
154
|
+
if f.endswith('.py'):
|
|
155
|
+
self.compile(_os.path.join(root, f))
|
|
@@ -12,6 +12,7 @@ import mpytool as _mpytool
|
|
|
12
12
|
import mpytool.terminal as _terminal
|
|
13
13
|
import mpytool.utils as _utils
|
|
14
14
|
from mpytool.logger import SimpleColorLogger
|
|
15
|
+
from mpytool.mpy_cross import MpyCross
|
|
15
16
|
|
|
16
17
|
try:
|
|
17
18
|
_about = _metadata.metadata("mpytool")
|
|
@@ -67,7 +68,7 @@ class MpyTool():
|
|
|
67
68
|
self._conn = conn
|
|
68
69
|
self._log = log if log is not None else SimpleColorLogger()
|
|
69
70
|
self._verbose_out = verbose # None = no verbose output (API mode)
|
|
70
|
-
self._exclude_dirs = {'.*', '*.pyc'}
|
|
71
|
+
self._exclude_dirs = {'.*', '*.pyc', '__pycache__'}
|
|
71
72
|
if exclude_dirs:
|
|
72
73
|
self._exclude_dirs.update(exclude_dirs)
|
|
73
74
|
self._mpy = _mpytool.Mpy(conn, log=self._log, chunk_size=chunk_size)
|
|
@@ -87,6 +88,9 @@ class MpyTool():
|
|
|
87
88
|
self._stats_wire_bytes = 0 # Actual bytes sent over wire (with encoding)
|
|
88
89
|
self._stats_transferred_files = 0
|
|
89
90
|
self._stats_start_time = None
|
|
91
|
+
# mpy-cross compilation
|
|
92
|
+
self._mpy_cross = False # --mpy flag active
|
|
93
|
+
self._mpy_compiler = MpyCross(self._log, self.verbose)
|
|
90
94
|
# Remote file info cache for batch operations
|
|
91
95
|
self._remote_file_cache = {} # {path: (size, hash) or None}
|
|
92
96
|
|
|
@@ -281,7 +285,12 @@ class MpyTool():
|
|
|
281
285
|
basename = _os.path.basename(src_path)
|
|
282
286
|
if basename and not _os.path.basename(dst_path):
|
|
283
287
|
dst_path = _join_remote_path(dst_path, basename)
|
|
284
|
-
|
|
288
|
+
cache = self._mpy_compiler.compiled.get(src_path)
|
|
289
|
+
if cache:
|
|
290
|
+
dst_path = dst_path[:-3] + '.mpy'
|
|
291
|
+
files[dst_path] = _os.path.getsize(cache)
|
|
292
|
+
else:
|
|
293
|
+
files[dst_path] = _os.path.getsize(src_path)
|
|
285
294
|
elif _os.path.isdir(src_path):
|
|
286
295
|
# For directories, mimic _put_dir behavior
|
|
287
296
|
if add_src_basename:
|
|
@@ -296,9 +305,16 @@ class MpyTool():
|
|
|
296
305
|
rel_path = ''
|
|
297
306
|
for file_name in filenames:
|
|
298
307
|
spath = _os.path.join(root, file_name)
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
308
|
+
cache = self._mpy_compiler.compiled.get(spath)
|
|
309
|
+
if cache:
|
|
310
|
+
dst_name = file_name[:-3] + '.mpy'
|
|
311
|
+
dpath = _join_remote_path(
|
|
312
|
+
_join_remote_path(dst_path, rel_path), dst_name)
|
|
313
|
+
files[dpath] = _os.path.getsize(cache)
|
|
314
|
+
else:
|
|
315
|
+
dpath = _join_remote_path(
|
|
316
|
+
_join_remote_path(dst_path, rel_path), file_name)
|
|
317
|
+
files[dpath] = _os.path.getsize(spath)
|
|
302
318
|
return files
|
|
303
319
|
|
|
304
320
|
def _file_needs_update(self, local_data, remote_path):
|
|
@@ -539,6 +555,13 @@ class MpyTool():
|
|
|
539
555
|
|
|
540
556
|
def _upload_file(self, data, src_path, dst_path, show_progress):
|
|
541
557
|
"""Upload file data to device with stats tracking and progress display"""
|
|
558
|
+
# Use compiled .mpy data if available
|
|
559
|
+
cache = self._mpy_compiler.compiled.get(src_path)
|
|
560
|
+
if cache:
|
|
561
|
+
with open(cache, 'rb') as f:
|
|
562
|
+
data = f.read()
|
|
563
|
+
if dst_path.endswith('.py'):
|
|
564
|
+
dst_path = dst_path[:-3] + '.mpy'
|
|
542
565
|
file_size = len(data)
|
|
543
566
|
self._stats_total_bytes += file_size
|
|
544
567
|
if show_progress and self._verbose >= 1:
|
|
@@ -750,28 +773,33 @@ class MpyTool():
|
|
|
750
773
|
|
|
751
774
|
def cmd_cp(self, *args):
|
|
752
775
|
"""Copy files between local and device"""
|
|
753
|
-
# Parse flags: -f/--force, -z/--compress, -Z/--no-compress
|
|
776
|
+
# Parse flags: -f/--force, -m/--mpy, -z/--compress, -Z/--no-compress
|
|
754
777
|
args = list(args)
|
|
755
778
|
force = None
|
|
779
|
+
mpy_cross = None
|
|
756
780
|
compress = None # None = use global setting
|
|
757
781
|
filtered_args = []
|
|
758
782
|
for a in args:
|
|
759
783
|
if a == '--force':
|
|
760
784
|
force = True
|
|
785
|
+
elif a == '--mpy':
|
|
786
|
+
mpy_cross = True
|
|
761
787
|
elif a == '--compress':
|
|
762
788
|
compress = True
|
|
763
789
|
elif a == '--no-compress':
|
|
764
790
|
compress = False
|
|
765
791
|
elif a.startswith('-') and not a.startswith('--') and len(a) > 1:
|
|
766
|
-
# Handle combined short flags like -
|
|
792
|
+
# Handle combined short flags like -fmz, -fZ
|
|
767
793
|
flags = a[1:]
|
|
768
794
|
if 'f' in flags:
|
|
769
795
|
force = True
|
|
796
|
+
if 'm' in flags:
|
|
797
|
+
mpy_cross = True
|
|
770
798
|
if 'z' in flags:
|
|
771
799
|
compress = True
|
|
772
800
|
if 'Z' in flags:
|
|
773
801
|
compress = False
|
|
774
|
-
remaining = flags.replace('f', '').replace('z', '').replace('Z', '')
|
|
802
|
+
remaining = flags.replace('f', '').replace('m', '').replace('z', '').replace('Z', '')
|
|
775
803
|
if remaining:
|
|
776
804
|
filtered_args.append('-' + remaining)
|
|
777
805
|
else:
|
|
@@ -782,8 +810,11 @@ class MpyTool():
|
|
|
782
810
|
# Save and set flags for this command
|
|
783
811
|
saved_force = self._force
|
|
784
812
|
saved_compress = self._compress
|
|
813
|
+
saved_mpy_cross = self._mpy_cross
|
|
785
814
|
if force is not None:
|
|
786
815
|
self._force = force
|
|
816
|
+
if mpy_cross is not None:
|
|
817
|
+
self._mpy_cross = mpy_cross
|
|
787
818
|
if compress is not None:
|
|
788
819
|
self._compress = compress
|
|
789
820
|
try:
|
|
@@ -791,12 +822,19 @@ class MpyTool():
|
|
|
791
822
|
finally:
|
|
792
823
|
self._force = saved_force
|
|
793
824
|
self._compress = saved_compress
|
|
825
|
+
self._mpy_cross = saved_mpy_cross
|
|
794
826
|
|
|
795
827
|
def _cmd_cp_impl(self, args):
|
|
796
828
|
sources = list(args[:-1])
|
|
797
829
|
dest = args[-1]
|
|
798
830
|
dest_is_remote = dest.startswith(':')
|
|
799
831
|
dest_path = dest[1:] if dest_is_remote else dest
|
|
832
|
+
# Initialize mpy-cross compilation if requested
|
|
833
|
+
if self._mpy_cross:
|
|
834
|
+
self._mpy_compiler.init(self._mpy.platform())
|
|
835
|
+
if self._mpy_compiler.active:
|
|
836
|
+
self._mpy_compiler.compile_sources(
|
|
837
|
+
sources, self._is_excluded)
|
|
800
838
|
# Determine if destination is a directory:
|
|
801
839
|
# - Remote: '' (CWD) or '/' (root) or ends with '/'
|
|
802
840
|
# - Local: ends with '/' or exists as directory
|
|
@@ -1421,7 +1459,7 @@ Commands (: prefix = device path, :/ = root, : = CWD):
|
|
|
1421
1459
|
ls [:path] list files and sizes (default: CWD)
|
|
1422
1460
|
tree [:path] list directory tree (default: CWD)
|
|
1423
1461
|
cat {:path} [...] print file content to stdout
|
|
1424
|
-
cp [-f] {src} [...] {dst}
|
|
1462
|
+
cp [-f] [-m] {src} [...] {dst} copy files (-f force, -m compile .mpy)
|
|
1425
1463
|
mv {:src} [...] {:dst} move/rename on device
|
|
1426
1464
|
mkdir {:path} [...] create directory (with parents)
|
|
1427
1465
|
rm {:path} [...] delete file/dir (:path/ = contents only)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mpytool
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.4
|
|
4
4
|
Summary: MPY tool - manage files on devices running MicroPython
|
|
5
5
|
Author-email: Pavel Revak <pavel.revak@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -103,6 +103,7 @@ $ mpytool cp :/main.py ./ # download file to current directory
|
|
|
103
103
|
$ mpytool cp :/ ./backup/ # download entire device to backup/
|
|
104
104
|
$ mpytool cp :/old.py :/new.py # copy file on device
|
|
105
105
|
$ mpytool cp -f main.py :/ # force upload even if unchanged
|
|
106
|
+
$ mpytool cp -m myapp :/ # compile .py to .mpy via mpy-cross
|
|
106
107
|
```
|
|
107
108
|
|
|
108
109
|
Path semantics: `:` = device CWD, `:/` = device root. Trailing `/` on source = copy contents only.
|
|
@@ -114,11 +115,20 @@ Use `-f` or `--force` to upload all files regardless.
|
|
|
114
115
|
$ mpytool cp -z main.py :/ # force compression (auto-detected by default)
|
|
115
116
|
$ mpytool cp --no-compress data.bin :/ # disable compression
|
|
116
117
|
$ mpytool -c 8K cp main.py :/ # set chunk size (512, 1K, 2K, 4K, 8K, 16K, 32K)
|
|
118
|
+
$ mpytool cp -m myapp :/ # compile .py to .mpy before upload
|
|
119
|
+
$ mpytool cp -fm myapp :/ # force upload + compile .mpy
|
|
117
120
|
```
|
|
118
121
|
|
|
119
122
|
Compression is auto-detected based on device RAM and deflate module availability.
|
|
120
123
|
Chunk size is auto-detected based on free RAM (larger chunks = faster transfer).
|
|
121
124
|
|
|
125
|
+
The `-m`/`--mpy` flag compiles `.py` files to `.mpy` bytecode using `mpy-cross` before upload.
|
|
126
|
+
Compiled files are cached in `__pycache__/` (recompiled only when source changes).
|
|
127
|
+
`boot.py` and `main.py` are always uploaded as `.py` (firmware requirement).
|
|
128
|
+
Requires `mpy-cross` in PATH (`pip install mpy-cross`). If mpy-cross version differs
|
|
129
|
+
from device, automatically uses `-b` flag to target the correct bytecode version.
|
|
130
|
+
If `mpy-cross` is not found, falls back to uploading `.py` with a warning.
|
|
131
|
+
|
|
122
132
|
### Move/rename on device
|
|
123
133
|
```
|
|
124
134
|
$ mpytool mv :/old.py :/new.py # rename file
|
|
@@ -1099,5 +1099,259 @@ class TestRunCommand(unittest.TestCase):
|
|
|
1099
1099
|
self.tool._mpy.comm.try_raw_paste.assert_called_once()
|
|
1100
1100
|
|
|
1101
1101
|
|
|
1102
|
+
class TestMpyCompilation(unittest.TestCase):
|
|
1103
|
+
"""Tests for --mpy compilation feature (MpyCross class + MpyTool integration)"""
|
|
1104
|
+
|
|
1105
|
+
def setUp(self):
|
|
1106
|
+
self.mock_conn = Mock()
|
|
1107
|
+
self.tool = MpyTool(self.mock_conn, verbose=None)
|
|
1108
|
+
self.tool._mpy = Mock()
|
|
1109
|
+
self.compiler = self.tool._mpy_compiler
|
|
1110
|
+
self.temp_dir = tempfile.mkdtemp()
|
|
1111
|
+
# Create test .py files
|
|
1112
|
+
self.script_py = os.path.join(self.temp_dir, 'script.py')
|
|
1113
|
+
with open(self.script_py, 'w') as f:
|
|
1114
|
+
f.write('print("hello")\n')
|
|
1115
|
+
self.boot_py = os.path.join(self.temp_dir, 'boot.py')
|
|
1116
|
+
with open(self.boot_py, 'w') as f:
|
|
1117
|
+
f.write('# boot\n')
|
|
1118
|
+
self.main_py = os.path.join(self.temp_dir, 'main.py')
|
|
1119
|
+
with open(self.main_py, 'w') as f:
|
|
1120
|
+
f.write('# main\n')
|
|
1121
|
+
self.data_json = os.path.join(self.temp_dir, 'data.json')
|
|
1122
|
+
with open(self.data_json, 'w') as f:
|
|
1123
|
+
f.write('{}')
|
|
1124
|
+
# Subdirectory with .py file
|
|
1125
|
+
self.sub_dir = os.path.join(self.temp_dir, 'lib')
|
|
1126
|
+
os.makedirs(self.sub_dir)
|
|
1127
|
+
self.lib_py = os.path.join(self.sub_dir, 'mylib.py')
|
|
1128
|
+
with open(self.lib_py, 'w') as f:
|
|
1129
|
+
f.write('x = 1\n')
|
|
1130
|
+
|
|
1131
|
+
def tearDown(self):
|
|
1132
|
+
shutil.rmtree(self.temp_dir)
|
|
1133
|
+
|
|
1134
|
+
def _setup_compiler(self, arch=None):
|
|
1135
|
+
"""Setup compiler with active state and fake version"""
|
|
1136
|
+
self.compiler.active = True
|
|
1137
|
+
self.compiler._ver = (6, 3)
|
|
1138
|
+
self.compiler._arch = arch
|
|
1139
|
+
self.compiler._args = []
|
|
1140
|
+
|
|
1141
|
+
def test_compile_skip_boot_py(self):
|
|
1142
|
+
"""boot.py should not be compiled"""
|
|
1143
|
+
self._setup_compiler()
|
|
1144
|
+
result = self.compiler.compile(self.boot_py)
|
|
1145
|
+
self.assertIsNone(result)
|
|
1146
|
+
self.assertNotIn(self.boot_py, self.compiler.compiled)
|
|
1147
|
+
|
|
1148
|
+
def test_compile_skip_main_py(self):
|
|
1149
|
+
"""main.py should not be compiled"""
|
|
1150
|
+
self._setup_compiler()
|
|
1151
|
+
result = self.compiler.compile(self.main_py)
|
|
1152
|
+
self.assertIsNone(result)
|
|
1153
|
+
self.assertNotIn(self.main_py, self.compiler.compiled)
|
|
1154
|
+
|
|
1155
|
+
def test_compile_skip_non_py(self):
|
|
1156
|
+
"""Non-.py files should not be compiled"""
|
|
1157
|
+
self._setup_compiler()
|
|
1158
|
+
result = self.compiler.compile(self.data_json)
|
|
1159
|
+
self.assertIsNone(result)
|
|
1160
|
+
|
|
1161
|
+
@patch('mpytool.mpy_cross._subprocess.run')
|
|
1162
|
+
def test_compile_creates_cache(self, mock_run):
|
|
1163
|
+
"""Compilation creates __pycache__/name.mpy-X.Y.mpy"""
|
|
1164
|
+
self._setup_compiler()
|
|
1165
|
+
mock_run.return_value = Mock(returncode=0, stderr='', stdout='')
|
|
1166
|
+
result = self.compiler.compile(self.script_py)
|
|
1167
|
+
expected_cache = os.path.join(
|
|
1168
|
+
self.temp_dir, '__pycache__', 'script.mpy-6.3.mpy')
|
|
1169
|
+
self.assertEqual(result, expected_cache)
|
|
1170
|
+
self.assertEqual(self.compiler.compiled[self.script_py], expected_cache)
|
|
1171
|
+
mock_run.assert_called_once_with(
|
|
1172
|
+
['mpy-cross', '-o', expected_cache, self.script_py],
|
|
1173
|
+
capture_output=True, text=True, timeout=30)
|
|
1174
|
+
|
|
1175
|
+
@patch('mpytool.mpy_cross._subprocess.run')
|
|
1176
|
+
def test_compile_cache_includes_arch(self, mock_run):
|
|
1177
|
+
"""Cache path includes architecture for native/viper support"""
|
|
1178
|
+
self._setup_compiler(arch='xtensawin')
|
|
1179
|
+
self.compiler._args = ['-march=xtensawin']
|
|
1180
|
+
mock_run.return_value = Mock(returncode=0, stderr='', stdout='')
|
|
1181
|
+
result = self.compiler.compile(self.script_py)
|
|
1182
|
+
expected_cache = os.path.join(
|
|
1183
|
+
self.temp_dir, '__pycache__', 'script.mpy-6.3-xtensawin.mpy')
|
|
1184
|
+
self.assertEqual(result, expected_cache)
|
|
1185
|
+
mock_run.assert_called_once_with(
|
|
1186
|
+
['mpy-cross', '-march=xtensawin', '-o',
|
|
1187
|
+
expected_cache, self.script_py],
|
|
1188
|
+
capture_output=True, text=True, timeout=30)
|
|
1189
|
+
|
|
1190
|
+
@patch('mpytool.mpy_cross._subprocess.run')
|
|
1191
|
+
def test_compile_uses_b_flag_on_mismatch(self, mock_run):
|
|
1192
|
+
"""Compilation uses -b flag when mpy-cross version differs from device"""
|
|
1193
|
+
self._setup_compiler()
|
|
1194
|
+
self.compiler._args = ['-b', '6.1']
|
|
1195
|
+
self.compiler._ver = (6, 1)
|
|
1196
|
+
mock_run.return_value = Mock(returncode=0, stderr='', stdout='')
|
|
1197
|
+
cache_path = os.path.join(
|
|
1198
|
+
self.temp_dir, '__pycache__', 'script.mpy-6.1.mpy')
|
|
1199
|
+
self.compiler.compile(self.script_py)
|
|
1200
|
+
mock_run.assert_called_once_with(
|
|
1201
|
+
['mpy-cross', '-b', '6.1', '-o', cache_path, self.script_py],
|
|
1202
|
+
capture_output=True, text=True, timeout=30)
|
|
1203
|
+
|
|
1204
|
+
@patch('mpytool.mpy_cross._subprocess.run')
|
|
1205
|
+
def test_compile_uses_cache_when_fresh(self, mock_run):
|
|
1206
|
+
"""Fresh cache file should be reused without recompilation"""
|
|
1207
|
+
self._setup_compiler()
|
|
1208
|
+
cache_dir = os.path.join(self.temp_dir, '__pycache__')
|
|
1209
|
+
os.makedirs(cache_dir)
|
|
1210
|
+
cache_path = os.path.join(cache_dir, 'script.mpy-6.3.mpy')
|
|
1211
|
+
with open(cache_path, 'wb') as f:
|
|
1212
|
+
f.write(b'\x4d\x06\x03')
|
|
1213
|
+
import time
|
|
1214
|
+
future = time.time() + 10
|
|
1215
|
+
os.utime(cache_path, (future, future))
|
|
1216
|
+
result = self.compiler.compile(self.script_py)
|
|
1217
|
+
self.assertEqual(result, cache_path)
|
|
1218
|
+
mock_run.assert_not_called()
|
|
1219
|
+
|
|
1220
|
+
@patch('mpytool.mpy_cross._subprocess.run')
|
|
1221
|
+
def test_compile_recompiles_stale_cache(self, mock_run):
|
|
1222
|
+
"""Stale cache file should trigger recompilation"""
|
|
1223
|
+
self._setup_compiler()
|
|
1224
|
+
cache_dir = os.path.join(self.temp_dir, '__pycache__')
|
|
1225
|
+
os.makedirs(cache_dir)
|
|
1226
|
+
cache_path = os.path.join(cache_dir, 'script.mpy-6.3.mpy')
|
|
1227
|
+
with open(cache_path, 'wb') as f:
|
|
1228
|
+
f.write(b'\x4d\x06\x03')
|
|
1229
|
+
import time
|
|
1230
|
+
future = time.time() + 10
|
|
1231
|
+
os.utime(self.script_py, (future, future))
|
|
1232
|
+
mock_run.return_value = Mock(returncode=0, stderr='', stdout='')
|
|
1233
|
+
result = self.compiler.compile(self.script_py)
|
|
1234
|
+
self.assertEqual(result, cache_path)
|
|
1235
|
+
mock_run.assert_called_once()
|
|
1236
|
+
|
|
1237
|
+
@patch('mpytool.mpy_cross._subprocess.run')
|
|
1238
|
+
def test_compile_failure_returns_none(self, mock_run):
|
|
1239
|
+
"""mpy-cross failure should return None (upload .py instead)"""
|
|
1240
|
+
self._setup_compiler()
|
|
1241
|
+
self.compiler._log = Mock()
|
|
1242
|
+
mock_run.return_value = Mock(
|
|
1243
|
+
returncode=1, stderr='SyntaxError: invalid syntax', stdout='')
|
|
1244
|
+
result = self.compiler.compile(self.script_py)
|
|
1245
|
+
self.assertIsNone(result)
|
|
1246
|
+
self.assertNotIn(self.script_py, self.compiler.compiled)
|
|
1247
|
+
|
|
1248
|
+
@patch('mpytool.mpy_cross._subprocess.run')
|
|
1249
|
+
def test_compile_sources_walks_directory(self, mock_run):
|
|
1250
|
+
"""compile_sources should compile all .py in directory"""
|
|
1251
|
+
self._setup_compiler()
|
|
1252
|
+
mock_run.return_value = Mock(returncode=0, stderr='', stdout='')
|
|
1253
|
+
self.compiler.compile_sources(
|
|
1254
|
+
[self.temp_dir], self.tool._is_excluded)
|
|
1255
|
+
compiled_basenames = {
|
|
1256
|
+
os.path.basename(p) for p in self.compiler.compiled}
|
|
1257
|
+
self.assertIn('script.py', compiled_basenames)
|
|
1258
|
+
self.assertIn('mylib.py', compiled_basenames)
|
|
1259
|
+
self.assertNotIn('boot.py', compiled_basenames)
|
|
1260
|
+
self.assertNotIn('main.py', compiled_basenames)
|
|
1261
|
+
|
|
1262
|
+
@patch('mpytool.mpy_cross._subprocess.run')
|
|
1263
|
+
def test_collect_dst_files_with_mpy(self, mock_run):
|
|
1264
|
+
"""_collect_dst_files should use .mpy paths and compiled sizes"""
|
|
1265
|
+
self._setup_compiler()
|
|
1266
|
+
mock_run.return_value = Mock(returncode=0, stderr='', stdout='')
|
|
1267
|
+
self.compiler.compile_sources(
|
|
1268
|
+
[self.temp_dir], self.tool._is_excluded)
|
|
1269
|
+
# Create fake cache files with known sizes
|
|
1270
|
+
for src, cache in self.compiler.compiled.items():
|
|
1271
|
+
os.makedirs(os.path.dirname(cache), exist_ok=True)
|
|
1272
|
+
with open(cache, 'wb') as f:
|
|
1273
|
+
f.write(b'\x00' * 42)
|
|
1274
|
+
files = self.tool._collect_dst_files(self.temp_dir, '/', add_src_basename=False)
|
|
1275
|
+
py_paths = [p for p in files if p.endswith('.py')]
|
|
1276
|
+
mpy_paths = [p for p in files if p.endswith('.mpy')]
|
|
1277
|
+
self.assertEqual(
|
|
1278
|
+
sorted(py_paths), sorted(['/boot.py', '/main.py']))
|
|
1279
|
+
self.assertEqual(
|
|
1280
|
+
sorted(mpy_paths), sorted(['/lib/mylib.mpy', '/script.mpy']))
|
|
1281
|
+
for p in mpy_paths:
|
|
1282
|
+
self.assertEqual(files[p], 42)
|
|
1283
|
+
|
|
1284
|
+
def test_upload_file_uses_compiled_data(self):
|
|
1285
|
+
"""_upload_file should use compiled data when available"""
|
|
1286
|
+
self._setup_compiler()
|
|
1287
|
+
cache_dir = os.path.join(self.temp_dir, '__pycache__')
|
|
1288
|
+
os.makedirs(cache_dir)
|
|
1289
|
+
cache_path = os.path.join(cache_dir, 'script.mpy-6.3.mpy')
|
|
1290
|
+
compiled_data = b'\x4d\x06\x03compiled'
|
|
1291
|
+
with open(cache_path, 'wb') as f:
|
|
1292
|
+
f.write(compiled_data)
|
|
1293
|
+
self.compiler.compiled[self.script_py] = cache_path
|
|
1294
|
+
self.tool._mpy.put.return_value = (set(), 0)
|
|
1295
|
+
self.tool._force = True
|
|
1296
|
+
self.tool._upload_file(
|
|
1297
|
+
b'original .py data', self.script_py, '/script.py', False)
|
|
1298
|
+
call_args = self.tool._mpy.put.call_args
|
|
1299
|
+
self.assertEqual(call_args[0][0], compiled_data)
|
|
1300
|
+
self.assertEqual(call_args[0][1], '/script.mpy')
|
|
1301
|
+
|
|
1302
|
+
@patch('mpytool.mpy_cross._shutil.which', return_value='/usr/bin/mpy-cross')
|
|
1303
|
+
@patch('mpytool.mpy_cross._subprocess.run')
|
|
1304
|
+
def test_init_version_match(self, mock_run, mock_which):
|
|
1305
|
+
"""init should succeed when versions match"""
|
|
1306
|
+
mock_run.return_value = Mock(
|
|
1307
|
+
returncode=0,
|
|
1308
|
+
stdout='MicroPython v1.24.0 on 2024-10-01; mpy-cross emitting mpy v6.3\n',
|
|
1309
|
+
stderr='')
|
|
1310
|
+
self.compiler.init({
|
|
1311
|
+
'mpy_ver': 6, 'mpy_sub': 3, 'mpy_arch': 4,
|
|
1312
|
+
'version': '1.24.0'})
|
|
1313
|
+
self.assertTrue(self.compiler.active)
|
|
1314
|
+
self.assertEqual(self.compiler._ver, (6, 3))
|
|
1315
|
+
self.assertIn('-march=armv6m', self.compiler._args)
|
|
1316
|
+
|
|
1317
|
+
@patch('mpytool.mpy_cross._shutil.which', return_value='/usr/bin/mpy-cross')
|
|
1318
|
+
@patch('mpytool.mpy_cross._subprocess.run')
|
|
1319
|
+
def test_init_version_mismatch_uses_b_flag(self, mock_run, mock_which):
|
|
1320
|
+
"""init should use -b flag on version mismatch"""
|
|
1321
|
+
mock_run.return_value = Mock(
|
|
1322
|
+
returncode=0,
|
|
1323
|
+
stdout='MicroPython v1.24.0; mpy-cross emitting mpy v6.3\n',
|
|
1324
|
+
stderr='')
|
|
1325
|
+
self.compiler.init({
|
|
1326
|
+
'mpy_ver': 6, 'mpy_sub': 1, 'mpy_arch': 10,
|
|
1327
|
+
'version': '1.20.0'})
|
|
1328
|
+
self.assertTrue(self.compiler.active)
|
|
1329
|
+
self.assertEqual(self.compiler._ver, (6, 1))
|
|
1330
|
+
self.assertIn('-b', self.compiler._args)
|
|
1331
|
+
self.assertIn('6.1', self.compiler._args)
|
|
1332
|
+
self.assertIn('-march=xtensawin', self.compiler._args)
|
|
1333
|
+
|
|
1334
|
+
@patch('mpytool.mpy_cross._shutil.which', return_value=None)
|
|
1335
|
+
def test_init_not_found(self, mock_which):
|
|
1336
|
+
"""init should deactivate when mpy-cross not in PATH"""
|
|
1337
|
+
self.compiler._log = Mock()
|
|
1338
|
+
self.compiler.init({'mpy_ver': 6, 'mpy_sub': 3, 'version': '1.24.0'})
|
|
1339
|
+
self.assertFalse(self.compiler.active)
|
|
1340
|
+
|
|
1341
|
+
def test_cmd_cp_mpy_flag_parsing(self):
|
|
1342
|
+
"""cmd_cp should parse --mpy and -m flags"""
|
|
1343
|
+
self.tool._mpy_cross = False
|
|
1344
|
+
self.tool._cmd_cp_impl = Mock()
|
|
1345
|
+
self.tool.cmd_cp('--mpy', 'src', ':dst')
|
|
1346
|
+
self.assertTrue(self.tool._mpy_cross or
|
|
1347
|
+
self.tool._cmd_cp_impl.called)
|
|
1348
|
+
|
|
1349
|
+
def test_cmd_cp_combined_flags(self):
|
|
1350
|
+
"""cmd_cp should parse combined flags like -fm"""
|
|
1351
|
+
self.tool._cmd_cp_impl = Mock()
|
|
1352
|
+
self.tool.cmd_cp('-fm', 'src', ':dst')
|
|
1353
|
+
# After cmd_cp finishes (even with mock), flags should be restored
|
|
1354
|
+
|
|
1355
|
+
|
|
1102
1356
|
if __name__ == "__main__":
|
|
1103
1357
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|