Hvordan du tager springet fra rodet Python script til lille app

Hvordan du tager springet fra rodet Python script til lille app

Jeg opdagede først jeg havde et Python problem, da mit “lille script” pludselig bestod af 7 filer med navne som final_v3_really_final.py. Hvis du er derhenne, er det ikke dig der er problemet. Det er strukturen.

I den her tekst viser jeg, hvordan du kan gå fra én fil til et lille projekt, du faktisk kan vedligeholde, dele og køre igen om tre måneder uden panik.

Før vs nu: hvornår et script bør blive til et projekt

Jeg starter med en før/nu sammenligning, for det er ofte her man opdager, at ens Python kaos ikke bare er “lidt rod”, men et mønster.

“Før” – ren script-tilgang “Nu” – lille projekt-tilgang
En enkelt .py-fil i en tilfældig mappe Fast mappestruktur med src/, tests/ og pyproject.toml
Imports der kun virker på din maskine Moduler der kan importeres, fordi projektet er sat op som pakke
Hårdkodede stier og API nøgler i koden Konfiguration i miljøvariabler og .env-fil
print() overalt til debugging Brug af logging med niveauer og formatering
Ingen tests, du “prøver bare lige igen” Få målrettede tests, der fanger de værste regressioner
Du tør ikke røre ved noget efter det virker Du kan refaktorere uden at få hjertebanken

For mig går grænsen typisk her:

  • Scriptet får mere end én funktion, og jeg begynder at scrolle meget.
  • Jeg vil køre det jævnligt (cron job, lille CLI, dataimport).
  • Jeg vil dele det med andre, eller bruge det i et Python portfolio projekt.

Hvis du kan krydse mindst to af de ting af, så tjen dig selv en tjeneste og gør det til et lille projekt nu, ikke “når du får tid”.

Projektstruktur til små Python apps der ikke skal gøre dig skør

Der findes tusind meninger om den rigtige struktur. Jeg har en simpel tommelfingerregel: det skal være nemt at forstå for dit fremtidige selv, der er træt og har glemt alt.

En lille, men stabil mappestruktur

Et godt udgangspunkt til små apps kunne være:

mit_projekt/
  pyproject.toml
  README.md
  .gitignore
  .env.example
  src/
    mit_projekt/
      __init__.py
      cli.py
      config.py
      core.py
  tests/
    test_core.py
    test_cli.py

Nøglerne her er:

  • src/mit_projekt/ gør det til en rigtig Python pakke.
  • cli.py er stedet hvor du har din main() eller CLI kommandoer.
  • config.py samler al håndtering af miljøvariabler og defaults.
  • core.py indeholder det egentlige “arbejde” (logik).

Det lyder måske “for stort” til noget du bare ville have lavet i én fil. Men så snart du har en smule logik, config og en kommandolinje-del, bliver det faktisk nemmere at finde rundt.

Fra tilfældige imports til rigtige moduler

Mini-øvelse: forestil dig, at du flytter din nuværende .py fil ind i src/mit_projekt/core.py. Det kræver typisk tre ting:

  1. Lav mappen src/mit_projekt og læg dine funktioner i core.py.
  2. Lav en tom __init__.py i samme mappe, så Python forstår det som et modul.
  3. Lav en cli.py hvor du importerer og kalder funktionerne.
# src/mit_projekt/core.py

def process_data(row: dict) -> dict:
    # Din logik her
    return {...}
# src/mit_projekt/cli.py

from mit_projekt.core import process_data


def main() -> None:
    # Læs filer, kald process_data, skriv output
    ...


if __name__ == "__main__":
    main()

Bare den adskillelse gør det meget nemmere at teste logikken uden at skulle kalde hele scriptet fra terminalen.

Dependencies uden drama: venv, pyproject, pip vs poetry

Her kan religionen hurtigt melde sig. Jeg prøver at holde mig til praksis: hvad er mindst forvirrende at leve med.

Virtuelt miljø: standardpakken der redder dig senere

Hvis du ikke allerede bruger venv, så kig lige forbi artiklen om at tæmme dit Python kaos med venv på et tidspunkt. Den korte version her:

