Why Python is your default automation language
Python is the best first choice when the job involves files, JSON, APIs, data, Home Assistant, n8n helpers, reports, or repetitive administration. It is readable, installed on most Linux systems, and has a strong standard library.
Use it for: calling an API, transforming a CSV, generating a report, checking a service, or gluing two systems together. Do not use it for a tiny shell pipeline that jq already solves, or microcontroller firmware where Arduino C++ belongs.
Safe project setup
Never install project packages into the system Python. Create an isolated virtual environment per project:
mkdir weather-report && cd weather-report
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
pip install requests pytest ruff
pip freeze > requirements.txt
When you return later: source .venv/bin/activate. Put .venv/ in .gitignore.
For new projects, uv is an excellent faster project/package tool:
uv init my-tool
cd my-tool
uv add requests
uv run python main.py
The shape of a good script
from pathlib import Path
import json
DATA_FILE = Path("devices.json")
def load_devices(path: Path) -> list[dict]:
"""Read and validate the local device list."""
try:
return json.loads(path.read_text())
except FileNotFoundError:
raise SystemExit(f"Missing file: {path}")
except json.JSONDecodeError as error:
raise SystemExit(f"Invalid JSON: {error}")
def online_devices(devices: list[dict]) -> list[str]:
return [d["name"] for d in devices if d.get("online")]
if __name__ == "__main__":
print("Online:", ", ".join(online_devices(load_devices(DATA_FILE))))
The pattern matters: imports, constants, small named functions, then one explicit entry point. It is testable and avoids code unexpectedly running when imported elsewhere.
Core syntax you need daily
# Strings and f-strings
name = "Lounge"
message = f"{name} is {temperature:.1f}°C"
# Conditions
if temperature < 18:
action = "heat"
elif temperature > 25:
action = "cool"
else:
action = "idle"
# Lists, dictionaries, loops
entities = ["light.lounge_lamp", "fan.living_room"]
states = {"light.lounge_lamp": "off"}
for entity in entities:
print(entity, states.get(entity, "unknown"))
# Comprehension: transform/filter a list
active = [entity for entity, state in states.items() if state == "on"]
Use None for “no value”, not a made-up string such as "null". Compare with is None, not == None.
Files, paths, CSV, and JSON
from pathlib import Path
import csv, json
config = json.loads(Path("config.json").read_text())
Path("output.txt").write_text("finished\n")
with open("transactions.csv", newline="") as file:
rows = list(csv.DictReader(file))
pathlib.Path is safer and clearer than manually joining file strings. Use with for files, sockets, and responses so cleanup happens even on errors.
Calling REST APIs well
import os
import requests
base_url = os.environ["HASS_URL"]
token = os.environ["HASS_TOKEN"]
headers = {"Authorization": f"Bearer {token}"}
response = requests.get(
f"{base_url}/api/states/sensor.temperature",
headers=headers,
timeout=15,
)
response.raise_for_status()
state = response.json()
print(state["state"])
Rules:
- Read secrets from environment variables, never source files.
- Always set a timeout.
- Call
raise_for_status()before trusting JSON. - Log enough context to diagnose a failure, but never log tokens.
- Handle retries only for genuinely transient failures; do not blindly retry bad requests.
Errors: fail helpfully
try:
result = risky_operation()
except requests.Timeout:
print("The service did not respond in 15 seconds.")
except requests.RequestException as error:
print(f"API request failed: {error}")
raise
Catch the narrowest error you can handle. Avoid except Exception: pass: it hides the information you need to fix the issue.
Type hints and data models
Hints make code easier to read and catch mistakes with tools:
from dataclasses import dataclass
@dataclass
class Device:
entity_id: str
room: str
enabled: bool = True
def notify(device: Device, message: str) -> None:
print(f"{device.room}: {message}")
They are especially valuable once a script grows beyond one file.
Testing with pytest
Put tests in tests/test_name.py:
from app import online_devices
def test_only_online_devices_are_returned():
devices = [{"name": "lamp", "online": True}, {"name": "sensor", "online": False}]
assert online_devices(devices) == ["lamp"]
Run pytest -q. Test pure logic first; fake external APIs rather than making every test depend on the network.
Quality commands
ruff check . # common errors and style issues
ruff format . # formatting
pytest -q # tests
python -m compileall . # basic syntax compilation
Run these before each commit. A formatter removes pointless style debates; a linter catches many mistakes before runtime.
Practical patterns
Command-line arguments
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
Every script that can modify state should offer --dry-run where practical.
Logging
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logging.info("Imported %s transactions", count)
Time zones
Use aware datetimes, never ambiguous local strings:
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
Common traps
| Trap | Better approach |
|---|---|
pip install globally | Use .venv or uv |
| Hard-coded token/password | Environment variable / secret manager |
requests.get() without timeout | Always specify one |
Mutable default def f(items=[]) | Use None, create list inside |
Bare except: | Catch expected exceptions only |
| Huge script with globals | Split into small functions/modules |
| Editing before reproducing a bug | Make a minimal failing case first |
Learning projects for your setup
- Build a CLI that reads HA state and prints a room summary.
- Turn a Firefly CSV export into a monthly category report.
- Monitor a Docker container and send a single alert on state change.
- Fetch weather data, cache it as JSON, and show a concise morning report.