Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
73e753c
minor updates to cache env file
rsgalloway Jan 7, 2026
d7e614d
bump version to 0.9.5
rsgalloway Jan 7, 2026
a8dc4ad
adds github workflow for running tests
rsgalloway Jan 7, 2026
914dcba
wrapper test should work on windows, do not run cmd tests on windows
rsgalloway Jan 7, 2026
2e74331
make sure pytest is installed
rsgalloway Jan 7, 2026
dd0e390
updates resolve_encrypted_environ assertions
rsgalloway Jan 7, 2026
4a2dd5c
skip cmd tests if not on linux
rsgalloway Jan 7, 2026
6cab0ed
update win paths in tests to match env files
rsgalloway Jan 7, 2026
b6f2d22
stop changing the path sep on the root path in the env test
rsgalloway Jan 7, 2026
7d59647
only support CAPITALIZED drive letters on windows to avoid path split…
rsgalloway Jan 7, 2026
7c52f6f
disable lowercase drive letter tests
rsgalloway Jan 7, 2026
4f46ba2
fix for windows
rsgalloway Jan 7, 2026
4993472
fixes for cmd wrapper
rsgalloway Jan 7, 2026
8c94df2
fix argv expansion and cmd wrapper
rsgalloway Jan 7, 2026
14e235c
conditional fixes in run_command for windows
rsgalloway Jan 7, 2026
5aea7d9
black formatting
rsgalloway Jan 7, 2026
88232a6
adds make.bat file, smoke test on windows, disable fail-fast tests
rsgalloway Jan 7, 2026
b3ce259
fix for workflow file
rsgalloway Jan 7, 2026
31dce43
updates to envstack banner for exit hints
rsgalloway Jan 7, 2026
8506f4c
support --quiet in env shell
rsgalloway Jan 7, 2026
fc8bbcb
updates test comments, and make test target commands
rsgalloway Jan 8, 2026
fee8aa0
minor readme updates
rsgalloway Jan 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: tests

on: [push, pull_request]

jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: python -m pip install --upgrade pip
- run: pip install pytest
- run: pip install -e .
- run: pytest tests -v
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,22 @@ found in `prod`.

#### Basic Usage

Running `envstack` will launch a new shell session with the resolved default
environment:
Running `envstack` will launch a new shell session with a resolved environment:

```shell
$ envstack
🚀 Launching envstack shell... CTRL+D to exit
🚀 Launching envstack shell... (CTRL+D or "exit" to quit)
(prod) ~/envstack$
```

Loading the `dev` environment (as defined in the `dev.env` file):

```shell
$ envstack dev
🚀 Launching envstack shell... (CTRL+D or "exit" to quit)
(dev) ~/dev/envstack$
```

Using the `-u` command will show you the default, unresolved environment
stack, defined in `default.env` files in `${ENVPATH}`:

Expand Down Expand Up @@ -614,5 +621,5 @@ Make sure you don't have any local .env files that may intefere with the unit
tests.

```bash
$ pytest tests -s
$ pytest tests -v
```
13 changes: 7 additions & 6 deletions env/cache.env
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
#!/usr/bin/env envstack
include: [default]
all: &all
ENVPATH: ${CACHE_ROOT}/env:${DEPLOY_ROOT}/env:${ENVPATH}
PATH: ${CACHE_ROOT}/bin:${DEPLOY_ROOT}/bin:${PATH}
PYTHONPATH: ${CACHE_ROOT}/lib/python:${DEPLOY_ROOT}/lib/python:${PYTHONPATH}
ENVPATH: ${CACHE_ROOT}/env:${ENVPATH}
PATH: ${CACHE_ROOT}/bin:${PATH}
PYTHONPATH: ${CACHE_ROOT}/lib/python:${PYTHONPATH}
CACHE_ROOT: ${CACHE_DIR}/${ENV}
Comment thread
rsgalloway marked this conversation as resolved.
darwin:
<<: *all
CACHE_ROOT: ${HOME}/Library/Caches/pipe/${ENV}
CACHE_DIR: ${HOME}/Library/Caches/pipe
linux:
<<: *all
CACHE_ROOT: ${HOME}/.cache/pipe/${ENV}
CACHE_DIR: ${HOME}/.cache/pipe
windows:
<<: *all
CACHE_ROOT: ${USERPROFILE}/AppData/Local/pipe/cache/${ENV}
CACHE_DIR: ${USERPROFILE}/AppData/Local/pipe
2 changes: 1 addition & 1 deletion lib/envstack/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,6 @@
"""

__prog__ = "envstack"
__version__ = "0.9.4"
__version__ = "0.9.5"

from envstack.env import clear, init, revert, save # noqa: F401
20 changes: 16 additions & 4 deletions lib/envstack/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"""

