Apply mypy-tests custom config to other mypy-based tests (#13825)

This commit is contained in:
Avasam
2025-04-28 06:24:26 -04:00
committed by GitHub
parent 23f94ffbc4
commit 5faa04038b
7 changed files with 269 additions and 220 deletions
+1
View File
@@ -166,6 +166,7 @@ _KNOWN_METADATA_FIELDS: Final = frozenset(
"tool",
"partial_stub",
"requires_python",
"mypy-tests",
}
)
_KNOWN_METADATA_TOOL_FIELDS: Final = {
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
from collections.abc import Generator, Iterable
from contextlib import contextmanager
from typing import Any, NamedTuple
import tomli
from ts_utils.metadata import metadata_path
from ts_utils.utils import NamedTemporaryFile, TemporaryFileWrapper
class MypyDistConf(NamedTuple):
module_name: str
values: dict[str, dict[str, Any]]
# The configuration section in the metadata file looks like the following, with multiple module sections possible
# [mypy-tests]
# [mypy-tests.yaml]
# module_name = "yaml"
# [mypy-tests.yaml.values]
# disallow_incomplete_defs = true
# disallow_untyped_defs = true
def mypy_configuration_from_distribution(distribution: str) -> list[MypyDistConf]:
with metadata_path(distribution).open("rb") as f:
data = tomli.load(f)
# TODO: This could be added to ts_utils.metadata
mypy_tests_conf: dict[str, dict[str, Any]] = data.get("mypy-tests", {})
if not mypy_tests_conf:
return []
def validate_configuration(section_name: str, mypy_section: dict[str, Any]) -> MypyDistConf:
assert isinstance(mypy_section, dict), f"{section_name} should be a section"
module_name = mypy_section.get("module_name")
assert module_name is not None, f"{section_name} should have a module_name key"
assert isinstance(module_name, str), f"{section_name} should be a key-value pair"
assert "values" in mypy_section, f"{section_name} should have a values section"
values: dict[str, dict[str, Any]] = mypy_section["values"]
assert isinstance(values, dict), "values should be a section"
return MypyDistConf(module_name, values.copy())
assert isinstance(mypy_tests_conf, dict), "mypy-tests should be a section"
return [validate_configuration(section_name, mypy_section) for section_name, mypy_section in mypy_tests_conf.items()]
@contextmanager
def temporary_mypy_config_file(configurations: Iterable[MypyDistConf]) -> Generator[TemporaryFileWrapper[str]]:
temp = NamedTemporaryFile("w+")
try:
for dist_conf in configurations:
temp.write(f"[mypy-{dist_conf.module_name}]\n")
for k, v in dist_conf.values.items():
temp.write(f"{k} = {v}\n")
temp.write("[mypy]\n")
temp.flush()
yield temp
finally:
temp.close()
+30 -4
View File
@@ -3,16 +3,24 @@
from __future__ import annotations
import functools
import os
import re
import sys
import tempfile
from collections.abc import Iterable, Mapping
from pathlib import Path
from typing import Any, Final, NamedTuple
from types import MethodType
from typing import TYPE_CHECKING, Any, Final, NamedTuple
from typing_extensions import TypeAlias
import pathspec
from packaging.requirements import Requirement
from .paths import REQUIREMENTS_PATH, STDLIB_PATH, STUBS_PATH, TEST_CASES_DIR, allowlists_path, test_cases_path
if TYPE_CHECKING:
from _typeshed import OpenTextMode
try:
from termcolor import colored as colored # pyright: ignore[reportAssignmentType]
except ImportError:
@@ -21,8 +29,6 @@ except ImportError:
return text
from .paths import REQUIREMENTS_PATH, STDLIB_PATH, STUBS_PATH, TEST_CASES_DIR, allowlists_path, test_cases_path
PYTHON_VERSION: Final = f"{sys.version_info.major}.{sys.version_info.minor}"
@@ -196,6 +202,26 @@ def allowlists(distribution_name: str) -> list[str]:
return ["stubtest_allowlist.txt", platform_allowlist]
# Re-exposing as a public name to avoid many pyright reportPrivateUsage
TemporaryFileWrapper = tempfile._TemporaryFileWrapper # pyright: ignore[reportPrivateUsage]
# We need to work around a limitation of tempfile.NamedTemporaryFile on Windows
# For details, see https://github.com/python/typeshed/pull/13620#discussion_r1990185997
# Python 3.12 added a cross-platform solution with `tempfile.NamedTemporaryFile("w+", delete_on_close=False)`
if sys.platform != "win32":
NamedTemporaryFile = tempfile.NamedTemporaryFile # noqa: TID251
else:
def NamedTemporaryFile(mode: OpenTextMode) -> TemporaryFileWrapper[str]: # noqa: N802
def close(self: TemporaryFileWrapper[str]) -> None:
TemporaryFileWrapper.close(self) # pyright: ignore[reportUnknownMemberType]
os.remove(self.name)
temp = tempfile.NamedTemporaryFile(mode, delete=False) # noqa: SIM115, TID251
temp.close = MethodType(close, temp) # type: ignore[method-assign]
return temp
# ====================================================================
# Parsing .gitignore
# ====================================================================
@@ -215,7 +241,7 @@ def spec_matches_path(spec: pathspec.PathSpec, path: Path) -> bool:
# ====================================================================
# mypy/stubtest call
# stubtest call
# ====================================================================
+5
View File
@@ -139,6 +139,8 @@ select = [
"TC005", # Found empty type-checking block
# "TC008", # TODO: Enable when out of preview
"TC010", # Invalid string member in `X | Y`-style union type
# Used for lint.flake8-import-conventions.aliases
"TID251", # `{name}` is banned: {message}
]
extend-safe-fixes = [
"UP036", # Remove unnecessary `sys.version_info` blocks
@@ -235,6 +237,9 @@ convention = "pep257" # https://docs.astral.sh/ruff/settings/#lint_pydocstyle_co
typing_extensions = "typing_extensions"
typing = "typing"
[tool.ruff.lint.flake8-tidy-imports.banned-api]
"tempfile.NamedTemporaryFile".msg = "Use `ts_util.util.NamedTemporaryFile` instead."
[tool.ruff.lint.isort]
split-on-trailing-comma = false
combine-as-imports = true
+29 -93
View File
@@ -5,14 +5,12 @@ from __future__ import annotations
import argparse
import concurrent.futures
import functools
import os
import subprocess
import sys
import tempfile
import time
from collections import defaultdict
from collections.abc import Generator
from dataclasses import dataclass
from enum import Enum
from itertools import product
@@ -21,10 +19,10 @@ from threading import Lock
from typing import Annotated, Any, NamedTuple
from typing_extensions import TypeAlias
import tomli
from packaging.requirements import Requirement
from ts_utils.metadata import PackageDependencies, get_recursive_requirements, metadata_path, read_metadata
from ts_utils.metadata import PackageDependencies, get_recursive_requirements, read_metadata
from ts_utils.mypy import MypyDistConf, mypy_configuration_from_distribution, temporary_mypy_config_file
from ts_utils.paths import STDLIB_PATH, STUBS_PATH, TESTS_DIR, TS_BASE_PATH, distribution_path
from ts_utils.utils import (
PYTHON_VERSION,
@@ -46,24 +44,6 @@ except ImportError:
print_error("Cannot import mypy. Did you install it?")
sys.exit(1)
# We need to work around a limitation of tempfile.NamedTemporaryFile on Windows
# For details, see https://github.com/python/typeshed/pull/13620#discussion_r1990185997
# Python 3.12 added a workaround with `tempfile.NamedTemporaryFile("w+", delete_on_close=False)`
if sys.platform != "win32":
_named_temporary_file = functools.partial(tempfile.NamedTemporaryFile, "w+")
else:
from contextlib import contextmanager
@contextmanager
def _named_temporary_file() -> Generator[tempfile._TemporaryFileWrapper[str]]: # pyright: ignore[reportPrivateUsage]
temp = tempfile.NamedTemporaryFile("w+", delete=False) # noqa: SIM115
try:
yield temp
finally:
temp.close()
os.remove(temp.name)
SUPPORTED_VERSIONS = ["3.13", "3.12", "3.11", "3.10", "3.9"]
SUPPORTED_PLATFORMS = ("linux", "win32", "darwin")
DIRECTORIES_TO_TEST = [STDLIB_PATH, STUBS_PATH]
@@ -177,49 +157,20 @@ def add_files(files: list[Path], module: Path, args: TestConfig) -> None:
files.extend(sorted(file for file in module.rglob("*.pyi") if match(file, args)))
class MypyDistConf(NamedTuple):
module_name: str
values: dict[str, dict[str, Any]]
# The configuration section in the metadata file looks like the following, with multiple module sections possible
# [mypy-tests]
# [mypy-tests.yaml]
# module_name = "yaml"
# [mypy-tests.yaml.values]
# disallow_incomplete_defs = true
# disallow_untyped_defs = true
def add_configuration(configurations: list[MypyDistConf], distribution: str) -> None:
with metadata_path(distribution).open("rb") as f:
data = tomli.load(f)
# TODO: This could be added to ts_utils.metadata, but is currently unused
mypy_tests_conf: dict[str, dict[str, Any]] = data.get("mypy-tests", {})
if not mypy_tests_conf:
return
assert isinstance(mypy_tests_conf, dict), "mypy-tests should be a section"
for section_name, mypy_section in mypy_tests_conf.items():
assert isinstance(mypy_section, dict), f"{section_name} should be a section"
module_name = mypy_section.get("module_name")
assert module_name is not None, f"{section_name} should have a module_name key"
assert isinstance(module_name, str), f"{section_name} should be a key-value pair"
assert "values" in mypy_section, f"{section_name} should have a values section"
values: dict[str, dict[str, Any]] = mypy_section["values"]
assert isinstance(values, dict), "values should be a section"
configurations.append(MypyDistConf(module_name, values.copy()))
class MypyResult(Enum):
SUCCESS = 0
FAILURE = 1
CRASH = 2
@staticmethod
def from_process_result(result: subprocess.CompletedProcess[Any]) -> MypyResult:
if result.returncode == 0:
return MypyResult.SUCCESS
elif result.returncode == 1:
return MypyResult.FAILURE
else:
return MypyResult.CRASH
def run_mypy(
args: TestConfig,
@@ -234,15 +185,7 @@ def run_mypy(
env_vars = dict(os.environ)
if mypypath is not None:
env_vars["MYPYPATH"] = mypypath
with _named_temporary_file() as temp:
temp.write("[mypy]\n")
for dist_conf in configurations:
temp.write(f"[mypy-{dist_conf.module_name}]\n")
for k, v in dist_conf.values.items():
temp.write(f"{k} = {v}\n")
temp.flush()
with temporary_mypy_config_file(configurations) as temp:
flags = [
"--python-version",
args.version,
@@ -278,29 +221,23 @@ def run_mypy(
if args.verbose:
print(colored(f"running {' '.join(mypy_command)}", "blue"))
result = subprocess.run(mypy_command, capture_output=True, text=True, env=env_vars, check=False)
if result.returncode:
print_error(f"failure (exit code {result.returncode})\n")
if result.stdout:
print_error(result.stdout)
if result.stderr:
print_error(result.stderr)
if non_types_dependencies and args.verbose:
print("Ran with the following environment:")
subprocess.run(["uv", "pip", "freeze"], env={**os.environ, "VIRTUAL_ENV": str(venv_dir)}, check=False)
print()
else:
print_success_msg()
if result.returncode == 0:
return MypyResult.SUCCESS
elif result.returncode == 1:
return MypyResult.FAILURE
else:
return MypyResult.CRASH
if result.returncode:
print_error(f"failure (exit code {result.returncode})\n")
if result.stdout:
print_error(result.stdout)
if result.stderr:
print_error(result.stderr)
if non_types_dependencies and args.verbose:
print("Ran with the following environment:")
subprocess.run(["uv", "pip", "freeze"], env={**os.environ, "VIRTUAL_ENV": str(venv_dir)}, check=False)
print()
else:
print_success_msg()
return MypyResult.from_process_result(result)
def add_third_party_files(
distribution: str, files: list[Path], args: TestConfig, configurations: list[MypyDistConf], seen_dists: set[str]
) -> None:
def add_third_party_files(distribution: str, files: list[Path], args: TestConfig, seen_dists: set[str]) -> None:
typeshed_reqs = get_recursive_requirements(distribution).typeshed_pkgs
if distribution in seen_dists:
return
@@ -311,7 +248,6 @@ def add_third_party_files(
if name.startswith("."):
continue
add_files(files, (root / name), args)
add_configuration(configurations, distribution)
class TestResult(NamedTuple):
@@ -328,9 +264,9 @@ def test_third_party_distribution(
and the second element is the number of checked files.
"""
files: list[Path] = []
configurations: list[MypyDistConf] = []
seen_dists: set[str] = set()
add_third_party_files(distribution, files, args, configurations, seen_dists)
add_third_party_files(distribution, files, args, seen_dists)
configurations = mypy_configuration_from_distribution(distribution)
if not files and args.filter:
return TestResult(MypyResult.SUCCESS, 0)
+61 -51
View File
@@ -22,6 +22,7 @@ from pathlib import Path
from typing_extensions import TypeAlias
from ts_utils.metadata import get_recursive_requirements, read_metadata
from ts_utils.mypy import mypy_configuration_from_distribution, temporary_mypy_config_file
from ts_utils.paths import STDLIB_PATH, TEST_CASES_DIR, TS_BASE_PATH, distribution_path
from ts_utils.utils import (
PYTHON_VERSION,
@@ -169,62 +170,71 @@ def run_testcases(
env_vars = dict(os.environ)
new_test_case_dir = tempdir / TEST_CASES_DIR
# "--enable-error-code ignore-without-code" is purposefully omitted.
# See https://github.com/python/typeshed/pull/8083
flags = [
"--python-version",
version,
"--show-traceback",
"--no-error-summary",
"--platform",
platform,
"--strict",
"--pretty",
# Avoid race conditions when reading the cache
# (https://github.com/python/typeshed/issues/11220)
"--no-incremental",
# Not useful for the test cases
"--disable-error-code=empty-body",
]
if package.is_stdlib:
python_exe = sys.executable
custom_typeshed = TS_BASE_PATH
flags.append("--no-site-packages")
configurations = []
else:
custom_typeshed = tempdir / TYPESHED
env_vars["MYPYPATH"] = os.pathsep.join(map(str, custom_typeshed.glob("stubs/*")))
has_non_types_dependencies = (tempdir / VENV_DIR).exists()
if has_non_types_dependencies:
python_exe = str(venv_python(tempdir / VENV_DIR))
else:
configurations = mypy_configuration_from_distribution(package.name)
with temporary_mypy_config_file(configurations) as temp:
# "--enable-error-code ignore-without-code" is purposefully omitted.
# See https://github.com/python/typeshed/pull/8083
flags = [
"--python-version",
version,
"--show-traceback",
"--no-error-summary",
"--platform",
platform,
"--strict",
"--pretty",
"--config-file",
temp.name,
# Avoid race conditions when reading the cache
# (https://github.com/python/typeshed/issues/11220)
"--no-incremental",
# Not useful for the test cases
"--disable-error-code=empty-body",
]
if package.is_stdlib:
python_exe = sys.executable
custom_typeshed = TS_BASE_PATH
flags.append("--no-site-packages")
flags.extend(["--custom-typeshed-dir", str(custom_typeshed)])
# If the test-case filename ends with -py39,
# only run the test if --python-version was set to 3.9 or higher (for example)
for path in new_test_case_dir.rglob("*.py"):
if match := re.fullmatch(r".*-py3(\d{1,2})", path.stem):
minor_version_required = int(match[1])
assert f"3.{minor_version_required}" in SUPPORTED_VERSIONS
python_minor_version = int(version.split(".")[1])
if minor_version_required > python_minor_version:
continue
flags.append(str(path))
mypy_command = [python_exe, "-m", "mypy", *flags]
if verbosity is Verbosity.VERBOSE:
description = f"{package.name}/{version}/{platform}"
msg = f"{description}: {mypy_command=}\n"
if "MYPYPATH" in env_vars:
msg += f"{description}: {env_vars['MYPYPATH']=}"
else:
msg += f"{description}: MYPYPATH not set"
msg += "\n"
verbose_log(msg)
return subprocess.run(mypy_command, capture_output=True, text=True, env=env_vars, check=False)
custom_typeshed = tempdir / TYPESHED
env_vars["MYPYPATH"] = os.pathsep.join(map(str, custom_typeshed.glob("stubs/*")))
has_non_types_dependencies = (tempdir / VENV_DIR).exists()
if has_non_types_dependencies:
python_exe = str(venv_python(tempdir / VENV_DIR))
else:
python_exe = sys.executable
flags.append("--no-site-packages")
flags.extend(["--custom-typeshed-dir", str(custom_typeshed)])
# If the test-case filename ends with -py39,
# only run the test if --python-version was set to 3.9 or higher (for example)
for path in new_test_case_dir.rglob("*.py"):
if match := re.fullmatch(r".*-py3(\d{1,2})", path.stem):
minor_version_required = int(match[1])
assert f"3.{minor_version_required}" in SUPPORTED_VERSIONS
python_minor_version = int(version.split(".")[1])
if minor_version_required > python_minor_version:
continue
flags.append(str(path))
mypy_command = [python_exe, "-m", "mypy", *flags]
if verbosity is Verbosity.VERBOSE:
description = f"{package.name}/{version}/{platform}"
msg = f"{description}: {mypy_command=}\n"
if "MYPYPATH" in env_vars:
msg += f"{description}: {env_vars['MYPYPATH']=}"
else:
msg += f"{description}: MYPYPATH not set"
msg += "\n"
verbose_log(msg)
return subprocess.run(mypy_command, capture_output=True, text=True, env=env_vars, check=False)
@dataclass(frozen=True)
+79 -72
View File
@@ -16,6 +16,7 @@ from time import time
from typing import NoReturn
from ts_utils.metadata import NoSuchStubError, get_recursive_requirements, read_metadata
from ts_utils.mypy import mypy_configuration_from_distribution, temporary_mypy_config_file
from ts_utils.paths import STUBS_PATH, allowlists_path, tests_path
from ts_utils.utils import (
PYTHON_VERSION,
@@ -95,89 +96,95 @@ def run_stubtest(
print_command_failure("Failed to install", e)
return False
ignore_missing_stub = ["--ignore-missing-stub"] if stubtest_settings.ignore_missing_stub else []
packages_to_check = [d.name for d in dist.iterdir() if d.is_dir() and d.name.isidentifier()]
modules_to_check = [d.stem for d in dist.iterdir() if d.is_file() and d.suffix == ".pyi"]
stubtest_cmd = [
python_exe,
"-m",
"mypy.stubtest",
# Use --custom-typeshed-dir in case we make linked changes to stdlib or _typeshed
"--custom-typeshed-dir",
str(dist.parent.parent),
*ignore_missing_stub,
*packages_to_check,
*modules_to_check,
*allowlist_stubtest_arguments(dist_name),
]
mypy_configuration = mypy_configuration_from_distribution(dist_name)
with temporary_mypy_config_file(mypy_configuration) as temp:
ignore_missing_stub = ["--ignore-missing-stub"] if stubtest_settings.ignore_missing_stub else []
packages_to_check = [d.name for d in dist.iterdir() if d.is_dir() and d.name.isidentifier()]
modules_to_check = [d.stem for d in dist.iterdir() if d.is_file() and d.suffix == ".pyi"]
stubtest_cmd = [
python_exe,
"-m",
"mypy.stubtest",
"--mypy-config-file",
temp.name,
# Use --custom-typeshed-dir in case we make linked changes to stdlib or _typeshed
"--custom-typeshed-dir",
str(dist.parent.parent),
*ignore_missing_stub,
*packages_to_check,
*modules_to_check,
*allowlist_stubtest_arguments(dist_name),
]
stubs_dir = dist.parent
mypypath_items = [str(dist)] + [str(stubs_dir / pkg.name) for pkg in requirements.typeshed_pkgs]
mypypath = os.pathsep.join(mypypath_items)
# For packages that need a display, we need to pass at least $DISPLAY
# to stubtest. $DISPLAY is set by xvfb-run in CI.
#
# It seems that some other environment variables are needed too,
# because the CI fails if we pass only os.environ["DISPLAY"]. I didn't
# "bisect" to see which variables are actually needed.
stubtest_env = os.environ | {"MYPYPATH": mypypath, "MYPY_FORCE_COLOR": "1"}
stubs_dir = dist.parent
mypypath_items = [str(dist)] + [str(stubs_dir / pkg.name) for pkg in requirements.typeshed_pkgs]
mypypath = os.pathsep.join(mypypath_items)
# For packages that need a display, we need to pass at least $DISPLAY
# to stubtest. $DISPLAY is set by xvfb-run in CI.
#
# It seems that some other environment variables are needed too,
# because the CI fails if we pass only os.environ["DISPLAY"]. I didn't
# "bisect" to see which variables are actually needed.
stubtest_env = os.environ | {"MYPYPATH": mypypath, "MYPY_FORCE_COLOR": "1"}
# Perform some black magic in order to run stubtest inside uWSGI
if dist_name == "uWSGI":
if not setup_uwsgi_stubtest_command(dist, venv_dir, stubtest_cmd):
return False
# Perform some black magic in order to run stubtest inside uWSGI
if dist_name == "uWSGI":
if not setup_uwsgi_stubtest_command(dist, venv_dir, stubtest_cmd):
return False
if dist_name == "gdb":
if not setup_gdb_stubtest_command(venv_dir, stubtest_cmd):
return False
if dist_name == "gdb":
if not setup_gdb_stubtest_command(venv_dir, stubtest_cmd):
return False
try:
subprocess.run(stubtest_cmd, env=stubtest_env, check=True, capture_output=True)
except subprocess.CalledProcessError as e:
print_time(time() - t)
print_error("fail")
try:
subprocess.run(stubtest_cmd, env=stubtest_env, check=True, capture_output=True)
except subprocess.CalledProcessError as e:
print_time(time() - t)
print_error("fail")
print_divider()
print("Commands run:")
print_commands(pip_cmd, stubtest_cmd, mypypath)
print_divider()
print("Commands run:")
print_commands(pip_cmd, stubtest_cmd, mypypath)
print_divider()
print("Command output:\n")
print_command_output(e)
print_divider()
print("Command output:\n")
print_command_output(e)
print_divider()
print("Python version: ", end="", flush=True)
ret = subprocess.run([sys.executable, "-VV"], capture_output=True, check=False)
print_command_output(ret)
print("\nRan with the following environment:")
ret = subprocess.run([pip_exe, "freeze", "--all"], capture_output=True, check=False)
print_command_output(ret)
if keep_tmp_dir:
print("Path to virtual environment:", venv_dir, flush=True)
print_divider()
main_allowlist_path = allowlists_path(dist_name) / "stubtest_allowlist.txt"
if main_allowlist_path.exists():
print(f'To fix "unused allowlist" errors, remove the corresponding entries from {main_allowlist_path}')
print()
else:
print(f"Re-running stubtest with --generate-allowlist.\nAdd the following to {main_allowlist_path}:")
ret = subprocess.run([*stubtest_cmd, "--generate-allowlist"], env=stubtest_env, capture_output=True, check=False)
print_divider()
print("Python version: ", end="", flush=True)
ret = subprocess.run([sys.executable, "-VV"], capture_output=True, check=False)
print_command_output(ret)
print_divider()
print(f"Upstream repository: {metadata.upstream_repository}")
print(f"Typeshed source code: https://github.com/python/typeshed/tree/main/stubs/{dist.name}")
print("\nRan with the following environment:")
ret = subprocess.run([pip_exe, "freeze", "--all"], capture_output=True, check=False)
print_command_output(ret)
if keep_tmp_dir:
print("Path to virtual environment:", venv_dir, flush=True)
print_divider()
print_divider()
main_allowlist_path = allowlists_path(dist_name) / "stubtest_allowlist.txt"
if main_allowlist_path.exists():
print(f'To fix "unused allowlist" errors, remove the corresponding entries from {main_allowlist_path}')
print()
else:
print(f"Re-running stubtest with --generate-allowlist.\nAdd the following to {main_allowlist_path}:")
ret = subprocess.run(
[*stubtest_cmd, "--generate-allowlist"], env=stubtest_env, capture_output=True, check=False
)
print_command_output(ret)
return False
else:
print_time(time() - t)
print_success_msg()
if keep_tmp_dir:
print_info(f"Virtual environment kept at: {venv_dir}")
print_divider()
print(f"Upstream repository: {metadata.upstream_repository}")
print(f"Typeshed source code: https://github.com/python/typeshed/tree/main/stubs/{dist.name}")
print_divider()
return False
else:
print_time(time() - t)
print_success_msg()
if keep_tmp_dir:
print_info(f"Virtual environment kept at: {venv_dir}")
finally:
if not keep_tmp_dir:
rmtree(venv_dir)