Python, dalle fondamenta Lezione 7 / 60

pathlib: i percorsi del filesystem fatti come si deve

Perché os.path è legacy, cosa ti danno gli oggetti Path, e il piccolo insieme di metodi che copre il 95% del lavoro reale.

Se hai imparato Python prima del 2015 circa, ogni script che toccava il filesystem iniziava con import os e costruiva i percorsi incollando stringhe insieme con os.path.join. Funzionava. Funziona ancora. Ma ogni riga di quel codice è una stringa. Il percorso non sa di essere un percorso. Lo puoi passare ovunque ci si aspetti una stringa, anche in posti che non hanno nulla a che fare con un percorso. La gestione cross-platform dei separatori è una chiamata di funzione. Leggere un file è open(os.path.join(base, "data", "events.json"), "r", encoding="utf-8").read(): otto token di cerimonia per una sola operazione.

pathlib è nella standard library da Python 3.4, e a partire dalla 3.13 le ruvidezze sono sparite. Gli oggetti Path sono il modo moderno. Questa lezione è il piccolo insieme di metodi che copre quasi tutto quello che farai con i file in uno script vero.

Le basi: cos’è un Path

from pathlib import Path

p: Path = Path("/tmp/foo.txt")          # POSIX assoluto
q: Path = Path("data/raw/events.json")  # relativo, agnostico al sistema operativo
home: Path = Path.home()                # /home/narcis o C:\Users\narcis
here: Path = Path.cwd()                 # directory di lavoro corrente

Path è una classe. Su Linux ottieni un PosixPath, su Windows un WindowsPath. Di solito non te ne importa; entrambi condividono la stessa interfaccia. Il costruttore accetta stringhe, altri oggetti Path, e qualunque cosa implementi __fspath__(). Non tocca il filesystem. Creare un Path non verifica che il file esista, non è I/O, non solleva eccezioni.

Unire percorsi: l’operatore /

Questa è la feature di punta.

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

L’operatore / unisce i segmenti del percorso con il separatore appropriato per il sistema operativo. Niente più os.path.join(a, b, c). Niente più domande su cosa succede se uno dei segmenti ha uno slash finale. Il lato destro può essere una stringa o un altro Path.

Tre cose da sapere:

  1. Se un segmento è un percorso assoluto, l’unione si resetta a quel percorso assoluto. Path("a") / "/b" è Path("/b"). Questo a volte morde; se stai concatenando input dell’utente, fai attenzione.
  2. / funziona in entrambe le direzioni purché un lato sia un Path. Path("a") / "b" e "a" / Path("b") funzionano entrambi.
  3. Non c’è l’operatore +. Usa /.

Le proprietà che userai di continuo

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

p.name        # 'access.log.gz'    -- componente finale
p.stem        # 'access.log'       -- nome meno l'ultimo suffisso
p.suffix      # '.gz'              -- solo l'ultima estensione
p.suffixes    # ['.log', '.gz']    -- tutte
p.parent      # Path('/var/log/app')
p.parents[1]  # Path('/var/log')   -- risali la catena
p.parts       # ('/', 'var', 'log', 'app', 'access.log.gz')
p.is_absolute()  # True

stem e suffix sono i due a cui ricorrerai nell’80% degli script di file processing. Vuoi cambiare estensione?

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

Vuoi rinominare il file ma tenere l’estensione?

renamed: Path = p.with_name("error.log.gz")
renamed = p.with_stem("error")  # 3.9+: cambia solo lo stem, mantieni il suffisso

Questi restituiscono nuovi oggetti Path. L’originale rimane invariato. Path è immutabile, come una stringa.

Controlli di esistenza e tipo

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

p.exists()    # True se c'è qualcosa a quel percorso
p.is_file()   # True se è un file regolare
p.is_dir()    # True se è una directory
p.is_symlink()

Questi colpiscono il filesystem. Seguono i symlink di default. Restituiscono False (invece di sollevare eccezioni) se il percorso non esiste, che di solito è quello che vuoi per una guard.

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

Lettura e scrittura

I metodi di convenienza che sostituiscono quattro righe di boilerplate con 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")

