Skip to content

Commit bcaff81

Browse files
committed
Make some small performance improvements
1 parent 0606a11 commit bcaff81

File tree

6 files changed

+137
-103
lines changed

6 files changed

+137
-103
lines changed

benches/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import pyautoenv
1515
from benches.tools import environment_variable, make_venv
16+
from tests.tools import clear_lru_caches
1617

1718
POETRY_PYPROJECT = """[project]
1819
name = "{project_name}"
@@ -38,8 +39,7 @@
3839
@pytest.fixture(autouse=True)
3940
def reset_caches() -> None:
4041
"""Reset the LRU caches in pyautoenv."""
41-
pyautoenv.poetry_cache_dir.cache_clear()
42-
pyautoenv.operating_system.cache_clear()
42+
clear_lru_caches(pyautoenv)
4343

4444

4545
@pytest.fixture(autouse=True, scope="module")

pyautoenv.py

Lines changed: 90 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
import os
3535
import sys
3636
from functools import lru_cache
37-
from typing import List, TextIO, Union
37+
from typing import Iterator, List, TextIO, Union
3838

3939
__version__ = "0.7.0"
4040

@@ -54,6 +54,10 @@
5454
VENV_NAMES = "PYAUTOENV_VENV_NAME"
5555
"""Directory names to search in for venv virtual environments."""
5656

57+
OS_LINUX = 0
58+
OS_MACOS = 1
59+
OS_WINDOWS = 2
60+
5761

5862
if __debug__:
5963
import logging
@@ -88,14 +92,6 @@ def __init__(
8892
self.pwsh = pwsh
8993

9094

91-
class Os:
92-
"""Pseudo-enum for supported operating systems."""
93-
94-
LINUX = 0
95-
MACOS = 1
96-
WINDOWS = 2
97-
98-
9995
def main(sys_args: List[str], stdout: TextIO) -> int:
10096
"""Write commands to activate/deactivate environments."""
10197
if __debug__:
@@ -148,7 +144,7 @@ def activator_in_venv(activator_path: str, venv_dir: str) -> bool:
148144

149145
def active_environment() -> Union[str, None]:
150146
"""Return the directory of the currently active environment."""
151-
active_env_dir = os.environ.get("VIRTUAL_ENV", None)
147+
active_env_dir = os.environ.get("VIRTUAL_ENV")
152148
if __debug__:
153149
logger.debug("active_environment: '%s'", active_env_dir)
154150
return active_env_dir
@@ -159,32 +155,37 @@ def parse_args(argv: List[str], stdout: TextIO) -> Args:
159155
# Avoiding argparse gives a good speed boost and the parsing logic
160156
# is not too complex. We won't get a full 'bells and whistles' CLI
161157
# experience, but that's fine for our use-case.
162-
163-
def parse_exit_flag(argv: List[str], flags: List[str]) -> bool:
164-
return any(f in argv for f in flags)
158+
if not argv:
159+
return Args(os.getcwd())
165160

166161
def parse_flag(argv: List[str], flag: str) -> bool:
167162
try:
168-
argv.pop(argv.index(flag))
163+
del argv[argv.index(flag)]
169164
except ValueError:
170165
return False
171166
return True
172167

173-
if parse_exit_flag(argv, ["-h", "--help"]):
174-
stdout.write(CLI_HELP)
175-
sys.exit(0)
176-
if parse_exit_flag(argv, ["-V", "--version"]):
177-
stdout.write(f"pyautoenv {__version__}\n")
178-
sys.exit(0)
179-
180168
fish = parse_flag(argv, "--fish")
181169
pwsh = parse_flag(argv, "--pwsh")
182170
num_activators = sum([fish, pwsh])
183171
if num_activators > 1:
184172
raise ValueError(
185173
f"zero or one activator flag expected, found {num_activators}",
186174
)
187-
# ignore empty arguments
175+
if not argv:
176+
return Args(os.getcwd(), fish=fish, pwsh=pwsh)
177+
178+
def parse_exit_flag(argv: List[str], flags: List[str]) -> bool:
179+
return any(f in argv for f in flags)
180+
181+
if parse_exit_flag(argv, ["-h", "--help"]):
182+
stdout.write(CLI_HELP)
183+
sys.exit(0)
184+
if parse_exit_flag(argv, ["-V", "--version"]):
185+
stdout.write(f"pyautoenv {__version__}\n")
186+
sys.exit(0)
187+
188+
# Ignore empty arguments.
188189
argv = [a for a in argv if a.strip()]
189190
if len(argv) > 1:
190191
raise ValueError(
@@ -212,12 +213,13 @@ def discover_env(args: Args) -> Union[str, None]:
212213

213214
def dir_is_ignored(directory: str) -> bool:
214215
"""Return True if the given directory is marked to be ignored."""
215-
return any(directory == ignored for ignored in ignored_dirs())
216+
return directory in ignored_dirs()
216217

217218

219+
@lru_cache(maxsize=1)
218220
def ignored_dirs() -> List[str]:
219221
"""Get the list of directories to not activate an environment within."""
220-
dirs = os.environ.get(IGNORE_DIRS, None)
222+
dirs = os.environ.get(IGNORE_DIRS)
221223
if dirs:
222224
return dirs.split(";")
223225
return []
@@ -240,26 +242,23 @@ def venv_activator(args: Args) -> Union[str, None]:
240242
Return None if the directory does not contain a venv, or the venv
241243
does not contain a suitable activator script.
242244
"""
243-
candidate_venv_dirs = venv_candidate_dirs(args)
244-
for path in candidate_venv_dirs:
245-
activate_script = activator(path, args)
246-
if os.path.isfile(activate_script):
247-
return activate_script
245+
for path in venv_candidate_dirs(args):
246+
for activate_script in iter_candidate_activators(path, args):
247+
if os.path.isfile(activate_script):
248+
return activate_script
248249
return None
249250

