Python, de la zero Lecția 7 / 60

pathlib: caile din filesystem facute cum trebuie

De ce os.path e mostenire, ce iti dau obiectele Path si setul mic de metode care acopera 95% din munca reala.

Dacă ai învățat Python înainte de prin 2015, fiecare script care atingea filesystem-ul începea cu import os și construia căile lipind string-uri cu os.path.join. Funcționa. Funcționează încă. Dar fiecare linie e un string. Calea nu știe că e o cale. O poți pasa oriunde se așteaptă un string, inclusiv în locuri care n-au treabă cu o cale. Tratarea separatorilor cross-platform e un apel de funcție. Citirea unui fișier devine open(os.path.join(base, "data", "events.json"), "r", encoding="utf-8").read(), opt token-uri de ceremonie pentru o singură operație.

pathlib se află în biblioteca standard din Python 3.4, iar începând cu 3.13 colțurile aspre s-au șlefuit. Obiectele Path sunt calea modernă. Lecția asta e setul mic de metode care acoperă aproape tot ce vei face cu fișierele într-un script real.

Bazele: ce este un Path

from pathlib import Path

p: Path = Path("/tmp/foo.txt")          # POSIX absolute
q: Path = Path("data/raw/events.json")  # relative, OS-agnostic
home: Path = Path.home()                # /home/narcis or C:\Users\narcis
here: Path = Path.cwd()                 # current working directory

Path e o clasă. Pe Linux primești un PosixPath, pe Windows un WindowsPath. De obicei nici nu-ți pasă; ambele au aceeași interfață. Constructorul acceptă string-uri, alte obiecte Path și orice implementează __fspath__(). Nu atinge filesystem-ul. Doar construcția unui Path nu verifică dacă fișierul există, nu face I/O, nu ridică nicio excepție.

Concatenarea căilor: operatorul /

Ăsta e feature-ul-vedetă.

base: Path = Path("data")
events: Path = base / "raw" / "events.json"
# Path('data/raw/events.json') on POSIX
# WindowsPath('data\\raw\\events.json') on Windows

Operatorul / lipește segmentele cu separatorul potrivit pentru OS. Gata cu os.path.join(a, b, c). Nu te mai întrebi ce se întâmplă când unul dintre segmente are un slash final. Partea dreaptă poate fi un string sau un alt Path.

Trei lucruri de știut:

  1. Dacă un segment e o cale absolută, concatenarea se resetează la acea cale absolută. Path("a") / "/b" devine Path("/b"). Asta îi prinde uneori pe oameni; dacă lipești input de la utilizator, fii atent.
  2. / merge în ambele direcții, atâta timp cât o parte e un Path. Și Path("a") / "b", și "a" / Path("b") funcționează.
  3. Nu există operator +. Folosește /.

Proprietatile pe care le vei folosi tot timpul

p: Path = Path("/var/log/app/access.log.gz")

p.name        # 'access.log.gz'    — final component
p.stem        # 'access.log'       — name minus the last suffix
p.suffix      # '.gz'              — last extension only
p.suffixes    # ['.log', '.gz']    — all of them
p.parent      # Path('/var/log/app')
p.parents[1]  # Path('/var/log')   — chain upward
p.parts       # ('/', 'var', 'log', 'app', 'access.log.gz')
p.is_absolute()  # True

stem și suffix sunt cele după care vei întinde mâna în 80% din scripturile de procesat fișiere. Vrei să schimbi extensia?

csv: Path = Path("report.xlsx").with_suffix(".csv")
# Path('report.csv')

Vrei să redenumești fișierul, dar să păstrezi extensia?

renamed: Path = p.with_name("error.log.gz")
renamed = p.with_stem("error")  # 3.9+: change stem only, keep suffix

Acestea returnează obiecte Path noi. Originalul nu se schimbă. Path e imutabil, ca un string.

Verificari de existenta si tip

p: Path = Path("data/raw/events.json")

p.exists()    # True if anything is at that path
p.is_file()   # True if it's a regular file
p.is_dir()    # True if it's a directory
p.is_symlink()

Astea ating filesystem-ul. Urmăresc symlink-urile implicit. Returnează False (în loc să ridice excepție) dacă nu există calea, ceea ce e de obicei ce vrei pentru un guard.

config: Path = Path("config.toml")
if not config.is_file():
    raise SystemExit(f"Missing config: {config}")

Citire si scriere

Metodele de conveniență care înlocuiesc patru linii de boilerplate cu open():

text: str = Path("notes.md").read_text(encoding="utf-8")
data: bytes = Path("image.png").read_bytes()

Path("output.txt").write_text("hello\n", encoding="utf-8")
Path("output.bin").write_bytes(b"\x00\x01\x02")

Fiecare read_text ar trebui să specifice explicit encoding="utf-8". Pe interpretori mai vechi, valoarea implicită depinde de locale și a provocat suficiente bătăi de cap cross-platform încât 3.10 a adăugat un avertisment, 3.15 va face UTF-8 valoarea implicită indiferent de locale, dar tot fii explicit. Tu cel din viitor îi vei mulțumi tu cel din prezent.

Pentru fișiere mari, nu folosi read_text, încarcă tot în memorie. Folosește open():

with Path("huge.log").open("r", encoding="utf-8") as f:
    for line in f:
        process(line)

Path.open() se comportă exact ca open() din built-ins, doar că are calea integrată. Context manager-ul închide fișierul. Streamingul printr-un fișier mare folosește memorie constantă.

Globbing: gasirea fisierelor dupa un sablon

data_dir: Path = Path("data")

# Files in this directory only
csvs: list[Path] = list(data_dir.glob("*.csv"))

# Recursive — every Python file under src/
py_files: list[Path] = list(Path("src").rglob("*.py"))

# Same as rglob but with explicit ** wildcard
also_py: list[Path] = list(Path("src").glob("**/*.py"))

glob returnează un generator, nu o listă. Înfășoară-l în list() dacă trebuie să numeri sau să reutilizezi. Iterează direct dacă procesezi fiecare element și nu te interesează totalul.

for log in Path("/var/log").rglob("*.log"):
    if log.stat().st_size > 1_000_000:
        print(f"big: {log}")

rglob recursează; glob nu. Wildcard-ul ** din glob("**/*.py") face același lucru ca rglob("*.py"), alege-o pe care ți se pare mai lizibilă.

Listarea unui director

for child in Path("data").iterdir():
    print(child.name, "is dir" if child.is_dir() else "is file")

iterdir() produce obiecte Path pentru tot ce se află într-un director: fișiere, subdirectoare, symlink-uri, tot. Fără garanție de ordine; sortează dacă vrei consistență.

sorted_files: list[Path] = sorted(Path("data").iterdir())

Crearea de directoare

out: Path = Path("artifacts/2026/05")
out.mkdir(parents=True, exist_ok=True)

Cele două argumente pe care le vei pasa mereu:

  • parents=True, creează directoarele intermediare. Fără el, mkdir eșuează dacă un părinte nu există (ca distincția veche mkdir vs mkdir -p).
  • exist_ok=True, nu ridica excepție dacă directorul există deja. Fără el, mkdir eșuează la a doua rulare a scriptului.

Împreună: creare idempotentă, recursivă, de directoare. Setează ambele. Mergi mai departe.

Stergerea de lucruri

Path("temp.txt").unlink()             # delete a file
Path("temp.txt").unlink(missing_ok=True)  # don't raise if it's gone
Path("empty_dir").rmdir()             # delete an empty directory

import shutil
shutil.rmtree(Path("build"))          # delete a directory and everything in it

shutil.rmtree e singura operație comună pe fișiere care nu are o metodă pe Path, fiindcă e suficient de distructivă încât importul explicit e o trăsătură, nu un bug. Acceptă direct un Path.

Rezolvarea si compararea cailor

Două căi pot face referire la același fișier dar să arate diferit ca string-uri. data/raw/../raw/events.json și data/raw/events.json sunt aceeași. La fel data/raw/events.json și calea absolută care îl conține. Ca să compari fiabil, normalizează prima.

p: Path = Path("data/raw/../raw/events.json")
p.resolve()
# Path('/home/narcis/project/data/raw/events.json') — absolute, symlinks followed, .. collapsed

resolve() face calea absolută, urmărește symlink-urile și elimină segmentele . și ... Atinge filesystem-ul. Folosește-l înainte să stochezi o cale undeva sau să compari cu alta. Două căi care arată diferit dar trimit la același fișier vor fi egale după resolve():

a: Path = Path("notes.md").resolve()
b: Path = Path("./subdir/../notes.md").resolve()
a == b  # True

O verișoară apropiată care nu atinge filesystem-ul e Path.absolute(), doar prefixează directorul curent dacă e calea relativă. Nu colapsează segmentele .. și nu rezolvă symlink-urile. Pentru calcul pur de cale, fără I/O, absolute() e unealta potrivită. Pentru „e același fișier ca celălalt”, e resolve().

Mai există și samefile():

Path("notes.md").samefile(Path("./subdir/../notes.md"))
# True — same inode

Util pentru „e fișierul la care mă gândesc”, fără să normalizezi manual.

Redenumire si mutare

src: Path = Path("draft.txt")
src.rename(Path("final.txt"))         # rename in place

# Across directories — same operation
src.rename(Path("archive/final.txt"))

# Replace, even if the destination exists
src.replace(Path("final.txt"))

rename va eșua pe unele platforme dacă destinația există; replace suprascrie. Pentru copii (păstrând originalul), folosește shutil.copy2 (păstrează metadatele) sau shutil.copy (doar conținutul):

import shutil
shutil.copy2(Path("source.csv"), Path("backup/source.csv"))

Toate astea acceptă obiecte Path direct. shutil e pe deplin path-aware.

PurePath vs Path: distinctia pe care nu o explica nimeni

Path face I/O. PurePath nu. PurePath e același API de manipulare a căilor, minus orice atinge filesystem-ul.

from pathlib import PurePath, PurePosixPath, PureWindowsPath

