├── .github └── workflows │ └── python-publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── flowchart_dev.svg ├── flowchart_sync.svg ├── flowchart_sync_files.svg ├── mpbridge ├── __init__.py ├── bridge.py ├── handler.py ├── ignore.py ├── serial_transport.py ├── shell.py └── utils.py └── pyproject.toml /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: '3.11' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install -U pip 24 | pip install -U build 25 | - name: Build package 26 | run: python -m build 27 | - name: Publish package 28 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 29 | with: 30 | user: __token__ 31 | password: ${{ secrets.PYPI_API_TOKEN }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | .idea/ 131 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Amirreza Hamzavi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📂 MPBridge ![Python Version](https://img.shields.io/badge/Python-3.8%20or%20later-blue?style=flat-square) ![PyPI Version](https://img.shields.io/pypi/v/mpbridge?label=PyPI%20Version&style=flat-square) ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/AmirHmZz/mpbridge/python-publish.yml?label=Builds&style=flat-square) 2 | 3 | CLI tool to synchronise and manage files on a [MicroPython](https://github.com/micropython/micropython) 4 | running device. 5 | 6 | ## 📥 Installation 7 | 8 | `mpbridge` must be installed with `sudo` or `administrator` level of permission in order to be accessible from terminal: 9 | 10 | * **Windows :** Open `cmd.exe` or `powershell.exe` as administrator and run `pip install -U mpbridge`. 11 | * **Linux / MacOS :** Run `sudo pip install -U mpbridge`. 12 | 13 | ## 🔎 How to use 14 | 15 | You can use `mpbridge` in several ways based on your needs: 16 | 17 | #### ⚜️ Bridge Mode 18 | 19 | * Run `mpbridge bridge [PORT]`. 20 | * This mode copies all files and folders from your `MicroPython` board into a temporary directory on your local device 21 | and listens for any filesystem events on local directory to apply them on your board. It keeps raw repl open, so you 22 | cannot use serial port in other applications simultaneously. 23 | 24 | #### ⚜️ Sync Directory 25 | 26 | ![](flowchart_sync.svg) 27 | 28 | ![](flowchart_sync_files.svg) 29 | 30 | * Run `mpbridge sync [PORT] [DIR_PARH]`. 31 | * This command syncs a specified local directory with a `MicroPython` board. The sync process will push 32 | all modified files and folders into board and also pull changes from board and exits. 33 | * If a conflict occurs, `mpbridge` will choose the **local version** of file automatically and 34 | overwrites it on connected board. 35 | * You can speed up syncing with `--use-hashtable` which allows mpbridge to cache calculated hashes 36 | on remote device. By using hashtable you won't be able to track files which are modified by remote 37 | device because hash is calculated at uploading stage. 38 | 39 | #### ⚜️ Development Mode 40 | 41 | ![](flowchart_dev.svg) 42 | 43 | * Run `mpbridge dev [PORT] [DIR_PARH]`. 44 | * This mode repeats a loop of tasks in specified directory on `MicroPython` device as below: 45 | * _Sync_ → _Prompt to enter REPL_ → _Clean Sync_ → _Start MicroPython REPL_ 46 | * You can also disable prompt with `--no-prompt` option to speed things: 47 | * _Clean Sync_ → _Start MicroPython REPL_ 48 | * This mode is useful when you keep switching between different tools to flash and run new codes repeatedly. 49 | You can specify your project directory as `DIR_PATH` and `mpbridge` will take care of changes when you are developing 50 | your project in your desired IDE. You can switch to `MicroPython REPL` anytime you wish to run the updated code on 51 | your board. 52 | * Default to current path of terminal if not set the `DIR_PATH`. 53 | * Automatic reset before entering MicroPython REPL can be enabled with `--auto-reset` option which can be set to 54 | `soft` (soft reset) or `hard` (hard reset). 55 | * You can also boost sync speed with `--use-hashtable` But you won't be able to track files which are modified 56 | by remote device itself. Use this option if only host is modifying files. 57 | * In order to compile source files before uploading to remote device, you should set `mpy-cross` executable path using 58 | `--mpy-cross-path` flag. Visit [Micropython](https://github.com/micropython/micropython/tree/master/mpy-cross) 59 | repository to build `mpy-cross` compiler for your platform. 60 | 61 | #### ⚜️ Delete all files 62 | 63 | * Run `mpbridge clear [PORT]`. 64 | * This command deletes all files and directories from `MicroPython` board and exits. 65 | 66 | #### ⚜️ List all connected devices 67 | 68 | * Run `mpbridge list`. 69 | * This command lists all connected devices. 70 | 71 | **Note** : `[PORT]` can be the **full port path** or one of the **short forms** below : 72 | 73 | * `c[n]` for `COM[n]` (`c3` is equal to `COM3`). 74 | * `u[n]` for `/dev/ttyUSB[n]` (`u3` is equal to `/dev/ttyUSB3`). 75 | * `a[n]` for `/dev/ttyACM[n]` (`a3` is equal to `/dev/ttyACM3`). 76 | 77 | ## 👀 Ignore files 78 | 79 | You can inform `mpbridge` to ignore syncing specific files or directories. This is useful when you don't want to sync 80 | some directories like `.git/` or `venv/` with your board. To use this feature create a file named `mpbridge.ignore` in 81 | your project directory and specify list of files and directories: 82 | 83 | ``` 84 | .git/ 85 | venv/ 86 | tests/test_1.py 87 | tests/test_2.py 88 | ``` 89 | 90 | * `mpbridge.ignore` syntax is not as same as `.gitignore` files. 91 | * At this time `mpbridge.ignore` only supports specifying file and directory paths directly. 92 | * You should add a **slash** at the end of directory names: `dir1/`. 93 | * Performing `sync` with `--dry-run` flag can be helpful for debugging your ignore files. 94 | 95 | ## ✅ Supported platforms 96 | 97 | - Windows 98 | - MacOS 99 | - Linux 100 | - FreeBSD/BSD 101 | 102 | ## 📦 Dependencies 103 | 104 | - Python 3.8 or above. 105 | - [mpremote](https://pypi.org/project/mpremote/) >= 0.4.0 106 | - [watchdog](https://pypi.org/project/watchdog/) >= 2.2.0 107 | - [click](https://pypi.org/project/click/) >= 7.0 108 | - [colorama](https://pypi.org/project/colorama/) >= 4.0 109 | -------------------------------------------------------------------------------- /flowchart_dev.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
mpbridge dev  
mpbridge dev  
Port must be set.
Port must be set.
Set the path you want to work on.
Default to current path of terminal if not set.
Set the path you want to work on....
[PORT]
[PORT]
[DIR_PATH]
[DIR_PATH]
[OPTIONS]
[OPTIONS]
--auto-reset
--auto-reset
soft
soft
hard
hard
soft
soft
Or
Or
Default to not reset if not set.
Default to not reset if not...
--no-prompt
--no-prompt
Disable prompt.
Auto Clean Sync & enter REPL.
Disable prompt....
Start 
Start 
yes
yes
no
no
--no-prompt
--no-prompt
Clean Sync files
Clean Sync files
yes
yes
no
no
--auto-reset
--auto-reset
reset device
reset device
Sync files
Sync files
     
keyboard Enter
...
REPL
REPL
     
keyboard ctrl+]
...
     
keyboard ctrl+C
...
Exit
Exit
set [PORT]
set [DIR_PATH]
set [PORT]...
Text is not SVG - cannot display
5 | -------------------------------------------------------------------------------- /flowchart_sync.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
mpbridge sync  
mpbridge sync  
Port must be set.
Port must be set.
Set the path you want to work on.
Default to current path of terminal if not set.
Set the path you want to work on....
[PORT]
[PORT]
[DIR_PATH]
[DIR_PATH]
[OPTIONS]
[OPTIONS]
--clean or -c
--clean or -c
Clean Sync files.
Clean Sync files.
Start 
Start 
yes
yes
no
no
--clean
or
 -c
--clean...
Clean Sync files
Clean Sync files
Sync files
Sync files
Exit
Exit
set [PORT]
set [DIR_PATH]
set [PORT]...
Text is not SVG - cannot display
5 | -------------------------------------------------------------------------------- /flowchart_sync_files.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
Start
Start
get file and dir list from local and
device
get file and...
yes
yes
no
no
Clean Sync files
Clean Sync files
no
no
yes
yes
exist in the device
exist in the de...
no
no
exist in the local
exist in the lo...
no
no
yes
yes
all files and dirs checked
all files and dirs...
hash
hash
yes
yes
delete file or dir on device
delete file o...
copy file or dir from device to local
copy file or dir fro...
no
no
same
same
Exit
Exit
copy file or dir from local to device 
copy file or dir fro...
no
no
yes
yes
exist in the mpbridge.ignore
exist in the mpbridg...
Sync files
or
Clean Sync files
Sync files...
Create an mpbridge.ignore in [DIR_PATH], and add the files or dirs you want to ignore, separating each item with a newline.
Create an mpbridge.ignore in [DIR_P...
Text is not SVG - cannot display
-------------------------------------------------------------------------------- /mpbridge/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.6.1" 2 | -------------------------------------------------------------------------------- /mpbridge/bridge.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import shutil 4 | import subprocess 5 | import tempfile 6 | import time 7 | from argparse import Namespace 8 | from typing import Optional 9 | 10 | import serial.tools.list_ports 11 | from colorama import Fore 12 | from mpremote.main import State, argparse_repl 13 | from watchdog.observers import Observer 14 | 15 | from . import utils 16 | from .handler import EventHandler 17 | from .ignore import IgnoreStorage 18 | from .serial_transport import ExtendedSerialTransport 19 | 20 | 21 | def start_bridge_mode(port: str): 22 | port = utils.port_abbreviation(port) 23 | print(Fore.YELLOW, "- Starting bridge mode on", port) 24 | utils.reset_term_color() 25 | st = ExtendedSerialTransport(device=port) 26 | st.enter_raw_repl_verbose() 27 | 28 | with tempfile.TemporaryDirectory( 29 | prefix=utils.get_temp_dirname_prefix(port) 30 | ) as tmp_dir_path: 31 | st.copy_all(dest_dir_path=tmp_dir_path) 32 | print(Fore.YELLOW, "- Started bridge mode in", tmp_dir_path) 33 | print(Fore.YELLOW, "- Use Ctrl-C to terminate the bridge") 34 | utils.reset_term_color() 35 | observer = Observer() 36 | observer.schedule( 37 | EventHandler(st=st, base_path=tmp_dir_path), tmp_dir_path, recursive=True 38 | ) 39 | observer.start() 40 | utils.open_dir(tmp_dir_path) 41 | try: 42 | while True: 43 | time.sleep(1) 44 | except KeyboardInterrupt: 45 | observer.stop() 46 | st.exit_raw_repl_verbose() 47 | observer.join() 48 | 49 | 50 | def sync( 51 | port: str, 52 | path: str, 53 | clean: bool, 54 | dry_run: bool, 55 | push_only: bool, 56 | use_hashtable: bool, 57 | ): 58 | port = utils.port_abbreviation(port) 59 | print(Fore.YELLOW, f"- Syncing files on {port} with {path}") 60 | utils.reset_term_color() 61 | st = ExtendedSerialTransport(device=port) 62 | st.enter_raw_repl_verbose() 63 | if clean: 64 | print(Fore.YELLOW, f"Removing absent files from {port}") 65 | st.delete_absent_items(dir_path=path, dry=dry_run) 66 | st.sync_with_dir( 67 | dir_path=path, dry=dry_run, push=push_only, use_hashtable=use_hashtable 68 | ) 69 | st.exit_raw_repl_verbose() 70 | 71 | 72 | def start_dev_mode( 73 | port: str, 74 | path: str, 75 | auto_reset: str, 76 | no_prompt: bool, 77 | use_hashtable: bool, 78 | mpy_cross_path: Optional[str], 79 | ): 80 | path = utils.replace_backslashes(path) 81 | port = utils.port_abbreviation(port) 82 | print(Fore.YELLOW, f"- Syncing files on {port} with {path}") 83 | utils.reset_term_color() 84 | 85 | while True: 86 | if mpy_cross_path is None: 87 | _dev_mode_iter( 88 | port=port, 89 | path=path, 90 | auto_reset=auto_reset, 91 | no_prompt=no_prompt, 92 | use_hashtable=use_hashtable, 93 | ) 94 | 95 | else: 96 | with tempfile.TemporaryDirectory( 97 | prefix=utils.get_temp_dirname_prefix(port) 98 | ) as tmp_dir_path: 99 | ignore = IgnoreStorage(dir_path=path) 100 | ldirs, lfiles = utils.recursive_list_dir(path) 101 | 102 | for ldir in ldirs.keys(): 103 | os.mkdir(tmp_dir_path + ldir) 104 | for lfile_rel, lfile_abs in lfiles.items(): 105 | if not ignore.match_file(lfile_rel): 106 | if lfile_rel != "/main.py" and lfile_rel.endswith(".py"): 107 | subprocess.run( 108 | [ 109 | mpy_cross_path, 110 | "-o", 111 | tmp_dir_path + lfile_rel[:-3] + ".mpy", 112 | lfile_abs, 113 | ], 114 | capture_output=False, 115 | ) 116 | else: 117 | shutil.copyfile(src=lfile_abs, dst=tmp_dir_path + lfile_rel) 118 | _dev_mode_iter( 119 | port=port, 120 | path=tmp_dir_path, 121 | auto_reset=auto_reset, 122 | no_prompt=no_prompt, 123 | use_hashtable=use_hashtable, 124 | ) 125 | 126 | 127 | def _dev_mode_iter( 128 | port: str, 129 | path: str, 130 | auto_reset: str, 131 | no_prompt: bool, 132 | use_hashtable: bool, 133 | ): 134 | st = ExtendedSerialTransport(device=port) 135 | st.enter_raw_repl_verbose() 136 | if not no_prompt: 137 | print(Fore.YELLOW, "- Sync files") 138 | st.sync_with_dir(dir_path=path, use_hashtable=use_hashtable) 139 | print( 140 | Fore.LIGHTWHITE_EX 141 | + " ? Press [Enter] to Clean Sync & Enter REPL\n" 142 | + " Press [Ctrl + C] to exit ", 143 | end="", 144 | ) 145 | utils.reset_term_color() 146 | input() 147 | print(Fore.YELLOW, "- Clean Sync files") 148 | st.delete_absent_items(dir_path=path) 149 | st.sync_with_dir(dir_path=path, use_hashtable=use_hashtable) 150 | if auto_reset is None: 151 | st.exit_raw_repl() 152 | st.close() 153 | elif auto_reset == "hard": 154 | st.verbose_hard_reset() 155 | st.close() 156 | time.sleep(1) 157 | elif auto_reset == "soft": 158 | st.exit_raw_repl() 159 | st.verbose_soft_reset() 160 | st.close() 161 | start_repl(port) 162 | 163 | 164 | def clear(port: str): 165 | port = utils.port_abbreviation(port) 166 | st = ExtendedSerialTransport(device=port) 167 | st.enter_raw_repl_verbose() 168 | st.clear_all() 169 | st.exit_raw_repl_verbose() 170 | 171 | 172 | def start_repl(port: str): 173 | from mpremote.commands import do_connect, do_disconnect 174 | from mpremote.repl import do_repl 175 | 176 | print(Fore.LIGHTMAGENTA_EX, "R Entering REPL using mpremote") 177 | utils.reset_term_color() 178 | port = utils.port_abbreviation(port) 179 | state = State() 180 | do_connect(state, Namespace(device=[port], next_command=[])) 181 | do_repl(state, argparse_repl().parse_args([])) 182 | do_disconnect(state) 183 | print("\n" + Fore.LIGHTMAGENTA_EX, "R Exiting REPL") 184 | utils.reset_term_color() 185 | 186 | 187 | def list_devices(): 188 | ports = sorted(serial.tools.list_ports.comports()) 189 | if ports: 190 | for i, port in enumerate(ports): 191 | print( 192 | Fore.LIGHTYELLOW_EX, 193 | "{}. {} {} {:04x}:{:04x} {} {}".format( 194 | i + 1, 195 | port.device, 196 | port.serial_number or "null", 197 | port.vid if isinstance(port.vid, int) else 0, 198 | port.pid if isinstance(port.pid, int) else 0, 199 | port.manufacturer or "null", 200 | port.product or "null", 201 | ), 202 | ) 203 | else: 204 | print(Fore.LIGHTYELLOW_EX, "Couldn't find any connected devices") 205 | utils.reset_term_color() 206 | -------------------------------------------------------------------------------- /mpbridge/handler.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from watchdog.events import ( 4 | FileSystemEventHandler, 5 | DirCreatedEvent, 6 | FileCreatedEvent, 7 | FileModifiedEvent, 8 | DirMovedEvent, 9 | FileMovedEvent, 10 | DirDeletedEvent, 11 | FileDeletedEvent, 12 | DirModifiedEvent, 13 | ) 14 | 15 | from .serial_transport import ExtendedSerialTransport 16 | from .utils import remove_prefix, replace_backslashes 17 | 18 | 19 | class EventHandler(FileSystemEventHandler): 20 | def __init__(self, st: ExtendedSerialTransport, base_path: str) -> None: 21 | self.st = st 22 | self.base_path = replace_backslashes(base_path) 23 | 24 | def dispatch(self, event): 25 | if replace_backslashes(event.src_path) != self.base_path: 26 | super().dispatch(event) 27 | 28 | def on_moved(self, event: Union[DirMovedEvent, FileMovedEvent]): 29 | src_path = remove_prefix(replace_backslashes(event.src_path), self.base_path) 30 | dest_path = replace_backslashes(event.dest_path) 31 | rel_dest_path = remove_prefix(dest_path, self.base_path) 32 | if ".goutputstream-" in src_path: 33 | self.st.fs_verbose_put(dest_path, rel_dest_path) 34 | else: 35 | self.st.fs_verbose_rename(src_path, rel_dest_path) 36 | super().on_moved(event) 37 | 38 | def on_created(self, event: Union[DirCreatedEvent, FileCreatedEvent]): 39 | src_path = replace_backslashes(event.src_path) 40 | rel_src_path = remove_prefix(src_path, self.base_path) 41 | if ".goutputstream-" in src_path: 42 | return 43 | if event.is_directory: 44 | self.st.fs_verbose_mkdir(rel_src_path) 45 | else: 46 | self.st.fs_verbose_put(src_path, rel_src_path) 47 | super().on_created(event) 48 | 49 | def on_deleted(self, event: Union[DirDeletedEvent, FileDeletedEvent]): 50 | src_path = remove_prefix(replace_backslashes(event.src_path), self.base_path) 51 | try: 52 | self.st.fs_verbose_rm(src_path) 53 | except: 54 | self.st.fs_verbose_rmdir(src_path) 55 | super().on_deleted(event) 56 | 57 | def on_modified(self, event: Union[FileModifiedEvent, DirModifiedEvent]): 58 | src_path = replace_backslashes(event.src_path) 59 | rel_src_path = remove_prefix(src_path, self.base_path) 60 | if ".goutputstream-" in src_path: 61 | return 62 | if not event.is_directory: 63 | self.st.fs_verbose_put(src_path, rel_src_path) 64 | -------------------------------------------------------------------------------- /mpbridge/ignore.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from . import utils 4 | 5 | 6 | class IgnoreStorage: 7 | def __init__(self, dir_path: str): 8 | self._dirs = [] 9 | self._files = [] 10 | self._root_dir = dir_path.rstrip("/") 11 | self.load() 12 | 13 | def load(self): 14 | for subdir, dirs, files in os.walk(self._root_dir, followlinks=True): 15 | subdir = utils.replace_backslashes(subdir) 16 | for file in files: 17 | if file == "mpbridge.ignore": 18 | self._load_ignore_file(abs_dir=subdir.rstrip("/")) 19 | 20 | def _load_ignore_file(self, abs_dir: str): 21 | rel_dir = utils.remove_prefix(abs_dir, self._root_dir) 22 | try: 23 | with open(f"{abs_dir}/mpbridge.ignore", "r") as file: 24 | for line in utils.replace_backslashes(file.read()).split("\n"): 25 | if not "".join(line.split()): 26 | continue 27 | line = "/" + line.lstrip("/") 28 | if line.endswith("/"): 29 | self._dirs.append(f"{rel_dir}{line.rstrip('/')}/") 30 | else: 31 | self._files.append(f"{rel_dir}{line}") 32 | except FileNotFoundError: 33 | pass 34 | except: 35 | raise RuntimeError("Invalid mpbridge.ignore file") 36 | 37 | def match_dir(self, rel_path: str) -> bool: 38 | rel_path = rel_path.rstrip("/") + "/" 39 | for ignored_dir in self._dirs: 40 | if rel_path.startswith(ignored_dir) or rel_path == ignored_dir: 41 | return True 42 | return False 43 | 44 | def match_file(self, rel_path: str) -> bool: 45 | if rel_path.endswith("mpbridge.hashtable"): 46 | return True 47 | for ignored_dir in self._dirs: 48 | if rel_path.startswith(ignored_dir): 49 | return True 50 | return rel_path in self._files 51 | -------------------------------------------------------------------------------- /mpbridge/serial_transport.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import os 3 | from contextlib import suppress 4 | 5 | from colorama import Fore 6 | from mpremote.transport_serial import SerialTransport, TransportError 7 | 8 | from . import utils 9 | from .ignore import IgnoreStorage 10 | from .utils import unpack_length_prefixed 11 | 12 | RECURSIVE_LS = """ 13 | from os import ilistdir 14 | from gc import collect 15 | def iter_dir(dir_path): 16 | collect() 17 | for item in ilistdir(dir_path): 18 | if dir_path[-1] != "/": 19 | dir_path += "/" 20 | idir = f"{dir_path}{item[0]}" 21 | if item[1] == 0x8000: 22 | yield idir, True, item[3] 23 | else: 24 | yield from iter_dir(idir) 25 | yield idir, False, 0 26 | for item in iter_dir("/"): 27 | print(item, end=",") 28 | """ 29 | 30 | SHA1_FUNC = """ 31 | from hashlib import sha1 32 | from gc import collect 33 | b = bytearray(1024) 34 | mv = memoryview(b) 35 | def get_sha1(path): 36 | h = sha1() 37 | with open(path,"rb") as f: 38 | while s := f.readinto(b): 39 | h.update(mv[:s]) 40 | collect() 41 | return h.digest() 42 | """ 43 | 44 | 45 | def generate_buffer(): 46 | buf = bytearray() 47 | 48 | def repr_consumer(b): 49 | nonlocal buf 50 | buf.extend(b.replace(b"\x04", b"")) 51 | 52 | return buf, repr_consumer 53 | 54 | 55 | class ExtendedSerialTransport(SerialTransport): 56 | def fs_recursive_listdir(self): 57 | buf, consumer = generate_buffer() 58 | self.exec(RECURSIVE_LS, data_consumer=consumer) 59 | dirs = {} 60 | files = {} 61 | for item in eval(f'[{buf.decode("utf-8")}]'): 62 | if item[1]: 63 | files[item[0]] = item[2] 64 | else: 65 | dirs[item[0]] = item[2] 66 | return dirs, files 67 | 68 | def fs_verbose_get(self, src, dest, chunk_size=1024, dry: bool = False): 69 | def print_prog(written, total): 70 | utils.print_progress_bar( 71 | iteration=written, 72 | total=total, 73 | decimals=0, 74 | prefix=f"{Fore.LIGHTCYAN_EX} ↓ Getting {src}".ljust(60), 75 | suffix="Complete", 76 | length=15, 77 | ) 78 | 79 | if not dry: 80 | self.fs_get(src, dest, chunk_size=chunk_size, progress_callback=print_prog) 81 | print_prog(1, 1) 82 | utils.reset_term_color(new_line=True) 83 | 84 | def fs_verbose_put(self, src, dest, chunk_size=1024, dry: bool = False): 85 | def print_prog(written, total): 86 | utils.print_progress_bar( 87 | iteration=written, 88 | total=total, 89 | decimals=0, 90 | prefix=f"{Fore.LIGHTYELLOW_EX} ↑ Putting {dest}".ljust(60), 91 | suffix="Complete", 92 | length=15, 93 | ) 94 | 95 | if not dry: 96 | self.fs_put(src, dest, chunk_size=chunk_size, progress_callback=print_prog) 97 | print_prog(1, 1) 98 | utils.reset_term_color(new_line=True) 99 | 100 | def fs_verbose_rename(self, src, dest, dry: bool = False): 101 | if not dry: 102 | buf, consumer = generate_buffer() 103 | self.exec( 104 | f'from os import rename; rename("{src}", "{dest}")', 105 | data_consumer=consumer, 106 | ) 107 | print(Fore.LIGHTBLUE_EX, "O Rename", src, "→", dest) 108 | utils.reset_term_color() 109 | 110 | def fs_verbose_mkdir(self, dir_path, dry: bool = False): 111 | if not dry: 112 | self.fs_mkdir(dir_path) 113 | print(Fore.LIGHTGREEN_EX, "* Created", dir_path) 114 | utils.reset_term_color() 115 | 116 | def fs_verbose_rm(self, src, dry: bool = False): 117 | if not dry: 118 | self.fs_rm(src) 119 | print(Fore.LIGHTRED_EX, "✕ Removed", src) 120 | utils.reset_term_color() 121 | 122 | def fs_verbose_rmdir(self, dir_path, dry: bool = False): 123 | try: 124 | if not dry: 125 | self.fs_rmdir(dir_path) 126 | except TransportError: 127 | print( 128 | Fore.RED, 129 | "E Cannot remove directory", 130 | dir_path, 131 | "as it might be mounted", 132 | ) 133 | else: 134 | print(Fore.LIGHTRED_EX, "✕ Removed", dir_path) 135 | utils.reset_term_color() 136 | 137 | def copy_all(self, dest_dir_path): 138 | rdirs, rfiles = self.fs_recursive_listdir() 139 | for rdir in rdirs: 140 | os.makedirs(dest_dir_path + rdir, exist_ok=True) 141 | for rfile in rfiles: 142 | self.fs_verbose_get(rfile, dest_dir_path + rfile, chunk_size=256) 143 | print(Fore.LIGHTGREEN_EX, "✓ Copied all files successfully") 144 | utils.reset_term_color() 145 | 146 | def sync_with_dir( 147 | self, 148 | dir_path, 149 | dry: bool = False, 150 | push: bool = False, 151 | use_hashtable: bool = False, 152 | ): 153 | print(Fore.YELLOW, "- Syncing") 154 | hashtable = self._get_hash_table() if use_hashtable else {} 155 | self.exec_raw_no_follow(SHA1_FUNC) 156 | dir_path = utils.replace_backslashes(dir_path) 157 | rdirs, rfiles = self.fs_recursive_listdir() 158 | ldirs, lfiles = utils.recursive_list_dir(dir_path) 159 | ignore = IgnoreStorage(dir_path=dir_path) 160 | if (not dry) and (not push): 161 | for rdir in rdirs.keys(): 162 | if rdir not in ldirs and not ignore.match_dir(rdir): 163 | os.makedirs(dir_path + rdir, exist_ok=True) 164 | for ldir in ldirs.keys(): 165 | if ldir not in rdirs and not ignore.match_dir(ldir): 166 | self.fs_verbose_mkdir(ldir, dry=dry) 167 | for lfile_rel, lfiles_abs in lfiles.items(): 168 | if ignore.match_file(lfile_rel): 169 | continue 170 | if rfiles.get(lfile_rel, None) == os.path.getsize(lfiles_abs): 171 | if lfile_rel in hashtable: 172 | sha1 = hashtable[lfile_rel] 173 | else: 174 | sha1 = self.get_sha1(lfile_rel) 175 | hashtable[lfile_rel] = sha1 176 | if sha1 == utils.get_file_sha1(lfiles_abs): 177 | continue 178 | hashtable[lfile_rel] = utils.get_file_sha1(lfiles_abs) 179 | self.fs_verbose_put(lfiles_abs, lfile_rel, chunk_size=256, dry=dry) 180 | if not push: 181 | for rfile, rsize in rfiles.items(): 182 | if ignore.match_file(rfile): 183 | continue 184 | if rfile not in lfiles: 185 | self.fs_verbose_get( 186 | rfile, dir_path + rfile, chunk_size=256, dry=dry 187 | ) 188 | self._write_hash_table(hashtable) 189 | print(Fore.LIGHTGREEN_EX, "✓ Files synced successfully") 190 | 191 | def delete_absent_items(self, dir_path, dry: bool = False): 192 | dir_path = utils.replace_backslashes(dir_path) 193 | rdirs, rfiles = self.fs_recursive_listdir() 194 | ldirs, lfiles = utils.recursive_list_dir(dir_path) 195 | ignore = IgnoreStorage(dir_path=dir_path) 196 | for rfile, rsize in rfiles.items(): 197 | if not ignore.match_file(rfile) and rfile not in lfiles: 198 | self.fs_verbose_rm(rfile, dry=dry) 199 | for rdir in rdirs.keys(): 200 | if not ignore.match_dir(rdir) and rdir not in ldirs: 201 | # There might be ignored files in folders 202 | with suppress(Exception): 203 | self.fs_verbose_rmdir(rdir, dry=dry) 204 | 205 | def clear_all(self): 206 | print(Fore.YELLOW, "- Deleting all files from MicroPython board") 207 | rdirs, rfiles = self.fs_recursive_listdir() 208 | for rfile in rfiles.keys(): 209 | self.fs_verbose_rm(rfile) 210 | for rdir in rdirs.keys(): 211 | self.fs_verbose_rmdir(rdir) 212 | print(Fore.LIGHTGREEN_EX, "✓ Deleted all files from MicroPython board") 213 | 214 | def enter_raw_repl_verbose(self, soft_reset=True): 215 | print(Fore.YELLOW, "- Entering raw repl") 216 | utils.reset_term_color() 217 | return self.enter_raw_repl(soft_reset) 218 | 219 | def exit_raw_repl_verbose(self): 220 | print(Fore.YELLOW, "- Exiting raw repl") 221 | utils.reset_term_color() 222 | self.exit_raw_repl() 223 | 224 | def get_sha1(self, file_path): 225 | return eval(self.eval(f'get_sha1("{file_path}")').decode("utf-8")) 226 | 227 | def verbose_hard_reset(self): 228 | self.exec_raw_no_follow("from machine import reset; reset()") 229 | self.serial.close() 230 | print(Fore.LIGHTGREEN_EX, "✓ Hard reset board successfully") 231 | utils.reset_term_color() 232 | 233 | def verbose_soft_reset(self): 234 | self.serial.write(b"\x04") 235 | print(Fore.LIGHTGREEN_EX, "✓ Soft reset board successfully") 236 | 237 | def _get_hash_table(self) -> dict: 238 | with suppress(Exception): 239 | return dict( 240 | map( 241 | lambda t: (t[0].decode("utf-8"), t[1]), 242 | itertools.batched( 243 | unpack_length_prefixed( 244 | "B", self.fs_readfile("mpbridge.hashtable") 245 | ), 246 | 2, 247 | ), 248 | ) 249 | ) 250 | return {} 251 | 252 | def _write_hash_table(self, hash_table: dict[str, bytes]): 253 | # TODO Ignore update if not changed 254 | self.fs_writefile( 255 | "mpbridge.hashtable", 256 | b"".join( 257 | map( 258 | lambda i: len(i[0]).to_bytes(length=1, byteorder="big") 259 | + i[0].encode("utf-8") 260 | + len(i[1]).to_bytes(length=1, byteorder="big") 261 | + i[1], 262 | hash_table.items(), 263 | ) 264 | ), 265 | ) 266 | print(Fore.LIGHTGREEN_EX, "✓ Updated hashtable") 267 | -------------------------------------------------------------------------------- /mpbridge/shell.py: -------------------------------------------------------------------------------- 1 | from email.policy import default 2 | from typing import Literal, Optional 3 | 4 | import click 5 | import colorama 6 | 7 | from . import bridge 8 | 9 | colorama.init() 10 | 11 | 12 | @click.group() 13 | def main(): 14 | pass 15 | 16 | 17 | @main.command("bridge", short_help="Start bridge mode") 18 | @click.argument("port") 19 | def bridge_mode(port: str): 20 | """Starts bridge mode on [PORT] 21 | 22 | [PORT] can be full path or : 23 | 24 | a[n] connect to serial port "/dev/ttyACM[n]" 25 | 26 | u[n] connect to serial port "/dev/ttyUSB[n]" 27 | 28 | c[n] connect to serial port "COM[n]" 29 | """ 30 | 31 | bridge.start_bridge_mode(port=port) 32 | 33 | 34 | @main.command("sync", short_help="Sync files with a directory") 35 | @click.argument("port") 36 | @click.argument( 37 | "dir_path", 38 | type=click.Path(exists=True, file_okay=False, dir_okay=True, resolve_path=True), 39 | default="", 40 | ) 41 | @click.option( 42 | "--clean", 43 | "-c", 44 | is_flag=True, 45 | help="Execute Clean Sync", 46 | ) 47 | @click.option( 48 | "--push-only", 49 | "-p", 50 | is_flag=True, 51 | help="Only push changes without pulling anything from remote device", 52 | ) 53 | @click.option( 54 | "--dry-run", 55 | "-d", 56 | is_flag=True, 57 | help="Test Sync command without performing any actions", 58 | ) 59 | @click.option( 60 | "--use-hashtable", 61 | is_flag=True, 62 | default=False, 63 | help="Use hashtable to speedup syncing process", 64 | ) 65 | def sync( 66 | port: str, 67 | dir_path: str, 68 | clean: bool, 69 | dry_run: bool, 70 | push_only: bool, 71 | use_hashtable: bool, 72 | ): 73 | """Sync files of on [PORT] in specified directory [DIR_PATH] 74 | 75 | If [DIR_PATH] is not set, it defaults to the current path 76 | 77 | [PORT] can be full path or : 78 | 79 | a[n] connect to serial port "/dev/ttyACM[n]" 80 | 81 | u[n] connect to serial port "/dev/ttyUSB[n]" 82 | 83 | c[n] connect to serial port "COM[n]" 84 | 85 | Sync files: 86 | 87 | Pull the files that are not in the local but exist in the device to the local. 88 | 89 | Push files that are not in the device but exist locally to the device. 90 | 91 | Check the hash of the files both in the local and the device, 92 | 93 | and then push the different files from the local to the device. 94 | 95 | Clean Sync files: 96 | 97 | Delete files from the device that do not exist locally but exist on the device. 98 | 99 | Push files that are not in the device but exist locally to the device. 100 | 101 | Check the hash of the files both in the local and the device, 102 | 103 | and then push the different files from the local to the device. 104 | """ 105 | bridge.sync( 106 | port=port, 107 | path=dir_path, 108 | clean=clean, 109 | dry_run=dry_run, 110 | push_only=push_only, 111 | use_hashtable=use_hashtable, 112 | ) 113 | 114 | 115 | @main.command("dev", short_help="Start development mode") 116 | @click.argument("port") 117 | @click.argument( 118 | "dir_path", 119 | type=click.Path(exists=True, file_okay=False, dir_okay=True, resolve_path=True), 120 | default="", 121 | ) 122 | @click.option( 123 | "--auto-reset", 124 | help="Enables auto reset before entering REPL", 125 | type=click.Choice(["soft", "hard"], case_sensitive=False), 126 | ) 127 | @click.option( 128 | "--no-prompt", 129 | is_flag=True, 130 | help="Disables prompt, auto Clean Sync & enter REPL", 131 | ) 132 | @click.option( 133 | "--use-hashtable", 134 | is_flag=True, 135 | default=False, 136 | help="Use hashtable to speedup syncing process", 137 | ) 138 | @click.option( 139 | "--mpy-cross-path", 140 | type=click.Path(exists=True, file_okay=True, dir_okay=False, resolve_path=True), 141 | default=None, 142 | help="Path to mpy-cross executable in order to compile source files", 143 | ) 144 | def dev( 145 | port: str, 146 | dir_path: str, 147 | auto_reset: Literal["soft", "hard"], 148 | no_prompt: bool, 149 | use_hashtable: bool, 150 | mpy_cross_path: Optional[str], 151 | ): 152 | """Start development mode on [PORT] in specified directory [DIR_PATH] 153 | 154 | If [DIR_PATH] is not set, it defaults to the current path 155 | 156 | [PORT] can be full path or : 157 | 158 | a[n] connect to serial port "/dev/ttyACM[n]" 159 | 160 | u[n] connect to serial port "/dev/ttyUSB[n]" 161 | 162 | c[n] connect to serial port "COM[n]" 163 | 164 | Workflow: 165 | 166 | Sync files → if keyboard `Enter` → Clean Sync files → REPL → if keyboard `ctrl+]` → back loop 167 | 168 | if --no-prompt → Clean Sync files → REPL → if keyboard `ctrl+]` → back loop 169 | 170 | When not in the REPL, keyboard `ctrl+C` exits the workflow 171 | 172 | Sync files: 173 | 174 | Pull the files that are not in the local but exist in the device to the local. 175 | 176 | Push files that are not in the device but exist locally to the device. 177 | 178 | Check the hash of the files both in the local and the device, 179 | 180 | and then push the different files from the local to the device. 181 | 182 | Clean Sync files: 183 | 184 | Delete files from the device that do not exist locally but exist on the device. 185 | 186 | Push files that are not in the device but exist locally to the device. 187 | 188 | Check the hash of the files both in the local and the device, 189 | 190 | and then push the different files from the local to the device. 191 | """ 192 | bridge.start_dev_mode( 193 | port=port, 194 | path=dir_path, 195 | auto_reset=auto_reset, 196 | no_prompt=no_prompt, 197 | use_hashtable=use_hashtable, 198 | mpy_cross_path=mpy_cross_path, 199 | ) 200 | 201 | 202 | @main.command("clear", short_help="Delete all files from MicroPython device") 203 | @click.argument("port") 204 | def clear(port: str): 205 | """Delete all files from MicroPython device connected to [PORT] 206 | 207 | [PORT] can be full path or : 208 | 209 | a[n] connect to serial port "/dev/ttyACM[n]" 210 | 211 | u[n] connect to serial port "/dev/ttyUSB[n]" 212 | 213 | c[n] connect to serial port "COM[n]" 214 | """ 215 | bridge.clear(port=port) 216 | 217 | 218 | @main.command("list", short_help="List available devices") 219 | def list_devices(): 220 | """List available devices 221 | 222 | [device] [serial_number] [vid]:[pid] [manufacturer] [product] 223 | """ 224 | bridge.list_devices() 225 | -------------------------------------------------------------------------------- /mpbridge/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import hashlib 4 | import os 5 | import re 6 | import struct 7 | import subprocess 8 | import sys 9 | 10 | from colorama import Style 11 | 12 | 13 | def remove_prefix(string: str, prefix: str) -> str: 14 | if string.startswith(prefix): 15 | return string[len(prefix) :] 16 | return string 17 | 18 | 19 | def remove_suffix(string: str, suffix: str) -> str: 20 | if string.endswith(suffix): 21 | return string[: -len(suffix)] 22 | return string 23 | 24 | 25 | def replace_backslashes(path: str) -> str: 26 | return path.replace("\\", "/") 27 | 28 | 29 | def open_dir(filename): 30 | if sys.platform == "win32": 31 | os.startfile(filename) 32 | else: 33 | opener = "open" if sys.platform == "darwin" else "xdg-open" 34 | subprocess.call([opener, filename]) 35 | 36 | 37 | def print_progress_bar( 38 | iteration, 39 | total, 40 | prefix="", 41 | suffix="", 42 | decimals=1, 43 | length=100, 44 | fill="█", 45 | print_end="\r", 46 | ): 47 | percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total))) 48 | filled_length = int(length * iteration // total) 49 | bar = fill * filled_length + "-" * (length - filled_length) 50 | print(f"\r{prefix} |{bar}| {percent}% {suffix}", end=print_end) 51 | 52 | 53 | def reset_term_color(new_line: bool = False): 54 | print(Style.RESET_ALL, end="\n" if new_line else "") 55 | 56 | 57 | def port_abbreviation(port: str): 58 | if re.fullmatch(r"^[auc]\d{1,2}$", port): 59 | if port[0] == "c": 60 | port = "COM" + port[1:] 61 | elif port[0] == "a": 62 | port = "/dev/ttyACM" + port[1:] 63 | elif port[0] == "u": 64 | port = "/dev/ttyUSB" + port[1:] 65 | return port 66 | 67 | 68 | def removeprefix(string, prefix): 69 | if not (isinstance(string, str) and isinstance(prefix, str)): 70 | raise TypeError("Param value type error") 71 | if string.startswith(prefix): 72 | return string[len(prefix) :] 73 | return string 74 | 75 | 76 | def recursive_list_dir(path: str) -> tuple[dict[str, str], dict[str, str]]: 77 | out_dirs = {} 78 | out_files = {} 79 | for abs_dir, dirs, files in os.walk(replace_backslashes(path), followlinks=True): 80 | abs_dir = replace_backslashes(abs_dir) 81 | rel_dir = removeprefix(abs_dir, path) 82 | for dir_name in dirs: 83 | out_dirs[f"{rel_dir}/{dir_name}"] = f"{abs_dir}/{dir_name}" 84 | for file_name in files: 85 | out_files[f"{rel_dir}/{file_name}"] = f"{abs_dir}/{file_name}" 86 | return out_dirs, out_files 87 | 88 | 89 | def get_file_sha1(path: str) -> bytes: 90 | with open(path, "rb") as file: 91 | return hashlib.sha1(file.read()).digest() 92 | 93 | 94 | def get_temp_dirname_prefix(full_port: str): 95 | return ( 96 | "mpbridge-" 97 | + remove_prefix(full_port, "/dev/").replace("tty", "").replace("/", "-") 98 | + "-" 99 | ) 100 | 101 | 102 | def unpack_length_prefixed(size_header_fmt: str, data: bytes | bytearray | memoryview): 103 | size_header_size = struct.calcsize(size_header_fmt) 104 | 105 | i = 0 106 | while i < len(data): 107 | size = struct.unpack(size_header_fmt, data[i : i + size_header_size])[0] 108 | i += size_header_size 109 | 110 | yield data[i : i + size] 111 | i += size 112 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [project] 9 | name = "mpbridge" 10 | version = "1.6.1" 11 | description = "File System Bridge to facilitate working with files on Micropython devices" 12 | license = { file = "LICENSE" } 13 | keywords = [ 14 | "micropython", 15 | "filemanager", 16 | "file-manager", 17 | "filesystem", 18 | "sync", 19 | "synchronize", 20 | ] 21 | requires-python = ">=3.9" 22 | dependencies = [ 23 | "watchdog>=2.2.0", 24 | "colorama>=0.4.0", 25 | "mpremote>=1.21.0, <=1.22.0", 26 | "click" 27 | ] 28 | classifiers = [ 29 | "Programming Language :: Python", 30 | "Operating System :: OS Independent" 31 | ] 32 | 33 | [project.scripts] 34 | mpbridge = "mpbridge.shell:main" 35 | 36 | [project.urls] 37 | homepage = "https://github.com/AmirHmZz/mpbridge" 38 | 39 | [project.readme] 40 | file = "README.md" 41 | content-type = "text/markdown" 42 | --------------------------------------------------------------------------------