From 279794bfd1e9420e6e6cc1461792338dc4b97e16 Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Sat, 24 May 2025 18:55:41 +0000 Subject: [PATCH 1/2] Add pre-hooks and post-hooks support for structure generation --- README.es.md | 29 +++++++++++ README.md | 29 +++++++++++ struct-schema.json | 10 ++++ struct_module/commands/generate.py | 80 +++++++++++++++++++++++------- struct_module/commands/validate.py | 9 ++++ tests/test_commands.py | 8 ++- tests/test_hooks.py | 69 ++++++++++++++++++++++++++ 7 files changed, 214 insertions(+), 20 deletions(-) create mode 100644 tests/test_hooks.py diff --git a/README.es.md b/README.es.md index 251e8d8..1d62a7d 100644 --- a/README.es.md +++ b/README.es.md @@ -348,6 +348,35 @@ python3 scripts/github-trigger.py mi-org struct-enabled - El token debe tener permisos suficientes para acceder a repositorios privados y activar flujos de trabajo. - Los errores durante la ejecución (por ejemplo, archivos faltantes o permisos insuficientes) se registrarán en la consola. +## 🪝 Ganchos de Pre-generación y Post-generación + +Puedes definir comandos de shell para ejecutar antes y después de la generación de la estructura usando las claves `pre_hooks` y `post_hooks` en tu configuración YAML. Son opcionales y te permiten automatizar pasos de preparación o limpieza. + +- **pre_hooks**: Lista de comandos de shell a ejecutar antes de la generación. Si algún comando falla (código distinto de cero), la generación se aborta. +- **post_hooks**: Lista de comandos de shell a ejecutar después de completar la generación. Si algún comando falla, se muestra un error. + +Ejemplo: + +```yaml +pre_hooks: + - echo "Preparando el entorno..." + - ./scripts/prep.sh + +post_hooks: + - echo "¡Generación completa!" + - ./scripts/cleanup.sh +files: + - README.md: + content: | + # Mi Proyecto +``` + +**Notas:** + +- La salida de los ganchos (stdout y stderr) se muestra en la terminal. +- Si un pre-hook falla, la generación se detiene. +- Si no se definen hooks, no ocurre nada extra. + ## 👩‍💻 Desarrollo Para comenzar con el desarrollo, sigue estos pasos: diff --git a/README.md b/README.md index ff139aa..d73486f 100644 --- a/README.md +++ b/README.md @@ -417,6 +417,35 @@ struct - [**Automating Project Structures with STRUCT and GitHub Actions**](https://medium.com/@httpdss/automating-project-structures-with-struct-and-github-actions-64e09c40c11e) - [**Advanced STRUCT Tips: Working with Template Variables and Jinja2 Filters**](https://medium.com/@httpdss/advanced-struct-tips-working-with-template-variables-and-jinja2-filters-b239bf3145e4) +## 🪝 Pre-generation and Post-generation Hooks + +You can define shell commands to run before and after structure generation using the `pre_hooks` and `post_hooks` keys in your YAML configuration. These are optional and allow you to automate setup or cleanup steps. + +- **pre_hooks**: List of shell commands to run before generation. If any command fails (non-zero exit), generation is aborted. +- **post_hooks**: List of shell commands to run after generation completes. If any command fails, an error is shown. + +Example: + +```yaml +pre_hooks: + - echo "Preparing environment..." + - ./scripts/prep.sh + +post_hooks: + - echo "Generation complete!" + - ./scripts/cleanup.sh +files: + - README.md: + content: | + # My Project +``` + +**Notes:** + +- Output from hooks (stdout and stderr) is shown in the terminal. +- If a pre-hook fails, generation is halted. +- If no hooks are defined, nothing extra happens. + ## 📜 License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/struct-schema.json b/struct-schema.json index b6f4051..d384f7a 100644 --- a/struct-schema.json +++ b/struct-schema.json @@ -97,6 +97,16 @@ } } } + }, + "pre_hooks": { + "type": "array", + "items": { "type": "string" }, + "description": "Shell commands to run before generation" + }, + "post_hooks": { + "type": "array", + "items": { "type": "string" }, + "description": "Shell commands to run after generation" } }, "additionalProperties": false diff --git a/struct_module/commands/generate.py b/struct_module/commands/generate.py index aa8cd4f..28f9d36 100644 --- a/struct_module/commands/generate.py +++ b/struct_module/commands/generate.py @@ -5,6 +5,7 @@ from struct_module.file_item import FileItem from struct_module.completers import file_strategy_completer from struct_module.utils import project_path +import subprocess # Generate command class class GenerateCommand(Command): @@ -22,6 +23,45 @@ def __init__(self, parser): parser.add_argument('--non-interactive', action='store_true', help='Run the command in non-interactive mode') parser.set_defaults(func=self.execute) + def _run_hooks(self, hooks, hook_type="pre"): # helper for running hooks + if not hooks: + return True + for cmd in hooks: + self.logger.info(f"Running {hook_type}-hook: {cmd}") + try: + result = subprocess.run(cmd, shell=True, check=True, capture_output=True, text=True) + if result.stdout: + self.logger.info(f"{hook_type}-hook stdout: {result.stdout.strip()}") + if result.stderr: + self.logger.info(f"{hook_type}-hook stderr: {result.stderr.strip()}") + except subprocess.CalledProcessError as e: + self.logger.error(f"{hook_type}-hook failed: {cmd}") + self.logger.error(f"Return code: {e.returncode}") + if e.stdout: + self.logger.error(f"stdout: {e.stdout.strip()}") + if e.stderr: + self.logger.error(f"stderr: {e.stderr.strip()}") + return False + return True + + def _load_yaml_config(self, structure_definition, structures_path): + if structure_definition.startswith("file://") and structure_definition.endswith(".yaml"): + with open(structure_definition[7:], 'r') as f: + return yaml.safe_load(f) + else: + this_file = os.path.dirname(os.path.realpath(__file__)) + contribs_path = os.path.join(this_file, "..", "contribs") + file_path = os.path.join(contribs_path, f"{structure_definition}.yaml") + if structures_path: + file_path = os.path.join(structures_path, f"{structure_definition}.yaml") + if not os.path.exists(file_path): + file_path = os.path.join(contribs_path, f"{structure_definition}.yaml") + if not os.path.exists(file_path): + self.logger.error(f"File not found: {file_path}") + return None + with open(file_path, 'r') as f: + return yaml.safe_load(f) + def execute(self, args): self.logger.info(f"Generating structure") self.logger.info(f" Structure definition: {args.structure_definition}") @@ -34,8 +74,27 @@ def execute(self, args): self.logger.info(f"Creating base path: {args.base_path}") os.makedirs(args.base_path) + # Load config to check for hooks + config = None + config = self._load_yaml_config(args.structure_definition, args.structures_path) + if config is None: + return + + pre_hooks = config.get('pre_hooks', []) + post_hooks = config.get('post_hooks', []) + + # Run pre-hooks + if not self._run_hooks(pre_hooks, hook_type="pre"): + self.logger.error("Aborting generation due to pre-hook failure.") + return + + # Actually generate structure self._create_structure(args) + # Run post-hooks + if not self._run_hooks(post_hooks, hook_type="post"): + self.logger.error("Post-hook failed.") + return def _create_structure(self, args): if isinstance(args, dict): @@ -43,24 +102,9 @@ def _create_structure(self, args): this_file = os.path.dirname(os.path.realpath(__file__)) contribs_path = os.path.join(this_file, "..", "contribs") - if args.structure_definition.startswith("file://") and args.structure_definition.endswith(".yaml"): - with open(args.structure_definition[7:], 'r') as f: - config = yaml.safe_load(f) - else: - file_path = os.path.join(contribs_path, f"{args.structure_definition}.yaml") - if args.structures_path: - file_path = os.path.join(args.structures_path, f"{args.structure_definition}.yaml") - - if not os.path.exists(file_path): - # fallback to contribs path - file_path = os.path.join(contribs_path, f"{args.structure_definition}.yaml") - - if not os.path.exists(file_path): - self.logger.error(f"File not found: {file_path}") - return - - with open(file_path, 'r') as f: - config = yaml.safe_load(f) + config = self._load_yaml_config(args.structure_definition, args.structures_path) + if config is None: + return template_vars = dict(item.split('=') for item in args.vars.split(',')) if args.vars else None config_structure = config.get('files', config.get('structure', [])) diff --git a/struct_module/commands/validate.py b/struct_module/commands/validate.py index 0d7b59f..15f7e38 100644 --- a/struct_module/commands/validate.py +++ b/struct_module/commands/validate.py @@ -18,6 +18,15 @@ def execute(self, args): with open(args.yaml_file, 'r') as f: config = yaml.safe_load(f) + # Validate pre_hooks and post_hooks if present + for hook_key in ["pre_hooks", "post_hooks"]: + if hook_key in config: + if not isinstance(config[hook_key], list): + raise ValueError(f"The '{hook_key}' key must be a list of shell commands (strings).") + for cmd in config[hook_key]: + if not isinstance(cmd, str): + raise ValueError(f"Each item in '{hook_key}' must be a string (shell command).") + if 'structure' in config and 'files' in config: self.logger.warning("Both 'structure' and 'files' keys exist. Prioritizing 'structure'.") self._validate_structure_config(config.get('structure') or config.get('files', [])) diff --git a/tests/test_commands.py b/tests/test_commands.py index 2716a1c..e72b194 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -12,8 +12,12 @@ def parser(): def test_generate_command(parser): command = GenerateCommand(parser) - args = parser.parse_args(['structure.yaml', 'base_path']) - with patch.object(command, '_create_structure') as mock_create_structure: + # Patch os.path.exists to always return True for the config file, and patch open/yaml.safe_load to return a minimal config + with patch('os.path.exists', return_value=True), \ + patch('builtins.open', new_callable=MagicMock) as mock_open, \ + patch('yaml.safe_load', return_value={'files': []}), \ + patch.object(command, '_create_structure') as mock_create_structure: + args = parser.parse_args(['structure.yaml', 'base_path']) command.execute(args) mock_create_structure.assert_called_once() diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..f41cd50 --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,69 @@ +import pytest +from unittest.mock import patch, MagicMock, call +from struct_module.commands.generate import GenerateCommand +import argparse + +@pytest.fixture +def parser(): + return argparse.ArgumentParser() + +def make_args(tmp_path, pre=None, post=None): + # Create a minimal YAML config file + config = { + 'files': [ + {'test.txt': {'content': 'hello'}} + ] + } + if pre: + config['pre_hooks'] = pre + if post: + config['post_hooks'] = post + yaml_path = tmp_path / 'struct.yaml' + import yaml as _yaml + with open(yaml_path, 'w') as f: + _yaml.safe_dump(config, f) + return yaml_path + +def test_no_hooks_runs_ok(tmp_path, parser): + yaml_path = make_args(tmp_path) + command = GenerateCommand(parser) + args = parser.parse_args([f'file://{yaml_path}', str(tmp_path)]) + with patch.object(command, '_create_structure') as mock_create_structure, \ + patch.object(command, '_run_hooks', wraps=command._run_hooks) as mock_run_hooks: + command.execute(args) + # _run_hooks should be called for pre and post, but both are no-op + assert mock_run_hooks.call_count == 2 + mock_create_structure.assert_called_once() + +def test_pre_hook_runs_and_blocks_on_failure(tmp_path, parser): + yaml_path = make_args(tmp_path, pre=['exit 1']) + command = GenerateCommand(parser) + args = parser.parse_args([f'file://{yaml_path}', str(tmp_path)]) + with patch('subprocess.run', side_effect=__import__('subprocess').CalledProcessError(1, 'exit 1')) as mock_subproc, \ + patch.object(command, '_create_structure') as mock_create_structure: + command.execute(args) + mock_subproc.assert_called_once() + mock_create_structure.assert_not_called() + +def test_post_hook_runs_and_blocks_on_failure(tmp_path, parser): + yaml_path = make_args(tmp_path, post=['exit 1']) + command = GenerateCommand(parser) + args = parser.parse_args([f'file://{yaml_path}', str(tmp_path)]) + with patch('subprocess.run', side_effect=__import__('subprocess').CalledProcessError(1, 'exit 1')) as mock_subproc, \ + patch.object(command, '_create_structure') as mock_create_structure: + command.execute(args) + # Only post-hook should run, so only one call + mock_subproc.assert_called_once() + mock_create_structure.assert_called_once() + +def test_hooks_order(tmp_path, parser): + yaml_path = make_args(tmp_path, pre=['echo pre'], post=['echo post']) + command = GenerateCommand(parser) + args = parser.parse_args([f'file://{yaml_path}', str(tmp_path)]) + with patch('subprocess.run') as mock_subproc, \ + patch.object(command, '_create_structure') as mock_create_structure: + command.execute(args) + # Should call pre first, then post + assert mock_subproc.call_args_list[0][0][0] == 'echo pre' + assert mock_subproc.call_args_list[1][0][0] == 'echo post' + mock_create_structure.assert_called_once() From cb3d470aa89ba0f1c82a3d109b6cdfa8c88c9d88 Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Sat, 24 May 2025 19:01:23 +0000 Subject: [PATCH 2/2] Fix table of contents links for installation section in README files --- README.es.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.es.md b/README.es.md index 1d62a7d..ad45b9c 100644 --- a/README.es.md +++ b/README.es.md @@ -11,7 +11,7 @@ - [Introducción](#-introducción) - [Características](#-características) -- [Instalación](#instalación) +- [Instalación](#️-instalación) - [Usando pip](#usando-pip) - [Desde el código fuente](#desde-el-código-fuente) - [Usando Docker](#usando-docker) diff --git a/README.md b/README.md index d73486f..3e4b719 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ - [Introduction](#-introduction) - [Features](#-features) -- [Installation](#installation) +- [Installation](#️-installation) - [Quick Start](#-quick-start) - [Usage](#-usage) - [YAML Configuration](#-yaml-configuration)