Skip to content

Adopt uv and pyproject.toml for dependency management - #223

Merged
vthorsteinsson merged 4 commits into
masterfrom
chore/uv-pyproject
Jul 29, 2026
Merged

Adopt uv and pyproject.toml for dependency management#223
vthorsteinsson merged 4 commits into
masterfrom
chore/uv-pyproject

Conversation

@vthorsteinsson

Copy link
Copy Markdown
Member

Dependencies move from requirements.txt into pyproject.toml, with uv.lock pinning an exact, reproducible set. CI installs from the lock instead of re-resolving on every run.

This targets a failure mode that has bitten this project repeatedly: siminn's venv was built in Oct 2023, drifted five months behind its own code, and failed on every daily restart for over two years without anyone noticing. A lockfile makes "the installed environment matches the declared one" a checkable property rather than an assumption.

Changes

  • pyproject.toml — dependencies translated verbatim from requirements.txt, keeping the PyPy-specific constraint comments (openai<1.40 so jiter/Rust isn't needed, httpx<0.28, setuptools<82 for pkg_resources). requires-python = ">=3.11", matching the interpreter matrix.
  • [tool.uv] package = false — GreynirServer is an application whose modules live at the repository root and run in place, not an installable library. uv manages the environment without trying to build the project, so no src/ restructure is needed.
  • [tool.uv.sources] pins icespeak to the greynir branch, replacing the git+https direct reference. Verified against the remote that greynir is a branch, not a tag.
  • SQLAlchemy type stubs move to a dev dependency group. They are never imported at runtime (verified by grep), and sqlalchemy-stubs declares mypy as a dependency — which is what dragged the Rust/PyO3 extension into the install and set the 3.11 floor. Production no longer installs them.
  • CI runs uv sync --frozen bound to the setup-python interpreter, then uv run for pytest, curlylint and scraper.py --init. --frozen means CI fails loudly if the lock drifts from pyproject.toml rather than silently re-resolving.
  • .gitignore — add .venv/, which the existing venv* pattern does not match (leading dot).

requirements.txt is deliberately kept

scripts/deploy.sh still runs pip install -r requirements.txt against the production venv, which is still PyPy 3.10. Regenerating that file from a lock resolved for >=3.11 could break a deploy. It carries a header explaining the situation and should be deleted once deploy.sh moves to uv sync --frozen --no-dev and production is rebuilt on PyPy 3.11.

Verification

Locally on CPython 3.11 with uv 0.12.0 (the same version CI installs):

  • uv lock resolves 91 packages, no conflicts
  • uv sync --frozen installs in 10.5 s, entirely from wheels, no build failures
  • uv lock --check confirms the lock matches pyproject.toml
  • the resulting environment imports cleanly with the PyPy pins intact — openai 1.39.0, httpx 0.27.2, SQLAlchemy 1.4.54, reynir 3.7.2

The test suite was not run locally. It imports main and would connect to the production scraper database on this host. CI is the correct place to verify it.

Out of scope

vectors/ keeps its own requirements.txt and CPython environment (currently 3.9.4, gensim 3.8.2). It is a separate, incompatible runtime — numpy and gensim don't work adequately under PyPy — so it wants its own uv project rather than a workspace member. Separate work.

🤖 Generated with Claude Code

vthorsteinsson and others added 4 commits July 29, 2026 17:29
Dependencies move from requirements.txt into pyproject.toml, with uv.lock
pinning an exact, reproducible set. CI installs from the lock instead of
re-resolving on every run.

This is the failure mode that has bitten this project repeatedly: siminn's
venv was built in Oct 2023, drifted five months behind its own code, and
failed on every daily restart for over two years without anyone noticing.
A lockfile makes "the installed environment matches the declared one" a
checkable property rather than an assumption.

- pyproject.toml: dependencies translated verbatim from requirements.txt,
  keeping the PyPy-specific constraint comments (openai<1.40 needs no Rust
  for jiter, httpx<0.28, setuptools<82 for pkg_resources). requires-python
  is >=3.11, matching the interpreter matrix.
- tool.uv package = false: GreynirServer is an application whose modules live
  at the repository root and run in place, not an installable library, so uv
  manages the environment without trying to build the project.
- tool.uv.sources pins icespeak to the 'greynir' branch, replacing the
  git+https direct reference (verified: 'greynir' is a branch, not a tag).
- The SQLAlchemy type stubs move to a dev dependency group. They are never
  imported at runtime, and sqlalchemy-stubs declares mypy as a dependency,
  which drags a Rust/PyO3 extension into what production installs.
- CI runs `uv sync --frozen` bound to the setup-python interpreter, then
  `uv run` for pytest, curlylint and scraper.py --init.
- .gitignore: add .venv/, which the existing venv* pattern does not match.

requirements.txt is deliberately left in place, with a header explaining why:
scripts/deploy.sh still runs `pip install -r requirements.txt` against the
production venv, which is still PyPy 3.10. Regenerating it from a lock
resolved for >=3.11 could break a deploy. It should be deleted once
deploy.sh moves to `uv sync` and production is rebuilt on PyPy 3.11.

Verified locally on CPython 3.11: uv lock resolves 91 packages with no
conflicts, uv sync installs them in 10s entirely from wheels with no build
failures, and the resulting environment imports cleanly with the PyPy pins
intact (openai 1.39.0, httpx 0.27.2). The test suite was NOT run locally --
it imports main and would connect to the production scraper database on this
host. CI is the correct place to verify it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Running the suite locally was unsafe: the database name is hardcoded to
"scraper" in db/__init__.py, so a local run cannot be isolated by database
name, and pytest would connect to the production cluster on port 5432 and
write to its query log.

scripts/test_local.sh mirrors the CI steps against a disposable PostgreSQL
cluster of its own -- initdb into a temp directory, start it on a spare port,
create the reynir role and scraper database, populate from
tests/files/populate_testdb.sql, run pytest, then tear the whole thing down.
It needs neither sudo nor Docker, and refuses outright to run against 5432.

Also adds [tool.pytest.ini_options] testpaths = ["tests"]. Without it pytest
walks the entire repository from its rootdir, so any local interpreter or
virtualenv directory (a pypy/ or venv/ tree) gets collected, putting vendored
third-party packages on sys.path where they shadow the real ones. This
produced 17 collection errors locally. CI never hit it because its checkout is
clean, but it broke local runs for anyone with a local interpreter directory.

Current local result: 34 passed, 7 skipped, 19 failed. All 19 failures are
"RuntimeError: TTS with Azure failed" -- icespeak asserts at import time that
a speech engine is configured, so the script writes structurally valid
placeholder keys to satisfy the import. Real synthesis needs real credentials,
which CI injects from repository secrets. Drop a real key into
resources/AzureSpeechServerKey.json for full parity; the script will not
overwrite a non-empty key file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
icespeak defaults ICESPEAK_KEYS_DIR to a relative "keys" directory. This
project has never had one -- the API keys live in resources/, and no keys/
directory exists in the repository or in either deployment. icespeak reads
the variable at import time and asserts that at least one speech engine is
configured, so without it every import of icespeak fails outright with
"No voices available".

Deployments papered over this by setting ICESPEAK_KEYS_DIR in .env, with the
result that a fresh checkout could not start the web server at all: main.py
imports routes, which imports icespeak, which asserted and died before Flask
ever started. The tests only worked because CI passed the variable explicitly.

utility.py now sets it unconditionally to the absolute RESOURCES_DIR, so it
holds regardless of the working directory and regardless of whether a .env
exists. utility is imported by main.py well before routes, which is what
guarantees it lands before icespeak is first imported.

Removes the now-redundant ICESPEAK_KEYS_DIR from the CI workflow and from
scripts/test_local.sh, where it would silently be overwritten anyway.

Verified: the web server starts with no ICESPEAK_KEYS_DIR in the environment
and serves pages, and the full suite passes locally (53 passed, 7 skipped)
with the variable unset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
config/Greynir.conf set host = 0.0.0.0, and the config file overrides the
GREYNIR_HOST environment variable (settings.py:183), so there was no way to
narrow the binding without editing this file. Running main.py on a host with
a public IP therefore exposed the Flask development server, with full access
to the production database, on every interface.

This only affects the development server: production serves through gunicorn,
which takes its bind address from config/gunicorn_config.py, and deploy.sh
does not copy config/Greynir.conf to deployments. Anyone who does want to
reach the dev server from another machine can either change this value locally
or forward a port over SSH.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vthorsteinsson
vthorsteinsson merged commit 3c72d3a into master Jul 29, 2026
3 checks passed
@vthorsteinsson
vthorsteinsson deleted the chore/uv-pyproject branch July 29, 2026 18:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant