3434import os
3535import sys
3636from 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
5454VENV_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
5862if __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-
9995def 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
149145def 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
213214def 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 )
218220def 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 )
260259def 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 )
313312def 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
0 commit comments