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
5 changes: 4 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -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" : {}
Expand Down
53 changes: 53 additions & 0 deletions README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions scripts/devcontainer_start.sh
Original file line number Diff line number Diff line change
@@ -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 .
35 changes: 31 additions & 4 deletions struct_module/commands/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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__))
Expand All @@ -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):
Expand All @@ -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 {},
}
)

Expand Down Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions struct_module/file_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion struct_module/template_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down
23 changes: 23 additions & 0 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
31 changes: 31 additions & 0 deletions tests/test_template_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading