Практика · Тема 16 · Модулі й пакети¶

Лекція: lecture.html · Тест: quiz.html · Домашнє завдання: homework.md

Досі весь код жив в одному місці. Зараз ми розкладемо його по файлах — і подивимось, як ці файли знаходять одне одного.

Що зробимо:

  1. створимо власний модуль прямо із зошита й імпортуємо його;
  2. спробуємо всі три форми імпорту й побачимо, які імена з'являються;
  3. на власні очі переконаємось, чим небезпечна from модуль import *;
  4. простежимо, як Python шукає модуль по sys.path, і отримаємо ModuleNotFoundError;
  5. переконаємось, що тіло модуля виконується один раз;
  6. побачимо __name__ в обох ролях — при запуску й при імпорті;
  7. зберемо справжній пакет із __init__.py і підпакетом;
  8. перевіримо власну реалізацію квадратного кореня проти math.sqrt;
  9. пройдемось стандартною бібліотекою.

Важливо: усі файли створюються в тимчасовій теці, яку операційна система прибере сама. Твій проєкт залишиться чистим, а зошит — самодостатнім: його можна запустити де завгодно.

1 · Тимчасова майстерня¶

Щоб імпортувати власний файл, Python має його побачити — тобто тека з файлом мусить бути у списку sys.path. Зробимо тимчасову теку й додамо її на початок списку.

In [1]:
import sys
import tempfile
from pathlib import Path

# окрема тека для всіх файлів цієї практики — щоб не смітити в проєкті
majsternya = Path(tempfile.mkdtemp(prefix="tema16_"))

# додаємо на ПОЧАТОК: саме так Python поводиться з текою запущеного скрипта
sys.path.insert(0, str(majsternya))

print("майстерня:", majsternya)
print("перший рядок sys.path:", sys.path[0])
print("тека існує:", majsternya.is_dir())
майстерня: /tmp/tema16_d_d2ahl5
перший рядок sys.path: /tmp/tema16_d_d2ahl5
тека існує: True

2 · Перший модуль¶

Модуль — це просто файл .py. Напишемо файл znyzhky.py з однією константою і однією функцією. Файл пишемо методом write_text: він створює файл і кладе туди рядок.

In [2]:
kod_znyzhky = '''
"""Розрахунок цін зі знижкою — модуль для навчальної крамниці."""

STANDARTNA = 0.10


def cina_zi_znyzhkoyu(cina, vidsotok):
    """Ціна, зменшена на заданий відсоток (0.10 = десять відсотків)."""
    # округлюємо до копійок, бо це ціна, а не абстрактне число
    return round(cina * (1 - vidsotok), 2)
'''

fajl_znyzhky = majsternya / "znyzhky.py"
fajl_znyzhky.write_text(kod_znyzhky, encoding="utf-8")

print("створено:", fajl_znyzhky.name)
print("---- вміст файлу ----")
print(fajl_znyzhky.read_text(encoding="utf-8"))
створено: znyzhky.py
---- вміст файлу ----

"""Розрахунок цін зі знижкою — модуль для навчальної крамниці."""

STANDARTNA = 0.10


def cina_zi_znyzhkoyu(cina, vidsotok):
    """Ціна, зменшена на заданий відсоток (0.10 = десять відсотків)."""
    # округлюємо до копійок, бо це ціна, а не абстрактне число
    return round(cina * (1 - vidsotok), 2)

Тепер найцікавіше: файл лежить на диску, а Python про нього ще не знає. Один рядок — і знає.

In [3]:
import znyzhky

print("модуль:", znyzhky)
print("завантажено з файлу:", znyzhky.__file__)
print("ціна 250 зі стандартною знижкою:", znyzhky.cina_zi_znyzhkoyu(250, znyzhky.STANDARTNA))

# перевіряємо, що функція справді рахує те, що ми задумали
assert znyzhky.cina_zi_znyzhkoyu(250, 0.10) == 225.0, "знижка порахувалась не так!"
print("✅ модуль працює")
модуль: <module 'znyzhky' from '/tmp/tema16_d_d2ahl5/znyzhky.py'>
завантажено з файлу: /tmp/tema16_d_d2ahl5/znyzhky.py
ціна 250 зі стандартною знижкою: 225.0
✅ модуль працює

