Features
venvstarter provides a range of features for controlling how dependencies are
managed and how the resulting virtualenv is used when the script is run.
Note
This page will speak about venvstarter in terms of the venvstarter.manager
class as that’s the intended way to use this library. This class is a wrapper
around the core logic provided by venvstarter.Starter.
All the features lead to this pattern when a script that uses venvstarter is run:
1. Create virtualenv if it doesn't exist
2. Install dependencies if the existing dependencies in the virtualenv don't match
3. os.exec into something from the virtualenv
Creating an isolated Python to use
venvstarter is used to create an executable that will run something from a
virtualenv. Sometimes all that is wanted is the python binary in that virtualenv
and for this purpose, None may be specified:
- /my_python
#!/usr/bin/env python # Instantiating the manager with None tells venvstarter to use the Python binary manager = __import__("venvstarter").manager(None) # optionally specify a desired range of python versions manager.min_python("3.10") # with any dependencies in the venv as per the API on the manager # And run creates and runs the desired program in the virtualenv manager.run()
And then:
> chmod +x ./my_python
> ./my_python /path/to/some/python/script.py
Will run the script.py using a fresh python3.10 as found in that virtualenv.
Note
When the manager is instantiated with a program that is not a string
(i.e. None or a callable object) then the virtualenv will default to being
called .venv. There is the manager.named(".my_venv") method to override
that default.
Using venvstarter to run a python tool
Let’s say the desire is to run the black auto formatter without requiring the
user or CI system to have a particular version installed:
- /format
#!/usr/bin/env python # Instantiating the manager with "black" tells venvstarter to use the # "black" console script that gets created by installing black manager = __import__("venvstarter").manager("black") # instruct venvstarter to ensure black exists at a particular version # These are essentially lines in a requirements.txt file manager.add_pypi_deps("black===22.6.0") # And run creates and runs the desired program in the virtualenv manager.run()
And then:
> chmod +x ./format
> ./format path/to/code
Will ensure there is a .black folder next to format that contains a
virtualenv that contains black from pypi at version 22.6.0 and then
run ./.black/bin/black path/to/code.
Dynamically choosing what to run
It’s possible to make venvstarter run something different depending on what
arguments are provided on the CLI.
- /my_program
#!/usr/bin/env python3 from pathlib import Path import typing as tp def run(venv_location: Path, args: list[str]) -> tp.Optional[str | list[str]]: if args and args[0] == "one": args.pop(0) return "command-one" elif args and args[0] == "two": args.pop(0) return "command-two" else: return "command-three" # Optionally specified the name of the virtualenv is .runner # The manager is initiated with a callable and so venvstarter would otherwise # default to naming the virtualenv ".venv" manager = __import__("venvstarter").manager(run).named(".runner") manager.run()
and then:
> chmod +x ./my_program
# Equivalent to > command-one 1 2 3
> ./my_program one 1 2 3
# Equivalent to > command-two 4 5 6
> ./my_program two 4 5 6
# Equivalent to > command-three three 7 8 9
> ./my_program three 7 8 9
In this case the manager has been instantiated with a function that takes in a
standard library Path object pointing to where the virtualenv env is, and
the list of arguments from the command line.
The function must return None, a single string, or a list of strings.
Returning None means venvstarter will execute the python binary in the virtualenv.
Returning a single string will make it use that name to find that executable
in the virtualenv. Returning a list of strings will use the first string as the
executable and extra arguments before appending the strings that remain in the
args list that was passed in.
Note
The args list passed into the function can be modified in place to
affect what venvstarter uses with the specified command.
Environment variables to change behavior
There are a couple environment variables that change what venvstarter does:
VENV_STARTER_CHECK_DEPS=0When this is set to 0 then
venvstarterwill not check if the dependencies in thevirtualenvare correct if thevirtualenvalready exists. This speeds up startup time as checking dependencies takes a second or two.VENVSTARTER_ONLY_MAKE_VENV=1When this is set to 1 then
venvstarterwill ensure thevirtualenvexists and has correct dependencies and then exit before doing anything with thevirtualenv.VENVSTARTER_UPGRADE_PIP=0This will make sure that pip is not ensured to be greater than 24 before requirements are installed
Installing local dependencies
venvstarter has the ability to install a local dependency as a symlink in the
virtualenv and only reinstall that dependency if it’s version changes. This is
how venvstarter knows to change any sub dependencies that come from that code.
For example, if there is this code structure in the repository:
/
pyproject.toml
mycode/
__init__.py
executor.py
run
/pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "mycode"
dynamic = ["version"]
dependencies = [
"dict2xml==1.7.0"
]
[project.scripts]
take-over-the-world = "mycode.executor:main"
[tool.hatch.version]
path = "mycode/__init__.py"
[tool.hatch.build.targets.sdist]
include = [
"/mycode"
]
/mycode/__init__.py
VERSION = "0.1"
/mycode/executor.py
def main():
print("The world is ours!")
- /run
#!/usr/bin/env python3 manager = __import__("venvstarter").manager("take-over-the-world").named(".runner") manager.add_local_dep( "{here}", version_file=( "mycode", "__init__.py", ), name="mycode=={version}", with_tests=True, ) manager.run()
This says that the setup.py to look for is in the same folder as the venvstarter
script (the {here} gets formatted with the folder the script is in) and that
relative to where the setup.py file is a VERSION variable can be found in
mycode/__init__.py. The dependency needs a name so that venvstarter knows
what to check when run is executed in the future and so mycode=={version}
is provided, which gets formatted with the value of that VERSION variable.
The with_tests then adds any tests extra requires block, which is
equivalent to saying:
> python install -e ".[tests]"
The full API can be found at venvstarter.manager.add_local_dep()
Now upon running ./run it will print “The world is ours!” to the console
as it will execute the take-over-the-world console script installed by the
dependency, which runs mycode.executor.main.
Installing from a requirements file
The manager also has the ability to find dependencies from a requirements.txt:
#!/usr/bin/env python3
manager = __import__("venvstarter").manager(None)
manager.add_requirements_file("{here}", "requirements.txt")
manager.run()
The add_requirements_file method takes in multiple strings that are joined
together as a path (so the difference between slashes in linux and windows do
not have to be considered) and will format each string with:
hereThe location of the directory this script exists in
homeThe location of the current user’s home folder
venv_parentThe location of the folder the
virtualenvwill sit in.
Note
Every time add_pypi_deps is called, each argument supplied to
the method is its own line in a requirements.txt that is installed with pip.
Installing dependencies from source only
Sometimes it’s desirable to not use a binary wheel for a dependency. This can be
specified using add_no_binary which takes the names of dependencies to install
from source:
manager = __import__("venvstarter").manager("black")
manager.add_pypi_deps("noseOfYeti[black]>=2.4.2")
manager.add_no_binary("black")
manager.run()
Here black is installed from source because noy-black requires it be
installed from source, so it can add some stuff on top of it.
This is equivalent to:
> python -m pip install --no-binary black noy-black noseOfYeti
When a new python version is needed
When a venvstarter script is run, it will check:
Does
virtualenvexist?Is it the desired python?
Are the specified dependencies at the desired versions?
The version of python is controlled via venvstarter.manager.min_python()
and venvstarter.manager.max_python().
For example:
manager = __import__("venvstarter").manager(None)
manager.min_python("3.7")
manager.max_python("3.11")
manager.run()
With this script venvstarter will stop when it finds a suitable python:
Is there
python3.11on PATH?Is there
python3.10Is there
python3.9Is there
python3.8Is there
python3.7Is
python3in PATH within the range?Is
pythonin PATH within the range?
For all of these, it determines if it’s a valid python at that version by
effectively executing print(sys.version_info) with that binary.
When venvstarter finds an existing virtualenv it will use the python in that
virtualenv to do the same check and will delete the virtualenv if the python
is not a suitable version and a suitable version can be found on the system so
that it may recreate the virtualenv.
Works on Windows as well
venvstarter has support for windows where the layout of the virtualenv is slightly
different and there are some different semantics around open files.
The tests for venvstarter are also run in a Windows environment for every change
that is made to this program.
Are there lock files? (nope, sorry)
The last time I investigated whether I could use new dependency management systems
like Poetry as a library, I quickly found that wasn’t possible. So for now
venvstarter continues to use pip (which also means venvstarter has no external
dependencies of its own) and pip itself does not support lockfiles.
Bootstrapping venvstarter
venvstarter means that a programmer can easily create an isolated environment
for any program desired to be run, however it does require the system has
venvstarter itself installed. To remove this step for non-technical users it
can be useful to have a small script that ensures venvstarter is installed
without manual intervention.
An example of this can be found in the venvstarter repo itself!
This is used by the two scripts in that folder that are used to run format and lint tools in CI (and locally for anyone who doesn’t have that setup in their editor)
https://github.com/delfick/venvstarter/blob/main/tools/black
https://github.com/delfick/venvstarter/blob/main/tools/pylama
Usage is using runpy to execute that script (more reliable than tricks to ensure
the import PATH is correct) and then importing venvstarter will work.
The script works by using the fact that the standard library importlib.reload
can be used to find a dependency if it’s been pip installed after a failed import.