2.1 Fájlok, útvonalak és projektgyökér

2.1.1 Cél és konkrét helyzet

A Northstar labor mappája több parancsot is futtathatóvá tesz: a lab/generate_data.py előállítja a tanulóadatokat, a lab/quickcheck.py pedig ellenőrzi a szerződött fájlokat. Az első kellemetlen tapasztalat általában nem az adattal, hanem az útvonallal van: ugyanaz a parancs működik a repository gyökeréből, de megtörik, ha a lab mappából indítjuk, vagy éppen fordítva.

A feladat most az, hogy ugyanazt a labparancsot két különböző munkakönyvtárból is ugyanazzal az eredménnyel futtassuk, majd a véletlenszerű CWD-függést egy rögzített projektgyökér-szerződéssel szüntessük meg. A megfigyelési egység itt egy teljes parancsinvokáció a hozzá tartozó munkakönyvtárral, a keletkező fájlokkal és az ellenőrző hash-szel. A siker feltétele az, hogy a két különböző helyről indított futtatás azonos fájlokat, azonos tartalommal hozzon létre; a hash-egyezésben nincs tolerancia, mert a quickcheck.py jellegű ellenőrzés bitpontosan hasonlítja össze a rögzített kontrollértékeket.

Előzetes becslés: ha egy script data/raw stílusú relatív utat használ, és a generátor a CWD-be írja a kimenetét, akkor a gyökérből indított python lab/generate_data.py a projekt gyökerébe teszi a fájlokat, a cd lab && python generate_data.py pedig a lab mappába. Az egyik futás biztosan nem oda ír, ahol a quickcheck.py keresi a fájljait, vagy a manifest hash eltér. A hiba nem az adat, hanem a futási hely hallgatólagos feltételezése.

2.1.2 Fogalmi magyarázat: aktuális munkakönyvtár

Az aktuális munkakönyvtár (current working directory, CWD) egy folyamat szintű tulajdonság. Amikor egy terminálban cd projektek/northstar parancsot adunk, a héj munkakönyvtára megváltozik, és minden ezután indított alárendelt folyamat, például a Python interpreter, ezt a munkakönyvtárat örökli. Bashban a pwd, PowerShellben a Get-Location, Pythonban a Path.cwd() mutatja. A CWD nem a script forrásfájljának helye, nem a notebook mappája és nem a repository gyökere automatikusan; csak az a könyvtár, ahonnan a folyamat indult.

A relatív útvonal data/raw/orders_raw.csv mindig a CWD-hez képest értelmezett. Ezért ugyanaz a karaktersorozat két helyről indítva egészen más fájlra mutat. Linuxon, macOS-en és WSL-ben a gyökér és a mappák hierarchiája /-rel kötött, Windowson \-rel; a pathlib éppen ezért platformfüggetlen Path("data") / "raw" felépítést javasol a kézzel írt data\raw helyett.

2.1.3 pathlib: a script helyéből számított út

A Python standard könyvtárának pathlib modulja objektumként kezeli az útvonalakat. A legfontosabbak:

Ha egy script a s005_demo/scripts/make_files.py helyen fekszik, akkor a projektgyökér így rögzíthető:

from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]  # s005_demo/scripts szülője: s005_demo

A módszer érvényességi korlátja, hogy a __file__ csak normál, forrásfájlként futtatott szkriptben értelmezhető. Jupyter-notebookban nincs __file__; interaktív interpreterben, becsomagolt vagy „fagyasztott” futtatható állományban nem feltétlenül valós fájlrendszeri útvonalra mutat. Az alfejezet módszere tehát parancssori scriptekre és modulokra szól, a notebookokra a későbbi 3.4 kernelregisztrációs munkamenet ad teljes megoldást.

2.1.4 data/raw és data/derived: bemenet és felülírható kimenet

A kutatási projekt akkor marad hordozható, ha az olvasó szeme meg tudja különböztetni az eredeti bemenetet az előállított kimenettől. A szokásos szerződés:

A kezdőlabor fájlszinten választja szét a nyers és tiszta adatot; nagyobb projektben a mappaszerkezet is ezt a szerződést tükrözi. A lényeg nem a mappa neve, hanem a felülírhatósági szabály: a raw állománynak nem szabad futás melléktermékeként készülnie, a derived állományt pedig bármikor el lehet dobni, mert a generáló utasítás adott. A kimeneti út (output path) tehát nem lehet a CWD rejtett mellékhatása, hanem a projektgyökérből levezetett, dokumentált útvonal.

