Практика · Тема 17 · Віртуальні середовища і pip> Лекція: lecture.html · Тест: quiz.html · Домашнє завдання: homework.mdУ лекції ми дивилися на схеми. Тут створимо справжнє віртуальне середовище — тим самиммодулем venv, тими самими командами — і перевіримо руками все, що було намальовано.Що зробимо:1. створимо тимчасову теку-майстерню, щоб не смітити в проєкті;2. викличемо python3 -m venv через subprocess і подивимось, що з'явилось на диску;3. прочитаємо pyvenv.cfg — паспорт середовища;4. порівняємо sys.prefix і sys.path двох інтерпретаторів на власні очі;5. передбачимо шлях до site-packages за правилами й перевіримо прогноз фактом;6. запустимо pip середовища й подивимось, що в ньому стоїть;7. напишемо й прочитаємо requirements.txt, розберемо обмеження версій своєю функцією;8. приберемося за собою — наприкінці на диску не лишиться нічого.Мережа не потрібна. Реальних пакетів із PyPI ми не ставимо: venv і pip працюють офлайн,а requirements.txt — це просто текстовий файл.¶

1 · Тимчасова майстерняУсе, що ми створимо, має жити в одному місці — щоб наприкінці видалити його однією командою.tempfile.mkdtemp() робить теку в системному сховищі тимчасових файлів і повертає шлях до неї.¶

In [1]:
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path

# окрема тека для всього, що створить цей зошит — щоб прибирання було в один рядок
majsternya = Path(tempfile.mkdtemp(prefix="tema17_"))

print("майстерня:", majsternya)
print("тека існує:", majsternya.is_dir())
print("цей зошит працює інтерпретатором:", sys.executable)
майстерня: /tmp/tema17_s019vcdl
тека існує: True
цей зошит працює інтерпретатором: /usr/bin/python3

2 · Створюємо середовищеКоманда з лекції — python3 -m venv .venv. Із зошита ми викликаємо її через subprocess:це той самий запуск програми, що й у терміналі, тільки результат повертається в Python.Замість слова python3 беремо sys.executable — точний шлях до інтерпретатора, який заразвиконує зошит. Так ми напевно знаємо, чий саме venv спрацював.¶

In [2]:
seredovyshche = majsternya / ".venv"

pochatok = time.time()
stvorennya = subprocess.run(
    [sys.executable, "-m", "venv", str(seredovyshche)],
    capture_output=True, text=True)
tryvalist = time.time() - pochatok

print("команда завершилась кодом:", stvorennya.returncode)
print("зайняло секунд:", round(tryvalist, 1))

# код 0 означає «успіх» — це домовленість усіх програм командного рядка
assert stvorennya.returncode == 0, stvorennya.stderr
assert seredovyshche.is_dir(), "теки середовища немає"
print("✅ середовище створено:", seredovyshche)
команда завершилась кодом: 0
зайняло секунд: 5.8
✅ середовище створено: /tmp/tema17_s019vcdl/.venv

3 · Що всерединіЗараз ми побачимо те саме дерево, що клікали в інтерактиві 2 лекції. Друкуємо лише два рівнівкладеності — глибше там сотні файлів самого pip.¶

In [3]:
def pokazaty_derevo(koren, hlybyna=2, vidstup=""):
    """Друкує вміст теки до заданої глибини — щоб вивід лишався читабельним."""
    if hlybyna == 0:
        return
    # спершу теки, потім файли: так дерево читається легше
    elementy = sorted(koren.iterdir(), key=lambda p: (not p.is_dir(), p.name))
    for element in elementy:
        znachok = "📁" if element.is_dir() else "  "
        print(f"{vidstup}{znachok} {element.name}")
        if element.is_dir():
            pokazaty_derevo(element, hlybyna - 1, vidstup + "    ")


print(".venv/")
pokazaty_derevo(seredovyshche, hlybyna=2)
.venv/
📁 bin
       Activate.ps1
       activate
       activate.csh
       activate.fish
       pip
       pip3
       pip3.12
       python
       python3
       python3.12
📁 include
    📁 python3.12
📁 lib
    📁 python3.12
📁 lib64
    📁 python3.12
   pyvenv.cfg