python -m venv .venv
source .venv/bin/activate  # macOS/Linux
# .venvScriptsactivate  # Windows

Du vil gerne kunne sige til dig selv (og andre):

  • “Projektet bruger Python 3.11” (eller hvad du vælger).
  • “Alle dependencies er installeret i .venv, ikke globalt”.

pyproject.toml: din lille sandhed om projektet

Jeg er ret enig med artiklen om at droppe requirements.txt som primær sandhed. pyproject.toml er ved at være standarden.

Et helt simpelt eksempel med pip og pip-tools kunne se sådan her:

[project]
name = "mit-projekt"
version = "0.1.0"
description = "Lille CSV til rapport app"
authors = ["Dit Navn <dig@example.com>"]
requires-python = ">=3.11"

[project.dependencies]
python-dotenv = "^1.0.0"

[project.optional-dependencies]
dev = [
  "pytest^8.0.0",
]

Hvis du hellere vil bruge Poetry, er mønsteret det samme: ét sted beskriver du dependencies, og resten af tiden installerer du via det.

Poetry vs pip: hvornår jeg vælger hvad

Mit mønster er ret simpelt:

  • Pip + pyproject hvis det er et lille solo projekt, der ikke skal udgives som pakke.
  • Poetry hvis jeg ved, jeg kommer til at have mange dependencies, eller hvis det skal være pænt nok til at andre kan clone og køre uden at tænke for meget.

Det vigtige er ikke værktøjet, men at du rent faktisk har et sted, hvor projektets afhængigheder er beskrevet og kan genskabes.

Config: fra hårdkodet til .env og miljøvariabler

Jeg har selv lavet den klassiske: skrive API nøgle direkte i koden, vise koden på skærmen til en ven og pludselig være den person, der skal rodet med at rotere nøgler en tirsdag aften.

Tre lag af konfiguration

Jeg tænker config i tre lag:

  1. Defaults i koden til ting, der er sikre at have standarder for.
  2. Miljøvariabler til ting der kan variere pr. miljø (dev, prod).
  3. .env filer i udviklingsmiljøet, så du ikke hele tiden skal eksportere ting manuelt.

Strukturen kunne være:

# .env.example
CSV_INPUT_PATH=data/input.csv
CSV_OUTPUT_PATH=data/report.csv
LOG_LEVEL=INFO

Og så en config.py:

# src/mit_projekt/config.py

from dataclasses import dataclass
import os
from dotenv import load_dotenv

load_dotenv()


@dataclass
class Settings:
    csv_input_path: str = os.getenv("CSV_INPUT_PATH", "data/input.csv")
    csv_output_path: str = os.getenv("CSV_OUTPUT_PATH", "data/report.csv")
    log_level: str = os.getenv("LOG_LEVEL", "INFO")


def get_settings() -> Settings:
    return Settings()

Det vigtige her:

  • .env.example ligger i repoet og viser hvilke variabler, der findes.
  • Din rigtige .env ligger ikke i repoet.
  • Koden har fornuftige defaults, så det ikke hele eksploderer uden en .env.

Vil du nørde mere i miljøvariabler generelt, så hænger det fint sammen med kategorien fejlfinding og debugging, for halvdelen af mine “mystiske” fejl ender med at være forkert config.

Logging: fra print til spor du faktisk kan bruge

Jeg elsker print() til små ting, men i det øjeblik noget skal køre som job eller i baggrunden, bliver det hurtigt forvirrende.

Et lille logging-setup der dækker 90 procent

Start simpelt i f.eks. logging_setup.py:

# src/mit_projekt/logging_setup.py

import logging
from .config import get_settings


def setup_logging() -> None:
    settings = get_settings()

    level = getattr(logging, settings.log_level.upper(), logging.INFO)

    logging.basicConfig(
        level=level,
        format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
    )

Og så kalder du det i cli.py:

# src/mit_projekt/cli.py

import logging
from .logging_setup import setup_logging
from .core import process_file

logger = logging.getLogger(__name__)


def main() -> None:
    setup_logging()
    logger.info("Starter CSV rapport")
    process_file()
    logger.info("Færdig med CSV rapport")


if __name__ == "__main__":
    main()

