diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 01df143..c1c1db0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -23,7 +23,7 @@ jobs: uv run pre-commit run --all-files - name: Test run: | - uv run pytest --cov --cov-report=term-missing --cov-report=xml + uv run pytest --cov --cov-report=term-missing --cov-report=xml --benchmark-disable - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: @@ -44,4 +44,4 @@ jobs: run: uv sync --all-groups - name: Test run: | - uv run pytest --cov --cov-report=term-missing + uv run pytest --cov --cov-report=term-missing --benchmark-disable diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df99dfd..7712845 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,6 +51,14 @@ To run the test suite: uv run pytest tests/ ``` +### Benchmarks + +To monitor performance, a set of benchmarks can be run: + +```console +uv run pytest benches/ +``` + ### Code Quality Python linting and code formatting is provided by `ruff`. diff --git a/benches/__init__.py b/benches/__init__.py new file mode 100644 index 0000000..2a83580 --- /dev/null +++ b/benches/__init__.py @@ -0,0 +1 @@ +"""Benchmarks test module.""" diff --git a/benches/conftest.py b/benches/conftest.py new file mode 100644 index 0000000..bd11672 --- /dev/null +++ b/benches/conftest.py @@ -0,0 +1,124 @@ +"""Pytest configuration for benchmarks.""" + +import base64 +import os +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Generator +from unittest import mock + +import pytest +from typing_extensions import Buffer + +import pyautoenv +from benches.tools import environment_variable, make_venv +from tests.tools import clear_lru_caches + +POETRY_PYPROJECT = """[project] +name = "{project_name}" +version = "0.1.0" +description = "" +authors = [ + {{name = "A Name",email = "someemail@abc.com"}} +] +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ +] + +[tool.poetry] +packages = [{{include = "{project_name}", from = "src"}}] + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" +""" + + +@pytest.fixture(autouse=True) +def reset_caches() -> None: + """Reset the LRU caches in pyautoenv.""" + clear_lru_caches(pyautoenv) + + +@pytest.fixture(autouse=True, scope="module") +def capture_logging() -> Generator[None, None, None]: + """Capture all logging as benchmarks are extremely noisy.""" + if __debug__: + logging_disable = pyautoenv.logger.disabled + try: + pyautoenv.logger.disabled = True + yield + finally: + pyautoenv.logger.disabled = logging_disable + else: + yield None + + +@pytest.fixture(autouse=True, scope="module") +def deactivate_venvs() -> Generator[None, None, None]: + """Fixture to 'deactivate' any currently active virtualenvs.""" + original_venv = os.environ.get("VIRTUAL_ENV") + try: + os.environ.pop("VIRTUAL_ENV", None) + yield + finally: + if original_venv is not None: + os.environ["VIRTUAL_ENV"] = original_venv + + +@pytest.fixture +def venv(tmp_path: Path) -> Path: + """Fixture returning a venv in a temporary directory.""" + return make_venv(tmp_path / "venv_fixture") + + +@dataclass +class PoetryVenvFixture: + """Poetry virtual environment fixture data.""" + + project_dir: Path + venv_dir: Path + + +@pytest.fixture +def poetry_venv(tmp_path: Path) -> Generator[PoetryVenvFixture, None, None]: + """Create a poetry virtual environment and associated project.""" + # Make poetry's cache directory. + cache_dir = tmp_path / "pypoetry" + cache_dir.mkdir() + virtualenvs_dir = cache_dir / "virtualenvs" + virtualenvs_dir.mkdir() + + # Create a virtual environment within the cache directory. + project_name = "benchmark" + py_version = ".".join( + str(v) for v in [sys.version_info.major, sys.version_info.minor] + ) + fake_hash = "SOMEHASH" + "A" * (32 - 8) + venv_name = f"{project_name}-{fake_hash[:8]}-py{py_version}" + venv_dir = make_venv(virtualenvs_dir, venv_name) + + # Create a poetry project directory with a lockfile and pyproject. + project_dir = tmp_path / project_name + project_dir.mkdir() + pyproject = project_dir / "pyproject.toml" + with pyproject.open("w") as f: + f.write(POETRY_PYPROJECT.format(project_name=project_name)) + (project_dir / "poetry.lock").touch() + + # Mock base64 encode to return a fixed hash so the poetry env is + # discoverable. Actually run the encoder so the benchmark is more + # representative, but return a fixed value. + real_b64_encode = base64.urlsafe_b64encode + + def b64encode(s: Buffer) -> bytes: + real_b64_encode(s) + return fake_hash.encode() + + with ( + mock.patch("base64.urlsafe_b64encode", new=b64encode), + environment_variable("POETRY_CACHE_DIR", str(cache_dir)), + ): + yield PoetryVenvFixture(project_dir, venv_dir) diff --git a/benches/test_benchmarks.py b/benches/test_benchmarks.py new file mode 100644 index 0000000..a3f16f1 --- /dev/null +++ b/benches/test_benchmarks.py @@ -0,0 +1,115 @@ +"""Benchmarks for pyautoenv's main function.""" + +from io import StringIO +from pathlib import Path +from typing import Union + +import pytest + +import pyautoenv +from benches.conftest import PoetryVenvFixture +from benches.tools import make_venv, venv_active, working_directory +from tests.tools import clear_lru_caches + + +class ResettingStream(StringIO): + """ + A writable stream that resets its position to 0 after each write. + + We can use this in benchmarks to check what's written to the stream + in the final iteration. + """ + + def write(self, s): + r = super().write(s) + self.seek(0) + return r + + +def run_main_benchmark(benchmark, *, shell: Union[str, None] = None): + stream = ResettingStream() + argv = [] + if shell: + argv.append(f"--{shell}") + benchmark(pyautoenv.main, argv, stdout=stream) + clear_lru_caches(pyautoenv) + return stream.getvalue() + + +def test_no_activation(benchmark, tmp_path: Path): + with working_directory(tmp_path): + assert not run_main_benchmark(benchmark) + + +def test_deactivate(benchmark, venv: Path, tmp_path: Path): + with venv_active(venv), working_directory(tmp_path): + assert run_main_benchmark(benchmark) == "deactivate" + + +@pytest.mark.parametrize("shell", [None, "fish", "pwsh"]) +def test_venv_activate(shell, benchmark, venv: Path): + with working_directory(venv): + output = run_main_benchmark(benchmark, shell=shell) + + assert all(s in output.lower() for s in ["activate", str(venv).lower()]), ( + output + ) + + +def test_venv_already_active(benchmark, venv: Path): + with venv_active(venv), working_directory(venv): + assert not run_main_benchmark(benchmark) + + +@pytest.mark.parametrize("shell", [None, "fish", "pwsh"]) +def test_venv_switch_venv(shell, benchmark, venv: Path, tmp_path: Path): + make_venv(tmp_path) + + with venv_active(venv), working_directory(tmp_path): + output = run_main_benchmark(benchmark, shell=shell) + + assert all( + s in output for s in ["deactivate", "&&", str(tmp_path), "activate"] + ), output + + +@pytest.mark.parametrize("shell", [None, "fish", "pwsh"]) +def test_poetry_activate(shell, benchmark, poetry_venv: PoetryVenvFixture): + with working_directory(poetry_venv.project_dir): + output = run_main_benchmark(benchmark, shell=shell) + + assert "activate" in output.lower() + assert str(poetry_venv.venv_dir).lower() in output.lower() + + +def test_poetry_already_active(benchmark, poetry_venv: PoetryVenvFixture): + with ( + venv_active(poetry_venv.venv_dir), + working_directory(poetry_venv.project_dir), + ): + assert not run_main_benchmark(benchmark) + + +@pytest.mark.parametrize("shell", [None, "fish", "pwsh"]) +def test_venv_switch_to_poetry( + shell, benchmark, poetry_venv: PoetryVenvFixture, venv: Path +): + with venv_active(venv), working_directory(poetry_venv.project_dir): + output = run_main_benchmark(benchmark, shell=shell) + + assert all( + s in output + for s in ["deactivate", "&&", str(poetry_venv.venv_dir), "activate"] + ), output + + +@pytest.mark.parametrize("shell", [None, "fish", "pwsh"]) +def test_poetry_switch_to_venv( + shell, benchmark, poetry_venv: PoetryVenvFixture, venv: Path +): + with venv_active(poetry_venv.venv_dir), working_directory(venv): + output = run_main_benchmark(benchmark, shell=shell) + + assert all( + s in output for s in ["deactivate", "&&", str(venv), "activate"] + ), output diff --git a/benches/tools.py b/benches/tools.py new file mode 100644 index 0000000..08a2103 --- /dev/null +++ b/benches/tools.py @@ -0,0 +1,51 @@ +"""Utilities for benchmarks.""" + +import os +from contextlib import contextmanager +from pathlib import Path +from typing import Generator + +import virtualenv + + +def make_venv(path: Path, venv_name: str = ".venv") -> Path: + """Make a virtual environment in the given directory.""" + venv_dir = path / venv_name + virtualenv.cli_run([str(venv_dir)]) + return venv_dir + + +@contextmanager +def environment_variable( + variable: str, value: str +) -> Generator[None, None, None]: + """Set an environment variable within a context.""" + original_value = os.environ.get(variable) + try: + os.environ[variable] = value + yield + finally: + if original_value: + os.environ[variable] = original_value + else: + os.environ.pop(variable) + + +@contextmanager +def working_directory(path: Path) -> Generator[None, None, None]: + """Set the current working directory within a context.""" + original_path = Path.cwd() + try: + os.chdir(path) + yield + finally: + os.chdir(original_path) + + +@contextmanager +def venv_active(venv_dir: Path) -> Generator[None, None, None]: + """Activate a virtual environment within a context.""" + if not venv_dir.is_dir(): + raise ValueError(f"Directory '{venv_dir}' does not exist.") + with environment_variable("VIRTUAL_ENV", str(venv_dir)): + yield diff --git a/pyautoenv.py b/pyautoenv.py index 4d13570..76729d4 100755 --- a/pyautoenv.py +++ b/pyautoenv.py @@ -34,7 +34,7 @@ import os import sys from functools import lru_cache -from typing import List, TextIO, Union +from typing import Iterator, List, TextIO, Union __version__ = "0.7.0" @@ -54,6 +54,10 @@ VENV_NAMES = "PYAUTOENV_VENV_NAME" """Directory names to search in for venv virtual environments.""" +OS_LINUX = 0 +OS_MACOS = 1 +OS_WINDOWS = 2 + if __debug__: import logging @@ -88,14 +92,6 @@ def __init__( self.pwsh = pwsh -class Os: - """Pseudo-enum for supported operating systems.""" - - LINUX = 0 - MACOS = 1 - WINDOWS = 2 - - def main(sys_args: List[str], stdout: TextIO) -> int: """Write commands to activate/deactivate environments.""" if __debug__: @@ -134,7 +130,7 @@ def deactivate(stream: TextIO) -> None: def deactivate_and_activate(stream: TextIO, new_activator: str) -> None: """Write command to deactivate the current env and activate another.""" - command = f"deactivate && . {new_activator}" + command = f"deactivate && . '{new_activator}'" if __debug__: logger.debug("deactivate_and_activate: '%s'", command) stream.write(command) @@ -148,7 +144,7 @@ def activator_in_venv(activator_path: str, venv_dir: str) -> bool: def active_environment() -> Union[str, None]: """Return the directory of the currently active environment.""" - active_env_dir = os.environ.get("VIRTUAL_ENV", None) + active_env_dir = os.environ.get("VIRTUAL_ENV") if __debug__: logger.debug("active_environment: '%s'", active_env_dir) return active_env_dir @@ -159,24 +155,16 @@ def parse_args(argv: List[str], stdout: TextIO) -> Args: # Avoiding argparse gives a good speed boost and the parsing logic # is not too complex. We won't get a full 'bells and whistles' CLI # experience, but that's fine for our use-case. - - def parse_exit_flag(argv: List[str], flags: List[str]) -> bool: - return any(f in argv for f in flags) + if not argv: + return Args(os.getcwd()) def parse_flag(argv: List[str], flag: str) -> bool: try: - argv.pop(argv.index(flag)) + del argv[argv.index(flag)] except ValueError: return False return True - if parse_exit_flag(argv, ["-h", "--help"]): - stdout.write(CLI_HELP) - sys.exit(0) - if parse_exit_flag(argv, ["-V", "--version"]): - stdout.write(f"pyautoenv {__version__}\n") - sys.exit(0) - fish = parse_flag(argv, "--fish") pwsh = parse_flag(argv, "--pwsh") num_activators = sum([fish, pwsh]) @@ -184,7 +172,20 @@ def parse_flag(argv: List[str], flag: str) -> bool: raise ValueError( f"zero or one activator flag expected, found {num_activators}", ) - # ignore empty arguments + if not argv: + return Args(os.getcwd(), fish=fish, pwsh=pwsh) + + def parse_exit_flag(argv: List[str], flags: List[str]) -> bool: + return any(f in argv for f in flags) + + if parse_exit_flag(argv, ["-h", "--help"]): + stdout.write(CLI_HELP) + sys.exit(0) + if parse_exit_flag(argv, ["-V", "--version"]): + stdout.write(f"pyautoenv {__version__}\n") + sys.exit(0) + + # Ignore empty arguments. argv = [a for a in argv if a.strip()] if len(argv) > 1: raise ValueError( @@ -212,12 +213,13 @@ def discover_env(args: Args) -> Union[str, None]: def dir_is_ignored(directory: str) -> bool: """Return True if the given directory is marked to be ignored.""" - return any(directory == ignored for ignored in ignored_dirs()) + return directory in ignored_dirs() +@lru_cache(maxsize=1) def ignored_dirs() -> List[str]: """Get the list of directories to not activate an environment within.""" - dirs = os.environ.get(IGNORE_DIRS, None) + dirs = os.environ.get(IGNORE_DIRS) if dirs: return dirs.split(";") return [] @@ -240,26 +242,25 @@ def venv_activator(args: Args) -> Union[str, None]: Return None if the directory does not contain a venv, or the venv does not contain a suitable activator script. """ - candidate_venv_dirs = venv_candidate_dirs(args) - for path in candidate_venv_dirs: - activate_script = activator(path, args) - if os.path.isfile(activate_script): - return activate_script + for path in venv_candidate_dirs(args): + for activate_script in iter_candidate_activators(path, args): + if __debug__: + logger.debug("venv_activator: candidate '%s'", activate_script) + if os.path.isfile(activate_script): + return activate_script return None -def venv_candidate_dirs(args: Args) -> List[str]: - """Get a list of candidate venv paths within the given directory.""" - candidate_paths = [] +def venv_candidate_dirs(args: Args) -> Iterator[str]: + """Get candidate venv paths within the given directory.""" for venv_name in venv_dir_names(): - candidate_dir = os.path.join(args.directory, venv_name) - candidate_paths.append(candidate_dir) - return candidate_paths + yield os.path.join(args.directory, venv_name) +@lru_cache(maxsize=1) def venv_dir_names() -> List[str]: """Get the possible names for a venv directory.""" - name_list = os.environ.get(VENV_NAMES, "") + name_list = os.environ.get(VENV_NAMES) if name_list: return [x for x in name_list.split(";") if x] return [".venv"] @@ -280,9 +281,13 @@ def poetry_activator(args: Args) -> Union[str, None]: env_list = poetry_env_list(args.directory) if env_list: env_dir = max(env_list, key=lambda p: os.stat(p).st_mtime) - env_activator = activator(env_dir, args) - if os.path.isfile(env_activator): - return activator(env_dir, args) + for env_activator in iter_candidate_activators(env_dir, args): + if __debug__: + logger.debug( + "poetry_activator: candidate: '%s'", env_activator + ) + if os.path.isfile(env_activator): + return env_activator return None @@ -297,30 +302,38 @@ def poetry_env_list(directory: str) -> List[str]: if cache_dir is None: return [] env_name = poetry_env_name(directory) + if __debug__: + logger.debug("poetry_env_list: env name: '%s'", env_name) if env_name is None: return [] + virtual_env_path = os.path.join(cache_dir, "virtualenvs") + if __debug__: + logger.debug("poetry_env_list: venvs path: '%s'", virtual_env_path) try: return [ f.path - for f in os.scandir(os.path.join(cache_dir, "virtualenvs")) + for f in os.scandir(virtual_env_path) if f.name.startswith(f"{env_name}-py") ] except OSError: + if __debug__: + logger.debug("poetry_env_list: os error:") + logger.exception("") return [] @lru_cache(maxsize=1) def poetry_cache_dir() -> Union[str, None]: """Return the poetry cache directory, or None if it's not found.""" - cache_dir = os.environ.get("POETRY_CACHE_DIR", None) + cache_dir = os.environ.get("POETRY_CACHE_DIR") if cache_dir and os.path.isdir(cache_dir): return cache_dir op_sys = operating_system() - if op_sys == Os.WINDOWS: + if op_sys == OS_WINDOWS: return windows_poetry_cache_dir() - if op_sys == Os.MACOS: + if op_sys == OS_MACOS: return macos_poetry_cache_dir() - if op_sys == Os.LINUX: + if op_sys == OS_LINUX: return linux_poetry_cache_dir() return None @@ -381,7 +394,6 @@ def poetry_env_name(directory: str) -> Union[str, None]: import base64 import hashlib - name = name.lower() sanitized_name = ( # This is a bit ugly, but it's more performant than using a regex. # The import time for the 're' module is also a factor. @@ -395,8 +407,9 @@ def poetry_env_name(directory: str) -> Union[str, None]: .replace("\r", "_") .replace("\n", "_") .replace("\t", "_") + .lower()[:42] ) - normalized_path = os.path.normcase(os.path.realpath(directory)) + normalized_path = os.path.normcase(directory) path_hash = hashlib.sha256(normalized_path.encode()).digest() b64_hash = base64.urlsafe_b64encode(path_hash).decode()[:8] return f"{sanitized_name}-{b64_hash}" @@ -407,53 +420,62 @@ def poetry_project_name(directory: str) -> Union[str, None]: pyproject_file_path = os.path.join(directory, "pyproject.toml") try: with open(pyproject_file_path, encoding="utf-8") as pyproject_file: - pyproject_lines = pyproject_file.readlines() + return parse_name_from_pyproject_file(pyproject_file) except OSError: return None + + +def parse_name_from_pyproject_file(file: TextIO) -> Union[str, None]: + """ + Parse the project name from a pyproject.toml file. + + Return ``None`` if the name cannot be parsed. + """ # Ideally we'd use a proper TOML parser to do this, but there isn't # one available in the standard library until Python 3.11. This # hacked together parser should work for the vast majority of cases. - in_tool_poetry_section = False - for line in pyproject_lines: - if line.strip() in ["[tool.poetry]", "[project]"]: - in_tool_poetry_section = True - continue - if line.strip().startswith("["): - in_tool_poetry_section = False - if not in_tool_poetry_section: - continue - try: - key, val = (part.strip().strip('"') for part in line.split("=")) - except ValueError: - continue - if key == "name": - return val + for line in file: + line = line.strip() # noqa: PLW2901 + if line in ("[project]", "[tool.poetry]"): + for project_line in file: + project_line = project_line.lstrip().lstrip("'\"") # noqa: PLW2901 + if project_line.startswith("["): + # New block started without finding the project name. + return None + if not project_line.startswith("name"): + continue + try: + key, val = project_line.split("=", maxsplit=1) + except ValueError: + continue + if key.rstrip().rstrip("'\"") == "name": + return val.strip().strip("'\"") return None -def activator(env_directory: str, args: Args) -> str: - """Get the activator script for the environment in the given directory.""" - is_windows = operating_system() == Os.WINDOWS - dir_name = "Scripts" if is_windows else "bin" +def iter_candidate_activators(env_directory: str, args: Args) -> Iterator[str]: + """ + Iterate over candidate activator paths. + + In general we'll know exactly the activator we want given the + environment directory and the shell we're using. However, in some + cases there may be slightly different activator script names + depending on how the venv was created. + """ + bin_dir = "Scripts" if operating_system() == OS_WINDOWS else "bin" if args.fish: script = "activate.fish" elif args.pwsh: - if is_windows: - script = "Activate.ps1" - else: - # PowerShell activation scripts on Nix systems have some - # slightly inconsistent naming. When using Poetry or uv, the - # activation script is lower case, using the venv module, - # the script is title case. - # We can't really know what was used to generate the venv - # so just check which activation script exists. - script_path = os.path.join(env_directory, dir_name, "activate.ps1") - if os.path.isfile(script_path): - return script_path - script = "Activate.ps1" + # PowerShell activation scripts on *Nix systems have some + # slightly inconsistent naming. When using Poetry or uv, the + # activation script is lower case, using the venv module, + # the script is title case. + for script in ("activate.ps1", "Activate.ps1"): + script_path = os.path.join(env_directory, bin_dir, script) + yield script_path else: script = "activate" - return os.path.join(env_directory, dir_name, script) + yield os.path.join(env_directory, bin_dir, script) @lru_cache(maxsize=1) @@ -464,11 +486,11 @@ def operating_system() -> Union[int, None]: Return 'None' if we're on an operating system we can't handle. """ if sys.platform.startswith("darwin"): - return Os.MACOS + return OS_MACOS if sys.platform.startswith("win"): - return Os.WINDOWS + return OS_WINDOWS if sys.platform.startswith("linux"): - return Os.LINUX + return OS_LINUX return None @@ -477,4 +499,6 @@ def operating_system() -> Union[int, None]: sys.exit(main(sys.argv[1:], sys.stdout)) except Exception as exc: # noqa: BLE001 sys.stderr.write(f"pyautoenv: error: {exc}\n") + if __debug__: + logger.exception("backtrace:") sys.exit(1) diff --git a/pyproject.toml b/pyproject.toml index 1bc4c05..4022979 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dev = [ ] test = [ "pyfakefs>=5.8.0", + "pytest-benchmark>=5.1.0", "pytest-cov>=6.1.1", "pytest>=8.3.5", "toml>=0.10.2", @@ -27,8 +28,8 @@ target-version = "py39" line-length = 79 [tool.coverage.report] -"exclude_lines" = ["if __name__ == .__main__.:"] -"omit" = ["test_*.py"] +exclude_lines = ["if __name__ == .__main__.:"] +omit = ["tests/*", "benches/*"] [tool.mypy] ignore_missing_imports = true diff --git a/tests/test_poetry.py b/tests/test_poetry.py index 9ce16fe..28d9b76 100644 --- a/tests/test_poetry.py +++ b/tests/test_poetry.py @@ -28,6 +28,7 @@ from tests.tools import ( OPERATING_SYSTEM, activate_venv, + clear_lru_caches, make_poetry_project, root_dir, ) @@ -44,23 +45,28 @@ def not_poetry_proj(self) -> Path: """The path to directory that does not contain an poetry project.""" return Path("not_a_poetry_proj") - @abc.abstractproperty + @property + @abc.abstractmethod def os(self) -> int: """The operating system the class is testing on.""" - @abc.abstractproperty + @property + @abc.abstractmethod def flag(self) -> str: """The command line flag to select the activator.""" - @abc.abstractproperty + @property + @abc.abstractmethod def activator(self) -> Path: """The path of the activator script relative to the venv dir.""" - @abc.abstractproperty + @property + @abc.abstractmethod def poetry_cache(self) -> Path: """The path to the directory containing poetry virtual environments.""" - @abc.abstractproperty + @property + @abc.abstractmethod def env(self) -> Dict[str, str]: """The environment variables to be present during the test.""" @@ -84,11 +90,11 @@ def fs(self, fs: FakeFilesystem) -> FakeFilesystem: fs.create_file(self.venv_dir / "bin" / "activate") fs.create_file(self.venv_dir / "bin" / "activate.ps1") fs.create_file(self.venv_dir / "bin" / "activate.fish") - fs.create_file(self.venv_dir / "Scripts" / "Activate.ps1") + fs.create_file(self.venv_dir / "Scripts" / "activate.ps1") return fs def setup_method(self): - pyautoenv.poetry_cache_dir.cache_clear() + clear_lru_caches(pyautoenv) self.os_patch = mock.patch(OPERATING_SYSTEM, return_value=self.os) self.os_patch.start() os.environ = copy.deepcopy(self.env) # noqa: B003 @@ -164,7 +170,7 @@ def test_deactivate_and_activate_switching_to_new_poetry_env( fs.create_file(new_activate) assert pyautoenv.main(["pyproj2", self.flag], stdout=stdout) == 0 - assert stdout.getvalue() == f"deactivate && . {new_activate}" + assert stdout.getvalue() == f"deactivate && . '{new_activate}'" def test_does_nothing_if_activate_script_is_not_file(self, fs): stdout = StringIO() @@ -296,7 +302,7 @@ class PoetryLinuxTester(PoetryTester): "HOME": str(root_dir() / "home" / "user"), "USERPROFILE": str(root_dir() / "home" / "user"), } - os = pyautoenv.Os.LINUX + os = pyautoenv.OS_LINUX poetry_cache = ( root_dir() / "home" / "user" / ".cache" / "pypoetry" / "virtualenvs" ) @@ -322,7 +328,7 @@ class PoetryMacosTester(PoetryTester): "HOME": str(root_dir() / "Users" / "user"), "USERPROFILE": str(root_dir() / "Users" / "user"), } - os = pyautoenv.Os.MACOS + os = pyautoenv.OS_MACOS poetry_cache = ( root_dir() / "Users" @@ -350,10 +356,10 @@ class TestPoetryFishMacos(PoetryMacosTester): class TestPoetryPwshWindows(PoetryTester): - activator = Path("Scripts/Activate.ps1") + activator = Path("Scripts/activate.ps1") env = {"LOCALAPPDATA": str(root_dir() / "Users/user/AppData/Local")} flag = "--pwsh" - os = pyautoenv.Os.WINDOWS + os = pyautoenv.OS_WINDOWS poetry_cache = ( root_dir() / "Users" diff --git a/tests/test_pyautoenv.py b/tests/test_pyautoenv.py index 8f70c7c..95e8993 100644 --- a/tests/test_pyautoenv.py +++ b/tests/test_pyautoenv.py @@ -35,13 +35,13 @@ def test_main_does_nothing_given_directory_does_not_exist(): @pytest.mark.parametrize( ("os_name", "enum_value"), [ - ("linux2", pyautoenv.Os.LINUX), - ("darwin", pyautoenv.Os.MACOS), - ("win32", pyautoenv.Os.WINDOWS), + ("linux2", pyautoenv.OS_LINUX), + ("darwin", pyautoenv.OS_MACOS), + ("win32", pyautoenv.OS_WINDOWS), ("Java", None), ], ) -def test_operating_system_returns_enum_based_on_sys_platform( +def test_operating_system_returns_value_based_on_sys_platform( os_name, enum_value, ): diff --git a/tests/test_venv.py b/tests/test_venv.py index 3c47afb..4cbb077 100644 --- a/tests/test_venv.py +++ b/tests/test_venv.py @@ -26,6 +26,7 @@ from tests.tools import ( OPERATING_SYSTEM, activate_venv, + clear_lru_caches, make_poetry_project, root_dir, ) @@ -35,20 +36,23 @@ class VenvTester(abc.ABC): PY_PROJ = root_dir() / "python_project" VENV_DIR = PY_PROJ / ".venv" - @abc.abstractproperty + @property + @abc.abstractmethod def os(self) -> int: """The operating system the class is testing on.""" - @abc.abstractproperty + @property + @abc.abstractmethod def flag(self) -> str: """The command line flag to select the activator.""" - @abc.abstractproperty + @property + @abc.abstractmethod def activator(self) -> str: """The name of the activator script.""" def setup_method(self): - pyautoenv.poetry_cache_dir.cache_clear() + clear_lru_caches(pyautoenv) os.environ = {} # noqa: B003 self.os_patch = mock.patch(OPERATING_SYSTEM, return_value=self.os) self.os_patch.start() @@ -64,7 +68,7 @@ def fs(self, fs: FakeFilesystem) -> FakeFilesystem: fs.create_file(self.VENV_DIR / "bin" / "activate.fish") fs.create_file(self.VENV_DIR / "bin" / "activate.ps1") fs.create_file(self.VENV_DIR / "Scripts" / "activate") - fs.create_file(self.VENV_DIR / "Scripts" / "Activate.ps1") + fs.create_file(self.VENV_DIR / "Scripts" / "activate.ps1") fs.create_dir("not_a_venv") return fs @@ -116,7 +120,7 @@ def test_deactivate_and_activate_switching_to_new_venv(self, fs): activate_venv(self.VENV_DIR) assert pyautoenv.main(["pyproj2", self.flag], stdout=stdout) == 0 - assert stdout.getvalue() == f"deactivate && . {new_venv_activate}" + assert stdout.getvalue() == f"deactivate && . '{new_venv_activate}'" @mock.patch("pyautoenv.poetry_activator") def test_deactivate_and_activate_switching_to_poetry( @@ -134,7 +138,7 @@ def test_deactivate_and_activate_switching_to_poetry( fs.create_file(activator) assert pyautoenv.main(["poetry_proj", self.flag], stdout) == 0 - assert stdout.getvalue() == f"deactivate && . {activator}" + assert stdout.getvalue() == f"deactivate && . '{activator}'" def test_does_nothing_if_activate_script_is_not_file(self, fs): stdout = StringIO() @@ -195,22 +199,22 @@ def test_deactivate_given_changing_to_ignored_directory(self): class TestVenvBashLinux(VenvTester): activator = "bin/activate" flag = "" - os = pyautoenv.Os.LINUX + os = pyautoenv.OS_LINUX class TestVenvPwshLinux(VenvTester): activator = "bin/activate.ps1" flag = "--pwsh" - os = pyautoenv.Os.LINUX + os = pyautoenv.OS_LINUX class TestVenvFishLinux(VenvTester): activator = "bin/activate.fish" flag = "--fish" - os = pyautoenv.Os.LINUX + os = pyautoenv.OS_LINUX class TestVenvPwshWindows(VenvTester): - activator = "Scripts/Activate.ps1" + activator = "Scripts/activate.ps1" flag = "--pwsh" - os = pyautoenv.Os.WINDOWS + os = pyautoenv.OS_WINDOWS diff --git a/tests/tools.py b/tests/tools.py index f57c560..ec63e3d 100644 --- a/tests/tools.py +++ b/tests/tools.py @@ -15,9 +15,12 @@ # along with this program. If not, see . """Utility functions for tests.""" +import inspect import os +from functools import lru_cache from pathlib import Path -from typing import Union +from types import ModuleType +from typing import Protocol, Union from pyfakefs.fake_filesystem import FakeFilesystem @@ -70,3 +73,24 @@ def root_dir() -> Path: our tests. """ return Path(os.path.abspath("/")) + + +def clear_lru_caches(module: ModuleType) -> None: + """Clear all the caches in ``lru_cache`` decorated functions.""" + for func in _find_lru_cached_functions(module): + func.cache_clear() + + +class _LruCachedFunction(Protocol): + def cache_clear(self) -> None: ... + + +@lru_cache +def _find_lru_cached_functions(module: ModuleType) -> list[_LruCachedFunction]: + """Find all function that are decorated with ``lru_cache``.""" + return [ + func + for _, func in inspect.getmembers( + module, lambda x: hasattr(x, "cache_clear") + ) + ] diff --git a/uv.lock b/uv.lock index 3b874ff..38c3c40 100644 --- a/uv.lock +++ b/uv.lock @@ -124,11 +124,11 @@ wheels = [ [[package]] name = "identify" -version = "2.6.9" +version = "2.6.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9b/98/a71ab060daec766acc30fb47dfca219d03de34a70d616a79a38c6066c5bf/identify-2.6.9.tar.gz", hash = "sha256:d40dfe3142a1421d8518e3d3985ef5ac42890683e32306ad614a29490abeb6bf", size = 99249 } +sdist = { url = "https://files.pythonhosted.org/packages/0c/83/b6ea0334e2e7327084a46aaaf71f2146fc061a192d6518c0d020120cd0aa/identify-2.6.10.tar.gz", hash = "sha256:45e92fd704f3da71cc3880036633f48b4b7265fd4de2b57627cb157216eb7eb8", size = 99201 } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/ce/0845144ed1f0e25db5e7a79c2354c1da4b5ce392b8966449d5db8dca18f1/identify-2.6.9-py2.py3-none-any.whl", hash = "sha256:c98b4322da415a8e5a70ff6e51fbc2d2932c015532d77e9f8537b4ba7813b150", size = 99101 }, + { url = "https://files.pythonhosted.org/packages/2b/d3/85feeba1d097b81a44bcffa6a0beab7b4dfffe78e82fc54978d3ac380736/identify-2.6.10-py2.py3-none-any.whl", hash = "sha256:5f34248f54136beed1a7ba6a6b5c4b6cf21ff495aac7c359e1ef831ae3b8ab25", size = 99101 }, ] [[package]] @@ -186,11 +186,11 @@ wheels = [ [[package]] name = "mypy-extensions" -version = "1.0.0" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, ] [[package]] @@ -204,11 +204,11 @@ wheels = [ [[package]] name = "packaging" -version = "24.2" +version = "25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, ] [[package]] @@ -245,6 +245,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707 }, ] +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335 }, +] + [[package]] name = "pyautoenv" version = "0.7.0" @@ -260,6 +269,7 @@ dev = [ test = [ { name = "pyfakefs" }, { name = "pytest" }, + { name = "pytest-benchmark" }, { name = "pytest-cov" }, { name = "toml" }, ] @@ -276,6 +286,7 @@ dev = [ test = [ { name = "pyfakefs", specifier = ">=5.8.0" }, { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-benchmark", specifier = ">=5.1.0" }, { name = "pytest-cov", specifier = ">=6.1.1" }, { name = "toml", specifier = ">=0.10.2" }, ] @@ -306,6 +317,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, ] +[[package]] +name = "pytest-benchmark" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/d0/a8bd08d641b393db3be3819b03e2d9bb8760ca8479080a26a5f6e540e99c/pytest-benchmark-5.1.0.tar.gz", hash = "sha256:9ea661cdc292e8231f7cd4c10b0319e56a2118e2c09d9f50e1b3d150d2aca105", size = 337810 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/d6/b41653199ea09d5969d4e385df9bbfd9a100f28ca7e824ce7c0a016e3053/pytest_benchmark-5.1.0-py3-none-any.whl", hash = "sha256:922de2dfa3033c227c96da942d1878191afa135a29485fb942e85dff1c592c89", size = 44259 }, +] + [[package]] name = "pytest-cov" version = "6.1.1" @@ -439,27 +463,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.11.4" +version = "0.11.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/5b/3ae20f89777115944e89c2d8c2e795dcc5b9e04052f76d5347e35e0da66e/ruff-0.11.4.tar.gz", hash = "sha256:f45bd2fb1a56a5a85fae3b95add03fb185a0b30cf47f5edc92aa0355ca1d7407", size = 3933063 } +sdist = { url = "https://files.pythonhosted.org/packages/d9/11/bcef6784c7e5d200b8a1f5c2ddf53e5da0efec37e6e5a44d163fb97e04ba/ruff-0.11.6.tar.gz", hash = "sha256:bec8bcc3ac228a45ccc811e45f7eb61b950dbf4cf31a67fa89352574b01c7d79", size = 4010053 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/db/baee59ac88f57527fcbaad3a7b309994e42329c6bc4d4d2b681a3d7b5426/ruff-0.11.4-py3-none-linux_armv6l.whl", hash = "sha256:d9f4a761ecbde448a2d3e12fb398647c7f0bf526dbc354a643ec505965824ed2", size = 10106493 }, - { url = "https://files.pythonhosted.org/packages/c1/d6/9a0962cbb347f4ff98b33d699bf1193ff04ca93bed4b4222fd881b502154/ruff-0.11.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8c1747d903447d45ca3d40c794d1a56458c51e5cc1bc77b7b64bd2cf0b1626cc", size = 10876382 }, - { url = "https://files.pythonhosted.org/packages/3a/8f/62bab0c7d7e1ae3707b69b157701b41c1ccab8f83e8501734d12ea8a839f/ruff-0.11.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:51a6494209cacca79e121e9b244dc30d3414dac8cc5afb93f852173a2ecfc906", size = 10237050 }, - { url = "https://files.pythonhosted.org/packages/09/96/e296965ae9705af19c265d4d441958ed65c0c58fc4ec340c27cc9d2a1f5b/ruff-0.11.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f171605f65f4fc49c87f41b456e882cd0c89e4ac9d58e149a2b07930e1d466f", size = 10424984 }, - { url = "https://files.pythonhosted.org/packages/e5/56/644595eb57d855afed6e54b852e2df8cd5ca94c78043b2f29bdfb29882d5/ruff-0.11.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ebf99ea9af918878e6ce42098981fc8c1db3850fef2f1ada69fb1dcdb0f8e79e", size = 9957438 }, - { url = "https://files.pythonhosted.org/packages/86/83/9d3f3bed0118aef3e871ded9e5687fb8c5776bde233427fd9ce0a45db2d4/ruff-0.11.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edad2eac42279df12e176564a23fc6f4aaeeb09abba840627780b1bb11a9d223", size = 11547282 }, - { url = "https://files.pythonhosted.org/packages/40/e6/0c6e4f5ae72fac5ccb44d72c0111f294a5c2c8cc5024afcb38e6bda5f4b3/ruff-0.11.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f103a848be9ff379fc19b5d656c1f911d0a0b4e3e0424f9532ececf319a4296e", size = 12182020 }, - { url = "https://files.pythonhosted.org/packages/b5/92/4aed0e460aeb1df5ea0c2fbe8d04f9725cccdb25d8da09a0d3f5b8764bf8/ruff-0.11.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:193e6fac6eb60cc97b9f728e953c21cc38a20077ed64f912e9d62b97487f3f2d", size = 11679154 }, - { url = "https://files.pythonhosted.org/packages/1b/d3/7316aa2609f2c592038e2543483eafbc62a0e1a6a6965178e284808c095c/ruff-0.11.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7af4e5f69b7c138be8dcffa5b4a061bf6ba6a3301f632a6bce25d45daff9bc99", size = 13905985 }, - { url = "https://files.pythonhosted.org/packages/63/80/734d3d17546e47ff99871f44ea7540ad2bbd7a480ed197fe8a1c8a261075/ruff-0.11.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:126b1bf13154aa18ae2d6c3c5efe144ec14b97c60844cfa6eb960c2a05188222", size = 11348343 }, - { url = "https://files.pythonhosted.org/packages/04/7b/70fc7f09a0161dce9613a4671d198f609e653d6f4ff9eee14d64c4c240fb/ruff-0.11.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8806daaf9dfa881a0ed603f8a0e364e4f11b6ed461b56cae2b1c0cab0645304", size = 10308487 }, - { url = "https://files.pythonhosted.org/packages/1a/22/1cdd62dabd678d75842bf4944fd889cf794dc9e58c18cc547f9eb28f95ed/ruff-0.11.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5d94bb1cc2fc94a769b0eb975344f1b1f3d294da1da9ddbb5a77665feb3a3019", size = 9929091 }, - { url = "https://files.pythonhosted.org/packages/9f/20/40e0563506332313148e783bbc1e4276d657962cc370657b2fff20e6e058/ruff-0.11.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:995071203d0fe2183fc7a268766fd7603afb9996785f086b0d76edee8755c896", size = 10924659 }, - { url = "https://files.pythonhosted.org/packages/b5/41/eef9b7aac8819d9e942f617f9db296f13d2c4576806d604aba8db5a753f1/ruff-0.11.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7a37ca937e307ea18156e775a6ac6e02f34b99e8c23fe63c1996185a4efe0751", size = 11428160 }, - { url = "https://files.pythonhosted.org/packages/ff/61/c488943414fb2b8754c02f3879de003e26efdd20f38167ded3fb3fc1cda3/ruff-0.11.4-py3-none-win32.whl", hash = "sha256:0e9365a7dff9b93af933dab8aebce53b72d8f815e131796268709890b4a83270", size = 10311496 }, - { url = "https://files.pythonhosted.org/packages/b6/2b/2a1c8deb5f5dfa3871eb7daa41492c4d2b2824a74d2b38e788617612a66d/ruff-0.11.4-py3-none-win_amd64.whl", hash = "sha256:5a9fa1c69c7815e39fcfb3646bbfd7f528fa8e2d4bebdcf4c2bd0fa037a255fb", size = 11399146 }, - { url = "https://files.pythonhosted.org/packages/4f/03/3aec4846226d54a37822e4c7ea39489e4abd6f88388fba74e3d4abe77300/ruff-0.11.4-py3-none-win_arm64.whl", hash = "sha256:d435db6b9b93d02934cf61ef332e66af82da6d8c69aefdea5994c89997c7a0fc", size = 10450306 }, + { url = "https://files.pythonhosted.org/packages/6e/1f/8848b625100ebcc8740c8bac5b5dd8ba97dd4ee210970e98832092c1635b/ruff-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:d84dcbe74cf9356d1bdb4a78cf74fd47c740bf7bdeb7529068f69b08272239a1", size = 10248105 }, + { url = "https://files.pythonhosted.org/packages/e0/47/c44036e70c6cc11e6ee24399c2a1e1f1e99be5152bd7dff0190e4b325b76/ruff-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9bc583628e1096148011a5d51ff3c836f51899e61112e03e5f2b1573a9b726de", size = 11001494 }, + { url = "https://files.pythonhosted.org/packages/ed/5b/170444061650202d84d316e8f112de02d092bff71fafe060d3542f5bc5df/ruff-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2959049faeb5ba5e3b378709e9d1bf0cab06528b306b9dd6ebd2a312127964a", size = 10352151 }, + { url = "https://files.pythonhosted.org/packages/ff/91/f02839fb3787c678e112c8865f2c3e87cfe1744dcc96ff9fc56cfb97dda2/ruff-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63c5d4e30d9d0de7fedbfb3e9e20d134b73a30c1e74b596f40f0629d5c28a193", size = 10541951 }, + { url = "https://files.pythonhosted.org/packages/9e/f3/c09933306096ff7a08abede3cc2534d6fcf5529ccd26504c16bf363989b5/ruff-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a4b9a4e1439f7d0a091c6763a100cef8fbdc10d68593df6f3cfa5abdd9246e", size = 10079195 }, + { url = "https://files.pythonhosted.org/packages/e0/0d/a87f8933fccbc0d8c653cfbf44bedda69c9582ba09210a309c066794e2ee/ruff-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5edf270223dd622218256569636dc3e708c2cb989242262fe378609eccf1308", size = 11698918 }, + { url = "https://files.pythonhosted.org/packages/52/7d/8eac0bd083ea8a0b55b7e4628428203441ca68cd55e0b67c135a4bc6e309/ruff-0.11.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f55844e818206a9dd31ff27f91385afb538067e2dc0beb05f82c293ab84f7d55", size = 12319426 }, + { url = "https://files.pythonhosted.org/packages/c2/dc/d0c17d875662d0c86fadcf4ca014ab2001f867621b793d5d7eef01b9dcce/ruff-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d8f782286c5ff562e4e00344f954b9320026d8e3fae2ba9e6948443fafd9ffc", size = 11791012 }, + { url = "https://files.pythonhosted.org/packages/f9/f3/81a1aea17f1065449a72509fc7ccc3659cf93148b136ff2a8291c4bc3ef1/ruff-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01c63ba219514271cee955cd0adc26a4083df1956d57847978383b0e50ffd7d2", size = 13949947 }, + { url = "https://files.pythonhosted.org/packages/61/9f/a3e34de425a668284e7024ee6fd41f452f6fa9d817f1f3495b46e5e3a407/ruff-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15adac20ef2ca296dd3d8e2bedc6202ea6de81c091a74661c3666e5c4c223ff6", size = 11471753 }, + { url = "https://files.pythonhosted.org/packages/df/c5/4a57a86d12542c0f6e2744f262257b2aa5a3783098ec14e40f3e4b3a354a/ruff-0.11.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4dd6b09e98144ad7aec026f5588e493c65057d1b387dd937d7787baa531d9bc2", size = 10417121 }, + { url = "https://files.pythonhosted.org/packages/58/3f/a3b4346dff07ef5b862e2ba06d98fcbf71f66f04cf01d375e871382b5e4b/ruff-0.11.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:45b2e1d6c0eed89c248d024ea95074d0e09988d8e7b1dad8d3ab9a67017a5b03", size = 10073829 }, + { url = "https://files.pythonhosted.org/packages/93/cc/7ed02e0b86a649216b845b3ac66ed55d8aa86f5898c5f1691797f408fcb9/ruff-0.11.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd40de4115b2ec4850302f1a1d8067f42e70b4990b68838ccb9ccd9f110c5e8b", size = 11076108 }, + { url = "https://files.pythonhosted.org/packages/39/5e/5b09840fef0eff1a6fa1dea6296c07d09c17cb6fb94ed5593aa591b50460/ruff-0.11.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:77cda2dfbac1ab73aef5e514c4cbfc4ec1fbef4b84a44c736cc26f61b3814cd9", size = 11512366 }, + { url = "https://files.pythonhosted.org/packages/6f/4c/1cd5a84a412d3626335ae69f5f9de2bb554eea0faf46deb1f0cb48534042/ruff-0.11.6-py3-none-win32.whl", hash = "sha256:5151a871554be3036cd6e51d0ec6eef56334d74dfe1702de717a995ee3d5b287", size = 10485900 }, + { url = "https://files.pythonhosted.org/packages/42/46/8997872bc44d43df986491c18d4418f1caff03bc47b7f381261d62c23442/ruff-0.11.6-py3-none-win_amd64.whl", hash = "sha256:cce85721d09c51f3b782c331b0abd07e9d7d5f775840379c640606d3159cae0e", size = 11558592 }, + { url = "https://files.pythonhosted.org/packages/d7/6a/65fecd51a9ca19e1477c3879a7fda24f8904174d1275b419422ac00f6eee/ruff-0.11.6-py3-none-win_arm64.whl", hash = "sha256:3567ba0d07fb170b1b48d944715e3294b77f5b7679e8ba258199a250383ccb79", size = 10682766 }, ] [[package]] @@ -512,11 +536,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.13.1" +version = "4.13.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/ad/cd3e3465232ec2416ae9b983f27b9e94dc8171d56ac99b345319a9475967/typing_extensions-4.13.1.tar.gz", hash = "sha256:98795af00fb9640edec5b8e31fc647597b4691f099ad75f469a2616be1a76dff", size = 106633 } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/c5/e7a0b0f5ed69f94c8ab7379c599e6036886bffcde609969a5325f47f1332/typing_extensions-4.13.1-py3-none-any.whl", hash = "sha256:4b6cf02909eb5495cfbc3f6e8fd49217e6cc7944e145cdda8caa3734777f9e69", size = 45739 }, + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, ] [[package]]