3 · Три форми імпорту¶

Форми відрізняються рівно одним: які імена з'являються у твоєму просторі імен. Перевіримо це не на слово, а вимірюванням: подивимось, чи існує ім'я у словнику globals().

In [4]:
# globals() повертає словник імен цієї клітинки (точніше — цього зошита як модуля)
def imya_isnuye(imya):
    """Чи є таке ім'я в поточному просторі імен?"""
    return imya in globals()


from znyzhky import cina_zi_znyzhkoyu     # форма 2: беремо одне ім'я
import znyzhky as zn                      # форма 3: той самий модуль під псевдонімом

print("ім'я znyzhky            :", imya_isnuye("znyzhky"))
print("ім'я cina_zi_znyzhkoyu  :", imya_isnuye("cina_zi_znyzhkoyu"))
print("ім'я zn                 :", imya_isnuye("zn"))
print()
print("усі три ведуть до того самого об'єкта:", znyzhky is zn)
print("функція теж та сама    :", znyzhky.cina_zi_znyzhkoyu is cina_zi_znyzhkoyu)

assert znyzhky is zn, "псевдонім мав указувати на той самий модуль"
print("✅ псевдонім — це друге ім'я, а не друга копія")
ім'я znyzhky            : True
ім'я cina_zi_znyzhkoyu  : True
ім'я zn                 : True

усі три ведуть до того самого об'єкта: True
функція теж та сама    : True
✅ псевдонім — це друге ім'я, а не друга копія

4 · Чому import * — погана звичка¶

Створимо маленький модуль, у якому навмисно є ім'я kurs. Уяви, що в нас уже є своя змінна з такою назвою. Подивимось, що з нею станеться після зірочки.

In [5]:
kod_valyuta = '''
"""Курси валют — модуль-пастка для демонстрації."""

kurs = 41.5          # тут kurs означає курс долара
NAZVA = "долар"
'''

(majsternya / "valyuta.py").write_text(kod_valyuta, encoding="utf-8")

kurs = "курс молодого бійця"        # НАША змінна: зовсім про інше
print("до імпорту   kurs =", repr(kurs))

from valyuta import *                # ← ось вона, зірочка

print("після імпорту kurs =", repr(kurs))
print()
print("наше значення зникло без жодного повідомлення про помилку")

assert kurs == 41.5, "зірочка мала затерти наше значення"
print("✅ саме це й показує демонстрація: тихе затирання")
до імпорту   kurs = 'курс молодого бійця'
після імпорту kurs = 41.5

наше значення зникло без жодного повідомлення про помилку
✅ саме це й показує демонстрація: тихе затирання

Порівняй із безпечною формою. Повернемо своє значення й імпортуємо те саме через import:

In [6]:
kurs = "курс молодого бійця"        # відновлюємо своє значення

import valyuta                       # модуль цілком, під власним іменем

print("наше   kurs         =", repr(kurs))
print("чуже   valyuta.kurs =", valyuta.kurs)

assert kurs == "курс молодого бійця", "наше значення мало залишитись на місці"
print("✅ обидва значення живі, і видно, кому яке належить")
наше   kurs         = 'курс молодого бійця'
чуже   valyuta.kurs = 41.5
✅ обидва значення живі, і видно, кому яке належить

5 · Як Python шукає модуль¶

Маршрут: спершу кеш sys.modules, потім вбудовані модулі, потім теки зі списку sys.path згори вниз. Подивимось на цей список і перевіримо кілька імен без жодної помилки — для цього є importlib.util.find_spec: він повертає опис знайденого модуля або None.

In [7]:
import importlib.util

print("перші три теки з sys.path:")
for teka in sys.path[:3]:
    print("   ", teka if teka else "'' (тека запуску)")
print()

