diff --git a/AUTHORS b/AUTHORS index e59aba4..fcfb6d3 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1 +1 @@ -waboring@hemna.com : 1 \ No newline at end of file +waboring@hemna.com : 1 diff --git a/aprsd/main.py b/aprsd/main.py index 25fc75b..1ed60bc 100644 --- a/aprsd/main.py +++ b/aprsd/main.py @@ -34,6 +34,7 @@ from oslo_config import cfg, generator import aprsd from aprsd import cli_helper, packets, threads, utils from aprsd.stats import collector +from aprsd.utils import config_converter # setup the global logger # log.basicConfig(level=log.DEBUG) # level=10 @@ -108,8 +109,14 @@ def check_version(ctx): @cli.command() +@click.option( + '--output-json', + default=False, + is_flag=True, + help='Output the sample config in JSON format instead of INI.', +) @click.pass_context -def sample_config(ctx): +def sample_config(ctx, output_json): """Generate a sample Config file from aprsd and all installed plugins.""" def _get_selected_entry_points(): @@ -151,8 +158,34 @@ def sample_config(ctx): if not sys.argv[1:]: raise SystemExit from ex raise - generator.generate(conf) - return + + if not output_json: + generator.generate(conf) + return + + import io + import json + from contextlib import redirect_stdout + + from rich.console import Console + + f = io.StringIO() + with redirect_stdout(f): + conf.format_ = 'json' + generator.generate(conf) + + s = f.getvalue() + c = Console() + c.print_json(data=json.loads(s)) + + +@cli.command() +@cli_helper.add_options(cli_helper.common_options) +@click.pass_context +@cli_helper.process_standard_options +def json_config(ctx): + """Output the current loaded configuration in JSON format.""" + click.echo(config_converter.conf_to_json(CONF)) @cli.command() diff --git a/requirements-dev.txt b/requirements-dev.txt index 9f54a27..b545951 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,7 +8,6 @@ click==8.3.1 # via pip-tools colorama==0.4.6 # via tox coverage==7.13.1 # via pytest-cov distlib==0.4.0 # via virtualenv -exceptiongroup==1.3.1 # via pytest filelock==3.20.0 # via tox, virtualenv identify==2.6.15 # via pre-commit iniconfig==2.3.0 # via pytest @@ -32,13 +31,12 @@ pytest-cov==7.0.0 # via -r requirements-dev.in pyyaml==6.0.3 # via pre-commit ruff==0.14.13 # via -r requirements-dev.in setuptools==80.9.0 # via pip-tools -tomli==2.4.0 # via build, coverage, mypy, pip-tools, pyproject-api, pytest, tox, tox-uv tox==4.32.0 # via tox-uv, -r requirements-dev.in tox-uv==1.29.0 # via -r requirements-dev.in types-pytz==2025.2.0.20251108 # via types-tzlocal, -r requirements-dev.in types-requests==2.32.4.20260107 # via -r requirements-dev.in types-tzlocal==5.1.0.1 # via -r requirements-dev.in -typing-extensions==4.15.0 # via exceptiongroup, mypy, tox, virtualenv +typing-extensions==4.15.0 # via mypy urllib3==2.6.2 # via types-requests uv==0.9.26 # via pre-commit-uv, tox-uv virtualenv==20.35.4 # via pre-commit, tox diff --git a/tests/cmds/test_sample_config.py b/tests/cmds/test_sample_config.py new file mode 100644 index 0000000..ae258ef --- /dev/null +++ b/tests/cmds/test_sample_config.py @@ -0,0 +1,144 @@ +import sys +import unittest +from unittest import mock + +from click.testing import CliRunner + +from aprsd.main import cli + + +class TestSampleConfigCommand(unittest.TestCase): + """Unit tests for the sample_config command.""" + + def _create_mock_entry_point(self, name): + """Create a mock entry point object.""" + mock_entry = mock.Mock() + mock_entry.name = name + mock_entry.group = 'oslo.config.opts' + return mock_entry + + @mock.patch('aprsd.main.generator.generate') + @mock.patch('aprsd.main.imp.entry_points') + @mock.patch('aprsd.main.metadata_version') + def test_sample_config_default_ini_output( + self, mock_version, mock_entry_points, mock_generate + ): + """Test sample_config command outputs INI format by default.""" + mock_version.return_value = '1.0.0' + # Mock entry_points to return at least one aprsd entry point + # so that get_namespaces() returns a non-empty list + if sys.version_info >= (3, 10): + mock_entry_points.return_value = [ + self._create_mock_entry_point('aprsd.conf') + ] + else: + # For Python < 3.10, entry_points() returns a dict-like object + mock_entry = self._create_mock_entry_point('aprsd.conf') + mock_dict = {'oslo.config.opts': [mock_entry]} + mock_entry_points.return_value = mock_dict + + runner = CliRunner() + result = runner.invoke( + cli, + ['sample-config'], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + # Verify generator.generate was called + mock_generate.assert_called_once() + # The conf object passed should not have format_ set to 'json' + call_args = mock_generate.call_args + conf_obj = call_args[0][0] + # When output_json is False, format_ should not be set to 'json' + assert not hasattr(conf_obj, 'format_') or conf_obj.format_ != 'json' + + @mock.patch('rich.console.Console') + @mock.patch('aprsd.main.generator.generate') + @mock.patch('aprsd.main.imp.entry_points') + @mock.patch('aprsd.main.metadata_version') + def test_sample_config_json_output( + self, mock_version, mock_entry_points, mock_generate, mock_console + ): + """Test sample_config command with --output-json flag outputs JSON format.""" + mock_version.return_value = '1.0.0' + # Mock entry_points to return at least one aprsd entry point + if sys.version_info >= (3, 10): + mock_entry_points.return_value = [ + self._create_mock_entry_point('aprsd.conf') + ] + else: + # For Python < 3.10, entry_points() returns a dict-like object + mock_entry = self._create_mock_entry_point('aprsd.conf') + mock_dict = {'oslo.config.opts': [mock_entry]} + mock_entry_points.return_value = mock_dict + + # Mock generator.generate to write JSON to stdout + # This simulates what oslo.config generator does when format_='json' + def generate_side_effect(conf): + import sys + + json_output = '{"test": "config", "version": "1.0"}' + sys.stdout.write(json_output) + + mock_generate.side_effect = generate_side_effect + + # Mock the Console + mock_console_instance = mock.Mock() + mock_console.return_value = mock_console_instance + + runner = CliRunner() + result = runner.invoke( + cli, + ['sample-config', '--output-json'], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + # Verify generator.generate was called + mock_generate.assert_called_once() + # Verify Console was instantiated + mock_console.assert_called_once() + # Verify print_json was called with parsed JSON + mock_console_instance.print_json.assert_called_once() + call_args = mock_console_instance.print_json.call_args + # The data argument should be a dict (parsed JSON) + assert isinstance(call_args[1]['data'], dict) + assert call_args[1]['data'] == {'test': 'config', 'version': '1.0'} + # Verify that conf.format_ was set to 'json' before generate was called + generate_call_conf = mock_generate.call_args[0][0] + assert generate_call_conf.format_ == 'json' + + @mock.patch('aprsd.main.generator.generate') + @mock.patch('aprsd.main.imp.entry_points') + @mock.patch('aprsd.main.metadata_version') + def test_sample_config_without_flag( + self, mock_version, mock_entry_points, mock_generate + ): + """Test sample_config command without --output-json flag (explicit default).""" + mock_version.return_value = '1.0.0' + # Mock entry_points to return at least one aprsd entry point + if sys.version_info >= (3, 10): + mock_entry_points.return_value = [ + self._create_mock_entry_point('aprsd.conf') + ] + else: + # For Python < 3.10, entry_points() returns a dict-like object + mock_entry = self._create_mock_entry_point('aprsd.conf') + mock_dict = {'oslo.config.opts': [mock_entry]} + mock_entry_points.return_value = mock_dict + + runner = CliRunner() + result = runner.invoke( + cli, + ['sample-config'], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + # Verify generator.generate was called + mock_generate.assert_called_once() + # Verify format_ was not set to 'json' + call_args = mock_generate.call_args + conf_obj = call_args[0][0] + assert not hasattr(conf_obj, 'format_') or conf_obj.format_ != 'json'