Repository: osl-incubator/makim (language: python)

Issue:
Add matrix strategy for targets
In some cases we would like to run a same command for multiples parameters. Currently it is possible using a "for loop" (in bash, or xonsh, etc) or using jinja2 "for loop".

it would be nice to do that using a matrix strategy, similar to what github actions does (for a entire job).

NOTE: matrix will be implemented just for "task scope".

Example:

```
version: 1.0.0
groups:
  main:
    env-file: .env
    tasks:
      print:
        help: print the values from a matrix strategy
        matrix:
          mytext: 
            - text1
            - text2
            - text3
        run: echo {{ matrix.mytext }}
```

when the command `makim main.print` is executed, it will run "echo" for each value in mytext list.
if more variables is defined in the matrix, it will run the `run` section for all the combination of variables.
the values for each variable in the matrix, should be a list



Discussion / hints:
so maybe we just need to wrap-up the final section:

```python
    FOR VARS IN MATRIX:   # THIS LINE IS A PSEUDOCODE
        UPDATE_MATRIX_VARS()  # THIS LINE IS A PSEUDOCODE
        if self.verbose:
            MakimLogs.print_info('=' * width)
            MakimLogs.print_info(
                'TARGET: ' + f'{self.group_name}.{self.task_name}'
            )
            MakimLogs.print_info('ARGS:')
            MakimLogs.print_info(pprint.pformat(args_input))
            MakimLogs.print_info('VARS:')
            MakimLogs.print_info(pprint.pformat(variables))
            MakimLogs.print_info('ENV:')
            MakimLogs.print_info(str(env))
 
            IF MATRIX:
                MakimLogs.print_info('MATRIX:')
                MakimLogs.print_info(str(matrix_vars))
            MakimLogs.print_info('-' * width)
            MakimLogs.print_info('>>> ' + cmd.replace('\n', '\n>>> '))
            MakimLogs.print_info('=' * width)

        if not self.dry_run and cmd:
            self._call_shell_app(cmd)
```

by wrap-up I mean that that section would be executed inside the matrix. Additionally, in that section it should set the variables from the matrix.

we probably don't need to wrap-up the final lines, they should just run once at the end:

```python
        # move back the environment variable to the previous values
        os.environ.clear()
        os.environ.update(self.env_scoped)
```

Fix patch:
diff --git a/.makim.yaml b/.makim.yaml
index f694b93..2425236 100644
--- a/.makim.yaml
+++ b/.makim.yaml
@@ -86,6 +86,7 @@ groups:
             - task: smoke-tests.dir-relative-path
             - task: smoke-tests.interactive-args
             - task: smoke-tests.run-hooks
+            - task: smoke-tests.matrix
 
       ci:
         help: Run all tasks used on CI
@@ -391,6 +392,27 @@ groups:
           makim $VERBOSE_FLAG --file $MAKIM_FILE --version
           makim $VERBOSE_FLAG --file $MAKIM_FILE build.compile
 
+      matrix:
+        help: Test makim with matrix combination
+        args:
+          verbose-mode:
+            help: Run the all the tests in verbose mode
+            type: bool
+            action: store_true
+        env:
+          MAKIM_FILE: tests/smoke/.makim-matrix-strategy.yaml
+        backend: bash
+        run: |
+          export VERBOSE_FLAG='${{ "--verbose" if args.verbose_mode else "" }}'
+          makim $VERBOSE_FLAG --file $MAKIM_FILE --help
+          makim $VERBOSE_FLAG --file $MAKIM_FILE --version
+          makim $VERBOSE_FLAG --file $MAKIM_FILE build.setup
+          makim $VERBOSE_FLAG --file $MAKIM_FILE build.lint
+          makim $VERBOSE_FLAG --file $MAKIM_FILE build.lint --fix
+          makim $VERBOSE_FLAG --file $MAKIM_FILE test.unit
+          makim $VERBOSE_FLAG --file $MAKIM_FILE test.browser
+          makim $VERBOSE_FLAG --file $MAKIM_FILE test.browser --headless
+
   error:
     help: This group helps tests failure tasks
     tasks:
diff --git a/src/makim/core.py b/src/makim/core.py
index ae4bce0..47a5202 100644
--- a/src/makim/core.py
+++ b/src/makim/core.py
@@ -17,6 +17,7 @@
 import warnings
 
 from copy import deepcopy
+from itertools import product
 from pathlib import Path
 from typing import Any, Dict, List, Optional, Union, cast
 