for imya in ["znyzhky", "json", "sys", "takogo_modulya_nemaje"]:
    opys = importlib.util.find_spec(imya)
    if opys is None:
        print(f"{imya:<24} → не знайдено ніде")
    elif opys.origin in (None, "built-in"):
        print(f"{imya:<24} → вбудований в інтерпретатор")
    else:
        print(f"{imya:<24} → {opys.origin}")

assert importlib.util.find_spec("takogo_modulya_nemaje") is None
print()
print("✅ find_spec чесно каже «немає», не піднімаючи помилки")
перші три теки з sys.path:
    /tmp/tema16_d_d2ahl5
    /usr/lib64/python312.zip
    /usr/lib64/python3.12

znyzhky                  → /tmp/tema16_d_d2ahl5/znyzhky.py
json                     → /usr/lib64/python3.12/json/__init__.py
sys                      → вбудований в інтерпретатор
takogo_modulya_nemaje    → не знайдено ніде

✅ find_spec чесно каже «немає», не піднімаючи помилки

А тепер подивимось, як виглядає справжня помилка. Клітинка нижче навмисно падає — це і є та сама ModuleNotFoundError, яку ти бачитимеш найчастіше.

In [8]:
import takogo_modulya_nemaje
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
Cell In[8], line 1
----> 1 import takogo_modulya_nemaje

ModuleNotFoundError: No module named 'takogo_modulya_nemaje'

Читай останній рядок: No module named 'takogo_modulya_nemaje'. Це не поломка Python, а звіт: маршрут пройдено до кінця, збігів немає. Три типові причини — одрук в імені, пакет не встановлений, або файл лежить у теці, якої немає в sys.path.

6 · Модуль виконується один раз¶

Імпорт — це виконання файлу. Напишемо модуль, який друкує рядок при завантаженні, та імпортуємо його чотири рази поспіль. Рахуємо, скільки разів побачимо друк.

In [9]:
kod_lichylnyk = '''
print("   [тіло модуля lichylnyk виконується]")

ZAPUSKIV = 1
'''

(majsternya / "lichylnyk.py").write_text(kod_lichylnyk, encoding="utf-8")

print("імпорт 1:")
import lichylnyk
print("імпорт 2:")
import lichylnyk
print("імпорт 3:")
from lichylnyk import ZAPUSKIV
print("імпорт 4:")
import lichylnyk as l4
print()
print("рядок вище надрукувався рівно один раз — решта взята з кешу sys.modules")
імпорт 1:
   [тіло модуля lichylnyk виконується]
імпорт 2:
імпорт 3:
імпорт 4:

рядок вище надрукувався рівно один раз — решта взята з кешу sys.modules

Переконаємось, що модуль справді лежить у кеші й що всі імена вказують на один об'єкт.

In [10]:
print("'lichylnyk' у sys.modules:", "lichylnyk" in sys.modules)
print("той самий об'єкт:", lichylnyk is sys.modules["lichylnyk"] is l4)

assert "lichylnyk" in sys.modules, "після імпорту модуль має бути в кеші"
assert lichylnyk is l4, "псевдонім і звичайне ім'я — це один модуль"
print("✅ кеш працює: один файл — один об'єкт на весь запуск програми")
'lichylnyk' у sys.modules: True
той самий об'єкт: True
✅ кеш працює: один файл — один об'єкт на весь запуск програми

7 · __name__ у двох ролях¶

Той самий файл поводиться по-різному залежно від того, запустили його чи імпортували. Напишемо zvit.py із захисною умовою і перевіримо обидва сценарії.

In [11]:
kod_zvit = '''
"""Розрахунок суми з ПДВ."""

STAVKA_PDV = 0.20


def z_pdv(suma):
    """Сума разом із податком, округлена до копійок."""
    return round(suma * (1 + STAVKA_PDV), 2)


print("рядок на верхньому рівні: виконується ЗАВЖДИ, __name__ =", __name__)

if __name__ == "__main__":
    # цей блок працює лише при прямому запуску файлу
    print("демонстрація: 1000 з ПДВ =", z_pdv(1000))
'''

(majsternya / "zvit.py").write_text(kod_zvit, encoding="utf-8")
print("файл zvit.py створено, рядків:", len(kod_zvit.strip().split(chr(10))))
файл zvit.py створено, рядків: 15

