From ea17267794b3989ea0d76bba5008f41651be5de4 Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Wed, 29 Oct 2025 19:08:52 -0300 Subject: [PATCH 1/3] feat: Support STRUCTKIT_STRUCTURES_PATH environment variable - Add STRUCTKIT_STRUCTURES_PATH env var support for --structures-path argument - CLI argument takes precedence over environment variable - Add comprehensive test suite covering all precedence scenarios - Update CLI documentation with environment variables section - Update generate command docs to mention the new env var --- docs/cli-reference.md | 20 ++++- structkit/commands/generate.py | 7 ++ tests/test_env_var_structures_path.py | 110 ++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 tests/test_env_var_structures_path.py diff --git a/docs/cli-reference.md b/docs/cli-reference.md index de06203..41c326c 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -21,6 +21,24 @@ These options are available for all commands: - `-c CONFIG_FILE, --config-file CONFIG_FILE`: Path to a configuration file. - `-i LOG_FILE, --log-file LOG_FILE`: Path to a log file. +## Environment Variables + +The following environment variables can be used to configure default values for CLI arguments: + +- `STRUCTKIT_LOG_LEVEL`: Set the default logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL). Overridden by the `--log` flag. +- `STRUCTKIT_STRUCTURES_PATH`: Set the default path to structure definitions. This is used when the `--structures-path` flag is not provided. + +**Example:** + +```sh +# Set a default structures path +export STRUCTKIT_STRUCTURES_PATH=~/custom-structures + +# Now you can omit the -s flag +structkit generate python-basic ./my-project +# Equivalent to: structkit generate -s ~/custom-structures python-basic ./my-project +``` + ## Commands ### `info` @@ -75,7 +93,7 @@ structkit generate - `structure_definition` (optional): Path to the YAML configuration file (default: `.struct.yaml`). - `base_path` (optional): Base path where the structure will be created (default: `.`). -- `-s STRUCTURES_PATH, --structures-path STRUCTURES_PATH`: Path to structure definitions. +- `-s STRUCTURES_PATH, --structures-path STRUCTURES_PATH`: Path to structure definitions. Can also be set via the `STRUCTKIT_STRUCTURES_PATH` environment variable (CLI flag takes precedence). - `-n INPUT_STORE, --input-store INPUT_STORE`: Path to the input store. - `-d, --dry-run`: Perform a dry run without creating any files or directories. - `--diff`: Show unified diffs for files that would be created/modified (works with `--dry-run` and in `-o console` mode). diff --git a/structkit/commands/generate.py b/structkit/commands/generate.py index 40dbbb8..ef7c702 100644 --- a/structkit/commands/generate.py +++ b/structkit/commands/generate.py @@ -124,6 +124,13 @@ def execute(self, args): self.logger.info(f" Structure definition: {args.structure_definition}") self.logger.info(f" Base path: {args.base_path}") + # Resolve structures_path precedence: CLI arg > STRUCTKIT_STRUCTURES_PATH env > None + if not args.structures_path: + env_structures_path = os.getenv('STRUCTKIT_STRUCTURES_PATH') + if env_structures_path: + args.structures_path = env_structures_path + self.logger.info(f"Using STRUCTKIT_STRUCTURES_PATH: {env_structures_path}") + # Load mappings if provided mappings = {} if getattr(args, 'mappings_file', None): diff --git a/tests/test_env_var_structures_path.py b/tests/test_env_var_structures_path.py new file mode 100644 index 0000000..16d231e --- /dev/null +++ b/tests/test_env_var_structures_path.py @@ -0,0 +1,110 @@ +import pytest +from unittest.mock import patch, MagicMock +from structkit.commands.generate import GenerateCommand +import argparse +import os + + +@pytest.fixture +def parser(): + return argparse.ArgumentParser() + + +def test_env_var_structures_path_used_when_no_cli_arg(parser): + """Test that STRUCTKIT_STRUCTURES_PATH env var is used when --structures-path is not provided.""" + command = GenerateCommand(parser) + + with patch.dict(os.environ, {'STRUCTKIT_STRUCTURES_PATH': '/custom/structures'}), \ + patch('os.path.exists', return_value=True), \ + patch('builtins.open', new_callable=MagicMock), \ + patch('yaml.safe_load', return_value={'files': []}), \ + patch.object(command, '_create_structure') as mock_create_structure: + + # Parse args without --structures-path + args = parser.parse_args(['structure.yaml', 'base_path']) + command.execute(args) + + # Verify structures_path was set from environment variable + assert args.structures_path == '/custom/structures' + mock_create_structure.assert_called_once() + + +def test_cli_arg_takes_precedence_over_env_var(parser): + """Test that CLI --structures-path takes precedence over STRUCTKIT_STRUCTURES_PATH env var.""" + command = GenerateCommand(parser) + + with patch.dict(os.environ, {'STRUCTKIT_STRUCTURES_PATH': '/env/structures'}), \ + patch('os.path.exists', return_value=True), \ + patch('builtins.open', new_callable=MagicMock), \ + patch('yaml.safe_load', return_value={'files': []}), \ + patch.object(command, '_create_structure') as mock_create_structure: + + # Parse args WITH --structures-path + args = parser.parse_args(['--structures-path', '/cli/structures', 'structure.yaml', 'base_path']) + command.execute(args) + + # Verify CLI arg was not overridden by env var + assert args.structures_path == '/cli/structures' + mock_create_structure.assert_called_once() + + +def test_no_structures_path_when_env_var_not_set(parser): + """Test that structures_path remains None when neither CLI arg nor env var is provided.""" + command = GenerateCommand(parser) + + # Ensure env var is not set + env = os.environ.copy() + env.pop('STRUCTKIT_STRUCTURES_PATH', None) + + with patch.dict(os.environ, env, clear=True), \ + patch('os.path.exists', return_value=True), \ + patch('builtins.open', new_callable=MagicMock), \ + patch('yaml.safe_load', return_value={'files': []}), \ + patch.object(command, '_create_structure') as mock_create_structure: + + # Parse args without --structures-path + args = parser.parse_args(['structure.yaml', 'base_path']) + command.execute(args) + + # Verify structures_path remains None + assert args.structures_path is None + mock_create_structure.assert_called_once() + + +def test_env_var_logging_message(parser, caplog): + """Test that a log message is emitted when using STRUCTKIT_STRUCTURES_PATH env var.""" + import logging + command = GenerateCommand(parser) + + with patch.dict(os.environ, {'STRUCTKIT_STRUCTURES_PATH': '/custom/structures'}), \ + patch('os.path.exists', return_value=True), \ + patch('builtins.open', new_callable=MagicMock), \ + patch('yaml.safe_load', return_value={'files': []}), \ + patch.object(command, '_create_structure') as mock_create_structure: + + # Enable debug logging to capture the log message + with caplog.at_level(logging.INFO): + args = parser.parse_args(['structure.yaml', 'base_path']) + command.execute(args) + + # Verify log message was emitted + assert 'Using STRUCTKIT_STRUCTURES_PATH: /custom/structures' in caplog.text + + +def test_empty_env_var_is_ignored(parser): + """Test that an empty STRUCTKIT_STRUCTURES_PATH env var is treated as not set.""" + command = GenerateCommand(parser) + + with patch.dict(os.environ, {'STRUCTKIT_STRUCTURES_PATH': ''}), \ + patch('os.path.exists', return_value=True), \ + patch('builtins.open', new_callable=MagicMock), \ + patch('yaml.safe_load', return_value={'files': []}), \ + patch.object(command, '_create_structure') as mock_create_structure: + + # Parse args without --structures-path + args = parser.parse_args(['structure.yaml', 'base_path']) + command.execute(args) + + # Verify structures_path remains None (empty string is falsy) + assert args.structures_path == '' or args.structures_path is None + mock_create_structure.assert_called_once() From 1d68a0c238d5561340d0cd8cec7010fc0625da9c Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Wed, 29 Oct 2025 19:16:38 -0300 Subject: [PATCH 2/3] refactor: Use argument parser default for STRUCTKIT_STRUCTURES_PATH - Set default value directly in argument parser instead of handling in execute() - Cleaner and more idiomatic argparse usage - Same functionality and precedence behavior --- structkit/commands/generate.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/structkit/commands/generate.py b/structkit/commands/generate.py index ef7c702..c9f4e5c 100644 --- a/structkit/commands/generate.py +++ b/structkit/commands/generate.py @@ -17,7 +17,13 @@ def __init__(self, parser): structure_arg = parser.add_argument('structure_definition', nargs='?', default='.struct.yaml', type=str, help='Path to the YAML configuration file (default: .struct.yaml)') structure_arg.completer = structures_completer parser.add_argument('base_path', nargs='?', default='.', type=str, help='Base path where the structure will be created (default: current directory)') - parser.add_argument('-s', '--structures-path', type=str, help='Path to structure definitions') + parser.add_argument( + '-s', + '--structures-path', + type=str, + help='Path to structure definitions', + default=os.getenv('STRUCTKIT_STRUCTURES_PATH') + ) parser.add_argument('-n', '--input-store', type=str, help='Path to the input store', default='/tmp/structkit/input.json') parser.add_argument('-d', '--dry-run', action='store_true', help='Perform a dry run without creating any files or directories') parser.add_argument('--diff', action='store_true', help='Show unified diffs for files that would change during dry-run or console output') @@ -124,13 +130,6 @@ def execute(self, args): self.logger.info(f" Structure definition: {args.structure_definition}") self.logger.info(f" Base path: {args.base_path}") - # Resolve structures_path precedence: CLI arg > STRUCTKIT_STRUCTURES_PATH env > None - if not args.structures_path: - env_structures_path = os.getenv('STRUCTKIT_STRUCTURES_PATH') - if env_structures_path: - args.structures_path = env_structures_path - self.logger.info(f"Using STRUCTKIT_STRUCTURES_PATH: {env_structures_path}") - # Load mappings if provided mappings = {} if getattr(args, 'mappings_file', None): From c89c67ddb99b966295118f240040f91e844f7f89 Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Wed, 29 Oct 2025 19:18:09 -0300 Subject: [PATCH 3/3] fix: Update tests for argparse default handling and add logging - Update all tests to re-create parsers with env vars set - This ensures os.getenv() is called during GenerateCommand.__init__ - Add logging when STRUCTKIT_STRUCTURES_PATH environment variable is used - All 5 tests now pass successfully --- structkit/commands/generate.py | 4 + tests/test_env_var_structures_path.py | 110 ++++++++++++++------------ 2 files changed, 63 insertions(+), 51 deletions(-) diff --git a/structkit/commands/generate.py b/structkit/commands/generate.py index c9f4e5c..eb4f943 100644 --- a/structkit/commands/generate.py +++ b/structkit/commands/generate.py @@ -130,6 +130,10 @@ def execute(self, args): self.logger.info(f" Structure definition: {args.structure_definition}") self.logger.info(f" Base path: {args.base_path}") + # Log if using STRUCTKIT_STRUCTURES_PATH environment variable + if args.structures_path and os.getenv('STRUCTKIT_STRUCTURES_PATH') == args.structures_path: + self.logger.info(f"Using STRUCTKIT_STRUCTURES_PATH: {args.structures_path}") + # Load mappings if provided mappings = {} if getattr(args, 'mappings_file', None): diff --git a/tests/test_env_var_structures_path.py b/tests/test_env_var_structures_path.py index 16d231e..cc331bc 100644 --- a/tests/test_env_var_structures_path.py +++ b/tests/test_env_var_structures_path.py @@ -14,19 +14,23 @@ def test_env_var_structures_path_used_when_no_cli_arg(parser): """Test that STRUCTKIT_STRUCTURES_PATH env var is used when --structures-path is not provided.""" command = GenerateCommand(parser) - with patch.dict(os.environ, {'STRUCTKIT_STRUCTURES_PATH': '/custom/structures'}), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.open', new_callable=MagicMock), \ - patch('yaml.safe_load', return_value={'files': []}), \ - patch.object(command, '_create_structure') as mock_create_structure: + # Re-create parser with env var set to capture it in the default + with patch.dict(os.environ, {'STRUCTKIT_STRUCTURES_PATH': '/custom/structures'}): + parser_with_env = argparse.ArgumentParser() + command_with_env = GenerateCommand(parser_with_env) - # Parse args without --structures-path - args = parser.parse_args(['structure.yaml', 'base_path']) - command.execute(args) + with patch('os.path.exists', return_value=True), \ + patch('builtins.open', new_callable=MagicMock), \ + patch('yaml.safe_load', return_value={'files': []}), \ + patch.object(command_with_env, '_create_structure') as mock_create_structure: - # Verify structures_path was set from environment variable - assert args.structures_path == '/custom/structures' - mock_create_structure.assert_called_once() + # Parse args without --structures-path + args = parser_with_env.parse_args(['structure.yaml', 'base_path']) + command_with_env.execute(args) + + # Verify structures_path was set from environment variable + assert args.structures_path == '/custom/structures' + mock_create_structure.assert_called_once() def test_cli_arg_takes_precedence_over_env_var(parser): @@ -50,61 +54,65 @@ def test_cli_arg_takes_precedence_over_env_var(parser): def test_no_structures_path_when_env_var_not_set(parser): """Test that structures_path remains None when neither CLI arg nor env var is provided.""" - command = GenerateCommand(parser) - # Ensure env var is not set env = os.environ.copy() env.pop('STRUCTKIT_STRUCTURES_PATH', None) - with patch.dict(os.environ, env, clear=True), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.open', new_callable=MagicMock), \ - patch('yaml.safe_load', return_value={'files': []}), \ - patch.object(command, '_create_structure') as mock_create_structure: + with patch.dict(os.environ, env, clear=True): + parser_without_env = argparse.ArgumentParser() + command_without_env = GenerateCommand(parser_without_env) - # Parse args without --structures-path - args = parser.parse_args(['structure.yaml', 'base_path']) - command.execute(args) + with patch('os.path.exists', return_value=True), \ + patch('builtins.open', new_callable=MagicMock), \ + patch('yaml.safe_load', return_value={'files': []}), \ + patch.object(command_without_env, '_create_structure') as mock_create_structure: - # Verify structures_path remains None - assert args.structures_path is None - mock_create_structure.assert_called_once() + # Parse args without --structures-path + args = parser_without_env.parse_args(['structure.yaml', 'base_path']) + command_without_env.execute(args) + + # Verify structures_path remains None + assert args.structures_path is None + mock_create_structure.assert_called_once() def test_env_var_logging_message(parser, caplog): """Test that a log message is emitted when using STRUCTKIT_STRUCTURES_PATH env var.""" import logging - command = GenerateCommand(parser) - with patch.dict(os.environ, {'STRUCTKIT_STRUCTURES_PATH': '/custom/structures'}), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.open', new_callable=MagicMock), \ - patch('yaml.safe_load', return_value={'files': []}), \ - patch.object(command, '_create_structure') as mock_create_structure: + with patch.dict(os.environ, {'STRUCTKIT_STRUCTURES_PATH': '/custom/structures'}): + parser_with_env = argparse.ArgumentParser() + command_with_env = GenerateCommand(parser_with_env) + + with patch('os.path.exists', return_value=True), \ + patch('builtins.open', new_callable=MagicMock), \ + patch('yaml.safe_load', return_value={'files': []}), \ + patch.object(command_with_env, '_create_structure') as mock_create_structure: - # Enable debug logging to capture the log message - with caplog.at_level(logging.INFO): - args = parser.parse_args(['structure.yaml', 'base_path']) - command.execute(args) + # Enable debug logging to capture the log message + with caplog.at_level(logging.INFO): + args = parser_with_env.parse_args(['structure.yaml', 'base_path']) + command_with_env.execute(args) - # Verify log message was emitted - assert 'Using STRUCTKIT_STRUCTURES_PATH: /custom/structures' in caplog.text + # Verify log message was emitted + assert 'Using STRUCTKIT_STRUCTURES_PATH: /custom/structures' in caplog.text def test_empty_env_var_is_ignored(parser): """Test that an empty STRUCTKIT_STRUCTURES_PATH env var is treated as not set.""" - command = GenerateCommand(parser) - - with patch.dict(os.environ, {'STRUCTKIT_STRUCTURES_PATH': ''}), \ - patch('os.path.exists', return_value=True), \ - patch('builtins.open', new_callable=MagicMock), \ - patch('yaml.safe_load', return_value={'files': []}), \ - patch.object(command, '_create_structure') as mock_create_structure: - - # Parse args without --structures-path - args = parser.parse_args(['structure.yaml', 'base_path']) - command.execute(args) - - # Verify structures_path remains None (empty string is falsy) - assert args.structures_path == '' or args.structures_path is None - mock_create_structure.assert_called_once() + with patch.dict(os.environ, {'STRUCTKIT_STRUCTURES_PATH': ''}): + parser_with_empty_env = argparse.ArgumentParser() + command_with_empty_env = GenerateCommand(parser_with_empty_env) + + with patch('os.path.exists', return_value=True), \ + patch('builtins.open', new_callable=MagicMock), \ + patch('yaml.safe_load', return_value={'files': []}), \ + patch.object(command_with_empty_env, '_create_structure') as mock_create_structure: + + # Parse args without --structures-path + args = parser_with_empty_env.parse_args(['structure.yaml', 'base_path']) + command_with_empty_env.execute(args) + + # Verify structures_path is empty string (from empty env var) + assert args.structures_path == '' or args.structures_path is None + mock_create_structure.assert_called_once()