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

TrapBetter approach
pip install globallyUse .venv or uv
Hard-coded token/passwordEnvironment variable / secret manager
requests.get() without timeoutAlways specify one
Mutable default def f(items=[])Use None, create list inside
Bare except:Catch expected exceptions only
Huge script with globalsSplit into small functions/modules
Editing before reproducing a bugMake a minimal failing case first

Learning projects for your setup

  1. Build a CLI that reads HA state and prints a room summary.
  2. Turn a Firefly CSV export into a monthly category report.
  3. Monitor a Docker container and send a single alert on state change.
  4. Fetch weather data, cache it as JSON, and show a concise morning report.