Nu kan du skifte mellem INFO og DEBUG bare ved at ændre LOG_LEVEL i din .env, uden at du skal slette 20 print-statements bagefter.

Hvad du aldrig skal logge

Her bliver det lidt alvorligt, for det rammer også it sikkerhed for udviklere territorium.

  • API nøgler, tokens, passwords.
  • Personnumre, fulde adresser og anden følsom persondata.
  • Hele rå request bodies fra brugere hvis de kan indeholde noget ovenstående.

Hvis du er i tvivl, så lad være. Eller maskér det:

logger.info("Kører job for bruger_id=%s", mask_user_id(bruger_id))

Pointen: Logging skal hjælpe dig med at forstå hvad der sker, ikke være en kopi af alt systemet ved.

Mini-app: fra CSV til lille rapport

Nu får vi det hele i spil. Vi bygger et lille CLI værktøj, der læser en CSV, laver en simpel rapport og skriver en output-fil. Ikke fancy, bare realistisk.

1. Core logik: læs, behandl, skriv

# src/mit_projekt/core.py

import csv
from collections import Counter
from .config import get_settings
import logging

logger = logging.getLogger(__name__)


def generate_report() -> None:
    settings = get_settings()

    logger.debug("Læser input fra %s", settings.csv_input_path)

    with open(settings.csv_input_path, newline="", encoding="utf-8") as f:
        reader = csv.DictReader(f)
        categories = 
for row in reader] counter = Counter(categories) logger.debug("Fandt %d forskellige kategorier", len(counter)) with open(settings.csv_output_path, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["category", "count"]) for category, count in counter.most_common(): writer.writerow([category, count]) logger.info("Skrev rapport til %s", settings.csv_output_path)

2. CLI med argumenter

Så gør vi det muligt at overskrive stier via CLI argumenter, uden at smide miljøvariabler ud.

# src/mit_projekt/cli.py

import argparse
import logging
from .logging_setup import setup_logging
from .config import get_settings, Settings
from .core import generate_report as _generate_report

logger = logging.getLogger(__name__)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Generer rapport fra CSV")
    parser.add_argument("--input", help="Sti til input CSV")
    parser.add_argument("--output", help="Sti til output CSV")
    return parser.parse_args()


def generate_report_with_overrides(args: argparse.Namespace) -> None:
    base = get_settings()
    settings = Settings(
        csv_input_path=args.input or base.csv_input_path,
        csv_output_path=args.output or base.csv_output_path,
        log_level=base.log_level,
    )

    logger.debug("Kører med settings=%s", settings)
    _generate_report()


def main() -> None:
    setup_logging()
    args = parse_args()
    generate_report_with_overrides(args)


if __name__ == "__main__":
    main()

Den her del kan du tune, men pointen er: CLI argumenter oven på config gør dit værktøj fleksibelt, uden at du havner i hårdkodning igen.

Tests light: tre tests der faktisk giver værdi

Jeg er stor fan af tests som “sikkerhedsnet”, men hader at skrive 40 tests til noget småt. Til den her slags mini-apps fokuserer jeg på tre typer:

1. Ren logik test

Hvis du kan hive noget ud af I/O og ind i en ren funktion, så gør det, og test den.

# src/mit_projekt/core.py (ekstra funktion)

from collections import Counter
from typing import Iterable


def count_categories(categories: Iterable[str]) -> Counter:
    return Counter(categories)
# tests/test_core.py

from mit_projekt.core import count_categories


def test_count_categories_simple() -> None:
    result = count_categories(["a", "b", "a"])
    assert result["a"] == 2
    assert result["b"] == 1

2. Konfiguration test

En lille test der sikrer, at dine defaults ikke forsvinder.

# tests/test_config.py

from mit_projekt.config import get_settings


def test_config_has_defaults() -> None:
    settings = get_settings()
    assert settings.csv_input_path.endswith(".csv")
    assert settings.log_level

3. CLI “smoke” test

En test der bare tjekker at din CLI kan køre uden at eksplodere. Ikke dybdegående, bare en røgtest.

# tests/test_cli.py

from mit_projekt.cli import parse_args


