Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions docs/usb.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,22 @@ directory each, installed with `mip`:
| [`usb-hid`](../usb-hid) | `usb_hid` | CircuitPython's `usb_hid`, so `adafruit_hid` types and moves the mouse |

MIDI is [`midiports`](../midiports/README.md): `midiports.usb_device` and
`midiports.usb_host`.
`midiports.usb_host`. It installs by name the same way.

From release 0.7.6, each package is in the PyDevices index under the name in
the table, and one install brings what it builds on with it:

```python
import mip
mip.install("github:PyDevices/pydevices/usb-host-msc") # brings usb-core with it
mip.install("usb-host-msc", index="https://PyDevices.github.io/mip") # brings usb-core with it
```

A board config or your own `manifest.py` can `require("usb-core")` by the same
names. To install a release before 0.7.6, or whatever is on `main`, fetch the
directory from GitHub instead:

```python
mip.install("github:PyDevices/pydevices/usb-host-msc")
```

## What your firmware needs
Expand Down
11 changes: 11 additions & 0 deletions midiports/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ has had 0.1.1 since September 2026, but a firmware that freezes the package may
From a computer, `mpremote mip install github:PyDevices/pydevices/midiports`
does the same.

From release 0.7.6, `midiports` is in the PyDevices index too, so a board
config can `require("midiports")` and you can install it by name:

```python
mip.install("midiports", index="https://PyDevices.github.io/mip")
```

That installs `midiports` alone, because `adafruit_midi` isn't in any mip
index. If your board doesn't have it, use the `github:` install above, which
fetches both.

On desktop Python, the Windows port needs `uwin32` from `pydevices-desktop`.
`midiports` isn't on PyPI yet, so put this folder on your path. Adafruit
publishes `adafruit_midi` there as `adafruit-circuitpython-midi`.
Expand Down
58 changes: 57 additions & 1 deletion mip-split.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Which modules in lib/ cannot run on a microcontroller, and which packages in
# lib/ publish to MIP on their own instead of inside `pydevices` (`own-package`,
# at the end: bledev).
# bledev), and which top-level directories publish to MIP under their own name
# (`top-level`, at the end: midiports and the usb-* packages).
#
# `displaydev`, `audiodev` and `multimer` each carry a full set of platform
# backends and pick one at import time through their `auto` module. That design
Expand Down Expand Up @@ -94,3 +95,58 @@ host-only = ["bleak", "webble"]
# On PyPI bledev stays inside the pydevices wheel. bleak is optional, behind
# `pip install "pydevices[ble]"`.
pypi-extras = { ble = ["bleak>=1.0"] }

# midiports and the USB packages are directories at the top of the repository,
# not in lib/, each with its own manifest.py. `top-level = true` publishes that
# directory to the index under its own name, so a board installs it with
# mip.install("usb-host-msc", index="https://PyDevices.github.io/mip")
# and a board config can require() it by name. The manifest.py is the single
# source for what a package is, requires and ships: the publisher reads it, and
# a firmware build freezes the same file. Only the version changes. The index
# carries the release's version, not the 0.1.0 in the manifest, so every
# PyDevices package in it is at the same number. A section takes no other keys.
#
# A package that require()s another here (usb-host-msc needs usb-core) must have
# that one listed too; the publisher refuses to sync otherwise, and
# tests/test_mip_split.py refuses a directory with a manifest.py that is missing
# from this list. package.json is for installing straight from GitHub and is
# not published.
#
# midiports reaches the index without Adafruit's adafruit_midi, which is not a
# package in any mip index: install it as midiports/README.md says.

[midiports]
top-level = true

[usb-core]
top-level = true

[usb-hid]
top-level = true

[usb-host-audio]
top-level = true

[usb-host-cdc]
top-level = true

[usb-host-hid]
top-level = true

[usb-host-msc]
top-level = true

[usb-host-net]
top-level = true

[usb-host-video]
top-level = true

[usb-device-audio]
top-level = true

[usb-device-msc]
top-level = true

[usb-device-video]
top-level = true
59 changes: 56 additions & 3 deletions tests/test_mip_split.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,42 @@
# SPDX-FileCopyrightText: 2026 Brad Barnett
#
# SPDX-License-Identifier: MIT
"""mip-split.toml names only modules that exist under lib/.
"""mip-split.toml names only modules and packages that exist.

The MIP index sync refuses a release whose mip-split.toml names a module
that isn't on disk, and it only runs after the tag exists (v0.6.0 failed
that way, #103). This catches it in the pull request instead.

It also keeps the list of top-level packages (midiports and the usb-*
directories) complete: a directory with a manifest.py that the file doesn't
name would never reach the index, and nothing would say so until a board
tried to install it by name (#226).
"""

import pathlib
import re
import tomllib
import unittest

ROOT = pathlib.Path(__file__).resolve().parent.parent


def load_split():
return tomllib.loads((ROOT / "mip-split.toml").read_text(encoding="utf-8"))


def lib_sections(split):
return {package: section for package, section in split.items() if not section.get("top-level")}


def top_level_sections(split):
return {package: section for package, section in split.items() if section.get("top-level")}


class TestMipSplit(unittest.TestCase):
def test_host_only_names_exist(self):
split = tomllib.loads((ROOT / "mip-split.toml").read_text(encoding="utf-8"))
for package, section in split.items():
split = load_split()
for package, section in lib_sections(split).items():
package_dir = ROOT / "lib" / package
with self.subTest(package=package):
self.assertTrue(package_dir.is_dir(), f"lib/{package} does not exist")
Expand All @@ -28,5 +46,40 @@ def test_host_only_names_exist(self):
self.assertEqual(missing, [], f"[{package}] host-only names modules that don't exist")


class TestTopLevelPackages(unittest.TestCase):
def test_every_section_is_a_directory_with_a_manifest_and_no_other_keys(self):
for package, section in top_level_sections(load_split()).items():
with self.subTest(package=package):
self.assertEqual(set(section), {"top-level"}, "the manifest.py says everything else")
self.assertTrue((ROOT / package / "manifest.py").is_file(), f"{package}/manifest.py")
self.assertFalse((ROOT / "lib" / package).exists(), f"lib/{package} would be ambiguous")

def test_every_package_directory_is_listed(self):
listed = set(top_level_sections(load_split()))
on_disk = {path.parent.name for path in ROOT.glob("*/manifest.py") if (path.parent / "package.json").is_file()}
self.assertEqual(
sorted(on_disk - listed),
[],
"these directories have a package.json and a manifest.py but are not in mip-split.toml, "
"so mip.install(<name>, index=...) can't find them",
)
self.assertEqual(sorted(listed - on_disk), [])

def test_every_require_of_a_sibling_names_a_listed_package(self):
listed = set(top_level_sections(load_split()))
for package in sorted(listed):
manifest = (ROOT / package / "manifest.py").read_text(encoding="utf-8")
for required in re.findall(r'^\s*require\(\s*["\']([^"\']+)["\']', manifest, re.MULTILINE):
if (ROOT / required / "manifest.py").is_file():
with self.subTest(package=package, requires=required):
self.assertIn(required, listed)

def test_every_manifest_has_one_version_for_the_publisher_to_set(self):
for package in sorted(top_level_sections(load_split())):
manifest = (ROOT / package / "manifest.py").read_text(encoding="utf-8")
with self.subTest(package=package):
self.assertEqual(len(re.findall(r'^\s*version\s*=\s*["\']', manifest, re.MULTILINE)), 1)


if __name__ == "__main__":
unittest.main()
Loading