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 logo +

+ 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 ...]] -``` +

+ envstack logo +

-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):