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.
- {mpytool-2.4.0 → mpytool-2.5.0}/PKG-INFO +45 -1
- {mpytool-2.4.0 → mpytool-2.5.0}/README.md +44 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/completions/_mpytool +7 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/completions/mpytool.bash +7 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/_version.py +3 -3
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/mpytool.py +125 -2
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool.egg-info/PKG-INFO +45 -1
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool.egg-info/SOURCES.txt +2 -0
- mpytool-2.5.0/mpytool.egg-info/scm_file_list.json +43 -0
- mpytool-2.5.0/mpytool.egg-info/scm_version.json +8 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/tests/test_cli.py +13 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/tests/test_mpytool.py +132 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/.github/workflows/integration.yml +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/.github/workflows/publish.yml +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/.github/workflows/tests.yml +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/.gitignore +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/LICENSE +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/README_API.md +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/README_bench.md +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/README_mpremote.md +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/__init__.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/cmd_cp.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/conn.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/conn_serial.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/conn_socket.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/logger.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/mount.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/mpy.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/mpy_comm.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/mpy_cross.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/speedtest.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/terminal.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/terminal_unix.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/terminal_win.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool/utils.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool.egg-info/dependency_links.txt +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool.egg-info/entry_points.txt +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool.egg-info/requires.txt +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/mpytool.egg-info/top_level.txt +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/pyproject.toml +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/setup.cfg +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/tests/__init__.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/tests/test_cmd_cp.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/tests/test_conn_socket.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/tests/test_errors.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/tests/test_helpers.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/tests/test_integration.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/tests/test_mount.py +0 -0
- {mpytool-2.4.0 → mpytool-2.5.0}/tests/test_mpy.py +0 -0
- {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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (2,
|
|
21
|
+
__version__ = version = '2.5.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 5, 0)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
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.
|
|
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
|
+
}
|
|
@@ -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
|
|
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
|