Ogni read_text dovrebbe specificare encoding="utf-8" esplicitamente. Il default dipende dal locale negli interpreti più vecchi e ha causato abbastanza grattacapi cross-platform che la 3.10 ha aggiunto un warning, la 3.15 renderà UTF-8 il default del locale, ma sii esplicito comunque. Il te del futuro ringrazierà il te del presente.

Per file grandi, non usare read_text: carica tutto in memoria. Usa open():

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

Path.open() funziona esattamente come la open() built-in, solo con il percorso già incorporato. Il context manager chiude il file. Lo streaming attraverso un file grande usa memoria costante.

Globbing: trovare file con un pattern

data_dir: Path = Path("data")

# File solo in questa directory
csvs: list[Path] = list(data_dir.glob("*.csv"))

# Ricorsivo: ogni file Python sotto src/
py_files: list[Path] = list(Path("src").rglob("*.py"))

# Come rglob ma con il wildcard ** esplicito
also_py: list[Path] = list(Path("src").glob("**/*.py"))

glob restituisce un generator, non una lista. Avvolgilo con list() se devi contare o riutilizzare. Itera direttamente se stai processando ognuno e non ti importa del totale.

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

rglob ricorre; glob no. Il wildcard ** in glob("**/*.py") fa la stessa cosa di rglob("*.py"): scegli quello che si legge meglio per te.

Elencare una directory

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

iterdir() restituisce oggetti Path per tutto ciò che c’è in una directory: file, sottodirectory, symlink, tutto. Nessuna garanzia sull’ordine; ordina se hai bisogno di consistenza.

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

Creare directory

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

I due argomenti che passerai sempre:

  • parents=True: crea le directory intermedie. Senza questo, mkdir fallisce se un genitore non esiste (come la vecchia distinzione tra mkdir e mkdir -p).
  • exist_ok=True: non sollevare eccezioni se la directory esiste già. Senza questo, mkdir fallisce alla seconda esecuzione del tuo script.

Insieme: creazione di directory idempotente e ricorsiva. Imposta entrambi. Vai avanti.

Cancellare cose

Path("temp.txt").unlink()             # cancella un file
Path("temp.txt").unlink(missing_ok=True)  # non sollevare se è già sparito
Path("empty_dir").rmdir()             # cancella una directory vuota

import shutil
shutil.rmtree(Path("build"))          # cancella una directory e tutto al suo interno

shutil.rmtree è l’unica operazione comune sui file che non ha un metodo di Path, perché è abbastanza distruttiva che l’import esplicito è una feature, non un bug. Accetta direttamente un Path.

Risolvere e confrontare percorsi

Due percorsi possono riferirsi allo stesso file ma sembrare diversi come stringhe. data/raw/../raw/events.json e data/raw/events.json sono lo stesso. Così come lo sono data/raw/events.json e il percorso assoluto che lo contiene. Per confrontare in modo affidabile, normalizza prima.

p: Path = Path("data/raw/../raw/events.json")
p.resolve()
# Path('/home/narcis/project/data/raw/events.json'): assoluto, symlink seguiti, .. collassati

resolve() rende un percorso assoluto, segue i symlink, e rimuove i segmenti . e ... Tocca il filesystem. Usalo prima di salvare un percorso da qualche parte o di confrontarlo con un altro. Due percorsi che sembrano diversi ma si riferiscono allo stesso file risulteranno uguali dopo resolve():

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

Un cugino stretto che non tocca il filesystem è Path.absolute(): aggiunge solo la directory di lavoro corrente davanti se il percorso è relativo. Non collassa i segmenti .. e non risolve i symlink. Per pura matematica sui percorsi senza I/O, absolute() è lo strumento giusto. Per “questo è lo stesso file di quello”, è resolve().

C’è anche samefile():

Path("notes.md").samefile(Path("./subdir/../notes.md"))
# True: stesso inode

Utile per “è questo il file che penso sia” senza normalizzare a mano.

Rinominare e spostare

src: Path = Path("draft.txt")
src.rename(Path("final.txt"))         # rinomina sul posto

# Tra directory: stessa operazione
src.rename(Path("archive/final.txt"))

# Sostituisce, anche se la destinazione esiste
src.replace(Path("final.txt"))

