Skip to content

Python

To use varlock with Python, install the standalone binary and run your app under varlock run. It loads and validates your env, then injects it into the process.

Add @generatePythonEnv to your schema to generate a small, self-contained module: a coerced Env TypedDict, a load_env() function that parses the injected __VARLOCK_ENV blob, and a SENSITIVE_KEYS constant.

.env.schema
# @generatePythonEnv(path=env.py)

The file is regenerated automatically on varlock load and varlock run, or explicitly with varlock codegen. It has no dependencies, importing it has no side effects, and it imports on any Python 3.7+ (it uses from __future__ import annotations, so NotRequired/Literal are type-checker-only).

Call load_env() once, then read keys off the dict. Values are coerced (int/bool/etc.), not raw strings:

from env import load_env, SENSITIVE_KEYS
env = load_env() # call once, hold or pass it around
port = env["DB_PORT"] # int
debug = env["DEBUG"] # bool
# build your own redaction using SENSITIVE_KEYS
safe = {k: ("***" if k in SENSITIVE_KEYS else v) for k, v in env.items()}
Terminal window
varlock run -- python main.py

load_env() raises a clear error if __VARLOCK_ENV is missing (e.g. you forgot varlock run). Optional keys that are unset are absent from the dict (NotRequired), not present as None.

Every load_env() call re-parses the blob. To avoid reloading, load once in a config module and share it, the idiomatic “settings module” pattern:

config.py
from env import load_env
env = load_env() # loaded once, typed as Env
anywhere.py
from config import env
port = env["DB_PORT"] # int

varlock run wraps any command, so boot your ASGI server, task runner, or scripts through it. Local dev, CI, and production all use the same schema:

Terminal window
varlock run -- uvicorn main:app --reload
varlock run -- poetry run pytest
varlock run -- python manage.py runserver

Some scripts re-exec themselves under varlock run so callers do not have to wrap every invocation. Two things to get right: check __VARLOCK_RUN first so you do not recurse, and pick the interpreter path deliberately rather than passing sys.executable.

import os
import sys
if "__VARLOCK_RUN" not in os.environ:
if sys.prefix != sys.base_prefix:
bin_dir = "Scripts" if os.name == "nt" else "bin"
exe = "python.exe" if os.name == "nt" else "python3"
python = os.path.join(sys.prefix, bin_dir, exe)
else:
python = sys.executable
os.execvp("varlock", ["varlock", "run", "--", python] + sys.argv)

Use os.execvp so the varlock binary is resolved from PATH. os.execv needs an absolute path, so it fails for a normal install.

The interpreter path matters because varlock run passes through whatever executable path it is given. On Linux, sys.executable is usually the venv interpreter, so passing it works. On macOS with Homebrew Python, sys.executable is the fully resolved Cellar binary, not the venv symlink: re-execing that path means PEP 405 never finds pyvenv.cfg, the child leaves the venv, and the script fails in ways that look like a varlock problem. Rebuilding the path from sys.prefix keeps the child inside the venv on both platforms.

Use sys.prefix, not VIRTUAL_ENV. sys.prefix != sys.base_prefix is Python’s own check for whether the running interpreter is in a venv, while VIRTUAL_ENV only reflects what the shell activated. The two disagree whenever a script is invoked through another venv’s interpreter directly, and following VIRTUAL_ENV there would re-exec into the wrong environment.

If you’d rather keep the settings library you already use, every key is also injected as a plain environment variable, so Pydantic Settings, environs, and friends work unchanged. They read the raw string values (e.g. "5432", "true"), so your settings class is what coerces them:

settings.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(extra="ignore")
app_env: str
database_url: str
openai_api_key: str
settings = Settings()
config.py
from environs import Env
env = Env()
# Do not call env.read_env(); varlock run already populated os.environ
DATABASE_URL = env.str("DATABASE_URL")
DEBUG = env.bool("DEBUG", default=False)