Sets up a modern Python project with uv, ruff, ty, and best practices for AI agent compatibility. Creates publishable packages with proper structure, type hints, and documentation.
Resources
1Install
npx skillscat add pratos/clanker-setup/python-uv-setup Install via the SkillsCat registry.
Python UV Project Setup
Activation
When this skill is triggered, ALWAYS display this banner first:
╭─────────────────────────────────────────────────────────────╮
│ 🐍 SKILL ACTIVATED: python-uv-setup │
├─────────────────────────────────────────────────────────────┤
│ Project: [project-name] │
│ Action: Setting up modern Python project with uv... │
│ Output: pyproject.toml, src layout, tests, CI │
╰─────────────────────────────────────────────────────────────╯When to Use
This skill activates when:
- "set up a new Python project"
- "create a Python package"
- "initialize Python with uv"
- "modern Python setup"
- "migrate from conda/poetry/pip to uv"
- "convert requirements.txt to uv"
- Need a clean, agent-friendly Python project structure
Quick Start
Option 1: Use the bootstrap script (recommended)
# Create a library
bash .pi/skills/python-uv-setup/scripts/bootstrap.sh my-package
# Create an application
bash .pi/skills/python-uv-setup/scripts/bootstrap.sh my-cli --appThis creates a complete project with:
- src layout, tests, CI
- pyproject.toml with ruff, pytest, coverage config
- Type hints and py.typed marker
- Git initialized with first commit
Option 2: Manual setup
# Create project directory
mkdir my-package && cd my-package
# Initialize with uv
uv init --lib --name my-package
# Or for an application (not a library)
uv init --app --name my-appMigrating Existing Projects
Brown-field (existing codebase) policy
When the repo already contains Python code, prioritize minimal change. The goal is uv compliance without altering application behavior.
Rules:
- Do not change application code unless explicitly requested.
- Do not move files unless explicitly allowed by the user.
- Preserve existing layout (do not force a
src/layout on brown-field repos). - Preserve declared Python support (do not auto-bump to 3.12 if the project advertises 3.10+) unless the user explicitly states a main version (e.g., “Python 3.12 is the main”). In that case, set
.python-versionandrequires-pythonto match the stated main version.
Brown-field steps (minimal, best practice):
- Read
README.md,requirements*.txt,setup.cfg/setup.py,pyproject.toml, and CI config to capture name, version, Python version range, and dependencies. - If no
pyproject.toml, create one with[project]metadata (name, version, description, readme, license). Reuse existing values when available. - Dependencies: import from existing files and pin exact versions (use
uv pip compileif needed). Preserve separation of runtime vs dev deps. - Add
.python-versionmatching the project’s supported Python (do not bump without permission). - Add
.venv/to.gitignore(keep existing ignore rules intact). - Generate
uv.lock(viauv lockoruv sync) and commit it. - Tooling configs (ruff/ty/pytest): may be added, but keep them conservative and do not enforce in CI unless the user requests.
- If CI exists, update it to use uv only if requested; otherwise leave CI untouched.
Quick Migration Script
# Auto-detect and migrate (backs up existing files)
bash .pi/skills/python-uv-setup/scripts/migrate.sh
# Or specify source explicitly
bash .pi/skills/python-uv-setup/scripts/migrate.sh --from poetry
bash .pi/skills/python-uv-setup/scripts/migrate.sh --from conda
bash .pi/skills/python-uv-setup/scripts/migrate.sh --from requirementsThe script will:
- Backup existing lock files and requirements
- Create/update pyproject.toml
- Import dependencies
- Run
uv sync
From requirements.txt
# 1. Initialize uv in existing project
uv init --bare # Don't overwrite existing files
# 2. Import dependencies from requirements.txt
uv add $(cat requirements.txt | grep -v '^#' | grep -v '^-' | tr '\n' ' ')
# Or if you have pinned versions you want to preserve:
uv pip compile requirements.txt -o requirements.lock
uv add -r requirements.txt
# 3. Import dev dependencies if separate
uv add --dev -r requirements-dev.txt
# 4. Verify
uv sync
uv run python -c "import your_package"Manual alternative - Add to pyproject.toml:
[project]
dependencies = [
"requests>=2.28.0",
"pandas>=2.0.0",
# Copy from requirements.txt, adjust version specs
]From Poetry (pyproject.toml + poetry.lock)
# 1. uv can read Poetry's pyproject.toml directly!
# Just remove poetry-specific sections and add uv build system
# 2. Export poetry deps (optional, for reference)
poetry export -f requirements.txt > requirements-poetry.txt
# 3. Update pyproject.toml - replace Poetry sections:Before (Poetry):
[tool.poetry]
name = "my-package"
version = "0.1.0"
[tool.poetry.dependencies]
python = "^3.11"
requests = "^2.28"
[tool.poetry.group.dev.dependencies]
pytest = "^8.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"After (uv):
[project]
name = "my-package"
version = "0.1.0"
requires-python = "==3.12.*"
dependencies = [
"requests==2.32.3",
]
[project.optional-dependencies]
dev = [
"pytest==8.3.5",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"# 4. Remove poetry files and sync
rm poetry.lock
uv sync --all-extrasFrom Conda (environment.yml)
# 1. Export conda environment to requirements format
conda list --export > conda-packages.txt
# Or from environment.yml, extract pip dependencies:
grep -A 1000 "pip:" environment.yml | grep "^ -" | sed 's/^ - //' > pip-deps.txt
# 2. Initialize uv
uv init --bare
# 3. Add Python-only packages (skip conda-specific like cudatoolkit)
# Review and add manually to pyproject.toml or:
uv add requests pandas numpy # etc.
# 4. For conda-only packages (CUDA, MKL, etc.)
# Keep a minimal conda env OR use system packagesHybrid approach (conda for CUDA + uv for Python):
# Create minimal conda env for CUDA only
conda create -n myenv python=3.12 cudatoolkit=12.1 -y
conda activate myenv
# Use uv for all Python packages
uv syncFrom pip + venv (no pyproject.toml)
# 1. Freeze current environment
pip freeze > requirements-frozen.txt
# 2. Initialize uv with project metadata
uv init --bare
# 3. Edit pyproject.toml with your project info
# Add dependencies (clean up pinned versions if desired)
# 4. Import dependencies
uv add requests pandas # Add your main deps
uv add --dev pytest ruff # Add dev deps
# 5. Remove old venv, let uv manage it
rm -rf venv .venv
uv sync --all-extrasFrom setup.py / setup.cfg
# 1. Convert setup.py to pyproject.toml
# Use the hatch migration tool or manual conversion:
# If you have setup.cfg, it maps closely to pyproject.toml:setup.cfg:
[metadata]
name = my-package
version = 0.1.0
[options]
packages = find:
install_requires =
requests>=2.28
pandas>=2.0pyproject.toml:
[project]
name = "my-package"
version = "0.1.0"
requires-python = "==3.12.*"
dependencies = [
"requests==2.32.3",
"pandas==2.2.3",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"# 2. Remove old files
rm setup.py setup.cfg MANIFEST.in
# 3. Sync
uv syncMigration Checklist
- Backup existing lock files (poetry.lock, Pipfile.lock, conda-lock.yml)
- Note Python version requirement
- List all dependencies (runtime + dev + optional)
- Check for conda-only packages (CUDA, MKL) - may need hybrid approach
- Create pyproject.toml with [project] section
- Add [build-system] with hatchling
- Run
uv sync --all-extras - Run tests to verify:
uv run pytest - Update CI/CD workflows to use uv
- Update README with new install instructions
- Remove old files: requirements*.txt, setup.py, setup.cfg, poetry.lock, Pipfile*
- Commit uv.lock to version control
Project Structure (src layout)
Note: Use this structure for new/green-field projects. For brown-field repos, keep the existing layout unless the user explicitly asks to reorganize.
my-package/
├── .github/
│ └── workflows/
│ └── ci.yml # GitHub Actions CI
├── src/
│ └── my_package/ # Package code (underscore, not hyphen)
│ ├── __init__.py # Package exports
│ ├── py.typed # PEP 561 marker for type hints
│ ├── core.py # Core functionality
│ └── cli.py # CLI entry point (optional)
├── tests/
│ ├── __init__.py
│ ├── conftest.py # pytest fixtures
│ └── test_core.py
├── docs/
│ └── README.md # Detailed documentation
├── .gitignore
├── .python-version # Pin Python version for uv
├── pyproject.toml # Project configuration (single source of truth)
├── README.md # Project overview
├── LICENSE # MIT recommended
└── uv.lock # Lockfile (commit this!)pyproject.toml (Complete Template)
[project]
name = "my-package"
version = "0.1.0"
description = "A short description of your package"
readme = "README.md"
license = { text = "MIT" }
requires-python = "==3.12.*"
authors = [
{ name = "Your Name", email = "you@example.com" }
]
keywords = ["keyword1", "keyword2"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Typing :: Typed",
]
dependencies = [
# Runtime dependencies
]
[project.optional-dependencies]
dev = [
"pytest==8.3.5",
"pytest-cov==6.0.0",
"ruff==0.9.4",
]
[project.scripts]
# CLI entry points
my-cli = "my_package.cli:main"
[project.urls]
Homepage = "https://github.com/username/my-package"
Documentation = "https://github.com/username/my-package#readme"
Repository = "https://github.com/username/my-package"
Issues = "https://github.com/username/my-package/issues"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/my_package"]
# ============================================================
# Ruff - Fast linter and formatter (replaces black, isort, flake8)
# ============================================================
[tool.ruff]
target-version = "py311"
line-length = 88
src = ["src", "tests"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"ARG", # flake8-unused-arguments
"SIM", # flake8-simplify
"TCH", # flake8-type-checking
"PTH", # flake8-use-pathlib
"ERA", # eradicate (commented out code)
"RUF", # ruff-specific rules
]
ignore = [
"E501", # line too long (formatter handles this)
]
[tool.ruff.lint.isort]
known-first-party = ["my_package"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
docstring-code-format = true
# ============================================================
# Pytest
# ============================================================
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = [
"-ra",
"--strict-markers",
"--strict-config",
]
# ============================================================
# Coverage
# ============================================================
[tool.coverage.run]
source = ["src"]
branch = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"if __name__ == .__main__.:",
"raise NotImplementedError",
]Essential Files
src/my_package/init.py
"""My Package - A short description."""
from my_package.core import main_function
__version__ = "0.1.0"
__all__ = ["main_function", "__version__"]src/my_package/py.typed
# PEP 561 marker - this package supports type checking(Empty file, just needs to exist)
src/my_package/core.py
"""Core functionality for my-package."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
def main_function(items: Sequence[str]) -> list[str]:
"""Process items and return results.
Args:
items: Input items to process.
Returns:
Processed items as a list.
Example:
>>> main_function(["a", "b", "c"])
['A', 'B', 'C']
"""
return [item.upper() for item in items]tests/conftest.py
"""Pytest configuration and fixtures."""
import pytest
@pytest.fixture
def sample_data() -> list[str]:
"""Provide sample test data."""
return ["apple", "banana", "cherry"]tests/test_core.py
"""Tests for core functionality."""
from my_package.core import main_function
def test_main_function(sample_data: list[str]) -> None:
"""Test main_function processes items correctly."""
result = main_function(sample_data)
assert result == ["APPLE", "BANANA", "CHERRY"]
def test_main_function_empty() -> None:
"""Test main_function handles empty input."""
assert main_function([]) == [].python-version
3.12New projects: pin to 3.12 for consistency.
Brown-field: match the project's existing supported Python version unless the user explicitly names a main version, in which case use that.
.gitignore
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
*.egg
dist/
build/
# Virtual environments
.venv/
venv/
ENV/
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
# Type checking
.mypy_cache/
# IDEs
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Project specific
*.log.github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true
- name: Set up Python 3.12
run: uv python install 3.12
- name: Install dependencies
run: uv sync --all-extras
- name: Lint with ruff
run: |
uv run ruff check .
uv run ruff format --check .
- name: Type check with ty
run: uv run ty check src/
- name: Test with pytest
run: uv run pytest --cov --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: ./coverage.xmlCommands Reference
Daily Development
# Install dependencies (creates .venv automatically)
uv sync
# Install with dev dependencies
uv sync --all-extras
# Run your code
uv run python -m my_package
uv run my-cli # if CLI entry point defined
# Run tests
uv run pytest
uv run pytest -v --cov
# Lint and format
uv run ruff check .
uv run ruff check . --fix # Auto-fix issues
uv run ruff format .
# Type check
ty check src/Dependency Management
# Add a dependency
uv add requests
# Add dev dependency
uv add --dev pytest-xdist
# Remove a dependency
uv remove requests
# Update all dependencies
uv lock --upgrade
# Update specific dependency
uv lock --upgrade-package requestsBuilding and Publishing
# Build package
uv build
# Publish to PyPI
uv publish
# Publish to TestPyPI first
uv publish --publish-url https://test.pypi.org/legacy/Version Pinning (Strict)
Always pin exact versions - no >=, >, ^, or ~:
# ✅ Good - Exact versions
dependencies = [
"requests==2.32.3",
"pandas==2.2.3",
"numpy==2.2.2",
]
# ❌ Bad - Version ranges
dependencies = [
"requests>=2.28",
"pandas^2.0",
"numpy~=2.0",
]Why exact versions:
- Reproducible builds across all environments
- No surprise breakages from upstream updates
- Easier debugging (everyone has same versions)
- AI agents can rely on specific API behavior
To get current versions:
# Check latest version of a package
uv pip show requests | grep Version
# Or install and check what was resolved
uv add requests
cat uv.lock | grep -A2 '"requests"'Python version:
# .python-version - new projects: 3.12; brown-field: match existing
3.12# pyproject.toml - new projects: pin to 3.12.x; brown-field: preserve existing range
requires-python = "==3.12.*"Agent-Friendly Best Practices
1. Type Everything
# ✅ Good - AI agents can understand types
def process(data: dict[str, int]) -> list[tuple[str, int]]:
return [(k, v) for k, v in data.items()]
# ❌ Bad - No type information
def process(data):
return [(k, v) for k, v in data.items()]2. Docstrings with Examples
def calculate_discount(price: float, percent: float) -> float:
"""Calculate discounted price.
Args:
price: Original price in dollars.
percent: Discount percentage (0-100).
Returns:
Discounted price.
Raises:
ValueError: If percent is not between 0 and 100.
Example:
>>> calculate_discount(100.0, 20.0)
80.0
"""3. Explicit Exports
# __init__.py - Be explicit about public API
__all__ = [
"MainClass",
"helper_function",
"CONSTANT",
]4. Structured Errors
class MyPackageError(Exception):
"""Base exception for my-package."""
class ValidationError(MyPackageError):
"""Raised when validation fails."""
def __init__(self, field: str, message: str) -> None:
self.field = field
self.message = message
super().__init__(f"{field}: {message}")5. Configuration via Pydantic (optional)
uv add pydantic pydantic-settingsfrom pydantic_settings import BaseSettings
class Settings(BaseSettings):
api_key: str
debug: bool = False
max_retries: int = 3
model_config = {"env_prefix": "MY_PACKAGE_"}Checklist
-
uv init --lib --name package-name - Create
src/package_name/directory structure - Add
py.typedmarker file - Configure
pyproject.tomlwith all tools - Add type hints to all functions
- Write docstrings with examples
- Set up tests with pytest
- Create
.github/workflows/ci.yml - Add
.gitignoreand.python-version - Initialize git and make first commit
- Run
uv sync --all-extrasto verify setup - Run
uv run pytestto verify tests work - Run
uv run ruff check .to verify linting - Run
ty check src/to verify types