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 lazily loaded ENV object with typed attributes, a coerced Env TypedDict with 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).

Import ENV and read keys as attributes. Values are coerced (int/bool/etc.), not raw strings:

from env import ENV
port = ENV.DB_PORT # int | None (optional item)
debug = ENV.DEBUG # bool
Terminal window
varlock run -- python main.py

ENV is loaded on first access and then cached, so importing the module has no side effects, and every module that imports ENV shares one instance. Loading raises a clear error if __VARLOCK_ENV is missing (e.g. you forgot varlock run). Optional keys that are unset read as None.

Each attribute is a documented property, so editors using Pyright or Pylance show the item’s description, @docs() links, valid enum values, and sensitivity on hover, and go-to-definition jumps to it. Items marked @deprecated are typed with @deprecated, so type checkers flag reads of them and editors show them struck through.

To build an instance yourself, for example in tests, pass a dict to EnvAttrs:

from env import EnvAttrs
env = EnvAttrs({"DB_HOST": "localhost", "APP_ENV": "dev"})

load_env() returns the values as a plain Env dict instead. Optional keys that are unset are absent from the dict (NotRequired), not present as None. Each call re-parses the blob, so call it once and share the result:

from env import load_env, SENSITIVE_KEYS
env = load_env()
port = env["DB_PORT"] # int
# build your own redaction using SENSITIVE_KEYS
safe = {k: ("***" if k in SENSITIVE_KEYS else v) for k, v in env.items()}

The Env fields carry the same docs as docstrings, but Pyright shows them only when hovering the declaration, not on env["DB_PORT"]. Use ENV if you want docs where values are read.

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)