Skip to content

Commit d3a00c4

Browse files
authored
Add mappings support to template generation (#52)
Introduce support for a mappings YAML file to inject key-value pairs into templates, enhancing the flexibility of generated files. Update documentation to reflect this new feature and provide usage examples.
1 parent b196795 commit d3a00c4

9 files changed

Lines changed: 210 additions & 13 deletions

File tree

.devcontainer/devcontainer.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
{
22
"name": "Struct devcontainer",
3-
"image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye",
3+
"image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye",
44
"features": {
5+
"ghcr.io/devcontainers/features/python:1": {
6+
"version": "3.12"
7+
},
58
"ghcr.io/gvatsal60/dev-container-features/pre-commit": {},
69
"ghcr.io/eitsupi/devcontainer-features/go-task:latest": {},
710
"ghcr.io/devcontainers-extra/features/shfmt:1" : {}

README.es.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,59 @@ files:
377377
- Si un pre-hook falla, la generación se detiene.
378378
- Si no se definen hooks, no ocurre nada extra.
379379

380+
## 🗺️ Soporte de Mappings
381+
382+
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.
383+
384+
### Ejemplo de archivo de mappings
385+
386+
```yaml
387+
mappings:
388+
teams:
389+
devops: devops-team
390+
aws_account_ids:
391+
myenv-non-prod: 123456789
392+
myenv-prod: 987654321
393+
```
394+
395+
### Uso en plantillas
396+
397+
Puedes referenciar valores del mapping en tus plantillas usando la variable `mappings`:
398+
399+
```jinja
400+
{{@ mappings.aws_account_ids['myenv-prod'] @}}
401+
```
402+
403+
Esto se renderizará como:
404+
405+
```
406+
987654321
407+
```
408+
409+
### Usar mappings en la cláusula `with`
410+
411+
También puedes asignar un valor desde un mapping directamente en la cláusula `with` para llamadas a struct de carpetas. Por ejemplo:
412+
413+
```yaml
414+
folders:
415+
- ./:
416+
struct:
417+
- configs/codeowners
418+
with:
419+
team: {{@ mappings.teams.devops @}}
420+
account_id: {{@ mappings.aws_account_ids['myenv-prod'] @}}
421+
```
422+
423+
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.
424+
425+
### Pasar el archivo de mappings
426+
427+
Usa el argumento `--mappings-file` con el comando `generate`:
428+
429+
```sh
430+
struct generate --mappings-file ./mimapa.yaml mi-estructura.yaml .
431+
```
432+
380433
## 👩‍💻 Desarrollo
381434

382435
Para comenzar con el desarrollo, sigue estos pasos:

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,59 @@ files:
446446
- If a pre-hook fails, generation is halted.
447447
- If no hooks are defined, nothing extra happens.
448448

449+
## 🗺️ Mappings Support
450+
451+
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.
452+
453+
### Example mappings file
454+
455+
```yaml
456+
mappings:
457+
teams:
458+
devops: devops-team
459+
aws_account_ids:
460+
myenv-non-prod: 123456789
461+
myenv-prod: 987654321
462+
```
463+
464+
### Usage in templates
465+
466+
You can reference mapping values in your templates using the `mappings` variable:
467+
468+
```jinja
469+
{{@ mappings.aws_account_ids['myenv-prod'] @}}
470+
```
471+
472+
This will render as:
473+
474+
```
475+
987654321
476+
```
477+
478+
### Using mappings in the `with` clause
479+
480+
You can also assign a value from a mapping directly in the `with` clause for folder struct calls. For example:
481+
482+
```yaml
483+
folders:
484+
- ./:
485+
struct:
486+
- configs/codeowners
487+
with:
488+
team: {{@ mappings.teams.devops @}}
489+
account_id: {{@ mappings.aws_account_ids['myenv-prod'] @}}
490+
```
491+
492+
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.
493+
494+
### Passing the mappings file
495+
496+
Use the `--mappings-file` argument with the `generate` command:
497+
498+
```sh
499+
struct generate --mappings-file ./mymap.yaml my-struct.yaml .
500+
```
501+
449502
## 📜 License
450503

451504
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

scripts/devcontainer_start.sh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
pip install -r requirements.txt
2-
pip install -r requirements.dev.txt
3-
pip install -e .
1+
pip3.12 install -r requirements.txt
2+
pip3.12 install -r requirements.dev.txt
3+
pip3.12 install -e .

struct_module/commands/generate.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
import argparse
55
from struct_module.file_item import FileItem
66
from struct_module.completers import file_strategy_completer
7-
from struct_module.utils import project_path
7+
from struct_module.template_renderer import TemplateRenderer
8+
89
import subprocess
910

1011
# Generate command class
@@ -21,6 +22,8 @@ def __init__(self, parser):
2122
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
2223
parser.add_argument('-p', '--global-system-prompt', type=str, help='Global system prompt for OpenAI')
2324
parser.add_argument('--non-interactive', action='store_true', help='Run the command in non-interactive mode')
25+
parser.add_argument('--mappings-file', type=str,
26+
help='Path to a YAML file containing mappings to be used in templates')
2427
parser.set_defaults(func=self.execute)
2528

2629
def _run_hooks(self, hooks, hook_type="pre"): # helper for running hooks
@@ -67,6 +70,18 @@ def execute(self, args):
6770
self.logger.info(f" Structure definition: {args.structure_definition}")
6871
self.logger.info(f" Base path: {args.base_path}")
6972

73+
# Load mappings if provided
74+
mappings = {}
75+
if getattr(args, 'mappings_file', None):
76+
if os.path.exists(args.mappings_file):
77+
with open(args.mappings_file, 'r') as mf:
78+
try:
79+
mappings = yaml.safe_load(mf) or {}
80+
except Exception as e:
81+
self.logger.error(f"Failed to load mappings file: {e}")
82+
else:
83+
self.logger.error(f"Mappings file not found: {args.mappings_file}")
84+
7085
if args.backup and not os.path.exists(args.backup):
7186
os.makedirs(args.backup)
7287

@@ -89,14 +104,14 @@ def execute(self, args):
89104
return
90105

91106
# Actually generate structure
92-
self._create_structure(args)
107+
self._create_structure(args, mappings)
93108

94109
# Run post-hooks
95110
if not self._run_hooks(post_hooks, hook_type="post"):
96111
self.logger.error("Post-hook failed.")
97112
return
98113

99-
def _create_structure(self, args):
114+
def _create_structure(self, args, mappings=None):
100115
if isinstance(args, dict):
101116
args = argparse.Namespace(**args)
102117
this_file = os.path.dirname(os.path.realpath(__file__))
@@ -121,6 +136,7 @@ def _create_structure(self, args):
121136
content["config_variables"] = config_variables
122137
content["input_store"] = args.input_store
123138
content["non_interactive"] = args.non_interactive
139+
content["mappings"] = mappings or {}
124140
file_item = FileItem(content)
125141
file_item.fetch_content()
126142
elif isinstance(content, str):
@@ -131,6 +147,7 @@ def _create_structure(self, args):
131147
"config_variables": config_variables,
132148
"input_store": args.input_store,
133149
"non_interactive": args.non_interactive,
150+
"mappings": mappings or {},
134151
}
135152
)
136153

@@ -183,7 +200,17 @@ def _create_structure(self, args):
183200
# dict to comma separated string
184201
if 'with' in content:
185202
if isinstance(content['with'], dict):
186-
merged_vars = ",".join([f"{k}={v}" for k, v in content['with'].items()])
203+
# Render Jinja2 expressions in each value using TemplateRenderer
204+
rendered_with = {}
205+
renderer = TemplateRenderer(
206+
config_variables, args.input_store, args.non_interactive, mappings)
207+
for k, v in content['with'].items():
208+
# Render the value as a template, passing in mappings and template_vars
209+
context = template_vars.copy() if template_vars else {}
210+
context['mappings'] = mappings or {}
211+
rendered_with[k] = renderer.render_template(str(v), context)
212+
merged_vars = ",".join(
213+
[f"{k}={v}" for k, v in rendered_with.items()])
187214

188215
if args.vars:
189216
merged_vars = args.vars + "," + merged_vars

struct_module/file_item.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,17 @@ def __init__(self, properties):
3333
self.system_prompt = properties.get("system_prompt") or properties.get("global_system_prompt")
3434
self.user_prompt = properties.get("user_prompt")
3535
self.openai_client = None
36+
self.mappings = properties.get("mappings", {})
3637

3738
if openai_api_key:
3839
self._configure_openai()
3940

4041
self.template_renderer = TemplateRenderer(
41-
self.config_variables,
42-
self.input_store,
43-
self.non_interactive
44-
)
42+
self.config_variables,
43+
self.input_store,
44+
self.non_interactive,
45+
self.mappings
46+
)
4547

