Python - podstawy

Materiał przygotowany z pomocą AI

Dane w przykładach

Wartości użyte w przykładach hydrologicznych (kody stacji, stany wody, przepływy, rzędne zera wodowskazów) są ilustracyjne — wybrane tak, żeby były wiarygodne dla danej stacji, ale nie były weryfikowane i mogą różnić się od rzeczywistych. Wszystkie fragmenty kodu są kompletne i samodzielnie wykonalne na CPython 3.10+ (chyba że oznaczono inaczej).

Środowisko do uruchomienia

Bloki kodu zostały zweryfikowane w Pythonie 3.12.12 z zafiksowanymi wersjami pakietów. Pełna instrukcja odtworzenia środowiska: Środowisko Python dla przykładów.

Python to interpretowany, dynamicznie typowany język programowania ogólnego przeznaczenia, znany z czytelnej składni i bardzo szerokiego ekosystemu bibliotek. Dominuje w analizie danych, automatyzacji, uczeniu maszynowym i zastosowaniach naukowych — w tym w hydrologii, meteorologii i GIS. Większość narzędzi opisanych w pozostałych wpisach tej kategorii (Hydrograf, Hydrolog, IMGWTools, Kartograf) jest napisana właśnie w Pythonie.

Ten wpis przedstawia fundamenty: czym Python jest i jak działa, jak go zainstalować i uruchomić, jakie ma typy danych i struktury, jak pisze się funkcje i klasy, jak organizuje kod w modułach i jak zarządza zależnościami. Przykłady proste mają charakter ogólny, zaawansowane trzymają się domeny hydrologicznej — odczytu pliku CSV ze stacjami, obliczeń statystycznych, zapisu raportów.

Czym jest Python

Python został stworzony przez Guido van Rossuma na przełomie lat 80. i 90. Jego cechy:

  • interpretowany — kod nie jest kompilowany do pliku wykonywalnego; uruchamia go interpreter linia po linii (de facto: bytecode wykonywany w wirtualnej maszynie);
  • dynamicznie typowany — typ zmiennej ustala się w czasie wykonania, ta sama nazwa może wskazywać na obiekty różnych typów;
  • wieloparadygmatowy — wspiera programowanie strukturalne, obiektowe i funkcyjne;
  • z zarządzaną pamięcią — programista nie alokuje ani nie zwalnia pamięci ręcznie, robi to garbage collector.

Wersje i implementacje

Aktywnie rozwijana jest linia Python 3.x — kolejne wersje wychodzą rocznie (3.10, 3.11, 3.12, 3.13…). Każda dokłada nowe konstrukcje składniowe i optymalizacje wydajności, ale zachowuje wsteczną kompatybilność na poziomie semantyki języka.

ImplementacjaCharakterystyka
CPythonReferencyjna, napisana w C — to ją mamy na myśli mówiąc „Python”
PyPyAlternatywna z JIT-em, kilkukrotnie szybsza dla długich obliczeń numerycznych
MicroPythonOkrojony Python na mikrokontrolery (ESP32, Raspberry Pi Pico)
JythonPython na JVM (rzadziej używany)
IronPythonPython na .NET (rzadziej używany)

Python 2 — koniec wsparcia

Python 2 oficjalnie zakończył życie 1 stycznia 2020 roku. Nowy kod piszemy wyłącznie w Pythonie 3. Jeśli trafiasz na poradnik z print "x" (bez nawiasów) — to Python 2, pomijaj.

Jak Python wykonuje kod

Po uruchomieniu skryptu CPython kompiluje go do bytecode’u (pliki .pyc w katalogu __pycache__/), a potem wykonuje go w wewnętrznej maszynie wirtualnej. Dla użytkownika cały proces wygląda jak „uruchomienie kodu źródłowego” — kompilacja jest niewidoczna i w 99% przypadków nie ma znaczenia.

Tryby uruchomienia:

  • REPL (Read-Eval-Print Loop) — interaktywna konsola, świetna do eksperymentów; uruchamiana komendą python3 bez argumentów;
  • skrypt — python3 nazwa_skryptu.py;
  • moduł — python3 -m nazwa_modułu (np. python3 -m http.server);
  • inline — python3 -c "print('test')" dla jednolinijkowca.

Zen of Python (PEP 20)

Filozofia języka — wpisana w sam Python jako ukryty moduł:

import this

Kluczowe zasady (skrót): czytelne jest lepsze niż sprytne, proste lepsze niż złożone, jawne lepsze niż domniemane, jest jeden — najlepiej tylko jeden — oczywisty sposób żeby coś zrobić. Te zasady są często cytowane podczas dyskusji o stylu kodu.

Instalacja i uruchamianie

Instalacja: Linux / macOS / Windows

Większość dystrybucji Linuksa ma Pythona w pakietach systemowych:

# Debian / Ubuntu
sudo apt install python3 python3-pip python3-venv
 
# Fedora / RHEL
sudo dnf install python3 python3-pip

macOS: najprościej przez Homebrew (instalacja Homebrew opisana w Komendy terminala - Linux i macOS):

brew install [email protected]

Windows: pobierz instalator z python.org/downloads. Podczas instalacji zaznacz „Add Python to PATH” — bez tego komenda python z konsoli nie zadziała.

Sprawdzenie, czy Python jest zainstalowany:

python3 --version

Wynik dla świeżej instalacji 3.12 wygląda tak:

Python 3.12.5

pyenv — zarządzanie wieloma wersjami Pythona

pyenv to zewnętrzne narzędzie, które pozwala zainstalować na jednej maszynie wiele wersji Pythona obok siebie i przełączać się między nimi per-projekt. Bez niego utykamy z wersją systemową, a różne projekty mogą wymagać różnych wersji (np. starszy moduł działa tylko na 3.10, nowy projekt na 3.12). Szczegóły w sekcji Wersje, środowiska wirtualne i zależności.

REPL i pierwszy skrypt

REPL uruchamiamy bez argumentów:

$ python3
Python 3.12.5 (main, Aug  6 2024, 19:08:49)
>>> 2 + 2
4
>>> print("Witaj")
Witaj
>>> exit()

Skrypt to plik z rozszerzeniem .py. Najprostszy skrypt:

# plik: 01_hello_world.py
print("Witaj, Świecie!")

Uruchomienie:

python3 hello.py

Shebang i python3 vs python

Jeśli chcemy uruchamiać skrypt bezpośrednio (./hello.py), pierwsza linia pliku musi zawierać tzw. shebang wskazujący interpreter:

#!/usr/bin/env python3
print("Witaj")

Plik trzeba dodatkowo oznaczyć jako wykonywalny komendą chmod +x hello.py (szczegóły w Komendy terminala - Linux i macOS).

Na większości systemów python (bez trójki) wskazuje na Pythona 2 lub w ogóle nie istnieje. Bezpieczne jest pisać python3 — i tego konwencjonalnie używamy w shebangach.

Składnia i konwencje

Wcięcia jako składnia

W Pythonie wcięcia są częścią składni — definiują bloki kodu. W innych językach mamy nawiasy klamrowe { }, tu mamy spacje:

x = 10
if x > 5:
    print("duża wartość")
    print("nadal w bloku if")
print("już poza if")

Standard (PEP 8) to 4 spacje na poziom. Mieszanie spacji i tabulatorów to klasyczne źródło IndentationError. Większość edytorów konfiguruje się tak, by tab wstawiał 4 spacje — ustawienie warto zweryfikować od razu.

