# 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)