2.1.5 UTF-8 és sorvég

Ugyanaz a látható szöveg eltérő bájtokat jelenthet, és a manifest hash bájtokra számol, nem látszatra. A árvíz öt karakter, de UTF-8 kódolásban hét bájt, mert az á és az í két-két bájtot foglal: C3 A1, illetve C3 AD. A karakterek és bájtok megkülönböztetése itt konkrét: a Python len("árvíz") értéke 5, a fájlbeli hossz viszont 7 bájt.

A sorvég ugyanilyen láthatatlan veszélyforrás. Linuxon és macOS-en a sorvég \n (0A), Windowson a hagyományos CRLF \r\n (0D 0A). A Python open() íráskor newline=None esetén Windowson CRLF-re fordíthat; a newline="\n" argumentum explicit LF-et kényszerít. A Path.write_text(..., newline="\n") a Python 3.10-től érhető el, a labor alapjaként használt Python 3.12-ben tehát rendelkezésre áll. A BOM (EF BB BF) sem látható, de megváltoztatja a hash-t; a fájlt a xxd -l 16 kezdősora leplezi le.

A sorvéget verziókezelt projektekben a .gitattributes rögzítheti, például:

*.py text eol=lf
*.csv text eol=lf
*.sh text eol=lf
*.md text eol=lf

Így a CSV-k tartalma platformon át hordozható, és a manifest hash-ek is egyezhetnek, ha a generátor determinisztikus.

2.1.6 Teljes minimális példa

Hozzunk létre egy s005_demo nevű miniprojektet a következő fastruktúrával:

s005_demo/
  scripts/make_files.py
  data/raw/            # a futás hozza létre
  data/derived/        # a futás hozza létre

A scripts/make_files.py tartalma:

from pathlib import Path

# A script a s005_demo/scripts/ mappában van, ezért a projektgyökér a szülő.
ROOT = Path(__file__).resolve().parents[1]
RAW = ROOT / "data" / "raw"
DERIVED = ROOT / "data" / "derived"

RAW.mkdir(parents=True, exist_ok=True)
DERIVED.mkdir(parents=True, exist_ok=True)

source = RAW / "note.txt"
source.write_text("első sor: árvíz €\n", encoding="utf-8", newline="\n")

target = DERIVED / "note_upper.txt"
target.write_text(source.read_text(encoding="utf-8").upper(),
                  encoding="utf-8", newline="\n")

print(f"ROOT={ROOT}")
print(f"source={source.relative_to(ROOT)} size={source.stat().st_size}B")
print(f"target={target.relative_to(ROOT)} size={target.stat().st_size}B")

Futtassuk ugyanazt a scriptet két különböző munkakönyvtárból. Linux/macOS bash munkamenetben:

# Platform: Linux/macOS. Munkakönyvtár először maga a projektgyökér.
cd /home/user/projects/s005_demo
python scripts/make_files.py

# Ezután a szülőből ugyanaz a script:
cd /home/user/projects
python s005_demo/scripts/make_files.py

Az elméletileg várt, szemléltető kimenet, ha a felhasználó útja /home/user/projects:

ROOT=/home/user/projects/s005_demo
source=data/raw/note.txt size=23B
target=data/derived/note_upper.txt size=23B

A második futás pontosan ugyanazt a ROOT értéket nyomtatja, mert az útvonalat a __file__ helyéből származtattuk, nem a CWD-ből. A relatív scripts/make_files.py hívási útvonal eltérhet, a .resolve() akkor is ugyanarra az abszolút fájlra mutat.

2.1.7 Eredmény értelmezése és kézi kontroll

A script három dolgot ellenőrizhetővé tesz. Először, mindkét futás ugyanazt az abszolút ROOT-ot írja, tehát a kimeneti fájlok ugyanoda kerülnek. Másodszor, a source és a target mérete egyaránt 23 B: az eredeti „első sor: árvíz €” szöveg 23 bájt UTF-8-ban, és a nagybetűs „ELSŐ SOR: ÁRVÍZ €” is 23 bájt, mert bár a karakterek közül négy több-bájtos, a kis- és nagybetűs pár ugyanannyi bájtot foglal. A € három bájt (E2 82 AC), a \n egy bájt (0A), a többi ékezetes betű kettő-kettő. Harmadszor, a kiíratás relatív útvonalat használ a relative_to(ROOT) hívással: a naplóban data/raw/note.txt szerepel, nem a gépspecifikus abszolút út, így a két gépen készült naplók csak akkor térhetnek el, ha valóban eltérő a tartalom.

