Python, de la zero Lecția 16 / 60

Layout-ul proiectului: src/ vs flat, unde merg testele, unde traiesc scripturile

Deciziile de layout care afecteaza importurile, testarea si packaging-ul si conventia care a castigat in 2026.

Poți scrie Python care rulează fără să te gândești vreodată unde merg fișierele. Un singur script.py funcționează bine. Dar în clipa în care vrei să-l testezi, să-l împachetezi, să-l împarți sau pur și simplu să nu te mai împiedici de tine însuți, layout-ul începe să conteze.

Sunt convenții pentru toate astea, iar până în 2026 cea mai mare parte a prafului s-a așezat. Lecția asta e harta.

Două layout-uri pe care le vei vedea

Deschide o sută de repouri Python și vei vedea două pattern-uri.

Layout flat:

weather-cli/
├── pyproject.toml
├── README.md
├── weather_cli/
│   ├── __init__.py
│   ├── api.py
│   └── cli.py
└── tests/
    ├── conftest.py
    └── test_api.py

Directorul pachetului stă la rădăcina proiectului, soră cu tests/ și pyproject.toml. Simplu. Vizibil dintr-o privire.

Layout src/:

weather-cli/
├── pyproject.toml
├── README.md
├── src/
│   └── weather_cli/
│       ├── __init__.py
│       ├── api.py
│       └── cli.py
└── tests/
    ├── conftest.py
    └── test_api.py

Pachetul trăiește un nivel mai adânc, în interiorul unui director src/. Un pic mai mult de tastat. Diferit într-un fel important.

De ce a câștigat src/

Layout-ul flat are o capcană tăcută: când rulezi python din rădăcina proiectului, directorul curent e în sys.path. Așa că import weather_cli merge indiferent dacă pachetul e instalat sau nu. Sună convenabil, până când livrezi un wheel căruia îi lipsește un fișier. Testele tale locale trec. CI-ul trece (importă și el din copia de lucru). Urci pe PyPI, un utilizator instalează și, surpriză, ImportError, fiindcă acel fișier pe care ai uitat să-l adaugi în pachet n-a ajuns niciodată în wheel.

Layout-ul src/ scoate directorul de lucru din ecuația importului. src/ nu e un pachet (nu are __init__.py direct înăuntru), iar weather_cli e cu un nivel prea adânc ca să fie auto-descoperit de path-ul lui Python. Ca să faci import weather_cli, trebuie să instalezi pachetul mai întâi, de obicei cu uv sync sau pip install -e ., care îl construiește în același fel în care va fi construit wheel-ul. Dacă lipsește ceva din configurația de packaging, afli imediat, pe propria mașină, în loc de într-un raport de bug.

Ăsta-i tot motivul. Prinde bug-urile de tip „am uitat să livrez fișierul ăsta” înainte să-ți părăsească laptopul. În 2026 ăsta e implicit-ul recomandat al Python Packaging Authority și ce produce uv init --package.

Mai sunt locuri unde flat e ok: scripturi care nu sunt pachete, proiecte minuscule pe care nu le vei publica niciodată, notebook-uri. Dar pentru orice ai rula teste împotriva lui și ai numi „bibliotecă”, mergi pe src/.

Anatomia unui proiect real

Iată arborele de directoare pentru un proiect Python credibil de mărime medie, gândește-te la o mică pipeline de date sau o unealtă CLI:

weather-cli/
├── .github/
│   └── workflows/
│       └── ci.yml
├── .gitignore
├── .python-version
├── README.md
├── pyproject.toml
├── uv.lock
├── src/
│   └── weather_cli/
│       ├── __init__.py
│       ├── __main__.py
│       ├── api.py
│       ├── cli.py
│       ├── config.py
│       └── py.typed
├── tests/
│   ├── __init__.py
│   ├── conftest.py
│   ├── test_api.py
│   ├── test_cli.py
│   └── fixtures/
│       └── sample_response.json
├── scripts/
│   └── regenerate_fixtures.py
├── notebooks/
│   └── exploration.ipynb
├── data/
│   └── .gitkeep
└── docs/
    └── usage.md

Hai să parcurgem fiecare bucată.

La ce e bun __init__.py

În src/weather_cli/, fișierul __init__.py face trei treburi:

1. Marchează directorul ca pachet. Ăsta e motivul istoric. Python modern suportă și „namespace packages” (directoare fără __init__.py), dar pentru un pachet obișnuit vrei fișierul acolo.

2. Curatează API-ul public. Orice pui în __init__.py e ce văd utilizatorii când fac import weather_cli. Un pattern curat:

# src/weather_cli/__init__.py
"""Tiny weather CLI."""

from weather_cli.api import fetch_forecast, Forecast
from weather_cli.cli import main

__all__ = ["fetch_forecast", "Forecast", "main"]
__version__ = "0.3.1"

