DIY CLI-tool: Find and Activate Virtual Environments
Tags: python, cli, windows
Published: 2020-02-24 21:36:08

In this post, I will cover my most used CLI-tool. It finds a virtual environment, created using [virtualenv](https://pypi.org/project/virtualenv/), in your current directory and activates it. It also allows you to pick which environment to activate if multiple where detected. Although this doesn't really save much time, it gives a much nicer working experience when jumping between many different projects. I will be talking about these tools from the perspective of my setup, described in parts one and two of this series. The target of this script is Windows, however, I would imagine it is easily ported to Linux as well.

Here is an example of running the tool with the recursive flag, finding multiple environments:
![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/rj1n4sgyrscrgxfl4l1z.PNG)

If you want all the code, I put it up on GitHub. At the time of writing the code in the repo is the same as the one shown in this post. However, it might have changed when you are reading this. In the end, the core design will most likely be the same even if some implementation details might change.
{% github fronkan/activate_env no-readme %}

The complexity of this tool comes from the needed interaction with your current shell. This, as far as I know, is not possible to do from Python. Therefore, it has three files in three different languages. The main file is:
* __find_virtual_env.py_, which does almost all the work

Two two shell files for wrapping the python script and then activates the environment:
* _activate_env.bat_, activates the environment when running _cmd_-shell
* _activate_env.ps1_, activates the environment when running _powershell_

I will take one file at the time starting with the meat of the tool, __find_virtual_env.py_.

## __find_virtual_env.py_
This parses command-line arguments, find all virtual environments in the directory and allow the user to pick which environment to activate. As this script will be wrapped by two other shell-scripts, I decided to have all interactions with the user go through _stderr_. Then it will just write the path to the activation script of the environment to _stdout_ which is picked up by the wrappers. I will go through the file from top to bottom, one function at the time and then finally the dunder main, \_\_main\_\_.

As you might expect, we start off with the imports. As you can see, this tool only depends on the standard library. Well, except for you having virtual environments, but I guess that goes without saying.
```python
import sys
from pathlib import Path
from contextlib import contextmanager
from typing import List
from argparse import ArgumentParser
import signal
```

As I stated earlier, I want to write almost all output to _stderr_. Therefore, I created a context manager to help me with this. This re-directs everything written to _stdout_ to _stderr_ while in the context-block and returns everything to normal when exiting.
```python
@contextmanager
def stderr_as_out():
    stdout = sys.stdout
    sys.stdout = sys.stderr
    yield
    sys.stdout = stdout
```

Next, I have a function which we will later register to react to the SIGINT signal (pressing ctrl-c). This was added to clean up the output when exiting from the program using an interrupt. It prints a newline to stderr and returns a failing status code. Maybe, the code actually should be zero in this case? However, I am currently using success (0) only when it actually produced an environment to be activated.
```python
def handle_sigint(*args):
    """Makes output cleaner sent SIGINT (ctrl-c)"""
    with stderr_as_out():
        print("")
    sys.exit(1)
```

The `find_envs` function searches for virtual environments in the supplied directory. As the environment can be named whatever you want, it looks for a folder containing the path Scripts/activate. So, if you had an environment named _env_ this would be env/Scripts/activate. This is the folder structure of virtual environments, at least on Windows. It uses _glob_ for finding all paths matching this structure, by default only at the current directory level. Because it was easy to add, it also supports the use of recursive search. This just switches the glob-expression from one leading start to two.
```python
def find_envs(cur_dir: Path, recursive: bool = False) -> List[Path]:
    search_pattern = "**/Scripts/activate" if recursive else "*/Scripts/activate"
    return [env_path for env_path in cur_dir.glob(search_pattern)]
```

If the program finds multiple environments I want the user to choose which to activate. For this I have two functions, the first one prints the choices with some added styling. Each choice has a index (used to make the choice), the name of the virtual environment and path to the environment.
```python
def print_envs(envs: List[Path]) -> None:
    max_name_len = max([len(env.parent.parent.name) for env in envs])
    for idx, env in enumerate(envs):
        env_dir = env.parent.parent
        name_str = f"({env_dir.name})"
        print(f"[{idx}]: {name_str:^{max_name_len+3}} - {env_dir}")
```

The next function accept the user input, repeating the question prompt until a valid index is supplied. It then returns the environment path corresponding to the index supplied by the user.
```python
def ask_user_to_choose_env(envs: List[Path]) -> Path:
    max_idx = len(envs) - 1
    while True:
        idx = input("Activate environment (index): ")
        try:
            idx = int(idx)
        except ValueError:
            print("Index must be a integer")
            continue

        if (idx < 0) or (idx > max_idx):
            print(f"Index must be in range [0, {max_idx}]")
            continue
        else:
            break

    return envs[idx]
```

Now to the _main_, where all the pieces are put together. First, we register the SIGINT handler show earlier. Secondly, the argument parser is created, which automatically adds the -h flag for help messages. Then a -r flag is added, which is a Boolean flag used to trigger the recursive version of _find_envs_.

The majority of the program is then run as a part of _stderr_as_out_-context block. Parsing of the arguments is also done here, as the -h flag otherwise would trigger some wired output from the wrapping scripts. The _find_env_ function runs and depending on the result the behavior differs. If no environment was found we print this information and exit the program. If only a single one was found it is activated and otherwise, we ask the user to pick an environment to activate. The final step is then to write this to _stdout_ using the final print-statement, which is outside the context-block.
```python
if __name__ == "__main__":
    signal.signal(signal.SIGINT, handle_sigint)
    parser = ArgumentParser(
        prog="activate",
        description="""
            Activates a virtual environment if found in the current directory. 
            If multiple are found the user may choose the environment. 
            All user-communication is written through stderr to not conflict with calling programs.
        """.strip(),
    )
    parser.add_argument(
        "-r",
        "--recursive",
        action="store_true",
        default=False,
        help="Recursivly searches through the directory for virtual environments",
    )
    with stderr_as_out():
        # We re-direct the parsing output to stderr as e.g -h argument will produce ouput
        # This would break the ps1/bat files which uses this script
        args = parser.parse_args()

        envs = find_envs(cur_dir=Path("."), recursive=args.recursive)

        if not envs:
            print("No virtual envs found")
            exit(1)
        elif len(envs) > 1:
            print_envs(envs)
            env = ask_user_to_choose_env(envs)
        else:
            env = envs[0]

    # Write env to stdout for the wrapping shell scripts to grab it
    print(str(env))
    exit(0)
```

## activate_envs.ps1
Next up, is the powershell script used to actually activate the chosen environment, when using powershell. This is a rather straight forward script. It runs the program, passing through all arguments to it and stores everything it has written to _stdout_ in a variable. As we know the script only writes the final environment choice to _stdout_ it is not much more work to be done. We check if it contains anything and if it does the `Invoke-Expression` statement will run the activate script. This is exactly what you would have done when manually activating the environment.
```powershell
$env = _find_virtual_env.py $args
if ($env){
    Invoke-Expression $env
}
```

## activate_envs.bat
Now, this file was the hardest one to write. I have to say batch is not a pretty language, and I hope you like GOTO! This time I will actually show you the code first and then try to explain it, so without further ado:
```batch
@echo off

FOR /F "tokens=*" %%F IN ('_find_virtual_env.py %*') DO (
    SET __choosen_virtual_env__=%%F
)

if [%__choosen_virtual_env__%]==[] GOTO EXIT 
 
call %__choosen_virtual_env__%


:EXIT
SET __choosen_virtual_env__=
```

The first step is to turn off echo, which otherwise will print every single line of your script to the terminal as it is running. Now I will decompose the for-loop. 
* The `/F` flag, I think stands for file and you treat _stdout_ as reading from a file. 
* The parameter `"tokens=*"`, tells it to read all tokens from the file. 
* `%%F` of course declares the loop variable, which must be a single letter. 
* '_find_virtual_env.py %*', runs the python script, passing all parameters to it
* The SET-statement stores the result in a new variable named `__choosen_virtual_env__`

I choose this rather complicated variable name as I did not want it to clash with any other global variable, because, well everything is global. There is a method for creating a local scope with `startlocal` and `endlocal`. However, this does not work when trying to activate the environment. This most likely comes from the environment activation depending on setting multiple environment variables. These, of course, are also affected by the local-scope created in this file and are destroyed when the scope ends. So, a global variable it is. 

The if-statement checks if the variable is empty and if it is, jumps to the _EXIT_ label. If it wasn't empty we will reach the call-statement which runs the activation script, again similar to how you would have done it manually. The final step, after the _EXIT_ label, is to set the variable to nothing. This very pretty syntax basically deletes the variable.

## Summary
We use Python for the heavy lifting, searching for virtual environments using glob and allowing the user to choose which environment to activate if multiple were detected. All communication from the python script to the user is done using _stderr_ and it only writes the final path to _stdout_. Then we wrap it in thin wrappers for each shell, _cmd_ and _powershell_. The wrapper just takes a single path from the Python script and runs it, activating the virtual environment.