4 · Паспорт середовищаpyvenv.cfg — той самий файл із трьох рядків. Рядок home вказує на справжній Python, звідкибереться стандартна бібліотека. Саме за наявністю цього файлу інтерпретатор розуміє, що працюєвсередині середовища.¶

In [4]:
fajl_pasporta = seredovyshche / "pyvenv.cfg"
tekst_pasporta = fajl_pasporta.read_text(encoding="utf-8")

print("---- pyvenv.cfg ----")
print(tekst_pasporta)

assert "home" in tekst_pasporta, "у паспорті немає рядка home"
assert "version" in tekst_pasporta, "у паспорті немає рядка version"
print("✅ паспорт на місці й містить home і version")
---- pyvenv.cfg ----
home = /usr/bin
include-system-site-packages = false
version = 3.12.2
executable = /usr/bin/python3.12
command = /usr/bin/python3 -m venv /tmp/tema17_s019vcdl/.venv

✅ паспорт на місці й містить home і version

5 · Власний інтерпретаторУ теці bin (на Windows — Scripts) лежить python середовища. Запустимо його й спитаємотри речі: хто він, який у нього sys.prefix і який sys.base_prefix.Ознака середовища одна: sys.prefix не дорівнює sys.base_prefix.¶

In [5]:
# назви тек відрізняються на Windows — os.name дорівнює "nt" саме там
if os.name == "nt":
    python_seredovyshcha = seredovyshche / "Scripts" / "python.exe"
else:
    python_seredovyshcha = seredovyshche / "bin" / "python"


def sprosyty(interpretator, kod):
    """Запускає інтерпретатор із коротким кодом і повертає надрукований ним рядок."""
    vidpovid = subprocess.run([str(interpretator), "-c", kod],
                              capture_output=True, text=True)
    assert vidpovid.returncode == 0, vidpovid.stderr
    return vidpovid.stdout.strip()


zapyt = "import sys; print(sys.prefix); print(sys.base_prefix)"
prefix_seredovyshcha, base_prefix_seredovyshcha = sprosyty(python_seredovyshcha, zapyt).split("\n")

print("python середовища :", python_seredovyshcha)
print("sys.prefix        :", prefix_seredovyshcha)
print("sys.base_prefix   :", base_prefix_seredovyshcha)
print()
print("а в зошита sys.prefix дорівнює sys.base_prefix:", sys.prefix == sys.base_prefix)

assert prefix_seredovyshcha != base_prefix_seredovyshcha, "це не схоже на середовище"
print("✅ prefix і base_prefix різні — ми справді всередині середовища")
python середовища : /tmp/tema17_s019vcdl/.venv/bin/python
sys.prefix        : /tmp/tema17_s019vcdl/.venv
sys.base_prefix   : /usr

а в зошита sys.prefix дорівнює sys.base_prefix: True
✅ prefix і base_prefix різні — ми справді всередині середовища

6 · sys.path до і післяНайголовніша перевірка теми. Візьмемо sys.path інтерпретатора середовища й sys.path зошита,залишимо в обох тільки теки з пакетами (ті, що закінчуються на site-packages) — і порівняємо.Очікування з лекції: у середовищі має бути своя тека з пакетами, і жодної чужої.¶

In [6]:
shlyah_seredovyshcha = json.loads(
    sprosyty(python_seredovyshcha, "import sys, json; print(json.dumps(sys.path))"))

paketni_seredovyshcha = [p for p in shlyah_seredovyshcha if p.endswith("site-packages")]
paketni_zoshyta = [p for p in sys.path if p.endswith("site-packages")]

print("тек із пакетами в середовища:", len(paketni_seredovyshcha))
for shlyah in paketni_seredovyshcha:
    print("   ", shlyah)
print()
print("тек із пакетами в зошита:", len(paketni_zoshyta))
for shlyah in paketni_zoshyta[:4]:
    print("   ", shlyah)
тек із пакетами в середовища: 2
    /tmp/tema17_s019vcdl/.venv/lib64/python3.12/site-packages
    /tmp/tema17_s019vcdl/.venv/lib/python3.12/site-packages

