Skip to content

Commit 8a2b8b3

Browse files
authored
Version 0.9.5 (#95)
* skip cmd tests if not on linux * stop changing the path sep on the root path in the env test * only support CAPITALIZED drive letters on windows to avoid path splitting issue * disable lowercase drive letter tests * fixes for windows: cmd wrapper, argv expansion * adds make.bat file, smoke test on windows, disable fail-fast tests * updates to envstack banner for exit hints * support --quiet in envstack shell * minor updates to cache env file * bump version to 0.9.5
1 parent 402d47c commit 8a2b8b3

14 files changed

Lines changed: 242 additions & 100 deletions

File tree

.github/workflows/tests.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: tests
2+
3+
on: [push, pull_request]
4+
5+
jobs:
6+
test:
7+
runs-on: ${{ matrix.os }}
8+
strategy:
9+
matrix:
10+
os: [ubuntu-latest, macos-latest, windows-latest]
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: actions/setup-python@v5
14+
with:
15+
python-version: "3.11"
16+
- run: python -m pip install --upgrade pip
17+
- run: pip install pytest
18+
- run: pip install -e .
19+
- run: pytest tests -v

README.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,22 @@ found in `prod`.
6969

7070
#### Basic Usage
7171

72-
Running `envstack` will launch a new shell session with the resolved default
73-
environment:
72+
Running `envstack` will launch a new shell session with a resolved environment:
7473

7574
```shell
7675
$ envstack
77-
🚀 Launching envstack shell... CTRL+D to exit
76+
🚀 Launching envstack shell... (CTRL+D or "exit" to quit)
7877
(prod) ~/envstack$
7978
```
8079

80+
Loading the `dev` environment (as defined in the `dev.env` file):
81+
82+
```shell
83+
$ envstack dev
84+
🚀 Launching envstack shell... (CTRL+D or "exit" to quit)
85+
(dev) ~/dev/envstack$
86+
```
87+
8188
Using the `-u` command will show you the default, unresolved environment
8289
stack, defined in `default.env` files in `${ENVPATH}`:
8390

@@ -614,5 +621,5 @@ Make sure you don't have any local .env files that may intefere with the unit
614621
tests.
615622
616623
```bash
617-
$ pytest tests -s
624+
$ pytest tests -v
618625
```

env/cache.env

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
#!/usr/bin/env envstack
22
include: [default]
33
all: &all
4-
ENVPATH: ${CACHE_ROOT}/env:${DEPLOY_ROOT}/env:${ENVPATH}
5-
PATH: ${CACHE_ROOT}/bin:${DEPLOY_ROOT}/bin:${PATH}
6-
PYTHONPATH: ${CACHE_ROOT}/lib/python:${DEPLOY_ROOT}/lib/python:${PYTHONPATH}
4+
ENVPATH: ${CACHE_ROOT}/env:${ENVPATH}
5+
PATH: ${CACHE_ROOT}/bin:${PATH}
6+
PYTHONPATH: ${CACHE_ROOT}/lib/python:${PYTHONPATH}
7+
CACHE_ROOT: ${CACHE_DIR}/${ENV}
78
darwin:
89
<<: *all
9-
CACHE_ROOT: ${HOME}/Library/Caches/pipe/${ENV}
10+
CACHE_DIR: ${HOME}/Library/Caches/pipe
1011
linux:
1112
<<: *all
12-
CACHE_ROOT: ${HOME}/.cache/pipe/${ENV}
13+
CACHE_DIR: ${HOME}/.cache/pipe
1314
windows:
1415
<<: *all
15-
CACHE_ROOT: ${USERPROFILE}/AppData/Local/pipe/cache/${ENV}
16+
CACHE_DIR: ${USERPROFILE}/AppData/Local/pipe

lib/envstack/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,6 @@
3434
"""
3535

3636
__prog__ = "envstack"
37-
__version__ = "0.9.4"
37+
__version__ = "0.9.5"
3838

3939
from envstack.env import clear, init, revert, save # noqa: F401

lib/envstack/cli.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"""
3535

3636
import argparse
37+
import os
3738
import re
3839
import sys
3940
import traceback
@@ -50,6 +51,7 @@
5051
resolve_environ,
5152
trace_var,
5253
)
54+
from envstack.envshell import EnvshellWrapper
5355
from envstack.logger import setup_stream_handler
5456
from envstack.wrapper import run_command
5557

@@ -260,11 +262,21 @@ def parse_args():
260262
return args, args_after_dash
261263

262264

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

267-
print("\U0001F680 Launching envstack shell... CTRL+D to exit")
268+
if not quiet:
269+
shell_name = os.path.basename(config.SHELL).lower()
270+
271+
if shell_name in ("cmd", "cmd.exe"):
272+
exit_hint = 'type "exit" to quit'
273+
elif shell_name in ("powershell", "pwsh"):
274+
exit_hint = 'type "exit" to quit'
275+
else:
276+
# bash, zsh, sh, etc.
277+
exit_hint = 'CTRL+D or "exit" to quit'
278+
279+
print(f"\U0001F680 Launching envstack shell... ({exit_hint})")
268280

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

