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..eb4f943 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,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 new file mode 100644 index 0000000..cc331bc --- /dev/null +++ b/tests/test_env_var_structures_path.py @@ -0,0 +1,118 @@ +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) + + # 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) + + 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: + + # 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): + """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.""" + # Ensure env var is not set + env = os.environ.copy() + env.pop('STRUCTKIT_STRUCTURES_PATH', None) + + with patch.dict(os.environ, env, clear=True): + parser_without_env = argparse.ArgumentParser() + command_without_env = GenerateCommand(parser_without_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_without_env, '_create_structure') as mock_create_structure: + + # 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 + + 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_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 + + +def test_empty_env_var_is_ignored(parser): + """Test that an empty STRUCTKIT_STRUCTURES_PATH env var is treated as not set.""" + 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()