diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3e963a9ff..b4b3ad8ef 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -59,7 +59,6 @@ plugins/* @anselor # Unit and Integration Tests tests/* @kmvanbrunt @tleonhardt -tests_isolated/* @anselor # Top-level project stuff .coveragerc @tleonhardt diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 81c8d0872..d9d46c479 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -346,10 +346,9 @@ This bit is up to you! The cmd2 project directory structure is pretty simple and straightforward. All actual code for cmd2 is located underneath the `cmd2` directory. The code to generate the documentation is in the `docs` -directory. Unit tests are in the `tests` directory. Integration tests are in the `tests_isolated` -directory. The `examples` directory contains examples of how to use cmd2. There are various other -files in the root directory, but these are primarily related to continuous integration and release -deployment. +directory. Unit and integration tests are in the `tests` directory. The `examples` directory +contains examples of how to use cmd2. There are various other files in the root directory, but these +are primarily related to continuous integration and release deployment. #### Changes to the documentation files diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cd8cd7c85..de85f29b4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,11 +38,6 @@ jobs: - name: Run tests run: uv run python -Xutf8 -m pytest --cov --cov-config=pyproject.toml --cov-report=xml tests - - name: Run isolated tests - run: - uv run python -Xutf8 -m pytest --cov --cov-config=pyproject.toml --cov-report=xml - tests_isolated - - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index a675a6006..8f2090b53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ time reading the [rich documentation](https://rich.readthedocs.io/). `value` property - Removed redundant setting of a parser's `prog` value in the `with_argparser()` decorator, as this is now handled centrally in `Cmd._build_parser()` + - The `auto_load_commands` argument to `cmd2.Cmd.__init__` now defaults to `False` - Enhancements - Enhanced all print methods (`poutput()`, `perror()`, `ppaged()`, etc.) to natively render diff --git a/Makefile b/Makefile index 4f6a7daf2..f0b49f929 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,6 @@ typecheck: ## Perform type checking test: ## Test the code with pytest. @echo "🚀 Testing code: Running pytest" @uv run python -Xutf8 -m pytest --cov --cov-config=pyproject.toml --cov-report=xml tests - @uv run python -Xutf8 -m pytest --cov --cov-config=pyproject.toml --cov-report=xml tests_isolated .PHONY: docs-test docs-test: ## Test if documentation can be built without warnings or errors diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 506470857..430b9a102 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -323,7 +323,7 @@ def __init__( terminators: list[str] | None = None, shortcuts: dict[str, str] | None = None, command_sets: Iterable[CommandSet] | None = None, - auto_load_commands: bool = True, + auto_load_commands: bool = False, allow_clipboard: bool = True, suggest_similar_command: bool = False, ) -> None: diff --git a/codecov.yml b/codecov.yml index f11ac473d..f3bcec98b 100644 --- a/codecov.yml +++ b/codecov.yml @@ -14,4 +14,3 @@ component_management: ignore: - "examples" # ignore example code folder - "tests" # ignore unit test code folder - - "tests_isolated" # ignore integration test code folder diff --git a/docs/features/modular_commands.md b/docs/features/modular_commands.md index 3bb2c64d5..f6867fc74 100644 --- a/docs/features/modular_commands.md +++ b/docs/features/modular_commands.md @@ -4,18 +4,20 @@ Cmd2 also enables developers to modularize their command definitions into [CommandSet][cmd2.CommandSet] objects. CommandSets represent a logical grouping of commands within a -`cmd2` application. By default, all `CommandSet` objects will be discovered and loaded automatically -when the [cmd2.Cmd][] class is instantiated with this mixin. This also enables the developer to -dynamically add/remove commands from the cmd2 application. This could be useful for loadable plugins -that add additional capabilities. Additionally, it allows for object-oriented encapsulation and -garbage collection of state that is specific to a CommandSet. +`cmd2` application. By default, `CommandSet` objects need to be manually registered. However, it is +possible for all `CommandSet` objects to be discovered and loaded automatically when the +[cmd2.Cmd][] class is instantiated with this mixin by setting `auto_load_commands=True`. This also +enables the developer to dynamically add/remove commands from the `cmd2` application. This could be +useful for loadable plugins that add additional capabilities. Additionally, it allows for +object-oriented encapsulation and garbage collection of state that is specific to a CommandSet. ### Features - Modular Command Sets - Commands can be broken into separate modules rather than in one god class holding all commands. - Automatic Command Discovery - In your application, merely defining and importing a CommandSet is - sufficient for `cmd2` to discover and load your command. No manual registration is necessary. + sufficient for `cmd2` to discover and load your command if you set `auto_load_commands=True`. No + manual registration is necessary. - Dynamically Loadable/Unloadable Commands - Command functions and CommandSets can both be loaded and unloaded dynamically during application execution. This can enable features such as dynamically loaded modules that add additional commands. @@ -71,7 +73,7 @@ class ExampleApp(cmd2.Cmd): CommandSets are automatically loaded. Nothing needs to be done. """ def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + super().__init__(*args, auto_load_commands=True, **kwargs) def do_something(self, arg): self.poutput('this is the something command') @@ -106,7 +108,7 @@ class ExampleApp(cmd2.Cmd): """ def __init__(self, *args, **kwargs): # gotta have this or neither the plugin or cmd2 will initialize - super().__init__(*args, **kwargs) + super().__init__(*args, auto_load_commands=True, **kwargs) def do_something(self, arg): self.last_result = 5 @@ -289,7 +291,7 @@ class LoadableVegetables(CommandSet): class ExampleApp(cmd2.Cmd): """ - CommandSets are automatically loaded. Nothing needs to be done. + CommandSets are loaded dynamically at runtime via other commands. """ def __init__(self, *args, **kwargs): diff --git a/docs/upgrades.md b/docs/upgrades.md index 1ae783a83..f8a31326e 100644 --- a/docs/upgrades.md +++ b/docs/upgrades.md @@ -106,3 +106,11 @@ however it now inherits from `argparse.HelpFormatter`. If you want RawText behav The benefit is that your `cmd2` applications now have more aesthetically pleasing help which includes color to make it quicker and easier to visually parse help text. This works for all supported versions of Python. + +### Other Changes + +- The `auto_load_commands` argument to `cmd2.Cmd.__init__` now defaults to `False` +- Replaced `Settable.get_value()` and `Settable.set_value()` methods with a more Pythonic `value` + property +- Removed redundant setting of a parser's `prog` value in the `with_argparser()` decorator, as this + is now handled centrally in `Cmd._build_parser()` diff --git a/pyproject.toml b/pyproject.toml index 24ba086c4..7b7a1e58f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,17 +83,16 @@ disallow_untyped_defs = true exclude = [ "^.git/", "^.venv/", - "^build/", # .build directory - "^docs/", # docs directory + "^build/", # .build directory + "^docs/", # docs directory "^dist/", - "^examples/", # examples directory - "^plugins/*", # plugins directory - "^noxfile\\.py$", # nox config file - "setup\\.py$", # any files named setup.py + "^examples/", # examples directory + "^plugins/*", # plugins directory + "^noxfile\\.py$", # nox config file + "setup\\.py$", # any files named setup.py "^site/", - "^tasks\\.py$", # tasks.py invoke config file - "^tests/", # tests directory - "^tests_isolated/", # tests_isolated directory + "^tasks\\.py$", # tasks.py invoke config file + "^tests/", # tests directory ] files = ['.'] show_column_numbers = true @@ -270,7 +269,7 @@ mccabe.max-complexity = 49 "plugins/*.py" = ["INP001"] # Module is part of an implicit namespace # Ingore various rulesets in test and plugins directories -"{plugins,tests,tests_isolated}/*.py" = [ +"{plugins,tests}/*.py" = [ "ANN", # Ignore all type annotation rules in test folders "ARG", # Ignore all unused argument warnings in test folders "D", # Ignore all pydocstyle rules in test folders diff --git a/tasks.py b/tasks.py index e848c6459..a74051cd1 100644 --- a/tasks.py +++ b/tasks.py @@ -52,28 +52,18 @@ def rmrf(items: str | list[str] | set[str], verbose: bool = True) -> None: @invoke.task() -def pytest(context: Context, junit: bool = False, pty: bool = True, base: bool = False, isolated: bool = False) -> None: +def pytest(context: Context, junit: bool = False, pty: bool = True) -> None: """Run tests and code coverage using pytest.""" with context.cd(TASK_ROOT_STR): command_str = 'pytest ' command_str += ' --cov=cmd2 ' command_str += ' --cov-append --cov-report=term --cov-report=html ' - if not base and not isolated: - base = True - isolated = True - if junit: command_str += ' --junitxml=junit/test-results.xml ' - if base: - tests_cmd = command_str + ' tests' - context.run(tests_cmd, pty=pty) - if isolated: - for _root, dirnames, _ in os.walk(str(TASK_ROOT / 'tests_isolated')): - for dir_name in dirnames: - if dir_name.startswith('test_'): - context.run(command_str + ' tests_isolated/' + dir_name) + tests_cmd = command_str + ' tests' + context.run(tests_cmd, pty=pty) namespace.add_task(pytest) diff --git a/tests/conftest.py b/tests/conftest.py index b77f9659d..fa31b42b9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -239,3 +239,13 @@ class WithCommandSets(ExternalTestMixin, cmd2.Cmd): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) + + +@pytest.fixture +def autoload_command_sets_app(): + return WithCommandSets(auto_load_commands=True) + + +@pytest.fixture +def manual_command_sets_app(): + return WithCommandSets(auto_load_commands=False) diff --git a/tests_isolated/test_commandset.py b/tests/test_commandset.py similarity index 80% rename from tests_isolated/test_commandset.py rename to tests/test_commandset.py index e01133004..63df00080 100644 --- a/tests_isolated/test_commandset.py +++ b/tests/test_commandset.py @@ -120,17 +120,17 @@ def do_crocodile(self, statement: cmd2.Statement) -> None: self._cmd.poutput('Crocodile!!') -def test_autoload_commands(command_sets_app) -> None: +def test_autoload_commands(autoload_command_sets_app) -> None: # verifies that, when autoload is enabled, CommandSets and registered functions all show up - cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = command_sets_app._build_command_info() + cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = autoload_command_sets_app._build_command_info() assert 'Alone' in cmds_cats assert 'elderberry' in cmds_cats['Alone'] assert 'main' in cmds_cats['Alone'] # Test subcommand was autoloaded - result = command_sets_app.app_cmd('main sub') + result = autoload_command_sets_app.app_cmd('main sub') assert 'Subcommand Ran' in result.stdout assert 'Also Alone' in cmds_cats @@ -230,19 +230,19 @@ def test_custom_construct_commandsets() -> None: assert command_set_2 not in matches -def test_load_commands(command_sets_manual, capsys) -> None: +def test_load_commands(manual_command_sets_app, capsys) -> None: # now install a command set and verify the commands are now present cmd_set = CommandSetA() - assert command_sets_manual.find_commandset_for_command('elderberry') is None - assert not command_sets_manual.find_commandsets(CommandSetA) + assert manual_command_sets_app.find_commandset_for_command('elderberry') is None + assert not manual_command_sets_app.find_commandsets(CommandSetA) - command_sets_manual.register_command_set(cmd_set) + manual_command_sets_app.register_command_set(cmd_set) - assert command_sets_manual.find_commandsets(CommandSetA)[0] is cmd_set - assert command_sets_manual.find_commandset_for_command('elderberry') is cmd_set + assert manual_command_sets_app.find_commandsets(CommandSetA)[0] is cmd_set + assert manual_command_sets_app.find_commandset_for_command('elderberry') is cmd_set - out = command_sets_manual.app_cmd('apple') + out = manual_command_sets_app.app_cmd('apple') assert 'Apple!' in out.stdout # Make sure registration callbacks ran @@ -250,23 +250,23 @@ def test_load_commands(command_sets_manual, capsys) -> None: assert "in on_register now" in out assert "in on_registered now" in out - cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = command_sets_manual._build_command_info() + cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info() assert 'Alone' in cmds_cats assert 'elderberry' in cmds_cats['Alone'] assert 'main' in cmds_cats['Alone'] # Test subcommand was loaded - result = command_sets_manual.app_cmd('main sub') + result = manual_command_sets_app.app_cmd('main sub') assert 'Subcommand Ran' in result.stdout assert 'Fruits' in cmds_cats assert 'cranberry' in cmds_cats['Fruits'] # uninstall the command set and verify it is now also no longer accessible - command_sets_manual.unregister_command_set(cmd_set) + manual_command_sets_app.unregister_command_set(cmd_set) - cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = command_sets_manual._build_command_info() + cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info() assert 'Alone' not in cmds_cats assert 'Fruits' not in cmds_cats @@ -277,27 +277,27 @@ def test_load_commands(command_sets_manual, capsys) -> None: assert "in on_unregistered now" in out # uninstall a second time and verify no errors happen - command_sets_manual.unregister_command_set(cmd_set) + manual_command_sets_app.unregister_command_set(cmd_set) # reinstall the command set and verify it is accessible - command_sets_manual.register_command_set(cmd_set) + manual_command_sets_app.register_command_set(cmd_set) - cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = command_sets_manual._build_command_info() + cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info() assert 'Alone' in cmds_cats assert 'elderberry' in cmds_cats['Alone'] assert 'main' in cmds_cats['Alone'] # Test subcommand was loaded - result = command_sets_manual.app_cmd('main sub') + result = manual_command_sets_app.app_cmd('main sub') assert 'Subcommand Ran' in result.stdout assert 'Fruits' in cmds_cats assert 'cranberry' in cmds_cats['Fruits'] -def test_commandset_decorators(command_sets_app) -> None: - result = command_sets_app.app_cmd('cranberry juice extra1 extra2') +def test_commandset_decorators(autoload_command_sets_app) -> None: + result = autoload_command_sets_app.app_cmd('cranberry juice extra1 extra2') assert result is not None assert result.data is not None assert len(result.data['unknown']) == 2 @@ -306,49 +306,49 @@ def test_commandset_decorators(command_sets_app) -> None: assert result.data['arg1'] == 'juice' assert not result.stderr - result = command_sets_app.app_cmd('durian juice extra1 extra2') + result = autoload_command_sets_app.app_cmd('durian juice extra1 extra2') assert len(result.data['args']) == 3 assert 'juice' in result.data['args'] assert 'extra1' in result.data['args'] assert 'extra2' in result.data['args'] assert not result.stderr - result = command_sets_app.app_cmd('durian') + result = autoload_command_sets_app.app_cmd('durian') assert len(result.data['args']) == 0 assert not result.stderr - result = command_sets_app.app_cmd('elderberry') + result = autoload_command_sets_app.app_cmd('elderberry') assert 'arguments are required' in result.stderr assert result.data is None - result = command_sets_app.app_cmd('elderberry a b') + result = autoload_command_sets_app.app_cmd('elderberry a b') assert 'unrecognized arguments' in result.stderr assert result.data is None -def test_load_commandset_errors(command_sets_manual, capsys) -> None: +def test_load_commandset_errors(manual_command_sets_app, capsys) -> None: cmd_set = CommandSetA() # create a conflicting command before installing CommandSet to verify rollback behavior - command_sets_manual._install_command_function('do_durian', cmd_set.do_durian) + manual_command_sets_app._install_command_function('do_durian', cmd_set.do_durian) with pytest.raises(CommandSetRegistrationError): - command_sets_manual.register_command_set(cmd_set) + manual_command_sets_app.register_command_set(cmd_set) # verify that the commands weren't installed - cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = command_sets_manual._build_command_info() + cmds_cats, _cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info() assert 'Alone' not in cmds_cats assert 'Fruits' not in cmds_cats - assert not command_sets_manual._installed_command_sets + assert not manual_command_sets_app._installed_command_sets - delattr(command_sets_manual, 'do_durian') + delattr(manual_command_sets_app, 'do_durian') # pre-create intentionally conflicting macro and alias names - command_sets_manual.app_cmd('macro create apple run_pyscript') - command_sets_manual.app_cmd('alias create banana run_pyscript') + manual_command_sets_app.app_cmd('macro create apple run_pyscript') + manual_command_sets_app.app_cmd('alias create banana run_pyscript') # now install a command set and verify the commands are now present - command_sets_manual.register_command_set(cmd_set) + manual_command_sets_app.register_command_set(cmd_set) _out, err = capsys.readouterr() # verify aliases and macros are deleted with warning if they conflict with a command @@ -357,27 +357,27 @@ def test_load_commandset_errors(command_sets_manual, capsys) -> None: # verify command functions which don't start with "do_" raise an exception with pytest.raises(CommandSetRegistrationError): - command_sets_manual._install_command_function('new_cmd', cmd_set.do_banana) + manual_command_sets_app._install_command_function('new_cmd', cmd_set.do_banana) # verify methods which don't start with "do_" raise an exception with pytest.raises(CommandSetRegistrationError): - command_sets_manual._install_command_function('do_new_cmd', cmd_set.on_register) + manual_command_sets_app._install_command_function('do_new_cmd', cmd_set.on_register) # verify duplicate commands are detected with pytest.raises(CommandSetRegistrationError): - command_sets_manual._install_command_function('do_banana', cmd_set.do_banana) + manual_command_sets_app._install_command_function('do_banana', cmd_set.do_banana) # verify bad command names are detected with pytest.raises(CommandSetRegistrationError): - command_sets_manual._install_command_function('do_bad command', cmd_set.do_banana) + manual_command_sets_app._install_command_function('do_bad command', cmd_set.do_banana) # verify error conflict with existing completer function with pytest.raises(CommandSetRegistrationError): - command_sets_manual._install_completer_function('durian', cmd_set.complete_durian) + manual_command_sets_app._install_completer_function('durian', cmd_set.complete_durian) # verify error conflict with existing help function with pytest.raises(CommandSetRegistrationError): - command_sets_manual._install_help_function('cranberry', cmd_set.help_cranberry) + manual_command_sets_app._install_help_function('cranberry', cmd_set.help_cranberry) class LoadableBase(cmd2.CommandSet): @@ -508,7 +508,7 @@ def cut_bokchoy(self, ns: argparse.Namespace) -> None: self._cmd.poutput('Bok Choy: ' + ns.style) -def test_subcommands(command_sets_manual) -> None: +def test_subcommands(manual_command_sets_app) -> None: base_cmds = LoadableBase(1) badbase_cmds = LoadableBadBase(1) fruit_cmds = LoadableFruits(1) @@ -516,120 +516,120 @@ def test_subcommands(command_sets_manual) -> None: # installing subcommands without base command present raises exception with pytest.raises(CommandSetRegistrationError): - command_sets_manual.register_command_set(fruit_cmds) + manual_command_sets_app.register_command_set(fruit_cmds) # if the base command is present but isn't an argparse command, expect exception - command_sets_manual.register_command_set(badbase_cmds) + manual_command_sets_app.register_command_set(badbase_cmds) with pytest.raises(CommandSetRegistrationError): - command_sets_manual.register_command_set(fruit_cmds) + manual_command_sets_app.register_command_set(fruit_cmds) # verify that the commands weren't installed - cmds_cats, cmds_doc, _cmds_undoc, _help_topics = command_sets_manual._build_command_info() + cmds_cats, cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info() assert 'cut' in cmds_doc assert 'Fruits' not in cmds_cats # Now install the good base commands - command_sets_manual.unregister_command_set(badbase_cmds) - command_sets_manual.register_command_set(base_cmds) + manual_command_sets_app.unregister_command_set(badbase_cmds) + manual_command_sets_app.register_command_set(base_cmds) # verify that we catch an attempt to register subcommands when the commandset isn't installed with pytest.raises(CommandSetRegistrationError): - command_sets_manual._register_subcommands(fruit_cmds) + manual_command_sets_app._register_subcommands(fruit_cmds) - cmd_result = command_sets_manual.app_cmd('cut') + cmd_result = manual_command_sets_app.app_cmd('cut') assert 'This command does nothing without sub-parsers registered' in cmd_result.stderr # verify that command set install without problems - command_sets_manual.register_command_set(fruit_cmds) - command_sets_manual.register_command_set(veg_cmds) - cmds_cats, cmds_doc, _cmds_undoc, _help_topics = command_sets_manual._build_command_info() + manual_command_sets_app.register_command_set(fruit_cmds) + manual_command_sets_app.register_command_set(veg_cmds) + cmds_cats, cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info() assert 'Fruits' in cmds_cats text = '' line = f'cut {text}' endidx = len(line) begidx = endidx - first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app) assert first_match is not None # check that the alias shows up correctly - assert command_sets_manual.completion_matches == ['banana', 'bananer', 'bokchoy'] + assert manual_command_sets_app.completion_matches == ['banana', 'bananer', 'bokchoy'] - cmd_result = command_sets_manual.app_cmd('cut banana discs') + cmd_result = manual_command_sets_app.app_cmd('cut banana discs') assert 'cutting banana: discs' in cmd_result.stdout text = '' line = f'cut bokchoy {text}' endidx = len(line) begidx = endidx - first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app) assert first_match is not None # verify that argparse completer in commandset functions correctly - assert command_sets_manual.completion_matches == ['diced', 'quartered'] + assert manual_command_sets_app.completion_matches == ['diced', 'quartered'] # verify that command set uninstalls without problems - command_sets_manual.unregister_command_set(fruit_cmds) - cmds_cats, cmds_doc, _cmds_undoc, _help_topics = command_sets_manual._build_command_info() + manual_command_sets_app.unregister_command_set(fruit_cmds) + cmds_cats, cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info() assert 'Fruits' not in cmds_cats # verify a double-unregister raises exception with pytest.raises(CommandSetRegistrationError): - command_sets_manual._unregister_subcommands(fruit_cmds) - command_sets_manual.unregister_command_set(veg_cmds) + manual_command_sets_app._unregister_subcommands(fruit_cmds) + manual_command_sets_app.unregister_command_set(veg_cmds) # Disable command and verify subcommands still load and unload - command_sets_manual.disable_command('cut', 'disabled for test') + manual_command_sets_app.disable_command('cut', 'disabled for test') # verify that command set install without problems - command_sets_manual.register_command_set(fruit_cmds) - command_sets_manual.register_command_set(veg_cmds) + manual_command_sets_app.register_command_set(fruit_cmds) + manual_command_sets_app.register_command_set(veg_cmds) - command_sets_manual.enable_command('cut') + manual_command_sets_app.enable_command('cut') - cmds_cats, cmds_doc, _cmds_undoc, _help_topics = command_sets_manual._build_command_info() + cmds_cats, cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info() assert 'Fruits' in cmds_cats text = '' line = f'cut {text}' endidx = len(line) begidx = endidx - first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app) assert first_match is not None # check that the alias shows up correctly - assert command_sets_manual.completion_matches == ['banana', 'bananer', 'bokchoy'] + assert manual_command_sets_app.completion_matches == ['banana', 'bananer', 'bokchoy'] text = '' line = f'cut bokchoy {text}' endidx = len(line) begidx = endidx - first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app) assert first_match is not None # verify that argparse completer in commandset functions correctly - assert command_sets_manual.completion_matches == ['diced', 'quartered'] + assert manual_command_sets_app.completion_matches == ['diced', 'quartered'] # disable again and verify can still uninstnall - command_sets_manual.disable_command('cut', 'disabled for test') + manual_command_sets_app.disable_command('cut', 'disabled for test') # verify that command set uninstalls without problems - command_sets_manual.unregister_command_set(fruit_cmds) - cmds_cats, cmds_doc, _cmds_undoc, _help_topics = command_sets_manual._build_command_info() + manual_command_sets_app.unregister_command_set(fruit_cmds) + cmds_cats, cmds_doc, _cmds_undoc, _help_topics = manual_command_sets_app._build_command_info() assert 'Fruits' not in cmds_cats # verify a double-unregister raises exception with pytest.raises(CommandSetRegistrationError): - command_sets_manual._unregister_subcommands(fruit_cmds) + manual_command_sets_app._unregister_subcommands(fruit_cmds) with pytest.raises(CommandSetRegistrationError): - command_sets_manual.unregister_command_set(base_cmds) + manual_command_sets_app.unregister_command_set(base_cmds) - command_sets_manual.unregister_command_set(veg_cmds) - command_sets_manual.unregister_command_set(base_cmds) + manual_command_sets_app.unregister_command_set(veg_cmds) + manual_command_sets_app.unregister_command_set(base_cmds) -def test_commandset_sigint(command_sets_manual) -> None: +def test_commandset_sigint(manual_command_sets_app) -> None: # shows that the command is able to continue execution if the sigint_handler # returns True that we've handled interrupting the command. class SigintHandledCommandSet(cmd2.CommandSet): @@ -642,8 +642,8 @@ def sigint_handler(self) -> bool: return True cs1 = SigintHandledCommandSet() - command_sets_manual.register_command_set(cs1) - out = command_sets_manual.app_cmd('foo') + manual_command_sets_app.register_command_set(cs1) + out = manual_command_sets_app.app_cmd('foo') assert 'in foo' in out.stdout assert 'end of foo' in out.stdout @@ -655,25 +655,25 @@ def do_bar(self, _) -> None: self._cmd.poutput('end of do bar') cs2 = SigintUnhandledCommandSet() - command_sets_manual.register_command_set(cs2) - out = command_sets_manual.app_cmd('bar') + manual_command_sets_app.register_command_set(cs2) + out = manual_command_sets_app.app_cmd('bar') assert 'in do bar' in out.stdout assert 'end of do bar' not in out.stdout -def test_nested_subcommands(command_sets_manual) -> None: +def test_nested_subcommands(manual_command_sets_app) -> None: base_cmds = LoadableBase(1) pasta_cmds = LoadablePastaStir(1) with pytest.raises(CommandSetRegistrationError): - command_sets_manual.register_command_set(pasta_cmds) + manual_command_sets_app.register_command_set(pasta_cmds) - command_sets_manual.register_command_set(base_cmds) + manual_command_sets_app.register_command_set(base_cmds) - command_sets_manual.register_command_set(pasta_cmds) + manual_command_sets_app.register_command_set(pasta_cmds) with pytest.raises(CommandSetRegistrationError): - command_sets_manual.unregister_command_set(base_cmds) + manual_command_sets_app.unregister_command_set(base_cmds) class BadNestedSubcommands(cmd2.CommandSet): def __init__(self, dummy) -> None: @@ -689,20 +689,20 @@ def stir_pasta_vigorously(self, ns: argparse.Namespace) -> None: self._cmd.poutput('stir the pasta vigorously') with pytest.raises(CommandSetRegistrationError): - command_sets_manual.register_command_set(BadNestedSubcommands(1)) + manual_command_sets_app.register_command_set(BadNestedSubcommands(1)) fruit_cmds = LoadableFruits(1) - command_sets_manual.register_command_set(fruit_cmds) + manual_command_sets_app.register_command_set(fruit_cmds) # validates custom namespace provider works correctly. Stir command will fail until # the cut command is called - result = command_sets_manual.app_cmd('stir pasta vigorously everyminute') + result = manual_command_sets_app.app_cmd('stir pasta vigorously everyminute') assert 'Need to cut before stirring' in result.stdout - result = command_sets_manual.app_cmd('cut banana discs') + result = manual_command_sets_app.app_cmd('cut banana discs') assert 'cutting banana: discs' in result.stdout - result = command_sets_manual.app_cmd('stir pasta vigorously everyminute') + result = manual_command_sets_app.app_cmd('stir pasta vigorously everyminute') assert 'stir the pasta vigorously' in result.stdout @@ -748,7 +748,7 @@ def cut_bokchoy(self, _: argparse.Namespace) -> None: @pytest.fixture def static_subcommands_app(): - return AppWithSubCommands() + return AppWithSubCommands(auto_load_commands=True) def test_static_subcommands(static_subcommands_app) -> None: @@ -831,7 +831,7 @@ def do_user_unrelated(self, ns: argparse.Namespace) -> None: self._cmd.poutput(f'something {ns.state}') -def test_cross_commandset_completer(command_sets_manual, capsys) -> None: +def test_cross_commandset_completer(manual_command_sets_app, capsys) -> None: global complete_states_expected_self # noqa: PLW0603 # This tests the different ways to locate the matching CommandSet when completing an argparse argument. # Exercises the 3 cases in cmd2.Cmd._resolve_func_self() which is called during argparse tab completion. @@ -850,48 +850,49 @@ def test_cross_commandset_completer(command_sets_manual, capsys) -> None: # Create instances of two different sub-class types to ensure no one removes the case 1 check in Cmd._resolve_func_self(). # If that check is removed, testing with only 1 sub-class type will still pass. Testing it with two sub-class types # will fail and show that the case 1 check cannot be removed. - command_sets_manual.register_command_set(user_sub1) - command_sets_manual.register_command_set(user_sub2) + manual_command_sets_app.register_command_set(user_sub1) + manual_command_sets_app.register_command_set(user_sub2) text = '' line = f'user_sub1 {text}' endidx = len(line) begidx = endidx complete_states_expected_self = user_sub1 - first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app) complete_states_expected_self = None assert first_match == 'alabama' - assert command_sets_manual.completion_matches == list(SupportFuncProvider.states) + assert manual_command_sets_app.completion_matches == list(SupportFuncProvider.states) assert ( - getattr(command_sets_manual.cmd_func('user_sub1').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY) == 'With Completer' + getattr(manual_command_sets_app.cmd_func('user_sub1').__func__, cmd2.constants.CMD_ATTR_HELP_CATEGORY) + == 'With Completer' ) - command_sets_manual.unregister_command_set(user_sub2) - command_sets_manual.unregister_command_set(user_sub1) + manual_command_sets_app.unregister_command_set(user_sub2) + manual_command_sets_app.unregister_command_set(user_sub1) #################################################################################################################### # This exercises Case 2 # If the CommandSet holding a command is unrelated to the CommandSet holding the completer function, then search # all installed CommandSet instances for one that is an exact type match - command_sets_manual.register_command_set(func_provider) - command_sets_manual.register_command_set(user_unrelated) + manual_command_sets_app.register_command_set(func_provider) + manual_command_sets_app.register_command_set(user_unrelated) text = '' line = f'user_unrelated {text}' endidx = len(line) begidx = endidx complete_states_expected_self = func_provider - first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app) complete_states_expected_self = None assert first_match == 'alabama' - assert command_sets_manual.completion_matches == list(SupportFuncProvider.states) + assert manual_command_sets_app.completion_matches == list(SupportFuncProvider.states) - command_sets_manual.unregister_command_set(user_unrelated) - command_sets_manual.unregister_command_set(func_provider) + manual_command_sets_app.unregister_command_set(user_unrelated) + manual_command_sets_app.unregister_command_set(func_provider) #################################################################################################################### # This exercises Case 3 @@ -899,22 +900,22 @@ def test_cross_commandset_completer(command_sets_manual, capsys) -> None: # and no exact type match can be found, but sub-class matches can be found and there is only a single # sub-class match, then use the lone sub-class match as the parent CommandSet. - command_sets_manual.register_command_set(user_sub1) - command_sets_manual.register_command_set(user_unrelated) + manual_command_sets_app.register_command_set(user_sub1) + manual_command_sets_app.register_command_set(user_unrelated) text = '' line = f'user_unrelated {text}' endidx = len(line) begidx = endidx complete_states_expected_self = user_sub1 - first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app) complete_states_expected_self = None assert first_match == 'alabama' - assert command_sets_manual.completion_matches == list(SupportFuncProvider.states) + assert manual_command_sets_app.completion_matches == list(SupportFuncProvider.states) - command_sets_manual.unregister_command_set(user_unrelated) - command_sets_manual.unregister_command_set(user_sub1) + manual_command_sets_app.unregister_command_set(user_unrelated) + manual_command_sets_app.unregister_command_set(user_sub1) #################################################################################################################### # Error Case 1 @@ -922,20 +923,20 @@ def test_cross_commandset_completer(command_sets_manual, capsys) -> None: # all installed CommandSet instances for one that is an exact type match, none are found # search for sub-class matches, also none are found. - command_sets_manual.register_command_set(user_unrelated) + manual_command_sets_app.register_command_set(user_unrelated) text = '' line = f'user_unrelated {text}' endidx = len(line) begidx = endidx - first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app) out, _err = capsys.readouterr() assert first_match is None - assert command_sets_manual.completion_matches == [] + assert manual_command_sets_app.completion_matches == [] assert "Could not find CommandSet instance" in out - command_sets_manual.unregister_command_set(user_unrelated) + manual_command_sets_app.unregister_command_set(user_unrelated) #################################################################################################################### # Error Case 2 @@ -943,24 +944,24 @@ def test_cross_commandset_completer(command_sets_manual, capsys) -> None: # all installed CommandSet instances for one that is an exact type match, none are found # search for sub-class matches, more than 1 is found. - command_sets_manual.register_command_set(user_sub1) - command_sets_manual.register_command_set(user_sub2) - command_sets_manual.register_command_set(user_unrelated) + manual_command_sets_app.register_command_set(user_sub1) + manual_command_sets_app.register_command_set(user_sub2) + manual_command_sets_app.register_command_set(user_unrelated) text = '' line = f'user_unrelated {text}' endidx = len(line) begidx = endidx - first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app) out, _err = capsys.readouterr() assert first_match is None - assert command_sets_manual.completion_matches == [] + assert manual_command_sets_app.completion_matches == [] assert "Could not find CommandSet instance" in out - command_sets_manual.unregister_command_set(user_unrelated) - command_sets_manual.unregister_command_set(user_sub2) - command_sets_manual.unregister_command_set(user_sub1) + manual_command_sets_app.unregister_command_set(user_unrelated) + manual_command_sets_app.unregister_command_set(user_sub2) + manual_command_sets_app.unregister_command_set(user_sub1) class CommandSetWithPathComplete(cmd2.CommandSet): @@ -976,16 +977,16 @@ def do_path(self, app: cmd2.Cmd, args) -> None: app.poutput(args.path) -def test_path_complete(command_sets_manual) -> None: +def test_path_complete(manual_command_sets_app) -> None: test_set = CommandSetWithPathComplete(1) - command_sets_manual.register_command_set(test_set) + manual_command_sets_app.register_command_set(test_set) text = '' line = f'path {text}' endidx = len(line) begidx = endidx - first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + first_match = complete_tester(text, line, begidx, endidx, manual_command_sets_app) assert first_match is not None diff --git a/tests_isolated/__init__.py b/tests_isolated/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests_isolated/conftest.py b/tests_isolated/conftest.py deleted file mode 100644 index 5a58627ec..000000000 --- a/tests_isolated/conftest.py +++ /dev/null @@ -1,196 +0,0 @@ -"""Cmd2 unit/functional testing""" - -import sys -from contextlib import ( - redirect_stderr, - redirect_stdout, -) -from typing import TYPE_CHECKING -from unittest import mock - -import pytest - -import cmd2 -from cmd2.rl_utils import readline -from cmd2.utils import StdSim - -if TYPE_CHECKING: - _Base = cmd2.Cmd -else: - _Base = object - - -class ExternalTestMixin(_Base): - """A cmd2 plugin (mixin class) that exposes an interface to execute application commands from python""" - - def __init__(self, *args, **kwargs): - """ - - :type self: cmd2.Cmd - :param args: - :param kwargs: - """ - # code placed here runs before cmd2 initializes - super().__init__(*args, **kwargs) - assert isinstance(self, cmd2.Cmd) - # code placed here runs after cmd2 initializes - self._pybridge = cmd2.py_bridge.PyBridge(self) - - def app_cmd(self, command: str, echo: bool | None = None) -> cmd2.CommandResult: - """ - Run the application command - - :param command: The application command as it would be written on the cmd2 application prompt - :param echo: Flag whether the command's output should be echoed to stdout/stderr - :return: A CommandResult object that captures stdout, stderr, and the command's result object - """ - assert isinstance(self, cmd2.Cmd) - assert isinstance(self, ExternalTestMixin) - try: - self._in_py = True - - return self._pybridge(command, echo=echo) - - finally: - self._in_py = False - - def fixture_setup(self): - """ - Replicates the behavior of `cmdloop()` preparing the state of the application - :type self: cmd2.Cmd - """ - - for func in self._preloop_hooks: - func() - self.preloop() - - def fixture_teardown(self): - """ - Replicates the behavior of `cmdloop()` tearing down the application - - :type self: cmd2.Cmd - """ - for func in self._postloop_hooks: - func() - self.postloop() - - -def verify_help_text(cmd2_app: cmd2.Cmd, help_output: str | list[str], verbose_strings: list[str] | None = None) -> None: - """This function verifies that all expected commands are present in the help text. - - :param cmd2_app: instance of cmd2.Cmd - :param help_output: output of help, either as a string or list of strings - :param verbose_strings: optional list of verbose strings to search for - """ - help_text = help_output if isinstance(help_output, str) else ''.join(help_output) - commands = cmd2_app.get_visible_commands() - for command in commands: - assert command in help_text - - if verbose_strings: - for verbose_string in verbose_strings: - assert verbose_string in help_text - - -# Output from the shortcuts command with default built-in shortcuts -SHORTCUTS_TXT = """Shortcuts for other commands: -!: shell -?: help -@: run_script -@@: _relative_run_script -""" - - -def normalize(block): - """Normalize a block of text to perform comparison. - - Strip newlines from the very beginning and very end Then split into separate lines and strip trailing whitespace - from each line. - """ - assert isinstance(block, str) - block = block.strip('\n') - return [line.rstrip() for line in block.splitlines()] - - -def run_cmd(app, cmd): - """Clear out and err StdSim buffers, run the command, and return out and err""" - saved_sysout = sys.stdout - sys.stdout = app.stdout - - # This will be used to capture app.stdout and sys.stdout - copy_cmd_stdout = StdSim(app.stdout) - - # This will be used to capture sys.stderr - copy_stderr = StdSim(sys.stderr) - - try: - app.stdout = copy_cmd_stdout - with redirect_stdout(copy_cmd_stdout), redirect_stderr(copy_stderr): - app.onecmd_plus_hooks(cmd) - finally: - app.stdout = copy_cmd_stdout.inner_stream - sys.stdout = saved_sysout - - out = copy_cmd_stdout.getvalue() - err = copy_stderr.getvalue() - return normalize(out), normalize(err) - - -@pytest.fixture -def base_app(): - return cmd2.Cmd() - - -# These are odd file names for testing quoting of them -odd_file_names = ['nothingweird', 'has spaces', '"is_double_quoted"', "'is_single_quoted'"] - - -def complete_tester(text: str, line: str, begidx: int, endidx: int, app) -> str | None: - """This is a convenience function to test cmd2.complete() since - in a unit test environment there is no actual console readline - is monitoring. Therefore we use mock to provide readline data - to complete(). - - :param text: the string prefix we are attempting to match - :param line: the current input line with leading whitespace removed - :param begidx: the beginning index of the prefix text - :param endidx: the ending index of the prefix text - :param app: the cmd2 app that will run completions - :return: The first matched string or None if there are no matches - Matches are stored in app.completion_matches - These matches also have been sorted by complete() - """ - - def get_line(): - return line - - def get_begidx(): - return begidx - - def get_endidx(): - return endidx - - # Run the readline tab completion function with readline mocks in place - with ( - mock.patch.object(readline, 'get_line_buffer', get_line), - mock.patch.object(readline, 'get_begidx', get_begidx), - mock.patch.object(readline, 'get_endidx', get_endidx), - ): - return app.complete(text, 0) - - -class WithCommandSets(ExternalTestMixin, cmd2.Cmd): - """Class for testing custom help_* methods which override docstring help.""" - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - -@pytest.fixture -def command_sets_app(): - return WithCommandSets() - - -@pytest.fixture -def command_sets_manual(): - return WithCommandSets(auto_load_commands=False)