Komentarze i docstring

# Komentarz jednoliniowy
 
x = 5  # komentarz inline
 
"""
Wieloliniowy string używany jako komentarz.
W Pythonie nie ma osobnej składni dla komentarzy blokowych
- używa się stringów wieloliniowych.
"""
 
 
def policz_srednia(wartosci):
    """Zwraca średnią arytmetyczną listy liczb.
 
    Pierwsza linia to skrótowy opis. Po pustej linii - szczegóły.
    Docstring jest dostępny w runtime jako `policz_srednia.__doc__`.
    """
    return sum(wartosci) / len(wartosci)
 
 
# Zwraca pierwszą linię docstringa
print(policz_srednia.__doc__.splitlines()[0])

Docstring (string jako pierwsza instrukcja w funkcji, klasie lub module) jest standardową formą dokumentacji — czytany przez help(), IDE i generatory dokumentacji. Konwencje opisuje PEP 257. Bardziej rozbudowany format z sekcjami parametrów, wartości zwracanych i przykładów (styl NumPy używany w narzędziach hydrologicznych) pokazuje sekcja Docstring jako dokumentacja niżej.

Identyfikatory i konwencje nazewnictwa

ElementKonwencjaPrzykład
Funkcje, zmienne, modułysnake_casepolicz_srednia, stan_wody
KlasyPascalCaseStacjaPomiarowa, Pomiar
StałeUPPER_SNAKE_CASEMAX_PRZEPLYW, API_URL
Prywatne (konwencja)prefiks __pomocnicza, _cache
Magiczne (Python sam)dunder __x____init__, __str__

Identyfikatory nie mogą zaczynać się od cyfry, zawierać myślnika ani spacji. Mogą zawierać polskie znaki, ale w praktyce ich unikamy — są źródłem problemów z encoding-iem między systemami.

Typowanie dynamiczne i type hints

Python jest dynamicznie typowany — typ wynika z aktualnej zawartości:

x = 10
print(type(x))
x = 10.0
print(type(x))
x = "tekst"
print(type(x))
x = [1, 2, 3]
print(type(x))

Od wersji 3.5 można dodawać podpowiedzi typów (PEP 484). Nie wpływają na działanie programu, ale dokumentują intencję i są weryfikowane przez statyczne narzędzia (mypy, pyright):

def policz_srednia(wartosci: list[float]) -> float:
    return sum(wartosci) / len(wartosci)
 
 
print(policz_srednia([1.5, 2.5, 3.0]))

Type hints w runtime

CPython wczytuje adnotacje, ale nie sprawdza ich w czasie działania — to nie jest TypeScript ani Java. Funkcja zadeklarowana jako zwracająca float zwróci nawet stringa, jeśli tak ją napiszesz, i nie dostaniesz błędu od interpretera. Walidację daje dopiero zewnętrzny linter typów (mypy).

Kodowanie pliku źródłowego

Python 3 traktuje pliki .py jako UTF-8 domyślnie — można swobodnie używać polskich znaków w komentarzach, stringach i (technicznie) w identyfikatorach. W kodzie z czasów Pythona 2 oraz w niektórych szablonach do dziś można spotkać deklarację kodowania w pierwszej lub drugiej linii pliku — tzw. coding cookie opisany w PEP 263:

# -*- coding: utf-8 -*-

Równoważnie:

# coding: utf-8

Jeśli plik zaczyna się od shebanga, deklaracja kodowania trafia do drugiej linii:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

W nowym kodzie pisanym wyłącznie w Pythonie 3 deklaracja jest niepotrzebna — interpreter zakłada UTF-8. Nadal pojawia się w starszych projektach i w niektórych przewodnikach stylu (np. Google Python Style Guide) „dla jasności”.

To nie to samo, co encoding= przy open()

Domyślne kodowanie pliku źródłowego (UTF-8 w Pythonie 3) i domyślne kodowanie używane przy otwieraniu plików danych funkcją open() to dwie różne sprawy. To drugie zależy od ustawień systemu (locale) i bywa różne między Linuxem, macOS a Windowsem — dlatego w funkcji open() zawsze podajemy parametr encoding="utf-8" jawnie. Patrz sekcja Operacje na plikach.

Typy podstawowe

Liczby

TypCo reprezentujePrzykład
intLiczba całkowita (dowolna długość)42, -7, 10**100
floatLiczba zmiennoprzecinkowa (IEEE 754 double)3.14, 1e-3, inf
complexLiczba zespolona2+3j
a = 7
b = 2
 