453465
else:
454-
return envshell(args.namespace)
466+
return envshell(args.namespace, quiet=args.quiet)
455467

456468
except KeyboardInterrupt:
457469
print("Stopping...")

lib/envstack/util.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
null = ""
5555

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

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

158158
for token in tokens:
159159
# windows-style path token; insert a marker before drive letters
160-
if re.match(r"^[a-zA-Z]:[/\\]", token) or "\\" in token:
160+
if re.match(r"^[A-Z]:[/\\]", token) or "\\" in token:
161161
modified = drive_letter_pattern.sub(lambda m: "|" + m.group("drive"), token)
162162
# split on the marker, then on colons that are not in drive-letters
163163
result += [
164164
p
165165
for part in modified.split("|")
166166
for p in re.split(
167-
r"(?<![a-zA-Z]):", part
167+
r"(?<![A-Z]):", part
168168
) # capture colons not in drive letters
169169
if p
170170
]

lib/envstack/wrapper.py

Lines changed: 20 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ def executable(self):
9292
9393
tool.log = MyLogger()
9494
"""
95+
9596
shell: bool = False
9697

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

210211

211212
class CmdWrapper(CommandWrapper):
212-
"""Wrapper class for running wrapped commands in command prompt."""
213+
"""Wrapper class for running wrapped commands in Windows cmd.exe."""
213214

214215
def __init__(self, namespace=config.DEFAULT_NAMESPACE, args=[]):
215-
"""
216-
Initializes the command wrapper with the given namespace and args,
217-
replacing the original command with the shell command, e.g.:
218-
219-
>>> cmd = CmdWrapper(stack, ['dir'])
220-
>>> print(cmd.executable())
221-
cmd
222-
>>> print(cmd.args)
223-
['/c', 'dir']
216+
super().__init__(namespace, args)
224217

225-
:param namespace: environment stack name (default: 'default').
226-
:param args: command and arguments as a list.
227-
"""
228-
super(CmdWrapper, self).__init__(namespace, args)
229-
self.args = ["/c", self.cmd]
218+
# Always run through cmd.exe explicitly
230219
self.shell = False
220+
self._cmd_exe = config.SHELL # expected: "cmd" or "cmd.exe"
231221

232-
def get_subprocess_args(self, cmd):
233-
"""Returns the arguments to be passed to the subprocess."""
234-
return [cmd] + self.args
222+
# Join the intended argv into one command-line string for /c
223+
cmdline = shell_join(self.cmd)
224+
225+
# cmd.exe /c <command>
226+
self._subprocess_argv = [self._cmd_exe, "/c", cmdline]
235227

236228
def executable(self):
237-
"""Returns the shell command to run the original command."""
238-
self.cmd = config.SHELL
239-
return self.cmd
229+
return self._cmd_exe
230+
231+
def get_subprocess_args(self, cmd):
232+
# Not used (we override get_subprocess_command)
233+
return []
234+
235+
def get_subprocess_command(self, env):
236+
return list(self._subprocess_argv)
240237

241238

242239
def run_command(command: str, namespace: str = config.DEFAULT_NAMESPACE):
@@ -254,39 +251,23 @@ def run_command(command: str, namespace: str = config.DEFAULT_NAMESPACE):
254251
255252
:param command: command to run as a list of arguments.
256253
:param namespace: environment stack name (default: 'default').
257-
:param interactive: run the command in an interactive shell (default: True).
258254
:returns: command exit code
259255
"""
260256
logger.setup_stream_handler()
261-
shellname = os.path.basename(config.SHELL)
262-
263-
# normalize to argv list
257+
shellname = os.path.basename(config.SHELL).lower()
264258
argv = list(command) if isinstance(command, (list, tuple)) else to_args(command)
265259

266-
needs_shell = any(re.search(r"\{(\w+)\}", a) for a in argv)
267-
if needs_shell:
268-
expr_argv = [re.sub(r"\{(\w+)\}", r"${\1}", a) for a in argv]
269-
expr = shell_join(expr_argv)
270-
return ShellWrapper(namespace, expr).launch()
271-
272260
if shellname in ["bash", "sh", "zsh"]:
273-
# 1) if user explicitly invoked a shell (bash/sh/zsh), do not wrap again
274-
if argv and os.path.basename(argv[0]) in ["bash", "sh", "zsh"]:
275-
return CommandWrapper(namespace, argv).launch()
276-
277-
# 2) if command contains {VARS}, convert to ${VARS} and run as a shell expression
278261
needs_shell = any(re.search(r"\{(\w+)\}", a) for a in argv)
279262
if needs_shell:
280263
expr_argv = [re.sub(r"\{(\w+)\}", r"${\1}", a) for a in argv]
281264
expr = shell_join(expr_argv)
282265
return ShellWrapper(namespace, expr).launch()
283266

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

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