250251

251-
def venv_candidate_dirs(args: Args) -> List[str]:
252-
"""Get a list of candidate venv paths within the given directory."""
253-
candidate_paths = []
252+
def venv_candidate_dirs(args: Args) -> Iterator[str]:
253+
"""Get candidate venv paths within the given directory."""
254254
for venv_name in venv_dir_names():
255-
candidate_dir = os.path.join(args.directory, venv_name)
256-
candidate_paths.append(candidate_dir)
257-
return candidate_paths
255+
yield os.path.join(args.directory, venv_name)
258256

259257

258+
@lru_cache(maxsize=1)
260259
def venv_dir_names() -> List[str]:
261260
"""Get the possible names for a venv directory."""
262-
name_list = os.environ.get(VENV_NAMES, "")
261+
name_list = os.environ.get(VENV_NAMES)
263262
if name_list:
264263
return [x for x in name_list.split(";") if x]
265264
return [".venv"]
@@ -280,9 +279,9 @@ def poetry_activator(args: Args) -> Union[str, None]:
280279
env_list = poetry_env_list(args.directory)
281280
if env_list:
282281
env_dir = max(env_list, key=lambda p: os.stat(p).st_mtime)
283-
env_activator = activator(env_dir, args)
284-
if os.path.isfile(env_activator):
285-
return activator(env_dir, args)
282+
for env_activator in iter_candidate_activators(env_dir, args):
283+
if os.path.isfile(env_activator):
284+
return env_activator
286285
return None
287286

288287

@@ -312,15 +311,15 @@ def poetry_env_list(directory: str) -> List[str]:
312311
@lru_cache(maxsize=1)
313312
def poetry_cache_dir() -> Union[str, None]:
314313
"""Return the poetry cache directory, or None if it's not found."""
315-
cache_dir = os.environ.get("POETRY_CACHE_DIR", None)
314+
cache_dir = os.environ.get("POETRY_CACHE_DIR")
316315
if cache_dir and os.path.isdir(cache_dir):
317316
return cache_dir
318317
op_sys = operating_system()
319-
if op_sys == Os.WINDOWS:
318+
if op_sys == OS_WINDOWS:
320319
return windows_poetry_cache_dir()
321-
if op_sys == Os.MACOS:
320+
if op_sys == OS_MACOS:
322321
return macos_poetry_cache_dir()
323-
if op_sys == Os.LINUX:
322+
if op_sys == OS_LINUX:
324323
return linux_poetry_cache_dir()
325324
return None
326325

