# Python Development Ubuntu Setup Adding Python development on top of an Ubuntu machine already configured per [[General_Development_Ubuntu_Setup]]. Most of the stack carries over — Ghostty, Starship, Git, GitHub CLI, VS Code, Claude Code, and the bash setup all work identically. This guide only covers the Python-specific additions. It's the Linux counterpart to [[Python_Development_Mac_Tahoe_Setup]], and everything here installs the same way on `amd64` and `arm64`. > [!tip] AI assistance is optional > The reference to Claude Code above assumes you completed Part 7 of the General guide. If you skipped it (which is fine) or use a different AI tool (Copilot, Cursor, Continue, etc.), this guide's instructions still work — the language toolchain doesn't depend on any AI being present. > [!info] Coexistence > C, Python, Ruby, Go, and Rust can all live on the same system without interfering. Each language's toolchain installs to its own prefix: `uv` under `~/.local/share/uv/`, `rv` under `~/.cargo/bin` + `~/.local/share/rv/`, Go under `~/go/`, Rust under `~/.rustup/` and `~/.cargo/`, C tooling under apt. No conflicts. --- ## Why `uv`? `uv` is Astral's unified Python toolchain. It replaces **pyenv + venv + pip + pip-tools + pipx** with a single fast binary and has essentially become the default for new Python projects in 2026. Written in Rust, 10–100× faster than pip for most operations. | Use case | Pre-2024 tool(s) | With uv | |----------|------------------|---------| | Install a Python version | pyenv | `uv python install 3.13` | | Create a virtualenv | `python -m venv` | `uv venv` (auto on `uv init`) | | Install dependencies | pip + requirements.txt | `uv add <pkg>` → `pyproject.toml` + lockfile | | Global CLI tools | pipx | `uv tool install <pkg>` | | Lock dependencies | pip-tools | built-in (`uv.lock`) | | Run scripts | `python foo.py` | `uv run foo.py` | > [!info] Why not the system Python or apt? > Ubuntu ships a Python at `/usr/bin/python3` that the OS itself depends on. **Don't install project packages into it** (and recent Ubuntu blocks `pip install` outside a venv anyway, via PEP 668). `uv` sidesteps all of this by managing its own Python builds — nothing you do touches the system interpreter. --- ## Install uv `uv` isn't in apt, but its official installer is architecture-aware (detects `x86_64` vs `aarch64`): ```bash curl -LsSf https://astral.sh/uv/install.sh | sh ``` This installs to `~/.local/bin/uv` (already on your PATH from the General guide). Open a new shell or `source ~/.bashrc`, then verify: ```bash uv --version ``` ### Install Python versions `uv` manages Python installations independently of the system Python. Multiple versions coexist: ```bash uv python install 3.13 uv python install 3.12 uv python install 3.11 uv python list ``` These install to `~/.local/share/uv/python/` — nothing touches `/usr/bin/python3`. The downloads are Astral's `python-build-standalone` builds, which exist for both `x86_64` and `aarch64` Linux, so this works unchanged on either architecture. ### Global Python CLI tools `uv tool` is the `pipx` replacement. Install commonly-used CLIs globally: ```bash uv tool install ruff uv tool install ipython uv tool install mypy ``` These land in `~/.local/bin/` and are callable by name anywhere. --- ## VS Code extensions for Python ```bash # Core Python support — language server, debugger, formatter integration code --install-extension ms-python.python code --install-extension ms-python.vscode-pylance code --install-extension ms-python.debugpy # Ruff (linter + formatter, matches the CLI you installed with uv) code --install-extension charliermarsh.ruff # Jupyter notebooks (optional) code --install-extension ms-toolsai.jupyter code --install-extension ms-toolsai.jupyter-renderers ``` --- ## VS Code settings Append these language-specific settings to your existing `settings.json` at `~/.config/Code/User/settings.json`. The general settings from [[General_Development_Ubuntu_Setup]] stay in place; these merge with them: ```json { // ── Python ──────────────────────────────────────────────── "python.defaultInterpreterPath": ".venv/bin/python", "python.terminal.activateEnvInCurrentTerminal": true, "python.terminal.activateEnvironment": true, "python.analysis.typeCheckingMode": "basic", "python.analysis.autoImportCompletions": true, "python.analysis.inlayHints.functionReturnTypes": true, "python.analysis.inlayHints.variableTypes": false, "python.testing.pytestEnabled": true, "python.testing.unittestEnabled": false, "[python]": { "editor.defaultFormatter": "charliermarsh.ruff", "editor.formatOnSave": true, "editor.tabSize": 4, "editor.rulers": [88, 120], "editor.codeActionsOnSave": { "source.fixAll.ruff": "explicit", "source.organizeImports.ruff": "explicit" } }, "ruff.nativeServer": "on", "ruff.lineLength": 88, // Hide Python clutter from the file explorer "files.exclude": { "**/__pycache__": true, "**/*.pyc": true, "**/.pytest_cache": true, "**/.ruff_cache": true, "**/.mypy_cache": true }, "search.exclude": { "**/.venv": true, "**/uv.lock": true } } ``` > [!info] `python.defaultInterpreterPath` > Pointing at `.venv/bin/python` means any project made with `uv init` "just works" — VS Code finds the venv, activates it in new terminals, and uses it for Pylance type-checking. No manual interpreter selection per-project. --- ## Full demo: A calculator program This walks through building, testing, debugging, and publishing a four-function calculator from scratch. Parallel implementations exist in C, Ruby, Go, and Rust. ### Create the project ```bash cd ~/projects uv init calc-python --python 3.13 cd calc-python ``` This creates: ``` calc-python/ ├── .python-version ├── .gitignore ├── README.md ├── main.py └── pyproject.toml ``` Remove the auto-generated `main.py` — we'll replace it with a proper project layout. ```bash rm main.py mkdir -p src/calc tests ``` ### `src/calc/__init__.py` — the core logic ```python """Four-function calculator.""" from __future__ import annotations def add(a: float, b: float) -> float: return a + b def sub(a: float, b: float) -> float: return a - b def mul(a: float, b: float) -> float: return a * b def div(a: float, b: float) -> float: if b == 0: raise ZeroDivisionError("division by zero") return a / b def calculate(a: float, op: str, b: float) -> float: """Dispatch to the appropriate operation based on op.""" match op: case "+": return add(a, b) case "-": return sub(a, b) case "*" | "x": return mul(a, b) case "/": return div(a, b) case _: raise ValueError(f"unknown operator {op!r}") ``` ### `src/calc/__main__.py` — the CLI entry point ```python """Command-line entry point for the calculator.""" from __future__ import annotations import sys from calc import calculate def usage(prog: str) -> None: print(f"Usage: {prog} <number> <op> <number>", file=sys.stderr) print(" op: + - * /", file=sys.stderr) print(f"Example: {prog} 6 + 7", file=sys.stderr) def main(argv: list[str] | None = None) -> int: argv = argv if argv is not None else sys.argv if len(argv) != 4: usage(argv[0]) return 1 try: a = float(argv[1]) b = float(argv[3]) except ValueError as e: print(f"Error: {e}", file=sys.stderr) return 1 try: result = calculate(a, argv[2], b) except (ValueError, ZeroDivisionError) as e: print(f"Error: {e}", file=sys.stderr) return 1 # Print as integer if it came out whole print(int(result) if result == int(result) else result) return 0 if __name__ == "__main__": raise SystemExit(main()) ``` ### `tests/test_calc.py` — unit tests with pytest ```python """Tests for the four-function calculator.""" from __future__ import annotations import pytest from calc import calculate class TestCalculate: def test_add(self): assert calculate(6, "+", 7) == 13 def test_sub(self): assert calculate(10, "-", 3) == 7 def test_mul_star(self): assert calculate(4, "*", 5) == 20 def test_mul_x(self): assert calculate(4, "x", 5) == 20 def test_div(self): assert calculate(20, "/", 4) == 5 def test_div_by_zero(self): with pytest.raises(ZeroDivisionError): calculate(5, "/", 0) def test_unknown_operator(self): with pytest.raises(ValueError, match="unknown operator"): calculate(1, "?", 2) @pytest.mark.parametrize( "a, op, b, expected", [ (1, "+", 1, 2), (0, "+", 0, 0), (-1, "+", 1, 0), (1.5, "+", 2.5, 4.0), (10, "-", 20, -10), (3, "*", 4, 12), (1, "/", 3, pytest.approx(0.3333, rel=1e-3)), ], ) def test_operations_parametrized(a, op, b, expected): assert calculate(a, op, b) == expected ``` ### Update `pyproject.toml` Replace the generated `pyproject.toml` with: ```toml [project] name = "calc-python" version = "0.1.0" description = "Four-function command-line calculator" requires-python = ">=3.13" dependencies = [] [project.scripts] calc = "calc.__main__:main" [dependency-groups] dev = [ "pytest>=8.0", "pytest-cov>=5.0", "mypy>=1.10", ] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/calc"] [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] addopts = "-v --cov=calc --cov-report=term-missing" [tool.ruff] line-length = 88 target-version = "py313" [tool.ruff.lint] select = ["E", "F", "I", "N", "W", "UP", "B", "C4", "SIM"] ``` ### Install dev dependencies ```bash uv sync --all-groups ``` This creates `.venv/`, installs `pytest`, `pytest-cov`, `mypy`, and the project itself in editable mode. ### Run everything ```bash # Run the CLI uv run calc 6 + 7 uv run calc 10 - 3 uv run calc 4 x 5 uv run calc 20 / 4 # Or run the module directly uv run python -m calc 6 + 7 # Run tests uv run pytest # Type check uv run mypy src/ # Lint and format (using the global ruff) ruff check src/ tests/ ruff format src/ tests/ # Or fix issues automatically ruff check --fix src/ tests/ ``` ### Debug in VS Code Create `.vscode/launch.json`: ```json { "version": "0.2.0", "configurations": [ { "name": "Debug calc CLI", "type": "debugpy", "request": "launch", "module": "calc", "args": ["6", "+", "7"], "console": "integratedTerminal", "env": { "PYTHONPATH": "${workspaceFolder}/src" } }, { "name": "Debug pytest", "type": "debugpy", "request": "launch", "module": "pytest", "args": ["-v"], "console": "integratedTerminal", "env": { "PYTHONPATH": "${workspaceFolder}/src" } } ] } ``` ### Publish to GitHub Same workflow as any project (see [[General_Development_Ubuntu_Setup#Publishing a repo to GitHub]]): ```bash git init git add . git commit -m "Initial commit: four-function calculator" gh repo create calc-python --private --source=. --remote=origin --push ``` ### `CLAUDE.md` for this project ```markdown # Project: calc-python ## Purpose Four-function command-line calculator in Python — pedagogical example. ## Conventions - Python 3.13 via uv (.python-version pins it) - src/ layout with package `calc` - Tests in tests/ with pytest - Format and lint with Ruff (config in pyproject.toml) - Type hints on all public functions ## Commands - Install deps: `uv sync --all-groups` - Run: `uv run calc <a> <op> <b>` - Test: `uv run pytest` - Test with coverage: `uv run pytest --cov` - Type check: `uv run mypy src/` - Format: `ruff format src/ tests/` - Lint: `ruff check --fix src/ tests/` ## Style - 4-space indent, 88-column line length (Black/Ruff default) - PEP 8 naming: snake_case functions/variables, CamelCase classes - Type hints required on public APIs - `from __future__ import annotations` at top of every module ``` --- ## Full demo: A calculator GUI Same calculator, wrapped in a graphical interface. This uses **Tkinter**, Python's standard GUI toolkit. ### Tkinter on Ubuntu Unlike macOS (where Tk ships with the system Python), on Linux you need the Tk system libraries. uv's `python-build-standalone` interpreters generally bundle Tk, but if you hit an `import tkinter` error, install the system package so it's available: ```bash sudo apt install -y python3-tk ``` > [!info] Why Tkinter? > It ships with Python, zero extra dependencies, works on all platforms, and renders acceptably with Ubuntu's theme. For a pedagogical calculator it's the right default. Alternatives (PySide6, Flet) are noted at the end. ### Add the GUI module Create `src/calc/gui.py`: ```python """Tkinter GUI for the four-function calculator.""" from __future__ import annotations import tkinter as tk from tkinter import ttk from calc import calculate class CalculatorApp: """A simple four-function calculator GUI.""" BUTTON_GRID = [ ["7", "8", "9", "/"], ["4", "5", "6", "*"], ["1", "2", "3", "-"], ["0", ".", "=", "+"], ] def __init__(self, root: tk.Tk) -> None: self.root = root self.root.title("Calculator") self.root.resizable(False, False) # Internal state self.current = "" self.stored: float | None = None self.pending_op: str | None = None self._build_ui() self._bind_keys() def _build_ui(self) -> None: style = ttk.Style() style.configure("Calc.TButton", font=("DejaVu Sans", 16), padding=10) style.configure("CalcOp.TButton", font=("DejaVu Sans", 16, "bold"), padding=10) # Display self.display_var = tk.StringVar(value="0") display = ttk.Entry( self.root, textvariable=self.display_var, justify="right", font=("DejaVu Sans Mono", 24), state="readonly", ) display.grid(row=0, column=0, columnspan=4, sticky="ew", padx=8, pady=8) # Button grid for row_idx, row in enumerate(self.BUTTON_GRID, start=1): for col_idx, label in enumerate(row): style_name = ( "CalcOp.TButton" if label in {"+", "-", "*", "/", "="} else "Calc.TButton" ) btn = ttk.Button( self.root, text=label, style=style_name, command=lambda lbl=label: self._on_press(lbl), ) btn.grid(row=row_idx, column=col_idx, sticky="nsew", padx=2, pady=2) # Clear button spans the bottom clear_btn = ttk.Button(self.root, text="Clear", command=self._clear) clear_btn.grid(row=5, column=0, columnspan=4, sticky="ew", padx=8, pady=8) # Make columns expand evenly for col in range(4): self.root.columnconfigure(col, weight=1, minsize=60) def _bind_keys(self) -> None: for key in "0123456789.": self.root.bind(key, lambda e, k=key: self._on_press(k)) for key in ["+", "-", "*", "/"]: self.root.bind(key, lambda e, k=key: self._on_press(k)) self.root.bind("<Return>", lambda e: self._on_press("=")) self.root.bind("=", lambda e: self._on_press("=")) self.root.bind("<Escape>", lambda e: self._clear()) self.root.bind("<BackSpace>", lambda e: self._backspace()) def _on_press(self, label: str) -> None: if label.isdigit() or label == ".": self.current += label self.display_var.set(self.current or "0") elif label in {"+", "-", "*", "/"}: self._apply_pending() self.pending_op = label self.current = "" elif label == "=": self._apply_pending() self.pending_op = None def _apply_pending(self) -> None: if not self.current: return try: value = float(self.current) except ValueError: self.display_var.set("Error") self._reset_state() return if self.stored is None or self.pending_op is None: self.stored = value else: try: self.stored = calculate(self.stored, self.pending_op, value) except (ZeroDivisionError, ValueError): self.display_var.set("Error") self._reset_state() return # Display whole numbers as integers display_val = int(self.stored) if self.stored == int(self.stored) else self.stored self.display_var.set(str(display_val)) self.current = "" def _reset_state(self) -> None: self.current = "" self.stored = None self.pending_op = None def _clear(self) -> None: self._reset_state() self.display_var.set("0") def _backspace(self) -> None: self.current = self.current[:-1] self.display_var.set(self.current or "0") def main() -> None: root = tk.Tk() CalculatorApp(root) root.mainloop() if __name__ == "__main__": main() ``` ### Add a GUI entry point to `pyproject.toml` ```toml [project.scripts] calc = "calc.__main__:main" calc-gui = "calc.gui:main" ``` Re-sync to pick up the new script: ```bash uv sync --all-groups ``` ### Tests for the GUI state machine GUI event handling is hard to test directly, but the state machine (`_apply_pending`, `_on_press`) is pure logic and easily unit-tested. Create `tests/test_gui.py`: ```python """Tests for the calculator GUI's state machine.""" from __future__ import annotations import tkinter as tk import pytest from calc.gui import CalculatorApp @pytest.fixture def app(): """Create a CalculatorApp with a real but invisible Tk root.""" root = tk.Tk() root.withdraw() app = CalculatorApp(root) yield app root.destroy() class TestCalculatorState: def test_initial_display(self, app): assert app.display_var.get() == "0" def test_single_digit(self, app): app._on_press("5") assert app.display_var.get() == "5" def test_multi_digit(self, app): for ch in "123": app._on_press(ch) assert app.display_var.get() == "123" def test_simple_addition(self, app): app._on_press("6") app._on_press("+") app._on_press("7") app._on_press("=") assert app.display_var.get() == "13" def test_chained_operations(self, app): # 2 + 3 * 4 evaluated left-to-right → (2+3)*4 = 20 for ch in ["2", "+", "3", "*", "4", "="]: app._on_press(ch) assert app.display_var.get() == "20" def test_division(self, app): for ch in ["2", "0", "/", "4", "="]: app._on_press(ch) assert app.display_var.get() == "5" def test_division_by_zero_shows_error(self, app): for ch in ["5", "/", "0", "="]: app._on_press(ch) assert app.display_var.get() == "Error" def test_clear_resets_state(self, app): for ch in ["5", "+", "3"]: app._on_press(ch) app._clear() assert app.display_var.get() == "0" assert app.current == "" assert app.stored is None assert app.pending_op is None def test_decimal_input(self, app): for ch in "1.5": app._on_press(ch) app._on_press("+") for ch in "2.5": app._on_press(ch) app._on_press("=") assert app.display_var.get() == "4" ``` ### Run the GUI ```bash uv run calc-gui ``` A small calculator window opens. Click buttons or use the keyboard — digits, `+`, `-`, `*`, `/`, `Enter` (for `=`), `Esc` (to clear), and `Backspace` (to delete the last digit). ### Run all tests (CLI + GUI) ```bash uv run pytest ``` The `CalculatorApp` tests use a hidden Tk root (`root.withdraw()`) so no windows pop up during test runs. > [!info] Headless testing > Tkinter needs a display connection to instantiate `tk.Tk()`. On a desktop Ubuntu session this works fine. For a **headless** VM or CI runner with no display, wrap the test run with a virtual framebuffer: `sudo apt install -y xvfb` then `xvfb-run -a uv run pytest`. (On a desktop you don't need this.) --- ## Alternative GUI libraries If Tkinter's default look bothers you, here are two drop-in alternatives: ### PySide6 (Qt for Python, native-looking widgets) ```bash uv add pyside6 ``` Good for polished applications. Ships with Qt Designer for drag-and-drop UI layout. Larger install (~200 MB). Available for both `x86_64` and `aarch64` Linux wheels. ### Flet (Flutter-powered, modern declarative UI) ```bash uv add flet ``` Write UIs in pure Python with Flutter widgets. Produces web, desktop, and mobile targets from the same code. For pedagogical work, Tkinter's zero-dependency "it just works" wins. --- ## Starship prompt — Python auto-detection The general Starship config you set up already includes the `[python]` module. When you `cd` into this project, the prompt shows: ``` ~/projects/calc-python main 3.13.2 (calc-python) ❯ ``` Reading left-to-right: directory, git branch, Python version, active venv name. --- ## direnv — auto-activate this project's environment With direnv hooked into your shell (see [[General_Development_Ubuntu_Setup]]), an `.envrc` in the project root loads when you `cd` in and unloads when you leave — so a bare `python`, `pytest`, or `ruff` uses this project's `.venv` without the `uv run` prefix. ```bash # from the project root, after `uv sync` has created .venv/ cat > .envrc <<'EOF' export VIRTUAL_ENV="$PWD/.venv" PATH_add "$VIRTUAL_ENV/bin" EOF direnv allow ``` > [!warning] Create the venv *before* `direnv allow` > `uv init` does **not** create `.venv/` — only `uv sync` (or the first `uv run`) does. If `.venv/` is missing when direnv loads, `python` still resolves to the system interpreter (and a `source .venv/bin/activate` line would error with `.venv/bin/activate: No such file or directory`). Run `uv sync` first, then `direnv allow`. `source .venv/bin/activate` works too once `.venv/` exists, but the two-line export above sidesteps the activate script's prompt rewriting — direnv manages the prompt itself. Either way, `uv run` already uses `.venv` regardless of direnv; this just extends that to tools you invoke directly. --- ## Troubleshooting > [!warning] `python: command not found` after installing via uv > `uv` doesn't put a `python` shim on your PATH globally — it uses `uv run python` inside projects. For a global `python` command, either create a shell alias (`alias python='uv run python'`) or use `uv python install 3.13 --default`. > [!warning] `ModuleNotFoundError: No module named 'tkinter'` > uv's bundled Python usually includes Tk, but if not, install the system Tk: `sudo apt install -y python3-tk`. Then `uv python install 3.13 --reinstall` so uv picks it up. > [!warning] Tkinter window doesn't appear in a VM > You need a display. On a desktop session it just works. On a headless VM, either run a desktop, or use `xvfb-run` for tests (GUIs can't be *seen* without a display). > [!warning] VS Code doesn't find the `.venv/` > Open the workspace from a terminal (`code .` from Ghostty), not from the desktop launcher — VS Code inherits the shell's PATH. Or manually select the interpreter: Ctrl+Shift+P → "Python: Select Interpreter" → pick `./.venv/bin/python`. > [!warning] `ModuleNotFoundError: No module named 'calc'` when running tests > The `src/` layout requires the package path to be set. The provided `pyproject.toml` includes `pythonpath = ["src"]` under `[tool.pytest.ini_options]` — make sure this line is present. --- ## Summary — the one-shot Python addition > [!warning] This is a checklist, not a script > Copy and paste one block at a time. One step in the middle is a **manual file-paste step** (append the Python settings block to `~/.config/Code/User/settings.json`) — do that before the verify step at the end will reflect your editor config. ```bash # Python toolchain (architecture-aware installer) curl -LsSf https://astral.sh/uv/install.sh | sh source ~/.bashrc # Install Python versions uv python install 3.13 3.12 # Global CLI tools uv tool install ruff uv tool install ipython uv tool install mypy # Tk system libraries (for the GUI demo) sudo apt install -y python3-tk # VS Code extensions code --install-extension ms-python.python code --install-extension ms-python.vscode-pylance code --install-extension ms-python.debugpy code --install-extension charliermarsh.ruff code --install-extension ms-toolsai.jupyter # Paste the Python block into ~/.config/Code/User/settings.json # Verify uv --version && uv python list && ruff --version ``` About three minutes on top of the general setup. --- ## Related notes - [[General_Development_Ubuntu_Setup]] - [[Python_Development_Mac_Tahoe_Setup]] — the macOS counterpart - [[C_Development_Ubuntu_Setup]] - [[Ruby_Development_Ubuntu_Setup]] - [[Go_Development_Ubuntu_Setup]] - [[Rust_Development_Ubuntu_Setup]] - [uv Quick Reference](https://docs.astral.sh/uv/) - [pytest Patterns](https://docs.pytest.org/en/stable/how-to/index.html)