Сценарій 1 — прямий запуск. Запускаємо файл окремим процесом тим самим інтерпретатором, у якому працює зошит. sys.executable — це шлях до нього.

In [12]:
import subprocess

zapusk = subprocess.run([sys.executable, str(majsternya / "zvit.py")],
                        capture_output=True, text=True)

print("---- що надрукував python3 zvit.py ----")
print(zapusk.stdout)

assert "__main__" in zapusk.stdout, "при прямому запуску __name__ мав бути __main__"
assert "демонстрація" in zapusk.stdout, "захисний блок мав виконатись"
print("✅ запустили напряму → __name__ == '__main__', блок спрацював")
---- що надрукував python3 zvit.py ----
рядок на верхньому рівні: виконується ЗАВЖДИ, __name__ = __main__
демонстрація: 1000 з ПДВ = 1200.0

✅ запустили напряму → __name__ == '__main__', блок спрацював

Сценарій 2 — імпорт. Той самий файл, жодного символу не змінено.

In [13]:
import zvit

print()
print("ім'я модуля:", zvit.__name__)
print("функція доступна:", zvit.z_pdv(500))
print("а от рядка «демонстрація» ми не побачили — умова була хибна")

assert zvit.__name__ == "zvit", "при імпорті __name__ дорівнює імені модуля"
assert zvit.z_pdv(1000) == 1200.0, "функція має рахувати ПДВ 20%"
print("✅ імпортували → __name__ == 'zvit', захисний блок промовчав")
рядок на верхньому рівні: виконується ЗАВЖДИ, __name__ = zvit

ім'я модуля: zvit
функція доступна: 600.0
а от рядка «демонстрація» ми не побачили — умова була хибна
✅ імпортували → __name__ == 'zvit', захисний блок промовчав

8 · Збираємо пакет¶

Пакет — це тека з модулями та файлом __init__.py. Зберемо крамницю: пакет krama з двома модулями й підпакетом zvity всередині.

In [14]:
teka_krama = majsternya / "krama"
teka_zvity = teka_krama / "zvity"
teka_zvity.mkdir(parents=True)          # parents=True створює обидві теки одразу

# __init__.py пакета: виконується першим при import krama
(teka_krama / "__init__.py").write_text(
    '''
"""Навчальна крамниця."""

OPYS = "крамниця кави та чаю"
print("   [виконується krama/__init__.py]")
''', encoding="utf-8")

(teka_krama / "tovary.py").write_text(
    '''
CINY = {"кава": 250, "чай": 120, "какао": 180}


def cina(nazva):
    """Ціна товару або 0, якщо такого немає."""
    return CINY.get(nazva, 0)
''', encoding="utf-8")

(teka_zvity / "__init__.py").write_text(
    '''
FORMAT = "текстовий"
print("   [виконується krama/zvity/__init__.py]")
''', encoding="utf-8")

(teka_zvity / "pdf.py").write_text(
    '''
def zberehty(ryadky):
    """Імітація збереження звіту: повертає кількість рядків."""
    return len(ryadky)
''', encoding="utf-8")

print("дерево пакета:")
for shlyah in sorted(teka_krama.rglob("*.py")):
    print("   ", shlyah.relative_to(majsternya))
дерево пакета:
    krama/__init__.py
    krama/tovary.py
    krama/zvity/__init__.py
    krama/zvity/pdf.py

Тепер імпортуємо. Стеж за друком: __init__.py кожного рівня виконується тоді, коли до цього рівня доходить черга — і теж лише один раз.

In [15]:
print("import krama:")
import krama
print("   OPYS =", krama.OPYS)
print()
print("import krama.tovary:")
from krama import tovary
print("   ціна кави:", tovary.cina("кава"))
print()
print("import krama.zvity.pdf:")
from krama.zvity import pdf
print("   рядків у звіті:", pdf.zberehty(["кава 250", "чай 120"]))