Acum utilizatorii scriu from weather_cli import fetch_forecast în loc de from weather_cli.api import fetch_forecast. Structura internă a modulelor e a ta, o poți refactoriza; suprafața publică e stabilă.

3. Expune o constantă de versiune. Fie hard-codată ca mai sus, fie citită dinamic:

from importlib.metadata import version
__version__ = version("weather-cli")

A doua formă are avantajul de a centraliza versiunea: extrage din metadatele pachetului instalat, care au venit din pyproject.toml.

Ce face __main__.py

Dacă creezi src/weather_cli/__main__.py, poți rula pachetul ca modul:

python -m weather_cli --city Rome

Util când vrei invocare python -m alături (sau în loc) de un script de consolă. Un __main__.py tipic:

# src/weather_cli/__main__.py
from weather_cli.cli import main

if __name__ == "__main__":
    raise SystemExit(main())

Ăsta e același punct de intrare la care ai face referire în pyproject.toml:

[project.scripts]
weather = "weather_cli.__main__:main"

așa că weather --city Rome și python -m weather_cli --city Rome fac exact același lucru. Centură și bretele.

Markerul py.typed

Dacă pachetul tău are type hints și vrei ca type checker-ele utilizatorilor din aval să le vadă efectiv, adaugă un fișier gol numit py.typed lângă __init__.py. Asta e PEP 561. Fără el, mypy și pyright tratează pachetul tău ca netipat, chiar dacă fiecare funcție are adnotări. E unul dintre acele câștiguri de o linie care îți ia zece minute să le descoperi prima dată.

Directorul de teste

tests/ oglindește pachetul. Dacă ai src/weather_cli/api.py, ai tests/test_api.py. Asta nu e impusă nicăieri: e doar mult mai ușor să găsești lucrurile.

conftest.py din capul lui tests/ ține fixture-urile partajate. pytest îl auto-descoperă; nu-l imporți. Unul tipic:

# tests/conftest.py
import json
from pathlib import Path
import pytest

FIXTURES = Path(__file__).parent / "fixtures"

@pytest.fixture
def sample_response():
    return json.loads((FIXTURES / "sample_response.json").read_text())

Ar trebui ca tests/ în sine să fie un pachet (cu propriul __init__.py)? Înțelepciunea veche zicea da; cea modernă zice că depinde. Cu layout-ul src/ și logica „rootdir” a lui pytest, de obicei nu ai nevoie. Adaugă unul doar dacă ai coliziuni de nume între fișiere de teste (două test_utils.py în subfoldere diferite): pytest are atunci nevoie de __init__.py ca să dezambiguizeze.

Unde merg scripts/ (și de ce nu în pachet)

scripts/ e pentru automatizare one-off care folosește pachetul, dar nu face parte din API-ul lui public. Lucruri ca:

  • regenerarea fixture-urilor de teste
  • migrări de date în masă rulate o singură dată
  • benchmark-uri
# scripts/regenerate_fixtures.py
"""Run from project root: uv run python scripts/regenerate_fixtures.py"""
from weather_cli.api import fetch_forecast
import json

forecast = fetch_forecast("Rome")
print(json.dumps(forecast, indent=2))

Astea nu sunt instalate cu pachetul. Nu sunt în [project.scripts]. Sunt acolo pur și simplu ca tu și colaboratorii tăi să aveți un loc evident unde să căutați „chestia aia pe care am scris-o o dată”. Punerea lor în interiorul pachetului le-ar livra fiecărui utilizator care îl instalează, ceea ce e nepoliticos.

Notebook-uri și date

notebooks/ e unde merg notebook-urile Jupyter. Testele nu rulează pe ele, type checker-ele le ignoră, iar pyproject.toml-ul tău ar trebui să le excludă din orice tooling. [tool.ruff] exclude = ["notebooks"] e o linie obișnuită.

data/ e pentru fișiere pe care codul tău le citește sau le scrie local. Dacă sunt mari sau generate, gitignore conținutul și comite un .gitkeep ca directorul să existe. Dacă sunt mici și canonice (fixture-uri de teste, CSV-uri de probă), comite-le, dar gândește-te dacă n-ar trebui să stea în tests/fixtures/.

Nu pune date în interiorul directorului pachetului decât dacă sunt date de pachet: fișiere care trebuie să meargă cu wheel-ul fiindcă codul tău le citește la runtime. Pentru asta, folosește importlib.resources:

from importlib.resources import files
config_text = (files("weather_cli") / "default_config.toml").read_text()

și spune build backend-ului să le includă ([tool.hatch.build] sau echivalent).

Importuri în interiorul pachetului

Folosește mereu importuri absolute în propriul tău pachet:

# good
from weather_cli.api import Forecast

# avoid (relative imports — work, but obscure where things come from)
from .api import Forecast

Ambele sunt Python valid. Importurile absolute sunt mai ușor de greppat, mai ușor de mutat (nu trebuie să numeri puncte când reorganizezi) și mai clare la citirea codului în izolare.

