Repository: conan-io/conan
Pull request: POC for tool_requires that make direct dependees inherit generators
Changelog: Feature: Add `self.generators_info` for `tool_requires` to propagate generators to their direct dependencies.
Docs: https://github.com/conan-io/docs/pull/3880

To share as idea for the team


Close https://github.com/conan-io/conan/issues/17113

Problem statement:
How to tell consumers to use specific generator?
### What is your question?

I have a package that requires certain generator to be used by consumers to work properly. How can I force consumers to use specific generator in addition to what they use in recipes?

### Have you read the CONTRIBUTING guide?

- [X] I've read the CONTRIBUTING guide

Hints from issue discussion:
Hi @andrey-zherikov 

This is not possible, packages cannot mandate to the consumers what build system should they use.
The information needs to be abstracted in the ``package_info()`` method, and consumers decide how to use it. 

Forcing a generator in consumers is also pointless if their build system won't use it, what problem are you trying to solve? What would be your generator doing?
My package provides VS build tools. To use it, consumer should use VCVars generator because it calls vcvars.bat from my package which properly sets build environment.

Do you have any ideas how I can do this without explicitly listing VCVars generator in all consumers?
> My package provides VS build tools. To use it, consumer should use VCVars generator because it calls vcvars.bat from my package which properly sets build environment.

Quick question: The ``VCVars`` generator calls ``vswhere`` unless the ``tools.microsoft.msbuild:installation_path`` is defined, it is this ``conf`` being defined in your package ``conf_info``?