тек із пакетами в зошита: 5
    /home/dmitro/.local/lib/python3.12/site-packages
    /usr/local/lib64/python3.12/site-packages
    /usr/local/lib/python3.12/site-packages
    /usr/lib64/python3.12/site-packages

А тепер сформулюємо очікування як перевірку. Чужою вважаємо будь-яку теку з пакетами, що лежитьпоза текою середовища: якщо така знайдеться, ізоляція протікає.

In [7]:
chuzhi = [p for p in paketni_seredovyshcha
          if not os.path.realpath(p).startswith(os.path.realpath(seredovyshche))]

print("чужих тек із пакетами в середовищі:", len(chuzhi))
print("перші чотири рядки sys.path середовища (тека запуску й стандартна бібліотека):")
for shlyah in shlyah_seredovyshcha[:4]:
    print("   ", repr(shlyah))

assert chuzhi == [], f"у середовище протекли чужі теки: {chuzhi}"
assert len(paketni_seredovyshcha) >= 1, "середовище взагалі не бачить своїх пакетів"
print("✅ пакети беруться тільки з середовища, системні не видно")
чужих тек із пакетами в середовищі: 0
перші чотири рядки sys.path середовища (тека запуску й стандартна бібліотека):
    ''
    '/usr/lib64/python312.zip'
    '/usr/lib64/python3.12'
    '/usr/lib64/python3.12/lib-dynload'
✅ пакети беруться тільки з середовища, системні не видно

7 · Прогноз проти фактуЦе найцінніша клітинка практики. Ми не питаємо систему, де лежить site-packages —ми обчислюємо шлях за правилом із лекції: .venv / lib / python<версія> / site-packages(на Windows — .venv / Lib / site-packages).А потім порівнюємо свій прогноз із тим, що насправді сказав інтерпретатор середовища.Якщо збіглося — значить, правило зрозуміле правильно, а не завчене.¶

In [8]:
versiya = f"python{sys.version_info.major}.{sys.version_info.minor}"

if os.name == "nt":
    prognoz = seredovyshche / "Lib" / "site-packages"
else:
    prognoz = seredovyshche / "lib" / versiya / "site-packages"

fakt = []
for shlyah in paketni_seredovyshcha:
    spravzhnij = os.path.realpath(shlyah)
    # на деяких системах lib64 — це лише посилання на lib, тож після realpath
    # два різні на вигляд рядки перетворюються на один і той самий шлях
    if spravzhnij not in fakt:
        fakt.append(spravzhnij)

print("наш прогноз :", prognoz)
print("факт        :")
for shlyah in fakt:
    print("             ", shlyah)

assert os.path.realpath(prognoz) in fakt, "прогноз розійшовся з фактом!"
print("✅ збігається — правило побудови шляху працює саме так, як у лекції")
наш прогноз : /tmp/tema17_s019vcdl/.venv/lib/python3.12/site-packages
факт        :
              /tmp/tema17_s019vcdl/.venv/lib/python3.12/site-packages
✅ збігається — правило побудови шляху працює саме так, як у лекції

8 · pip середовищаpip — звичайний модуль, тому найнадійніша форма виклику — python -m pip. Так ми точнознаємо, чий саме pip запустився: той, що належить нашому середовищу.¶

In [9]:
def pip(*argumenty):
    """Викликає pip середовища й повертає його вивід рядком."""
    # --disable-pip-version-check прибирає рекламне нагадування про оновлення
    vidpovid = subprocess.run(
        [str(python_seredovyshcha), "-m", "pip", "--disable-pip-version-check", *argumenty],
        capture_output=True, text=True)
    assert vidpovid.returncode == 0, vidpovid.stderr
    return vidpovid.stdout.strip()


print(pip("--version"))
print()
print("---- pip list ----")
print(pip("list"))
pip 23.2.1 from /tmp/tema17_s019vcdl/.venv/lib64/python3.12/site-packages/pip (python 3.12)

---- pip list ----
Package Version
------- -------
pip     23.2.1

Список короткий — і це правильно: у свіжому середовищі стоїть тільки сам pip.Саме тому воно й важить кілька мегабайтів, а не сотню.Тепер pip freeze — команда, якою роблять requirements.txt. У порожньому середовищі вонадрукує нічого, і сам pip до свого списку не потрапляє ніколи.

