TIL: Managing Editable Dependencies in uv Without Committing Local Paths
The Problem
When developing Python packages that depend on each other, you often need to install dependencies in editable mode to see changes immediately. With `uv`, I was running into an annoying workflow issue:
- Install a dependency as editable: `uv pip install -e ../django-cast`
- Run tests or dev server: `uv run pytest` or `just dev`
- uv automatically runs `uv sync`, which reinstalls the package from git, overwriting my editable install!
This meant I had to reinstall editable packages after every `uv run` command, which was frustrating and slowed down development. There's also an existing GitHub issue for that.
Initial Attempts
First, I tried using `--no-sync`:
uv run --no-sync pytest
But this didn't work reliably, as sync would still happen in many cases.
Then I tried `--with-requirements`:
# requirements-dev.txt
-e ../django-cast
-e ../cast-vue
# Run with:
uv run --with-requirements requirements-dev.txt pytest
But this doesn't actually install packages as editable - it treats them as temporary overlay dependencies.
The Solution: tool.uv.sources
The key insight was to use uv's native `[tool.uv.sources]` configuration in `pyproject.toml`. This section allows you to override where dependencies come from:
[tool.uv.sources]
django-cast = { path = "../django-cast", editable = true }
cast-vue = { path = "../cast-vue", editable = true }
When these sources are defined, `uv sync` respects them and installs the packages as editable from the local paths.
The Challenge: Not Committing Local Paths
But this created a new problem: we don't want to commit these local paths to version control! The production `pyproject.toml` should use git sources:
[tool.uv.sources]
django-cast = { git = "https://github.com/ephes/django-cast", branch = "develop" }
cast-vue = { git = "https://github.com/ephes/cast-vue" }
The Complete Solution
I created two commands to toggle between development and production sources:
### switch-to-dev-environment
# Modifies pyproject.toml to use local paths
pyproject["tool"]["uv"]["sources"][package_name] = {
"path": f"../{package_path.name}",
"editable": True
}
### switch-to-git-sources
# Restores git sources in pyproject.toml
pyproject["tool"]["uv"]["sources"][package_name] = {
"git": "https://github.com/ephes/django-cast",
"branch": "develop"
}
Pre-commit Hook Safety Net
To prevent accidentally committing local paths, I added a pre-commit hook that:
- Checks if `tool.uv.sources` contains any editable local paths
- Automatically runs `switch-to-git-sources` if found
- Fails the commit and asks you to stage the corrected `pyproject.toml`
# .pre-commit-scripts/check-uv-sources.py
for package, source in sources.items():
if isinstance(source, dict) and "path" in source and source.get("editable"):
print(f"Found local editable path for {package}: {source['path']}")
has_local_paths = True
if has_local_paths:
subprocess.run(["uv", "run", "commands.py", "switch-to-git-sources"])
return 1 # Fail the commit
The Workflow
Now the development workflow is smooth:
- Switch to local development: `uv run commands.py switch-to-dev-environment`
- Work on your code - all `uv run` commands respect the editable installs
- When ready to commit, the pre-commit hook automatically switches back to git sources
- Your production `pyproject.toml` always has the correct git sources
Key Takeaways
- `tool.uv.sources` is the proper way to override dependency sources in uv
- It supports editable installs with `editable = true`
- Using TOML manipulation + pre-commit hooks creates a seamless workflow
- This approach is better than requirements files or command-line flags because it works with uv's native sync behavior
This solution feels a bit "weird" (modifying and restoring `pyproject.toml` on the fly), but it works for now and prevents the common mistake of committing local development paths.