Bitpontos hash-ellenőrzést is futtathatunk a s005_demo gyökérből:

# Platform: Linux/macOS. Munkakönyvtár: /home/user/projects/s005_demo
python - <<'PY'
from pathlib import Path
import hashlib

for p in sorted(Path("data").rglob("*.txt")):
    digest = hashlib.sha256(p.read_bytes()).hexdigest()
    print(p, digest)
PY

A két futás után a hash azonos; az ellenőrzés itt bitpontos, tolerancia nélküli. Ha a fájlt CRLF-re mentenék, a hash azonnal megváltozna, pedig a képernyőn ugyanaz a szöveg látszana.

Hordozható útvonal-szerződés. A munkakönyvtár (CWD), a script helye és a projekt gyökere eltérő fogalmak. A Path(__file__) a modul helyéből indul ki, ezért csak akkor jó a parents[1], ha a dokumentált könyvtárstruktúra valóban fennáll. Telepített csomag esetén az adatfájlok feloldását inkább explicit konfigurációval vagy csomagerőforrással végezzük.

Platformellenőrzés: az útvonalillesztéshez pathlib-ot használjunk, ne kézzel fűzzünk \ vagy / jeleket. Tesztelendő a szóközt tartalmazó projektút, az eltérő CWD és a nem létező bemenet: utóbbinál legyen értelmes hibaüzenet, ne néma új fájl létrehozása.

2.1.8 Feladatok

1. feladat (értelmezés). Adott a következő fastruktúra:

projekt/
  scripts/run.py
  data/input.csv

A scripts/run.py mindössze ennyit tesz:

from pathlib import Path
print(Path.cwd())
print(Path(__file__).resolve())
print(Path("data/input.csv").resolve())

Futtatjuk először cd projekt && python scripts/run.py, majd cd projekt/scripts && python run.py paranccsal. Mindkét futásnál add meg a három sor várt kimenetét, és magyarázd meg, miért változik a harmadik sor, pedig a script ugyanaz. Melyik futásban létezik a data/input.csv fájl?

2. feladat (implementálás). A Northstar repository gyökerébe hozz létre run_lab.sh wrapper szkriptet. Adott input a következő:

northstar/
  lab/generate_data.py
  lab/quickcheck.py

A wrapper a saját fájlhelyéből számítsa ki a projektgyökeret, váltson a lab mappába, majd futtassa a generate_data.py és quickcheck.py parancsokat. Futtasd a wrapper szkriptet a gyökérből, a lab mappából és egy teljesen más munkakönyvtárból is. Mi a siker feltétele, és miért nem elég a python generate_data.py közvetlen hívása a gyökérből?

3. feladat (hibadiagnózis). A /home/user/northstar/notebooks/check.ipynb notebook első cellája:

from pathlib import Path
raw = Path("lab/orders_raw.csv")
print(raw.exists(), raw.resolve())

A Jupyter Labot a /home/user könyvtárból indítottuk, majd a notebookot a fájlkezelőben a northstar/notebooks mappából nyitottuk meg. A cella False értéket és /home/user/lab/orders_raw.csv útvonalat ír. Miért? Adj két javítást, amelyek egyike a Jupyter indítási szerződését rögzíti, a másika védő asserttel leáll, ha a feltétel nem teljesül! Miért nem jó megoldás egy os.chdir("/home/user/northstar") cellát beszúrni a notebook elejére?

4. feladat (transzfer, PowerShell). Írd át a run_lab.sh-t run_lab.ps1 formába Windows PowerShellre. A PowerShell script a $PSScriptRoot változót használja, ne az aktuális könyvtárat! A generált CSV fájlok sorvége maradjon LF, függetlenül attól, hogy a .ps1 fájlt CRLF-fel szerkesztettük. Mit ellenőriz a manifest hash? Mi történik, ha egy Windows-szerkesztő a generált orders.csv fájlt CRLF-re normalizálja?

2.1.9 Megoldások és indoklás