Importuri condiționale și leneșe

De cele mai multe ori, importă tot în capul fișierului. Dar două pattern-uri merită cunoscute:

Importuri condiționale pentru dependențe opționale:

try:
    import polars as pl
    HAS_POLARS = True
except ImportError:
    HAS_POLARS = False

def to_dataframe(rows):
    if not HAS_POLARS:
        raise ImportError("Install with `pip install weather-cli[polars]` to use this.")
    return pl.DataFrame(rows)

Importuri leneșe când timpul de import contează, mai ales pentru CLI-uri. Dacă pachetul tău importă pandas în capul fișierului, fiecare invocare de CLI plătește o taxă de pornire de o secundă chiar și când vrei doar --help. Mută importurile grele în interiorul funcțiilor care au nevoie de ele:

def export_to_excel(rows, path):
    import openpyxl  # only imported when this function runs
    ...

Pentru biblioteci publicate pe PyPI poți expune și atribute leneșe prin __getattr__ la nivel de modul (PEP 562), dar e exagerat până nu ești sigur că ai nevoie.

Un noxfile.py sau Makefile?

Drăguț de avut. Ambele îți permit să scrii nox -s tests sau make lint ca să nu memorezi comenzile lungi. Într-un proiect bazat pe uv poți pune comenzile direct în pyproject.toml și să lași uv run să facă treaba: uv run pytest, uv run ruff check ., uv run mypy src/. Suficient pentru majoritatea proiectelor.

Un pattern care îmi place: păstrează un Makefile la nivel superior cu trei sau patru target-uri care înfășoară comenzile reale, ca noii veniți să poată tasta make test și să nu-și bată capul cu toolchain-ul.

.PHONY: test lint fmt typecheck
test:
	uv run pytest
lint:
	uv run ruff check .
fmt:
	uv run ruff format .
typecheck:
	uv run mypy src

Banal, dar documentează comenzile așteptate ale proiectului într-un singur loc și înlătură fricțiunea „cum era comanda aia?”. Utilizatorii Windows fără make pot folosi just pentru același efect.

Un exemplu cu aromă de data engineering

Layout-urile se schimbă ușor în funcție de domeniu. Pentru un proiect de data engineering, gen ceva care extrage dintr-un warehouse, transformă cu polars sau duckdb și emite Parquet, vei vedea adesea un arbore puțin mai bogat:

sales-pipeline/
├── pyproject.toml
├── uv.lock
├── src/
│   └── sales_pipeline/
│       ├── __init__.py
│       ├── extract.py
│       ├── transform.py
│       ├── load.py
│       └── sql/
│           ├── __init__.py
│           └── queries/
│               ├── orders.sql
│               └── customers.sql
├── tests/
│   ├── conftest.py
│   ├── test_transform.py
│   └── fixtures/
│       └── orders_sample.parquet
├── dbt/                       # if you also have a dbt project
├── airflow/                   # DAG definitions
├── notebooks/
└── data/
    ├── raw/                   # gitignored, populated locally
    └── processed/             # gitignored

Ideea nu e că fiecare proiect are nevoie de un director dbt/ sau airflow/: e că lucrurile specifice domeniului trăiesc la nivel superior, lângă (nu în interiorul) pachetului tău Python. Ține src/sales_pipeline/ mic și concentrat; lasă orchestrarea, munca de scratch și datele brute să se răsfire în altă parte.

Nu te lupta cu convențiile

Tentația când începi un proiect e să inventezi ceva isteț: o ierarhie de pachete adânc imbricată, nume custom de directoare, layout-uri novatoare de teste. Rezistă. Fiecare dezvoltator Python care se uită la repo-ul tău (inclusiv tu, peste șase luni) are memorie musculară pentru src/<pachet>/, tests/ și pyproject.toml. Punerea lucrurilor unde se așteaptă oamenii să fie nu costă nimic și economisește o mie de mici fricțiuni.

Rezumatul

  • Layout-ul src/ e implicit-ul în 2026. Prinde bug-urile de packaging din timp.
  • __init__.py curatează API-ul public; __main__.py permite python -m.
  • Testele oglindesc structura pachetului. conftest.py ține fixture-urile partajate.
  • Scripturile, notebook-urile și datele trăiesc în propriile lor directoare la nivel superior, niciodată în interiorul pachetului.
  • Importuri absolute în propriul pachet. Importuri leneșe doar când timpul de pornire doare.
  • Adaugă py.typed dacă pachetul tău are type hints și vrei ca utilizatorii să beneficieze de ele.

Combinat cu lecțiile precedente (pyproject.toml ca configurare, uv ca manager) ai acum tabloul modern complet pentru organizarea și livrarea unui proiect Python. Modulul următor trece la inserții profunde din biblioteca standard: argparse, logging, subprocess și acele bucăți din stdlib care merită știute pe de rost.

Caută