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.pyer stedet hvor du har dinmain()eller CLI kommandoer.config.pysamler al håndtering af miljøvariabler og defaults.core.pyindeholder 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:
- Lav mappen
src/mit_projektog læg dine funktioner icore.py. - Lav en tom
__init__.pyi samme mappe, så Python forstår det som et modul. - Lav en
cli.pyhvor 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:
- Defaults i koden til ting, der er sikre at have standarder for.
- Miljøvariabler til ting der kan variere pr. miljø (dev, prod).
- .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.exampleligger i repoet og viser hvilke variabler, der findes.- Din rigtige
.envligger 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.







Send kommentar
Du skal være logget ind for at skrive en kommentar.