mpytool 2.4.0__tar.gz → 2.5.0__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 (50) hide show
  1. {mpytool-2.4.0 → mpytool-2.5.0}/PKG-INFO +45 -1
  2. {mpytool-2.4.0 → mpytool-2.5.0}/README.md +44 -0
  3. {mpytool-2.4.0 → mpytool-2.5.0}/completions/_mpytool +7 -0
  4. {mpytool-2.4.0 → mpytool-2.5.0}/completions/mpytool.bash +7 -0
  5. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/_version.py +3 -3
  6. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/mpytool.py +125 -2
  7. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool.egg-info/PKG-INFO +45 -1
  8. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool.egg-info/SOURCES.txt +2 -0
  9. mpytool-2.5.0/mpytool.egg-info/scm_file_list.json +43 -0
  10. mpytool-2.5.0/mpytool.egg-info/scm_version.json +8 -0
  11. {mpytool-2.4.0 → mpytool-2.5.0}/tests/test_cli.py +13 -0
  12. {mpytool-2.4.0 → mpytool-2.5.0}/tests/test_mpytool.py +132 -0
  13. {mpytool-2.4.0 → mpytool-2.5.0}/.github/workflows/integration.yml +0 -0
  14. {mpytool-2.4.0 → mpytool-2.5.0}/.github/workflows/publish.yml +0 -0
  15. {mpytool-2.4.0 → mpytool-2.5.0}/.github/workflows/tests.yml +0 -0
  16. {mpytool-2.4.0 → mpytool-2.5.0}/.gitignore +0 -0
  17. {mpytool-2.4.0 → mpytool-2.5.0}/LICENSE +0 -0
  18. {mpytool-2.4.0 → mpytool-2.5.0}/README_API.md +0 -0
  19. {mpytool-2.4.0 → mpytool-2.5.0}/README_bench.md +0 -0
  20. {mpytool-2.4.0 → mpytool-2.5.0}/README_mpremote.md +0 -0
  21. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/__init__.py +0 -0
  22. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/cmd_cp.py +0 -0
  23. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/conn.py +0 -0
  24. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/conn_serial.py +0 -0
  25. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/conn_socket.py +0 -0
  26. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/logger.py +0 -0
  27. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/mount.py +0 -0
  28. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/mpy.py +0 -0
  29. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/mpy_comm.py +0 -0
  30. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/mpy_cross.py +0 -0
  31. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/speedtest.py +0 -0
  32. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/terminal.py +0 -0
  33. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/terminal_unix.py +0 -0
  34. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/terminal_win.py +0 -0
  35. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/utils.py +0 -0
  36. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool.egg-info/dependency_links.txt +0 -0
  37. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool.egg-info/entry_points.txt +0 -0
  38. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool.egg-info/requires.txt +0 -0
  39. {mpytool-2.4.0 → mpytool-2.5.0}/mpytool.egg-info/top_level.txt +0 -0
  40. {mpytool-2.4.0 → mpytool-2.5.0}/pyproject.toml +0 -0
  41. {mpytool-2.4.0 → mpytool-2.5.0}/setup.cfg +0 -0
  42. {mpytool-2.4.0 → mpytool-2.5.0}/tests/__init__.py +0 -0
  43. {mpytool-2.4.0 → mpytool-2.5.0}/tests/test_cmd_cp.py +0 -0
  44. {mpytool-2.4.0 → mpytool-2.5.0}/tests/test_conn_socket.py +0 -0
  45. {mpytool-2.4.0 → mpytool-2.5.0}/tests/test_errors.py +0 -0
  46. {mpytool-2.4.0 → mpytool-2.5.0}/tests/test_helpers.py +0 -0
  47. {mpytool-2.4.0 → mpytool-2.5.0}/tests/test_integration.py +0 -0
  48. {mpytool-2.4.0 → mpytool-2.5.0}/tests/test_mount.py +0 -0
  49. {mpytool-2.4.0 → mpytool-2.5.0}/tests/test_mpy.py +0 -0
  50. {mpytool-2.4.0 → mpytool-2.5.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mpytool
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: MPY tool - manage files on devices running MicroPython
5
5
  Author-email: Pavel Revak <pavelrevak@gmail.com>
6
6
  License-Expression: MIT
@@ -33,6 +33,9 @@ It is an alternative to the official [mpremote](https://docs.micropython.org/en/
33
33
  bootloader entry
34
34
  - **Wipe device** - erase the whole filesystem and machine-reset
35
35
  for a clean slate (handy at the start of automated tests)
36
+ - **Deploy projects** - `sync` uploads a whole project to the device
37
+ using a `.mpyproject` config file (deploy map, excludes, `.mpy`
38
+ compilation)
36
39
  - **General-purpose serial terminal** - `repl` and `monitor` work
37
40
  with any serial device
38
41
  - **Mount local directory** - VFS mount (read-only or read-write)
@@ -168,6 +171,47 @@ Requires `mpy-cross` in PATH (`pip install mpy-cross`). If mpy-cross version dif
168
171
  from device, automatically uses `-b` flag to target the correct bytecode version.
169
172
  If `mpy-cross` is not found, falls back to uploading `.py` with a warning.
170
173
 
174
+ ### Deploy project (sync)
175
+
176
+ `sync` uploads a whole project to the device based on a `.mpyproject`
177
+ configuration file. It is the command-line counterpart of the deploy feature in
178
+ the [Sublime Text plugin](https://github.com/cortexm/mpytool-sublime) and shares
179
+ the same `.mpyproject` format. Unlike the plugin it is non-interactive: when more
180
+ than one device is connected, pass `-p` to pick the port.
181
+
182
+ ```
183
+ $ mpytool sync # deploy project in CWD (.mpyproject searched upward)
184
+ $ mpytool sync ~/projects/blink # deploy a project in another directory
185
+ $ mpytool -p /dev/ttyACM0 sync # pick port explicitly (multiple devices)
186
+ $ mpytool sync -- reset -- monitor # deploy, then reset and watch output
187
+ ```
188
+
189
+ The `.mpyproject` file (JSON, trailing commas tolerated) describes what to upload:
190
+
191
+ ```json
192
+ {
193
+ "name": "blink",
194
+ "compile": true,
195
+ "deploy": {
196
+ "": ["./"],
197
+ "/lib/": ["../shared/utils.py"],
198
+ "/main.py": "src/main_dev.py"
199
+ },
200
+ "exclude": ["*.bak", "tests"]
201
+ }
202
+ ```
203
+
204
+ - `deploy` maps a device destination to its source(s), relative to the project
205
+ root. A key of `""` or one ending in `/` is a **directory** (value is a list of
206
+ sources); any other key is a **rename** (value is a single source file).
207
+ Defaults to `{"": ["./"]}` (whole project to root) when omitted.
208
+ - `compile: true` compiles `.py` to `.mpy` (same as `cp -m`).
209
+ - `exclude` adds ignore patterns on top of the defaults (`__pycache__`, `*.pyc`,
210
+ dotfiles). Combined with global `-e` patterns.
211
+
212
+ The connection fields (`port`, `address`) used by the Sublime plugin are ignored
213
+ by the CLI — use the global `-p` / `-a` options instead.
214
+
171
215
  ### Move/rename on device
172
216
  ```
173
217
  $ mpytool mv :/old.py :/new.py # rename file
@@ -18,6 +18,9 @@ It is an alternative to the official [mpremote](https://docs.micropython.org/en/
18
18
  bootloader entry
19
19
  - **Wipe device** - erase the whole filesystem and machine-reset
20
20
  for a clean slate (handy at the start of automated tests)
21
+ - **Deploy projects** - `sync` uploads a whole project to the device
22
+ using a `.mpyproject` config file (deploy map, excludes, `.mpy`
23
+ compilation)
21
24
  - **General-purpose serial terminal** - `repl` and `monitor` work
22
25
  with any serial device
23
26
  - **Mount local directory** - VFS mount (read-only or read-write)
@@ -153,6 +156,47 @@ Requires `mpy-cross` in PATH (`pip install mpy-cross`). If mpy-cross version dif
153
156
  from device, automatically uses `-b` flag to target the correct bytecode version.
154
157
  If `mpy-cross` is not found, falls back to uploading `.py` with a warning.
155
158
 
159
+ ### Deploy project (sync)
160
+
161
+ `sync` uploads a whole project to the device based on a `.mpyproject`
162
+ configuration file. It is the command-line counterpart of the deploy feature in
163
+ the [Sublime Text plugin](https://github.com/cortexm/mpytool-sublime) and shares
164
+ the same `.mpyproject` format. Unlike the plugin it is non-interactive: when more
165
+ than one device is connected, pass `-p` to pick the port.
166
+
167
+ ```
168
+ $ mpytool sync # deploy project in CWD (.mpyproject searched upward)
169
+ $ mpytool sync ~/projects/blink # deploy a project in another directory
170
+ $ mpytool -p /dev/ttyACM0 sync # pick port explicitly (multiple devices)
171
+ $ mpytool sync -- reset -- monitor # deploy, then reset and watch output
172
+ ```
173
+
174
+ The `.mpyproject` file (JSON, trailing commas tolerated) describes what to upload:
175
+
176
+ ```json
177
+ {
178
+ "name": "blink",
179
+ "compile": true,
180
+ "deploy": {
181
+ "": ["./"],
182
+ "/lib/": ["../shared/utils.py"],
183
+ "/main.py": "src/main_dev.py"
184
+ },
185
+ "exclude": ["*.bak", "tests"]
186
+ }
187
+ ```
188
+
189
+ - `deploy` maps a device destination to its source(s), relative to the project
190
+ root. A key of `""` or one ending in `/` is a **directory** (value is a list of
191
+ sources); any other key is a **rename** (value is a single source file).
192
+ Defaults to `{"": ["./"]}` (whole project to root) when omitted.
193
+ - `compile: true` compiles `.py` to `.mpy` (same as `cp -m`).
194
+ - `exclude` adds ignore patterns on top of the defaults (`__pycache__`, `*.pyc`,
195
+ dotfiles). Combined with global `-e` patterns.
196
+
197
+ The connection fields (`port`, `address`) used by the Sublime plugin are ignored
198
+ by the CLI — use the global `-p` / `-a` options instead.
199
+
156
200
  ### Move/rename on device
157
201
  ```
158
202
  $ mpytool mv :/old.py :/new.py # rename file
@@ -269,6 +269,13 @@ _mpytool() {
269
269
  fi
270
270
  fi
271
271
  ;;
272
+ sync)
273
+ # Optional local project directory, -- anytime
274
+ if [[ $pos -eq 2 ]]; then
275
+ _files -/
276
+ fi
277
+ compadd -- '--'
278
+ ;;
272
279
  exec)
273
280
  # 1 code string, -- after it
274
281
  [[ $nargs -ge 1 ]] && compadd -- '--'
@@ -272,6 +272,13 @@ _mpytool() {
272
272
  fi
273
273
  fi
274
274
  ;;
275
+ sync)
276
+ # Optional local project directory, -- for chaining
277
+ if [[ $pos -eq 2 ]]; then
278
+ COMPREPLY=($(compgen -d -- "$cur"))
279
+ fi
280
+ [[ -z "$cur" || "--" == "$cur"* ]] && COMPREPLY+=("--")
281
+ ;;
275
282
  exec)
276
283
  # 1 code string, -- after it
277
284
  [[ $nargs -ge 1 && ( -z "$cur" || "--" == "$cur"* ) ]] && COMPREPLY+=("--")
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.4.0'
22
- __version_tuple__ = version_tuple = (2, 4, 0)
21
+ __version__ = version = '2.5.0'
22
+ __version_tuple__ = version_tuple = (2, 5, 0)
23
23
 
24
- __commit_id__ = commit_id = 'gf2ecc3ebb'
24
+ __commit_id__ = commit_id = 'ga70fd9bfe'
@@ -4,7 +4,9 @@ import argparse as _argparse
4
4
  import datetime as _datetime
5
5
  import fnmatch as _fnmatch
6
6
  import importlib.metadata as _metadata
7
+ import json as _json
7
8
  import os as _os
9
+ import re as _re
8
10
  import shlex as _shlex
9
11
  import subprocess as _subprocess
10
12
  import sys as _sys
@@ -30,7 +32,7 @@ else:
30
32
 
31
33
  # Order of commands in help and completion
32
34
  _CMD_ORDER = [
33
- 'ls', 'tree', 'cat', 'cp', 'mv', 'mkdir', 'rm', 'pwd', 'cd', 'path',
35
+ 'ls', 'tree', 'cat', 'cp', 'sync', 'mv', 'mkdir', 'rm', 'pwd', 'cd', 'path',
34
36
  'stop', 'reset', 'wipe', 'monitor', 'repl', 'exec', 'run', 'edit', 'info',
35
37
  'rtc', 'flash', 'mount', 'ln', 'speedtest', 'sleep', 'ports',
36
38
  ]
@@ -57,6 +59,48 @@ def _remote_basename(path):
57
59
  return path.rstrip('/').split('/')[-1]
58
60
 
59
61
 
62
+ def _find_mpyproject(path):
63
+ """Find .mpyproject searching upward from path (file or directory).
64
+
65
+ Mirrors the Sublime plugin: walks parent directories, skipping any
66
+ .backup directory (which may hold a .mpyproject downloaded from a
67
+ device). Returns the path to .mpyproject or None.
68
+ """
69
+ path = _os.path.abspath(path)
70
+ directory = path if _os.path.isdir(path) else _os.path.dirname(path)
71
+ while directory and directory != _os.path.dirname(directory):
72
+ if _os.path.basename(directory) == '.backup':
73
+ directory = _os.path.dirname(directory)
74
+ continue
75
+ candidate = _os.path.join(directory, '.mpyproject')
76
+ if _os.path.exists(candidate):
77
+ return candidate
78
+ directory = _os.path.dirname(directory)
79
+ return None
80
+
81
+
82
+ def _load_mpyproject(path):
83
+ """Load .mpyproject JSON config.
84
+
85
+ Tolerates trailing commas (common, not strictly valid JSON), like the
86
+ Sublime plugin. Returns the parsed dict, {} for an empty file, or None
87
+ on a JSON syntax error.
88
+ """
89
+ try:
90
+ with open(path, 'r', encoding='utf-8') as file:
91
+ content = file.read().strip()
92
+ except OSError:
93
+ return {}
94
+ if not content:
95
+ return {}
96
+ # Remove trailing commas before } or ] (tolerated, not valid JSON)
97
+ content = _re.sub(r',\s*([}\]])', r'\1', content)
98
+ try:
99
+ return _json.loads(content)
100
+ except _json.JSONDecodeError:
101
+ return None
102
+
103
+
60
104
  def _normalize_path_arg(value):
61
105
  """Argparse type for path arguments - normalize Windows backslashes"""
62
106
  if _sys.platform == 'win32' and value.startswith(':'):
@@ -785,6 +829,77 @@ class MpyTool():
785
829
  else:
786
830
  self.verbose(" reset", 1)
787
831
 
832
+ def cmd_sync(self, project_dir=None):
833
+ """Deploy a project to the device per its .mpyproject config.
834
+
835
+ Reads the deploy map / exclude / compile settings from .mpyproject
836
+ (found in project_dir or the current directory, searched upward) and
837
+ runs the corresponding cp operations. Non-interactive: the connection
838
+ comes from the global -p / -a options.
839
+ """
840
+ start = project_dir if project_dir else '.'
841
+ if not _os.path.exists(start):
842
+ raise ParamsError(f"path not found: {start}")
843
+ mpyproject = _find_mpyproject(start)
844
+ if not mpyproject:
845
+ raise ParamsError(
846
+ f"no .mpyproject found in '{start}' or any parent directory")
847
+ config = _load_mpyproject(mpyproject)
848
+ if config is None:
849
+ raise ParamsError(f"invalid JSON in {mpyproject}")
850
+ root = _os.path.dirname(mpyproject) or '.'
851
+ name = config.get('name') or _os.path.basename(_os.path.abspath(root))
852
+ self.verbose(f"SYNC: {name} ({mpyproject})", 1)
853
+ # Merge config exclude patterns into the active exclude set
854
+ for pattern in config.get('exclude', []):
855
+ self._exclude_dirs.add(pattern)
856
+ compile_mpy = config.get('compile', False)
857
+ # deploy map: {"": ["./"], "/lib/": ["../x.py"]}
858
+ # dir mode (dest is "" or ends with "/"): sources is a list
859
+ # rename mode (dest does not end with "/"): sources is a single string
860
+ deploy = config.get('deploy') or {"": ["./"]}
861
+ cp_jobs = self._build_sync_jobs(deploy, mpyproject)
862
+ if not cp_jobs:
863
+ raise ParamsError(
864
+ f"nothing to deploy, configure 'deploy' in {mpyproject}")
865
+ # Sources are relative to the project root - run cp from there
866
+ prev_cwd = _os.getcwd()
867
+ try:
868
+ _os.chdir(root)
869
+ for sources, dest in cp_jobs:
870
+ cp_args = ['-m'] if compile_mpy else []
871
+ cp_args.extend(sources)
872
+ cp_args.append(dest)
873
+ self.cmd_cp(cp_args)
874
+ finally:
875
+ _os.chdir(prev_cwd)
876
+
877
+ @staticmethod
878
+ def _build_sync_jobs(deploy, mpyproject):
879
+ """Translate a .mpyproject deploy map into (sources, dest) cp jobs"""
880
+ jobs = []
881
+ for dest, sources in deploy.items():
882
+ if not sources:
883
+ continue
884
+ is_dir_mode = dest == "" or dest.endswith("/")
885
+ if is_dir_mode and not isinstance(sources, list):
886
+ raise ParamsError(
887
+ f"deploy '{dest}' is a directory, sources must be a list "
888
+ f"(in {mpyproject})")
889
+ if not is_dir_mode and not isinstance(sources, str):
890
+ raise ParamsError(
891
+ f"deploy '{dest}' is a rename, sources must be a single "
892
+ f"string (in {mpyproject})")
893
+ if is_dir_mode:
894
+ # Drop empties, dedupe while preserving order
895
+ srcs = list(dict.fromkeys(s for s in sources if s))
896
+ else:
897
+ srcs = [sources]
898
+ if not srcs:
899
+ continue
900
+ jobs.append((srcs, f":{dest}"))
901
+ return jobs
902
+
788
903
  @command('ls', 'List files and directories on device.')
789
904
  @argument('path', nargs='?', default=':', metavar='remote',
790
905
  type=_normalize_path_arg, help='device path (default: CWD)')
@@ -1186,6 +1301,14 @@ class MpyTool():
1186
1301
  self.cmd_cp(commands)
1187
1302
  commands.clear()
1188
1303
 
1304
+ @command('sync', 'Deploy project to device per .mpyproject config.')
1305
+ @argument('dir', nargs='?', metavar='project_dir',
1306
+ help='project directory (default: current, searched upward)')
1307
+ def _dispatch_sync(self, commands, is_last_group):
1308
+ args = _make_parser(self._dispatch_sync).parse_args(commands)
1309
+ commands.clear()
1310
+ self.cmd_sync(args.dir)
1311
+
1189
1312
  @command('mv', 'Move or rename files on device.')
1190
1313
  @argument('paths', nargs='+', metavar='remote',
1191
1314
  type=_normalize_path_arg, help='source(s) and destination (all with : prefix)')
@@ -1469,7 +1592,7 @@ class MpyTool():
1469
1592
  _COMMANDS = frozenset({
1470
1593
  'ls', 'tree', 'cat', 'mkdir', 'rm', 'pwd', 'cd', 'path',
1471
1594
  'reset', 'wipe', 'stop', 'monitor', 'repl', 'exec', 'run', 'edit', 'info',
1472
- 'rtc', 'flash', 'sleep', 'cp', 'mv', 'mount', 'ln', 'speedtest',
1595
+ 'rtc', 'flash', 'sleep', 'cp', 'sync', 'mv', 'mount', 'ln', 'speedtest',
1473
1596
  'ports', '_paths', '_commands', '_options', '_args',
1474
1597
  })
1475
1598
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mpytool
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: MPY tool - manage files on devices running MicroPython
5
5
  Author-email: Pavel Revak <pavelrevak@gmail.com>
6
6
  License-Expression: MIT
@@ -33,6 +33,9 @@ It is an alternative to the official [mpremote](https://docs.micropython.org/en/
33
33
  bootloader entry
34
34
  - **Wipe device** - erase the whole filesystem and machine-reset
35
35
  for a clean slate (handy at the start of automated tests)
36
+ - **Deploy projects** - `sync` uploads a whole project to the device
37
+ using a `.mpyproject` config file (deploy map, excludes, `.mpy`
38
+ compilation)
36
39
  - **General-purpose serial terminal** - `repl` and `monitor` work
37
40
  with any serial device
38
41
  - **Mount local directory** - VFS mount (read-only or read-write)
@@ -168,6 +171,47 @@ Requires `mpy-cross` in PATH (`pip install mpy-cross`). If mpy-cross version dif
168
171
  from device, automatically uses `-b` flag to target the correct bytecode version.
169
172
  If `mpy-cross` is not found, falls back to uploading `.py` with a warning.
170
173
 
174
+ ### Deploy project (sync)
175
+
176
+ `sync` uploads a whole project to the device based on a `.mpyproject`
177
+ configuration file. It is the command-line counterpart of the deploy feature in
178
+ the [Sublime Text plugin](https://github.com/cortexm/mpytool-sublime) and shares
179
+ the same `.mpyproject` format. Unlike the plugin it is non-interactive: when more
180
+ than one device is connected, pass `-p` to pick the port.
181
+
182
+ ```
183
+ $ mpytool sync # deploy project in CWD (.mpyproject searched upward)
184
+ $ mpytool sync ~/projects/blink # deploy a project in another directory
185
+ $ mpytool -p /dev/ttyACM0 sync # pick port explicitly (multiple devices)
186
+ $ mpytool sync -- reset -- monitor # deploy, then reset and watch output
187
+ ```
188
+
189
+ The `.mpyproject` file (JSON, trailing commas tolerated) describes what to upload:
190
+
191
+ ```json
192
+ {
193
+ "name": "blink",
194
+ "compile": true,
195
+ "deploy": {
196
+ "": ["./"],
197
+ "/lib/": ["../shared/utils.py"],
198
+ "/main.py": "src/main_dev.py"
199
+ },
200
+ "exclude": ["*.bak", "tests"]
201
+ }
202
+ ```
203
+
204
+ - `deploy` maps a device destination to its source(s), relative to the project
205
+ root. A key of `""` or one ending in `/` is a **directory** (value is a list of
206
+ sources); any other key is a **rename** (value is a single source file).
207
+ Defaults to `{"": ["./"]}` (whole project to root) when omitted.
208
+ - `compile: true` compiles `.py` to `.mpy` (same as `cp -m`).
209
+ - `exclude` adds ignore patterns on top of the defaults (`__pycache__`, `*.pyc`,
210
+ dotfiles). Combined with global `-e` patterns.
211
+
212
+ The connection fields (`port`, `address`) used by the Sublime plugin are ignored
213
+ by the CLI — use the global `-p` / `-a` options instead.
214
+
171
215
  ### Move/rename on device
172
216
  ```
173
217
  $ mpytool mv :/old.py :/new.py # rename file
@@ -32,6 +32,8 @@ mpytool.egg-info/SOURCES.txt
32
32
  mpytool.egg-info/dependency_links.txt
33
33
  mpytool.egg-info/entry_points.txt
34
34
  mpytool.egg-info/requires.txt
35
+ mpytool.egg-info/scm_file_list.json
36
+ mpytool.egg-info/scm_version.json
35
37
  mpytool.egg-info/top_level.txt
36
38
  tests/__init__.py
37
39
  tests/test_cli.py
@@ -0,0 +1,43 @@
1
+ {
2
+ "files": [
3
+ ".github/workflows/integration.yml",
4
+ ".github/workflows/publish.yml",
5
+ ".github/workflows/tests.yml",
6
+ ".gitignore",
7
+ "LICENSE",
8
+ "README.md",
9
+ "README_API.md",
10
+ "README_bench.md",
11
+ "README_mpremote.md",
12
+ "completions/_mpytool",
13
+ "completions/mpytool.bash",
14
+ "mpytool/__init__.py",
15
+ "mpytool/cmd_cp.py",
16
+ "mpytool/conn.py",
17
+ "mpytool/conn_serial.py",
18
+ "mpytool/conn_socket.py",
19
+ "mpytool/logger.py",
20
+ "mpytool/mount.py",
21
+ "mpytool/mpy.py",
22
+ "mpytool/mpy_comm.py",
23
+ "mpytool/mpy_cross.py",
24
+ "mpytool/mpytool.py",
25
+ "mpytool/speedtest.py",
26
+ "mpytool/terminal.py",
27
+ "mpytool/terminal_unix.py",
28
+ "mpytool/terminal_win.py",
29
+ "mpytool/utils.py",
30
+ "pyproject.toml",
31
+ "tests/__init__.py",
32
+ "tests/test_cli.py",
33
+ "tests/test_cmd_cp.py",
34
+ "tests/test_conn_socket.py",
35
+ "tests/test_errors.py",
36
+ "tests/test_helpers.py",
37
+ "tests/test_integration.py",
38
+ "tests/test_mount.py",
39
+ "tests/test_mpy.py",
40
+ "tests/test_mpytool.py",
41
+ "tests/test_utils.py"
42
+ ]
43
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "2.5.0",
3
+ "distance": 0,
4
+ "node": "ga70fd9bfe1287f18ee4c15bc39424b0c206920ec",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-06-26"
8
+ }
@@ -100,6 +100,19 @@ class TestCliHelp(unittest.TestCase):
100
100
  self.assertEqual(result.returncode, 0)
101
101
  self.assertIn(':', result.stdout)
102
102
 
103
+ def test_sync_help(self):
104
+ """mpytool sync --help shows sync usage and project_dir argument"""
105
+ result = run_mpytool('sync', '--help')
106
+ self.assertEqual(result.returncode, 0)
107
+ self.assertIn('project_dir', result.stdout)
108
+
109
+ def test_sync_no_mpyproject(self):
110
+ """mpytool sync in a directory without .mpyproject errors clearly"""
111
+ with tempfile.TemporaryDirectory() as tmp:
112
+ result = run_mpytool('sync', tmp)
113
+ self.assertNotEqual(result.returncode, 0)
114
+ self.assertIn('.mpyproject', result.stdout + result.stderr)
115
+
103
116
  def test_mv_help(self):
104
117
  """mpytool mv --help shows mv usage"""
105
118
  result = run_mpytool('mv', '--help')
@@ -1004,5 +1004,137 @@ class TestRtcCommand(unittest.TestCase):
1004
1004
  mock_dt.datetime.now.assert_called_with(mock_dt.timezone.utc)
1005
1005
 
1006
1006
 
1007
+ class TestSyncCommand(unittest.TestCase):
1008
+ """Tests for sync command (deploy project per .mpyproject)"""
1009
+
1010
+ def setUp(self):
1011
+ self.tmpdir = tempfile.mkdtemp()
1012
+ self.tool = MpyTool(Mock(), verbose=None)
1013
+ # Capture cp invocations and the CWD active at call time
1014
+ self.cp_calls = []
1015
+ self.tool.cmd_cp = lambda args: self.cp_calls.append(
1016
+ (os.path.basename(os.getcwd()), list(args)))
1017
+
1018
+ def tearDown(self):
1019
+ shutil.rmtree(self.tmpdir, ignore_errors=True)
1020
+
1021
+ def _write_project(self, content, subdir=''):
1022
+ """Create a .mpyproject with given content, return its directory"""
1023
+ root = os.path.join(self.tmpdir, subdir) if subdir else self.tmpdir
1024
+ os.makedirs(root, exist_ok=True)
1025
+ with open(os.path.join(root, '.mpyproject'), 'w') as file:
1026
+ file.write(content)
1027
+ return root
1028
+
1029
+ def test_default_deploy(self):
1030
+ """Missing deploy key -> default './' to root"""
1031
+ root = self._write_project('{"name": "demo"}')
1032
+ self.tool.cmd_sync(root)
1033
+ self.assertEqual([args for _, args in self.cp_calls], [['./', ':']])
1034
+
1035
+ def test_deploy_dir_and_rename(self):
1036
+ """Dir mode list + rename mode string translate to cp jobs"""
1037
+ root = self._write_project(
1038
+ '{"deploy": {"/lib/": ["a.py", "b.py"], "/main.py": "src/m.py"}}')
1039
+ self.tool.cmd_sync(root)
1040
+ self.assertEqual(
1041
+ [args for _, args in self.cp_calls],
1042
+ [['a.py', 'b.py', ':/lib/'], ['src/m.py', ':/main.py']])
1043
+
1044
+ def test_compile_flag(self):
1045
+ """compile:true prepends -m to every cp job"""
1046
+ root = self._write_project(
1047
+ '{"compile": true, "deploy": {"": ["./"]}}')
1048
+ self.tool.cmd_sync(root)
1049
+ self.assertEqual(self.cp_calls[0][1], ['-m', './', ':'])
1050
+
1051
+ def test_dedupe_sources(self):
1052
+ """Duplicate sources in dir mode are removed, order preserved"""
1053
+ root = self._write_project(
1054
+ '{"deploy": {"": ["./", "./", "x.py"]}}')
1055
+ self.tool.cmd_sync(root)
1056
+ self.assertEqual(self.cp_calls[0][1], ['./', 'x.py', ':'])
1057
+
1058
+ def test_exclude_merged(self):
1059
+ """exclude patterns from config merge into instance excludes"""
1060
+ root = self._write_project(
1061
+ '{"deploy": {"": ["./"]}, "exclude": ["*.bak", "tmp"]}')
1062
+ self.tool.cmd_sync(root)
1063
+ self.assertIn('*.bak', self.tool._exclude_dirs)
1064
+ self.assertIn('tmp', self.tool._exclude_dirs)
1065
+
1066
+ def test_trailing_comma_tolerated(self):
1067
+ """JSON with trailing commas still loads"""
1068
+ root = self._write_project('{"deploy": {"": ["./"],},}')
1069
+ self.tool.cmd_sync(root)
1070
+ self.assertEqual(self.cp_calls[0][1], ['./', ':'])
1071
+
1072
+ def test_runs_from_project_root(self):
1073
+ """cp jobs run with project root as CWD; CWD restored afterwards"""
1074
+ root = self._write_project('{"deploy": {"": ["./"]}}', subdir='myproj')
1075
+ prev = os.getcwd()
1076
+ self.tool.cmd_sync(root)
1077
+ self.assertEqual(self.cp_calls[0][0], 'myproj')
1078
+ self.assertEqual(os.getcwd(), prev)
1079
+
1080
+ def test_search_upward(self):
1081
+ """.mpyproject is found by searching parent directories"""
1082
+ root = self._write_project('{"deploy": {"": ["./"]}}', subdir='app')
1083
+ deep = os.path.join(root, 'pkg', 'mod')
1084
+ os.makedirs(deep)
1085
+ self.tool.cmd_sync(deep)
1086
+ self.assertEqual(self.cp_calls[0][0], 'app')
1087
+
1088
+ def test_no_mpyproject(self):
1089
+ """Missing .mpyproject raises ParamsError"""
1090
+ empty = os.path.join(self.tmpdir, 'empty')
1091
+ os.makedirs(empty)
1092
+ with self.assertRaises(ParamsError) as ctx:
1093
+ self.tool.cmd_sync(empty)
1094
+ self.assertIn('.mpyproject', str(ctx.exception))
1095
+
1096
+ def test_path_not_found(self):
1097
+ """Nonexistent path raises ParamsError"""
1098
+ with self.assertRaises(ParamsError) as ctx:
1099
+ self.tool.cmd_sync(os.path.join(self.tmpdir, 'nope'))
1100
+ self.assertIn('not found', str(ctx.exception))
1101
+
1102
+ def test_invalid_json(self):
1103
+ """Malformed JSON raises ParamsError"""
1104
+ root = self._write_project('{ not valid }')
1105
+ with self.assertRaises(ParamsError) as ctx:
1106
+ self.tool.cmd_sync(root)
1107
+ self.assertIn('invalid JSON', str(ctx.exception))
1108
+
1109
+ def test_dir_mode_string_invalid(self):
1110
+ """Directory dest with string source is a config error"""
1111
+ root = self._write_project('{"deploy": {"/lib/": "single.py"}}')
1112
+ with self.assertRaises(ParamsError) as ctx:
1113
+ self.tool.cmd_sync(root)
1114
+ self.assertIn('must be a list', str(ctx.exception))
1115
+
1116
+ def test_rename_mode_list_invalid(self):
1117
+ """Rename dest with list source is a config error"""
1118
+ root = self._write_project('{"deploy": {"/main.py": ["a.py"]}}')
1119
+ with self.assertRaises(ParamsError) as ctx:
1120
+ self.tool.cmd_sync(root)
1121
+ self.assertIn('must be a single string', str(ctx.exception))
1122
+
1123
+ def test_nothing_to_deploy(self):
1124
+ """Deploy entries with only empty sources -> nothing to deploy"""
1125
+ root = self._write_project('{"deploy": {"/lib/": []}}')
1126
+ with self.assertRaises(ParamsError) as ctx:
1127
+ self.tool.cmd_sync(root)
1128
+ self.assertIn('nothing to deploy', str(ctx.exception))
1129
+
1130
+ def test_dispatch_sync(self):
1131
+ """_dispatch_sync parses optional directory and consumes commands"""
1132
+ root = self._write_project('{"deploy": {"": ["./"]}}')
1133
+ commands = [root]
1134
+ self.tool._dispatch_sync(commands, True)
1135
+ self.assertEqual(commands, [])
1136
+ self.assertEqual(self.cp_calls[0][1], ['./', ':'])
1137
+
1138
+
1007
1139
  if __name__ == "__main__":
1008
1140
  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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes