# Python Development Mac Tahoe Setup Adding Python development on top of an Apple Silicon Mac already configured per [[General_Development_Mac_Tahoe_Setup]]. Most of the stack carries over — Ghostty, Starship, Git, GitHub CLI, VS Code, Claude Code, and the zsh setup all work identically. This guide only covers the Python-specific additions. > [!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 — your dev environment works without any AI) 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 `~/.rv/`, Go under `~/go/`, Rust under `~/.rustup/` and `~/.cargo/`, C/CMake under Homebrew. 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` | --- ## Install uv ```bash brew install uv ``` 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`. ### 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 `~/Library/Application Support/Code/User/settings.json`. The general settings from [[General_Development_Mac_Tahoe_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_Mac_Tahoe_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**, which ships with Python — no install needed. ### Why Tkinter? | Option | Pros | Cons | |--------|------|------| | **Tkinter** | Ships with Python, zero dependencies, works on all platforms, native macOS look | Dated visual style out of the box | | **PyQt6 / PySide6** | Professional native widgets, extensive | Larger install, LGPL/GPL licensing considerations | | **Flet** | Flutter-powered, modern, declarative | Adds a runtime dependency | | **Kivy** | Touch-friendly, cross-platform mobile | Custom rendering, not native-looking | For a pedagogical calculator, Tkinter is the right default. Apple Silicon's Python ships with a modern Tk (8.6+) that renders acceptably on macOS 26. ### 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=("SF Pro", 16), padding=10) style.configure("CalcOp.TButton", font=("SF Pro", 16, "bold"), padding=10) # Display self.display_var = tk.StringVar(value="0") display = ttk.Entry( self.root, textvariable=self.display_var, justify="right", font=("SF 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 requires a display connection to instantiate `tk.Tk()`. On macOS this works fine even in Terminal sessions. For CI environments (GitHub Actions, etc.), you'd typically either skip GUI tests on headless runners or use `Xvfb` on Linux. --- ## Alternative GUI libraries If Tkinter's default look bothers you, here are two drop-in alternatives: ### PySide6 (Qt for Python, native macOS widgets) ```bash uv add pyside6 ``` Good for polished applications. Ships with Qt Designer for drag-and-drop UI layout. Larger install (~200 MB). ### 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. Best if you want a modern aesthetic without hand-crafting styles. For pedagogical work, Tkinter's zero-dependency "it just works" wins. Both alternatives are worth knowing about for real applications. --- ## 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. --- ## 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] Tkinter GUI window doesn't appear / crashes on launch > macOS 26 ships with a modern Tcl/Tk built-in; `uv`'s Python versions use it. If you see Tcl errors, try: `uv python install 3.13 --reinstall`. If problems persist, install `tcl-tk` via Homebrew: `brew install tcl-tk`. > [!warning] VS Code doesn't find the `.venv/` > Open the workspace from a terminal (`code .` from Ghostty), not from Spotlight — VS Code inherits the shell's PATH. Or manually select the interpreter: Cmd+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 > The block below is **not a shell script you can save and run.** It's a sequence of commands to **copy and paste into your terminal one block at a time**, in order. One step in the middle is a **manual file-paste step** (commented as "Paste the Python block into ~/Library/Application Support/Code/User/settings.json") — you have to open that file and append the recommended Python settings block from earlier in this guide before the verify step at the end will pass. ```bash # Python toolchain brew install uv # 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 # 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 ~/Library/Application Support/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_Mac_Tahoe_Setup]] - [[C_Development_Mac_Tahoe_Setup]] - [[Ruby_Development_Mac_Tahoe_Setup]] - [[Go_Development_Mac_Tahoe_Setup]] - [[Rust_Development_Mac_Tahoe_Setup]] - [uv Quick Reference](https://docs.astral.sh/uv/) - [pytest Patterns](https://docs.pytest.org/en/stable/how-to/index.html)