assert tovary.cina("кава") == 250, "ціна кави мала бути 250"
assert pdf.zberehty(["a", "b", "c"]) == 3, "звіт мав порахувати три рядки"
print()
print("✅ пакет зібрано й повністю працює")
import krama:
   [виконується krama/__init__.py]
   OPYS = крамниця кави та чаю

import krama.tovary:
   ціна кави: 250

import krama.zvity.pdf:
   [виконується krama/zvity/__init__.py]
   рядків у звіті: 2

✅ пакет зібрано й повністю працює

Зверни увагу на повні імена модулів: вони рахуються від кореня проєкту, а не від теки, у якій ти пишеш. Це і є абсолютний імпорт.

In [16]:
print("krama           →", krama.__name__)
print("krama.tovary    →", tovary.__name__)
print("krama.zvity.pdf →", pdf.__name__)
print()
print("у sys.modules з'явилися записи на кожен рівень:")
for imya in sorted(k for k in sys.modules if k.startswith("krama")):
    print("   ", imya)

assert pdf.__name__ == "krama.zvity.pdf", "повне ім'я має містити всі рівні"
print()
print("✅ кожна крапка — це один рівень теки")
krama           → krama
krama.tovary    → krama.tovary
krama.zvity.pdf → krama.zvity.pdf

у sys.modules з'явилися записи на кожен рівень:
    krama
    krama.tovary
    krama.zvity
    krama.zvity.pdf

✅ кожна крапка — це один рівень теки

9 · Наша реалізація проти бібліотечної¶

Найкорисніше, що дає практика: побачити, що всередині бібліотеки немає магії. Напишемо власний модуль із квадратним коренем за методом Ньютона й порівняємо з math.sqrt.

In [17]:
kod_matematyka = '''
"""Наша власна маленька математика."""


def koren(chyslo, krokiv=40):
    """Квадратний корінь методом Ньютона.

    Ідея: беремо будь-яке припущення й багато разів усереднюємо його
    з часткою chyslo/pryp. Значення швидко сходиться до кореня.
    """
    if chyslo == 0:
        return 0.0
    pryp = chyslo / 2          # перше припущення — половина числа
    for _ in range(krokiv):
        pryp = (pryp + chyslo / pryp) / 2
    return pryp
'''

(majsternya / "moya_matematyka.py").write_text(kod_matematyka, encoding="utf-8")

import math
import moya_matematyka

print(f"{'число':>10} | {'наш корінь':>20} | {'math.sqrt':>20}")
print("-" * 58)
for chyslo in [2, 9, 16, 123.45, 1e6]:
    nash = moya_matematyka.koren(chyslo)
    bibliotechnyj = math.sqrt(chyslo)
    print(f"{chyslo:>10} | {nash:>20.15f} | {bibliotechnyj:>20.15f}")
     число |           наш корінь |            math.sqrt
----------------------------------------------------------
         2 |    1.414213562373095 |    1.414213562373095
         9 |    3.000000000000000 |    3.000000000000000
        16 |    4.000000000000000 |    4.000000000000000
    123.45 |   11.110805551354051 |   11.110805551354051
 1000000.0 | 1000.000000000000000 | 1000.000000000000000
In [18]:
# перевіряємо збіг на всьому наборі одразу, з допуском на похибку float
rozbizhnosti = []
for chyslo in [2, 9, 16, 123.45, 1e6, 0.25, 7]:
    rozbizhnosti.append(abs(moya_matematyka.koren(chyslo) - math.sqrt(chyslo)))

najbilsha = max(rozbizhnosti)
print("найбільша розбіжність:", najbilsha)

assert najbilsha < 1e-9, "наш корінь розійшовся з бібліотечним!"
print("✅ збігається — усередині math.sqrt теж просто алгоритм")
найбільша розбіжність: 2.220446049250313e-16
✅ збігається — усередині math.sqrt теж просто алгоритм

10 · Батарейки в комплекті¶

Шість модулів стандартної бібліотеки, які знадобляться найшвидше. Нічого встановлювати не треба — вони приїхали разом з інтерпретатором.

In [19]:
import random
import json
import collections
from datetime import date

# зерно робить «випадковість» відтворюваною — той самий результат при кожному запуску
random.seed(42)

