Deep Engineering

ЗАМЕР

bench/venv/basics.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/interview/python/venv-dependencies
Как запустить
python3.11 bench/venv/basics.py
python3.12 bench/venv/basics.py
python3.13 bench/venv/basics.py
python3.14 bench/venv/basics.py

python3.11 bench/venv/practice.py
python3.12 bench/venv/practice.py
python3.13 bench/venv/practice.py
python3.14 bench/venv/practice.py

Запись прогона

Замеры для урока «Окружения и зависимости»

Скрипт Что делает
basics.py создаёт окружение во временной папке и смотрит на него: что в папке и в pyvenv.cfg, sys.prefix без активации, пакеты в свежем окружении, изоляция поставленного пакета и pip freeze, что меняет activate, что ломается в копии окружения, что пишет pip freeze для пакетов из PyPI, из локальной папки и editable, и что видит env/bin/python -S
practice.py источник ответов двух практических задач урока
python3.11 bench/venv/basics.py
python3.12 bench/venv/basics.py
python3.13 bench/venv/basics.py
python3.14 bench/venv/basics.py

python3.11 bench/venv/practice.py
python3.12 bench/venv/practice.py
python3.13 bench/venv/practice.py
python3.14 bench/venv/practice.py

Запускать самим интерпретатором, а не символической ссылкой на него: venv определяет базовую установку по пути интерпретатора. Раздел 4 basics.py ставит tomli-w==1.0.0 из PyPI, раздел 7 скачивает setuptools для сборки двух локальных пакетов — нужна сеть.

Записи в runs/: без суффикса — CPython 3.13.7, остальные — с суффиксом версии (3.11.15, 3.12.3, 3.14.7). Путь временной папки в выводе заменён на <папка>, путь базовой установки — на <python>.

Что получилось