In [10]:
zamorozhene = pip("freeze")

print("довжина виводу pip freeze:", len(zamorozhene), "символів")
print("вміст:", repr(zamorozhene))

assert "pip==" not in zamorozhene, "pip не має потрапляти у freeze"
print("✅ freeze не включає сам pip — у файлі залежностей його не буває")
довжина виводу pip freeze: 0 символів
вміст: ''
✅ freeze не включає сам pip — у файлі залежностей його не буває

9 · requirements.txt як текстРеальних пакетів ми не ставимо, тому файл напишемо руками — рівно в тому форматі, якийзгенерував би pip freeze. Це той самий текст: по рядку на пакет, порожні рядки й рядкиз # ігноруються.¶

In [11]:
vmist_requirements = """\
# залежності проєкту «Афіша»
# згенеровано командою: pip freeze > requirements.txt

zvitnyk==1.4.2
tablychky>=2.0,<3.0
kalendar~=1.7.3

# рядок нижче закоментований — пакет знадобиться пізніше
# grafiky==0.9.1
"""

fajl_requirements = majsternya / "requirements.txt"
fajl_requirements.write_text(vmist_requirements, encoding="utf-8")

print("файл записано:", fajl_requirements.name)
print("---- requirements.txt ----")
print(fajl_requirements.read_text(encoding="utf-8"))
файл записано: requirements.txt
---- requirements.txt ----
# залежності проєкту «Афіша»
# згенеровано командою: pip freeze > requirements.txt

zvitnyk==1.4.2
tablychky>=2.0,<3.0
kalendar~=1.7.3

# рядок нижче закоментований — пакет знадобиться пізніше
# grafiky==0.9.1

10 · Читаємо файл своїм розбирачемpip install -r робить із цим файлом дві речі: викидає порожні рядки й коментарі, а рештурозбирає на «ім'я + обмеження». Повторимо це самі — так стане видно, що всередині немає магії.¶

In [12]:
OPERATORY = ("~=", "==", ">=", "<=", ">", "<")


def rozibraty_ryadok(ryadok):
    """Розкладає рядок вимоги на ім'я пакета й список обмежень."""
    for operator in OPERATORY:
        pozyciya = ryadok.find(operator)
        if pozyciya != -1:
            imya = ryadok[:pozyciya].strip()
            # обмежень може бути кілька через кому: >=2.0,<3.0
            obmezhennya = [chastyna.strip() for chastyna in ryadok[pozyciya:].split(",")]
            return imya, obmezhennya
    return ryadok.strip(), []


vymohy = []
for ryadok in fajl_requirements.read_text(encoding="utf-8").splitlines():
    ochyshcheny = ryadok.strip()
    # порожні рядки й коментарі pip просто пропускає
    if not ochyshcheny or ochyshcheny.startswith("#"):
        continue
    vymohy.append(rozibraty_ryadok(ochyshcheny))

for imya, obmezhennya in vymohy:
    print(f"{imya:<12} {obmezhennya}")

assert len(vymohy) == 3, f"мало бути три вимоги, а вийшло {len(vymohy)}"
assert vymohy[0] == ("zvitnyk", ["==1.4.2"]), vymohy[0]
assert vymohy[1] == ("tablychky", [">=2.0", "<3.0"]), vymohy[1]
print("✅ три вимоги розібрано, закоментований рядок пропущено")
zvitnyk      ['==1.4.2']
tablychky    ['>=2.0', '<3.0']
kalendar     ['~=1.7.3']
✅ три вимоги розібрано, закоментований рядок пропущено

11 · Яку версію обере pipТепер найцікавіше з лекції: обмеження — це стеля, і серед усіх відповідних версій беретьсянайновіша. Напишемо функцію, яка перевіряє одну версію проти одного обмеження, і прогонимоїї по тому самому списку релізів, що був в інтеракторі 5.¶

In [13]:
RELIZY = ["1.4.0", "1.4.2", "1.4.9", "1.5.0", "1.7.3", "2.0.0", "2.1.0"]


def yak_chysla(versiya):
    """Перетворює '1.4.2' на (1, 4, 2) — кортежі порівнюються поелементно."""
    return tuple(int(chastyna) for chastyna in versiya.split("."))