p: PurePosixPath = PurePosixPath("/etc/hosts")
p.parent      # works
p.suffix      # works
p.exists()    # AttributeError — no filesystem access

Când e util? Două cazuri:

  1. Teste. Vrei să verifici că funcția ta construiește calea corectă, fără să configurezi un filesystem fals. Pasează-i un PurePath.
  2. Manipulare cross-platform. Ești pe Linux, dar parsezi căi dintr-un log de Windows. PureWindowsPath înțelege separatorii și literele de drive de pe Windows, chiar și pe un host POSIX.

Cea mai mare parte din cod folosește Path simplu. Menționezi că PurePath există și mergi mai departe.

Rescrierea unui script os.path in pathlib

Iată o bucată de cod în stilul vechi:

import os
import os.path

def collect_logs(root: str) -> list[str]:
    out: list[str] = []
    for dirpath, _, filenames in os.walk(root):
        for name in filenames:
            if name.endswith(".log"):
                full = os.path.join(dirpath, name)
                if os.path.getsize(full) > 0:
                    out.append(full)
    return out

archive_dir = os.path.join(root, "archive")
if not os.path.isdir(archive_dir):
    os.makedirs(archive_dir)

for path in collect_logs("/var/log"):
    base = os.path.basename(path)
    dest = os.path.join(archive_dir, base + ".processed")
    with open(path, "r", encoding="utf-8") as src, open(dest, "w", encoding="utf-8") as dst:
        dst.write(src.read())

Aceeași logică în pathlib:

from pathlib import Path

def collect_logs(root: Path) -> list[Path]:
    return [p for p in root.rglob("*.log") if p.stat().st_size > 0]

archive_dir: Path = root / "archive"
archive_dir.mkdir(parents=True, exist_ok=True)

for path in collect_logs(Path("/var/log")):
    dest: Path = archive_dir / (path.name + ".processed")
    dest.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")

Jumătate din linii. Niciun jonglat de string-uri. Semnături de tip care spun „asta e o cale”, nu „asta e un string care se întâmplă să fie o cale”. Când recitești a doua versiune peste un an, vezi dintr-o ochire ce se întâmplă.

Stat info: dimensiune, mtime, mod

Când ai nevoie de metadatele unui fișier, stat() returnează un os.stat_result cu tot ce știe OS-ul despre fișier:

info = Path("events.json").stat()
info.st_size      # bytes
info.st_mtime     # last modification, seconds since epoch (float)
info.st_ctime     # creation time on Windows, metadata-change on POSIX
info.st_mode      # permission bits and file type

Pentru ore de modificare prietenoase la oameni, convertește cu datetime:

from datetime import datetime, timezone

mtime: datetime = datetime.fromtimestamp(
    Path("events.json").stat().st_mtime,
    tz=timezone.utc,
)

Path.stat() urmărește symlink-urile; Path.lstat() nu. Dacă vrei să afli dacă o cale e un symlink către un fișier sau fișierul în sine, folosește lstat() și verifică modul.

Lucrul cu directorul curent de lucru

Path.cwd()                     # absolute path of where the script is running
Path("data/raw").is_absolute()  # False, relative
Path("data/raw").absolute()     # prepend cwd, no symlink resolution

Un Path relativ e rezolvat în raport cu cwd ori de câte ori chiar faci I/O pe el. Asta înseamnă că un script care folosește Path("data/raw/events.json") va citi fișiere diferite în funcție de directorul din care îl rulezi. Dacă vrei o cale relativă la scriptul în sine, ancorează-o cu __file__:

HERE: Path = Path(__file__).resolve().parent
DATA: Path = HERE / "data" / "raw"

Acum DATA indică același loc, indiferent de unde e invocat scriptul. Ăsta e pattern-ul corect pentru orice script care își aduce cu el fișierele de date.

Manunchiul de metode pe care merita sa le memorezi

Dacă nu reții nimic altceva din lecția asta:

  • Path("...") / "..." / "..." pentru concatenare
  • .parent, .name, .stem, .suffix pentru părți
  • .exists(), .is_file(), .is_dir() pentru verificări
  • .read_text(encoding="utf-8"), .write_text(...) pentru fișiere mici
  • .glob("*.csv"), .rglob("**/*.py") pentru căutare
  • .mkdir(parents=True, exist_ok=True) pentru creare
  • .unlink(missing_ok=True) și shutil.rmtree(...) pentru ștergere

Asta e 95%. API-ul complet mai are vreo cincizeci de metode pentru symlink-uri, permisiuni, ownership, hardlink-uri, rezolvare, există când ai nevoie de ele. Majoritatea scripturilor n-au nevoie.

Lecția următoare: mlaștina fusurilor orare. Naive vs aware datetimes, de ce pytz e în sfârșit retras în favoarea zoneinfo din stdlib și capcana DST care strică aritmetica „o zi mai târziu” de două ori pe an.


Referințe: pathlib — Object-oriented filesystem paths, shutil — High-level file operations. Consultat 2026-05-01.

Caută