import argparse
import os
import re
import sys
import traceback
Expand All @@ -50,6 +51,7 @@
resolve_environ,
trace_var,
)
from envstack.envshell import EnvshellWrapper
from envstack.logger import setup_stream_handler
from envstack.wrapper import run_command

Expand Down Expand Up @@ -260,11 +262,21 @@ def parse_args():
return args, args_after_dash


def envshell(namespace: List[str] = None):
def envshell(namespace: List[str] = None, quiet: bool = False):
"""Run a shell in the given environment stack."""
from .envshell import EnvshellWrapper

print("\U0001F680 Launching envstack shell... CTRL+D to exit")
if not quiet:
shell_name = os.path.basename(config.SHELL).lower()

if shell_name in ("cmd", "cmd.exe"):
exit_hint = 'type "exit" to quit'
elif shell_name in ("powershell", "pwsh"):
exit_hint = 'type "exit" to quit'
else:
# bash, zsh, sh, etc.
exit_hint = 'CTRL+D or "exit" to quit'

print(f"\U0001F680 Launching envstack shell... ({exit_hint})")

name = (namespace or [config.DEFAULT_NAMESPACE])[:]
shell = EnvshellWrapper(name)
Expand Down Expand Up @@ -451,7 +463,7 @@ def main():
print(f"{k}={v}")

else:
return envshell(args.namespace)
return envshell(args.namespace, quiet=args.quiet)

except KeyboardInterrupt:
print("Stopping...")
Expand Down
6 changes: 3 additions & 3 deletions lib/envstack/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
null = ""

# regular expression pattern for matching windows drive letters
drive_letter_pattern = re.compile(r"(?P<sep>[:;])?(?P<drive>[a-zA-Z]:[/\\])")
drive_letter_pattern = re.compile(r"(?P<sep>[:;])?(?P<drive>[A-Z]:[/\\])")

# regular expression pattern for bash-like variable expansion
variable_pattern = re.compile(
Expand Down Expand Up @@ -157,14 +157,14 @@ def split_windows_paths(path_str: str):

for token in tokens:
# windows-style path token; insert a marker before drive letters
if re.match(r"^[a-zA-Z]:[/\\]", token) or "\\" in token:
if re.match(r"^[A-Z]:[/\\]", token) or "\\" in token:
modified = drive_letter_pattern.sub(lambda m: "|" + m.group("drive"), token)
# split on the marker, then on colons that are not in drive-letters
result += [
p
for part in modified.split("|")
for p in re.split(
r"(?<![a-zA-Z]):", part
r"(?<![A-Z]):", part
) # capture colons not in drive letters
if p
]
Expand Down
59 changes: 20 additions & 39 deletions lib/envstack/wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ def executable(self):

tool.log = MyLogger()
"""

shell: bool = False

def __init__(self, namespace, args=[]):
Expand Down Expand Up @@ -209,34 +210,30 @@ def executable(self):


class CmdWrapper(CommandWrapper):
"""Wrapper class for running wrapped commands in command prompt."""
"""Wrapper class for running wrapped commands in Windows cmd.exe."""

def __init__(self, namespace=config.DEFAULT_NAMESPACE, args=[]):
"""
Initializes the command wrapper with the given namespace and args,
replacing the original command with the shell command, e.g.:

>>> cmd = CmdWrapper(stack, ['dir'])
>>> print(cmd.executable())
cmd
>>> print(cmd.args)
['/c', 'dir']
super().__init__(namespace, args)

:param namespace: environment stack name (default: 'default').
:param args: command and arguments as a list.
"""
super(CmdWrapper, self).__init__(namespace, args)
self.args = ["/c", self.cmd]
# Always run through cmd.exe explicitly
self.shell = False
self._cmd_exe = config.SHELL # expected: "cmd" or "cmd.exe"

def get_subprocess_args(self, cmd):
"""Returns the arguments to be passed to the subprocess."""
return [cmd] + self.args
# Join the intended argv into one command-line string for /c
cmdline = shell_join(self.cmd)

# cmd.exe /c <command>
self._subprocess_argv = [self._cmd_exe, "/c", cmdline]

def executable(self):
"""Returns the shell command to run the original command."""
self.cmd = config.SHELL
return self.cmd
return self._cmd_exe

def get_subprocess_args(self, cmd):
# Not used (we override get_subprocess_command)
return []

def get_subprocess_command(self, env):
return list(self._subprocess_argv)


def run_command(command: str, namespace: str = config.DEFAULT_NAMESPACE):
Expand All @@ -254,39 +251,23 @@ def run_command(command: str, namespace: str = config.DEFAULT_NAMESPACE):

:param command: command to run as a list of arguments.
:param namespace: environment stack name (default: 'default').
:param interactive: run the command in an interactive shell (default: True).
:returns: command exit code
"""
Comment thread
rsgalloway marked this conversation as resolved.
logger.setup_stream_handler()
shellname = os.path.basename(config.SHELL)

# normalize to argv list
shellname = os.path.basename(config.SHELL).lower()
argv = list(command) if isinstance(command, (list, tuple)) else to_args(command)

needs_shell = any(re.search(r"\{(\w+)\}", a) for a in argv)
if needs_shell:
expr_argv = [re.sub(r"\{(\w+)\}", r"${\1}", a) for a in argv]
expr = shell_join(expr_argv)
return ShellWrapper(namespace, expr).launch()

if shellname in ["bash", "sh", "zsh"]:
# 1) if user explicitly invoked a shell (bash/sh/zsh), do not wrap again
if argv and os.path.basename(argv[0]) in ["bash", "sh", "zsh"]:
return CommandWrapper(namespace, argv).launch()

# 2) if command contains {VARS}, convert to ${VARS} and run as a shell expression
needs_shell = any(re.search(r"\{(\w+)\}", a) for a in argv)
if needs_shell:
expr_argv = [re.sub(r"\{(\w+)\}", r"${\1}", a) for a in argv]
expr = shell_join(expr_argv)
return ShellWrapper(namespace, expr).launch()

# 3) otherwise run direct argv (best behavior)
return CommandWrapper(namespace, argv).launch()

if shellname in ["cmd"]:
# windows behavior preserved (if you need it)
expr = re.sub(r"\{(\w+)\}", r"%\1%", " ".join(argv))
expr = [re.sub(r"\{(\w+)\}", r"%\1%", a) for a in argv]
return CmdWrapper(namespace, expr).launch()

return CommandWrapper(namespace, argv).launch()
89 changes: 89 additions & 0 deletions make.bat
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion

rem =============================================================================
rem Project: EnvStack - Environment Variable Management
rem make.bat - Windows-friendly Makefile target runner
rem
rem Usage:
rem make.bat -> build
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 -r requirements.txt -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 :clean || exit /b 1
echo [build] Installing requirements into "%BUILD_DIR%" ...
python -m pip install -r requirements.txt -t "%BUILD_DIR%"
if errorlevel 1 exit /b 1
exit /b 0

: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%
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@

setup(
name="envstack",
version="0.9.4",
version="0.9.5",
description="Stacked environment variable management system",
long_description=long_description,
long_description_content_type="text/markdown",
Expand Down
5 changes: 5 additions & 0 deletions tests/test_cmds.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

import os
import platform
import pytest
import shutil
import subprocess
import sys
Expand All @@ -46,6 +47,10 @@

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)",
)

def make_command(envstack_bin: str, filename: str, *args: str):
"""
Expand Down
Loading