Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

- [Introduction](#-introduction)
- [Features](#-features)
- [Installation](#installation)
- [Installation](#️-installation)
- [Quick Start](#-quick-start)
- [Usage](#-usage)
- [YAML Configuration](#-yaml-configuration)
Expand Down Expand Up @@ -417,6 +417,35 @@ struct <Tab>
- [**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.
Expand Down
10 changes: 10 additions & 0 deletions struct-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 62 additions & 18 deletions struct_module/commands/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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}")
Expand All @@ -34,33 +74,37 @@ 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):
args = argparse.Namespace(**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', []))
Expand Down
9 changes: 9 additions & 0 deletions struct_module/commands/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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', []))
Expand Down
8 changes: 6 additions & 2 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
69 changes: 69 additions & 0 deletions tests/test_hooks.py
Original file line number Diff line number Diff line change
@@ -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()
Loading