@@ -381,7 +380,6 @@ def poetry_env_name(directory: str) -> Union[str, None]:
381380
import base64
382381
import hashlib
383382

384-
name = name.lower()
385383
sanitized_name = (
386384
# This is a bit ugly, but it's more performant than using a regex.
387385
# The import time for the 're' module is also a factor.
@@ -395,8 +393,9 @@ def poetry_env_name(directory: str) -> Union[str, None]:
395393
.replace("\r", "_")
396394
.replace("\n", "_")
397395
.replace("\t", "_")
396+
.lower()[:42]
398397
)
399-
normalized_path = os.path.normcase(os.path.realpath(directory))
398+
normalized_path = os.path.normcase(directory)
400399
path_hash = hashlib.sha256(normalized_path.encode()).digest()
401400
b64_hash = base64.urlsafe_b64encode(path_hash).decode()[:8]
402401
return f"{sanitized_name}-{b64_hash}"
@@ -407,53 +406,62 @@ def poetry_project_name(directory: str) -> Union[str, None]:
407406
pyproject_file_path = os.path.join(directory, "pyproject.toml")
408407
try:
409408
with open(pyproject_file_path, encoding="utf-8") as pyproject_file:
410-
pyproject_lines = pyproject_file.readlines()
409+
return parse_name_from_pyproject_file(pyproject_file)
411410
except OSError:
412411
return None
412+
413+
414+
def parse_name_from_pyproject_file(file: TextIO) -> Union[str, None]:
415+
"""
416+
Parse the project name from a pyproject.toml file.
417+
418+
Return ``None`` if the name cannot be parsed.
419+
"""
413420
# Ideally we'd use a proper TOML parser to do this, but there isn't
414421
# one available in the standard library until Python 3.11. This
415422
# hacked together parser should work for the vast majority of cases.
416-
in_tool_poetry_section = False
417-
for line in pyproject_lines:
418-
if line.strip() in ["[tool.poetry]", "[project]"]:
419-
in_tool_poetry_section = True
420-
continue
421-
if line.strip().startswith("["):
422-
in_tool_poetry_section = False
423-
if not in_tool_poetry_section:
424-
continue
425-
try:
426-
key, val = (part.strip().strip('"') for part in line.split("="))
427-
except ValueError:
428-
continue
429-
if key == "name":
430-
return val
423+
for line in file:
424+
line = line.strip() # noqa: PLW2901
425+
if line in ("[project]", "[tool.poetry]"):
426+
for project_line in file:
427+
project_line = project_line.lstrip().lstrip("'\"") # noqa: PLW2901
428+
if project_line.startswith("["):
429+
# New block started without finding the project name.
430+
return None
431+
if not project_line.startswith("name"):
432+
continue
433+
try:
434+
key, val = project_line.split("=", maxsplit=1)
435+
except ValueError:
436+
continue
437+
if key.rstrip().rstrip("'\"") == "name":
438+
return val.strip().strip("'\"")
431439
return None
432440

433441

434-
def activator(env_directory: str, args: Args) -> str:
435-
"""Get the activator script for the environment in the given directory."""
436-
is_windows = operating_system() == Os.WINDOWS
437-
dir_name = "Scripts" if is_windows else "bin"
442+
def iter_candidate_activators(env_directory: str, args: Args) -> Iterator[str]:
443+
"""
444+
Iterate over candidate activator paths.
445+
446+
In general we'll know exactly the activator we want given the
447+
environment directory and the shell we're using. However, in some
448+
cases there may be slightly different activator script names
449+
depending on how the venv was created.
450+
"""
451+
bin_dir = "Scripts" if operating_system() == OS_WINDOWS else "bin"
438452
if args.fish:
439453
script = "activate.fish"
440454
elif args.pwsh:
441-
if is_windows:
442-
script = "Activate.ps1"
443-
else:
444-
# PowerShell activation scripts on Nix systems have some
445-
# slightly inconsistent naming. When using Poetry or uv, the
446-
# activation script is lower case, using the venv module,
447-
# the script is title case.
448-
# We can't really know what was used to generate the venv
449-
# so just check which activation script exists.
450-
script_path = os.path.join(env_directory, dir_name, "activate.ps1")
451-
if os.path.isfile(script_path):
452-
return script_path
453-
script = "Activate.ps1"
455+
# PowerShell activation scripts on *Nix systems have some
456+
# slightly inconsistent naming. When using Poetry or uv, the
457+
# activation script is lower case, using the venv module,
458+
# the script is title case.
459+
for script in ("activate.ps1", "Activate.ps1"):
460+
script_path = os.path.join(env_directory, bin_dir, script)
461+
yield script_path
454462
else:
455463
script = "activate"
456-
return os.path.join(env_directory, dir_name, script)
464+
yield os.path.join(env_directory, bin_dir, script)
457465