292273
return CommandWrapper(namespace, argv).launch()

make.bat

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
@echo off
2+
setlocal EnableExtensions EnableDelayedExpansion
3+
4+
rem =============================================================================
5+
rem Project: EnvStack - Environment Variable Management
6+
rem make.bat - Windows-friendly Makefile target runner
7+
rem
8+
rem Usage:
9+
rem make.bat -> build
10+
rem make.bat clean -> remove build artifacts
11+
rem make.bat test -> run basic envstack shell checks
12+
rem make.bat dryrun -> simulate installation (dist --dryrun)
13+
rem make.bat install -> build then dist --force --yes
14+
rem make.bat help -> show this help
15+
rem =============================================================================
16+
17+
set "BUILD_DIR=build"
18+
set "ENVPATH=%CD%\env"
19+
set "ENVSTACK_CMD=envstack"
20+
21+
set "TARGET=%~1"
22+
if "%TARGET%"=="" set "TARGET=build"
23+
24+
if /I "%TARGET%"=="help" goto :help
25+
if /I "%TARGET%"=="clean" goto :clean
26+
if /I "%TARGET%"=="build" goto :build
27+
if /I "%TARGET%"=="all" goto :build
28+
if /I "%TARGET%"=="test" goto :test
29+
if /I "%TARGET%"=="dryrun" goto :dryrun
30+
if /I "%TARGET%"=="install" goto :install
31+
32+
echo.
33+
echo Unknown target: "%TARGET%"
34+
echo.
35+
goto :help
36+
37+
:help
38+
echo Targets:
39+
echo build - Build artifacts (pip install -r requirements.txt -t %BUILD_DIR%)
40+
echo clean - Remove build artifacts (%BUILD_DIR%)
41+
echo test - Basic envstack command checks
42+
echo dryrun - Simulate installation (dist --dryrun) via envstack
43+
echo install - Build then install using distman (dist --force --yes)
44+
echo all - Alias for build
45+
echo help - Show this help
46+
echo.
47+
echo Notes:
48+
echo - ENVPATH is set to: %ENVPATH%
49+
echo - Requires: python/pip, envstack, and distman (for dryrun/install)
50+
exit /b 0
51+
52+
:clean
53+
echo [clean] Removing "%BUILD_DIR%" ...
54+
if exist "%BUILD_DIR%" (
55+
rmdir /s /q "%BUILD_DIR%"
56+
)
57+
exit /b 0
58+
59+
:build
60+
call :clean || exit /b 1
61+
echo [build] Installing requirements into "%BUILD_DIR%" ...
62+
python -m pip install -r requirements.txt -t "%BUILD_DIR%"
63+
if errorlevel 1 exit /b 1
64+
exit /b 0
65+
66+
:test
67+
echo [test] Running envstack checks...
68+
rem Use a one-liner so ENVPATH applies to the envstack process.
69+
set "CMD=set ENVPATH=%ENVPATH% ^&^& %ENVSTACK_CMD% -- dir"
70+
cmd /c "%CMD%"
71+
if errorlevel 1 exit /b 1
72+
73+
set "CMD=set ENVPATH=%ENVPATH% ^&^& %ENVSTACK_CMD% -- where python"
74+
cmd /c "%CMD%"
75+
if errorlevel 1 exit /b 1
76+
77+
exit /b 0
78+
79+
:dryrun
80+
echo [dryrun] Simulating install via dist --dryrun...
81+
set "CMD=set ENVPATH=%ENVPATH% ^&^& %ENVSTACK_CMD% -- dist --dryrun"
82+
cmd /c "%CMD%"
83+
exit /b %errorlevel%
84+
85+
:install
86+
call :build || exit /b 1
87+
echo [install] Installing via distman (dist --force --yes)...
88+
dist --force --yes
89+
exit /b %errorlevel%

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040

4141
setup(
4242
name="envstack",
43-
version="0.9.4",
43+
version="0.9.5",
4444
description="Stacked environment variable management system",
4545
long_description=long_description,
4646
long_description_content_type="text/markdown",

tests/test_cmds.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
import os
3737
import platform
38+
import pytest
3839
import shutil
3940
import subprocess
4041
import sys
@@ -46,6 +47,10 @@
4647

4748
from test_env import create_test_root, update_env_file
4849

50+
pytestmark = pytest.mark.skipif(
51+
sys.platform != "linux",
52+
reason="Linux-only shell integration tests (bash/ls/PS1, paths, env layout)",
53+
)
4954

5055
def make_command(envstack_bin: str, filename: str, *args: str):
5156
"""

0 commit comments

Comments
 (0)