print(a + b)   # 9
print(a - b)   # 5
print(a * b)   # 14
print(a / b)   # 3.5  - dzielenie zawsze daje float
print(a // b)  # 3    - dzielenie całkowite (zaokrąglenie w dół)
print(a % b)   # 1    - reszta z dzielenia
print(a ** b)  # 49   - potęgowanie

float ma ograniczoną precyzję

0.1 + 0.2 w Pythonie nie daje dokładnie 0.3 — to konsekwencja standardu IEEE 754 (taka sama jak w SQL, Javie czy JavaScripcie). Dla wartości z domeny finansowej używaj klasy decimal.Decimal. Dla pomiarów fizycznych z kontrolowaną niepewnością float zwykle wystarcza, bo już sam pomiar ma większy błąd niż precyzja typu.

Łańcuchy znaków (str)

imie = "Anna"                       # apostrofy lub cudzysłowy
opis = 'tekst z "cytatem" w środku'
wieloliniowy = """linia 1
linia 2"""
 
# konkatenacja
print("Witaj, " + imie)
 
# Cytaty
print(opis)
 
# wieloliniowy
print(wieloliniowy)
 
# powielenie
print("-" * 20)
 
# długość
print(len(imie))   # 4
 
# indeksowanie i slicing
slowo = "hydrologia"
print(slowo[0])     # h
print(slowo[-1])    # a (ostatni)
print(slowo[0:5])   # hydro
print(slowo[::-1])  # aigolordyh (od końca)

f-stringi (od Pythona 3.6) — zalecany sposób formatowania:

stacja = "Poznań-Most Rocha"
przeplyw = 64.2
print(f"Stacja {stacja}: przepływ {przeplyw:.1f} m³/s")

{wyrażenie:format} — po dwukropku idzie specyfikator: .1f (jedno miejsce po przecinku), 04d (liczba uzupełniona zerami do 4 znaków), >10 (wyrównanie do prawej w polu szerokości 10). Stringi w Pythonie są niemutowalne — nie da się zmienić znaku w istniejącym stringu, można tylko utworzyć nowy.

Wartości logiczne i None

prawda = True
falsz = False
brak = None
 
print(prawda and falsz)  # False
print(prawda or falsz)   # True
print(not prawda)        # False
 
# typowy idiom - sprawdzenie None
wynik = None
if wynik is None:
    print("brak wyniku")

None to specjalna wartość oznaczająca „brak wartości” (analogicznie do NULL w SQL). Porównujemy z nią przez is None / is not None, nie przez == None.

W kontekstach logicznych wartości „fałsz” są: False, None, 0, 0.0, "" (pusty string), [] (pusta lista), {}, set(). Wszystko inne traktowane jest jako „prawda”.

Konwersje typów

# string -> liczba
x = int("42")
y = float("3.14")
 
# liczba -> string
napis = str(42)
 
# float -> int (obcięcie, nie zaokrąglenie!)
print(int(3.9))           # 3
print(int(-3.9))          # -3
 
# zaokrąglenie
print(round(3.5))         # 4
print(round(3.14159, 2))  # 3.14

Próba konwersji nieprzystającego stringa rzuca ValueError:

try:
    x = int("abc")
except ValueError as e:
    print(f"nie udało się: {e}")

Operatory

Arytmetyczne, porównania, logiczne

OperatorZnaczenie
+, -, *, /Dodawanie, odejmowanie, mnożenie, dzielenie zwykłe
//Dzielenie całkowite
%Modulo (reszta)
**Potęgowanie
==, !=Równe, różne
<, >, <=, >=Porównania
and, or, notOperatory logiczne
in, not inNależy / nie należy do kolekcji
is, is notIdentyczność obiektów (ten sam obiekt w pamięci)

in / not in, is / is not

in działa na każdej kolekcji wspierającej iterację:

print(3 in [1, 2, 3])                 # True
print("Warta" in ("Warta", "Wisła"))  # True
print("h" in "hydrologia")            # True
print("brak" not in {"a", "b"}).      # True

is sprawdza tożsamość obiektów — czy dwie nazwy wskazują na ten sam obiekt w pamięci, nie czy ich wartości są równe:

a = [1, 2, 3]
b = [1, 2, 3]
c = a
 
print(a == b)  # True  - wartości są równe
print(a is b)  # False - to różne obiekty w pamięci
print(a is c)  # True  - c i a wskazują na ten sam obiekt

is na liczbach i stringach — pułapka

CPython cache’uje małe liczby (-5 do 256) oraz krótkie stringi. Dlatego 1000 is 1000 może wyjść True w REPL-u, ale False w skrypcie albo w innej wersji interpretera. Nigdy nie używaj is do porównywania wartości — tylko do is None, is True, is False (te trzy są singletonami i porównanie przez is jest jedyną poprawną formą).

Struktury danych

Wbudowane kolekcje to lista, krotka, słownik i zbiór. Każda ma inną charakterystykę pod kątem mutowalności, kolejności i indeksowania.

StrukturaSkładniaMutowalna?Uporządkowana?Duplikaty?Indeksowanie
list[1, 2, 3]taktaktakpo pozycji
tuple(1, 2, 3)nietaktakpo pozycji
dict{"a": 1}taktak (od 3.7)klucze niepo kluczu
set{1, 2, 3}taknieniebrak
frozensetfrozenset([1, 2])nienieniebrak

Lista (list)

stacje = ["Poznań", "Warszawa", "Kalisz"]
 
stacje.append("Wrocław")        # dodanie na koniec
stacje.insert(1, "Kraków")      # wstawienie na pozycję 1
stacje.remove("Kalisz")         # usunięcie po wartości
ostatnia = stacje.pop()         # pobranie i usunięcie ostatniej
print(stacje)
print(f"ostatnia: {ostatnia}")
 
# slicing
print(stacje[0:2])   # pierwsze dwa
print(stacje[::-1])  # odwrócona kopia
 
# sortowanie
liczby = [3, 1, 4, 1, 5, 9, 2, 6]
liczby.sort()                   # sortuje w miejscu
print(liczby)
posortowane = sorted([3, 1, 2]) # zwraca nową listę, nie modyfikuje
print(posortowane)

Krotka (tuple)

Krotka to niemutowalna lista. Przydaje się, gdy chcemy mieć pewność, że dane się nie zmienią — np. współrzędne stacji albo wynik funkcji zwracającej wiele wartości.

poznan_most_rocha = (52.41, 16.95)  # (lat, lon)
print(poznan_most_rocha[0])
 
# rozpakowanie
lat, lon = poznan_most_rocha
print(f"lat={lat}, lon={lon}")
 
# krotka z jednym elementem - przecinek konieczny
jednoelementowa = (5,)
print(type(jednoelementowa))

Funkcje często zwracają krotkę — najprościej rozpakować ją od razu:

def min_max(wartosci):
    return min(wartosci), max(wartosci)
 
 
a, b = min_max([10, 5, 7, 12, 3])
print(f"min={a}, max={b}")

Słownik (dict)

Mapuje klucze na wartości. Kluczem może być dowolna wartość niemutowalna (string, liczba, krotka).

stacja = {
    "kod_imgw": "152160140",
    "nazwa": "Poznań-Most Rocha",
    "rzeka": "Warta",
    "rzedna_zera": 49.54,
}
 
# odczyt
print(stacja["nazwa"])
print(stacja.get("brak_klucza"))         # None zamiast KeyError
print(stacja.get("brak_klucza", "n/d"))  # wartość domyślna
 
# modyfikacja
stacja["dorzecze"] = "Odra"
del stacja["rzedna_zera"]
 
# iteracja
for klucz, wartosc in stacja.items():
    print(f"{klucz}: {wartosc}")

Zbiór (set, frozenset)

Nieuporządkowana kolekcja unikalnych wartości — naturalnie modeluje „zbiór” w sensie matematycznym.

rzeki = {"Warta", "Wisła", "Odra"}
rzeki.add("Prosna")
rzeki.add("Warta")  # duplikat - ignorowany
print(rzeki)
 
# operacje matematyczne
a = {1, 2, 3}
b = {2, 3, 4}
print(a | b)  # suma:        {1, 2, 3, 4}
print(a & b)  # przecięcie:  {2, 3}
print(a - b)  # różnica:     {1}
print(a ^ b)  # różnica symetryczna: {1, 4}

Typowe zastosowanie: usuwanie duplikatów z listy.

lista_z_powtorzeniami = ["Warta", "Wisła", "Warta", "Odra", "Wisła"]
unikalne = list(set(lista_z_powtorzeniami))
print(unikalne)  # kolejność niegwarantowana

frozenset to niemutowalna wersja zbioru — można go użyć jako klucza w słowniku albo elementu innego zbioru.

Comprehensions

Zwarta składnia tworzenia listy/słownika/zbioru z istniejącej kolekcji. Czytelniejsza i zwykle szybsza niż jawna pętla.

# list comprehension
przeplywy_cm = [245, 240, 312, 238, 156]
przeplywy_m = [x / 100 for x in przeplywy_cm]
print(przeplywy_m)
 
# z warunkiem (filter)
duze = [x for x in przeplywy_cm if x > 200]
print(duze)
 
# dict comprehension
stacje = ["Poznań", "Warszawa", "Kalisz"]
indeksy = {nazwa: i for i, nazwa in enumerate(stacje)}
print(indeksy)
 
# set comprehension
slowo = "hydrologia"
unikalne_litery = {ch for ch in slowo}
print(unikalne_litery)

Comprehension czy pętla

Comprehension jest świetne dla prostych przekształceń jeden-do-jednego z opcjonalnym filtrem. Gdy logika rośnie (zagnieżdżone warunki, wiele etapów, side-effects) — pisz zwykłą pętlę for. Czytelność > sprytność.

Instrukcje sterujące

if / elif / else

przeplyw = 45.5
 
if przeplyw > 100:
    kategoria = "wezbranie"
elif przeplyw > 30:
    kategoria = "stan średni"
elif przeplyw > 10:
    kategoria = "niżówka"
else:
    kategoria = "stan ekstremalnie niski"
 
print(kategoria)

for

Iteracja po elementach kolekcji:

stacje = ["Poznań", "Warszawa", "Kalisz"]
 
for s in stacje:
    print(s)

Pomocnicze funkcje:

# range - sekwencja liczb
for i in range(5):         # 0, 1, 2, 3, 4
    print(i)
 
for i in range(2, 8):      # 2..7
    print(i)
 
for i in range(0, 10, 2):  # co 2: 0, 2, 4, 6, 8
    print(i)
 
# enumerate - indeks + wartość
for i, s in enumerate(stacje):
    print(f"{i}: {s}")
 
# zip - równoległa iteracja
nazwy = ["Poznań", "Warszawa"]
przeplywy = [64.2, 542.0]
for nazwa, q in zip(nazwy, przeplywy):
    print(f"{nazwa}: {q} m³/s")

while

x = 0
while x < 5:
    print(x)
    x += 1

while używamy, gdy nie znamy z góry liczby iteracji — np. czytamy z czujnika dopóki nie pojawi się wartość spełniająca warunek.

break, continue i else w pętlach

# break - wyjście z pętli
for x in range(10):
    if x == 5:
        break
    print(x)  # 0, 1, 2, 3, 4
 
# continue - pomija resztę iteracji
for x in range(5):
    if x % 2 == 0:
        continue
    print(x)  # 1, 3
 
# else po pętli - wykonuje się, gdy NIE było break
for x in range(3):
    print(x)
else:
    print("pętla się zakończyła normalnie")

else po pętli to konstrukcja rzadka i często myląca — wielu programistów czyta ją jak „else dla if w pętli”. Używaj świadomie.

match / case (Python 3.10+)

Wzorzec dopasowywania, dodany w 3.10. Bardziej rozbudowany niż switch z C czy Javy — pozwala dopasowywać też strukturę.

def opis_stanu(kategoria: str) -> str:
    match kategoria:
        case "wezbranie":
            return "powyżej stanu ostrzegawczego"
        case "stan średni" | "niżówka":
            return "stan codzienny"
        case _:
            return "nieznana kategoria"
 
 
print(opis_stanu("wezbranie"))
print(opis_stanu("nieznane"))

_ to wildcard — pasuje do wszystkiego (analog default z switch).

Funkcje

Definicja, parametry, wartości zwracane

def stan_w_metrach(stan_cm, rzedna_zera):
    return rzedna_zera + stan_cm / 100
 
 
h = stan_w_metrach(245, 49.54)
print(f"{h:.2f} m n.p.m.")

Funkcja bez return zwraca None.

Argumenty pozycyjne, nazwane, domyślne

def opis_stacji(nazwa, rzeka, dorzecze="Wisła"):
    return f"{nazwa} ({rzeka}, dorzecze {dorzecze})"
 
 
# pozycyjnie
print(opis_stacji("Warszawa-Bulwary", "Wisła"))
 
# nazwane (kolejność dowolna)
print(opis_stacji(rzeka="Warta", nazwa="Poznań", dorzecze="Odra"))
 
# z wartością domyślną
print(opis_stacji("Kraków-Bielany", "Wisła"))

Mutowalna wartość domyślna — pułapka

def f(x, y=[]): wygląda niewinnie, ale lista [] jest tworzona raz przy definicji funkcji i współdzielona między wszystkimi wywołaniami. Każde y.append(...) modyfikuje tę samą listę — zachowanie często mylnie diagnozowane jako „pamiętanie stanu między wywołaniami”. Poprawnie: def f(x, y=None): i wewnątrz if y is None: y = [].

*args i **kwargs

def policz_srednia(*wartosci):
    """Akceptuje dowolną liczbę argumentów pozycyjnych."""
    return sum(wartosci) / len(wartosci)
 
 
print(policz_srednia(1, 2, 3))
print(policz_srednia(10, 20, 30, 40))
 
 
def loguj(**dane):
    """Akceptuje dowolne argumenty nazwane - trafiają do słownika."""
    for klucz, wartosc in dane.items():
        print(f"{klucz}={wartosc}")
 
 
loguj(stacja="Poznań", przeplyw=64.2, data="2024-05-10")

Funkcje anonimowe (lambda)

kwadrat = lambda x: x ** 2
print(kwadrat(5))
 
# typowo używane jako argument funkcji wyższego rzędu
liczby = [3, 1, 4, 1, 5, 9, 2, 6]
posortowane = sorted(liczby, key=lambda x: -x)  # malejąco
print(posortowane)

lambda ma jedno wyrażenie i nie pozwala pisać wieloliniowych funkcji — celowo. Gdy logika rośnie, użyj def.

Type hints w sygnaturze

def stan_w_metrach(stan_cm: int, rzedna_zera: float) -> float:
    return rzedna_zera + stan_cm / 100
 
 
print(stan_w_metrach(245, 49.54))

Hints można zostawić tylko na ważnych granicach (publiczne API modułu) i pominąć w funkcjach pomocniczych — to częsta praktyka. mypy lub pyright weryfikują je statycznie poza interpreterem.

Docstring jako dokumentacja

W Pythonie funkcjonują trzy popularne konwencje formatowania docstringów:

  • Google — Args:, Returns: z wcięciem;
  • NumPy/numpydoc — sekcje z poziomą linią ---- pod nagłówkiem;
  • Sphinx/reST — pola w stylu :param x:, :returns:.

W bazie wiedzy oraz w narzędziach hydrologicznych (Hydrolog, IMGWTools, Kartograf) używamy stylu NumPy — jest najczęstszy w Pythonie naukowym, czytelny w surowej postaci pliku i wspierany przez generatory dokumentacji (Sphinx z rozszerzeniem napoleon, MkDocs Material). Ten sam docstring napisany w trzech stylach wygląda inaczej, a wymóg jest jeden: spójność w obrębie projektu.

def policz_charakterystyki(przeplywy):
    """Oblicza podstawowe statystyki serii przepływów.
 
    Parameters
    ----------
    przeplywy : list of float
        Lista wartości przepływu w m³/s.
 
    Returns
    -------
    dict
        Słownik z kluczami ``min``, ``max``, ``srednia``, ``mediana``.
 
    Examples
    --------
    >>> wynik = policz_charakterystyki([45.2, 67.1, 89.3, 12.5, 56.8])
    >>> round(wynik["srednia"], 2)
    54.18
    """
    posortowane = sorted(przeplywy)
    n = len(posortowane)
    mediana = posortowane[n // 2] if n % 2 else (posortowane[n // 2 - 1] + posortowane[n // 2]) / 2
    return {
        "min": min(przeplywy),
        "max": max(przeplywy),
        "srednia": sum(przeplywy) / n,
        "mediana": mediana,
    }
 
 
wynik = policz_charakterystyki([45.2, 67.1, 89.3, 12.5, 56.8])
print(wynik)

Najczęściej spotykane sekcje stylu NumPy: Parameters, Returns, Yields (dla generatorów), Raises, Notes, Examples, See Also. Linie ---- muszą mieć dokładnie tyle samo znaków co nagłówek nad nimi — jest to wymóg parsera. Krótkie, jednolinijkowe docstringi (np. """Stacja o podanym kodzie nie istnieje w bazie.""" w klasie wyjątku) są w pełni zgodne z NumPy — sekcje pojawiają się dopiero, gdy funkcja ma parametry albo zwracaną wartość wartą udokumentowania.

Programowanie obiektowe (OOP)

Klasa to szablon obiektu — definiuje, jakie atrybuty (dane) i metody (zachowania) mają jego egzemplarze.

Klasy i instancje

class Stacja:
    def __init__(self, kod_imgw, nazwa, rzeka):
        self.kod_imgw = kod_imgw
        self.nazwa = nazwa
        self.rzeka = rzeka
 
    def opis(self):
        return f"{self.nazwa} ({self.rzeka})"
 
 
s = Stacja("152160140", "Poznań-Most Rocha", "Warta")
print(s.opis())
print(s.kod_imgw)
  • __init__ — konstruktor; wywoływany przy tworzeniu instancji
  • self — pierwszy parametr każdej metody instancji; wskazuje na konkretny obiekt
  • atrybuty przypisujemy przez self.<nazwa> = ...

Atrybuty i metody (instance / class / static)

class Pomiar:
    JEDNOSTKA = "m³/s"  # atrybut klasy - wspólny dla wszystkich instancji
 
    def __init__(self, stacja_id, przeplyw):
        self.stacja_id = stacja_id      # atrybut instancji
        self.przeplyw = przeplyw
 
    def opis(self):                      # metoda instancji
        return f"stacja {self.stacja_id}: {self.przeplyw} {self.JEDNOSTKA}"
 
    @classmethod
    def z_litrow(cls, stacja_id, ls):    # metoda klasowa - alternatywny konstruktor
        return cls(stacja_id, ls / 1000)
 
    @staticmethod
    def m3s_na_ls(m3s):                  # metoda statyczna - funkcja w przestrzeni klasy
        return m3s * 1000
 
 
p1 = Pomiar(1, 64.2)
p2 = Pomiar.z_litrow(1, 64200)
 
print(p1.opis())
print(p2.opis())
print(Pomiar.m3s_na_ls(64.2))

__init__, __str__, __repr__

Dunder-metody (od ang. double underscore, po polsku znane również jako metody specjalne lub metody magiczne) sterują zachowaniem obiektów w standardowych operacjach.

class Stacja:
    def __init__(self, kod, nazwa):
        self.kod = kod
        self.nazwa = nazwa
 
    def __str__(self):
        return f"{self.nazwa} [{self.kod}]"
 
    def __repr__(self):
        return f"Stacja(kod={self.kod!r}, nazwa={self.nazwa!r})"
 
 
s = Stacja("152160140", "Poznań-Most Rocha")
 
print(str(s))   # przyjazna reprezentacja (dla użytkownika)
print(repr(s))  # techniczna (dla programisty); idealnie taka, że eval(repr(x)) == x

print(obiekt) używa __str__. Reprezentacja w REPL-u i w listach ([stacja, stacja]) używa __repr__. Dobry __repr__ znacznie ułatwia debugowanie.

Dziedziczenie

class Stacja:
    def __init__(self, kod, nazwa):
        self.kod = kod
        self.nazwa = nazwa
 
    def opis(self):
        return f"Stacja {self.nazwa}"
 
 
class StacjaHydrologiczna(Stacja):
    def __init__(self, kod, nazwa, rzeka, rzedna_zera):
        super().__init__(kod, nazwa)
        self.rzeka = rzeka
        self.rzedna_zera = rzedna_zera
 
    def opis(self):
        bazowy = super().opis()
        return f"{bazowy} na rzece {self.rzeka}"
 
 
s = StacjaHydrologiczna("152160140", "Poznań-Most Rocha", "Warta", 49.54)
print(s.opis())
print(isinstance(s, Stacja))  # True

super() daje dostęp do metod klasy bazowej. isinstance sprawdza, czy obiekt jest egzemplarzem klasy lub jakiejkolwiek z jej klas pochodnych.

Python wspiera dziedziczenie wielokrotne — klasa może mieć wiele klas bazowych. Kolejność wyszukiwania metod określa MRO (Method Resolution Order); dla większości codziennych zastosowań wystarczy dziedziczenie pojedyncze.

Dataclasses (3.7+)

Bardzo częsty wzorzec — klasa, która tylko trzyma dane. @dataclass generuje __init__, __repr__ i __eq__ automatycznie:

from dataclasses import dataclass
 
 
@dataclass
class Pomiar:
    stacja_id: int
    data_pomiaru: str
    stan_wody: int
    przeplyw: float
 
 
p = Pomiar(stacja_id=1, data_pomiaru="2024-05-10", stan_wody=245, przeplyw=64.2)
print(p)  # Pomiar(stacja_id=1, data_pomiaru='2024-05-10', stan_wody=245, przeplyw=64.2)
print(p == Pomiar(1, "2024-05-10", 245, 64.2))  # True

Dataclasses to standard dla obiektów-rekordów. Mniej kodu boilerplate, lepsza czytelność.

Enkapsulacja (konwencja _ i __)

Python nie ma prawdziwego private — wszystko jest dostępne z zewnątrz. Konwencjonalnie:

  • _atrybut — „prywatny” przez konwencję; nie używaj z zewnątrz, choć technicznie możesz;
  • __atrybut — name mangling: Python automatycznie zmienia nazwę na _NazwaKlasy__atrybut (ochrona przed kolizją w dziedziczeniu, nie przed celowym dostępem).
class Bufor:
    def __init__(self):
        self._wewnetrzny = []   # konwencja: nie tykać
        self.__bardzo_prywatny = "x"
 
    def dodaj(self, wartosc):
        self._wewnetrzny.append(wartosc)
 
 
b = Bufor()
b.dodaj(1)
print(b._wewnetrzny)             # technicznie działa, ale łamie konwencję
print(b._Bufor__bardzo_prywatny) # name mangling: można dostać się "ręcznie"

Obsługa wyjątków

Wyjątek to obiekt sygnalizujący błąd — może zostać przechwycony i obsłużony albo (jeśli nie) zatrzymać program z komunikatem i tracebackiem.

try / except / else / finally

def parsuj_przeplyw(tekst):
    try:
        wartosc = float(tekst)
    except ValueError:
        print(f"nie udało się sparsować: {tekst!r}")
        return None
    else:
        # wykonuje się TYLKO gdy try nie rzucił wyjątku
        print(f"sparsowano: {wartosc}")
        return wartosc
    finally:
        # wykonuje się ZAWSZE - i przy sukcesie, i przy wyjątku
        print("zakończono parsowanie")
 
 
parsuj_przeplyw("64.2")
parsuj_przeplyw("brak danych")

Wbudowane typy wyjątków

WyjątekKiedy występuje
ValueErrorArgument poprawnego typu, ale niewłaściwa wartość (int("abc"))
TypeErrorNiewłaściwy typ argumentu (len(5))
KeyErrorBrak klucza w słowniku
IndexErrorIndeks spoza zakresu listy/krotki
FileNotFoundErrorBrak pliku (open("nieistnieje.txt"))
ZeroDivisionErrorDzielenie przez zero
AttributeErrorBrak atrybutu w obiekcie
ImportErrorBrak modułu / błąd importu
StopIterationKoniec iteracji (rzadko łapany ręcznie)
slownik = {"a": 1}
 
try:
    print(slownik["b"])
except KeyError as e:
    print(f"brak klucza: {e}")

Bare except: to anty-wzorzec

except: (bez podanego typu) lub except Exception: łapie też niezamierzone błędy — np. KeyboardInterrupt (Ctrl+C w długim skrypcie). Łap konkretne typy wyjątków, a w naprawdę szerokim łapaniu zostaw przynajmniej except Exception (nie BaseException).

Własne wyjątki i raise ... from

class StacjaNieZnaleziona(Exception):
    """Stacja o podanym kodzie nie istnieje w bazie."""
 
 
def znajdz_stacje(kod, baza):
    try:
        return baza[kod]
    except KeyError as e:
        raise StacjaNieZnaleziona(f"brak stacji o kodzie {kod}") from e
 
 
baza = {"152160140": "Poznań-Most Rocha"}
 
try:
    znajdz_stacje("999999999", baza)
except StacjaNieZnaleziona as e:
    print(e)

raise NowyWyjatek(...) from oryginalny zachowuje pełen kontekst — w tracebacku zobaczysz oba wyjątki z adnotacją „The above exception was the direct cause”.

Operacje na plikach

open() i tryby

# zapis (tworzy lub nadpisuje)
with open("/tmp/test.txt", "w", encoding="utf-8") as f:
    f.write("linia 1\n")
    f.write("linia 2\n")
 
# odczyt
with open("/tmp/test.txt", "r", encoding="utf-8") as f:
    zawartosc = f.read()
print(zawartosc)
TrybCo robi
rOdczyt (domyślny)
wZapis (nadpisuje istniejący plik)
aDopisywanie na koniec
xTworzenie — błąd, jeśli plik istnieje
bBinarny (np. rb, wb dla obrazów, archiwów)
tTekstowy (domyślny, można pominąć)
+Otwarcie do odczytu i zapisu (np. r+)

Zawsze podawaj encoding

Domyślne kodowanie różni się między systemami (UTF-8 na nowoczesnych Linuksach i macOS, czasem CP-1250 na Windowsie). Niejawne poleganie na domyślnym kodowaniu prowadzi do UnicodeDecodeError przy uruchomieniu skryptu na innej maszynie. Dobra praktyka: zawsze encoding="utf-8".

Context manager with

with open(...) as f: to context manager — gwarantuje, że plik zostanie zamknięty (f.close()) niezależnie od tego, czy w środku pojawi się wyjątek. To zalecany sposób pracy z plikami, soketami i większością zasobów wymagających sprzątania.

Czytanie linia po linii

# tworzymy testowy plik
with open("/tmp/lista.txt", "w", encoding="utf-8") as f:
    f.write("Warta\nWisła\nOdra\n")
 
# wariant 1 - iteracja po linii (najefektywniejsze pamięciowo)
with open("/tmp/lista.txt", "r", encoding="utf-8") as f:
    for linia in f:
        print(linia.rstrip())   # rstrip usuwa końcowy \n
 
# wariant 2 - wszystkie linie do listy
with open("/tmp/lista.txt", "r", encoding="utf-8") as f:
    linie = f.readlines()
print(linie)

Standard library: csv i json

import csv
 
# zapis CSV
wiersze = [
    {"kod": "152160140", "nazwa": "Poznań-Most Rocha", "rzeka": "Warta"},
    {"kod": "152210170", "nazwa": "Warszawa-Bulwary", "rzeka": "Wisła"},
]
 
with open("/tmp/stacje.csv", "w", encoding="utf-8", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=["kod", "nazwa", "rzeka"])
    writer.writeheader()
    writer.writerows(wiersze)
 
# odczyt CSV
with open("/tmp/stacje.csv", "r", encoding="utf-8") as f:
    reader = csv.DictReader(f)
    for wiersz in reader:
        print(wiersz["nazwa"])
import json
 
dane = {
    "stacja": "Poznań-Most Rocha",
    "rzeka": "Warta",
    "rzedna_zera": 49.54,
    "wspolrzedne": [52.41, 16.95],
}
 
# do stringa
tekst = json.dumps(dane, ensure_ascii=False, indent=2)
print(tekst)
 
# do pliku
with open("/tmp/stacja.json", "w", encoding="utf-8") as f:
    json.dump(dane, f, ensure_ascii=False, indent=2)
 
# odczyt z pliku
with open("/tmp/stacja.json", "r", encoding="utf-8") as f:
    z_pliku = json.load(f)
print(z_pliku["stacja"])

ensure_ascii=False zachowuje polskie znaki w plikach JSON (domyślnie kodowane są jako ó itp.).

Moduły i paczki

import, from, as

Moduł to plik .py. Paczka to katalog z plikiem __init__.py (lub od Pythona 3.3+ bez tego pliku — jako namespace package).

import math
print(math.pi)
print(math.sqrt(16))
 
from math import sqrt, pi
print(sqrt(16))
 
import math as m       # alias modułu
print(m.pi)
 
from math import sqrt as pierwiastek  # alias funkcji
print(pierwiastek(25))

Gwiazdka from modul import * jest generalnie odradzana — zaśmieca przestrzeń nazw i utrudnia znalezienie, skąd pochodzi dana funkcja.

Tworzenie własnego modułu

Plik narzedzia.py:

# narzedzia.py
def stan_w_metrach(stan_cm: int, rzedna_zera: float) -> float:
    return rzedna_zera + stan_cm / 100
 
 
PROG_OSTRZEGAWCZY_CM = 300

Użycie z innego pliku w tym samym katalogu:

# main.py
import narzedzia
 
print(narzedzia.stan_w_metrach(245, 49.54))
print(narzedzia.PROG_OSTRZEGAWCZY_CM)

Wybrane moduły standard library

ModułCo oferuje
osOperacje systemowe (zmienne środowiskowe, ścieżki, procesy).
sysArgumenty linii komend, stdin/stdout, exit()
pathlibObiektowy interfejs do ścieżek (nowoczesna alternatywa dla os.path)
datetimeDaty i czas
reWyrażenia regularne
jsonSerializacja JSON
csvCzytanie i pisanie CSV
loggingLogowanie zdarzeń
urllibHTTP w bibliotece standardowej
subprocessUruchamianie zewnętrznych procesów
argparse. Parsowanie argumentów linii komend

Przykład pathlib:

from pathlib import Path
 
sciezka = Path("/tmp/test_pythona.txt")
sciezka.write_text("hello\n", encoding="utf-8")
 
print(sciezka.exists())
print(sciezka.suffix)
print(sciezka.read_text(encoding="utf-8"))
 
sciezka.unlink()  # usunięcie

pathlib zamiast os.path

pathlib.Path daje bardziej czytelny, obiektowy interfejs do ścieżek niż klasyczne os.path. Operator / służy do łączenia (Path("/tmp") / "plik.txt"), metody read_text(), write_text(), exists(), iterdir() upraszczają najczęstsze operacje. W nowym kodzie preferujemy pathlib.

if __name__ == "__main__":

Idiom pozwalający odróżnić sytuację „skrypt jest uruchamiany bezpośrednio” od „moduł jest importowany przez inny skrypt”.

# narzedzia.py
def stan_w_metrach(stan_cm, rzedna_zera):
    return rzedna_zera + stan_cm / 100
 
 
def main():
    print(stan_w_metrach(245, 49.54))
 
 
if __name__ == "__main__":
    main()

Po imporcie z innego pliku (import narzedzia) blok main() się nie wykona. Po uruchomieniu python3 narzedzia.py — wykona się. Standardowy układ: definicje funkcji + if __name__ == "__main__": na końcu z entry pointem.

Wersje, środowiska wirtualne i zależności

pyenv — instalacja i przełączanie wersji Pythona

pyenv rozwiązuje problem „mam Pythona 3.10 z systemu, ale nowy projekt wymaga 3.12” — pozwala mieć obie wersje (i dowolnie wiele innych) na tej samej maszynie i przełączać się między nimi per-katalog.

Instalacja:

# macOS przez Homebrew
brew install pyenv
 
# Linux - skrypt instalacyjny
curl https://pyenv.run | bash

Po instalacji trzeba dopisać do pliku startowego powłoki (~/.zshrc, ~/.bashrc):

export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"

Podstawowe komendy:

pyenv install --list             # dostępne wersje
pyenv install 3.12.5             # instalacja
pyenv versions                   # zainstalowane lokalnie
pyenv global 3.12.5              # ustaw globalnie domyślną wersję
pyenv local 3.11.9               # ustaw dla bieżącego katalogu (tworzy plik .python-version)
python3 --version                # potwierdzenie

Plik .python-version w katalogu projektu jest commitowany do repo — gwarantuje, że każdy współtwórca i CI używają tej samej wersji Pythona. To analog package.json engines z Node.js czy .ruby-version.

Relacja pyenv ↔ venv

pyenv wybiera interpreter (którą wersję Pythona uruchamiamy). venv izoluje zależności (paczki pip per-projekt). To dwie ortogonalne sprawy — typowy stack to pyenv + venv razem: pyenv ustawia wersję, venv izoluje paczki dla tej wersji.

Po co venv

Bez izolacji wszystkie projekty na jednej maszynie korzystają z tej samej globalnej instalacji. pip install w jednym projekcie wpływa na inne — łatwo o konflikty wersji bibliotek. Wirtualne środowisko to lokalny katalog z własnym interpreterem Pythona (dowiązanie symboliczne) i własnym katalogiem na biblioteki — projekty są izolowane.

python -m venv + aktywacja

# tworzymy venv w katalogu projektu
cd /tmp/moj-projekt
python3 -m venv .venv               # dobrą praktyką jest dodawnie kropki na początku
 
# aktywacja
source .venv/bin/activate           # Linux / macOS
# .venv\Scripts\activate            # Windows PowerShell
 
# w aktywnym venv każdy `python` i `pip` korzysta z lokalnego venv
which python
pip install requests
 
# dezaktywacja
deactivate

Aktywacja modyfikuje PATH — python i pip w terminalu wskazują na binaria z .venv/. Prompt powłoki zwykle pokazuje (venv) na początku, żeby było widać, że venv jest aktywny.

.venv/ nie jest commitowane do repo — tylko requirements.txt (lub pyproject.toml) z listą zależności.

pip install / freeze, requirements.txt

# instalacja konkretnej paczki
pip install requests
 
# instalacja konkretnej wersji
pip install "requests==2.31.0"
 
# instalacja z minimalną wersją
pip install "requests>=2.30"
 
# lista zainstalowanych paczek
pip list
 
# eksport do requirements.txt
pip freeze > requirements.txt
 
# odtworzenie środowiska na innej maszynie
pip install -r requirements.txt

requirements.txt to płaska lista paczek z wersjami:

requests==2.31.0
pandas==2.2.2
numpy==1.26.4

Jest commitowany do repo — gwarantuje, że każdy programista (i CI) zainstaluje dokładnie te same wersje paczek.

pyproject.toml — wzmianka

Nowsze projekty używają pyproject.toml (PEP 621) zamiast requirements.txt — pojedynczy plik konfiguracyjny opisujący zależności, metadane projektu i konfigurację narzędzi (black, ruff, mypy). Współpracuje z menedżerami paczek nowej generacji: poetry, uv, pdm, hatch.

uv (od Astral) zyskuje obecnie popularność — instaluje paczki kilkukrotnie szybciej niż pip i ma wbudowane zarządzanie wersjami Pythona, częściowo zastępując pyenv i venv. Dla nowych projektów warto rozważyć.

Ekosystem — popularne biblioteki

BibliotekaDomena
numpyTablice numeryczne, algebra liniowa
pandasDataFrame’y, analiza danych tabelarycznych
matplotlibWizualizacje, wykresy
scipyStatystyka, optymalizacja, interpolacje
requestsHTTP klient (przyjazny interfejs nad urllib)
pytestFramework testowy (de facto standard)
DjangoWebowy framework full-stack
FlaskLekki webowy framework
FastAPINowoczesny framework REST z type hints
scikit-learnKlasyczne uczenie maszynowe
PyTorch, TensorFlowSieci neuronowe i deep learning
geopandas, shapely, rasterioGIS w Pythonie

W kontekście narzędzi hydrologicznych: IMGWTools korzysta z pandas i requests, Hydrolog z numpy i scipy, Kartograf z geopandas i rasterio. W tym wpisie nie wchodzimy głębiej w żadną z bibliotek — to tematy na osobne opracowania.

Testowanie

assert jako narzędzie debugowania

Najprostsza forma testu — sprawdzenie warunku. Jeśli fałsz, rzuca AssertionError:

def stan_w_metrach(stan_cm, rzedna_zera):
    assert stan_cm >= 0, "stan wody nie może być ujemny"
    return rzedna_zera + stan_cm / 100
 
 
print(stan_w_metrach(245, 49.54))
 
try:
    stan_w_metrach(-5, 49.54)
except AssertionError as e:
    print(f"asercja zadziałała: {e}")

assert znika z python -O

Asercje są usuwane przy uruchomieniu z flagą optymalizacji (python3 -O). Nie używaj assert do walidacji danych użytkownika ani do warunków bezpieczeństwa — używaj zwykłego if + raise. assert jest dla niezmiennych założeń o stanie programu („tu zawsze powinno być x”).

unittest — biblioteka standardowa

import unittest
 
 
def stan_w_metrach(stan_cm, rzedna_zera):
    return rzedna_zera + stan_cm / 100
 
 
class TestStanWMetrach(unittest.TestCase):
    def test_zero(self):
        self.assertEqual(stan_w_metrach(0, 49.54), 49.54)
 
    def test_245_cm(self):
        self.assertAlmostEqual(stan_w_metrach(245, 49.54), 51.99, places=2)
 
    def test_ujemna_rzedna(self):
        # rzędna zera może być ujemna w niektórych formatach
        self.assertEqual(stan_w_metrach(100, -5.0), -4.0)
 
 
if __name__ == "__main__":
    unittest.main()

Uruchomienie: python3 -m unittest plik.py. unittest jest w bibliotece standardowej — nie wymaga instalacji.

pytest — de facto standard

pytest to zewnętrzna biblioteka (pip install pytest), ale w praktyce używana w większości nowych projektów. Mniej boilerplate’u niż unittest:

# plik: test_narzedzia.py
def stan_w_metrach(stan_cm, rzedna_zera):
    return rzedna_zera + stan_cm / 100
 
 
def test_zero():
    assert stan_w_metrach(0, 49.54) == 49.54
 
 
def test_245_cm():
    assert abs(stan_w_metrach(245, 49.54) - 51.99) < 0.01
 
 
def test_ujemna_rzedna():
    assert stan_w_metrach(100, -5.0) == -4.0

Uruchomienie: pytest test_narzedzia.py lub samo pytest (znajduje wszystkie pliki test_*.py). Każda funkcja test_* to osobny test — wynik zwraca podsumowanie (passed / failed / skipped).

pytest ma rozbudowany system fixtures (przygotowywanie danych testowych przez funkcje oznaczone @pytest.fixture) i parametryzacji (@pytest.mark.parametrize — uruchomienie tego samego testu z wieloma zestawami danych), które poznajemy zwykle po opanowaniu podstaw.

Pełny artykuł

Fixtures, parametryzacja, conftest.py, mockowanie, TDD oraz testowanie kodu rastrowego — patrz Testowanie kodu w Pythonie.

Co testować w pierwszej kolejności

  • logikę z gałęziami — funkcje, które na podstawie warunków wybierają jedną z kilku ścieżek;
  • transformacje danych — funkcje czyste (input → output) bez efektów ubocznych;
  • przypadki brzegowe — pusta lista, zero, wartości ujemne, brak klucza w słowniku;
  • regresje — gdy znalazłeś bug, dopisz test, który go wykrywa, zanim go naprawisz.

Niekoniecznie warto testować trywialne gettery, integracje z bazą i siecią (te zwykle pokrywają testy integracyjne, nie jednostkowe), kod GUI.

Dobre praktyki

PEP 8 i automatyczne formatowanie

PEP 8 to oficjalny przewodnik stylu Pythona — definiuje wcięcia, spacje, długość linii (79 znaków klasycznie, 88 czy 100 współcześnie), nazewnictwo. Większości reguł nie trzeba pamiętać, bo wymuszają je narzędzia:

  • black — formatuje kod „ostatecznie” (mało konfigurowalny, świadomie); pip install black && black plik.py
  • ruff — szybki linter i formatter (zastępuje flake8, isort, częściowo black); de facto standard w nowych projektach
  • isort — porządkuje importy alfabetycznie i grupowo

Pojedynczy wybór: w nowym projekcie zacznij od ruff z domyślną konfiguracją — robi 80% roboty bez tuningu.

Type hints i mypy

def policz_srednia(wartosci: list[float]) -> float:
    return sum(wartosci) / len(wartosci)

mypy plik.py zweryfikuje statycznie, czy typy się zgadzają. W praktyce: dodawaj hints do publicznego API (funkcje eksportowane z modułu, sygnatury klas), pomijaj w drobnych funkcjach pomocniczych jeśli generują szum.

logging zamiast print

import logging
 
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
 
def pobierz_dane(stacja_id):
    logging.info(f"pobieranie danych dla stacji {stacja_id}")
    # ... logika ...
    logging.warning("brak pomiarów dla 2024-05-10")
    return []
 
 
pobierz_dane(1)

print jest OK w skryptach jednorazowych. W kodzie produkcyjnym logging daje: poziomy ważności (DEBUG/INFO/WARNING/ERROR/CRITICAL), filtrowanie, kierowanie do plików / sysloga / serwisów monitoringu, formatowanie z czasem i kontekstem.

Czytelność > sprytność

# Sprytnie, ale trudno czytać
wynik = [x for x in [y for y in dane if y > 0] if x % 2 == 0]
 
# Czytelnie
dodatnie = [y for y in dane if y > 0]
parzyste_dodatnie = [x for x in dodatnie if x % 2 == 0]

Kod czytasz wielokrotnie więcej razy niż piszesz. Zen of Python: „Readability counts.”

Kompatybilność wersji

  • Pisz nowy kod na ostatnich dwóch-trzech wersjach Pythona (np. 3.11, 3.12, 3.13). Cofanie się dalej rzadko ma sens.
  • W publicznych bibliotekach deklaruj minimalną wersję w pyproject.toml (python = ">=3.10").
  • Funkcje takie jak match/case (3.10+), tomllib (3.11+), generic types list[int] zamiast List[int] (3.9+) są dostępne tylko od konkretnych wersji — sprawdzaj nawias w dokumentacji.

eval, exec, pickle na niezaufanych danych

eval(string) i exec(string) wykonują dowolny kod Pythona — wartość ze źródła zewnętrznego (formularz, plik konfiguracyjny od użytkownika, dane z sieci) potraktowana w ten sposób to natychmiastowe RCE. Tak samo pickle.loads() na danych z niezaufanego źródła pozwala odtworzyć dowolny obiekt — w tym wykonać dowolny kod podczas deserializacji. Do parsowania danych użyj json / yaml.safe_load / tomllib. Do wywołania zewnętrznego procesu — subprocess.run([...]) z listą argumentów, nigdy os.system(string) z konkatenacją.

Pełny przykład

Skrypt liczący proste statystyki przepływów dla zestawu stacji — łączy elementy ze wszystkich poprzednich sekcji:

"""Statystyki przepływów dla zestawu stacji hydrologicznych.
 
Skrypt:
1. Definiuje stacje i ich pomiary jako struktury w kodzie.
2. Liczy charakterystyki dla każdej stacji.
3. Zapisuje raport w formacie JSON.
"""
 
import json
from dataclasses import dataclass, field
from pathlib import Path
 
 
@dataclass
class Stacja:
    kod_imgw: str
    nazwa: str
    rzeka: str
    rzedna_zera: float | None = None
    pomiary: list[float] = field(default_factory=list)
 
    def charakterystyki(self) -> dict[str, float]:
        if not self.pomiary:
            return {"liczba_pomiarow": 0}
        return {
            "liczba_pomiarow": len(self.pomiary),
            "min": min(self.pomiary),
            "max": max(self.pomiary),
            "srednia": round(sum(self.pomiary) / len(self.pomiary), 2),
        }
 
 
def main() -> None:
    stacje = [
        Stacja(
            kod_imgw="152160140",
            nazwa="Poznań-Most Rocha",
            rzeka="Warta",
            rzedna_zera=49.54,
            pomiary=[64.2, 61.5, 58.3, 67.1, 60.0],
        ),
        Stacja(
            kod_imgw="152210170",
            nazwa="Warszawa-Bulwary",
            rzeka="Wisła",
            rzedna_zera=75.17,
            pomiary=[542.0, 538.4, 551.2, 545.9],
        ),
        Stacja(
            kod_imgw="151180020",
            nazwa="Piwonice",
            rzeka="Prosna",
            pomiary=[8.15, 7.42, 9.01, 8.55],
        ),
    ]
 
    raport = {
        s.nazwa: {
            "kod_imgw": s.kod_imgw,
            "rzeka": s.rzeka,
            "rzedna_zera": s.rzedna_zera,
            **s.charakterystyki(),
        }
        for s in stacje
    }
 
    sciezka = Path("/tmp/raport_stacji.json")
    sciezka.write_text(
        json.dumps(raport, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
 
    print(f"Raport zapisany do: {sciezka}")
    for nazwa, dane in raport.items():
        print(f"  {nazwa} ({dane['rzeka']}): średnia {dane.get('srednia', 'b.d.')} m³/s")
 
 
if __name__ == "__main__":
    main()

Po uruchomieniu (python3 raport.py) skrypt wypisuje:

Raport zapisany do: /tmp/raport_stacji.json
  Poznań-Most Rocha (Warta): średnia 62.22 m³/s
  Warszawa-Bulwary (Wisła): średnia 544.38 m³/s
  Piwonice (Prosna): średnia 8.28 m³/s

Zawartość /tmp/raport_stacji.json:

{
  "Poznań-Most Rocha": {
    "kod_imgw": "152160140",
    "rzeka": "Warta",
    "rzedna_zera": 49.54,
    "liczba_pomiarow": 5,
    "min": 58.3,
    "max": 67.1,
    "srednia": 62.22
  },
  ...
}

W tym jednym pliku użyliśmy: dataclass, type hints, list comprehension, dict comprehension, f-stringów, pathlib, json, if __name__ == "__main__":, docstringa. To realistyczna struktura skryptu pomocniczego — od tego wzorca naturalnie wyrastają większe moduły, gdy logika rośnie.

Zobacz także