DODATEK · WMODUŁY · pathlib, subprocess, dataclasses, loggingEXT OF · Rozdział 14
Dodatek W / Głębiej: Narzędzia

Wzorce Pythona w kodzie narzędzi

Rozdział 14 wyjaśnił, po co w pipeline jest akurat Python. Ten dodatek pokazuje, jak wygląda kod, który się z niego naprawdę pisze — te same kilkanaście operacji i metod, które wracają w prawie każdym narzędziu, niezależnie od studia.

TL;DR
Etap: Ścieżki i pliki

Ścieżki jako obiekty, nie sklejane stringi

Pomocnik pokazał budowanie ścieżki przez os.path.join — to wciąż działający, częsty w starszym kodzie styl. We współczesnym kodzie narzędziowym dominuje jednak pathlib.Path: ścieżka staje się obiektem z operatorem / zamiast sklejania stringów, i z metodami, które same wiedzą, jak się zachować na Windows i na Linuksie naraz.

from pathlib import Path

publish_dir = Path(show) / task / "publish"
publish_dir.mkdir(parents=True, exist_ok=True)

latest = publish_dir / "shot0100_v004.json"
preview = latest.with_suffix(".jpg")   # shot0100_v004.jpg
print(latest.exists(), latest.parent.name)

Narzędzie rzadko operuje na jednym pliku — częściej na wszystkich plikach danego typu w całym showie. Path.rglob() przeszukuje drzewo katalogów rekurencyjnie i leniwie: nie wczytuje całej listy do pamięci naraz, tylko zwraca kolejne dopasowania jedno po drugim, co ma znaczenie, gdy show ma setki tysięcy plików.

from pathlib import Path

show_root = Path("/shows/demo")
for texture in show_root.rglob("*.tex"):
    print(f"znaleziono: {texture.relative_to(show_root)}")

Nazwy plików w pipeline zwykle kodują metadane w samej nazwie — numer shota, numer wersji. Wyrażenie regularne z nazwanymi grupami wyciąga je naraz, jedną operacją, zamiast ręcznego cięcia stringa po znaku podkreślenia i licząc, że format nigdy się nie zmieni.

import re

pattern = re.compile(r"shot_(?P<shot>\d+)_v(?P<version>\d+)\.\w+$")
match = pattern.match("shot_010_v003.mb")
if match:
    shot = match.group("shot")              # "010"
    version = int(match.group("version"))   # 3
Etap: Procesy zewnętrzne

Python jako klej, nie jako silnik

Rozdział 14 postawił tezę: Python nie zastępuje C++, tylko go spina. W kodzie narzędzia widać to dosłownie — większość linii nie liczy niczego samodzielnie, tylko woła inny program i sprawdza, czy się udało. subprocess.run() to standardowy sposób: przechwycić wyjście, sprawdzić kod wyjścia, nigdy nie zakładać, że zewnętrzny program się nie wywalił.

import subprocess

result = subprocess.run(
    ["ffmpeg", "-i", str(src), "-vframes", "1", str(thumbnail)],
    capture_output=True, text=True,
)
if result.returncode != 0:
    raise RuntimeError(f"ffmpeg nie powiódł się: {result.stderr}")

Samodzielne skrypty farmowe (Rozdział 19) uruchamiają się poza jakimkolwiek DCC, więc potrzebują prawdziwego interfejsu linii poleceń — nie wartości wpisanych na sztywno na górze pliku. argparse daje nazwane flagi, wartości domyślne i, praktycznie za darmo, tryb --dry-run do bezpiecznego testowania.

import argparse

parser = argparse.ArgumentParser(description="Konwersja tekstur showu wsadowo")
parser.add_argument("show")
parser.add_argument("--dry-run", action="store_true",
                     help="tylko pokaż, co by się stało")
args = parser.parse_args()

if args.dry_run:
    print(f"[dry-run] skonwertowałbym tekstury dla {args.show}")