rename fallirà su alcune piattaforme se la destinazione esiste; replace sovrascrive. Per le copie (preservando l’originale), usa shutil.copy2 (preserva i metadati) o shutil.copy (solo i contenuti):

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

Tutti questi accettano direttamente oggetti Path. shutil è path-aware in tutto il modulo.

PurePath vs Path: la distinzione che nessuno spiega

Path fa I/O. PurePath no. PurePath è la stessa API di manipolazione dei percorsi meno tutto ciò che tocca il filesystem.

from pathlib import PurePath, PurePosixPath, PureWindowsPath

p: PurePosixPath = PurePosixPath("/etc/hosts")
p.parent      # funziona
p.suffix      # funziona
p.exists()    # AttributeError: nessun accesso al filesystem

Quando è utile? Due casi:

  1. Test. Vuoi verificare che la tua funzione costruisca il percorso giusto senza preparare un filesystem finto. Passale un PurePath.
  2. Manipolazione cross-platform. Sei su Linux ma stai parsando percorsi da un log file Windows. PureWindowsPath capisce i separatori Windows e le lettere di drive anche su un host POSIX.

La maggior parte del codice usa il semplice Path. Sappi che PurePath esiste, vai avanti.

Riscrivere uno script os.path in pathlib

Ecco un pezzo di codice nel vecchio stile:

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())

Stessa logica in 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")

Metà delle righe. Niente giochetti con stringhe. Type signature che dicono “questo è un percorso”, non “questa è una stringa qualunque che per caso è un percorso”. Quando rileggi la seconda versione fra un anno, capisci a colpo d’occhio cosa sta succedendo.

Info di stat: dimensione, mtime, modalità

Quando ti serve metadata del file, stat() restituisce un os.stat_result con tutto quello che il sistema operativo sa del file:

info = Path("events.json").stat()
info.st_size      # byte
info.st_mtime     # ultima modifica, secondi dall'epoca (float)
info.st_ctime     # tempo di creazione su Windows, cambio di metadata su POSIX
info.st_mode      # bit di permessi e tipo di file

Per tempi di modifica leggibili dagli umani, converti con datetime:

from datetime import datetime, timezone

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

Path.stat() segue i symlink; Path.lstat() no. Se vuoi sapere se un percorso è un symlink a un file o il file stesso, usa lstat() e controlla la modalità.

Lavorare con la directory di lavoro corrente

Path.cwd()                      # percorso assoluto di dove gira lo script
Path("data/raw").is_absolute()  # False, relativo
Path("data/raw").absolute()     # antepone cwd, nessuna risoluzione di symlink

Un Path relativo viene risolto rispetto alla cwd ogni volta che fai I/O. Questo significa che uno script che usa Path("data/raw/events.json") leggerà file diversi a seconda di dove lo lanci. Se vuoi un percorso relativo allo script stesso, ancoralo con __file__:

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

Ora DATA punta allo stesso posto indipendentemente da dove lo script viene invocato. Questo è il pattern giusto per qualunque script che porta con sé i propri file di dati.

La manciata di metodi che vale la pena memorizzare

Se ti ricordi nient’altro di questa lezione:

  • Path("...") / "..." / "..." per unire
  • .parent, .name, .stem, .suffix per le parti
  • .exists(), .is_file(), .is_dir() per i controlli
  • .read_text(encoding="utf-8"), .write_text(...) per file piccoli
  • .glob("*.csv"), .rglob("**/*.py") per cercare
  • .mkdir(parents=True, exist_ok=True) per creare
  • .unlink(missing_ok=True) e shutil.rmtree(...) per cancellare

Questo è il 95%. L’API completa ha altri cinquanta metodi per symlink, permessi, ownership, hardlink, risoluzione: esistono quando ti servono. La maggior parte degli script non ne ha bisogno.

Prossima lezione: la palude dei timezone. Datetime naive vs aware, perché pytz è finalmente in pensione a favore di zoneinfo della stdlib, e la trappola del DST che rompe l’aritmetica “1 giorno dopo” due volte all’anno.


Riferimenti: pathlib — Object-oriented filesystem paths, shutil — High-level file operations. Recuperato il 2026-05-01.

Cerca