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.
Files changed (30) hide show
  1. {mpytool-2.2.3 → mpytool-2.2.4}/PKG-INFO +11 -1
  2. {mpytool-2.2.3 → mpytool-2.2.4}/README.md +10 -0
  3. {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/mpy.py +3 -0
  4. mpytool-2.2.4/mpytool/mpy_cross.py +155 -0
  5. {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/mpytool.py +47 -9
  6. {mpytool-2.2.3 → mpytool-2.2.4}/mpytool.egg-info/PKG-INFO +11 -1
  7. {mpytool-2.2.3 → mpytool-2.2.4}/mpytool.egg-info/SOURCES.txt +1 -0
  8. {mpytool-2.2.3 → mpytool-2.2.4}/pyproject.toml +1 -1
  9. {mpytool-2.2.3 → mpytool-2.2.4}/tests/test_mpytool.py +254 -0
  10. {mpytool-2.2.3 → mpytool-2.2.4}/LICENSE +0 -0
  11. {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/__init__.py +0 -0
  12. {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/conn.py +0 -0
  13. {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/conn_serial.py +0 -0
  14. {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/conn_socket.py +0 -0
  15. {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/logger.py +0 -0
  16. {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/mpy_comm.py +0 -0
  17. {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/speedtest.py +0 -0
  18. {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/terminal.py +0 -0
  19. {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/terminal_unix.py +0 -0
  20. {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/terminal_win.py +0 -0
  21. {mpytool-2.2.3 → mpytool-2.2.4}/mpytool/utils.py +0 -0
  22. {mpytool-2.2.3 → mpytool-2.2.4}/mpytool.egg-info/dependency_links.txt +0 -0
  23. {mpytool-2.2.3 → mpytool-2.2.4}/mpytool.egg-info/entry_points.txt +0 -0
  24. {mpytool-2.2.3 → mpytool-2.2.4}/mpytool.egg-info/requires.txt +0 -0
  25. {mpytool-2.2.3 → mpytool-2.2.4}/mpytool.egg-info/top_level.txt +0 -0
  26. {mpytool-2.2.3 → mpytool-2.2.4}/setup.cfg +0 -0
  27. {mpytool-2.2.3 → mpytool-2.2.4}/tests/test_errors.py +0 -0
  28. {mpytool-2.2.3 → mpytool-2.2.4}/tests/test_integration.py +0 -0
  29. {mpytool-2.2.3 → mpytool-2.2.4}/tests/test_mpy.py +0 -0
  30. {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
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
- files[dst_path] = _os.path.getsize(src_path)
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
- dpath = _join_remote_path(
300
- _join_remote_path(dst_path, rel_path), file_name)
301
- files[dpath] = _os.path.getsize(spath)
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 -fz, -fZ
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} copy files (-f = force overwrite)
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
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
@@ -8,6 +8,7 @@ mpytool/conn_socket.py
8
8
  mpytool/logger.py
9
9
  mpytool/mpy.py
10
10
  mpytool/mpy_comm.py
11
+ mpytool/mpy_cross.py
11
12
  mpytool/mpytool.py
12
13
  mpytool/speedtest.py
13
14
  mpytool/terminal.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mpytool"
7
- version = "2.2.3"
7
+ version = "2.2.4"
8
8
  description = "MPY tool - manage files on devices running MicroPython"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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