Files
romm/docker/Dockerfile
T
Georges-Antoine AssiandClaude Opus 5.5 6f2a361481 Play RPG Maker 2000/2003 games in the browser with EasyRPG (#5077)
* feat(player): play RPG Maker 2000/2003 games in the browser with EasyRPG

Embed the EasyRPG web player for extracted game folders on the rpg-maker
platform. The backend generates the player's index.json from the scanned
files and merges in the free EasyRPG RTP under every name the official
and fan RTPs give its assets, so games that lean on the RTP find them.
In-game saves sync with the user's RomM saves through the player's
IndexedDB storage.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor(easyrpg): read the free RTP from a fixed path instead of an env var

The full image always keeps the RTP at /var/www/html/assets/easyrpg/rtp,
so the path is a constant beside the web path nginx serves it under. The
dev container links its copy there at startup.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(easyrpg): code-review fixes for the save sync and asset endpoint

- Wait for an in-flight push before the quit push, so the newest save uploads
- Keep Play disabled until the fresh rom (and its saves) is fetched
- Stop onPlay and the rom fetch from acting after the view unmounts
- Leave through usePlayerExit like the other players
- Handle a rejected IDB commit when the run itself fails
- Track uploads by the browser's file name
- Load only visibility and files per asset request, not the full rom
- Serve DEV_MODE files as octet-stream, matching nginx
- Make the traversal test actually reach the endpoint

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor(easyrpg): simplify pass over the player and asset endpoint

- Load only file paths per asset request, and look up RTP folders in O(1)
- Reuse RomFile.file_name_for_download and usePlayerFullscreen
- Share one dev/prod serve helper for game and RTP files
- Derive the save sync's game key from the rom
- Have the RTP table tool reuse normalize_name
- Make save-module internals private and tidy the prepare/read loops

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* feat(easyrpg): flag RPG Maker game folders on the rom schemas

Gallery lists carry no files, so the frontend guessed that every multi-file
rom on the rpg-maker platform was playable, including RPG Maker XP/VX/MV
folders. Rom now has a deferred `is_easyrpg_game` subquery (an RPG_RT.ldb at
the folder root), loaded with the other gallery file stats and derived from
loaded files on the with_files path, and the frontend reads it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor(easyrpg): drop server save sync, keep saves per user in the browser

Server sync moves to a follow-up that puts the web players on device sync.
Saves now stay in the player's IndexedDB, as js-dos does, with the same note
on the start page. The player is launched as `<rom id>-<user id>` so each
RomM account sharing a browser profile keeps its own saves; nginx and Vite
drop the user id when routing to the backend.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(easyrpg): address Greptile review

- Start timing the play session once the player page loads
- Use object-form props in the view test's button mock
- Keep the RTP table tool's docstring to one sentence

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor(easyrpg): keep the RTP table in a fixtures folder

Matches handler/metadata/fixtures, where the other bundled data files live.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-03 17:51:38 -04:00

413 lines
16 KiB
Docker

# syntax=docker/dockerfile:1
# Stages:
# - python-runtime: Patch the Python image's libraries before they ship
# - frontend-build: Build frontend
# - backend-build: Build backend environment
# - backend-dev-build: Similar to `backend-build`, but also compiles and installs development dependencies
# - rahasher-build: Build RAHasher
# - sigil-build: Build sigil Python bindings (title ID extraction)
# - gme-build: Build libgme to WebAssembly (chiptune soundtracks)
# - rom-converto-fetch: Fetch the rom-converto CLI (ROM conversion and metadata)
# - emulator-stage: Fetch and extract emulators
# - nginx-build: Build nginx modules
# - production-stage: Setup frontend and backend
# - slim-image: Slim image with only the necessary files
# - full-image: Full image with emulator stage
# - dev-slim: Slim image with development dependencies
# - dev-full: Full image with emulator stage and development dependencies
# ARGUMENT DECLARATIONS
ARG ALPINE_VERSION=3.24
ARG ALPINE_SHA256=294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
# Pins the base image tag, so it cannot read ../.python-version; keep the two in sync.
ARG PYTHON_VERSION=3.14
ARG PYTHON_ALPINE_SHA256=9e9fde4d32eedce0b661d9ab91e826b62dddf28e928c230ec55f1866cac66b01
ARG NODE_VERSION=24.16
ARG NODE_ALPINE_SHA256=21f403ab171f2dc89bad4dd69d7721bfd15f084ccb46cdd225f31f2bc59b5c9a
ARG NGINX_VERSION=1.30.5
ARG NGINX_SHA256=a5f2157a0302eb0c5e300415effb63a9e70ed1eb9c107283819bf6d149ab607c
ARG UV_VERSION=0.12.19
ARG UV_SHA256=b165481b747c340b4b3b820f6e453c3aea939c8b583cbfc01c4e99ec61181bc2
ARG EMSDK_VERSION=4.0.12
ARG EMSDK_AMD64_SHA256=744fb6a68941970951bacf9d6632041a0398260492232691ef22bbf54b0585c6
ARG EMSDK_ARM64_SHA256=369a4cb655aa1066e6e450dde774243c502d999b1d93fb4890a9d1428daa9280
ARG GME_VERSION=0.6.5
ARG GME_COMMIT=9e23d10f9fd2a6a2f33b10912dd8dc7153258995
FROM python:${PYTHON_VERSION}-alpine${ALPINE_VERSION}@sha256:${PYTHON_ALPINE_SHA256} AS python-alias
# PATCHED PYTHON RUNTIME
# production-stage copies /usr/lib from here over its own, so these libraries
# need the same patches, or the stale ones land in the final image. Kept apart
# from python-alias so that busting this layer's cache (see `no-cache-filters`
# in build.yml) does not also rebuild the venv and the sigil bindings.
FROM python-alias AS python-runtime
RUN apk upgrade --no-cache
# FRONTEND BUILD
# Built on the native build platform: the output (/front/dist) is static JS/CSS,
# fully architecture-independent, so there is no need to emulate the target arch.
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-alpine${ALPINE_VERSION}@sha256:${NODE_ALPINE_SHA256} AS frontend-build
WORKDIR /front
COPY ./frontend/package*.json ./
RUN npm ci --ignore-scripts --no-audit --no-fund
COPY ./frontend ./
RUN npm run build
# BACKEND NODE HELPERS BUILD (server-side ROM patching via RomPatcher.js)
FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION}@sha256:${NODE_ALPINE_SHA256} AS backend-node-build
WORKDIR /backend-node/rom_patcher
COPY ./backend/utils/rom_patcher/package*.json ./
RUN npm ci --ignore-scripts --no-audit --no-fund
# https://github.com/astral-sh/uv/pkgs/container/uv
FROM ghcr.io/astral-sh/uv:${UV_VERSION}-python${PYTHON_VERSION}-alpine@sha256:${UV_SHA256} AS uv-stage
# BACKEND PYTHON BUILD
FROM python-alias AS backend-build
# libpq-dev is needed to build psycopg-c
# mariadb-connector-c-dev is needed to build mariadb-connector
RUN apk add --no-cache \
gcc \
libpq-dev \
mariadb-connector-c-dev \
musl-dev
COPY --from=uv-stage /usr/local/bin/uv /usr/local/bin/uvx /bin/
WORKDIR /src
COPY ./pyproject.toml ./uv.lock /src/
RUN uv sync --frozen --no-cache
FROM backend-build AS backend-dev-build
# linux-headers is needed to install psutil
RUN apk add --no-cache \
linux-headers
RUN uv sync --frozen --no-cache --all-extras
# CUSTOM RAHASHER FOR RETROACHIEVEMENTS
FROM alpine:${ALPINE_VERSION}@sha256:${ALPINE_SHA256} AS rahasher-build
RUN apk add --no-cache \
g++ \
git \
linux-headers \
make \
zlib-dev \
bash
# Tag 1.8.3. Pinned by commit because a tag can be moved.
# Keep this pin in sync with the dev image's RALIBRETRO_COMMIT in ../Dockerfile.
ARG RALIBRETRO_COMMIT=8ab61f745ab753a70b5482f2a0ccb6a4ced5193f
RUN git clone --filter=blob:none https://github.com/RetroAchievements/RALibretro.git && \
cd ./RALibretro && \
git checkout "${RALIBRETRO_COMMIT}" && \
git submodule update --init --recursive && \
sed -i '22a #include <ctime>' ./src/Util.h && \
sed -i '6a #include <unistd.h>' \
./src/libchdr/deps/zlib-1.3.1/gzlib.c \
./src/libchdr/deps/zlib-1.3.1/gzread.c \
./src/libchdr/deps/zlib-1.3.1/gzwrite.c && \
make HAVE_CHD=1 -f ./Makefile.RAHasher
# SIGIL TITLE ID EXTRACTION LIBRARY
# Built from python-alias (not plain alpine) so the cffi extension matches the
# CPython ABI of the venv produced in backend-build.
FROM python-alias AS sigil-build
RUN apk add --no-cache \
cmake \
g++ \
git \
make \
musl-dev
# Keep this pin in sync with the dev image's SIGIL_VERSION in ../Dockerfile.
ARG SIGIL_VERSION=8a3b0089676e07f74da2d9ad08979dedf5cae7c0
RUN git clone --filter=blob:none https://github.com/rommapp/argosy-sigil.git && \
cd ./argosy-sigil && \
git checkout "${SIGIL_VERSION}" && \
git submodule update --init --recursive && \
cmake -B ./build-python -S . -DSIGIL_BUILD_CLI=OFF -DSIGIL_BUILD_TESTS=OFF && \
cmake --build ./build-python --target sigil && \
pip install --no-cache-dir cffi setuptools && \
cd ./bindings/python && \
python build_sigil.py && \
mkdir /sigil-pkg && \
cp ./sigil/*.py ./sigil/_sigil.*.so /sigil-pkg/
# LIBGME (LGPL-2.1+) FOR CHIPTUNE SOUNDTRACKS
# The wasm is the same on every platform, so it builds with the builder's own
# emsdk image. Keep the pins in sync with the dev image's copies in ../Dockerfile.
# trunk-ignore(hadolint/DL3029)
FROM --platform=linux/amd64 emscripten/emsdk:${EMSDK_VERSION}@sha256:${EMSDK_AMD64_SHA256} AS emsdk-amd64
# trunk-ignore(hadolint/DL3029)
FROM --platform=linux/arm64 emscripten/emsdk:${EMSDK_VERSION}-arm64@sha256:${EMSDK_ARM64_SHA256} AS emsdk-arm64
# trunk-ignore(hadolint/DL3006,checkov/CKV_DOCKER_7)
FROM emsdk-${BUILDARCH} AS gme-build
ARG GME_VERSION
ARG GME_COMMIT
# checkov:skip=CKV_DOCKER_4:the ADD below is a Git URL pinned to a commit by --checksum
ADD --checksum=${GME_COMMIT} \
"https://github.com/libgme/game-music-emu.git#${GME_VERSION}" \
/libgme
COPY ./docker/gme /romm-gme
RUN /romm-gme/build.sh /libgme /gme
# ROM-CONVERTO CLI
# Keep the version and both sums in sync with the dev image's copy in ../Dockerfile.
FROM alpine:${ALPINE_VERSION}@sha256:${ALPINE_SHA256} AS rom-converto-fetch
ARG TARGETARCH
ARG ROM_CONVERTO_VERSION=v0.23.2
RUN case "${TARGETARCH}" in \
amd64) rc="linux-x64-musl"; sum="734ecccfa77d425c01dc3ba621261389c1c23d23f0e5d6b8e3346fda1879d687" ;; \
arm64) rc="linux-arm64-musl"; sum="e90cca392b08b859b79bab13fcd47300ffc613d5dca7da1b4d1e38e3a4bbc297" ;; \
*) echo "unsupported TARGETARCH: ${TARGETARCH}" && exit 1 ;; \
esac && \
wget -qO /rom-converto \
"https://github.com/DevYukine/rom-converto/releases/download/${ROM_CONVERTO_VERSION}/rom-converto-cli-${rc}" && \
echo "${sum} /rom-converto" | sha256sum -c - && \
chmod 0755 /rom-converto
# FETCH EMULATORJS, RUFFLE, JS-DOS, FAKE-08 AND EASYRPG
# Keep the pins below in sync with the dev image's copies in ../Dockerfile.
# checkov:skip=CKV_DOCKER_4:every ADD below is a remote URL pinned by --checksum
FROM alpine:${ALPINE_VERSION}@sha256:${ALPINE_SHA256} AS emulator-stage
RUN apk add --no-cache \
7zip
ARG EMULATORJS_VERSION=4.2.3
ARG EMULATORJS_SHA256=07d451bc06fa3ad04ab30d9b94eb63ac34ad0babee52d60357b002bde8f3850b
ADD --checksum=sha256:${EMULATORJS_SHA256} \
"https://github.com/EmulatorJS/EmulatorJS/releases/download/v${EMULATORJS_VERSION}/${EMULATORJS_VERSION}.7z" \
/downloads/emulatorjs.7z
RUN 7z x -y /downloads/emulatorjs.7z -o/emulatorjs
ARG RUFFLE_VERSION=nightly-2025-08-14
ARG RUFFLE_FILE=ruffle-nightly-2025_08_14-web-selfhosted.zip
ARG RUFFLE_SHA256=178870c5e7dd825a8df35920dfc5328d83e53f3c4d5d95f70b1ea9cd13494151
ADD --checksum=sha256:${RUFFLE_SHA256} \
"https://github.com/ruffle-rs/ruffle/releases/download/${RUFFLE_VERSION}/${RUFFLE_FILE}" \
/downloads/ruffle.zip
RUN unzip -o /downloads/ruffle.zip -d /ruffle
ARG JSDOS_VERSION=8.4.1
ARG JSDOS_SHA256=26118692bbb180aec78ec1697eb1ea6b28ff410101870cfa3e68309914c7eaa6
ADD --checksum=sha256:${JSDOS_SHA256} \
"https://github.com/caiiiycuk/js-dos/releases/download/v${JSDOS_VERSION}/release.zip" \
/downloads/jsdos.zip
# The bundled index.html is a js-dos demo page that would be served unauthenticated;
# source maps, Emscripten symbol files and type declarations are unused at runtime.
RUN unzip -o /downloads/jsdos.zip -d /jsdos && \
rm -rf /jsdos/dist/index.html /jsdos/dist/emulators/types && \
find /jsdos/dist \( -name '*.map' -o -name '*.symbols' \) -exec rm -f {} +
# FAKE-08 (MIT) publishes no web build, so these come from p3a (Apache-2.0),
# which compiled them. Pinned by commit and checksum: that tree has no tags.
ARG FAKE08_P3A_COMMIT=6519efd9dd1ca853e5c66f7ae9146ace0b7073dc
ARG FAKE08_JS_SHA256=fd2cd4677956037e41a91dbd40fc1a9c4f5979355d55ce3f9312400e11da463e
ARG FAKE08_WASM_SHA256=4339a77e0aa5aa6f4a5bce9fd8286053eedf7f23f2d853db7f4900f6fff4c93a
# Created first, or ADD --chmod would also apply to the directory it creates.
RUN mkdir /fake08
ADD --checksum=sha256:${FAKE08_JS_SHA256} --chmod=644 \
"https://raw.githubusercontent.com/fabkury/p3a/${FAKE08_P3A_COMMIT}/webui/pico8/fake08.js" \
/fake08/fake08.js
ADD --checksum=sha256:${FAKE08_WASM_SHA256} --chmod=644 \
"https://raw.githubusercontent.com/fabkury/p3a/${FAKE08_P3A_COMMIT}/webui/pico8/fake08.wasm" \
/fake08/fake08.wasm
ARG EASYRPG_VERSION=0.8.1.1
ARG EASYRPG_SHA256=99b6963c943bb355e1b1221125cb4536ca2f627c329d8a89cee5a928cdd6a14f
ADD --checksum=sha256:${EASYRPG_SHA256} \
"https://easyrpg.org/downloads/player/${EASYRPG_VERSION}/easyrpg-player-${EASYRPG_VERSION}-js.tar.gz" \
/downloads/easyrpg.tar.gz
# games/ is where the player looks for games, which the server routes to the backend.
RUN mkdir /easyrpg && \
tar -xzf /downloads/easyrpg.tar.gz -C /easyrpg --strip-components=1 && \
rm -rf /easyrpg/games
# The free RTP (CC-BY-4.0) has no releases, so it is pinned by commit.
ARG EASYRPG_RTP_COMMIT=993d88cbc78c658d348bbfa74a3b424d393d27e5
ARG EASYRPG_RTP_SHA256=7e42abebbec94989f4ff214fc082511eef749114e76e3a342cb62d07c03d56e7
ADD --checksum=sha256:${EASYRPG_RTP_SHA256} \
"https://github.com/EasyRPG/RTP/archive/${EASYRPG_RTP_COMMIT}.tar.gz" \
/downloads/easyrpg-rtp.tar.gz
RUN mkdir /easyrpg/rtp && \
tar -xzf /downloads/easyrpg-rtp.tar.gz -C /easyrpg/rtp --strip-components=1 && \
find /easyrpg/rtp \( -name '.git*' -o -name Makefile \) -exec rm -f {} +
# BUILD NGINX MODULE WITH MOD_ZIP
FROM alpine:${ALPINE_VERSION}@sha256:${ALPINE_SHA256} AS nginx-build
RUN apk add --no-cache \
gcc \
git \
libc-dev \
make \
pcre-dev \
zlib-dev
ARG NGINX_VERSION
# The specified commit SHA is the latest commit on the `master` branch at the time of writing.
# It includes a fix to correctly calculate CRC-32 checksums when using upstream subrequests.
# TODO: Move to a tagged release of `mod_zip`, once a version newer than 1.3.0 is released.
ARG NGINX_MOD_ZIP_COMMIT=a9f9afa441117831cc712a832c98408b3f0416f6
# Clone both `nginx` and `ngx_http_zip_module` repositories, needed to compile the module from source.
# This is needed to be able to dinamically load it as a module in the final image. `nginx` Docker
# images do not have a simple way to include third-party modules.
RUN git clone https://github.com/evanmiller/mod_zip.git && \
cd ./mod_zip && \
git checkout "${NGINX_MOD_ZIP_COMMIT}" && \
cd ../ && \
git clone --branch "release-${NGINX_VERSION}" --depth 1 https://github.com/nginx/nginx.git && \
cd ./nginx && \
./auto/configure --with-compat --add-dynamic-module=../mod_zip/ && \
make -f ./objs/Makefile modules && \
chmod 644 ./objs/ngx_http_zip_module.so
# PRODUCTION STAGE
FROM nginx:${NGINX_VERSION}-alpine${ALPINE_VERSION}@sha256:${NGINX_SHA256} AS production-stage
ARG WEBSERVER_FOLDER=/var/www/html
# Alpine backports security fixes to its repo faster than the base image is
# rebuilt, so the digest pin alone leaves known-vulnerable packages in the image.
RUN apk upgrade --no-cache && apk add --no-cache \
bash \
libarchive-tools \
libmagic \
mariadb-connector-c \
libpq \
nodejs \
7zip \
tzdata \
valkey \
valkey-cli
# Add Python by copying it from the official Docker image. This way, we don't rely on Alpine's
# Python version, which could not be the same as the one used in the backend build stage.
# TODO: Replace with a bundled installation of Python using `uv`, when it is supported.
# Related issue: https://github.com/astral-sh/uv/issues/7865
ARG PYTHON_VERSION
COPY --from=python-runtime /usr/lib/* /usr/lib/
COPY --from=python-runtime /usr/local/bin/* /usr/local/bin/
COPY --from=python-runtime /usr/local/include/python${PYTHON_VERSION} /usr/local/include/python${PYTHON_VERSION}
COPY --from=python-runtime /usr/local/lib/libpython* /usr/local/lib/
COPY --from=python-runtime /usr/local/lib/python${PYTHON_VERSION} /usr/local/lib/python${PYTHON_VERSION}
COPY --from=rahasher-build /RALibretro/bin64/RAHasher /usr/bin/RAHasher
COPY --from=rom-converto-fetch /rom-converto /usr/bin/rom-converto
COPY --from=nginx-build ./nginx/objs/ngx_http_zip_module.so /usr/lib/nginx/modules/
COPY --from=frontend-build /front/dist ${WEBSERVER_FOLDER}
COPY --from=backend-node-build /backend-node/rom_patcher/node_modules/rom-patcher/rom-patcher-js /backend/utils/rom_patcher/rom-patcher-js
COPY ./frontend/assets ${WEBSERVER_FOLDER}/assets
COPY --from=gme-build /gme ${WEBSERVER_FOLDER}/assets/gme
RUN mkdir -p ${WEBSERVER_FOLDER}/assets/romm && \
ln -sf /romm/resources ${WEBSERVER_FOLDER}/assets/romm/resources
COPY ./backend /backend
# Setup init script and config files
COPY ./docker/init_scripts/* /
COPY ./docker/nginx/js/ /etc/nginx/js/
COPY ./docker/nginx/templates/ /etc/nginx/templates/
COPY ./docker/nginx/default.conf /etc/nginx/nginx.conf
COPY ./docker/gunicorn/logging.conf /etc/gunicorn/logging.conf
# User permissions
# - Create default user `romm` (1000) and group `romm` (1000).
# - Create base directories and make default user/group the owner.
# - Make nginx configuration files writable by everyone for `envsubst` to work
RUN addgroup -g 1000 -S romm && adduser -u 1000 -D -S -G romm romm && \
mkdir /romm /redis-data && \
chown romm:romm /romm /redis-data && \
chmod 755 /romm /redis-data && \
chmod -R a+w /etc/nginx/conf.d && \
chmod -R a+w /etc/gunicorn
# SLIM IMAGE
FROM scratch AS slim-image
COPY --from=production-stage / /
COPY --from=backend-build /src/.venv /src/.venv
# Optional sigil bindings for title ID extraction; the backend feature
# no-ops when the package is absent.
ARG PYTHON_VERSION
COPY --from=sigil-build /sigil-pkg /src/.venv/lib/python${PYTHON_VERSION}/site-packages/sigil
ENV PATH="/src/.venv/bin:${PATH}"
# ScreenScraper developer (application) credentials, injected at build time from
# CI secrets. Kept out of the source tree; empty when not supplied at build.
ARG SCREENSCRAPER_DEV_ID
ARG SCREENSCRAPER_DEV_PASSWORD
ENV SCREENSCRAPER_DEV_ID=${SCREENSCRAPER_DEV_ID}
ENV SCREENSCRAPER_DEV_PASSWORD=${SCREENSCRAPER_DEV_PASSWORD}
# Security: Set security-focused environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/backend
# Declare only the parent `/romm`, not its subdirs, so Docker keeps them
# on one mount (`st_dev`) and cross-directory `os.link()` hardlinks don't fail.
VOLUME ["/romm", "/redis-data"]
# Expose non-privileged ports
EXPOSE 8080 6379/tcp
WORKDIR /romm
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["/init"]
# FULL IMAGE
FROM slim-image AS full-image
ARG WEBSERVER_FOLDER=/var/www/html
COPY --from=emulator-stage /emulatorjs ${WEBSERVER_FOLDER}/assets/emulatorjs
COPY --from=emulator-stage /ruffle ${WEBSERVER_FOLDER}/assets/ruffle
COPY --from=emulator-stage /jsdos/dist ${WEBSERVER_FOLDER}/assets/jsdos
COPY --from=emulator-stage /fake08 ${WEBSERVER_FOLDER}/assets/pico8
COPY --from=emulator-stage /easyrpg ${WEBSERVER_FOLDER}/assets/easyrpg
FROM slim-image AS dev-slim
COPY --from=backend-dev-build /src/.venv /src/.venv
FROM full-image AS dev-full
COPY --from=backend-dev-build /src/.venv /src/.venv