458466

459467
@lru_cache(maxsize=1)
@@ -464,11 +472,11 @@ def operating_system() -> Union[int, None]:
464472
Return 'None' if we're on an operating system we can't handle.
465473
"""
466474
if sys.platform.startswith("darwin"):
467-
return Os.MACOS
475+
return OS_MACOS
468476
if sys.platform.startswith("win"):
469-
return Os.WINDOWS
477+
return OS_WINDOWS
470478
if sys.platform.startswith("linux"):
471-
return Os.LINUX
479+
return OS_LINUX
472480
return None
473481

474482

tests/test_poetry.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from tests.tools import (
2929
OPERATING_SYSTEM,
3030
activate_venv,
31+
clear_lru_caches,
3132
make_poetry_project,
3233
root_dir,
3334
)
@@ -89,11 +90,11 @@ def fs(self, fs: FakeFilesystem) -> FakeFilesystem:
8990
fs.create_file(self.venv_dir / "bin" / "activate")
9091
fs.create_file(self.venv_dir / "bin" / "activate.ps1")
9192
fs.create_file(self.venv_dir / "bin" / "activate.fish")
92-
fs.create_file(self.venv_dir / "Scripts" / "Activate.ps1")
93+
fs.create_file(self.venv_dir / "Scripts" / "activate.ps1")
9394
return fs
9495

9596
def setup_method(self):
96-
pyautoenv.poetry_cache_dir.cache_clear()
97+
clear_lru_caches(pyautoenv)
9798
self.os_patch = mock.patch(OPERATING_SYSTEM, return_value=self.os)
9899
self.os_patch.start()
99100
os.environ = copy.deepcopy(self.env) # noqa: B003
@@ -299,9 +300,9 @@ def test_deactivate_given_changing_to_ignored_directory(self):
299300
class PoetryLinuxTester(PoetryTester):
300301
env = {
301302
"HOME": str(root_dir() / "home" / "user"),
302-
"USERPROFILE": str(root_dir() / "home" / "user"),
303+
"USERPROFILE": str(root_dir() / "Users" / "user"),
303304
}
304-
os = pyautoenv.Os.LINUX
305+
os = pyautoenv.OS_LINUX
305306
poetry_cache = (
306307
root_dir() / "home" / "user" / ".cache" / "pypoetry" / "virtualenvs"
307308
)
@@ -327,7 +328,7 @@ class PoetryMacosTester(PoetryTester):
327328
"HOME": str(root_dir() / "Users" / "user"),
328329
"USERPROFILE": str(root_dir() / "Users" / "user"),
329330
}
330-
os = pyautoenv.Os.MACOS
331+
os = pyautoenv.OS_MACOS
331332
poetry_cache = (
332333
root_dir()
333334
/ "Users"
@@ -355,10 +356,10 @@ class TestPoetryFishMacos(PoetryMacosTester):
355356

356357

357358
class TestPoetryPwshWindows(PoetryTester):
358-
activator = Path("Scripts/Activate.ps1")
359+
activator = Path("Scripts/activate.ps1")
359360
env = {"LOCALAPPDATA": str(root_dir() / "Users/user/AppData/Local")}
360361
flag = "--pwsh"
361-
os = pyautoenv.Os.WINDOWS
362+
os = pyautoenv.OS_WINDOWS
362363
poetry_cache = (
363364
root_dir()
364365
/ "Users"

0 commit comments

Comments
 (0)