diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 79fc13a..ae10e9d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,7 +1,10 @@ { "name": "Struct devcontainer", - "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", + "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", "features": { + "ghcr.io/devcontainers/features/python:1": { + "version": "3.12" + }, "ghcr.io/gvatsal60/dev-container-features/pre-commit": {}, "ghcr.io/eitsupi/devcontainer-features/go-task:latest": {}, "ghcr.io/devcontainers-extra/features/shfmt:1" : {} diff --git a/README.es.md b/README.es.md index feb2da7..492857c 100644 --- a/README.es.md +++ b/README.es.md @@ -377,6 +377,59 @@ files: - Si un pre-hook falla, la generación se detiene. - Si no se definen hooks, no ocurre nada extra. +## 🗺️ Soporte de Mappings + +Puedes proporcionar un archivo YAML de mappings para inyectar mapas clave-valor en tus plantillas. Esto es útil para referenciar valores específicos de entorno, IDs o cualquier otro mapeo que quieras usar en tus archivos generados. + +### Ejemplo de archivo de mappings + +```yaml +mappings: + teams: + devops: devops-team + aws_account_ids: + myenv-non-prod: 123456789 + myenv-prod: 987654321 +``` + +### Uso en plantillas + +Puedes referenciar valores del mapping en tus plantillas usando la variable `mappings`: + +```jinja +{{@ mappings.aws_account_ids['myenv-prod'] @}} +``` + +Esto se renderizará como: + +``` +987654321 +``` + +### Usar mappings en la cláusula `with` + +También puedes asignar un valor desde un mapping directamente en la cláusula `with` para llamadas a struct de carpetas. Por ejemplo: + +```yaml +folders: + - ./: + struct: + - configs/codeowners + with: + team: {{@ mappings.teams.devops @}} + account_id: {{@ mappings.aws_account_ids['myenv-prod'] @}} +``` + +Esto asignará el valor `devops-team` a la variable `team` y `987654321` a `account_id` en el struct, usando los valores de tu archivo de mappings. + +### Pasar el archivo de mappings + +Usa el argumento `--mappings-file` con el comando `generate`: + +```sh +struct generate --mappings-file ./mimapa.yaml mi-estructura.yaml . +``` + ## 👩‍💻 Desarrollo Para comenzar con el desarrollo, sigue estos pasos: diff --git a/README.md b/README.md index 3e4b719..88bc903 100644 --- a/README.md +++ b/README.md @@ -446,6 +446,59 @@ files: - If a pre-hook fails, generation is halted. - If no hooks are defined, nothing extra happens. +## 🗺️ Mappings Support + +You can provide a mappings YAML file to inject key-value maps into your templates. This is useful for referencing environment-specific values, IDs, or any other mapping you want to use in your generated files. + +### Example mappings file + +```yaml +mappings: + teams: + devops: devops-team + aws_account_ids: + myenv-non-prod: 123456789 + myenv-prod: 987654321 +``` + +### Usage in templates + +You can reference mapping values in your templates using the `mappings` variable: + +```jinja +{{@ mappings.aws_account_ids['myenv-prod'] @}} +``` + +This will render as: + +``` +987654321 +``` + +### Using mappings in the `with` clause + +You can also assign a value from a mapping directly in the `with` clause for folder struct calls. For example: + +```yaml +folders: + - ./: + struct: + - configs/codeowners + with: + team: {{@ mappings.teams.devops @}} + account_id: {{@ mappings.aws_account_ids['myenv-prod'] @}} +``` + +This will assign the value `devops-team` to the variable `team` and `987654321` to `account_id` in the struct, using the values from your mappings file. + +### Passing the mappings file + +Use the `--mappings-file` argument with the `generate` command: + +```sh +struct generate --mappings-file ./mymap.yaml my-struct.yaml . +``` + ## 📜 License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/scripts/devcontainer_start.sh b/scripts/devcontainer_start.sh index e90b4f3..1bc409e 100644 --- a/scripts/devcontainer_start.sh +++ b/scripts/devcontainer_start.sh @@ -1,3 +1,3 @@ -pip install -r requirements.txt -pip install -r requirements.dev.txt -pip install -e . +pip3.12 install -r requirements.txt +pip3.12 install -r requirements.dev.txt +pip3.12 install -e . diff --git a/struct_module/commands/generate.py b/struct_module/commands/generate.py index f17052f..ca2431e 100644 --- a/struct_module/commands/generate.py +++ b/struct_module/commands/generate.py @@ -4,7 +4,8 @@ import argparse from struct_module.file_item import FileItem from struct_module.completers import file_strategy_completer -from struct_module.utils import project_path +from struct_module.template_renderer import TemplateRenderer + import subprocess # Generate command class @@ -21,6 +22,8 @@ def __init__(self, parser): parser.add_argument('-f', '--file-strategy', type=str, choices=['overwrite', 'skip', 'append', 'rename', 'backup'], default='overwrite', help='Strategy for handling existing files').completer = file_strategy_completer parser.add_argument('-p', '--global-system-prompt', type=str, help='Global system prompt for OpenAI') parser.add_argument('--non-interactive', action='store_true', help='Run the command in non-interactive mode') + parser.add_argument('--mappings-file', type=str, + help='Path to a YAML file containing mappings to be used in templates') parser.set_defaults(func=self.execute) def _run_hooks(self, hooks, hook_type="pre"): # helper for running hooks @@ -67,6 +70,18 @@ def execute(self, args): self.logger.info(f" Structure definition: {args.structure_definition}") self.logger.info(f" Base path: {args.base_path}") + # Load mappings if provided + mappings = {} + if getattr(args, 'mappings_file', None): + if os.path.exists(args.mappings_file): + with open(args.mappings_file, 'r') as mf: + try: + mappings = yaml.safe_load(mf) or {} + except Exception as e: + self.logger.error(f"Failed to load mappings file: {e}") + else: + self.logger.error(f"Mappings file not found: {args.mappings_file}") + if args.backup and not os.path.exists(args.backup): os.makedirs(args.backup) @@ -89,14 +104,14 @@ def execute(self, args): return # Actually generate structure - self._create_structure(args) + self._create_structure(args, mappings) # 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): + def _create_structure(self, args, mappings=None): if isinstance(args, dict): args = argparse.Namespace(**args) this_file = os.path.dirname(os.path.realpath(__file__)) @@ -121,6 +136,7 @@ def _create_structure(self, args): content["config_variables"] = config_variables content["input_store"] = args.input_store content["non_interactive"] = args.non_interactive + content["mappings"] = mappings or {} file_item = FileItem(content) file_item.fetch_content() elif isinstance(content, str): @@ -131,6 +147,7 @@ def _create_structure(self, args): "config_variables": config_variables, "input_store": args.input_store, "non_interactive": args.non_interactive, + "mappings": mappings or {}, } ) @@ -183,7 +200,17 @@ def _create_structure(self, args): # dict to comma separated string if 'with' in content: if isinstance(content['with'], dict): - merged_vars = ",".join([f"{k}={v}" for k, v in content['with'].items()]) + # Render Jinja2 expressions in each value using TemplateRenderer + rendered_with = {} + renderer = TemplateRenderer( + config_variables, args.input_store, args.non_interactive, mappings) + for k, v in content['with'].items(): + # Render the value as a template, passing in mappings and template_vars + context = template_vars.copy() if template_vars else {} + context['mappings'] = mappings or {} + rendered_with[k] = renderer.render_template(str(v), context) + merged_vars = ",".join( + [f"{k}={v}" for k, v in rendered_with.items()]) if args.vars: merged_vars = args.vars + "," + merged_vars diff --git a/struct_module/file_item.py b/struct_module/file_item.py index 6b4c2ce..abcc4cb 100644 --- a/struct_module/file_item.py +++ b/struct_module/file_item.py @@ -33,15 +33,17 @@ def __init__(self, properties): self.system_prompt = properties.get("system_prompt") or properties.get("global_system_prompt") self.user_prompt = properties.get("user_prompt") self.openai_client = None + self.mappings = properties.get("mappings", {}) if openai_api_key: self._configure_openai() self.template_renderer = TemplateRenderer( - self.config_variables, - self.input_store, - self.non_interactive - ) + self.config_variables, + self.input_store, + self.non_interactive, + self.mappings + ) def _configure_openai(self): self.openai_client = OpenAI(api_key=openai_api_key) diff --git a/struct_module/template_renderer.py b/struct_module/template_renderer.py index de5d45c..2382351 100644 --- a/struct_module/template_renderer.py +++ b/struct_module/template_renderer.py @@ -8,9 +8,10 @@ from struct_module.utils import get_current_repo class TemplateRenderer: - def __init__(self, config_variables, input_store, non_interactive): + def __init__(self, config_variables, input_store, non_interactive, mappings=None): self.config_variables = config_variables self.non_interactive = non_interactive + self.mappings = mappings or {} self.env = Environment( trim_blocks=True, @@ -66,6 +67,10 @@ def get_defaults_from_config(self): def render_template(self, content, vars): + # Inject mappings into the template context + if self.mappings: + vars = vars.copy() if vars else {} + vars['mappings'] = self.mappings template = self.env.from_string(content) return template.render(vars) diff --git a/tests/test_commands.py b/tests/test_commands.py index e72b194..627ea2c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -52,3 +52,26 @@ def test_validate_command(parser): mock_validate_structure.assert_called_once() mock_validate_folders.assert_called_once() mock_validate_variables.assert_called_once() + + +def test_with_value_renders_jinja2_with_mappings(): + from struct_module.template_renderer import TemplateRenderer + config_variables = [] + input_store = "/tmp/input.json" + non_interactive = True + mappings = { + "teams": { + "devops": "devops-team" + } + } + # Simulate a 'with' dict as in the folder struct logic + with_dict = {"team": "{{@ mappings.teams.devops @}}"} + template_vars = {} + renderer = TemplateRenderer( + config_variables, input_store, non_interactive, mappings) + rendered_with = {} + for k, v in with_dict.items(): + context = template_vars.copy() if template_vars else {} + context['mappings'] = mappings or {} + rendered_with[k] = renderer.render_template(str(v), context) + assert rendered_with["team"] == "devops-team" diff --git a/tests/test_template_renderer.py b/tests/test_template_renderer.py index 0d1c03b..08bdee8 100644 --- a/tests/test_template_renderer.py +++ b/tests/test_template_renderer.py @@ -28,3 +28,34 @@ def test_prompt_for_missing_vars(renderer): def test_get_defaults_from_config(renderer): defaults = renderer.get_defaults_from_config() assert defaults == {"var1": "default1", "var2": "default2"} + + +def test_render_template_with_mappings(): + config_variables = [] + input_store = "/tmp/input.json" + non_interactive = True + mappings = { + "aws_account_ids": { + "myenv-non-prod": "123456789", + "myenv-prod": "987654321" + } + } + renderer = TemplateRenderer( + config_variables, input_store, non_interactive, mappings=mappings) + content = "Account: {{@ mappings.aws_account_ids['myenv-prod'] @}}" + rendered_content = renderer.render_template(content, {}) + assert rendered_content == "Account: 987654321" + + # Also test dot notation + content_dot = "Account: {{@ mappings.aws_account_ids.myenv_non_prod @}}" + # Jinja2 does not allow dash in dot notation, so we use underscore for this test + mappings_dot = { + "aws_account_ids": { + "myenv_non_prod": "123456789", + "myenv_prod": "987654321" + } + } + renderer_dot = TemplateRenderer( + config_variables, input_store, non_interactive, mappings=mappings_dot) + rendered_content_dot = renderer_dot.render_template(content_dot, {}) + assert rendered_content_dot == "Account: 123456789"