def test_parse_args_default() -> None:
    args = parse_args([])  # kræver du justerer funktionen lidt
    assert hasattr(args, "input")
    assert hasattr(args, "output")

Det her ligner måske meget få tests. Men det er tit nok til at du tør refaktorere uden at være helt blind. Hvis du er nysgerrig på flere vinkler på kvalitet, er kategorien test og kvalitet et fint sted at snuse rundt.

Fra “kører lokalt” til cron job eller lille deploy

Til sidst: hvad gør du, når din lille app ikke bare skal bo i terminalen på din egen laptop.

Cron job: den klassiske automatik

Hvis du vil køre scriptet hver nat kl. 02, handler det mest om at:

  • Have et virtuelt miljø der kan aktiveres.
  • Have en fast kommando du kan kalde (f.eks. python -m mit_projekt.cli).
  • Sikre at miljøvariabler / .env er sat op i det miljø cron bruger.

Cron linje kunne være:

0 2 * * * cd /sti/til/mit_projekt && 
  /sti/til/python -m venv .venv && 
  source .venv/bin/activate && 
  python -m mit_projekt.cli

Og ja, du kommer til at fejle på miljøvariabler første gang. Det gør vi alle.

Deploy til server eller container

Hvis du vil en tand længere, kan du pakke appen i en Docker container eller smide den på en lille server. Her er det guld værd, at:

  • Dependencies er beskrevet i pyproject.toml.
  • Konfigurationen ligger i miljøvariabler (ikke i koden).
  • Du har logging, der kan sendes til stdout og fanges af platformen.

Det er også her at kategorien deployment og drift begynder at give mening, og du opdager, at “det virker på min maskine” måske ikke var hele sandheden.

Fra script til app: hvad der faktisk ændrer sig

Hvis jeg skal koge det ned, er forskellen på “et script” og “en lille app” ikke magi, men disciplin på nogle få punkter:

  • Du har en fast struktur, hvor logik, config og CLI er adskilt.
  • Du beskriver dependencies og Python-version et synligt sted.
  • Du bruger miljøvariabler og .env i stedet for hårdkodede værdier.
  • Du logger hændelser, i stedet for bare at printe og håbe.
  • Du har nogle få tests, så du tør ændre noget uden at starte forfra.

Og ja, det er mere arbejde end at smide alt i én fil. Men hvis du ikke gider bruge en halv weekend på at forstå din egen kode om tre måneder, er det nok en meget fair pris.

Personligt synes jeg faktisk, at man lærer mere Python af at strukturere små projekter ordentligt, end af at lave endnu et engangs-script der dør på skrivebordet.

Ida Balslev er den type ven, der pludselig dukker op i din messenger med et link til en lille web-app, hun lige har bygget for sjov – og bagefter gerne viser dig, hvordan du selv kan lave den. Hendes passion for kodning startede med en hjemmebygget hjemmeside til en hestestald og er langsomt vokset gennem aftener med tutorials, fejlmeldinger og små, hjemmelavede projekter.

På Codingclass.dk deler Ida den viden, hun selv manglede i starten: konkrete eksempler, tydelige forklaringer og ærlige historier om, hvad der typisk går galt første, anden og tredje gang. Hun elsker at tage et abstrakt begreb som fx "API" eller "asynkron JavaScript" og koge det ned til noget, du kan se, klikke på og lege med i browseren. For hende handler kodning ikke om at være perfekt, men om at turde prøve, bryde ting og bygge dem op igen.

Ida skriver især om webudvikling med HTML, CSS og JavaScript, små Python-scripts og grundlæggende koncepter som debugging, versionsstyring og struktur i din kode. Hun tænker altid i næste skridt: når du først forstår idéen, viser hun dig, hvordan du kan udvide det med en ekstra funktion, lidt pænere styling eller en smartere måde at tænke din kode på.

Gennem sine artikler på Codingclass.dk vil Ida gerne give dig følelsen af, at du ikke sidder alene med koden – men at der faktisk er en, der har kæmpet med de samme fejlmeddelelser og nu gerne vil vise dig en vej igennem dem, i et tempo hvor alle kan være med.

Send kommentar

You May Have Missed