From fd771a224aa5d4608f7d58f77195eca76c0369de Mon Sep 17 00:00:00 2001
From: Paul Farault <diode-farault.consultant@dgfip.finances.gouv.fr>
Date: Mon, 15 Jul 2024 21:20:02 +0200
Subject: [PATCH 1/3] feat: handle run directory for the whole cli

---
 tdp/cli/__main__.py             | 10 ++++++++++
 tdp/cli/commands/deploy.py      |  9 ---------
 tdp/core/deployment/executor.py |  2 --
 tests/e2e/test_tdp_deploy.py    |  2 --
 4 files changed, 10 insertions(+), 13 deletions(-)

diff --git a/tdp/cli/__main__.py b/tdp/cli/__main__.py
index 4ed44822..d50acb97 100644
--- a/tdp/cli/__main__.py
+++ b/tdp/cli/__main__.py
@@ -2,6 +2,7 @@
 # SPDX-License-Identifier: Apache-2.0
 
 import logging
+from os import chdir
 from pathlib import Path
 from typing import Optional
 
@@ -50,6 +51,15 @@ def load_env(ctx: click.Context, param: click.Parameter, value: Path) -> Optiona
     type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
     help="Set the level of log output.",
 )
+@click.option(
+    "--run-directory",
+    envvar="TDP_RUN_DIRECTORY",
+    type=click.Path(resolve_path=True, exists=True),
+    help="Working directory where the executor is launched (`ansible-playbook` for Ansible).",
+    required=True,
+    callback=lambda ctx, param, value: chdir(value),
+    expose_value=False,
+)
 def cli(log_level: str):
     setup_logging(log_level)
     logging.info("Logging is configured.")
diff --git a/tdp/cli/commands/deploy.py b/tdp/cli/commands/deploy.py
index 2d582023..b61b277f 100644
--- a/tdp/cli/commands/deploy.py
+++ b/tdp/cli/commands/deploy.py
@@ -42,13 +42,6 @@
     is_flag=True,
     help="Mock the deploy, do not actually run the ansible playbook.",
 )