Sesja DCC i samodzielny proces na farmie łączy ten sam kontekst — show, shot — ale nie przez argumenty, tylko przez zmienne środowiskowe, ustawione raz przy budowie rozwiązanego środowiska (Dodatek I). os.environ.get() z wartością domyślną to obronny, standardowy sposób ich odczytu.

import os

show = os.environ.get("SHOW")
shot = os.environ.get("SHOT", "unknown")
if show is None:
    raise EnvironmentError("SHOW nie jest ustawione — uruchom w rozwiązanym kontekście")
Etap: Reprezentacja danych

Kiedy dict przestaje wystarczać

Dict z kluczami-stringami jest wszędzie w kodzie narzędziowym, ale ma cichą wadę: literówka w kluczu nie wywali błędu od razu, tylko gdzieś dalej, jako KeyError w zupełnie innym miejscu kodu. @dataclass daje strukturze nazwę i kształt — pola, typy, czytelny repr — przy minimalnym narzucie w stosunku do zwykłej klasy.

from dataclasses import dataclass

@dataclass
class Shot:
    show: str
    number: str
    frame_range: tuple[int, int]

    @property
    def name(self) -> str:
        return f"{self.show}_{self.number}"

s = Shot("DEMO", "010", (1001, 1150))
print(s.name, s)   # DEMO_010, Shot(show='DEMO', number='010', ...)

Zapytanie do API takiego jak ShotGrid/Flow (Rozdział 8) zwraca listę dictów. Comprehension to idiomatyczny sposób ich filtrowania i przekształcania — jedna linia zamiast pętli for z ręcznym append.

# entities = wynik zapytania do ShotGrid/Flow, lista dictów
approved_shots = [e["code"] for e in entities if e["sg_status_list"] == "apr"]
by_code = {e["code"]: e for e in entities}

Configi w pipeline zwykle mają warstwy — wartości domyślne dla całego showu, nadpisane per shot. Złożenie dwóch dictów jedną linią ({**defaults, **overrides}) to prostszy odpowiednik ręcznego kopiowania klucz po kluczu.

import json

with open(config_path) as f:
    overrides = json.load(f)

defaults = {"fps": 24, "resolution": [1920, 1080]}
config = {**defaults, **overrides}   # overrides wygrywają przy konflikcie
Etap: Odporność

To, co odróżnia demo od narzędzia dla 200 artystów

print() nie ma poziomów, nie da się go wyłączyć ani przekierować do pliku loga na farmie bez przepisywania kodu. logging rozwiązuje wszystkie trzy naraz — i, przy okazji, log.exception() zapisuje pełny traceback, nie tylko treść komunikatu.

import logging

log = logging.getLogger(__name__)

def publish(shot):
    log.info("publikuję %s", shot.name)
    try:
        ...
    except OSError:
        log.exception("publish nie powiódł się dla %s", shot.name)
        raise

Goły except: łapie dosłownie wszystko — literówkę w nazwie zmiennej tak samo jak brak pliku — i chowa błąd zamiast go ujawnić. Rozdział 15 mówił o czytelnym komunikacie zamiast surowego tracebacka; własna klasa wyjątku plus łapanie konkretnego typu to sposób, żeby to zrobić w praktyce.

class PublishError(Exception):
    """Coś poszło nie tak podczas publikowania wersji."""

def publish(shot):
    try:
        _write_version(shot)
    except FileNotFoundError as e:
        raise PublishError(f"brak folderu publish dla {shot.name}") from e

Ostatni powracający wzorzec: „tymczasowo zmień coś, zawsze przywróć — nawet jeśli coś po drodze wybuchnie". Blok with gwarantuje, że kod sprzątający uruchomi się także przy wyjątku; contextlib.contextmanager to najlżejszy sposób, żeby taki menedżer kontekstu napisać samemu, bez pełnej klasy z __enter__/__exit__.

from contextlib import contextmanager

@contextmanager
def temp_namespace(ns):
    previous = cmds.namespaceInfo(currentNamespace=True)
    cmds.namespace(set=ns)
    try:
        yield
    finally:
        cmds.namespace(set=previous)   # wraca nawet po wyjątku

with temp_namespace("REF_charA"):
    cmds.createNode("transform")