I ran my ops repo on Pipenv for a long time. It was a good fit because it combined two things I care about:
- dependency management
- runnable scripts in one place
That “deps + scripts” model is still one of Pipenv’s best features.
I migrated anyway because my priorities changed. I wanted a faster Python toolchain and a workflow entry point that is portable across machines and CI. uv and Taskfile deliver that without giving up the one command experience.
TL;DR
I replaced:
- Pipfile and Pipfile.lock
- pipenv install and pipenv run
- Pipfile [scripts]
With:
- pyproject.toml and uv.lock
- uv sync and uv run
- Taskfile tasks
Tools in two paragraphs
uv
uv manages a local virtual environment, resolves dependencies, writes a lockfile, and runs commands inside the environment.
What I use most:
- uv sync to create or update .venv from uv.lock
- uv run to execute Ansible and Python tools without activating the venv
The practical benefit is speed. Sync and run are noticeably faster than my old flow.
Taskfile
Taskfile is a small task runner with a static Go binary. It defines workflows as tasks and supports dependencies between tasks.
Why it fits ops repos:
- one entry point for common actions
- task dependencies and variables
- works the same locally and in CI
- no language runtime required to run the task runner itself
Why I liked Pipenv
I do not consider this a “Pipenv is bad” story. Pipenv did what I needed:
- it managed the environment
- it gave me scripts in the same file
- it kept day to day commands memorable
The reason I moved is not feature parity. It is operational strategy.
Why I moved anyway
The setup I want today optimizes for:
- fast environment sync and fast command execution
- a CI agnostic workflow contract for the repo
- self hosted friendliness and fewer platform assumptions
Hosted runners and policies change. Licensing and usage limits tighten. I want a workflow that still works when convenience goes away. A static task runner plus a fast Python toolchain is a good baseline.
Goals
- keep one command ergonomics
- make dependency sync deterministic
- replace Pipfile scripts with a real workflow layer
- keep Ansible and linters running inside the same Python environment
The result: uv + Taskfile
After the migration the repo revolves around three files:
- pyproject.toml for dependencies
- uv.lock for resolved versions
- Taskfile.yml for workflows
The mental model is simple:
- uv owns Python
- Taskfile owns execution
The Taskfile
This is the shape I ended up with. It keeps deps as a first class task and runs everything through uv run.
# yaml-language-server: $schema=https://taskfile.dev/schema.json
version: "3"
vars:
playbook: playbook_install.yml
tasks:
deps:
sources:
- pyproject.toml
- uv.lock
generates:
- .venv/pyvenv.cfg
cmds:
- uv sync --all-extras --dev
silent: true
default:
deps: [deps]
generates:
- $HOME/.gnupg
- $HOME/.config/kitty
- $HOME/.config/nvim
- $HOME/.mplayer
cmds:
- uv run ansible-playbook {{.playbook}}
lint:
deps: [deps]
cmds:
- uv run ansible-lint {{.playbook}}
configs:
deps: [deps]
cmds:
- uv run ansible-playbook {{.playbook}} --tags configs
upgrade:
cmds:
- uv sync --upgrade
nvim:
deps: [default]
cmds:
- nvim --headless "+Lazy! restore" +qa
- nvim --headless "+checkhealth" +qa
- nvim --headless "+checkhealth vim.lsp" +qa
Why deps is separate
Pipenv made environment setup implicit. I prefer it explicit.
Every task that needs Python depends on deps. That makes failures clearer and keeps the workflow consistent.
Why sources and generates matter
deps has inputs and outputs:
- sources are pyproject.toml and uv.lock
- generates is .venv/pyvenv.cfg
That gives Taskfile enough context to skip work when nothing changed.
Migration steps
I did this as “automate first then refine”.
1) Convert the project
From the repo root:
uvx migrate-to-uv
This gave me a starting pyproject.toml and uv.lock.
2) Review dependencies
I checked:
- which packages belong in main vs dev dependencies
- which extras I actually want installed
3) Migrate scripts into tasks
Pipfile scripts were convenient but flat. Taskfile tasks became the new interface:
- task
- task lint
- task configs
- task upgrade
4) Replace pipenv run with uv run
The mapping is direct:
- pipenv run ansible-playbook becomes uv run ansible-playbook
- pipenv run ansible-lint becomes uv run ansible-lint
What improved
- speed in sync and run
- workflows are explicit and discoverable
- tasks work locally, on self hosted runners, and across CI systems
- the repo has a clear contract for “how to operate it”