I am afraid there is no direct way to force a consumer to call a generator.
But a pragmatic and relatively simple possibility is to use a hook:
- A ``pre_generate()`` hook
- The hook checks if your package is a ``tool_require`` (checking for example ``conanifile.dependencies.build``
- If it is a dependency, force the addition ``conanfile.generators.append("VCVars")`` or something like that

Please try that and let us know.
> > My package provides VS build tools. To use it, consumer should use VCVars generator because it calls vcvars.bat from my package which properly sets build environment.
> 
> Quick question: The `VCVars` generator calls `vswhere` unless the `tools.microsoft.msbuild:installation_path` is defined, it is this `conf` being defined in your package `conf_info`?

Yes, it does this plus some environment tuning through `self.buildenv_info`:
```python
def package_info(self):
    self.conf_info.define(
        "tools.microsoft.msbuild:installation_path", self.package_folder)
    )
```



> But a pragmatic and relatively simple possibility is to use a hook

I'll give it a try and circle back to you.

Meantime, the issue IMHO is pretty straightforward. I'd like to provide a custom command that sets build environment (that basically what VCVars is doing - it adds a call to vcvars.bat). Right now the only way to set build env for consumers is through `buildenv_info` in a form of specific operations (set/unset/append/prepend) on a specific variables but there is no way to tell "call this .bat/.sh to setup build environment"
> But a pragmatic and relatively simple possibility is to use a hook:
> 
> * A `pre_generate()` hook
> * The hook checks if your package is a `tool_require` (checking for example `conanifile.dependencies.build`
> * If it is a dependency, force the addition `conanfile.generators.append("VCVars")` or something like that

I tried this and was able to add VCVars generator but it doesn't work:
```
pkg/1.0: Generator 'VCVars' calling 'generate()'
ERROR: Error in generator 'VCVars': VS non-existing installation: Visual Studio 17. If using a non-default toolset from a VS IDE version consider specifying it with the 'tools.microsoft.msbuild:vs_version' conf
```
Although I have the following in profile (which is printed by `conan create` command:
```
tools.microsoft.msbuild:installation_path="C:/conan-work/.conan2/p/vs-bu61a1308f29b1c/p"
tools.microsoft.msbuild:vs_version=17
```

I see there is a check that fails if the path doesn't exist.
I think it is the extra quotes in the ``"tools.microsoft.msbuild:installation_path`` definition, you should remove them.
It seems I messed up with CONAN_HOME dir so I re-tested from the scratch and can confirm that hook works. Here is basically the hook I used:
```python
def pre_generate(conanfile):
    if "vs-buildtools" in conanfile.dependencies.build:
        conanfile.output.info("Adding VCVars generator")
        conanfile.generators.append("VCVars")
```

@memsharded Thank you for your help. Feel free to close this.

Commit message: add: expose create_env_script and register_env_script, adding a test with new custom generator

Patch to review:
diff --git a/conan/internal/api/install/generators.py b/conan/internal/api/install/generators.py
index 9b7de140d42..e2f40341515 100644
--- a/conan/internal/api/install/generators.py
+++ b/conan/internal/api/install/generators.py
@@ -73,6 +73,7 @@ def load_cache_generators(path):
 def write_generators(conanfile, app, envs_generation=None):
     new_gen_folder = conanfile.generators_folder
     _receive_conf(conanfile)
+    _receive_generators(conanfile)
 
     hook_manager = app.hook_manager
     # TODO: Optimize this, so the global generators are not loaded every call to write_generators
@@ -91,8 +92,11 @@ def write_generators(conanfile, app, envs_generation=None):
     conanfile.generators = []
     try:
         for generator_name in old_generators:
-            global_generator = global_generators.get(generator_name)
-            generator_class = global_generator or _get_generator_class(generator_name)
+            if isinstance(generator_name, str):
+                global_generator = global_generators.get(generator_name)
+                generator_class = global_generator or _get_generator_class(generator_name)
+            else:
+                generator_class = generator_name
             if generator_class:
                 try:
                     generator = generator_class(conanfile)
@@ -151,6 +155,13 @@ def _receive_conf(conanfile):
             conanfile.conf.compose_conf(build_require.conf_info)
 
 
+def _receive_generators(conanfile):
+    """  Collect generators_info from the immediate build_requires"""
+    for build_require in conanfile.dependencies.direct_build.values():
+        if build_require.generator_info:
+            conanfile.generators = build_require.generator_info + conanfile.generators
+
+
 def _generate_aggregated_env(conanfile):
 
     def deactivates(filenames):
diff --git a/conan/tools/env/__init__.py b/conan/tools/env/__init__.py
index 2a6b6da6f97..36ada371449 100644
--- a/conan/tools/env/__init__.py
+++ b/conan/tools/env/__init__.py
@@ -1,3 +1,3 @@
-from conan.tools.env.environment import Environment
+from conan.tools.env.environment import Environment, create_env_script, register_env_script
 from conan.tools.env.virtualbuildenv import VirtualBuildEnv
 from conan.tools.env.virtualrunenv import VirtualRunEnv
diff --git a/conan/tools/env/environment.py b/conan/tools/env/environment.py
index 59a9660f5ba..da04f216db8 100644
--- a/conan/tools/env/environment.py
+++ b/conan/tools/env/environment.py
@@ -646,9 +646,15 @@ def loads(text):
         return result
 
 
-def create_env_script(conanfile, content, filename, scope):
+def create_env_script(conanfile, content, filename, scope="build"):
     """
-    Create a file with any content which will be registered as a new script for the defined "group".
+    Create a file with any content which will be registered as a new script for the defined "scope".
+
+    Args:
+        conanfile: The Conanfile instance.
+        content (str): The content of the script to write into the file.
+        filename (str): The name of the file to be created in the generators folder.
+        scope (str): The scope or environment group for which the script will be registered.
     """
     path = os.path.join(conanfile.generators_folder, filename)
     save(path, content)
@@ -657,11 +663,16 @@ def create_env_script(conanfile, content, filename, scope):
         register_env_script(conanfile, path, scope)
 
 
-def register_env_script(conanfile, env_script_path, scope):
+def register_env_script(conanfile, env_script_path, scope="build"):
     """
-    Add the "env_script_path" to the current list of registered scripts for defined "group"
+    Add the "env_script_path" to the current list of registered scripts for defined "scope"
     These will be mapped to files:
     - conan{group}.bat|sh = calls env_script_path1,... env_script_pathN
+
+    Args:
+        conanfile: The Conanfile instance.
+        env_script_path (str): The full path of the script to register.
+        scope (str): The scope ('build' or 'host') for which the script will be registered.
     """
     existing = conanfile.env_scripts.setdefault(scope, [])
     if env_script_path not in existing:
diff --git a/conans/model/conan_file.py b/conans/model/conan_file.py
index 50cecb9b01e..33c2256b91a 100644
--- a/conans/model/conan_file.py
+++ b/conans/model/conan_file.py
@@ -74,6 +74,7 @@ class ConanFile:
     buildenv_info = None
     runenv_info = None
     conf_info = None
+    generator_info = None
 
     def __init__(self, display_name=""):
         self.display_name = display_name
diff --git a/conans/model/conanfile_interface.py b/conans/model/conanfile_interface.py
index 29a1607923f..2305821ea74 100644
--- a/conans/model/conanfile_interface.py
+++ b/conans/model/conanfile_interface.py
@@ -88,6 +88,10 @@ def context(self):
     def conf_info(self):
         return self._conanfile.conf_info
 
+    @property
+    def generator_info(self):
+        return self._conanfile.generator_info
+
     @property
     def dependencies(self):
         return self._conanfile.dependencies
diff --git a/test/integration/generators/test_generators_from_br.py b/test/integration/generators/test_generators_from_br.py
new file mode 100644
index 00000000000..bb80585865d
--- /dev/null
+++ b/test/integration/generators/test_generators_from_br.py
@@ -0,0 +1,74 @@
+import re
+import textwrap
+
+from conan.test.assets.genconanfile import GenConanfile
+from conan.test.utils.tools import TestClient
+
+
+def test_inject_generators_conf():
+    tc = TestClient(light=True)
+    tc.save({"tool/conanfile.py": textwrap.dedent("""
+    from conan import ConanFile
+
+    class MyGenerator:
+        def __init__(self, conanfile):
+            self.conanfile = conanfile
+
+        def generate(self):
+            self.conanfile.output.info("MyGenerator generated")
+
+    class ToolConan(ConanFile):
+        name = "tool"
+        version = "0.1"
+
+        def package_info(self):
+            self.generator_info = ["CMakeToolchain", MyGenerator]
+    """),
+             "conanfile.py": GenConanfile("app", "1.0").with_tool_requires("tool/0.1")})
+
+    tc.run("create tool")
+    tc.run("create .")
+    assert "app/1.0: CMakeToolchain generated: conan_toolchain.cmake" in tc.out
+    assert "app/1.0: MyGenerator generated" in tc.out
+
+
+def test_inject_vars():
+    import os
+    tc = TestClient(light=True)
+    tc.save({
+    "tool/envars_generator.sh": textwrap.dedent("""
+    export VAR1="value1"
+    export PATH="$PATH:/additional/path"
+    """),
+    "tool/conanfile.py": textwrap.dedent("""
+    from conan import ConanFile
+    from conan.tools.env import create_env_script
+    import os
+
+    class GeneratorSet:
+        def __init__(self, conanfile):
+            self.conanfile = conanfile
+
+        def generate(self):
+            content = 'call envars_generator.sh'
+            name = f"conantest.sh"
+            create_env_script(self.conanfile, content, name)
+
+    class ToolConan(ConanFile):
+        name = "tool"
+        version = "0.1"
+
+        def package_info(self):
+            self.generator_info = [GeneratorSet]
+    """),
+             "conanfile.py": GenConanfile("app", "1.0").with_tool_requires("tool/0.1")})
+
+    tc.run("create tool")
+    tc.run(f"create . ")
+
+    regex = r'Build folder (.+)'
+    out = tc.out
+    build_folder = re.findall(regex, out)[0]
+    file_path = os.path.join(build_folder, "conanbuild.sh")
+    with open(file_path, 'r') as file:
+         assert "conantest.sh" in file.read()


Code review comments:
diff_hunk: @@ -29,3 +30,45 @@ def package_info(self):
     tc.run("create .")
     assert "app/1.0: CMakeToolchain generated: conan_toolchain.cmake" in tc.out
     assert "app/1.0: MyGenerator generated" in tc.out
+
+
+def test_inject_vars():
+    import os
+    tc = TestClient(light=True)
+    tc.save({
+    "tool/envars_generator.sh": textwrap.dedent("""
+    export VAR1="value1"
+    export PATH="$PATH:/additional/path"
+    """),
+    "tool/conanfile.py": textwrap.dedent("""
+    from conan import ConanFile
+    from conan.tools.env import create_env_script
+    import os
+
+    class GeneratorSet:
+        def __init__(self, conanfile):
+            self.conanfile = conanfile
+
+        def generate(self):
+            content = 'call envars_generator.sh'
+            name = f"conantest.sh"
+            create_env_script(self.conanfile, content, name)
path: test/integration/generators/test_generators_from_br.py
text: @user1:
Please also use the ``register_env_script()`` here, to test that public API.

diff_hunk: @@ -29,3 +30,45 @@ def package_info(self):
     tc.run("create .")
     assert "app/1.0: CMakeToolchain generated: conan_toolchain.cmake" in tc.out
     assert "app/1.0: MyGenerator generated" in tc.out
+
+
+def test_inject_vars():
+    import os
+    tc = TestClient(light=True)
+    tc.save({
+    "tool/envars_generator.sh": textwrap.dedent("""
+    export VAR1="value1"
+    export PATH="$PATH:/additional/path"
+    """),
+    "tool/conanfile.py": textwrap.dedent("""
+    from conan import ConanFile
+    from conan.tools.env import create_env_script
+    import os
+
+    class GeneratorSet:
+        def __init__(self, conanfile):
+            self.conanfile = conanfile
+
+        def generate(self):
+            content = 'call envars_generator.sh'
+            name = f"conantest.sh"
+            create_env_script(self.conanfile, content, name)
+
+    class ToolConan(ConanFile):
+        name = "tool"
+        version = "0.1"
+
+        def package_info(self):
+            self.generator_info = [GeneratorSet]
+    """),
+             "conanfile.py": GenConanfile("app", "1.0").with_tool_requires("tool/0.1")})
+
+    tc.run("create tool")
+    tc.run(f"create . ")
+
+    regex = r'Build folder (.+)'
+    out = tc.out
+    build_folder = re.findall(regex, out)[0]
+    file_path = os.path.join(build_folder, "conanbuild.sh")
+    with open(file_path, 'r') as file:
+         assert "conantest.sh" in file.read()
path: test/integration/generators/test_generators_from_br.py
text: @user1:
```suggestion
    tc.run("create tool")
    tc.run(f"install . ")

    content = tc.load("conanbuild.sh")
    assert "conantest.sh" in content
```