@@ -363,7 +364,7 @@ def _load_dotenv(self, data_scope: dict[str, Any]) -> dict[str, str]:
 
     def _load_scoped_data(
         self, scope: str
-    ) -> tuple[dict[str, str], dict[str, str]]:
+    ) -> tuple[dict[str, str], dict[str, Any]]:
         scope_options = ('global', 'group', 'task')
         if scope not in scope_options:
             raise Exception(f'The given scope `{scope}` is not valid.')
@@ -451,6 +452,66 @@ def _load_task_args(self) -> None:
                     else (False if is_bool else None)
                 )
 
+    def _log_command_execution(
+        self, execution_context: dict[str, Any], width: int
+    ) -> None:
+        """Log the command execution details.
+
+        execution_context should contain: args_input, current_vars,
+        env, and optionally matrix_vars
+        """
+        MakimLogs.print_info('=' * width)
+        MakimLogs.print_info(
+            'TARGET: ' + f'{self.group_name}.{self.task_name}'
+        )
+        MakimLogs.print_info('ARGS:')
+        MakimLogs.print_info(pprint.pformat(execution_context['args_input']))
+        MakimLogs.print_info('VARS:')
+        MakimLogs.print_info(pprint.pformat(execution_context['current_vars']))
+        MakimLogs.print_info('ENV:')
+        MakimLogs.print_info(str(execution_context['env']))
+
+        if execution_context.get('matrix_vars'):
+            MakimLogs.print_info('MATRIX:')
+            MakimLogs.print_info(
+                pprint.pformat(execution_context['matrix_vars'])
+            )
+
+        MakimLogs.print_info('-' * width)
+
+    def _generate_matrix_combinations(
+        self, matrix_config: dict[str, Any]
+    ) -> list[dict[str, Any]]:
+        """Generate all possible combinations from matrix configuration.
+
+        Parameters
+        ----------
+        matrix_config : dict
+            Dictionary containing matrix variables and their possible values
+
+        Returns
+        -------
+        list[dict]
+            List of dictionaries, each containing one possible combination
+        """
+        if not matrix_config:
+            return []
+
+        # Convert matrix config into format suitable for product
+        keys = list(matrix_config.keys())
+        values = [
+            matrix_config[k]
+            if isinstance(matrix_config[k], list)
+            else [matrix_config[k]]
+            for k in keys
+        ]
+
+        combinations = []
+        for combo in product(*values):
+            combinations.append(dict(zip(keys, combo)))
+
+        return combinations
+
     # run commands
     def _run_hooks(self, args: dict[str, Any], hook_type: str) -> None:
         if not self.task_data.get('hooks', {}).get(hook_type):
@@ -518,20 +579,22 @@ def _run_hooks(self, args: dict[str, Any], hook_type: str) -> None:
 
     def _run_command(self, args: dict[str, Any]) -> None:
         cmd = self.task_data.get('run', '').strip()
-        if 'vars' not in self.group_data:
-            self.group_data['vars'] = {}
 
-        if not isinstance(self.group_data['vars'], dict):
+        if not isinstance(self.group_data.get('vars', {}), dict):
             MakimLogs.raise_error(
                 '`vars` attribute inside the group '
                 f'{self.group_name} is not a dictionary.',
                 MakimError.MAKIM_VARS_ATTRIBUTE_INVALID,
             )
 
-        env, variables = self._load_scoped_data('task')
-        for k, v in env.items():
-            os.environ[k] = v
+        # Get matrix configuration if it exists
+        matrix_combinations: list[dict[str, Any]] = []
+        if matrix_config := self.task_data.get('matrix', {}):
+            matrix_combinations = self._generate_matrix_combinations(
+                matrix_config
+            )
 
+        env, variables = self._load_scoped_data('task')
         self.env_scoped = deepcopy(env)
 
         args_input = {'file': self.file}
@@ -565,36 +628,47 @@ def _run_command(self, args: dict[str, Any]) -> None:
                     MakimError.MAKIM_ARGUMENT_REQUIRED,
                 )
 
-        cmd = str(cmd)
-        cmd = TEMPLATE.from_string(cmd).render(
-            args=args_input, env=env, vars=variables
-        )
         width, _ = get_terminal_size()
 