4648
def _configure_openai(self):
4749
self.openai_client = OpenAI(api_key=openai_api_key)

struct_module/template_renderer.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
from struct_module.utils import get_current_repo
99

1010
class TemplateRenderer:
11-
def __init__(self, config_variables, input_store, non_interactive):
11+
def __init__(self, config_variables, input_store, non_interactive, mappings=None):
1212
self.config_variables = config_variables
1313
self.non_interactive = non_interactive
14+
self.mappings = mappings or {}
1415

1516
self.env = Environment(
1617
trim_blocks=True,
@@ -66,6 +67,10 @@ def get_defaults_from_config(self):
6667

6768

6869
def render_template(self, content, vars):
70+
# Inject mappings into the template context
71+
if self.mappings:
72+
vars = vars.copy() if vars else {}
73+
vars['mappings'] = self.mappings
6974
template = self.env.from_string(content)
7075
return template.render(vars)
7176

tests/test_commands.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,26 @@ def test_validate_command(parser):
5252
mock_validate_structure.assert_called_once()
5353
mock_validate_folders.assert_called_once()
5454
mock_validate_variables.assert_called_once()
55+
56+
57+
def test_with_value_renders_jinja2_with_mappings():
58+
from struct_module.template_renderer import TemplateRenderer
59+
config_variables = []
60+
input_store = "/tmp/input.json"
61+
non_interactive = True
62+
mappings = {
63+
"teams": {
64+
"devops": "devops-team"
65+
}
66+
}
67+
# Simulate a 'with' dict as in the folder struct logic
68+
with_dict = {"team": "{{@ mappings.teams.devops @}}"}
69+
template_vars = {}
70+
renderer = TemplateRenderer(
71+
config_variables, input_store, non_interactive, mappings)
72+
rendered_with = {}
73+
for k, v in with_dict.items():
74+
context = template_vars.copy() if template_vars else {}
75+
context['mappings'] = mappings or {}
76+
rendered_with[k] = renderer.render_template(str(v), context)
77+
assert rendered_with["team"] == "devops-team"

tests/test_template_renderer.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,34 @@ def test_prompt_for_missing_vars(renderer):
2828
def test_get_defaults_from_config(renderer):
2929
defaults = renderer.get_defaults_from_config()
3030
assert defaults == {"var1": "default1", "var2": "default2"}
31+
32+
33+
def test_render_template_with_mappings():
34+
config_variables = []
35+
input_store = "/tmp/input.json"
36+
non_interactive = True
37+
mappings = {
38+
"aws_account_ids": {
39+
"myenv-non-prod": "123456789",
40+
"myenv-prod": "987654321"
41+
}
42+
}
43+
renderer = TemplateRenderer(
44+
config_variables, input_store, non_interactive, mappings=mappings)
45+
content = "Account: {{@ mappings.aws_account_ids['myenv-prod'] @}}"
46+
rendered_content = renderer.render_template(content, {})
47+
assert rendered_content == "Account: 987654321"
48+
49+
# Also test dot notation
50+
content_dot = "Account: {{@ mappings.aws_account_ids.myenv_non_prod @}}"
51+
# Jinja2 does not allow dash in dot notation, so we use underscore for this test
52+
mappings_dot = {
53+
"aws_account_ids": {
54+
"myenv_non_prod": "123456789",
55+
"myenv_prod": "987654321"
56+
}
57+
}
58+
renderer_dot = TemplateRenderer(
59+
config_variables, input_store, non_interactive, mappings=mappings_dot)
60+
rendered_content_dot = renderer_dot.render_template(content_dot, {})
61+
assert rendered_content_dot == "Account: 123456789"

0 commit comments

Comments
 (0)