Папка окружения: bin/, include/, lib/, lib64 (символическая ссылка на lib), pyvenv.cfg; с 3.13 — ещё .gitignore (его создаёт командная строка python -m venv). bin/python — символическая ссылка на базовый интерпретатор; абсолютный путь окружения записан в bin/activate и в первой строке bin/pip (#!<папка>/env/bin/python3.13). На 3.14 в bin/ есть ещё имя 𝜋thon.

Без активации: sys.prefix — папка окружения, sys.base_prefix — базовая установка, пакеты ставятся в site-packages окружения.

Свежее окружение: 3.11 — pip==24.0 и setuptools==79.0.1; 3.12 — pip==24.0; 3.13 — pip==25.2; 3.14 — pip==26.2.1. Наполняет его ensurepip; набор пакетов задан в Lib/ensurepip/__init__.py каждой версии.

Изоляция: tomli_w импортируется интерпретатором окружения и не импортируется базовым (ModuleNotFoundError). У базового sys.prefix — <python>, пакеты — в <python>/lib/python3.13/site-packages (у сборок 3.11 и 3.12 из Ubuntu — <python>/local/lib/python3.1X/dist-packages). В метаданных пакета Name: tomli_w; pip freeze — tomli_w==1.0.0, без самого pip (на 3.11 — и без setuptools).

activate: ставит env/bin первым в PATH, задаёт VIRTUAL_ENV и VIRTUAL_ENV_PROMPT (env на 3.13 и 3.14, (env) на 3.11 и 3.12); deactivate возвращает PATH прежним. Что ещё делает шаблон activate — приглашение PS1, снятие PYTHONHOME — прогон не печатает.

Копия в другую папку: moved/bin/python ставит sys.prefix на <папка>/moved, а moved/bin/pip по шебангу работает со старым окружением (<папка>/env/lib/…/pip); moved/bin/python -m pip — с копией. Когда старой папки нет, moved/bin/pip не запускается: FileNotFoundError. Одинаково на 3.11–3.14.

pip freeze (раздел 7): снимок в формате requirements — tomli_w==1.0.0 для пакета из PyPI, localpkg @ file://<папка>/localpkg для пакета из локальной папки, # Editable install with no version control (editpkg==0.1) и -e <папка>/editpkg для editable. --all добавляет то, что freeze пропускает: на 3.11 — pip==24.0 и setuptools==79.0.1, на 3.12–3.14 — только pip. Правило пропуска — pip/_internal/commands/freeze.py (pip 24.0: строки 12–20, по sys.version_info < (3, 12) к pip добавляются setuptools, distribute, wheel).

env/bin/python -S (раздел 8): на 3.11–3.13 sys.prefix — <python>, prefix != base_prefix — False; на 3.14 — <папка>/env и True. С 3.14 префиксы окружения выставляет инициализация путей, а не site (документация site 3.14, «Changed in version 3.14»). site-packages окружения в sys.path под -S нет ни на одной версии.

Скрипт

192 строк
"""Виртуальные окружения и зависимости: что делает venv и что — pip.

Скрипт создаёт окружение во временной папке тем интерпретатором, которым
запущен, и смотрит на него снаружи и изнутри. Путь временной папки в
выводе заменён на `<папка>`, путь к базовому интерпретатору — на `<python>`.

Шаг 4 ставит пакет из PyPI — нужен доступ к сети. Пакет выбран такой,
которого нет в базовых интерпретаторах машины записи: tomli-w 1.0.0.

ВОСЕМЬ СЮЖЕТОВ:

1. ЧТО ТАКОЕ ОКРУЖЕНИЕ. Папка со своим bin/python и файлом pyvenv.cfg —
   и больше почти ничего.

2. КАК ИНТЕРПРЕТАТОР ПОНИМАЕТ, ЧТО ОН В ОКРУЖЕНИИ. sys.prefix указывает на
   окружение, sys.base_prefix — на базовую установку. Активировать для
   этого ничего не нужно.

3. ЧТО ЛЕЖИТ В СВЕЖЕМ ОКРУЖЕНИИ. pip list сразу после создания.

4. ИЗОЛЯЦИЯ. Пакет, поставленный в окружение, виден его интерпретатору и не
   виден базовому; pip freeze печатает его с точной версией.

5. АКТИВАЦИЯ. Скрипт activate ставит bin/ окружения первым в PATH и задаёт
   VIRTUAL_ENV и VIRTUAL_ENV_PROMPT; deactivate всё возвращает.

6. КОПИЯ. Окружение, скопированное в другую папку: его python видит новую
   папку, а bin/pip по шебангу запускает интерпретатор старого окружения —
   и без старой папки не запускается вовсе.

7. ЧТО ПИШЕТ PIP FREEZE. Снимок окружения в формате requirements: пакет из
   PyPI — строкой имя==версия, пакет из локальной папки — строкой с прямой
   ссылкой (direct URL), editable-установка — строкой -e с путём. Сам pip
   freeze пропускает (на 3.11 — ещё setuptools), freeze --all — нет. Шаг
   ставит два крошечных пакета из временной папки; для их сборки pip
   скачивает setuptools из PyPI — нужна сеть.

8. ПРЕФИКСЫ БЕЗ МОДУЛЯ SITE. env/bin/python -S: до 3.13 включительно
   sys.prefix окружения ставил модуль site, и -S это отменял; с 3.14 его
   ставит инициализация путей, и -S на него не влияет. site-packages
   окружения в sys.path без site не попадает ни на одной версии.

Запускать: python3.11 / 3.12 / 3.13 / 3.14 (не символической ссылкой на
интерпретатор, а им самим — иначе venv найдёт не ту базовую установку).
"""
import os
import subprocess
import sys
import tempfile

print("PY", sys.version.split()[0])
print()

tmp = os.path.realpath(tempfile.mkdtemp())
env_dir = os.path.join(tmp, "env")
env_py = os.path.join(env_dir, "bin", "python")
base_prefix = sys.base_prefix


def clean(text):
    return text.replace(tmp, "<папка>").replace(base_prefix, "<python>").rstrip()


def run(cmd, **kw):
    r = subprocess.run(cmd, capture_output=True, text=True, **kw)
    return clean(r.stdout + r.stderr)


subprocess.run([sys.executable, "-m", "venv", env_dir], check=True)

# ── 1 ─────────────────────────────────────────────────────────────────────
print("1) что внутри папки окружения (верхний уровень и bin/)")
print("   ", sorted(os.listdir(env_dir)))
print("   bin/:", sorted(n for n in os.listdir(os.path.join(env_dir, "bin")) if not n.startswith("activate")),
      "+ скрипты activate*")
py_link = os.path.join(env_dir, "bin", "python")
print("   bin/python — символическая ссылка:", os.path.islink(py_link), "->", clean(os.path.realpath(py_link)))
with open(os.path.join(env_dir, "bin", "activate"), encoding="utf-8") as f:
    print("   абсолютный путь окружения записан в bin/activate:", env_dir in f.read())
with open(os.path.join(env_dir, "bin", "pip"), encoding="utf-8") as f:
    print("   первая строка bin/pip:", clean(f.readline().strip()))
print("   pyvenv.cfg:")
with open(os.path.join(env_dir, "pyvenv.cfg"), encoding="utf-8") as f:
    for line in f:
        key = line.split("=")[0].strip()
        if key in ("home", "include-system-site-packages", "version"):
            print("     ", clean(line.strip()))
print()

# ── 2 ─────────────────────────────────────────────────────────────────────
print("2) интерпретатор окружения, без активации")
probe = "import sys; print(sys.prefix); print(sys.base_prefix); print(sys.prefix != sys.base_prefix)"
lines = run([env_py, "-c", probe]).splitlines()
print("   sys.prefix               ->", lines[0])
print("   sys.base_prefix          ->", lines[1])
print("   prefix != base_prefix    ->", lines[2])
site = run([env_py, "-c", "import sysconfig; print(sysconfig.get_paths()['purelib'])"])
print("   куда ставятся пакеты     ->", site)
print()

# ── 3 ─────────────────────────────────────────────────────────────────────
print("3) pip list в свежем окружении")
for line in run([env_py, "-m", "pip", "list", "--format=freeze", "--disable-pip-version-check"]).splitlines():
    print("   ", line)
print()

# ── 4 ─────────────────────────────────────────────────────────────────────
print("4) изоляция: pip install tomli-w==1.0.0 в окружение")
subprocess.run([env_py, "-m", "pip", "install", "-q", "--disable-pip-version-check", "tomli-w==1.0.0"],
               check=True, capture_output=True)
check = "import tomli_w; print('импортирован, версия', tomli_w.__version__)"
print("   интерпретатор окружения ->", run([env_py, "-c", check]).splitlines()[-1])
print("   базовый интерпретатор   ->", run([sys.executable, "-c", check]).splitlines()[-1])
base_paths = run([sys.executable, "-c", "import sys, sysconfig; print(sys.prefix); print(sysconfig.get_paths()['purelib'])"]).splitlines()
print("   у базового sys.prefix   ->", base_paths[0])
print("   у базового пакеты в     ->", base_paths[1])
meta = run([env_py, "-c", "from importlib.metadata import metadata; print(metadata('tomli-w')['Name'])"])
print("   Name в метаданных пакета ->", meta)
freeze = run([env_py, "-m", "pip", "freeze", "--disable-pip-version-check"]).splitlines()
print("   pip freeze              ->", freeze)
print()

# ── 5 ─────────────────────────────────────────────────────────────────────
print("5) активация в bash: что она меняет")
script = f'''
before_path="$PATH"
source "{env_dir}/bin/activate"
echo "VIRTUAL_ENV=$VIRTUAL_ENV"
echo "VIRTUAL_ENV_PROMPT=$VIRTUAL_ENV_PROMPT"
echo "первый элемент PATH: ${{PATH%%:*}}"
echo "python в PATH: $(command -v python)"
deactivate
echo "после deactivate PATH прежний: $([ "$PATH" = "$before_path" ] && echo да || echo нет)"
'''
for line in run(["bash", "-c", script]).splitlines():
    print("   ", line)
print()

# ── 6 ─────────────────────────────────────────────────────────────────────
print("6) копия окружения в другую папку той же машины")
import shutil
moved = os.path.join(tmp, "moved")
shutil.copytree(env_dir, moved, symlinks=True)
moved_py = os.path.join(moved, "bin", "python")
moved_pip = os.path.join(moved, "bin", "pip")
print("   moved/bin/python: sys.prefix      ->", run([moved_py, "-c", "import sys; print(sys.prefix)"]))
print("   moved/bin/pip --version           ->", run([moved_pip, "--version", "--disable-pip-version-check"]).split(" from ")[1].split(" (")[0])
print("   moved/bin/python -m pip --version ->", run([moved_py, "-m", "pip", "--version", "--disable-pip-version-check"]).split(" from ")[1].split(" (")[0])
away = os.path.join(tmp, "env-away")
os.rename(env_dir, away)
try:
    r = subprocess.run([moved_pip, "--version"], capture_output=True, text=True)
    print("   без старой папки moved/bin/pip    -> запустился, код", r.returncode)
except OSError as e:
    print("   без старой папки moved/bin/pip    ->", type(e).__name__ + ":", e.strerror)
finally:
    os.rename(away, env_dir)

# ── 7 ─────────────────────────────────────────────────────────────────────
print()
print("7) pip freeze: что попадает в снимок")
for name in ("localpkg", "editpkg"):
    pkg_dir = os.path.join(tmp, name)
    os.makedirs(os.path.join(pkg_dir, name))
    with open(os.path.join(pkg_dir, "pyproject.toml"), "w", encoding="utf-8") as f:
        f.write('[build-system]\nrequires = ["setuptools>=61"]\nbuild-backend = "setuptools.build_meta"\n\n'
                f'[project]\nname = "{name}"\nversion = "0.1"\n')
    with open(os.path.join(pkg_dir, name, "__init__.py"), "w", encoding="utf-8") as f:
        f.write("X = 1\n")
pip_q = [env_py, "-m", "pip", "install", "-q", "--disable-pip-version-check"]
subprocess.run(pip_q + [os.path.join(tmp, "localpkg")], check=True, capture_output=True)
subprocess.run(pip_q + ["-e", os.path.join(tmp, "editpkg")], check=True, capture_output=True)
frozen = run([env_py, "-m", "pip", "freeze", "--disable-pip-version-check"]).splitlines()
print("   python -m pip freeze:")
for line in frozen:
    print("     ", line)
frozen_all = run([env_py, "-m", "pip", "freeze", "--all", "--disable-pip-version-check"]).splitlines()
print("   только в python -m pip freeze --all:", [line for line in frozen_all if line not in frozen])

# ── 8 ─────────────────────────────────────────────────────────────────────
print()
print("8) env/bin/python -S: модуль site не импортируется")
probe_s = ("import sys; print(sys.prefix); print(sys.base_prefix); "
           "print(sys.prefix != sys.base_prefix); print('site' in sys.modules); "
           "print(any('site-packages' in p for p in sys.path))")
lines = run([env_py, "-S", "-c", probe_s]).splitlines()
print("   sys.prefix               ->", lines[0])
print("   sys.base_prefix          ->", lines[1])
print("   prefix != base_prefix    ->", lines[2])
print("   site в sys.modules       ->", lines[3])
print("   site-packages в sys.path ->", lines[4])