-@click.option(
-    "--run-directory",
-    envvar="TDP_RUN_DIRECTORY",
-    type=click.Path(resolve_path=True, path_type=Path, exists=True),
-    help="Working directory where the executor is launched (`ansible-playbook` for Ansible).",
-    required=True,
-)
 @validate_option
 @vars_option
 def deploy(
@@ -57,7 +50,6 @@ def deploy(
     db_engine: Engine,
     force_stale_update: bool,
     mock_deploy: bool,
-    run_directory: Path,
     validate: bool,
     vars: Path,
 ):
@@ -77,7 +69,6 @@ def deploy(
         deployment_iterator = DeploymentRunner(
             collections=collections,
             executor=Executor(
-                run_directory=run_directory.absolute() if run_directory else None,
                 dry=dry or mock_deploy,
             ),
             cluster_variables=cluster_variables,
diff --git a/tdp/core/deployment/executor.py b/tdp/core/deployment/executor.py
index ae6a54db..3138fa4a 100644
--- a/tdp/core/deployment/executor.py
+++ b/tdp/core/deployment/executor.py
@@ -28,7 +28,6 @@ def __init__(self, run_directory=None, dry: bool = False):
             ExecutableNotFoundError: If the ansible-playbook command is not found in PATH.
         """
         # TODO configurable via config file
-        self._rundir = run_directory
         self._dry = dry
 
         # Resolve ansible-playbook command
@@ -57,7 +56,6 @@ def _execute_ansible_command(
                     command,
                     stdout=subprocess.PIPE,
                     stderr=subprocess.STDOUT,
-                    cwd=self._rundir,
                     universal_newlines=True,
                 )
                 if res.stdout is None:
diff --git a/tests/e2e/test_tdp_deploy.py b/tests/e2e/test_tdp_deploy.py
index c79fd119..bdd3f234 100644
--- a/tests/e2e/test_tdp_deploy.py
+++ b/tests/e2e/test_tdp_deploy.py
@@ -34,8 +34,6 @@ def test_tdp_deploy_mock(
             tdp_init.db_dsn,
             "--vars",
             str(tdp_init.vars),
-            "--run-directory",
-            str(tmp_path),
             "--mock-deploy",
         ],
     )

From 84fd5d3fc8add9e954dac525335c642764a472b9 Mon Sep 17 00:00:00 2001
From: Paul Farault <diode-farault.consultant@dgfip.finances.gouv.fr>
Date: Thu, 18 Jul 2024 12:14:00 +0200
Subject: [PATCH 2/3] feat: lazy load ansible imports

---
 tdp/cli/commands/default_diff.py |   4 +-
 tdp/core/ansible_loader.py       | 102 +++++++++++++++++++++++++++++++
 tdp/core/inventory_reader.py     |  32 +++-------
 tdp/core/variables/variables.py  |  18 ++++--
 4 files changed, 123 insertions(+), 33 deletions(-)
 create mode 100644 tdp/core/ansible_loader.py

diff --git a/tdp/cli/commands/default_diff.py b/tdp/cli/commands/default_diff.py
index 5401bd36..c8d677e1 100644
--- a/tdp/cli/commands/default_diff.py
+++ b/tdp/cli/commands/default_diff.py
@@ -10,9 +10,9 @@
 from typing import TYPE_CHECKING, Optional
 
 import click
-from ansible.utils.vars import merge_hash
 
 from tdp.cli.params import collections_option, vars_option
+from tdp.core.ansible_loader import AnsibleLoader
 from tdp.core.constants import DEFAULT_VARS_DIRECTORY_NAME
 from tdp.core.variables import ClusterVariables, Variables
 
@@ -73,7 +73,7 @@ def service_diff(collections, service):
             with Variables(default_service_vars_filepath).open(
                 "r"
             ) as default_variables:
-                default_service_varfile = merge_hash(
+                default_service_varfile = AnsibleLoader.load_merge_hash()(
                     default_service_varfile, default_variables
                 )
 
diff --git a/tdp/core/ansible_loader.py b/tdp/core/ansible_loader.py
new file mode 100644
index 00000000..cf130f32
--- /dev/null
+++ b/tdp/core/ansible_loader.py
@@ -0,0 +1,102 @@
+# Copyright 2022 TOSIT.IO
+# SPDX-License-Identifier: Apache-2.0
+
+
+class AnsibleLoader:
+    """Lazy loader for Ansible classes and functions.
+
+    This class is required as ansible automatically generate a config when imported.
+    """
+
+    _merge_hash = None
+    _from_yaml = None
+    _AnsibleDumper = None
+    _InventoryCLI = None
+    _InventoryReader = None
+    _InventoryManager = None
+    _CustomInventoryCLI = None
+
+    @classmethod
+    def load_merge_hash(cls):
+        """Load the merge_hash function from ansible."""
+        if cls._merge_hash is None:
+            from ansible.utils.vars import merge_hash
+
+            cls._merge_hash = merge_hash
+
+        return cls._merge_hash
+
+    @classmethod
+    def load_from_yaml(cls):
+        """Load the from_yaml function from ansible."""
+        if cls._from_yaml is None:
+            from ansible.parsing.utils.yaml import from_yaml
+
+            cls._from_yaml = from_yaml
+
+        return cls._from_yaml
+
+    @classmethod
+    def load_AnsibleDumper(cls):
+        """Load the AnsibleDumper class from ansible."""
+        if cls._AnsibleDumper is None:
+            from ansible.parsing.yaml.dumper import AnsibleDumper
+
+            cls._AnsibleDumper = AnsibleDumper
+
+        return cls._AnsibleDumper
+
+    @classmethod
+    def load_InventoryCLI(cls):
+        """Load the InventoryCLI class from ansible."""
+        if cls._InventoryCLI is None:
+            from ansible.cli.inventory import InventoryCLI
+
+            cls._InventoryCLI = InventoryCLI
+
+        return cls._InventoryCLI
+
+    @classmethod
+    def load_InventoryReader(cls):
+        """Load the InventoryReader class from ansible."""
+        if cls._InventoryReader is None:
+            from tdp.core.inventory_reader import InventoryReader
+
+            cls._InventoryReader = InventoryReader
+
+        return cls._InventoryReader
+
+    @classmethod
+    def load_InventoryManager(cls):
+        """Load the InventoryManager class from ansible."""
+        if cls._InventoryManager is None:
+            from ansible.inventory.manager import InventoryManager
+
+            cls._InventoryManager = InventoryManager
+
+        return cls._InventoryManager
+
+    @classmethod
+    def get_CustomInventoryCLI(cls):
+        if cls._CustomInventoryCLI is None:
+
+            class CustomInventoryCLI(cls.load_InventoryCLI()):
+                """Represent a custom Ansible inventory CLI which does nothing.
+                This is used to load inventory files with Ansible code.
+                """
+
+                def __init__(self):
+                    super().__init__(["program", "--list"])
+                    # "run" must be called from CLI (the parent of InventoryCLI), to
+                    # initialize context (reading ansible.cfg for example).
+                    super(cls.load_InventoryCLI(), self).run()
+                    # Get InventoryManager instance
+                    _, self.inventory, _ = self._play_prereqs()
+
+                # Avoid call InventoryCLI "run", we do not need to run InventoryCLI
+                def run(self):
+                    pass
+
+            cls._CustomInventoryCLI = CustomInventoryCLI()
+
+        return cls._CustomInventoryCLI
diff --git a/tdp/core/inventory_reader.py b/tdp/core/inventory_reader.py
index 7e58041c..eb5b975c 100644
--- a/tdp/core/inventory_reader.py
+++ b/tdp/core/inventory_reader.py
@@ -1,11 +1,11 @@
 # Copyright 2022 TOSIT.IO
 # SPDX-License-Identifier: Apache-2.0
 
-from typing import Optional, TextIO
+from typing import TYPE_CHECKING, Optional, TextIO
 
 import yaml
-from ansible.cli.inventory import InventoryCLI
-from ansible.inventory.manager import InventoryManager
+
+from tdp.core.ansible_loader import AnsibleLoader
 
 try:
     from yaml import CLoader as Loader
@@ -13,33 +13,15 @@
     from yaml import Loader
 
 
-# From ansible/cli/inventory.py
-class _CustomInventoryCLI(InventoryCLI):
-    """Represent a custom Ansible inventory CLI which does nothing.
-    This is used to load inventory files with Ansible code.
-    """
-
-    def __init__(self):
-        super().__init__(["program", "--list"])
-        # "run" must be called from CLI (the parent of InventoryCLI), to
-        # initialize context (reading ansible.cfg for example).
-        super(InventoryCLI, self).run()
-        # Get InventoryManager instance
-        _, self.inventory, _ = self._play_prereqs()
-
-    # Avoid call InventoryCLI "run", we do not need to run InventoryCLI
-    def run(self):
-        pass
-
-
-custom_inventory_cli_instance = _CustomInventoryCLI()
+if TYPE_CHECKING:
+    from ansible.inventory.manager import InventoryManager
 
 
 class InventoryReader:
     """Represent an Ansible inventory reader."""
 
-    def __init__(self, inventory: Optional[InventoryManager] = None):
-        self.inventory = inventory or custom_inventory_cli_instance.inventory
+    def __init__(self, inventory: Optional["InventoryManager"] = None):
+        self.inventory = inventory or AnsibleLoader.get_CustomInventoryCLI().inventory
 
     def get_hosts(self, *args, **kwargs) -> list[str]:
         """Takes a pattern or list of patterns and returns a list of matching
diff --git a/tdp/core/variables/variables.py b/tdp/core/variables/variables.py
index fa3ba6bb..7a6afbce 100644
--- a/tdp/core/variables/variables.py
+++ b/tdp/core/variables/variables.py
@@ -10,10 +10,8 @@
 from weakref import proxy
 
 import yaml
-from ansible.parsing.utils.yaml import from_yaml
-from ansible.parsing.yaml.dumper import AnsibleDumper
-from ansible.utils.vars import merge_hash
 
+from tdp.core.ansible_loader import AnsibleLoader
 from tdp.core.types import PathLike
 
 
@@ -90,7 +88,7 @@ def merge(self, mapping: MutableMapping) -> None:
         Args:
             mapping: Mapping to merge.
         """
-        self._content = merge_hash(self._content, mapping)
+        self._content = AnsibleLoader.load_merge_hash()(self._content, mapping)
 
     def __getitem__(self, key):
         return self._content.__getitem__(key)
@@ -131,7 +129,10 @@ def __init__(self, path: Path, mode: Optional[str] = None):
         self._file_path = path
         self._file_descriptor = open(self._file_path, mode or "r+")
         # Initialize the content of the variables file
-        super().__init__(content=from_yaml(self._file_descriptor) or {}, name=path.name)
+        super().__init__(
+            content=AnsibleLoader.load_from_yaml()(self._file_descriptor) or {},
+            name=path.name,
+        )
 
     def __enter__(self) -> _VariablesIOWrapper:
         return proxy(self)
@@ -152,7 +153,12 @@ def _flush_on_disk(self) -> None:
         # Write the content of the variables file on disk
         self._file_descriptor.seek(0)
         self._file_descriptor.write(
-            yaml.dump(self._content, Dumper=AnsibleDumper, sort_keys=False, width=1000)
+            yaml.dump(
+                self._content,
+                Dumper=AnsibleLoader.load_AnsibleDumper(),
+                sort_keys=False,
+                width=1000,
+            )
         )
         self._file_descriptor.truncate()
         self._file_descriptor.flush()

From 95f681656c881b4ebf3c9bb2512a5ca8ea8a0b80 Mon Sep 17 00:00:00 2001
From: Paul Farault <diode-farault.consultant@dgfip.finances.gouv.fr>
Date: Thu, 18 Jul 2024 15:01:40 +0200
Subject: [PATCH 3/3] feat: rename --run-directory to --cwd

---
 tdp/cli/__main__.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/tdp/cli/__main__.py b/tdp/cli/__main__.py
index d50acb97..597f6457 100644
--- a/tdp/cli/__main__.py
+++ b/tdp/cli/__main__.py
@@ -52,10 +52,10 @@ def load_env(ctx: click.Context, param: click.Parameter, value: Path) -> Optiona
     help="Set the level of log output.",
 )
 @click.option(
-    "--run-directory",
-    envvar="TDP_RUN_DIRECTORY",
+    "--cwd",
+    envvar="TDP_CWD",
     type=click.Path(resolve_path=True, exists=True),
-    help="Working directory where the executor is launched (`ansible-playbook` for Ansible).",
+    help="Current working directory, where the command will be executed.",
     required=True,
     callback=lambda ctx, param, value: chdir(value),
     expose_value=False,