diff --git a/CHANGELOG.md b/CHANGELOG.md index d3571ab..13f7b48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +- remove the unused CLI `--scope` option (#131). Use `ENVPATH` to select stack search directories; the Python API `scope` parameter remains supported. + - remove the legacy `envstack.path` API (`Path`, `Template`, `get_scope`, `get_template`, `match_template`, and `extract_fields`), its unused `env.Scope` wrapper, and path-only exceptions (#132). Use `pathlib` or `os.path` for filesystem operations and application-owned code for path templating; stack discovery continues to use `ENVPATH` and the current directory or explicit scope. +### Changed + +- allow `-u`/`--unresolved` to select zero or more variables, matching `-r`/`--resolve`, with `-q` for a single value. Put stack names before the option: replace `envstack -u dev` with `envstack dev -u`. + ### Added - add NumPy, Pillow, and OpenEXR examples demonstrating accumulated search paths and runtime selection of NumPy builds diff --git a/README.md b/README.md index 84e99c8..c00e5f7 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,9 @@ $ envstack prod ``` +To inspect selected raw values, use `envstack default -u PATH PYTHONPATH`. +Put stack names before `-u`, as with `-r`; omit variable names to show all values. + To inspect the unresolved environment (before variable expansion): ```bash diff --git a/docs/api.md b/docs/api.md index 713fee3..889513c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -33,6 +33,22 @@ This is the programmatic equivalent of: $ envstack default -u ``` +Select raw values without expanding their references: + +```bash +envstack default -u PATH PYTHONPATH +envstack default -u PYTHONPATH -q +``` + +Both `-u`/`--unresolved` and `-r`/`--resolve` accept zero or more variable +names. Without names they show all values; `-q` prints only the value of one +selected variable. Unknown variable names are omitted. + +Put stack names before these options: use `envstack dev -u`, not +`envstack -u dev`. Arguments after `-u` now select variables, just as they do +after `-r`. + + ## Resolving a stack into concrete values To evaluate variable references such as `${ROOT}/${ENV}`, resolve the loaded diff --git a/docs/index.md b/docs/index.md index a9e938e..332c6b1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -104,6 +104,9 @@ $ envstack prod ``` +To inspect selected raw values, use `envstack default -u PATH PYTHONPATH`. +Put stack names before `-u`, as with `-r`; omit variable names to show all values. + Inspect the unresolved environment: ```bash diff --git a/lib/envstack/cli.py b/lib/envstack/cli.py index e8050d0..1ea78d2 100755 --- a/lib/envstack/cli.py +++ b/lib/envstack/cli.py @@ -218,16 +218,13 @@ def parse_args(): metavar="KEY=VALUE", help="overlay KEY=VALUE pairs to envstack environments", ) - parser.add_argument( - "--scope", - metavar="SCOPE", - help="search scope for environment stack files", - ) parser.add_argument( "-u", "--unresolved", - action="store_true", - help="dump unresolved environment variables to stdout", + nargs="*", + action=StoreOnce, + metavar="VAR", + help="show unresolved values for VARs (default: all); put STACKs before -u", ) parser.add_argument( "-r", @@ -453,10 +450,15 @@ def main(): for source in env.sources: print(source.path) - elif args.unresolved: + elif args.unresolved is not None: 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}") + keys = args.unresolved or env.keys() + if args.quiet and len(keys) != 1: + print("error: --quiet requires exactly one KEY") + return 2 + for key in sorted(str(k) for k in keys): + if key in env: + print(env[key] if args.quiet else f"{key}={env[key]}") else: return envshell(args.namespace, quiet=args.quiet) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..bb628db --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,58 @@ +"""CLI variable selection without launching a shell or installed executable.""" + +import sys + +import pytest + +from envstack import cli +from envstack import env as env_module + + +@pytest.fixture +def run_cli(tmp_path, monkeypatch, capsys): + (tmp_path / "sample.env").write_text("all:\n ROOT: /base\n LIB: ${ROOT}/lib\n EMPTY: ''\n") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("ENVPATH", str(tmp_path)) + monkeypatch.setattr(env_module, "seen_stacks", set()) + + def run(*args): + monkeypatch.setattr(sys, "argv", ["envstack", *args]) + status = cli.main() + return status or 0, capsys.readouterr().out + + return run + + +@pytest.mark.parametrize("option", ["-u", "--unresolved"]) +def test_unresolved_variable_selection(run_cli, option): + assert run_cli("sample", option, "LIB", "EMPTY") == (0, "EMPTY=\nLIB=${ROOT}/lib\n") + + +def test_unresolved_without_variables_keeps_full_output(run_cli): + status, output = run_cli("sample", "-u") + assert status == 0 + assert "LIB=${ROOT}/lib\n" in output + assert "ROOT=/base\n" in output + assert "STACK=sample\n" in output + + +@pytest.mark.parametrize("option,expected", [("-u", "${ROOT}/lib\n"), ("-r", "/base/lib\n")]) +def test_selected_value_quiet(run_cli, option, expected): + assert run_cli("sample", option, "LIB", "-q") == (0, expected) + + +def test_unresolved_quiet_rejects_multiple_variables(run_cli): + status, output = run_cli("sample", "-u", "ROOT", "LIB", "-q") + assert status == 2 + assert output == "error: --quiet requires exactly one KEY\n" + + +@pytest.mark.parametrize("option", ["-u", "-r"]) +def test_unknown_variable_is_omitted(run_cli, option): + assert run_cli("sample", option, "UNKNOWN") == (0, "") + + +def test_unresolved_repeated_option_rejected(run_cli): + with pytest.raises(SystemExit) as exc: + run_cli("sample", "-u", "LIB", "-u", "ROOT") + assert exc.value.code == 2 diff --git a/tests/test_cmds.py b/tests/test_cmds.py index d6d65bf..3b1f7d8 100644 --- a/tests/test_cmds.py +++ b/tests/test_cmds.py @@ -76,7 +76,7 @@ class TestUnresolved(unittest.TestCase): """Tests unresolved environment variables from the cli.""" def setUp(self): - self.envstack_bin = "envstack -u" + self.envstack_bin = "envstack" self.root = { "linux": "/mnt/pipe", "win32": "//tools/pipe", @@ -99,7 +99,9 @@ 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 + " -u", shell=True, universal_newlines=True + ) self.assertEqual(output, expected_output) def test_dev(self): @@ -117,7 +119,7 @@ def test_dev(self): """ % self.root ) - command = "%s dev" % self.envstack_bin + command = "%s dev -u" % self.envstack_bin output = subprocess.check_output(command, shell=True, universal_newlines=True) self.assertEqual(output, expected_output) @@ -137,7 +139,7 @@ def test_hello(self): """ % self.root ) - command = "%s hello" % self.envstack_bin + command = "%s hello -u" % self.envstack_bin output = subprocess.check_output(command, shell=True, universal_newlines=True) self.assertEqual(output, expected_output) @@ -160,7 +162,7 @@ def test_thing(self): TRUE=1 YES=1 """ - command = "%s thing" % self.envstack_bin + command = "%s thing -u" % self.envstack_bin output = subprocess.check_output(command, shell=True, universal_newlines=True) self.assertEqual(output, expected_output) @@ -169,7 +171,7 @@ class TestEncrypt(unittest.TestCase): """Tests encrypting environment variables from the cli.""" def setUp(self): - self.envstack_bin = "envstack -u" + self.envstack_bin = "envstack" self.root = { "linux": "/mnt/pipe", "win32": "//tools/pipe", @@ -198,7 +200,7 @@ def test_default(self): ROOT=L21udC9waXBl STACK=ZGVmYXVsdA== """ - command = "%s --encrypt" % self.envstack_bin + command = "%s -u --encrypt" % self.envstack_bin output = subprocess.check_output(command, shell=True, universal_newlines=True) self.assertEqual(output, expected_output) @@ -207,7 +209,7 @@ def test_default_AESGCM(self): encrypted values change every time.""" os.environ[AESGCMEncryptor.KEY_VAR_NAME] = AESGCMEncryptor.generate_key() expected_output = f"""{self.root}/prod\n""" - command = "%s -e -- echo {DEPLOY_ROOT}" % self.envstack_bin + command = "%s -u -e -- echo {DEPLOY_ROOT}" % self.envstack_bin output = subprocess.check_output( command, shell=True, env=os.environ, universal_newlines=True ) @@ -216,21 +218,21 @@ def test_default_AESGCM(self): def test_default_resolve(self): """Get and encrypt default stack values, and test they are resolved in a subprocess.""" expected_output = f"""{self.root}/prod\n""" - command = "%s --encrypt -- echo {DEPLOY_ROOT}" % self.envstack_bin + command = "%s -u --encrypt -- echo {DEPLOY_ROOT}" % self.envstack_bin output = subprocess.check_output(command, shell=True, universal_newlines=True) self.assertEqual(output, expected_output) def test_default_command_echo(self): """Tests that the default stack works with encrypted values.""" expected_output = f"""{self.root}/prod\n""" - command = "%s --encrypt -- echo {DEPLOY_ROOT}" % self.envstack_bin + command = "%s -u --encrypt -- echo {DEPLOY_ROOT}" % self.envstack_bin output = subprocess.check_output(command, shell=True, universal_newlines=True) self.assertEqual(output, expected_output) def test_hello_command_echo(self): """Tests that resolved and encrypted values resolve in subprocesses.""" expected_output = "goodbye\n" - command = "%s thing -r HELLO -e -- echo {HELLO}" % self.envstack_bin + command = "%s thing -u -r HELLO -e -- echo {HELLO}" % self.envstack_bin output = subprocess.check_output(command, shell=True, universal_newlines=True) self.assertEqual(output, expected_output) @@ -247,14 +249,14 @@ def test_dev(self): ROOT=L21udC9waXBl STACK=ZGV2 """ - command = "%s dev --encrypt" % self.envstack_bin + command = "%s dev -u --encrypt" % self.envstack_bin output = subprocess.check_output(command, shell=True, universal_newlines=True) self.assertEqual(output, expected_output) def test_dev_command(self): """Tests that encrypted values resolve in subprocess in the dev stack.""" expected_output = f"""{self.root}/dev\n""" - command = "%s dev --encrypt -- echo {DEPLOY_ROOT}" % self.envstack_bin + command = "%s dev -u --encrypt -- echo {DEPLOY_ROOT}" % self.envstack_bin output = subprocess.check_output(command, shell=True, universal_newlines=True) self.assertEqual(output, expected_output)