diff --git a/.flake8 b/.flake8
new file mode 100644
index 0000000..921d25d
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,21 @@
+[flake8]
+max-line-length = 100
+max-complexity = 35
+exclude =
+ .git,
+ __pycache__,
+ .venv,
+ venv,
+ build,
+ dist,
+ baseline
+extend-ignore =
+ E203,
+ E501,
+ W503
+per-file-ignores =
+ lib/envstack/cli.py:C901
+ lib/envstack/env.py:F405
+ lib/envstack/path.py:F405
+ lib/envstack/util.py:C901
+ tests/test_cmds.py:W291
diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml
new file mode 100644
index 0000000..1fd89ee
--- /dev/null
+++ b/.github/workflows/publish-docs.yml
@@ -0,0 +1,60 @@
+name: publish-docs
+
+on:
+ push:
+ branches:
+ - master
+ - main
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: ubuntu-24.04
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Configure Pages
+ uses: actions/configure-pages@v5
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+
+ - name: Build Jekyll source tree
+ run: |
+ python scripts/build_pages_site.py --output _site_src
+
+ - name: Build site with Jekyll
+ uses: actions/jekyll-build-pages@v1
+ with:
+ source: _site_src
+ destination: _site
+
+ - name: Upload Pages artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: _site
+
+ deploy:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-24.04
+ needs: build
+
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index c3ac1f4..96156ff 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -1,10 +1,40 @@
name: tests
-on: [push, pull_request]
+on:
+ push:
+ paths:
+ - ".github/workflows/tests.yml"
+ - ".flake8"
+ - "bin/**"
+ - "lib/**"
+ - "tests/**"
+ - "pyproject.toml"
+ pull_request:
+ paths:
+ - ".github/workflows/tests.yml"
+ - ".flake8"
+ - "bin/**"
+ - "lib/**"
+ - "tests/**"
+ - "pyproject.toml"
jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - run: python -m pip install --upgrade pip
+ - run: pip install -e ".[dev]"
+ - run: black --check lib tests bin/
+ - run: isort --check-only lib tests bin/
+ - run: flake8 lib tests bin
+
test:
runs-on: ${{ matrix.os }}
+ needs: lint
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
@@ -15,6 +45,5 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
- run: python -m pip install --upgrade pip
- - run: pip install pytest
- - run: pip install -e .
- - run: pytest tests -v
+ - run: pip install -e ".[dev]"
+ - run: python -m pytest tests -v
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c1c49a3..110bfe1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
+## [1.0.4] - 2026-07-25
+
+### Added
+- add a `dev` extra with `pytest`, `black`, `isort`, and `flake8` for one-command local setup
+- add lightweight performance regression tests using example-based environment fixtures
+- add a GitHub Pages docs publishing workflow and a generated docs-site builder
+
+### Changed
+- move pytest configuration into `pyproject.toml`
+- broaden CI lint checks to cover the full `bin/` directory
+- simplify the docs landing page so the published site acts more like a lightweight home page that links to deeper documentation
+
+### Fixed
+- restore AES-GCM compatibility on Python 3.8/current `cryptography` by passing an explicit backend where required
+- restore the missing `envstack.wrapper` module to release artifacts by cleaning up release/build hygiene
+- make plain `pytest tests -q` work reliably via test-suite bootstrap and shared test helpers
+
+---
+
## [1.0.3] - 2026-07-23
### Changed
@@ -19,6 +38,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- restore Python 3.14 compatibility in `EnvVar.vars()` by avoiding `string.Template` pattern access that now raises under 3.14
- suppress the `cryptography` Python 3.8 deprecation warning during import so `envstack` subprocess tests and CLI startup remain quiet on supported 3.8 installs
+### Notes
+- The `1.0.3` PyPI release published on July 23, 2026 was yanked because the distributed wheel/sdist omitted `envstack.wrapper`, which breaks CLI startup after `pip install envstack`.
+- Use `1.0.4` or newer instead.
+
---
## [1.0.2] - 2026-04-01
diff --git a/Makefile b/Makefile
deleted file mode 100644
index 818506f..0000000
--- a/Makefile
+++ /dev/null
@@ -1,55 +0,0 @@
-# =============================================================================
-# Project: EnvStack - Environment Variable Management
-# Makefile for building project executables on Linux
-#
-# Usage:
-# make - Builds targets
-# make clean - Removes all build artifacts
-# make dryrun - Simulates installation without making changes
-# make install - Installs the build artifacts using distman
-#
-# Requirements:
-# - Python and pip installed (Linux)
-# - Wine installed for Windows builds on Linux
-# - distman installed for installation (pip install distman)
-# =============================================================================
-
-# Define the installation command
-BUILD_DIR := build
-BUILD_CMD := python -m pip install . -t $(BUILD_DIR)
-
-# envstack command uses ./env for ENVPATH
-ENVSTACK_CMD := ENVPATH=$(CURDIR)/env \
- PATH=$(CURDIR)/bin:$(BUILD_DIR)/bin:$$PATH \
- PYTHONPATH=$(CURDIR)/lib:$(BUILD_DIR):$$PYTHONPATH \
- envstack ${PROJECT} ${ENV}
-
-# Clean target to remove the build directory
-clean:
- rm -rf build
-
-# Target to build for Linux
-build: clean
- $(BUILD_CMD)
- rm -rf build/bin build/lib build/envstack build/bdist* build/__pycache__
-
-# Combined target to build for both platforms
-all: build
-
-# Run the pytest suite from an editable install, matching CI behavior
-test:
- python -m pip install pytest
- python -m pip install -e .
- pytest tests -q
-
-# Install dryrun target to simulate installation
-dryrun:
- $(ENVSTACK_CMD) -- dist --dryrun
-
-# Install target to install the builds using distman
-# using --force allows uncommitted changes to be disted
-install: build
- dist --force --yes
-
-# Phony targets
-.PHONY: all build dryrun install clean test pytest
diff --git a/README.md b/README.md
index e24565f..88bb062 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,7 @@
+
+
+
+
envstack
========
diff --git a/bin/envstack b/bin/envstack
index 48a2228..9965438 100755
--- a/bin/envstack
+++ b/bin/envstack
@@ -35,6 +35,7 @@ Contains a simple executable for the envstack cli.py module.
import re
import sys
+
from envstack.cli import main
if __name__ == "__main__":
diff --git a/bin/hello b/bin/hello
index 36a9842..a2d05ed 100755
--- a/bin/hello
+++ b/bin/hello
@@ -43,11 +43,13 @@ Usage:
"""
import sys
+
from envstack.wrapper import Wrapper
class HelloWrapper(Wrapper):
"""A simple wrapper that prints the value of an environment variable."""
+
def __init__(self, *args, **kwargs):
super(HelloWrapper, self).__init__(*args, **kwargs)
self.shell = True
diff --git a/bin/whichenv b/bin/whichenv
index ace6389..c78cc48 100755
--- a/bin/whichenv
+++ b/bin/whichenv
@@ -35,6 +35,7 @@ Contains a simple executable for the envstack cli.py module.
import re
import sys
+
from envstack.cli import whichenv
if __name__ == "__main__":
diff --git a/docs/assets/envstack.png b/docs/assets/envstack.png
new file mode 100644
index 0000000..43a6685
Binary files /dev/null and b/docs/assets/envstack.png differ
diff --git a/docs/index.md b/docs/index.md
index 0b9d852..40ebca9 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,300 +1,105 @@
-# envstack
-
-envstack is a **stacked environment configuration and activation layer** for tools
-and processes.
-
-It provides a deterministic way to **activate environments and compose
-configuration hierarchically**, using explicit layering, inheritance, and
-overrides.
-
-At its core, envstack is about:
-- Environment activation
-- Configuration layering
-- Policy and precedence
-- Making sure tools run with the correct environment
-
-## The problem envstack solves
-
-Real environments are not flat.
-
-They are:
-- Hierarchical (base → env → project → task)
-- Contextual (dev, prod, CI, local)
-- Layered over time
-- Full of defaults, overrides, and exceptions
-
-Flat `.env` files don’t model this well. envstack treats environment
-configuration as a **stack**: ordered, inspectable, and reproducible.
-
-## Core concepts
-
-### Environment stacks
-An environment stack is one or more named environments combined in order.
-Stacks define **precedence**: variables flow from higher scope to lower scope,
-with explicit overrides.
-
-### Hierarchy and inheritance
-Environments can inherit from other environments, forming a hierarchy.
-Downstream environments may refine or override upstream values without
-duplication.
-
-### Includes
-Environment definitions may include other environments declaratively,
-allowing shared base configuration to be reused consistently.
-
-### Resolution
-Variables can reference other variables, define defaults, or require values
-to be set. Resolution is explicit and inspectable before execution.
-
-## What envstack is (and is not)
-
-**envstack is:**
-- A configuration layer for environment variables
-- An environment activation mechanism
-- A system for hierarchical configuration composition
-
-**envstack is not:**
-- A dependency resolver
-- A package manager
-- A runtime isolation tool by default
-
-envstack focuses on **how environments are composed and activated**, not how
-dependencies are installed.
-
-## Typical uses
-
-envstack is commonly used for:
-- CLI tools
-- Services
-- CI/CD pipelines
-- Developer workstations
-- Pipeline and DCC tooling
-- Shared, hierarchical environment configuration
-
-## Project docs
-
-- [API](api.md)
-- [Comparison](comparison.md)
-- [Design](design.md)
-- [Examples](examples.md)
-- [FAQ](faq.md)
-- [Roadmap](roadmap.md)
-- [Secrets](secrets.md)
-
-## A simple example
-
-```yaml
-ROOT: /mnt/pipe
-ENV: ${ENV:=prod}
-DEPLOY_ROOT: ${ROOT}/${ENV}
-```
-
-```bash
-$ envstack -r DEPLOY_ROOT
-DEPLOY_ROOT=/mnt/pipe/prod
-```
-```bash
-$ envstack -- echo {DEPLOY_ROOT}
-/mnt/pipe/prod
-```
-
-## Philosophy
-
-"You want envstack. It’s what .env files wish they were when they grew up."
-
-envstack is intentionally opinionated:
-
-- Explicit over implicit
-- Layered over flat
-- Inspectable over magical
-
-## Basic Usage
-
-To see the unresolved environment for one or more environment stacks (values are
-defined in the stacks from left to right):
-
-```bash
-$ envstack [STACK [STACK ...]] -u
-```
-
-To resolve one or more environment vars for a given stack:
-
-```bash
-$ envstack [STACK] -r [VAR [VAR ...]]
-```
+
+
+
-To trace where one or more environment vars is being set:
-
-```bash
-$ envstack [STACK] -t [VAR [VAR ...]]
-```
-
-To run commands in an environment stack:
-
-```bash
-$ envstack [STACK] -- [COMMAND]
-```
-
-To get the list of source files for a given stack:
-
-```bash
-$ envstack [STACK] --sources
-```
-
-## Running Commands
-
-To run any command line executable inside of an environment stack, where
-`[COMMAND]` is the command to run:
-
-```bash
-$ envstack [STACK] -- [COMMAND]
-```
-
-For example:
-
-```bash
-$ envstack -- echo {HELLO}
-world
-```
-
-Running a node command:
-
-```bash
-$ echo "console.log('Hello ' + process.env.HELLO)" > index.js
-$ node index.js
-Hello undefined
-$ envstack hello -- node index.js
-Hello world
-```
-
-Running Python commands in the default stack:
+# envstack
-```bash
-$ envstack -- python -c "import os; print(os.environ['LOG_LEVEL'])"
-INFO
-```
+envstack is an **environment variable composition and activation layer** for
+tools and processes.
-Overriding values in the stack:
+It is built for cases where environments are hierarchical, shared, and
+context-dependent, and where a flat `.env` file stops being enough.
```bash
-$ LOG_LEVEL=DEBUG envstack -- python -c "import os; print(os.environ['LOG_LEVEL'])"
-DEBUG
+export ENVPATH=studio/base:show/foo:tool/nuke/14
+envstack -- nuke
```
-## Resolving Values
+## Why envstack
-To resolve an environment stack or a variable use `--resolve/-r [VAR]`.
+envstack is a lightweight CLI and Python library for composing, tracing,
+exporting, and reproducing environment variables using a PATH-like model
+called `ENVPATH`.
-```bash
-$ envstack -r ENV
-ENV=prod
-$ envstack -r DEPLOY_ROOT
-DEPLOY_ROOT=/mnt/pipe/prod
-```
+### Compose
-## Setting Values
+- Hierarchical environment layers
+- Ordered precedence and overrides
+- Cross-platform environment activation
-envstack uses bash-like variable expansion modifiers. Setting `$VAR` to a fixed
-value means `$VAR` will always use that value. Using an expansion modifier
-allows you to override the value:
+### Explain
-| Value | Description |
-|---------------------|-------------|
-| value | 'value' |
-| ${VAR:=default} | VAR = VAR or 'default' |
-| ${VAR:-default} | os.environ.get('VAR', 'default') |
-| ${VAR:?error message} | if not VAR: raise ValueError() |
+- Trace variable origins
+- Inspect unresolved values
+- Debug stack ordering and overrides
-Without the expansion modifier, values are set and do not change (but can be
-overridden by lower scope stacks, i.e. a lower scope stack file may override
-a higher one).
+### Export
-If we define `${HELLO}` like this:
+- Bake resolved environments
+- Export to shell formats
+- Reproduce environments deterministically
-```yaml
-HELLO: world
-```
+## ENVPATH
-Then the value is set and cannot be modified (except by lower scope stacks):
+`ENVPATH` defines **where** environment fragments are discovered and **in what
+order** they apply, similar to `PATH`, but for full environments.
```bash
-$ envstack -- echo {HELLO}
-world
-$ HELLO=goodbye envstack -- echo {HELLO}
-world
+export ENVPATH=prod/base:prod/show/foo:prod/tools/nuke14
+envstack -- nuke
```
-With an expansion modifier, variables have a default value and can also be
-overridden in the environment, or by higher scope stacks:
-
-```yaml
-HELLO: ${HELLO:=world}
-```
+Later entries can layer on top of earlier ones through includes, hierarchy, and
+explicit precedence rules.
-Here we show the default value, and how we can override it in the environment:
+## envstack is not
-```bash
-$ envstack -- echo {HELLO}
-world
-$ HELLO=goodbye envstack -- echo {HELLO}
-goodbye
-```
+- A dependency solver
+- A package manager
+- A virtualenv replacement
+- A build system
-## Using the command-line
+It is intentionally boring: explicit inputs, deterministic outputs, and tooling
+that tells you what it did.
-Here we can set values using the `envstack` command:
+## Install
```bash
-$ envstack --set HELLO=world
-HELLO=world
+pip install envstack
```
-We can also Base64 encode or encrypt values automatically:
-
-```bash
-$ envstack -s HELLO=world -e
-HELLO=d29ybGQ=
-```
+## Quickstart
-Add more variables (note that `$` needs to be escaped in bash or else it will
-be evaluated immediately):
+Inspect the unresolved environment:
```bash
-$ envstack -s HELLO=world VAR=\${HELLO}
-HELLO=world
-VAR=${HELLO}
+envstack -u
```
-To write out the results to an env file, use the `-o` option:
+Resolve a specific variable:
```bash
-envstack -s HELLO=world -o hello.env
+envstack -r DEPLOY_ROOT
```
-Convert existing `.env` files to envstack by piping them into envstack:
+Run a command inside the active stack:
```bash
-cat .env | envstack --set -o out.env
+envstack -- echo {DEPLOY_ROOT}
```
-## Creating Environments
-
-Several examples or starter stacks are available in the
-[examples](https://github.com/rsgalloway/envstack/tree/master/examples) folder.
-
-To create a new environment file, use `--set` to declare some variables:
+Trace where a variable comes from:
```bash
-envstack -s FOO=bar BAR=\${FOO} -o out.env
+envstack -t PATH
```
-### Secrets and encryption
-
-envstack supports optional encryption of environment values to protect sensitive
-data when serialized to disk or shared as artifacts.
-
-Encryption is explicit and reversible at activation time, and keys are supplied
-via the environment or via environment stacks.
+## Learn More
-envstack intentionally does not attempt to manage secrets beyond environment
-variable semantics.
+- [Design](design.md): mental model, hierarchy, and precedence
+- [Examples](examples.md): common patterns and stack layouts
+- [Secrets](secrets.md): encrypted values and key handling
+- [Comparison](comparison.md): how envstack differs from adjacent tools
+- [FAQ](faq.md): operational details and gotchas
+- [API](api.md): Python and CLI reference material
+- [Roadmap](roadmap.md): planned improvements and future work
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 230a882..314dd8a 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -14,8 +14,9 @@ The following items are active candidates for near-term roadmap work:
## Performance and benchmarking
-envstack does not yet have dedicated benchmark tooling or performance
-regression coverage.
+envstack now has lightweight performance regression coverage, but it does not
+yet have dedicated A/B benchmark tooling that compares base and candidate
+changes on the same runner.
This is likely worth adding soon, especially before larger changes to
environment resolution, shell startup, or subprocess wrapping.
@@ -38,3 +39,7 @@ Future benchmark work should ideally cover both:
- one-off profiling for targeted optimization work
- regression checks that help catch performance drift across releases
+
+The next benchmark milestone should be same-VM branch-vs-base comparisons,
+similar to the workflow used in `pyseq`, so we can catch smaller regressions in
+the `5-10%` range with less runner noise.
diff --git a/lib/envstack/__init__.py b/lib/envstack/__init__.py
index 4a26f98..8358f66 100644
--- a/lib/envstack/__init__.py
+++ b/lib/envstack/__init__.py
@@ -34,7 +34,6 @@
"""
__prog__ = "envstack"
-__version__ = "1.0.3"
+__version__ = "1.0.4"
-from envstack.env import clear, init, revert, save # noqa: F401
-from envstack.env import load_environ, resolve_environ # noqa: F401
+from envstack.env import clear, init, load_environ, resolve_environ, revert, save # noqa: F401
diff --git a/lib/envstack/cli.py b/lib/envstack/cli.py
index ece019d..e8050d0 100755
--- a/lib/envstack/cli.py
+++ b/lib/envstack/cli.py
@@ -42,11 +42,11 @@
from envstack import __version__, config
from envstack.env import (
- bake_environ,
Env,
+ bake_environ,
encrypt_environ,
- export_env_to_shell,
export,
+ export_env_to_shell,
load_environ,
resolve_environ,
trace_var,
@@ -394,9 +394,7 @@ def main():
if args.depth:
print("error: --depth is not valid with --resolve")
return 2
- resolved = resolve_environ(
- load_environ(args.namespace, platform=args.platform)
- )
+ resolved = resolve_environ(load_environ(args.namespace, platform=args.platform))
if args.set:
resolved.update(_parse_keyvals(args.set))
if args.encrypt:
@@ -456,9 +454,7 @@ def main():
print(source.path)
elif args.unresolved:
- env = load_environ(
- args.namespace, platform=args.platform, encrypt=args.encrypt
- )
+ env = load_environ(args.namespace, platform=args.platform, encrypt=args.encrypt)
for k, v in sorted(env.items(), key=lambda x: str(x[0])):
print(f"{k}={v}")
diff --git a/lib/envstack/encrypt.py b/lib/envstack/encrypt.py
index ac2061f..1ea3c08 100644
--- a/lib/envstack/encrypt.py
+++ b/lib/envstack/encrypt.py
@@ -53,6 +53,7 @@
Cipher = None
algorithms = None
modes = None
+default_backend = None
try:
# cryptography emits a Python 3.8 deprecation warning during import.
with warnings.catch_warnings():
@@ -62,8 +63,9 @@
category=DeprecationWarning,
module=r"cryptography(\..*)?$",
)
- from cryptography.fernet import Fernet, InvalidToken
from cryptography.exceptions import InvalidTag
+ from cryptography.fernet import Fernet, InvalidToken
+ from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
@@ -214,7 +216,7 @@ def encrypt_data(self, secret: str):
:return: Dictionary containing nonce, ciphertext, and tag.
"""
nonce = os.urandom(12) # GCM requires a 12-byte nonce
- cipher = Cipher(algorithms.AES(self.key), modes.GCM(nonce))
+ cipher = Cipher(algorithms.AES(self.key), modes.GCM(nonce), backend=default_backend())
encryptor = cipher.encryptor()
padded_data = pad_data(secret)
ciphertext = encryptor.update(padded_data) + encryptor.finalize()
@@ -235,7 +237,11 @@ def decrypt_data(self, encrypted_data: dict):
nonce = b64decode(encrypted_data["nonce"])
ciphertext = b64decode(encrypted_data["ciphertext"])
tag = b64decode(encrypted_data["tag"])
- cipher = Cipher(algorithms.AES(self.key), modes.GCM(nonce, tag))
+ cipher = Cipher(
+ algorithms.AES(self.key),
+ modes.GCM(nonce, tag),
+ backend=default_backend(),
+ )
decryptor = cipher.decryptor()
padded_data = decryptor.update(ciphertext) + decryptor.finalize()
return unpad_data(padded_data)
diff --git a/lib/envstack/env.py b/lib/envstack/env.py
index ac877eb..3067066 100644
--- a/lib/envstack/env.py
+++ b/lib/envstack/env.py
@@ -43,12 +43,7 @@
from envstack import config, logger, path, util
from envstack.exceptions import * # noqa
-from envstack.node import (
- BaseNode,
- EncryptedNode,
- custom_node_types,
- get_keys_from_env,
-)
+from envstack.node import BaseNode, EncryptedNode, custom_node_types, get_keys_from_env
# value delimiter pattern (splits values by os.pathsep)
delimiter_pattern = re.compile("(?![^{]*})[;:]+")
@@ -468,9 +463,7 @@ def _find_files(file_basename):
logger.log.warning(f"Error accessing {potential_file}")
continue
if not found_files and not ignore_missing:
- raise TemplateNotFound( # noqa
- f"{file_basename} not found in ENVPATH or scope."
- )
+ raise TemplateNotFound(f"{file_basename} not found in ENVPATH or scope.") # noqa
return found_files
def _load_file(file_basename):
@@ -787,9 +780,7 @@ def bake_environ(
return load_environ(name, scope=scope).bake(filename, depth, encrypt)
-def encrypt_environ(
- env: dict, node_class: BaseNode = EncryptedNode, encrypt: bool = True
-):
+def encrypt_environ(env: dict, node_class: BaseNode = EncryptedNode, encrypt: bool = True):
"""Encrypts all values in a given environment, returning a new environment.
Looks for encryption keys in the environment.
diff --git a/lib/envstack/node.py b/lib/envstack/node.py
index 8932c60..1b57fb6 100644
--- a/lib/envstack/node.py
+++ b/lib/envstack/node.py
@@ -306,9 +306,7 @@ def anchor_node(self, node: yaml.Node):
# set basekey for the node
if self.depth == 2:
- assert isinstance(node, yaml.ScalarNode), (
- "yaml node not a string: %s" % node
- )
+ assert isinstance(node, yaml.ScalarNode), "yaml node not a string: %s" % node
self.basekey = str(node.value)
node.value = self.basekey
diff --git a/lib/envstack/path.py b/lib/envstack/path.py
index 93d2c9c..4d2fa9b 100644
--- a/lib/envstack/path.py
+++ b/lib/envstack/path.py
@@ -189,9 +189,7 @@ def to_platform(
from_env = _load_resolved_stack(stack, platform=self.platform, scope=scope)
to_env = _load_resolved_stack(stack, platform=platform, scope=scope)
except Exception as err:
- raise InvalidPath(
- f"Failed to load stack '{stack}' for platform conversion: {err}"
- )
+ raise InvalidPath(f"Failed to load stack '{stack}' for platform conversion: {err}")
from_root = from_env.get(root_var)
to_root = to_env.get(root_var)
@@ -335,15 +333,11 @@ def extract_fields(
try:
p = Path(filepath, platform=platform)
if isinstance(template, str):
- template = get_template(
- template, stack=stack, platform=platform, scope=p.scope()
- )
+ template = get_template(template, stack=stack, platform=platform, scope=p.scope())
return template.get_fields(filepath)
except InvalidPath: # noqa
- logger.log.debug(
- "path does not match template: {0} {1}".format(template, filepath)
- )
+ logger.log.debug("path does not match template: {0} {1}".format(template, filepath))
return {}
except Exception as err:
diff --git a/lib/envstack/util.py b/lib/envstack/util.py
index cb1e1a0..572f6aa 100644
--- a/lib/envstack/util.py
+++ b/lib/envstack/util.py
@@ -45,13 +45,7 @@
import yaml
from envstack import config
-from envstack.node import (
- AESGCMNode,
- Base64Node,
- EncryptedNode,
- FernetNode,
- CustomLoader,
-)
+from envstack.node import AESGCMNode, Base64Node, CustomLoader, EncryptedNode, FernetNode
# default memoization cache timeout in seconds
CACHE_TIMEOUT = 5
@@ -63,9 +57,7 @@
drive_letter_pattern = re.compile(r"(?P[:;])?(?P[A-Z]:[/\\])")
# regular expression pattern for bash-like variable expansion
-variable_pattern = re.compile(
- r"\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([-=?])((?:\$\{[^}]+\}|[^}])*))?\}"
-)
+variable_pattern = re.compile(r"\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([-=?])((?:\$\{[^}]+\}|[^}])*))?\}")
# regular expression pattern for command substitution
cmdsub_pattern = re.compile(r"^\s*\$\((?P.*)\)\s*$", re.DOTALL)
@@ -172,9 +164,7 @@ def split_windows_paths(path_str: str):
result += [
p
for part in modified.split("|")
- for p in re.split(
- r"(? build
-rem make.bat build -> build (clean + install-to-build + prune)
-rem make.bat clean -> remove build artifacts
-rem make.bat test -> run basic envstack shell checks
-rem make.bat dryrun -> simulate installation (dist --dryrun)
-rem make.bat install -> build then dist --force --yes
-rem make.bat help -> show this help
-rem =============================================================================
-
-set "BUILD_DIR=build"
-set "ENVPATH=%CD%\env"
-set "ENVSTACK_CMD=envstack"
-
-set "TARGET=%~1"
-if "%TARGET%"=="" set "TARGET=build"
-
-if /I "%TARGET%"=="help" goto :help
-if /I "%TARGET%"=="clean" goto :clean
-if /I "%TARGET%"=="build" goto :build
-if /I "%TARGET%"=="all" goto :build
-if /I "%TARGET%"=="test" goto :test
-if /I "%TARGET%"=="dryrun" goto :dryrun
-if /I "%TARGET%"=="install" goto :install
-
-echo.
-echo Unknown target: "%TARGET%"
-echo.
-goto :help
-
-:help
-echo Targets:
-echo build - Build artifacts (pip install . -t %BUILD_DIR%)
-echo clean - Remove build artifacts (%BUILD_DIR%)
-echo test - Basic envstack command checks
-echo dryrun - Simulate installation (dist --dryrun) via envstack
-echo install - Build then install using distman (dist --force --yes)
-echo all - Alias for build
-echo help - Show this help
-echo.
-echo Notes:
-echo - ENVPATH is set to: %ENVPATH%
-echo - Requires: python/pip, envstack, and distman (for dryrun/install)
-exit /b 0
-
-:clean
-echo [clean] Removing "%BUILD_DIR%" ...
-if exist "%BUILD_DIR%" (
- rmdir /s /q "%BUILD_DIR%"
-)
-exit /b 0
-
-:build
-call "%~f0" clean
-if errorlevel 1 exit /b %errorlevel%
-
-rem Ensure build dir exists
-if not exist build mkdir build
-
-rem Install this project + its runtime deps into .\build (per pyproject.toml)
-python -m pip install --upgrade pip setuptools wheel >NUL
-echo Installing envstack into .\build ...
-python -m pip install . -t build
-if errorlevel 1 exit /b %errorlevel%
-
-rem Prune items that are not intended to ship (match Makefile)
-for %%D in (build\bin build\lib build\envstack build\__pycache__) do (
- if exist "%%D" rmdir /s /q "%%D"
-)
-
-rem Remove bdist-style dirs under build (build\bdist*)
-for /d %%D in ("build\bdist*") do (
- if exist "%%D" rmdir /s /q "%%D"
-)
-
-goto :eof
-
-:test
-echo [test] Running envstack checks...
-rem Use a one-liner so ENVPATH applies to the envstack process.
-set "CMD=set ENVPATH=%ENVPATH% ^&^& %ENVSTACK_CMD% -- dir"
-cmd /c "%CMD%"
-if errorlevel 1 exit /b 1
-
-set "CMD=set ENVPATH=%ENVPATH% ^&^& %ENVSTACK_CMD% -- where python"
-cmd /c "%CMD%"
-if errorlevel 1 exit /b 1
-
-exit /b 0
-
-:dryrun
-echo [dryrun] Simulating install via dist --dryrun...
-set "CMD=set ENVPATH=%ENVPATH% ^&^& %ENVSTACK_CMD% -- dist --dryrun"
-cmd /c "%CMD%"
-exit /b %errorlevel%
-
-:install
-call :build || exit /b 1
-echo [install] Installing via distman (dist --force --yes)...
-dist --force --yes
-exit /b %errorlevel%
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 21bfd2d..f283adc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "envstack"
-version = "1.0.3"
+version = "1.0.4"
description = "Environment variable composition layer for tools and processes."
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.6"
@@ -56,8 +56,33 @@ Documentation = "https://github.com/rsgalloway/envstack/tree/master/docs"
envstack = "envstack.cli:main"
whichenv = "envstack.cli:whichenv"
+[project.optional-dependencies]
+dev = [
+ "pytest",
+ "flake8==7.1.1",
+ "mccabe==0.7.0",
+ "isort==5.13.2",
+ "black==24.8.0",
+]
+
[tool.setuptools]
zip-safe = false
[tool.setuptools.packages.find]
where = ["lib"]
+
+[tool.isort]
+profile = "black"
+line_length = 100
+skip_gitignore = true
+
+[tool.black]
+line-length = 100
+target-version = ["py38"]
+
+[tool.pytest.ini_options]
+markers = [
+ "integration: tests that run real subprocesses (slower / OS-dependent)",
+]
+testpaths = ["tests"]
+norecursedirs = ["tmp"]
diff --git a/pytest.ini b/pytest.ini
deleted file mode 100644
index 4d7cb7b..0000000
--- a/pytest.ini
+++ /dev/null
@@ -1,5 +0,0 @@
-[pytest]
-markers =
- integration: tests that run real subprocesses (slower / OS-dependent)
-testpaths = tests
-norecursedirs = tmp
diff --git a/scripts/build_pages_site.py b/scripts/build_pages_site.py
new file mode 100644
index 0000000..304a162
--- /dev/null
+++ b/scripts/build_pages_site.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+#
+# Copyright (c) 2024-2026, Ryan Galloway (ryan@rsgalloway.com)
+#
+
+"""Build a simple Jekyll-friendly docs site from repository markdown files."""
+
+import argparse
+import re
+import shutil
+from pathlib import Path
+
+LINK_PATTERNS = (
+ (r"\(README\.md\)", "(index.html)"),
+ (r"\(docs/index\.md\)", "(docs/index.html)"),
+ (r"\(docs/([^)]+)\.md\)", r"(docs/\1.html)"),
+ (r"\(([^:)#]+)\.md\)", r"(\1.html)"),
+)
+
+
+def rewrite_links(content: str) -> str:
+ """Rewrite local markdown links for generated HTML output."""
+ updated = content
+ for pattern, replacement in LINK_PATTERNS:
+ updated = re.sub(pattern, replacement, updated)
+ return updated
+
+
+def extract_title(content: str, fallback: str) -> str:
+ """Extract the first markdown H1 title or use a fallback."""
+ for line in content.splitlines():
+ if line.startswith("# "):
+ return line[2:].strip()
+ return fallback
+
+
+def wrap_markdown(content: str, title: str) -> str:
+ """Add minimal Jekyll front matter to markdown content."""
+ return f"---\nlayout: default\ntitle: {title}\n---\n\n{content}"
+
+
+def write_markdown_page(src: Path, dst: Path, fallback_title: str):
+ """Copy a markdown file into the site tree with front matter and fixed links."""
+ content = src.read_text(encoding="utf-8")
+ title = extract_title(content, fallback_title)
+ content = rewrite_links(content)
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ dst.write_text(wrap_markdown(content, title), encoding="utf-8")
+
+
+def write_site_config(output_dir: Path):
+ """Write a minimal Jekyll config file."""
+ config = """title: envstack
+description: Environment variable composition and activation layer
+theme: minima
+markdown: kramdown
+permalink: pretty
+"""
+ (output_dir / "_config.yml").write_text(config, encoding="utf-8")
+
+
+def build_site(args):
+ repo_root = Path(args.repo_root).resolve()
+ output_dir = Path(args.output).resolve()
+
+ if output_dir.exists():
+ shutil.rmtree(output_dir)
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ write_site_config(output_dir)
+
+ docs_dir = repo_root / "docs"
+ for src in docs_dir.glob("*.md"):
+ if src.name == "index.md":
+ dst = output_dir / "index.md"
+ fallback = "envstack"
+ else:
+ dst = output_dir / "docs" / src.name
+ fallback = src.stem.replace("-", " ").title()
+ write_markdown_page(src, dst, fallback)
+
+ write_markdown_page(repo_root / "README.md", output_dir / "docs" / "readme.md", "README")
+
+ if (docs_dir / "assets").exists():
+ shutil.copytree(docs_dir / "assets", output_dir / "assets")
+ shutil.copytree(docs_dir / "assets", output_dir / "docs" / "assets")
+
+ cname = repo_root / "CNAME"
+ if cname.exists():
+ shutil.copy2(cname, output_dir / "CNAME")
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--repo-root", default=".")
+ parser.add_argument("--output", required=True)
+ args = parser.parse_args()
+ build_site(args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..c51349f
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,8 @@
+import os
+import sys
+
+ROOT = os.path.dirname(os.path.dirname(__file__))
+LIB = os.path.join(ROOT, "lib")
+
+if LIB not in sys.path:
+ sys.path.insert(0, LIB)
diff --git a/tests/helpers.py b/tests/helpers.py
new file mode 100644
index 0000000..e93ff95
--- /dev/null
+++ b/tests/helpers.py
@@ -0,0 +1,50 @@
+import os
+import shutil
+import tempfile
+
+envpath = os.path.join(os.path.dirname(__file__), "fixtures", "env")
+
+
+def create_test_root():
+ """Creates a temporary directory with the contents of the "env" folder."""
+ root = tempfile.mkdtemp()
+
+ for env in ("prod", "dev"):
+ shutil.copytree(envpath, os.path.join(root, env, "env"))
+
+ return root
+
+
+def update_env_file(file_path: str, key: str, value: str):
+ """Updates a key in a YAML file with a new value."""
+ import yaml
+
+ with open(file_path, "r") as f:
+ data = yaml.safe_load(f)
+
+ for _, env_config in data.items():
+ if isinstance(env_config, dict) and key in env_config:
+ env_config[key] = value
+
+ with open(file_path, "w") as f:
+ yaml.safe_dump(data, f, sort_keys=False)
+
+
+def create_fixture_env_root():
+ """Create a temporary envstack root populated from test fixtures."""
+ root = tempfile.mkdtemp()
+ prod_env = os.path.join(root, "prod", "env")
+ dev_env = os.path.join(root, "dev", "env")
+
+ os.makedirs(prod_env)
+ os.makedirs(dev_env)
+
+ shutil.copy2(os.path.join(envpath, "default.env"), prod_env)
+ shutil.copy2(os.path.join(envpath, "test.env"), prod_env)
+ shutil.copy2(os.path.join(envpath, "keys.env"), prod_env)
+ shutil.copy2(os.path.join(envpath, "secrets.env"), prod_env)
+ shutil.copy2(os.path.join(envpath, "project.env"), prod_env)
+ shutil.copy2(os.path.join(envpath, "hello.env"), prod_env)
+ shutil.copy2(os.path.join(envpath, "dev.env"), dev_env)
+
+ return root
diff --git a/tests/test_cmds.py b/tests/test_cmds.py
index e5af4f7..d6d65bf 100644
--- a/tests/test_cmds.py
+++ b/tests/test_cmds.py
@@ -35,18 +35,18 @@
import os
import platform
-import pytest
import shutil
import subprocess
import sys
import tempfile
import unittest
+import pytest
+from helpers import create_test_root, update_env_file
+
import envstack
from envstack.encrypt import AESGCMEncryptor, FernetEncryptor
-from test_env import create_test_root, update_env_file
-
pytestmark = pytest.mark.skipif(
sys.platform != "linux",
reason="Linux-only shell integration tests (bash/ls/PS1, paths, env layout)",
@@ -99,9 +99,7 @@ def test_default(self):
"""
% self.root
)
- output = subprocess.check_output(
- self.envstack_bin, shell=True, universal_newlines=True
- )
+ output = subprocess.check_output(self.envstack_bin, shell=True, universal_newlines=True)
self.assertEqual(output, expected_output)
def test_dev(self):
@@ -231,7 +229,7 @@ def test_default_command_echo(self):
def test_hello_command_echo(self):
"""Tests that resolved and encrypted values resolve in subprocesses."""
- expected_output = f"""goodbye\n"""
+ expected_output = "goodbye\n"
command = "%s thing -r HELLO -e -- echo {HELLO}" % self.envstack_bin
output = subprocess.check_output(command, shell=True, universal_newlines=True)
self.assertEqual(output, expected_output)
@@ -310,8 +308,7 @@ def test_project(self):
STACK=project
"""
command = (
- "ENV=blah ROOT=/var/tmp %s project -r DEPLOY_ROOT HELLO ROOT STACK"
- % self.envstack_bin
+ "ENV=blah ROOT=/var/tmp %s project -r DEPLOY_ROOT HELLO ROOT STACK" % self.envstack_bin
)
output = subprocess.check_output(command, shell=True, universal_newlines=True)
self.assertEqual(output, expected_output)
@@ -426,9 +423,7 @@ def test_dev(self):
def test_thing(self):
"""Tests baking the thing stack with depth of 1."""
- command = make_command(
- self.envstack_bin, self.filename, "thing", "--depth", "1"
- )
+ command = make_command(self.envstack_bin, self.filename, "thing", "--depth", "1")
expected_output = r"""#!/usr/bin/env envstack
include: [default]
all: &all
diff --git a/tests/test_encrypt.py b/tests/test_encrypt.py
index d2e6a84..51d2edc 100644
--- a/tests/test_encrypt.py
+++ b/tests/test_encrypt.py
@@ -34,13 +34,8 @@
"""
import unittest
-from unittest.mock import patch
-from envstack.encrypt import (
- AESGCMEncryptor,
- Base64Encryptor,
- FernetEncryptor,
-)
+from envstack.encrypt import AESGCMEncryptor, Base64Encryptor, FernetEncryptor
class TestBase64Encryptor(unittest.TestCase):
diff --git a/tests/test_env.py b/tests/test_env.py
index 91e3b34..37f3bc6 100644
--- a/tests/test_env.py
+++ b/tests/test_env.py
@@ -37,46 +37,18 @@
import shutil
import sys
import unittest
-import tempfile
+
+from helpers import create_test_root, update_env_file
import envstack
-from envstack.env import Env, EnvVar, Scope, Source
from envstack.encrypt import AESGCMEncryptor, FernetEncryptor
+from envstack.env import Env, EnvVar, Scope, Source
from envstack.util import dict_diff
# path to the env directory
envpath = os.path.join(os.path.dirname(__file__), "fixtures", "env")
-def create_test_root():
- """Creates a temporary directory with the contents of the "env" folder."""
-
- # create a temporary directory
- root = tempfile.mkdtemp()
-
- for env in ("prod", "dev"):
- shutil.copytree(envpath, os.path.join(root, env, "env"))
-
- return root
-
-
-def update_env_file(file_path: str, key: str, value: str):
- """Updates a key in a YAML file with a new value."""
- import yaml
-
- # read the YAML file
- with open(file_path, "r") as f:
- data = yaml.safe_load(f)
-
- for _, env_config in data.items():
- if isinstance(env_config, dict) and key in env_config:
- env_config[key] = value
-
- # write the modified data back to the file
- with open(file_path, "w") as f:
- yaml.safe_dump(data, f, sort_keys=False)
-
-
class TestEnvVar(unittest.TestCase):
def test_init(self):
v = EnvVar("$FOO:${BAR}")
@@ -382,7 +354,7 @@ def test_custom(self):
os.environ["CUSTOM"] = "/var/tmp"
env = {"FOO": "${CUSTOM}/foo"}
resolved = resolve_environ(env)
- self.assertEqual(resolved["FOO"], f"/var/tmp/foo")
+ self.assertEqual(resolved["FOO"], "/var/tmp/foo")
def test_simple(self):
"""Tests resolving a simple environment."""
@@ -738,7 +710,7 @@ def tearDown(self):
def encrypt_environ(self, stack_name):
"""Tests load_environ with encryption (Base64 only)."""
- from envstack.env import load_environ, encrypt_environ
+ from envstack.env import encrypt_environ, load_environ
from envstack.node import EncryptedNode
env = load_environ(stack_name)
@@ -817,8 +789,8 @@ def bake_encrypted_environ(self, stack_name):
def resolve_encrypted_environ(self, stack_name):
"""Tests resolve_environ with encrypted environ."""
- from envstack.env import encrypt_environ, load_environ, resolve_environ
from envstack.encrypt import AESGCMEncryptor, FernetEncryptor
+ from envstack.env import encrypt_environ, load_environ, resolve_environ
env = load_environ(stack_name) # unresolved values
resolved = resolve_environ(env) # resolved values
@@ -839,18 +811,14 @@ def resolve_encrypted_environ(self, stack_name):
continue
encrypted_value = encrypted[key] # encrypted value
resolved_value = resolved[key] # resolved value
- encrypted_resolved_value = encrypted_resolved[
- key
- ] # resolved encrypted value
+ encrypted_resolved_value = encrypted_resolved[key] # resolved encrypted value
self.assertNotEqual(encrypted_value, None)
self.assertNotEqual(resolved_value, None)
self.assertNotEqual(encrypted_resolved_value, None)
self.assertNotEqual(encrypted_value, value)
self.assertNotEqual(encrypted_value, resolved_value)
# self.assertEqual(resolved_value, encrypted_resolved_value)
- if isinstance(resolved_value, str) and isinstance(
- encrypted_resolved_value, str
- ):
+ if isinstance(resolved_value, str) and isinstance(encrypted_resolved_value, str):
self.assertTrue(
resolved_value.startswith(encrypted_resolved_value),
f"{encrypted_resolved_value} not prefix of {resolved_value}",
@@ -1040,7 +1008,7 @@ def test_issue_55(self):
The STACK name and the test env file name should be the same.
"""
- from envstack.env import load_environ, Env
+ from envstack.env import Env, load_environ
# update default.env to point to test root
default_env_file = os.path.join(self.root, "prod", "env", "default.env")
@@ -1102,7 +1070,7 @@ def test_issue_58(self):
BAZ: ${BAZ}
NUM: ${NUM:=0}
"""
- from envstack.env import load_environ, resolve_environ, Source
+ from envstack.env import Source, load_environ, resolve_environ
# create grandparent.env that sets a value for FOO
grandparent = {
diff --git a/tests/test_node.py b/tests/test_node.py
index 8e17969..8707d8b 100644
--- a/tests/test_node.py
+++ b/tests/test_node.py
@@ -121,9 +121,7 @@ def test_resolve_fail(self):
os.environ[AESGCMEncryptor.KEY_VAR_NAME] = key1
value = "super_secret_password"
encrypted = AESGCMEncryptor().encrypt(value)
- node = EncryptedNode.from_yaml(
- None, yaml.ScalarNode(EncryptedNode.yaml_tag, encrypted)
- )
+ node = EncryptedNode.from_yaml(None, yaml.ScalarNode(EncryptedNode.yaml_tag, encrypted))
key2 = AESGCMEncryptor.generate_key()
os.environ[AESGCMEncryptor.KEY_VAR_NAME] = key2
resolved = node.resolve()
@@ -142,9 +140,7 @@ def test_resolve_success(self):
os.environ[AESGCMEncryptor.KEY_VAR_NAME] = key
value = "super_secret_password"
encrypted = AESGCMEncryptor().encrypt(value)
- node = EncryptedNode.from_yaml(
- None, yaml.ScalarNode(EncryptedNode.yaml_tag, encrypted)
- )
+ node = EncryptedNode.from_yaml(None, yaml.ScalarNode(EncryptedNode.yaml_tag, encrypted))
self.assertEqual(node.value, encrypted)
resolved = node.resolve()
self.assertEqual(resolved, value)
@@ -275,12 +271,8 @@ class TestSecretsEnv(unittest.TestCase):
def setUp(self):
"""set up the test environment"""
self.root = tempfile.mkdtemp()
- os.environ[AESGCMEncryptor.KEY_VAR_NAME] = (
- "jHLNsFrhs9JsjuPkNhYX5ubwLpId2ZSxcFXAkHyMjOU="
- )
- os.environ[FernetEncryptor.KEY_VAR_NAME] = (
- "v4-Ry7uKSOBEXMDv9x_crBBpi0eo2WCYNAIlSB1t4VE="
- )
+ os.environ[AESGCMEncryptor.KEY_VAR_NAME] = "jHLNsFrhs9JsjuPkNhYX5ubwLpId2ZSxcFXAkHyMjOU="
+ os.environ[FernetEncryptor.KEY_VAR_NAME] = "v4-Ry7uKSOBEXMDv9x_crBBpi0eo2WCYNAIlSB1t4VE="
def tearDown(self):
"""tear down the test environment"""
diff --git a/tests/test_path.py b/tests/test_path.py
index 439ec8e..7790035 100644
--- a/tests/test_path.py
+++ b/tests/test_path.py
@@ -134,9 +134,7 @@ def test_match_template_picks_no_expansion(self):
SEQDIR="${ROOT}/projects/{seq}",
)
with patch("envstack.path._load_resolved_stack", return_value=env):
- t = match_template(
- "${ROOT}/projects/aa", stack="fps", scope="/tmp", expand=False
- )
+ t = match_template("${ROOT}/projects/aa", stack="fps", scope="/tmp", expand=False)
self.assertIsNotNone(t)
self.assertEqual(str(t), "${ROOT}/projects/{seq}")
diff --git a/tests/test_performance.py b/tests/test_performance.py
new file mode 100644
index 0000000..b49e013
--- /dev/null
+++ b/tests/test_performance.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+#
+# Copyright (c) 2024-2026, Ryan Galloway (ryan@rsgalloway.com)
+#
+
+"""Coarse performance regression tests for envstack."""
+
+import os
+import shutil
+import time
+import unittest
+
+from helpers import create_fixture_env_root
+
+from envstack import util
+from envstack.encrypt import AESGCMEncryptor, FernetEncryptor
+from envstack.env import encrypt_environ, load_environ, resolve_environ
+
+
+class PerformanceTests(unittest.TestCase):
+ """Coarse-grained performance guardrails for envstack hotspots."""
+
+ def setUp(self):
+ self.root = create_fixture_env_root()
+ self.old_env = os.environ.copy()
+ self.old_cache_timeout = util.CACHE_TIMEOUT
+
+ os.environ["ROOT"] = self.root
+ os.environ["ENVPATH"] = os.pathsep.join(
+ [
+ os.path.join(self.root, "prod", "env"),
+ os.path.join(self.root, "dev", "env"),
+ ]
+ )
+ os.environ[AESGCMEncryptor.KEY_VAR_NAME] = "jHLNsFrhs9JsjuPkNhYX5ubwLpId2ZSxcFXAkHyMjOU="
+ os.environ[FernetEncryptor.KEY_VAR_NAME] = "v4-Ry7uKSOBEXMDv9x_crBBpi0eo2WCYNAIlSB1t4VE="
+
+ # Keep memoized paths warm and stable across the repeated calls below.
+ util.CACHE_TIMEOUT = 60
+
+ def tearDown(self):
+ os.environ.clear()
+ os.environ.update(self.old_env)
+ util.CACHE_TIMEOUT = self.old_cache_timeout
+ shutil.rmtree(self.root)
+
+ def test_load_environ_repeated_dev_stack_stays_fast(self):
+ """Repeated stack loading should remain comfortably below startup-budget scale."""
+ start = time.perf_counter()
+ last = None
+ for _ in range(200):
+ last = load_environ("dev", scope=self.root)
+ elapsed = time.perf_counter() - start
+
+ self.assertIsNotNone(last)
+ self.assertEqual(last["ENV"], "dev")
+ self.assertEqual(last["STACK"], "dev")
+ self.assertLess(elapsed, 1.0)
+
+ def test_resolve_environ_large_modifier_graph_stays_fast(self):
+ """Large chained substitutions should not regress catastrophically."""
+ env = {"ROOT": self.root, "PATH": "/usr/bin", "ENVPATH": os.environ["ENVPATH"]}
+ for i in range(500):
+ env[f"VAR_{i}"] = "${ROOT}/show/pkg/%d:${PATH}" % i
+
+ start = time.perf_counter()
+ resolved = resolve_environ(env)
+ elapsed = time.perf_counter() - start
+
+ self.assertTrue(resolved["VAR_499"].startswith(self.root))
+ self.assertIn("/pkg/499", resolved["VAR_499"])
+ self.assertLess(elapsed, 1.0)
+
+ def test_encrypted_stack_load_and_resolve_stays_fast(self):
+ """Encrypted stack resolution should stay well within a coarse CI budget."""
+ start = time.perf_counter()
+ last = None
+ for _ in range(100):
+ last = resolve_environ(load_environ("secrets", scope=self.root, encrypt=True))
+ elapsed = time.perf_counter() - start
+
+ self.assertIsNotNone(last)
+ self.assertEqual(last["KEY"], "This is encrypted")
+ self.assertEqual(last["SECRET"], "my_super_secret_password")
+ self.assertLess(elapsed, 1.5)
+
+ def test_encrypt_environ_medium_payload_stays_fast(self):
+ """Bulk encryption of a moderate environment should remain snappy."""
+ env = load_environ("dev", scope=self.root)
+ for i in range(200):
+ env[f"EXTRA_{i}"] = "${DEPLOY_ROOT}/lib/%d:${PATH}" % i
+
+ start = time.perf_counter()
+ encrypted = encrypt_environ(env)
+ elapsed = time.perf_counter() - start
+
+ self.assertEqual(encrypted["ENV"].resolve(env=os.environ), "dev")
+ self.assertLess(elapsed, 1.0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_util.py b/tests/test_util.py
index e29f178..97d5f10 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -39,14 +39,14 @@
from envstack import config, util
from envstack.util import (
- null,
- encode,
- evaluate_command,
- evaluate_modifiers,
dedupe_list,
dedupe_paths,
detect_path,
+ encode,
+ evaluate_command,
+ evaluate_modifiers,
get_stack_name,
+ null,
partition_platform_data,
safe_eval,
split_paths,
@@ -443,6 +443,7 @@ def test_unquote_strings(self):
'<<': '*all'
"""
import tempfile
+
from envstack.util import unquote_strings
with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file:
@@ -586,9 +587,7 @@ def test_dedupe_paths(self):
"/some/other/path",
]
result = dedupe_paths(":".join(paths))
- expected_result = os.pathsep.join(
- ["/usr/bin", "/usr/local/bin", "/some/other/path"]
- )
+ expected_result = os.pathsep.join(["/usr/bin", "/usr/local/bin", "/some/other/path"])
self.assertEqual(result, expected_result)
paths = ["/usr/bin"]
@@ -624,9 +623,7 @@ def test_dedupe_paths_windows(self):
]
path = ":".join(paths)
result = dedupe_paths(path, platform="windows")
- self.assertEqual(
- result, "C:\\Program Files\\Python;D:/path2;E:/path3;/usr/local/bin"
- )
+ self.assertEqual(result, "C:\\Program Files\\Python;D:/path2;E:/path3;/usr/local/bin")
# mixed paths
path = "//tools/pipe/prod/env;//tools/pipe/prod/env;/home/user/envstack/env"
@@ -634,13 +631,9 @@ def test_dedupe_paths_windows(self):
self.assertEqual(result, "//tools/pipe/prod/env;/home/user/envstack/env")
# mixed paths with duplicate
- path = (
- "C:\\Program Files\\Python;D:/path2;E:/path3;/usr/local/bin:/usr/local/bin"
- )
+ path = "C:\\Program Files\\Python;D:/path2;E:/path3;/usr/local/bin:/usr/local/bin"
result = dedupe_paths(path, platform="windows")
- self.assertEqual(
- result, "C:\\Program Files\\Python;D:/path2;E:/path3;/usr/local/bin"
- )
+ self.assertEqual(result, "C:\\Program Files\\Python;D:/path2;E:/path3;/usr/local/bin")
class TestSafeEval(unittest.TestCase):
diff --git a/tests/test_wrapper.py b/tests/test_wrapper.py
index f0c33dd..30cd25b 100644
--- a/tests/test_wrapper.py
+++ b/tests/test_wrapper.py
@@ -35,12 +35,12 @@
import os
import sys
-import pytest
from types import SimpleNamespace
-import envstack.wrapper as wrapper_mod
-from envstack.wrapper import Wrapper, CommandWrapper, run_command, capture_output
+import pytest
+import envstack.wrapper as wrapper_mod
+from envstack.wrapper import CommandWrapper, Wrapper, capture_output, run_command
IS_WINDOWS = sys.platform.startswith("win")
@@ -78,9 +78,7 @@ def test_wrapper_shell_true_allows_command_string(stub_env, capfd):
rc = w.launch()
out, err = capfd.readouterr()
assert rc == 0
- assert out.strip() == (
- "C:\\tmp\\envstack-root" if IS_WINDOWS else "/tmp/envstack-root"
- )
+ assert out.strip() == ("C:\\tmp\\envstack-root" if IS_WINDOWS else "/tmp/envstack-root")
def test_commandwrapper_runs_argv_without_shell(stub_env, capfd):