Практика · Тема 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() робить теку в системному сховищі тимчасових файлів і повертає шлях до неї.¶
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 спрацював.¶
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.¶
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, звідкибереться стандартна бібліотека. Саме за наявністю цього файлу інтерпретатор розуміє, що працюєвсередині середовища.¶
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.¶
# назви тек відрізняються на 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) — і порівняємо.Очікування з лекції: у середовищі має бути своя тека з пакетами, і жодної чужої.¶
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
А тепер сформулюємо очікування як перевірку. Чужою вважаємо будь-яку теку з пакетами, що лежитьпоза текою середовища: якщо така знайдеться, ізоляція протікає.
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).А потім порівнюємо свій прогноз із тим, що насправді сказав інтерпретатор середовища.Якщо збіглося — значить, правило зрозуміле правильно, а не завчене.¶
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 запустився: той, що належить нашому середовищу.¶
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 до свого списку не потрапляє ніколи.
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. Це той самий текст: по рядку на пакет, порожні рядки й рядкиз # ігноруються.¶
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 робить із цим файлом дві речі: викидає порожні рядки й коментарі, а рештурозбирає на «ім'я + обмеження». Повторимо це самі — так стане видно, що всередині немає магії.¶
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.¶
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 у лекції.Числа мають збігтися до одного.
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 і перевіримо на кількохшляхах, що саме він приховає.¶
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видаляє теку разом із усім вмістом.Це не формальність: середовище — похідна річ, і саме тому його не шкода видаляти.¶
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 —справжній, а не роздрукований рядком.