print("math      · корінь із 2      :", round(math.sqrt(2), 6))
print("random    · кидок кубика     :", random.randint(1, 6))
print("datetime  · днів у 2026 році    :", (date(2027, 1, 1) - date(2026, 1, 1)).days)
print("pathlib   · склеєний шлях    :", Path("dani") / "zvit.csv")
print("json      · об'єкт у текст   :", json.dumps({"mova": "uk"}))
print("collections · лічильник      :", collections.Counter(["кава", "чай", "кава"]))
math      · корінь із 2      :
 1.414214
random    · кидок кубика     : 6
datetime  · днів у 2026 році    : 365
pathlib   · склеєний шлях    : dani/zvit.csv
json      · об'єкт у текст   : {"mova": "uk"}
collections · лічильник      : Counter({'кава': 2, 'чай': 1})
In [20]:
# перевіряємо кілька тверджень про ці модулі — щоб не вірити на слово
assert json.loads(json.dumps({"mova": "uk"})) == {"mova": "uk"}, "json туди й назад має збігтись"
assert (Path("dani") / "zvit.csv").suffix == ".csv", "pathlib має розпізнати розширення"
assert collections.Counter("кака")["к"] == 2, "лічильник мав знайти дві букви к"

random.seed(42)
persha_seriya = [random.randint(1, 6) for _ in range(5)]
random.seed(42)
druha_seriya = [random.randint(1, 6) for _ in range(5)]
print("з тим самим зерном:", persha_seriya, "і", druha_seriya)

assert persha_seriya == druha_seriya, "однакове зерно має давати однакову послідовність"
print("✅ усі твердження про стандартну бібліотеку підтвердились")
з тим самим зерном: [6, 1, 1, 6, 3] і [6, 1, 1, 6, 3]
✅ усі твердження про стандартну бібліотеку підтвердились

11 · Прибираємо за собою¶

Тимчасова тека більше не потрібна. Видаляємо її — модулі, які вже завантажені в пам'ять, від цього нікуди не подінуться, бо вони живуть у sys.modules.

In [21]:
import shutil

fajliv_bulo = len(list(majsternya.rglob("*.py")))
shutil.rmtree(majsternya)
sys.path.remove(str(majsternya))

print("видалено файлів .py:", fajliv_bulo)
print("тека існує:", majsternya.exists())
print("а модуль у пам'яті — так, живий:", znyzhky.cina_zi_znyzhkoyu(100, 0.5))

assert not majsternya.exists(), "тимчасова тека мала зникнути"
print("✅ проєкт лишився чистим")
видалено файлів .py: 9
тека існує: False
а модуль у пам'яті — так, живий: 50.0
✅ проєкт лишився чистим

Завдання¶

🟢 Рівень 1 — База¶

Створи в тимчасовій теці модуль perevodyach.py з двома функціями: v_hryvni(usd, kurs) і v_dolary(uah, kurs). Імпортуй його трьома різними формами й перевір assert-ом, що переведення туди й назад повертає початкове число.

Зроблено, якщо: усі три форми імпорту працюють, а assert проходить.

🟡 Рівень 2 — Плюс¶

Додай до perevodyach.py блок if __name__ == "__main__": з демонстрацією. Запусти файл через subprocess і переконайся, що демонстрація друкується. Потім імпортуй його й переконайся, що при імпорті демонстрації немає.

Зроблено, якщо: обидва assert-и (на наявність і на відсутність рядка) проходять.

🔴 Рівень 3 — Виклик¶

Збери пакет finansy з підпакетом finansy/kursy і модулем finansy/kursy/nbu.py. У finansy/__init__.py зроби фасад: перетягни туди функцію з глибини так, щоб працювало from finansy import v_hryvni. Потім навмисно створи циклічний імпорт між двома модулями пакета, отримай AttributeError — і виправ його, винісши спільну константу в третій модуль.

Зроблено, якщо: фасад працює, циклічний імпорт відтворено (traceback збережено в клітинці з тегом raises-exception) і після виправлення пакет імпортується без помилок.