def pidhodyt(versiya, operator, mezha):
    """Чи задовольняє версія одне обмеження."""
    ye = yak_chysla(versiya)
    treba = yak_chysla(mezha)
    if operator == "==":
        return ye == treba
    if operator == ">=":
        return ye >= treba
    if operator == "<":
        return ye < treba
    if operator == "~=":
        # «сумісний реліз»: рости дозволено лише останньому написаному числу
        dovzhyna_prefiksa = len(treba) - 1
        return ye >= treba and ye[:dovzhyna_prefiksa] == treba[:dovzhyna_prefiksa]
    raise ValueError(f"невідомий оператор: {operator}")


ZAHOLOVKY = ["==1.4.2", ">=1.4", "~=1.4.2", "~=1.4"]

print(f"{'версія':<10}" + "".join(f"{nazva:<10}" for nazva in ZAHOLOVKY))
for versiya in RELIZY:
    ryadok = [
        pidhodyt(versiya, "==", "1.4.2"),
        pidhodyt(versiya, ">=", "1.4"),
        pidhodyt(versiya, "~=", "1.4.2"),
        pidhodyt(versiya, "~=", "1.4"),
    ]
    poznachky = "".join(f"{('✓' if znachennya else '✗'):<10}" for znachennya in ryadok)
    print(f"{versiya:<10}" + poznachky)
версія    ==1.4.2   >=1.4     ~=1.4.2   ~=1.4     
1.4.0     ✗         ✓         ✗         ✓         
1.4.2     ✓         ✓         ✓         ✓         
1.4.9     ✗         ✓         ✓         ✓         
1.5.0     ✗         ✓         ✗         ✓         
1.7.3     ✗         ✓         ✗         ✓         
2.0.0     ✗         ✓         ✗         ✗         
2.1.0     ✗         ✓         ✗         ✗         

А тепер порівняємо результат нашої функції з тим, що показував інтерактив 5 у лекції.Числа мають збігтися до одного.

In [14]:
def vidpovidni(obmezhennya):
    """Усі релізи, що проходять усі обмеження одразу."""
    pidhodyat = []
    for versiya in RELIZY:
        if all(pidhodyt(versiya, operator, mezha) for operator, mezha in obmezhennya):
            pidhodyat.append(versiya)
    return pidhodyat


perevirky = {
    "zvitnyk==1.4.2":     [("==", "1.4.2")],
    "zvitnyk>=1.4":       [(">=", "1.4")],
    "zvitnyk>=1.4,<2.0":  [(">=", "1.4"), ("<", "2.0")],
    "zvitnyk~=1.4.2":     [("~=", "1.4.2")],
    "zvitnyk~=1.4":       [("~=", "1.4")],
}

# те, що показував інтерактив 5: скільки версій підходить і яку візьме pip
ochikuvannya = {
    "zvitnyk==1.4.2":     (1, "1.4.2"),
    "zvitnyk>=1.4":       (7, "2.1.0"),
    "zvitnyk>=1.4,<2.0":  (5, "1.7.3"),
    "zvitnyk~=1.4.2":     (2, "1.4.9"),
    "zvitnyk~=1.4":       (5, "1.7.3"),
}

for zapys, obmezhennya in perevirky.items():
    pidhodyat = vidpovidni(obmezhennya)
    vizme = pidhodyat[-1]          # список релізів іде від старих до нових
    print(f"{zapys:<20} підходить {len(pidhodyat)} з 7, візьме {vizme}")
    assert (len(pidhodyat), vizme) == ochikuvannya[zapys], f"розійшлося на {zapys}"

print("✅ усі п'ять записів дали рівно те, що в лекції")
zvitnyk==1.4.2       підходить 1 з 7, візьме 1.4.2
zvitnyk>=1.4         підходить 7 з 7, візьме 2.1.0
zvitnyk>=1.4,<2.0    підходить 5 з 7, візьме 1.7.3
zvitnyk~=1.4.2       підходить 2 з 7, візьме 1.4.9
zvitnyk~=1.4         підходить 5 з 7, візьме 1.7.3
✅ усі п'ять записів дали рівно те, що в лекції

