From 5be9096acbb4244f020e91d48771c42e85468d22 Mon Sep 17 00:00:00 2001 From: Ryan Gard Date: Fri, 8 May 2026 14:51:22 -0700 Subject: [PATCH] Return Appropriate Exit Codes - Add exit code return when using the 'konsave' CLI. - Add 'py.test' framework with plug-ins. - Add test cases for verifying 'konsave' CLI exit codes. - Update Makefile with target for running tests. - Reformat codebase using black. --- CHANGELOG.md | 6 +++ Makefile | 2 +- konsave/__init__.py | 2 +- konsave/__main__.py | 83 ++++++++++++++++++++++++++--------------- konsave/consts.py | 2 +- konsave/funcs.py | 3 +- konsave/parse.py | 1 + requirements_dev.txt | 4 ++ tests/__init__.py | 0 tests/conftest.py | 89 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 63 +++++++++++++++++++++++++++++++ 11 files changed, 220 insertions(+), 35 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_cli.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 77fd779..08afa1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Reorganized CLI arguments into logical groups (Profile Management, Import & Export, Options, Miscellaneous) for better readability - Improved all help messages with clearer and more descriptive text +- Return appropriate exit codes when ran from the CLI. + +### Added +- Add 'py.test' framework with plug-ins. +- Create basic test fixtures for testing 'konsave' in a shell like environment. +- Add basic test cases for verifying CLI exit codes. ## [2.2.0] - 2023-01-31 ### Added diff --git a/Makefile b/Makefile index 298d983..ba798f7 100644 --- a/Makefile +++ b/Makefile @@ -58,4 +58,4 @@ maintclean: distclean # mypy -p tests --no-strict-optional --ignore-missing-imports --install-types tests: - python3 ./test.py + pytest tests diff --git a/konsave/__init__.py b/konsave/__init__.py index 2325c07..8d5ebf3 100644 --- a/konsave/__init__.py +++ b/konsave/__init__.py @@ -6,4 +6,4 @@ __version__ = distribution(__name__).version except PackageNotFoundError: # Package is not installed - pass + __version__ = "unknown" diff --git a/konsave/__main__.py b/konsave/__main__.py index 5581401..7493686 100755 --- a/konsave/__main__.py +++ b/konsave/__main__.py @@ -2,6 +2,7 @@ import argparse import os +import sys import shutil from importlib.resources import files @@ -41,30 +42,35 @@ def _get_parser() -> argparse.ArgumentParser: "Profile Management", "Commands for managing configuration profiles" ) profile_group.add_argument( - "-l", "--list", + "-l", + "--list", action="store_true", help="List all saved profiles", ) profile_group.add_argument( - "-s", "--save", + "-s", + "--save", type=str, help="Save current configuration as a new profile", metavar="", ) profile_group.add_argument( - "-a", "--apply", + "-a", + "--apply", type=str, help="Apply a saved profile to restore its configuration", metavar="", ) profile_group.add_argument( - "-r", "--remove", + "-r", + "--remove", type=str, help="Delete a saved profile permanently", metavar="", ) profile_group.add_argument( - "-w", "--wipe", + "-w", + "--wipe", action="store_true", help="Delete all saved profiles (use with caution!)", ) @@ -75,13 +81,15 @@ def _get_parser() -> argparse.ArgumentParser: "Commands for sharing profiles with others", ) transfer_group.add_argument( - "-e", "--export-profile", + "-e", + "--export-profile", type=str, help="Export a profile as a shareable .knsv archive file", metavar="", ) transfer_group.add_argument( - "-i", "--import-profile", + "-i", + "--import-profile", type=str, help="Import a profile from a .knsv archive file", metavar="", @@ -92,17 +100,20 @@ def _get_parser() -> argparse.ArgumentParser: "Options", "Additional options to modify command behavior" ) options_group.add_argument( - "-f", "--force", + "-f", + "--force", action="store_true", help="Force overwrite when saving/exporting (skip confirmation prompts)", ) options_group.add_argument( - "-d", "--export-directory", + "-d", + "--export-directory", help="Specify custom directory for exported profile (default: current directory)", metavar="", ) options_group.add_argument( - "-n", "--export-name", + "-n", + "--export-name", help="Specify custom filename for exported profile archive", metavar="", ) @@ -113,7 +124,8 @@ def _get_parser() -> argparse.ArgumentParser: "-h", "--help", action="help", help="Show this help message and exit" ) misc_group.add_argument( - "-v", "--version", + "-v", + "--version", action="store_true", help="Display the current version of Konsave", ) @@ -135,25 +147,36 @@ def main(): parser = _get_parser() args = parser.parse_args() - if args.list: - list_profiles(list_of_profiles, length_of_lop) - elif args.save: - save_profile(args.save, list_of_profiles, force=args.force) - elif args.remove: - remove_profile(args.remove, list_of_profiles, length_of_lop) - elif args.apply: - apply_profile(args.apply, list_of_profiles, length_of_lop) - elif args.export_profile: - export(args.export_profile, list_of_profiles, length_of_lop, - args.export_directory, args.export_name, args.force) - elif args.import_profile: - import_profile(args.import_profile) - elif args.version: - print(f"Konsave: {VERSION}") - elif args.wipe: - wipe() - else: - parser.print_help() + try: + if args.list: + list_profiles(list_of_profiles, length_of_lop) + elif args.save: + save_profile(args.save, list_of_profiles, force=args.force) + elif args.remove: + remove_profile(args.remove, list_of_profiles, length_of_lop) + elif args.apply: + apply_profile(args.apply, list_of_profiles, length_of_lop) + elif args.export_profile: + export( + args.export_profile, + list_of_profiles, + length_of_lop, + args.export_directory, + args.export_name, + args.force, + ) + elif args.import_profile: + import_profile(args.import_profile) + elif args.version: + print(f"Konsave: {VERSION}") + elif args.wipe: + wipe() + else: + parser.print_help() + except RuntimeError: + sys.exit(1) + + sys.exit(0) if __name__ == "__main__": diff --git a/konsave/consts.py b/konsave/consts.py index 286c28b..83eb29f 100644 --- a/konsave/consts.py +++ b/konsave/consts.py @@ -1,10 +1,10 @@ """ This module contains all the variables for konsave """ + import os from konsave import __version__ - HOME = os.path.expandvars("$HOME") CONFIG_DIR = os.path.join(HOME, ".config") SHARE_DIR = os.path.join(HOME, ".local/share") diff --git a/konsave/funcs.py b/konsave/funcs.py index a7cae9f..9cf6dbb 100644 --- a/konsave/funcs.py +++ b/konsave/funcs.py @@ -49,8 +49,7 @@ def inner_func(*args, **kwargs): print( f"Konsave: {err}\nPlease check the log at {log_file} for more details." ) - return None - + raise RuntimeError from err return function return inner_func diff --git a/konsave/parse.py b/konsave/parse.py index edc092f..61760d9 100644 --- a/konsave/parse.py +++ b/konsave/parse.py @@ -1,6 +1,7 @@ """ This module parses conf.yaml """ + import os import re from konsave.consts import HOME, CONFIG_DIR, SHARE_DIR, BIN_DIR diff --git a/requirements_dev.txt b/requirements_dev.txt index c7298b0..089ad8f 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -2,3 +2,7 @@ aiohttp>=3.7.4 aiohttp_cors>=0.7.0 black>=20.8b1 pylint>=2.7.2 +ipython==9.13.0 +pytest==9.0.3 +pytest-mock==3.15.1 +pyfakefs==6.2.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a11c13a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,89 @@ +import os +import sys +import pytest +import konsave.consts +import konsave.__main__ + + +@pytest.fixture(scope="function") +def mock_cli(request, mocker): + """Mock 'sys.argv' for testing the 'konsave' CLI interface.""" + + test_args = ["konsave", request.param] + + mocker.patch.object(sys, "argv", test_args) + + +@pytest.fixture(scope="function") +def konsave_env_paths(): + """The 'konsave.consts' environment paths.""" + + knsv_env_paths = { + "HOME": konsave.consts.HOME, + "CONFIG_DIR": konsave.consts.CONFIG_DIR, + "SHARE_DIR": konsave.consts.SHARE_DIR, + "BIN_DIR": konsave.consts.BIN_DIR, + "KONSAVE_DIR": konsave.consts.KONSAVE_DIR, + "PROFILES_DIR": konsave.consts.PROFILES_DIR, + "CACHE_DIR": os.path.join(konsave.consts.HOME, ".cache"), + "CONFIG_FILE": konsave.consts.CONFIG_FILE, + } + + return knsv_env_paths + + +@pytest.fixture(scope="function") +def konsave_test_env(fs, konsave_env_paths): + """Construct the fake filesystem directory structure for testing 'konsave'.""" + + for p in [v for k, v in konsave_env_paths.items() if "FILE" not in k]: + fs.create_dir(p) + + return fs + + +@pytest.fixture(scope="function") +def konsave_conf_kde(konsave_test_env): + """Inject real 'kde' config file into the fake filesystem.""" + + konsave_test_env.add_real_file( + source_path="konsave/conf_kde.yaml", + target_path="/home/{user_name}/.config/konsave/conf.yaml".format( + user_name=os.getlogin() + ), + ) + + return konsave_test_env + + +@pytest.fixture(scope="function") +def konsave_conf_other(konsave_test_env, konsave_env_paths): + """Inject real 'other' config file into the fake filesystem.""" + + konsave_test_env.add_real_file( + source_path="konsave/conf_other.yaml", + target_path=os.path.join(konsave_env_paths["CONFIG_DIR"], "conf.yaml"), + ) + + return konsave_test_env + + +@pytest.fixture(scope="function") +def basic_kde_test_env(mocker, konsave_conf_kde, konsave_env_paths): + """Configure the test directory structure for a basic two profile 'konsave' environment.""" + + konsave_conf_kde.create_dir( + os.path.join(konsave_env_paths["PROFILES_DIR"], "test_profile_1") + ) + konsave_conf_kde.create_dir( + os.path.join(konsave_env_paths["PROFILES_DIR"], "test_profile_2") + ) + + # This must be patched because 'konsave.consts' was already loaded by this module. + mocker.patch.object( + konsave.__main__, + "list_of_profiles", + os.listdir(konsave_env_paths["PROFILES_DIR"]), + ) + + return konsave_conf_kde diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..41a695f --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,63 @@ +import pytest +from konsave.__main__ import main as konsave_main + + +class TestCli(object): + """Test cases for CLI""" + + @pytest.mark.parametrize("mock_cli", ["-h"], indirect=["mock_cli"]) + def test_help(self, capsys, mock_cli, basic_kde_test_env): + """Verify that the '-h' argument returns with exit code 0 and appropriate output.""" + + with pytest.raises(SystemExit) as e: + konsave_main() + + assert e.value.code == 0 + assert "usage: Konsave" in capsys.readouterr().out + + @pytest.mark.parametrize("mock_cli", ["-l"], indirect=["mock_cli"]) + def test_list(self, capsys, mock_cli, basic_kde_test_env): + """ + Verify that the '-l' argument returns with exit code 0 and lists two + profiles in output. + """ + + with pytest.raises(SystemExit) as e: + konsave_main() + + assert e.value.code == 0 + assert "test_profile_1" and "test_profile_2" in capsys.readouterr().out + + +class TestCliNegative(object): + """Negative test cases for CLI.""" + + @pytest.mark.parametrize("mock_cli", ["-r does_not_exist"], indirect=["mock_cli"]) + def test_attempt_remove_non_existent_profile( + self, capsys, mock_cli, basic_kde_test_env + ): + """ + Verify that the '-r' argument with non-existent profile returns with exit + code 1 and appropriate error message in output. + """ + + with pytest.raises(SystemExit) as e: + konsave_main() + + assert e.value.code == 1 + assert "Konsave: Profile not found." in capsys.readouterr().out + + @pytest.mark.parametrize("mock_cli", ["-i does_not_exist"], indirect=["mock_cli"]) + def test_attempt_import_non_existent_profile( + self, capsys, mock_cli, basic_kde_test_env + ): + """ + Verify that the '-i' argument with non-existent profile returns with exit + code 1 and appropriate error message in output. + """ + + with pytest.raises(SystemExit) as e: + konsave_main() + + assert e.value.code == 1 + assert "Konsave: Not a valid konsave file" in capsys.readouterr().out