-        if self.verbose:
-            MakimLogs.print_info('=' * width)
-            MakimLogs.print_info(
-                'TARGET: ' + f'{self.group_name}.{self.task_name}'
+        # Run command for each matrix combination
+        for matrix_vars in matrix_combinations or [{}]:
+            # Update environment variables
+            for k, v in env.items():
+                os.environ[k] = v
+
+            # Create a copy of variables and update with matrix values
+            current_vars = deepcopy(variables)
+            if matrix_vars:
+                current_vars['matrix'] = matrix_vars
+
+            # Render command with current matrix values
+            current_cmd = TEMPLATE.from_string(cmd).render(
+                args=args_input, env=env, vars=current_vars, matrix=matrix_vars
             )
-            MakimLogs.print_info('ARGS:')
-            MakimLogs.print_info(pprint.pformat(args_input))
-            MakimLogs.print_info('VARS:')
-            MakimLogs.print_info(pprint.pformat(variables))
-            MakimLogs.print_info('ENV:')
-            MakimLogs.print_info(str(env))
-            MakimLogs.print_info('-' * width)
-            MakimLogs.print_info('>>> ' + cmd.replace('\n', '\n>>> '))
-            MakimLogs.print_info('=' * width)
-
-        if not self.dry_run and cmd:
-            self._call_shell_app(cmd)
+
+            if self.verbose:
+                # Prepare execution context for logging
+                execution_context = {
+                    'args_input': args_input,
+                    'current_vars': current_vars,
+                    'env': env,
+                    'matrix_vars': matrix_vars,
+                }
+                # Log command execution details
+                self._log_command_execution(execution_context, width)
+                MakimLogs.print_info(
+                    '>>> ' + current_cmd.replace('\n', '\n>>> ')
+                )
+                MakimLogs.print_info('=' * width)
+
+            if not self.dry_run and current_cmd:
+                self._call_shell_app(current_cmd)
 
         # move back the environment variable to the previous values
         os.environ.clear()
         os.environ.update(self.env_scoped)
 
-        # public methods
-
+    # public methods
     def load(
         self, file: str, dry_run: bool = False, verbose: bool = False
     ) -> None:


Test patch:
diff --git a/tests/smoke/.makim-matrix-strategy.yaml b/tests/smoke/.makim-matrix-strategy.yaml
new file mode 100644
index 0000000..fd4ff9f
--- /dev/null
+++ b/tests/smoke/.makim-matrix-strategy.yaml
@@ -0,0 +1,83 @@
+version: 1.0.0
+groups:
+  build:
+    vars:
+      app_name: "myapp"
+      base_image: "node:18"
+    tasks:
+      setup:
+        help: Setup development environment
+        matrix:
+          env:
+            - dev
+            - test
+            - prod
+          arch:
+            - amd64
+            - arm64
+        run: |
+          echo "[Setup] Environment: ${{ matrix.env }}, Architecture: ${{ matrix.arch }}"
+          echo "[Setup] Using base image: ${{ vars.base_image }}"
+          echo "[Setup] Installing dependencies for ${{ matrix.env }}"
+
+      lint:
+        help: Run linting checks
+        matrix:
+          type:
+            - eslint
+            - prettier
+          path:
+            - src
+            - tests
+        args:
+          fix:
+            type: bool
+            default: false
+            help: Automatically fix issues
+        run: |
+          echo "[Lint] Running ${{ matrix.type }} on ${{ matrix.path }}"
+          {% if args.fix %}
+          echo "[Lint] Auto-fixing enabled for ${{ matrix.type }}"
+          {% endif %}
+
+  test:
+    vars:
+      timeout: "30s"
+    tasks:
+      unit:
+        help: Run unit tests across Node versions
+        matrix:
+          node:
+            - "16"
+            - "18"
+            - "20"
+          mode:
+            - basic
+            - coverage
+        run: |
+          echo "[Test] Using Node.js ${{ matrix.node }}"
+          echo "[Test] Mode: ${{ matrix.mode }}"
+          echo "[Test] Timeout set to ${{ vars.timeout }}"
+
+      browser:
+        help: Run browser tests
+        matrix:
+          browser:
+            - chrome
+            - firefox
+          viewport:
+            - desktop
+            - mobile
+        args:
+          headless:
+            type: bool
+            default: true
+            help: Run in headless mode
+        run: |
+          echo "[Browser Test] Testing on ${{ matrix.browser }}"
+          echo "[Browser Test] Viewport: ${{ matrix.viewport }}"
+          {% if args.headless %}
+          echo "[Browser Test] Running in headless mode"
+          {% else %}
+          echo "[Browser Test] Running in GUI mode"
+          {% endif %}