12 · .gitignoreТеку середовища в репозиторій не кладуть — вона похідна, важка й прив'язана до конкретноїмашини. Замість неї комітять requirements.txt. Напишемо .gitignore і перевіримо на кількохшляхах, що саме він приховає.¶

In [15]:
import fnmatch

vmist_gitignore = """\
.venv/
__pycache__/
*.pyc
"""

fajl_gitignore = majsternya / ".gitignore"
fajl_gitignore.write_text(vmist_gitignore, encoding="utf-8")

pravyla = [ryadok.strip() for ryadok in vmist_gitignore.splitlines() if ryadok.strip()]


def chy_ignoruyetsya(shlyah, pravyla):
    """Спрощена перевірка: чи збігається хоч одна частина шляху з правилом."""
    chastyny = Path(shlyah).parts
    for pravylo in pravyla:
        zrazok = pravylo.rstrip("/")
        if any(fnmatch.fnmatch(chastyna, zrazok) for chastyna in chastyny):
            return True
    return False


shlyahy = [".venv/bin/python", "__pycache__/zvit.cpython-312.pyc",
           "zvit.py", "requirements.txt", "modul.pyc"]

for shlyah in shlyahy:
    print(f"{shlyah:<38} {'приховано' if chy_ignoruyetsya(shlyah, pravyla) else 'у репозиторій'}")

assert chy_ignoruyetsya(".venv/bin/python", pravyla) is True
assert chy_ignoruyetsya("requirements.txt", pravyla) is False
print("✅ середовище приховане, файл залежностей — ні. Саме такий обмін нам і потрібен")
.venv/bin/python                       приховано
__pycache__/zvit.cpython-312.pyc       приховано
zvit.py                                у репозиторій
requirements.txt                       у репозиторій
modul.pyc                              приховано
✅ середовище приховане, файл залежностей — ні. Саме такий обмін нам і потрібен

13 · Прибираємо за собоюУсе, що ми створили, лежить в одній теці — тому прибирання займає рядок. shutil.rmtreeвидаляє теку разом із усім вмістом.Це не формальність: середовище — похідна річ, і саме тому його не шкода видаляти.¶

In [16]:
print("перед прибиранням:", majsternya, "· існує:", majsternya.is_dir())

shutil.rmtree(majsternya)

print("після прибирання :", majsternya, "· існує:", majsternya.is_dir())

assert not majsternya.exists(), "тимчасова тека лишилась на диску!"
print("✅ на диску не лишилось нічого — зошит можна запускати скільки завгодно разів")
перед прибиранням: /tmp/tema17_s019vcdl · існує: True
після прибирання : /tmp/tema17_s019vcdl · існує: False
✅ на диску не лишилось нічого — зошит можна запускати скільки завгодно разів

---## Завдання### 🟢 Рівень 1 — БазаСтвори в новій тимчасовій теці два середовища — env_a і env_b. Для кожного дістаньsys.prefix через subprocess і переконайся assert-ом, що вони різні, а sys.base_prefixу них однаковий. Наприкінці приберися.Зроблено, якщо: обидва assert-и проходять, а shutil.rmtree не лишає теки на диску.### 🟡 Рівень 2 — ПлюсНапиши функцію porivnyaty(fajl_a, fajl_b), яка читає два requirements.txt і повертає тримножини: пакети тільки в першому, тільки в другому й ті, що є в обох, але різних версій.Перевір її на парі файлів, які створиш сама.Зроблено, якщо: функція правильно знаходить усі три категорії, і це підтвердженоassert-ами на очікуваних множинах.### 🔴 Рівень 3 — ВикликДоведи, що ізоляція справжня. Створи середовище, вручну поклади у його site-packages файлzvitnyk.py з функцією, яка повертає рядок. Потім переконайся, що:1. python середовища імпортує цей модуль і бачить функцію;2. інтерпретатор зошита той самий import zvitnyk виконати не може;3. після shutil.rmtree модуль зникає й для середовища теж.Пункт 2 оформи клітинкою з тегом raises-exception, щоб побачити справжнійModuleNotFoundError.Зроблено, якщо: усі три пункти підтверджені виводом, а traceback у пункті 2 —справжній, а не роздрукований рядком.