python-passenv
Load environment variables from the pass password
store, with the ergonomics of python-dotenv.
One pass entry is one environment variable. Folders are namespaces.
~/.password-store/
├── shared/
│ └── webhook_url.gpg → WEBHOOK_URL
└── project_a/
├── api_token.gpg → API_TOKEN
└── db/
└── dsn.gpg → DSN
- No
.envfile on disk — secrets stay encrypted until the process asks for them. - Zero runtime dependencies; it shells out to the
passbinary you already trust. - Fully typed (
py.typed), Python 3.10+.
Install
uv add python-passenv # or: pip install python-passenv
You need pass on your PATH and a store you can decrypt.
Quickstart
from passenv import load_passenv
load_passenv("project_a") # decrypts project_a/** into os.environ, returns True
import os
os.environ["API_TOKEN"] # → 's3cret…'
Read the values without touching the process environment:
from passenv import passenv_values
config = passenv_values("project_a") # {'API_TOKEN': 's3cret…', 'DSN': 'postgres…'}
Read a single entry by its full path:
from passenv import get_key
get_key("project_a/api_token") # → 's3cret…'
get_key("project_a/nope") # → None
Coming from python-dotenv
python-dotenv |
python-passenv |
|---|---|
load_dotenv() |
load_passenv() |
load_dotenv(".env.prod") |
load_passenv("prod") |
load_dotenv(override=True) |
load_passenv(override=True) |
dotenv_values() |
passenv_values() |
get_key(".env", "API_TOKEN") |
get_key("project_a/api_token") |
set_key / unset_key |
not in v1 — use the pass CLI |
$VAR interpolation |
not in v1 |
| source: a file path | source: a store namespace |
Namespace semantics
A namespace is a folder path relative to the store root.
load_passenv() # the whole store
load_passenv("project_a") # project_a/** only
load_passenv(["shared", "project_a"]) # merged; project_a wins on conflicts
- Recursive.
project_a/db/dsnis included inload_passenv("project_a"). - Leaf names only. That entry becomes
DSN, notDB_DSN. - Collisions are errors. Two entries in one namespace whose leaves map to the same
variable raise
KeyCollisionError— detected before anything is decrypted. - Layering across namespaces is fine. Later namespaces override earlier ones, and
the merge is resolved before anything is written to
os.environ. - Only
*.gpgfiles count. Dotfiles and dot-directories (.git,.gpg-id,.extensions) are skipped.
Values
By default a variable gets the first line of the entry — the pass convention of
"password on line one, metadata below":
s3cret-token-value
url: https://api.example.com
user: service-account
passenv_values("project_a")["API_TOKEN"] # 's3cret-token-value'
passenv_values("project_a", multiline=True)["API_TOKEN"] # the whole entry, one trailing \n removed
Values are never .strip()ed — leading and trailing spaces in a secret survive intact.
Naming
key_transform is applied to the leaf name and defaults to str.upper:
load_passenv("project_a", key_transform=lambda leaf: f"APP_{leaf.upper()}") # APP_API_TOKEN, …
Where the store lives
In precedence order:
- the
store_dir=argument, $PASSWORD_STORE_DIR,~/.password-store.
When store_dir is given it is exported as PASSWORD_STORE_DIR to the pass
subprocess, so a non-default store works without touching your own environment.
Errors
| Exception | Raised when |
|---|---|
PassEnvError |
base class for everything below |
PassNotFoundError |
the pass executable is not on PATH |
PassDecryptError |
pass show exited non-zero (.entry, .returncode, .stderr) |
NamespaceNotFoundError |
the namespace folder is missing and required=True |
KeyCollisionError |
two entries map to the same variable (.key, .entries) |
With required=False (the default) a missing namespace is not an error:
load_passenv returns False and passenv_values returns {}.
Logging
passenv logs to the passenv logger and records keys only, never values.
load_passenv(verbose=True) turns those records on even if your app has not
configured logging.
API
def load_passenv(namespace=None, *, override=False, multiline=False,
key_transform=str.upper, required=False, verbose=False,
store_dir=None) -> bool: ...
def passenv_values(namespace=None, *, multiline=False, key_transform=str.upper,
required=False, store_dir=None) -> dict[str, str]: ...
def get_key(entry, *, multiline=False, store_dir=None) -> str | None: ...
load_passenv returns True if at least one entry was read. With override=False
it never clobbers a variable that is already set.
Development
uv sync --group dev
uv run prek install # install the pre-commit and commit-msg hooks (once per clone)
uv run ruff check . && uv run ruff format --check . && uv run pyrefly check
uv run pytest # unit tests, no gpg required
uv run pytest -m integration # real pass + gpg, ephemeral key and store
The integration suite generates an unprotected GPG key into a temporary GNUPGHOME,
runs pass init against a temporary store, and is skipped when pass or gpg is
missing. It never touches your real store.
Git hooks
prek runs the checks in prek.toml before every
commit. Because this project's whole subject is secrets, the secret-scanning hooks run
first and abort the commit immediately when one of them trips:
| Hook | Blocks |
|---|---|
betterleaks |
credentials in the staged diff, matched against betterleaks' provider rules |
detect-private-key |
PEM/OpenSSH private-key blocks |
deny-filename-pattern |
*.gpg, *.pem, *.key, .env*, id_rsa, .netrc, … |
deny-filename-pattern (store paths) |
anything under a .password-store/ or .gnupg/ directory |
The rest of the run is repo hygiene (trailing whitespace, line endings, TOML/YAML
syntax, large files) followed by ruff check --fix, ruff format and pyrefly check,
all invoked through uv run so hook and CI versions come from the same dev group.
uv run prek run --all-files # run every hook by hand
uv run prek run betterleaks # run just one
uv run prek auto-update # bump pinned hook revisions
CI runs the same hooks via prek run --all-files. Since the betterleaks hook only
inspects staged changes, a separate CI job scans the full history with the pinned
betterleaks image.
Commit messages
Commit subjects follow Conventional Commits,
because the release version is computed from them — feat: bumps the minor, fix: and
perf: bump the patch, everything else releases nothing. A commit-msg hook rejects
subjects that do not parse. See RELEASING.md.
Releasing
Merging to main is the release. python-semantic-release reads the commits since the
last tag, bumps the version, writes the changelog, tags, cuts a GitHub Release with the
sdist and wheel attached, and publishes to PyPI via Trusted Publishing — or does nothing
at all, if nothing releasable landed. Details, including the dry-run and forced-bump
triggers, are in RELEASING.md.
License
MIT — see LICENSE.
Metadata
Release files for python-passenv 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| python_passenv-0.1.1.tar.gz | 95.0 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| python_passenv-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 105.6 kB
Release files / python_passenv-0.1.1.tar.gz
| Download URL | python_passenv-0.1.1.tar.gz |
|---|---|
| Size | 95.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
58443d9c80e44e4f657d0fbdc3fa80dfd5db3cef673e7a6829ef2dfd0fe62915
|
|
BLAKE2b-256 checksum How to use checksums |
a58009cc0807ac22a468a8e18b3c55df8c9b856c992ab56be484b18b1d200caa
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 22, 2026.
Transparency logRelease files / python_passenv-0.1.1-py3-none-any.whl
| Download URL | python_passenv-0.1.1-py3-none-any.whl |
|---|---|
| Size | 10.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
811f7191426088fc4eb783d53f2129839113a4a225a24d92ad1e63aa44695c08
|
|
BLAKE2b-256 checksum How to use checksums |
84b7f907e31cd57a646a2cf7242ac2b10ed4c240cdd5a0e8da3b40ec0619668a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 22, 2026.
Transparency logSecurity for python-passenv 0.1.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for python_passenv-0.1.1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 22, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.