diff --git a/ROADMAP.md b/ROADMAP.md index 75002f5..8b4c149 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,5 +15,7 @@ if something is broken or missing for you, open an updater would fetch and install a new version for you. - **Progress inside a large file.** A long transfer of one big file shows only elapsed time; a percentage and the bytes moved would show how far along it is. +- **`omip` in the editor and the browser.** **Install Package** and package search + offer `mpftp omip` for OmniPython packages, as the command line does. - **Translations.** The browser interface and the extension in your language, with English as the fallback for anything not yet translated. diff --git a/cli/src/mpftp/cli.py b/cli/src/mpftp/cli.py index 3e7931f..4bcbe5a 100755 --- a/cli/src/mpftp/cli.py +++ b/cli/src/mpftp/cli.py @@ -1831,6 +1831,26 @@ def cmd_mip(ns: argparse.Namespace) -> None: client.close() +def cmd_omip(ns: argparse.Namespace) -> None: + if not ns.packages and not ns.self_file: + raise SystemExit("mpftp omip: name a package, or pass --self to put omip.py on the board") + client, mode = get_client() + try: + ensure_device(client, ns.device, ns.baud) + params: dict[str, Any] = { + "packages": ns.packages, + "target": ns.target, + "mpy": not ns.no_mpy, + "omip_file": ns.self_file, + } + if ns.index: + params["index"] = ns.index + out(client.call("omip_install", params)) + finally: + if mode.startswith("sidecar"): + client.close() + + def cmd_circup(ns: argparse.Namespace) -> None: client, mode = get_client() try: @@ -2824,6 +2844,31 @@ def build_parser() -> argparse.ArgumentParser: ) mip.set_defaults(func=cmd_mip) + omip = sub.add_parser( + "omip", + parents=[device_opts], + help="Install OmniPython packages with what they need from micropython-lib, or omip.py itself", + ) + omip.add_argument("packages", nargs="*") + omip.add_argument( + "--self", + dest="self_file", + action="store_true", + help="Copy omip.py to the board, so it can install for itself (MicroPython and CircuitPython)", + ) + omip.add_argument( + "--target", + default="/lib", + help="Board install directory (default: /lib)", + ) + omip.add_argument("--no-mpy", action="store_true") + omip.add_argument( + "--index", + default=None, + help="Look for the packages on this index alone (default: the OmniPython index, then micropython-lib)", + ) + omip.set_defaults(func=cmd_omip) + circ = sub.add_parser( "circup", parents=[device_opts], diff --git a/cli/src/mpftp/omip.py b/cli/src/mpftp/omip.py new file mode 100644 index 0000000..c583761 --- /dev/null +++ b/cli/src/mpftp/omip.py @@ -0,0 +1,129 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT + +"""Work out what an OmniPython package needs, from the computer. + +A board with no network can't run ``omip`` on itself, so the host does the same +walk and copies the files. A package is looked for on the OmniPython index and +then on micropython-lib, and whatever it names under ``upstream`` in its JSON +comes from micropython-lib. ``mip`` and mpremote ignore that key. + +Nothing here touches a board: `walk` returns the files, and the sidecar copies +them. +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from typing import Optional + +INDEX = "https://lib.omnipython.org" +UPSTREAM = "https://micropython.org/pi/v2" +#: The one file that gives a board ``omip.install``, the bootstrap ``mip`` uses too. +OMIP_FILE = "github:PyDevices/pydevices/lib/omip.py" + +_URL_PREFIXES = ("http://", "https://", "github:", "gitlab:", "codeberg:") +_HOSTS = { + "codeberg:": "codeberg.org/api/v1/repos/{}/{}/raw/{p}?ref={}", + "github:": "raw.githubusercontent.com/{}/{}/{}/{p}", + "gitlab:": "gitlab.com/{}/{}/-/raw/{}/{p}", +} +_TIMEOUT = 30 + + +class OmipError(RuntimeError): + """A package or a file it needs can't be had.""" + + +@dataclass +class PlannedFile: + path: str # relative to the install target + url: str + short_hash: Optional[str] = None # set when the file comes from an index + + +@dataclass +class Plan: + channel: str # "py", or the board's .mpy version + files: list[PlannedFile] = field(default_factory=list) + seen: set[str] = field(default_factory=set) + + +def fetch(url: str) -> Optional[bytes]: + """The bytes at ``url``, or None when the server says it isn't there.""" + # Some hosts refuse urllib's own user agent. + request = urllib.request.Request(url, headers={"User-Agent": "mpftp"}) + try: + with urllib.request.urlopen(request, timeout=_TIMEOUT) as response: + return response.read() + except urllib.error.HTTPError as e: + if e.code == 404: + return None + raise OmipError(f"HTTP {e.code} from {url}") from e + except (urllib.error.URLError, OSError) as e: + raise OmipError(f"Can't reach {url}: {getattr(e, 'reason', e)}") from e + + +def _is_url(spec: str) -> bool: + return spec.startswith(_URL_PREFIXES) + + +def _rewrite_url(url: str, version: Optional[str]) -> str: + for host, form in _HOSTS.items(): + if url.startswith(host): + parts = url[len(host) :].split("/") + return "https://" + form.format(parts[0], parts[1], version or "HEAD", p="/".join(parts[2:])) + return url + + +def _read_json(url: str) -> Optional[dict]: + body = fetch(url) + if body is None: + return None + try: + return json.loads(body) + except ValueError as e: + raise OmipError(f"Not JSON at {url}: {e}") from e + + +def walk(plan: Plan, spec: str, version: Optional[str], indexes: list[str]) -> None: + """Add the files of ``spec``, and of everything it needs, to ``plan``.""" + if spec in plan.seen: + return + plan.seen.add(spec) + source = indexes[0] + base = None + if _is_url(spec): + url = _rewrite_url(spec, version) + if spec.endswith((".py", ".mpy")): + plan.files.append(PlannedFile(spec.rsplit("/", 1)[-1], url)) + return + if not spec.endswith(".json"): + url = url.rstrip("/") + "/package.json" + body = _read_json(url) + if body is None: + raise OmipError(f"Package not found: {url}") + base = url.rpartition("/")[0] + else: + for index in indexes: + body = _read_json(f"{index}/package/{plan.channel}/{spec}/{version or 'latest'}.json") + if body is not None: + source = index + break + else: + raise OmipError(f"Package not found: {spec} (looked on {', '.join(indexes)})") + indexes = [source] + for path, short_hash in body.get("hashes", ()): + plan.files.append(PlannedFile(path, f"{source}/file/{short_hash[:2]}/{short_hash}", short_hash)) + for path, url in body.get("urls", ()): + if base and not _is_url(url): + url = f"{base}/{url}" + plan.files.append(PlannedFile(path, _rewrite_url(url, version))) + for dep, dep_version in body.get("deps", ()): + walk(plan, dep, dep_version, indexes) + for dep, dep_version in body.get("upstream", ()): + walk(plan, dep, dep_version, [UPSTREAM]) diff --git a/cli/src/mpftp/sidecar.py b/cli/src/mpftp/sidecar.py index f48acb3..45a7c01 100644 --- a/cli/src/mpftp/sidecar.py +++ b/cli/src/mpftp/sidecar.py @@ -3357,6 +3357,102 @@ def mip_install( elif not self.transport: self._repl_mode = False + def omip_install( + self, + packages: list[str], + target: Optional[str] = None, + mpy: bool = True, + index: Optional[str] = None, + omip_file: bool = False, + ) -> dict[str, Any]: + """Install packages the way ``omip`` does on a board, from the host. + + Finds each package on the OmniPython index and then micropython-lib, or + on ``index`` alone, and follows its ``upstream`` key to micropython-lib. + Everything is downloaded before the first write, so a package that can't + be found leaves the board as it was. ``omip_file`` also copies ``omip.py`` + itself, which is the one thing this does on CircuitPython. + """ + from . import omip + + resolved_target = (target or "/lib").rstrip("/") or "/lib" + if not resolved_target.startswith("/"): + raise RuntimeError(f"omip target must be an absolute board path (got {resolved_target!r})") + if (self.interpreter or "micropython") == "circuitpython": + if packages or not omip_file: + raise RuntimeError( + "omip installs packages on MicroPython; use mpftp circup on CircuitPython" + ) + return self._omip_file_to_circuitpython(resolved_target) + + specs = ([omip.OMIP_FILE] if omip_file else []) + list(packages) + if not specs: + raise RuntimeError("omip_install: packages required") + indexes = [index.rstrip("/")] if index else [omip.INDEX, omip.UPSTREAM] + from mpremote import mip as mp_mip + + def op(t): + channel: Any = "py" + if mpy: + t.exec("import sys") + channel = t.eval("getattr(sys.implementation, '_mpy', 0) & 0xFF") or "py" + plan = omip.Plan(str(channel)) + for spec in specs: + name, _, version = spec.partition("@") + _notify("mip_progress", {"package": spec, "target": resolved_target, "phase": "start"}) + omip.walk(plan, name, version or None, indexes) + + logs: list[str] = [] + downloads: list[tuple[str, bytes]] = [] + for planned in plan.files: + dest = f"{resolved_target}/{planned.path}" + if planned.short_hash and mp_mip._check_exists(t, dest, planned.short_hash): + logs.append(f"Exists: {dest}") + continue + data = omip.fetch(planned.url) + if data is None: + raise omip.OmipError(f"File not found: {planned.url}") + logs.append(f"Copying: {dest} from {planned.url}") + downloads.append((dest, data)) + for dest, data in downloads: + mp_mip._ensure_path_exists(t, dest) + t.fs_writefile(dest, data, progress_callback=_progress_notifier(dest)) + self._sync_remote_fs(t) + for spec in specs: + _notify("mip_progress", {"package": spec, "target": resolved_target, "phase": "done"}) + return { + "output": "\n".join(logs), + "packages": specs, + "target": resolved_target, + "indexes": indexes, + "mpy": mpy, + } + + return self.with_raw(self._transfer(op)) + + def _omip_file_to_circuitpython(self, target: str) -> dict[str, Any]: + """Copy ``omip.py`` on, through the drive when there is one.""" + from . import omip + + planned = omip.Plan("py") + omip.walk(planned, omip.OMIP_FILE, None, [omip.INDEX]) + file = planned.files[0] + data = omip.fetch(file.url) + if data is None: + raise omip.OmipError(f"File not found: {file.url}") + dest = f"{target}/{file.path}" + if self._circuitpy_host_path(dest) is None: + from mpremote import mip as mp_mip + + self.with_raw(lambda t: (self._ensure_cp_writable(t), mp_mip._ensure_path_exists(t, dest))) + written = self.fs_write(dest, base64.b64encode(data).decode()) + return { + "output": f"Copying: {dest} from {file.url}", + "packages": [omip.OMIP_FILE], + "target": target, + "via": written.get("via", "serial"), + } + def _find_circuitpy_host_roots(self) -> list[str]: """Host paths for a mounted CIRCUITPY drive (Windows letters or /media).""" roots: list[str] = [] @@ -4866,6 +4962,13 @@ def _debug_tee_loop( bool(p.get("mpy", True)), p.get("index"), ), + "omip_install": lambda p: SESSION.omip_install( + list(p.get("packages") or []), + p.get("target"), + bool(p.get("mpy", True)), + p.get("index"), + bool(p.get("omip_file", False)), + ), "circup_install": lambda p: SESSION.circup_install( list(p.get("packages") or []), py=bool(p.get("py", False)), diff --git a/cli/tests/test_omip.py b/cli/tests/test_omip.py new file mode 100644 index 0000000..eb6e2d4 --- /dev/null +++ b/cli/tests/test_omip.py @@ -0,0 +1,327 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT + +"""``mpftp omip``: the host walks a package the way omip does on a board. + +The two indexes are real HTTP servers and the install runs through the real +session and mpremote's own helpers. Only the board is a stand-in, a folder. +""" + +from __future__ import annotations + +import argparse +import hashlib +import http.server +import io +import json +import tempfile +import threading +import unittest +from contextlib import redirect_stdout +from functools import partial +from pathlib import Path +from unittest import mock + +from mpftp import omip +from mpftp.cli import build_parser, cmd_omip +from test_helpers import _load_sidecar + + +class Quiet(http.server.SimpleHTTPRequestHandler): + def log_message(self, *args): + pass + + +def serve(root: Path): + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), partial(Quiet, directory=str(root))) + threading.Thread(target=server.serve_forever, args=(0.02,), daemon=True).start() + return server, f"http://127.0.0.1:{server.server_address[1]}" + + +def add_package(root: Path, channel: str, name: str, files: dict[str, bytes], **keys) -> None: + """Write a package the way an index lays it out: JSON by name, files by hash.""" + hashes = [] + for path, data in files.items(): + short = hashlib.sha256(data).hexdigest()[:8] + blob = root / "file" / short[:2] / short + blob.parent.mkdir(parents=True, exist_ok=True) + blob.write_bytes(data) + hashes.append([path, short]) + pkg = root / "package" / channel / name / "latest.json" + pkg.parent.mkdir(parents=True, exist_ok=True) + pkg.write_text(json.dumps({"v": 1, "hashes": hashes, "version": "1.0", **keys})) + + +class FolderBoard: + """The parts of an mpremote transport an install uses, on a folder.""" + + in_raw_repl = True + + def __init__(self, root: Path, mpy=6): + self.root = root + self.mpy = mpy + self.writes: list[str] = [] + + def _at(self, path: str) -> Path: + return self.root / path.lstrip("/") + + def exec(self, code, **kwargs): + return b"" + + def eval(self, expr, **kwargs): + return self.mpy + + def fs_exists(self, path): + return self._at(path).exists() + + def fs_mkdir(self, path): + self._at(path).mkdir() + + def fs_hashfile(self, path, algo): + if not self._at(path).exists(): + raise FileNotFoundError(path) + return hashlib.sha256(self._at(path).read_bytes()).digest() + + def fs_writefile(self, path, data, chunk_size=256, progress_callback=None): + self.writes.append(path) + self._at(path).write_bytes(data) + + +class Base(unittest.TestCase): + def setUp(self): + self.mod = _load_sidecar() + quiet = mock.patch.object(self.mod, "_notify") + quiet.start() + self.addCleanup(quiet.stop) + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + root = Path(tmp.name) + self.ours_root = root / "ours" + self.up_root = root / "upstream" + self.other_root = root / "other" + self.web_root = root / "web" + for folder in (self.ours_root, self.up_root, self.other_root, self.web_root): + folder.mkdir() + self.board_root = root / "board" + self.board_root.mkdir() + + add_package( + self.ours_root, "6", "ourble", {"ourble/__init__.mpy": b"ours"}, + deps=[["ourhelper", "latest"]], upstream=[["aioble", "latest"]], + ) + add_package(self.ours_root, "6", "ourhelper", {"ourhelper.mpy": b"helper"}) + add_package(self.ours_root, "py", "ourble", {"ourble/__init__.py": b"ours source"}) + add_package(self.up_root, "6", "aioble", {"aioble/core.mpy": b"aioble"}, deps=[["aioble-core", "latest"]]) + add_package(self.up_root, "6", "aioble-core", {"aioble/device.mpy": b"device"}) + add_package(self.up_root, "6", "fnmatch", {"fnmatch.mpy": b"from upstream"}) + add_package(self.up_root, "6", "both", {"both.mpy": b"from upstream"}) + add_package(self.ours_root, "6", "both", {"both.mpy": b"from ours"}) + add_package(self.other_root, "6", "ourble", {"other.mpy": b"other"}, upstream=[["aioble", "latest"]]) + (self.web_root / "omip.py").write_bytes(b"# omip\n") + + self.urls = {} + for key, folder in ( + ("ours", self.ours_root), + ("up", self.up_root), + ("other", self.other_root), + ("web", self.web_root), + ): + server, self.urls[key] = serve(folder) + self.addCleanup(server.server_close) + self.addCleanup(server.shutdown) + for name, value in ( + ("INDEX", self.urls["ours"]), + ("UPSTREAM", self.urls["up"]), + ("OMIP_FILE", self.urls["web"] + "/omip.py"), + ): + patch = mock.patch.object(omip, name, value) + patch.start() + self.addCleanup(patch.stop) + + self.board = FolderBoard(self.board_root) + self.session = self.mod.Session() + self.session.interpreter = "micropython" + self.session.transport = self.board + + def on_board(self) -> dict[str, bytes]: + return { + "/" + p.relative_to(self.board_root).as_posix(): p.read_bytes() + for p in self.board_root.rglob("*") + if p.is_file() + } + + +class HostWalk(Base): + def test_a_package_brings_its_deps_and_what_it_names_upstream(self): + result = self.session.omip_install(["ourble"]) + self.assertEqual( + self.on_board(), + { + "/lib/ourble/__init__.mpy": b"ours", + "/lib/ourhelper.mpy": b"helper", + "/lib/aioble/core.mpy": b"aioble", + "/lib/aioble/device.mpy": b"device", + }, + ) + self.assertEqual(result["target"], "/lib") + + def test_a_name_only_upstream_has_is_found_there(self): + self.session.omip_install(["fnmatch"]) + self.assertEqual(self.on_board(), {"/lib/fnmatch.mpy": b"from upstream"}) + + def test_ours_comes_before_upstream(self): + self.session.omip_install(["both"]) + self.assertEqual(self.on_board(), {"/lib/both.mpy": b"from ours"}) + + def test_index_replaces_the_lookup_but_upstream_still_comes_from_micropython_lib(self): + self.session.omip_install(["ourble"], index=self.urls["other"] + "/") + self.assertEqual( + self.on_board(), + { + "/lib/other.mpy": b"other", + "/lib/aioble/core.mpy": b"aioble", + "/lib/aioble/device.mpy": b"device", + }, + ) + + def test_no_mpy_takes_the_py_channel(self): + self.session.omip_install(["ourble"], mpy=False, index=self.urls["ours"]) + self.assertEqual(self.on_board(), {"/lib/ourble/__init__.py": b"ours source"}) + + def test_target_is_honoured(self): + self.session.omip_install(["fnmatch"], target="/flash/lib/") + self.assertEqual(self.on_board(), {"/flash/lib/fnmatch.mpy": b"from upstream"}) + + def test_a_relative_target_is_refused(self): + with self.assertRaisesRegex(RuntimeError, "absolute"): + self.session.omip_install(["fnmatch"], target="lib") + + def test_a_missing_package_says_where_it_looked_and_writes_nothing(self): + with self.assertRaises(omip.OmipError) as ctx: + self.session.omip_install(["fnmatch", "nothere"]) + self.assertIn(self.urls["ours"], str(ctx.exception)) + self.assertIn(self.urls["up"], str(ctx.exception)) + self.assertEqual(self.on_board(), {}) + + def test_a_missing_upstream_package_fails_rather_than_half_installs(self): + (self.up_root / "package" / "6" / "aioble" / "latest.json").unlink() + with self.assertRaisesRegex(omip.OmipError, "aioble"): + self.session.omip_install(["ourble"]) + self.assertEqual(self.on_board(), {}) + + def test_a_missing_file_writes_nothing(self): + for blob in (self.up_root / "file").rglob("*"): + if blob.is_file(): + blob.unlink() + with self.assertRaisesRegex(omip.OmipError, "File not found"): + self.session.omip_install(["ourble"]) + self.assertEqual(self.on_board(), {}) + + def test_a_file_already_there_is_left_alone(self): + self.session.omip_install(["ourble"]) + self.board.writes.clear() + result = self.session.omip_install(["ourble"]) + self.assertEqual(self.board.writes, []) + self.assertIn("Exists: /lib/aioble/core.mpy", result["output"]) + + +class OmipItself(Base): + def test_self_copies_the_file_as_source_on_micropython(self): + self.session.omip_install([], omip_file=True) + self.assertEqual(self.on_board(), {"/lib/omip.py": b"# omip\n"}) + + def test_self_goes_by_the_github_address_mip_teaches(self): + with mock.patch.object(omip, "OMIP_FILE", "github:PyDevices/pydevices/lib/omip.py"): + plan = omip.Plan("6") + omip.walk(plan, omip.OMIP_FILE, None, [omip.INDEX]) + self.assertEqual( + plan.files[0].url, + "https://raw.githubusercontent.com/PyDevices/pydevices/HEAD/lib/omip.py", + ) + + def test_self_copies_through_the_drive_on_circuitpython(self): + self.session.interpreter = "circuitpython" + with tempfile.TemporaryDirectory() as drive: + self.session._circuitpy_msc_root = lambda: Path(drive) + result = self.session.omip_install([], omip_file=True) + self.assertEqual((Path(drive) / "lib" / "omip.py").read_bytes(), b"# omip\n") + self.assertEqual(result["via"], "circuitpy_msc") + + def test_self_copies_over_serial_on_circuitpython_with_no_drive(self): + self.session.interpreter = "circuitpython" + self.session._circuitpy_msc_root = lambda: None + self.session.omip_install([], omip_file=True) + self.assertEqual(self.on_board(), {"/lib/omip.py": b"# omip\n"}) + + def test_named_packages_on_circuitpython_point_at_circup(self): + self.session.interpreter = "circuitpython" + with self.assertRaisesRegex(RuntimeError, "circup"): + self.session.omip_install(["bledev"]) + self.assertEqual(self.on_board(), {}) + + +class PlainMip(Base): + """``mpftp mip --index`` is mpremote's mip: one index, and upstream ignored.""" + + def test_mip_index_ignores_upstream_and_stays_on_its_one_index(self): + with mock.patch.object(self.session, "_take_control_resilient", side_effect=lambda t, **k: t): + self.session.mip_install(["ourble"], index=self.urls["ours"]) + self.assertEqual( + self.on_board(), + {"/lib/ourble/__init__.mpy": b"ours", "/lib/ourhelper.mpy": b"helper"}, + ) + + +class Cli(unittest.TestCase): + class Client: + def __init__(self): + self.calls = [] + + def call(self, method, params=None): + self.calls.append((method, params)) + return {} + + def _run(self, *argv): + ns = build_parser().parse_args(["omip", *argv]) + client = self.Client() + with ( + mock.patch("mpftp.cli.get_client", return_value=(client, "tcp")), + mock.patch("mpftp.cli.ensure_device"), + redirect_stdout(io.StringIO()), + ): + ns.func(ns) + return client.calls[-1] + + def test_names_and_flags_reach_the_sidecar(self): + method, params = self._run("bledev", "--index", "http://x", "--target", "/flash/lib", "--no-mpy") + self.assertEqual(method, "omip_install") + self.assertEqual( + params, + {"packages": ["bledev"], "target": "/flash/lib", "mpy": False, "omip_file": False, "index": "http://x"}, + ) + + def test_self_needs_no_name(self): + _, params = self._run("--self") + self.assertEqual(params["packages"], []) + self.assertTrue(params["omip_file"]) + + def test_nothing_to_do_is_an_error_not_a_quiet_success(self): + ns = argparse.Namespace(packages=[], self_file=False) + with self.assertRaisesRegex(SystemExit, "--self"): + cmd_omip(ns) + + def test_mip_keeps_its_own_method(self): + ns = build_parser().parse_args(["mip", "bledev", "--index", "http://x"]) + client = self.Client() + with ( + mock.patch("mpftp.cli.get_client", return_value=(client, "tcp")), + mock.patch("mpftp.cli.ensure_device"), + redirect_stdout(io.StringIO()), + ): + ns.func(ns) + self.assertEqual(client.calls[-1][0], "mip_install") + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/guides/install-packages.md b/docs/guides/install-packages.md index c579141..16bdd1d 100644 --- a/docs/guides/install-packages.md +++ b/docs/guides/install-packages.md @@ -6,11 +6,45 @@ by the interpreter it detected. ```bash mpftp mip github:org/repo # MicroPython; installs to /lib by default mpftp mip NAME --index https://PyDevices.github.io/mip +mpftp omip NAME # MicroPython; an OmniPython package and what it needs mpftp circup adafruit_display_text # CircuitPython; installs to /lib ``` -In the editor and the browser interface, **mpftp: Install Package** does the -same. +In the editor and the browser interface, **mpftp: Install Package** runs `mip` +or `circup`. `omip` is on the command line. + +## Install an OmniPython package + +`mpftp omip NAME` looks on the OmniPython index and then on micropython-lib, and +brings what the package names from micropython-lib as well. `mip` can't do that: +it takes every file from the one index it was given, so `mpftp mip` keeps doing +what `mip` does. The computer does the fetching, so the board needs no network. + +```bash +mpftp omip palettes +mpftp omip fnmatch --no-mpy --target /flash/lib +``` + +Everything is downloaded before the first file is written, so a package that +can't be found leaves the board as it was. `--index` looks for the package on +that index alone; what it needs from micropython-lib still comes from there. + +To let the board install for itself, put `omip.py` on it: + +```bash +mpftp omip --self +``` + +Then, on the board, with Wi-Fi: + +```python +import omip +omip.install("palettes") +``` + +`mpftp omip --self` works on CircuitPython too, through the drive when it is +mounted. Naming a package there is refused: on CircuitPython, install from the +computer with `mpftp circup`. ## Find a package diff --git a/docs/guides/work-with-circuitpython.md b/docs/guides/work-with-circuitpython.md index 880077a..1c82dd0 100644 --- a/docs/guides/work-with-circuitpython.md +++ b/docs/guides/work-with-circuitpython.md @@ -9,7 +9,8 @@ will cost you time. The sections below are the differences that matter. |---|---|---| | `soft-reset` | Raw soft reset, skips `main.py` | Toggles friendly and raw REPL, does **not** run `code.py` | | `soft-reboot` | Ctrl-D, runs `main.py` | Ctrl-D, runs `code.py` | -| Packages | `mip` | `circup` | +| Packages | `mip`, `omip` | `circup` | +| `omip.py` on the board | `mpftp omip --self` | `mpftp omip --self` | CircuitPython may show "Press any key to enter the REPL…"; mpftp sends a key before raw. Prefer the CDC REPL port; CDC2 data interfaces are filtered. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 012a351..f4afa31 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -51,6 +51,8 @@ and exits non-zero. | Command | What it does | |---|---| | `mip PKG` | Install with mip (MicroPython); `--target` defaults to `/lib`, `--index` names an index | +| `omip PKG` | Install an OmniPython package and what it needs from micropython-lib (MicroPython). `--target` defaults to `/lib`, `--index` names an index, `--no-mpy` takes source | +| `omip --self` | Copy `omip.py` to the board (MicroPython and CircuitPython) | | `circup PKG` | Install with circup (CircuitPython); `--target` defaults to `/lib` | | `search WORDS` | Search the package indexes. `--source`, `--interpreter`, `--limit` (25), `--refresh`, `--text` |