1. feladat megoldása. Az első futásnál a CWD projekt, a __file__ projekt/scripts/run.py, a relatív data/input.csv pedig projekt/data/input.csv. Várt kimenet:

/home/user/projektek/projekt
/home/user/projektek/projekt/scripts/run.py
/home/user/projektek/projekt/data/input.csv

A második futásnál a CWD projekt/scripts, a __file__ ugyanaz a script, de a relatív út a CWD-hez képest oldódik fel, tehát projekt/scripts/data/input.csv. A második futásban a fájl általában nem létezik; a relatív útvonal a munkakönyvtártól függ, nem a script forrásfájljának helyétől. Pontosan ezért köti a projektgyökér-szkript a ROOT-ot a __file__-hoz.

2. feladat megoldása. A run_lab.sh tartalma:

#!/usr/bin/env bash
set -euo pipefail

# A szkript saját könyvtárából számítjuk a projektgyökeret:
# a CWD nem befolyásolhatja a generálás helyét.
THIS_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LAB_DIR="$THIS_DIR/lab"

cd "$LAB_DIR"
python generate_data.py
python quickcheck.py

Futtatás három munkakönyvtárból:

# Linux/macOS bash. Először a repository gyökere:
cd /home/user/northstar
./run_lab.sh

# Ugyanaz a szkript a lab mappából:
cd lab
../run_lab.sh

# Teljesen más munkakönyvtárból:
cd /tmp
/home/user/northstar/run_lab.sh

A siker feltétele az, hogy a lab/generate_data.py által írt fájlok mindhárom esetben a lab mappába kerüljenek, és a lab/quickcheck.py ellenőrző hash-e egyezzen a lab/manifest.json rögzített kontrollértékével. A közvetlen python generate_data.py hívás azért nem elég a gyökérből, mert a gyökérben nincs ilyen fájl: lab/generate_data.py lenne a hívandó, ám ha a generátor a CWD-t használja a kimenethez, akkor a fájlok a gyökérbe kerülnének, ahol a quickcheck.py nem találja őket a szerződött helyen. A wrapper mindig a lab mappába cd-zik, tehát a generátor CWD-je determinisztikus.

A set -euo pipefail azt jelenti: hiba esetén a script azonnal leáll, nem hagyja figyelmen kívül a hibás parancsot. Az unset változó (-u) is hibának számít; ez megakadályozza az elgépelt útvonalak csendes továbbvitelét.

3. feladat megoldása. A Jupyter kernel CWD-je az indítási könyvtár, nem a notebook mappája. A notebookot a böngészőben a northstar/notebooks mappából nyitottuk meg, de a kernel /home/user-ből indult, ezért a Path("lab/orders_raw.csv") a /home/user/lab/orders_raw.csv helyre mutat, ami nem létezik. A notebook helye nem változtatja meg a kernel CWD-jét; a kettő eltérhet, és ez a fejezet gyakori tévedése.

Első javítás: rögzítsd az indítási szerződést, és indítsd a Jupyter Labot a projektgyökérből:

# Linux/macOS bash, a northstar gyökérből:
cd /home/user/northstar
jupyter lab

Ekkor a kernel CWD-je a projektgyökér, és a cellában a relatív lab/orders_raw.csv elérési út helyesen oldódik fel.

Második javítás: védő assert a notebook első cellájában:

from pathlib import Path

PROJECT_ROOT = Path.cwd()
REQUIRED = PROJECT_ROOT / "lab" / "orders_raw.csv"
assert REQUIRED.exists(), f"A kernel CWD nem a Northstar gyökér: {PROJECT_ROOT}"
RAW = REQUIRED

Ez még mindig feltételezi, hogy a Jupytert a projektgyökérből indították, de a feltétel nem hallgatólagos többé: ha megszegik, a notebook azonnal, értelmes üzenettel áll le. Az os.chdir("/home/user/northstar") rossz megoldás, mert a cellába gépspecifikus abszolút utat éget; más gépen megtörik, és a cella újrafuttatása állapotot módosít. A teljes, hordozható kernelindító megoldást a 3.4 alfejezet építi fel.

4. feladat megoldása. A run_lab.ps1 tartalma:

# Futtatás: powershell -ExecutionPolicy Bypass -File .\run_lab.ps1
$ErrorActionPreference = "Stop"

$Root = $PSScriptRoot
$Lab = Join-Path $Root "lab"
Set-Location $Lab

