Build the venv from the distribution's Python instead of uv

The backend now targets 3.11, which is what Raspberry Pi OS ships, so there is
nothing left for uv to solve. It was only ever there to obtain a 3.13 the distro
does not have, and it brought its own problems: the download had to be matched
to the Pi's 32-bit userland by hand, and no armv7 wheel exists for a 3.13 ABI,
so Pillow was compiled from source and needed image headers installed alongside.

apt for the interpreter, python3 -m venv, pip for the rest - and piwheels then
supplies prebuilt armhf wheels for the native dependencies.

A venv cannot be migrated between interpreters (it holds absolute paths into the
one that made it), and `creates:` would keep the old one forever, so check what
built it and rebuild when it does not match the current python3. That is what
carries a device off the uv-installed 3.13 without hand-holding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 21:21:12 +02:00
parent 6fcc345a26
commit 9bd23467f0
3 changed files with 41 additions and 63 deletions

View File

@@ -5,9 +5,12 @@ Deploys the MusicMouse backend (`python-backend/`) and its built web frontend.
- Checks out `musicmouse_version` from `pi_musicmouse_repo` to `/opt/musicmouse`, then - Checks out `musicmouse_version` from `pi_musicmouse_repo` to `/opt/musicmouse`, then
**fails early** if that checkout has no `python-backend/pyproject.toml` - see **fails early** if that checkout has no `python-backend/pyproject.toml` - see
"Which version" below. "Which version" below.
- Installs a pinned `uv` release and uses it to create `/opt/musicmouse/.venv` with - Creates `/opt/musicmouse/.venv` from the distribution's own `python3` and pip-installs
Python `pi_musicmouse_python_version` (the backend needs 3.12+ for PEP 695 syntax; the backend into it. The backend targets the Python that Raspberry Pi OS ships (3.11
Raspbian Bookworm's apt python3 is only 3.11) and `pip install` the backend into it. on Bookworm) precisely so this step stays boring: apt and piwheels have prebuilt
armhf wheels for the native dependencies, so nothing has to be compiled on the Pi.
If the venv was built by a different interpreter than the current `python3`, it is
thrown away and rebuilt.
- Builds the frontend (`web/`) on the *control machine* (delegated to `localhost`, not - Builds the frontend (`web/`) on the *control machine* (delegated to `localhost`, not
the Pi - no Node/npm is installed on the Pi) and syncs `web/dist` over. the Pi - no Node/npm is installed on the Pi) and syncs `web/dist` over.
- Installs `/media/musicmouse/config.yml` from `files/config-<inventory_hostname>.yml` - Installs `/media/musicmouse/config.yml` from `files/config-<inventory_hostname>.yml`

View File

@@ -5,24 +5,6 @@ pi_musicmouse_repo: "ssh://git@git.bauer.tech:2222/martin/musicmouse.git"
# `git push -f origin <branch>:deploy/musicdolphin` and re-run this role. # `git push -f origin <branch>:deploy/musicdolphin` and re-run this role.
# musicmouse_version: "deploy/musicdolphin" # musicmouse_version: "deploy/musicdolphin"
pi_musicmouse_python_version: "3.13"
pi_musicmouse_uv_version: "0.12.10"
# Which uv build to download. Raspberry Pi OS ships a 64-bit kernel with a 32-bit
# userland, so `uname -m` (and therefore ansible_architecture) says aarch64 on a box
# that can only run armhf binaries - the aarch64 build installs fine and then fails to
# exec with "No such file or directory", which is the dynamic loader missing, not the
# file. ansible_userspace_bits is the fact that tells the truth.
pi_musicmouse_uv_target: >-
{{ 'armv7-unknown-linux-gnueabihf'
if (ansible_architecture in ['aarch64', 'armv7l', 'armv6l'] and ansible_userspace_bits == '32')
else _pi_musicmouse_uv_targets[ansible_architecture] }}
_pi_musicmouse_uv_targets:
aarch64: aarch64-unknown-linux-gnu
armv7l: armv7-unknown-linux-gnueabihf
armv6l: armv7-unknown-linux-gnueabihf
x86_64: x86_64-unknown-linux-gnu
# Where to check out the repo on the *control machine* to build the frontend (npm isn't # Where to check out the repo on the *control machine* to build the frontend (npm isn't
# installed on the Pi - see README). Keyed by version so different hosts on different # installed on the Pi - see README). Keyed by version so different hosts on different
# versions don't clobber each other's build. # versions don't clobber each other's build.

View File

@@ -3,47 +3,15 @@
ansible.builtin.apt: ansible.builtin.apt:
name: name:
- git - git
- libvlc5 # runtime lib python-vlc (installed into the venv by uv) binds against - libvlc5 # runtime lib python-vlc binds against
- vlc-plugin-base - vlc-plugin-base
- alsa-utils - alsa-utils
- samba - samba
# Pillow ships no armv7 wheel and Raspberry Pi OS's userland is 32-bit, so it is - python3
# compiled from source here and needs its image headers. (piwheels would have a - python3-venv
# prebuilt one, but only for the system Python, not the 3.13 uv installs.) - python3-pip
- libjpeg-dev
- zlib1g-dev
cache_valid_time: 7200 cache_valid_time: 7200
- name: Ensure uv install directory exists
ansible.builtin.file:
path: "/opt/uv-{{ pi_musicmouse_uv_version }}-{{ pi_musicmouse_uv_target }}"
state: directory
mode: "0755"
- name: Download and extract uv {{ pi_musicmouse_uv_version }}
ansible.builtin.unarchive:
src: "https://github.com/astral-sh/uv/releases/download/{{ pi_musicmouse_uv_version }}/uv-{{ pi_musicmouse_uv_target }}.tar.gz"
dest: "/opt/uv-{{ pi_musicmouse_uv_version }}-{{ pi_musicmouse_uv_target }}"
remote_src: true
extra_opts: ["--strip-components=1"]
creates: "/opt/uv-{{ pi_musicmouse_uv_version }}-{{ pi_musicmouse_uv_target }}/uv"
- name: Symlink uv/uvx into /usr/local/bin
ansible.builtin.file:
src: "/opt/uv-{{ pi_musicmouse_uv_version }}-{{ pi_musicmouse_uv_target }}/{{ item }}"
dest: "/usr/local/bin/{{ item }}"
state: link
force: true
loop:
- uv
- uvx
- name: Verify uv runs on this host
ansible.builtin.command:
cmd: /usr/local/bin/uv --version
register: pi_musicmouse_uv_version_out
changed_when: false
- name: Checkout Musicmouse repo - name: Checkout Musicmouse repo
ansible.builtin.git: ansible.builtin.git:
repo: "{{ pi_musicmouse_repo }}" repo: "{{ pi_musicmouse_repo }}"
@@ -73,9 +41,34 @@
buried. buried.
when: not ansible_check_mode and not pi_musicmouse_pyproject.stat.exists when: not ansible_check_mode and not pi_musicmouse_pyproject.stat.exists
- name: Create virtualenv with Python {{ pi_musicmouse_python_version }} - name: Get the system Python version
ansible.builtin.command: ansible.builtin.command:
cmd: "/usr/local/bin/uv venv --python {{ pi_musicmouse_python_version }} /opt/musicmouse/.venv" cmd: python3 -c "import sys; print('%d.%d' % sys.version_info[:2])"
register: pi_musicmouse_system_python
changed_when: false
- name: Get the version the venv was built with
ansible.builtin.command:
cmd: /opt/musicmouse/.venv/bin/python -c "import sys; print('%d.%d' % sys.version_info[:2])"
register: pi_musicmouse_venv_python
changed_when: false
failed_when: false
- name: Discard a venv built by a different Python
# `creates:` below would otherwise keep an old interpreter forever - which is exactly
# what a switch away from a separately-installed Python leaves behind. A venv holds
# absolute paths into the interpreter that made it, so it cannot be migrated; the
# cheap and correct move is to rebuild it.
ansible.builtin.file:
path: /opt/musicmouse/.venv
state: absent
when: >-
pi_musicmouse_venv_python.rc == 0
and pi_musicmouse_venv_python.stdout | trim != pi_musicmouse_system_python.stdout | trim
- name: Create the virtualenv from the system Python
ansible.builtin.command:
cmd: "python3 -m venv /opt/musicmouse/.venv"
creates: /opt/musicmouse/.venv/bin/python creates: /opt/musicmouse/.venv/bin/python
# Installing the backend is deliberately not a handler hanging off the checkout. # Installing the backend is deliberately not a handler hanging off the checkout.
@@ -85,9 +78,9 @@
# ModuleNotFoundError. Instead, decide from what is actually on the device - the commit # ModuleNotFoundError. Instead, decide from what is actually on the device - the commit
# it has checked out, and whether the venv can import the package at all. # it has checked out, and whether the venv can import the package at all.
# #
# uv's own output cannot answer this: `musicmouse` is a local path dependency, so every # pip's own output cannot answer this either: `musicmouse` is a local path dependency,
# run rebuilds and reinstalls it and always prints "Installed 1 package". Keying off # so every run rebuilds and reinstalls it. Keying off that restarts the service on
# that restarts the service on every run and rescans the whole library for nothing. # every run and rescans the whole library for nothing.
- name: Read which commit the venv was built from - name: Read which commit the venv was built from
ansible.builtin.command: ansible.builtin.command:
cmd: cat /opt/musicmouse/.venv/.installed-commit cmd: cat /opt/musicmouse/.venv/.installed-commit
@@ -104,7 +97,7 @@
- name: Install the backend into the venv - name: Install the backend into the venv
ansible.builtin.command: ansible.builtin.command:
cmd: "/usr/local/bin/uv pip install --python /opt/musicmouse/.venv/bin/python /opt/musicmouse/python-backend" cmd: "/opt/musicmouse/.venv/bin/pip install /opt/musicmouse/python-backend"
when: >- when: >-
pi_musicmouse_importable.rc != 0 pi_musicmouse_importable.rc != 0
or pi_musicmouse_stamp.stdout | trim != pi_musicmouse_commit or pi_musicmouse_stamp.stdout | trim != pi_musicmouse_commit