Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
__pycache__
*.retry
*.log
.vagrant/
.ansible
.vagrant/
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,34 @@ django_settings:
}
```

## uv (Astral) install mode

The role can install Python dependencies via [uv](https://docs.astral.sh/uv/) instead of pip, pipenv, or poetry. This mode requires the consuming project to have a `pyproject.toml` and `uv.lock` checked in.

Enable it by setting:

```yml
django_use_uv: true
django_use_regular_old_pip: false
django_use_pipenv: false
django_use_poetry: false
```

When enabled, the role:

1. Installs the `uv` binary to `/usr/local/bin/uv` on the host (idempotent — re-runs are no-ops).
2. Runs `uv sync --frozen --no-dev --python {{ django_python_version }}` against `{{ django_uv_project_dir }}` (defaults to `{{ django_checkout_path }}`), with `UV_PROJECT_ENVIRONMENT={{ django_venv_path }}` so the resulting venv lands at the path the rest of the role (uwsgi, systemd units) expects.
3. Continues to install `django_pip_packages` (uwsgi, celery, etc.) into the same venv via the role's existing pip step — no change there.

Tunables:

| Variable | Default | Purpose |
|---|---|---|
| `django_use_uv` | `false` | Enable the uv install mode. |
| `django_uv_version` | `"latest"` | Version installed via the astral.sh installer. Pin to e.g. `"0.6.10"` for reproducibility. |
| `django_uv_project_dir` | `"{{ django_checkout_path }}"` | Directory containing `pyproject.toml` + `uv.lock`. |
| `django_uv_sync_args` | `"--frozen --no-dev"` | Flags passed to `uv sync`. |

## Testing

This project utilizes molecule for testing, the molecule tool can be installed by running `pip install 'molecule[docker]'` after which tests can run with `molecule test --all`
Expand Down
10 changes: 10 additions & 0 deletions defaults/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ django_setuptools_version:
## pipenv
django_use_pipenv: false

## uv (Astral)
django_use_uv: false
# Version of uv to install. Use "latest" or a pinned release like "0.6.10".
# Pinning makes the install task's `creates:` idempotency check meaningful.
django_uv_version: "latest"
# Directory containing pyproject.toml + uv.lock. Defaults to the checkout root.
django_uv_project_dir: "{{ django_checkout_path }}"
# Flags passed to `uv sync`. Default mirrors prod intent: locked, prod-only.
django_uv_sync_args: "--frozen --no-dev"

## dependency pip packages
## packages you'd want to install before install packages in the requirement's file
django_dependency_pip_packages: []
Expand Down
57 changes: 57 additions & 0 deletions molecule/uv/converge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
- name: Converge
hosts: all
pre_tasks:
- name: Update apt cache.
ansible.builtin.apt:
update_cache: true
cache_valid_time: 600
when: ansible_os_family == 'Debian'

- name: Wait for systemd to complete initialization. # noqa: command-instead-of-module
ansible.builtin.command: systemctl is-system-running
register: systemctl_status
until: >
'running' in systemctl_status.stdout or
'degraded' in systemctl_status.stdout
retries: 30
delay: 5
when: ansible_service_mgr == 'systemd'
changed_when: false
failed_when: systemctl_status.rc > 1
roles:
- role: onaio.django
django_python_version: "python3.12"
django_system_user: "sample_uv_app"
django_system_user_home: "/home/{{ django_system_user }}"
django_recreate_virtual_env: false
django_manage_services: false
django_codebase_path: "{{ django_system_user_home }}/app"
django_versioned_path: "{{ django_codebase_path }}-versioned"
django_checkout_path: "{{ django_versioned_path }}/{{ ansible_date_time['epoch'] }}"
# Pull from the local fixture repo seeded in prepare.yml.
django_git_url: "/opt/sample-uv-project"
django_git_version: "main"
django_system_wide_dependencies:
- build-essential
- git
# uv mode
django_use_uv: true
django_use_regular_old_pip: false
django_use_pipenv: false
django_use_poetry: false
# Verify that the role's mode-agnostic extras-pip step still installs
# into the uv-created venv. tally-ho uses this for uwsgi; we use a
# tiny pure-python package here to keep the converge fast.
django_pip_packages:
- click
# Disable celery to keep the test surface minimal.
django_enable_celery: false
django_local_settings_path: "{{ django_checkout_path }}/sample/local_settings.py"
django_settings_module: "sample.settings"
django_wsgi_module: "sample.wsgi:application"
django_init_commands:
django_top_python_statements:
- import os
django_bottom_python_statements:
- SECRET_KEY = "molecule-fixture-not-a-real-secret"
33 changes: 33 additions & 0 deletions molecule/uv/molecule.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
dependency:
name: galaxy
driver:
name: docker
platforms:
- name: instance
image: geerlingguy/docker-${MOLECULE_DISTRO:-ubuntu2204}-ansible:latest
privileged: true
command: /sbin/init
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
env:
LC_ALL: "C.UTF-8"
LANG: "C.UTF-8"
provisioner:
name: ansible
scenario:
name: uv
test_sequence:
- dependency
- cleanup
- destroy
- syntax
- create
- prepare
- converge
- verify
- cleanup
- destroy
verifier:
name: testinfra
62 changes: 62 additions & 0 deletions molecule/uv/prepare.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
# Stage the sample uv project as a local git repo on the molecule host so
# the role's git-clone step has a reachable file:// URL to pull from. This
# avoids depending on an external public repo for the test.
- name: Prepare sample uv project on the molecule host
hosts: all
become: true
vars:
sample_repo_path: /opt/sample-uv-project
fixture_src: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/tests/fixtures/sample-uv-project"
tasks:
- name: Install git
ansible.builtin.apt:
name: git
state: present
update_cache: true
cache_valid_time: 600

- name: Ensure sample project directory exists
ansible.builtin.file:
path: "{{ sample_repo_path }}"
state: directory
mode: "0755"

- name: Copy fixture files into the sample project directory
ansible.builtin.copy:
src: "{{ fixture_src }}/"
dest: "{{ sample_repo_path }}/"
mode: preserve

- name: Initialize git repo for the fixture
ansible.builtin.shell: |
set -eo pipefail
cd "{{ sample_repo_path }}"
if [ ! -d .git ]; then
git -c init.defaultBranch=main init -q
git -c user.email=molecule@example.com \
-c user.name=molecule \
-c commit.gpgsign=false \
add -A
git -c user.email=molecule@example.com \
-c user.name=molecule \
-c commit.gpgsign=false \
commit -q -m "fixture"
fi
args:
executable: /bin/bash
changed_when: true

# The role's git task runs as the django system user, but the fixture
# repo above was created as root. Modern git refuses to operate on
# repos with mismatched ownership; mark the fixture path as safe
# system-wide so the role's clone doesn't trip on it.
# Use `git config` directly: ansible.builtin has no git_config module,
# and pulling in community.general just for this molecule-fixture
# helper isn't worth the collection dep + CI lint plumbing.
# safe.directory='*' trusts every repo system-wide; safe inside a
# disposable molecule container, would not be acceptable in prod.
- name: Mark all directories as safe for git (molecule fixture only) # noqa: command-instead-of-module
ansible.builtin.command:
cmd: git config --system --add safe.directory '*'
changed_when: true
44 changes: 44 additions & 0 deletions molecule/uv/tests/test_uv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import os

import testinfra.utils.ansible_runner

testinfra_hosts = testinfra.utils.ansible_runner.AnsibleRunner(
os.environ['MOLECULE_INVENTORY_FILE']
).get_hosts('all')


def test_uv_binary_installed(host):
uv = host.file('/usr/local/bin/uv')
assert uv.exists
assert uv.mode & 0o111 # executable

cmd = host.run('/usr/local/bin/uv --version')
assert cmd.rc == 0
assert 'uv' in cmd.stdout


def test_venv_python_present(host):
venv_python = host.file(
'/home/sample_uv_app/.virtualenvs/sample_uv_app/bin/python'
)
assert venv_python.exists


def test_django_admin_in_venv(host):
django_admin = host.file(
'/home/sample_uv_app/.virtualenvs/sample_uv_app/bin/django-admin'
)
assert django_admin.exists
assert django_admin.mode & 0o111


def test_extras_pip_packages_installed_into_uv_venv(host):
# The role's mode-agnostic extras-pip step (django_pip_packages) must
# install into the same venv uv created. tally-ho relies on this for
# uwsgi; the converge sets django_pip_packages to ["click"].
pip_show = host.run(
'/home/sample_uv_app/.virtualenvs/sample_uv_app/bin/python '
'-m pip show click'
)
assert pip_show.rc == 0
assert 'Name: click' in pip_show.stdout
34 changes: 34 additions & 0 deletions tasks/install.yml
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,40 @@
when:
- django_use_pipenv| bool

- name: Install Python packages using uv sync
ansible.builtin.command:
cmd: >-
uv sync
{{ django_uv_sync_args }}
--python {{ django_python_version }}
chdir: "{{ django_uv_project_dir }}"
environment:
UV_PROJECT_ENVIRONMENT: "{{ django_venv_path }}"
UV_LINK_MODE: copy
UV_COMPILE_BYTECODE: "1"
become: true
become_user: "{{ django_system_user }}"
changed_when: true
when:
- django_use_uv| bool

# uv sync does not install pip into the venv (uv manages packages
# directly). Bootstrap pip so the mode-agnostic extras-pip step below
# (django_pip_packages: uwsgi, celery, ...) has a working pip binary.
- name: Bootstrap pip into the uv-managed venv
ansible.builtin.command:
cmd: >-
uv pip install
--python {{ django_venv_path }}/bin/python
pip
environment:
UV_LINK_MODE: copy
become: true
become_user: "{{ django_system_user }}"
changed_when: true
when:
- django_use_uv| bool

- name: Install other python packages using pip
ansible.builtin.pip:
name: "{{ django_pip_packages }}"
Expand Down
33 changes: 33 additions & 0 deletions tasks/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,36 @@
become: true
become_user: root
when: django_install_virtualenv

- name: Install uv install-script prerequisites
ansible.builtin.apt:
name:
- curl
- ca-certificates
state: present
update_cache: true
cache_valid_time: 600
become: true
become_user: root
when: django_use_uv

- name: Install uv (Astral)
vars:
# "latest" → unpinned URL (astral.sh/uv/install.sh), which the
# installer treats as latest. Anything else → pinned URL with the
# version segment (astral.sh/uv/<x.y.z>/install.sh).
_uv_installer_url: >-
{{
'https://astral.sh/uv/install.sh'
if django_uv_version == 'latest'
else 'https://astral.sh/uv/' ~ django_uv_version ~ '/install.sh'
}}
ansible.builtin.shell: |
set -eo pipefail
curl -LsSf {{ _uv_installer_url }} | env UV_INSTALL_DIR=/usr/local/bin sh
args:
executable: /bin/bash
creates: /usr/local/bin/uv
become: true
become_user: root
when: django_use_uv
13 changes: 13 additions & 0 deletions tests/fixtures/sample-uv-project/manage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env python
import os
import sys


def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sample.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)


if __name__ == "__main__":
main()
15 changes: 15 additions & 0 deletions tests/fixtures/sample-uv-project/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[project]
name = "sample-uv-project"
version = "0.1.0"
description = "Minimal Django app used by the molecule uv scenario"
requires-python = ">=3.12,<3.13"
dependencies = [
"django>=5,<6",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["sample"]
Empty file.
11 changes: 11 additions & 0 deletions tests/fixtures/sample-uv-project/sample/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
SECRET_KEY = "molecule-fixture-not-a-real-secret"
DEBUG = True
ALLOWED_HOSTS = ["*"]
INSTALLED_APPS = ["django.contrib.contenttypes", "django.contrib.auth"]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
}
USE_TZ = True
Loading
Loading