python generate_data.py
if ($LASTEXITCODE -ne 0) {
    throw "generate_data.py failed with exit code $LASTEXITCODE"
}

python quickcheck.py
if ($LASTEXITCODE -ne 0) {
    throw "quickcheck.py failed with exit code $LASTEXITCODE"
}

A $PSScriptRoot a script mappájának abszolút útja, így a hívó PowerShell munkakönyvtára nem befolyásolja a futást. A $LASTEXITCODE a külső python program visszatérési értéke; PowerShellben a Python hibáját nem dobja automatikusan a shell, ezért kell explicit ellenőrzés.

A generált CSV fájlok sorvége attól függetlenül LF marad, hogy a .ps1 forrást CRLF-fel szerkesztettük, mert a CSV-t a Python generate_data.py írja. Ha a generátor a szöveges íráskor explicit newline="\n" beállítást használ, akkor a fájlbeli bájtok közömbösek a Windows szerkesztő beállításaira. A manifest hash a CSV fájlok tényleges bájtjaira számol; ha egy Windows-szerkesztő egy meglévő orders.csv-t CRLF-re normalizál, a hash megváltozik, és a quickcheck.py eltérést jelez, jóllehet a látható szöveg ugyanaz. Ilyenkor a fájlt újra kell generálni, nem a hash-t módosítani; a .gitattributes eol=lf szabálya pedig megelőzi, hogy a verziókezelés maga normalizálja eltérően a fájlt.

2.1.10 Gyakori tévedés ellenpéldával

A leggyakoribb tévedés a notebook helyének és a kernel munkakönyvtárának összekeverése. Képzeljük el, hogy a notebooks/check.ipynb mellé tesszük a data/raw mappát, és a notebookban Path("data/raw/orders_raw.csv")-t írunk. Ha a Jupyter Labot a notebooks mappából indítjuk, a kernel CWD-je a notebooks, tehát a fájl megtalálható. Ha viszont a Jupyter Labot a home könyvtárból vagy a projekt gyökeréből indítjuk, a kernel ugyanazt a notebookot futtatja, de a relatív út más helyre mutat; a notebook mappájának közelsége nem érdekes a kernel számára.

A 1.1-ben láttuk, hogy a nevező hallgatólagos megválasztása csendesen tönkreteszi az arányszámítást. Itt a CWD hallgatólagos megválasztása teszi ugyanezt a fájlműveletekkel: a script fut, a napló készül, csak éppen nem ott és nem arról a fájlról, amelyet a szerződés állít. A diagnózis mindkét esetben ugyanaz: a láthatatlan feltételt explicitté kell tenni a futás elején.

2.1.11 Önellenőrzés

Tedd fel magadnak a következő kérdéseket, mielőtt egy scriptet megosztasz:

  1. Ki tudom mondani a futtatás pillanatában érvényes CWD-t, például pwd vagy Path.cwd() paranccsal?
  2. A projektgyökér a __file__-ból származik, vagy a CWD-től függ?
  3. A raw állomány olvasható-e utasítás nélkül, a derived felülírható-e reprodukálható módon?
  4. A fájlírás explicit UTF-8 és LF beállítást használ, és a hash-t bájtokra ellenőrzöm?
  5. Ugyanaz a parancs két munkakönyvtárból is ugyanazt a manifest hash-t adja?
  6. Jupyter-notebook esetén explicit szerződés vagy assert rögzíti a kernel CWD-jét?

Ha valamelyik nemre változik, előbb a fájlkezelési, nem az adatmodell hibájában keressük a bajt.

2.1.12 Rövid összefoglaló

A hordozható projekt a munkakönyvtártól független projektgyökérből dolgozik. A Path(__file__).resolve() a script tényleges helyéből vezeti le a gyökeret, így ugyanaz a parancs két helyről indítva is ugyanoda ír. A data/raw bemenet és a data/derived kimenet elkülönítése védi az eredeti fájlokat; az explicit UTF-8 és LF beállítás pedig a manifest hash platformfüggetlenségét biztosítja. Jupyterben a kernel CWD-je nem a notebook mappája, ezért azt vagy indítási szerződéssel, vagy védő asserttel kell rögzíteni. A következő alfejezetben a futó folyamatokat vizsgáljuk: hibakimenetet, visszatérési kódot, memóriahasználatot és a háttérben futó Python-folyamatok azonosítását.