Repository: conan-io/conan
Pull request #17675 (2025-01-31 15:38:42)

Workspace install with meta-project and monolithic build
Changelog: Feature: Workspace new ``workspace install`` command for monolithic super-projects containing multiple ``editables``.
Changelog: Feature: New ``conan new workspace`` template contains CMake-based monolithic super-project that works with ``conan workspace install``.
Docs: https://github.com/conan-io/docs/pull/XXXX

Very early stage proof of concept based on FetchContent

Close https://github.com/conan-io/conan/issues/17665
Close https://github.com/conan-io/conan/issues/17723

Artalus: Quick question: is the `conanfilews.py` already set in stone?
We are ending up with a whole bunch of Conan files to describe the setup - projects' own `conanfile.py`, `python_requires.py` to deduplicate code between them, `conanws.py` to describe the workspace programmatically, `conanws.yml` with declarative data for it, and now this thing... Can its functionality not be offloaded to `python_requires` or `conanws.py`?
Artalus: Can we somehow filter (in a user-configurable way) what _part_ of the workspace gets installed?

I checked out this PR's branch, and the `conan new workspace` example works alright - but only in its bare minimum setup. In our monorepo (and I believe in many others) there are OS-specific projects that f.x. work only on Windows. If I specify such project in `conanws.yml` and it happens to require a Windows-only dependency (like `msys2` or `7z`), `conan workspace install` fails.
I tried to `raise ConanInvalidConfiguration()` in recipe's `def validate` or `def configure`, remembering how CCI's c3i tends to ignore those instead of failing a CI run, but no luck.

One more point in favor of such filtering - a user might be _not interested_ in the whole set of deps for all monorepo, f.x. if their team is only focused on a small subset of projects in there. On my previous job we had essentially a few `conanfile.txt`s, one per OS, and devs would often complain that `conan install` would prepare a few gigs of packages when they only need to work on 1 project out of 50.

So maybe do something like `conan workspace install app1`, similar to `conan workspace build app1`?
memsharded: > I checked out this PR's branch, and the conan new workspace example works alright - but only in its bare minimum setup. In our monorepo (and I believe in many others) there are OS-specific projects that f.x. work only on Windows. If I specify such project in conanws.yml and it happens to require a Windows-only dependency (like msys2 or 7z), conan workspace install fails.
I tried to raise ConanInvalidConfiguration() in recipe's def validate or def configure, remembering how CCI's c3i tends to ignore those instead of failing a CI run, but no luck.

If the workspace will contain different folders as editables for different platforms, this sounds like a job for the ``conanws.py`` ``editables()`` method, that can implement the filtering you want based on the current OS, isn't it?

> One more point in favor of such filtering - a user might be not interested in the whole set of deps for all monorepo, f.x. if their team is only focused on a small subset of projects in there. On my previous job we had essentially a few conanfile.txts, one per OS, and devs would often complain that conan install would prepare a few gigs of packages when they only need to work on 1 project out of 50.

> So maybe do something like conan workspace install app1, similar to conan workspace build app1?

The individual package flow still works, it is always possible to do ``conan install app1 --build=editable`` or ``conan build app1 --build=editable``. But that wouldn't be connected to the monolithic build, or did I misunderstood something.


Artalus: > this sounds like a job for the conanws.py editables() method, that can implement the filtering you want based on the current OS, isn't it?

If this is exactly the intended use case for having `conanws.py` over a `conanws.yml` - awesome, will explore it further! I only started experimenting with WS recently, so not sure what goes where 🙂 

> The individual package flow still works, it is always possible to do conan install app1 --build=editable or conan build app1 --build=editable. But that wouldn't be connected to the monolithic build, or did I misunderstood something.

I am currently considering this feature from the perspective of #17549 .
`conan workspace install` w/ monolithic build fixes my issue (when `liba` has a private 3rdparty dependency). But reverting to plain `conan install` would gives me include propagation errors again...

Patch:
diff --git a/conan/api/subapi/workspace.py b/conan/api/subapi/workspace.py
index 446d08347..8eb077496 100644
--- a/conan/api/subapi/workspace.py
+++ b/conan/api/subapi/workspace.py
@@ -3,16 +3,20 @@ import os
 import shutil
 from pathlib import Path
 
+from conan import ConanFile
+from conan.api.model import RecipeReference
 from conan.api.output import ConanOutput
 from conan.cli import make_abs_path
+from conan.errors import ConanException
 from conan.internal.conan_app import ConanApp
 from conan.internal.model.workspace import Workspace
 from conan.tools.scm import Git
-from conan.errors import ConanException
-from conans.client.graph.graph import RECIPE_EDITABLE
+from conans.client.graph.graph import RECIPE_EDITABLE, DepsGraph, CONTEXT_HOST, RECIPE_VIRTUAL, Node, \
+    RECIPE_CONSUMER
+from conans.client.graph.graph import TransitiveRequirement
+from conans.client.graph.profile_node_definer import consumer_definer
 from conans.client.loader import load_python_file
 from conans.client.source import retrieve_exports_sources
-from conan.api.model import RecipeReference
 from conans.util.files import merge_directories
 
 
@@ -203,3 +207,47 @@ class WorkspaceAPI:
         for ref, info in editables.items():
             if info["path"].replace("\\", "/") == path:
                 return RecipeReference.loads(ref)
+
+    def collapse_editables(self, deps_graph, profile_host, profile_build):
+        ConanOutput().title(f"Collapsing workspace editables")
+
+        root_class = self._ws.root_conanfile()
+        if root_class is not None:
+            conanfile = root_class("conanws.py base project Conanfile")
+            consumer_definer(conanfile, profile_host, profile_build)
+            root = Node(None, conanfile, context=CONTEXT_HOST, recipe=RECIPE_CONSUMER,
+                        path=self._folder)  # path lets use the conanws.py folder
+            root.should_build = True  # It is a consumer, this is something we are building
+            for field in ("requires", "build_requires", "test_requires", "requirements", "build",
+                          "source", "package"):
+                if getattr(conanfile, field, None):
+                    raise ConanException(f"Conanfile in conanws.py shouldn't have '{field}'")
+        else:
+            ConanOutput().info("Workspace conanfilews.py not found in the workspace folder, "
+                               "using default behavior")
+            conanfile = ConanFile(display_name="cli")
+            consumer_definer(conanfile, profile_host, profile_build)
+            root = Node(ref=None, conanfile=conanfile, context=CONTEXT_HOST, recipe=RECIPE_VIRTUAL)
+
+        result = DepsGraph()  # TODO: We might need to copy more information from the original graph
+        result.add_node(root)
+        for node in deps_graph.nodes[1:]:  # Exclude the current root
+            if node.recipe != RECIPE_EDITABLE:
+                result.add_node(node)
+                continue
+            for r, t in node.transitive_deps.items():
+                if t.node.recipe == RECIPE_EDITABLE:
+                    continue
+                existing = root.transitive_deps.pop(r, None)
+                if existing is None:
+                    root.transitive_deps[r] = t
+                else:
+                    require = existing.require
+                    require.aggregate(r)
+                    root.transitive_deps[require] = TransitiveRequirement(require, t.node)
+
+        # The graph edges must be defined too
+        for r, t in root.transitive_deps.items():
+            result.add_edge(root, t.node, r)
+
+        return result
diff --git a/conan/cli/commands/new.py b/conan/cli/commands/new.py
index 374f9548e..a4ea59b87 100644
--- a/conan/cli/commands/new.py
+++ b/conan/cli/commands/new.py
@@ -46,6 +46,7 @@ def new(conan_api, parser, *args):
             definitions[k] = v
 
     files = conan_api.new.get_template(args.template)  # First priority: user folder
+    is_builtin = False
     if not files:  # then, try the templates in the Conan home
         files = conan_api.new.get_home_template(args.template)
     if files:
@@ -53,10 +54,13 @@ def new(conan_api, parser, *args):
     else:
         template_files = conan_api.new.get_builtin_template(args.template)
         non_template_files = {}
+        is_builtin = True
 
     if not template_files and not non_template_files:
         raise ConanException("Template doesn't exist or not a folder: {}".format(args.template))
 
+    if is_builtin and args.template == "workspace":  # hardcoded for the workspace special case
+        definitions["name"] = "liba"
     template_files = conan_api.new.render(template_files, definitions)
 
     # Saving the resulting files
diff --git a/conan/cli/commands/workspace.py b/conan/cli/commands/workspace.py
index 474068fdf..ed49e7820 100644
--- a/conan/cli/commands/workspace.py
+++ b/conan/cli/commands/workspace.py
@@ -3,6 +3,7 @@ import os
 
 from conan.api.conan_api import ConanAPI
 from conan.api.output import ConanOutput, cli_out_write
+from conan.api.subapi.workspace import WorkspaceAPI
 from conan.cli import make_abs_path
 from conan.cli.args import add_reference_args, add_common_install_arguments, add_lockfile_args
 from conan.cli.command import conan_command, conan_subcommand
@@ -163,8 +164,62 @@ def workspace_build(conan_api: ConanAPI, parser, subparser, *args):
         conan_api.local.build(conanfile)
 
 
+@conan_subcommand()
+def workspace_install(conan_api: ConanAPI, parser, subparser, *args):
+    """
+    Install the workspace as a monolith, installing only external dependencies to the workspace,
+    generating a single result (generators, etc) for the whole workspace.
+    """
+    subparser.add_argument("-g", "--generator", action="append", help='Generators to use')
+    subparser.add_argument("-of", "--output-folder",
+                           help='The root output folder for generated and build files')
+    subparser.add_argument("--envs-generation", default=None, choices=["false"],
+                           help="Generation strategy for virtual environment files for the root")
+    add_common_install_arguments(subparser)
+    add_lockfile_args(subparser)
+    args = parser.parse_args(*args)
+    # Basic collaborators: remotes, lockfile, profiles
+    remotes = conan_api.remotes.list(args.remote) if not args.no_remote else []
+    overrides = eval(args.lockfile_overrides) if args.lockfile_overrides else None
+    # The lockfile by default if not defined will be read from the root workspace folder
+    ws_folder = conan_api.workspace.folder()
+    lockfile = conan_api.lockfile.get_lockfile(lockfile=args.lockfile, conanfile_path=ws_folder,
+                                               cwd=None,
+                                               partial=args.lockfile_partial, overrides=overrides)
+    profile_host, profile_build = conan_api.profiles.get_profiles_from_args(args)
+    print_profiles(profile_host, profile_build)
+
+    conan_api.workspace.info()  # FIXME: Just to force error if WS not enabled
+    # Build a dependency graph with all editables as requirements
+    editables = conan_api.workspace.editable_packages
+    requires = [ref for ref in editables]
+    if not requires:
+        raise ConanException("This workspace cannot be installed, it doesn't have any editable")
+    deps_graph = conan_api.graph.load_graph_requires(requires, [],
+                                                     profile_host, profile_build, lockfile,
+                                                     remotes, args.build, args.update)
+    deps_graph.report_graph_error()
+    print_graph_basic(deps_graph)
+
+    # Collapsing the graph
+    ws_graph = conan_api.workspace.collapse_editables(deps_graph, profile_host, profile_build)
+    ConanOutput().subtitle("Collapsed graph")
+    print_graph_basic(ws_graph)
+
+    conan_api.graph.analyze_binaries(ws_graph, args.build, remotes=remotes, update=args.update,
+                                     lockfile=lockfile)
+    print_graph_packages(ws_graph)
+    conan_api.install.install_binaries(deps_graph=ws_graph, remotes=remotes)
+    output_folder = make_abs_path(args.output_folder) if args.output_folder else None
+    conan_api.install.install_consumer(ws_graph, args.generator, ws_folder, output_folder,
+                                       envs_generation=args.envs_generation)
+
+
 @conan_command(group="Consumer")
 def workspace(conan_api, parser, *args):  # noqa
     """
     Manage Conan workspaces (group of packages in editable mode)
     """
+    if (WorkspaceAPI.TEST_ENABLED or os.getenv("CONAN_WORKSPACE_ENABLE")) != "will_break_next":
+        raise ConanException("Workspace command disabled without CONAN_WORKSPACE_ENABLE env var,"
+                             "please read the docs about this 'incubating' feature")
diff --git a/conan/internal/api/new/workspace.py b/conan/internal/api/new/workspace.py
index 7a56617eb..1a16b4476 100644
--- a/conan/internal/api/new/workspace.py
+++ b/conan/internal/api/new/workspace.py
@@ -15,19 +15,71 @@ products:
 - app1
 """
 
-workspace_files = {"conanws.yml": conanws_yml}
+cmake = """\
+cmake_minimum_required(VERSION 3.25)
+project(monorepo CXX)
 
-for lib in ("liba", "libb"):
-    definitions = {"name": lib}
-    if lib == "libb":
-        definitions["requires"] = ["liba/0.1"]
-    elif lib == "libc":
-        definitions["requires"] = ["libb/0.1"]
-    files = NewAPI.render(cmake_lib_files, definitions)
-    files = {f"{lib}/{k}": v for k, v in files.items()}
-    workspace_files.update(files)
+include(FetchContent)
 
+function(add_project SUBFOLDER)
+    message(STATUS "Adding project ${SUBFOLDER}")
+    FetchContent_Declare(
+        ${SUBFOLDER}
+        SOURCE_DIR ${CMAKE_CURRENT_LIST_DIR}/${SUBFOLDER}
+        SYSTEM
+        OVERRIDE_FIND_PACKAGE
+    )
+    FetchContent_MakeAvailable(${SUBFOLDER})
+endfunction()
 
+add_project(liba)
+# They should be defined in the liba/CMakeLists.txt, but we can fix it here
+add_library(liba::liba ALIAS liba)
+add_project(libb)
+add_library(libb::libb ALIAS libb)
+add_project(app1)
+"""
+
+conanfile = '''\
+from conan import Workspace
+from conan import ConanFile
+from conan.tools.cmake import CMakeDeps, CMakeToolchain, cmake_layout
+
+
+class MyWs(ConanFile):
+    """ This is a special conanfile, used only for workspace definition of layout
+    and generators. It shouldn't have requirements, tool_requirements. It shouldn't have
+    build() or package() methods
+    """
+    settings = "os", "compiler", "build_type", "arch"
+
+    def generate(self):
+        deps = CMakeDeps(self)
+        deps.generate()
+        tc = CMakeToolchain(self)
+        tc.generate()
+
+    def layout(self):
+        cmake_layout(self)
+
+
+class Ws(Workspace):
+    def root_conanfile(self):
+        return MyWs
+'''
+
+workspace_files = {"conanws.yml": conanws_yml,
+                   "CMakeLists.txt": cmake,
+                   "conanws.py": conanfile,
+                   ".gitignore": "build"}
+# liba
+files = {f"liba/{k}": v for k, v in cmake_lib_files.items()}
+workspace_files.update(files)
+# libb
+files = NewAPI.render(cmake_lib_files, {"requires": ["liba/0.1"], "name": "libb"})
+files = {f"libb/{k}": v for k, v in files.items()}
+workspace_files.update(files)
+# app
 files = NewAPI.render(cmake_exe_files, definitions={"name": "app1", "requires": ["libb/0.1"]})
 files = {f"app1/{k}": v for k, v in files.items()}
 workspace_files.update(files)
diff --git a/conan/internal/model/workspace.py b/conan/internal/model/workspace.py
index 13f89e3ce..01079f35b 100644
--- a/conan/internal/model/workspace.py
+++ b/conan/internal/model/workspace.py
@@ -89,3 +89,6 @@ class Workspace:
         conanfile = loader.load_named(conanfile_path, name=None, version=None, user=None,
                                       channel=None, remotes=None, graph_lock=None)
         return conanfile
+
+    def root_conanfile(self):  # noqa
+        return None
diff --git a/conan/tools/gnu/autotoolstoolchain.py b/conan/tools/gnu/autotoolstoolchain.py
index fd5421cc8..e2daf568f 100644
--- a/conan/tools/gnu/autotoolstoolchain.py
+++ b/conan/tools/gnu/autotoolstoolchain.py
@@ -257,7 +257,7 @@ class AutotoolsToolchain:
         env.append("LDFLAGS", self.ldflags)
         env.prepend_path("PKG_CONFIG_PATH", self._conanfile.generators_folder)
         # Issue related: https://github.com/conan-io/conan/issues/15486
-        if self._is_cross_building and getattr(self._conanfile, 'conf_build', False):
+        if self._is_cross_building and self._conanfile.conf_build:
             compilers_build_mapping = (
                 self._conanfile.conf_build.get("tools.build:compiler_executables", default={},
                                                check_type=dict)
diff --git a/conan/tools/gnu/gnutoolchain.py b/conan/tools/gnu/gnutoolchain.py
index a23f4bf0b..1b22240e8 100644
--- a/conan/tools/gnu/gnutoolchain.py
+++ b/conan/tools/gnu/gnutoolchain.py
@@ -186,7 +186,7 @@ class GnuToolchain:
                     compiler = unix_path(self._conanfile, compiler)
                     ret[env_var] = compiler  # User/tools ones have precedence
         # Issue related: https://github.com/conan-io/conan/issues/15486
-        if self._is_cross_building and getattr(self._conanfile, 'conf_build', False):
+        if self._is_cross_building and self._conanfile.conf_build:
             compilers_build_mapping = (
                 self._conanfile.conf_build.get("tools.build:compiler_executables", default={},
                                                check_type=dict)
diff --git a/conans/client/graph/profile_node_definer.py b/conans/client/graph/profile_node_definer.py
index 748304d03..63bbed9a0 100644
--- a/conans/client/graph/profile_node_definer.py
+++ b/conans/client/graph/profile_node_definer.py
@@ -110,3 +110,4 @@ def consumer_definer(conanfile, profile_host, profile_build):
     conanfile._conan_buildenv = profile_host.buildenv
     conanfile._conan_runenv = profile_host.runenv
     conanfile.conf = profile_host.conf.get_conanfile_conf(None, is_consumer=True)
+    conanfile.conf_build = profile_build.conf.get_conanfile_conf(None, is_consumer=True)


Test patch:
diff --git a/test/functional/workspace/__init__.py b/test/functional/workspace/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/test/functional/workspace/test_workspace.py b/test/functional/workspace/test_workspace.py
new file mode 100644
index 000000000..78978ebaf
--- /dev/null
+++ b/test/functional/workspace/test_workspace.py
@@ -0,0 +1,49 @@
+import os
+import platform
+
+import pytest
+
+from conan.api.subapi.workspace import WorkspaceAPI
+from conan.test.utils.tools import TestClient
+
+
+WorkspaceAPI.TEST_ENABLED = "will_break_next"
+
+
+@pytest.mark.tool("cmake")
+def test_build():
+    # This is not using the meta-project at all
+    c = TestClient()
+    c.run("new cmake_lib -d name=mymath")
+    c.run("create . -tf=")
+
+    c.save({}, clean_first=True)
+    c.run("new workspace -d requires=mymath/0.1")
+    c.run("workspace build")
+    assert "Calling build() for the product app1/0.1" in c.out
+    assert "conanfile.py (app1/0.1): Running CMake.build()" in c.out
+    # it works without failing
+
+
+# The workspace CMake needs at least 3.25 for find_package to work
+@pytest.mark.tool("cmake", "3.27")
+def test_metabuild():
+    # This is using the meta-project
+    c = TestClient()
+    c.run("new cmake_lib -d name=mymath")
+    c.run("create . -tf=")
+
+    c.save({}, clean_first=True)
+    c.run("new workspace -d requires=mymath/0.1")
+    c.run("workspace install")
+    assert os.path.exists(os.path.join(c.current_folder, "CMakeUserPresets.json"))
+    build_folder = "build/Release" if platform.system() != "Windows" else "build"
+    assert os.path.exists(os.path.join(c.current_folder, build_folder, "generators"))
+    config_preset = "conan-default" if platform.system() == "Windows" else "conan-release"
+    c.run_command(f"cmake --preset {config_preset}")
+    assert "Conan: Target declared 'mymath::mymath'" in c.out
+    assert "Adding project liba" in c.out
+    assert "Adding project libb" in c.out
+    assert "Adding project app1" in c.out
+    c.run_command("cmake --build --preset conan-release")
+    # it doesn't fail
diff --git a/test/integration/toolchains/gnu/test_autotoolstoolchain.py b/test/integration/toolchains/gnu/test_autotoolstoolchain.py
index c15908f98..64c9d8c9d 100644
--- a/test/integration/toolchains/gnu/test_autotoolstoolchain.py
+++ b/test/integration/toolchains/gnu/test_autotoolstoolchain.py
@@ -347,30 +347,23 @@ def test_conf_build_does_not_exist():
     host = textwrap.dedent("""
     [settings]
     arch=x86_64
-    build_type=Release
-    compiler=gcc
-    compiler.cppstd=gnu17
-    compiler.libcxx=libstdc++11
-    compiler.version=13
     os=Linux
     [conf]
-    tools.build:compiler_executables={'c': 'x86_64-linux-gnu-gcc', 'cpp': 'x86_64-linux-gnu-g++'}
+    tools.build:compiler_executables={'c': '/usr/bin/gcc', 'cpp': '/usr/bin/g++'}
     """)
     build = textwrap.dedent("""
     [settings]
     arch=armv8
-    build_type=Release
-    compiler=gcc
-    compiler.cppstd=gnu17
-    compiler.libcxx=libstdc++11
-    compiler.version=13
     os=Linux
+    [conf]
+    tools.build:compiler_executables={'c': 'x86_64-linux-gnu-gcc', 'cpp': 'x86_64-linux-gnu-g++'}
     """)
     c = TestClient()
-    c.save({
-        "conanfile.py": GenConanfile("pkg", "0.1"),
-        "host": host,
-        "build": build
-    })
+    c.save({"conanfile.py": GenConanfile("pkg", "0.1"),
+            "host": host,
+            "build": build})
     c.run("export .")
     c.run("install --requires=pkg/0.1 --build=pkg/0.1 -g AutotoolsToolchain -pr:h host -pr:b build")
+    tc = c.load("conanautotoolstoolchain.sh")
+    assert 'export CC_FOR_BUILD="x86_64-linux-gnu-gcc"' in tc
+    assert 'export CXX_FOR_BUILD="x86_64-linux-gnu-g++"' in tc
diff --git a/test/integration/toolchains/gnu/test_gnutoolchain.py b/test/integration/toolchains/gnu/test_gnutoolchain.py
index a5fe7f3f6..e47e30e05 100644
--- a/test/integration/toolchains/gnu/test_gnutoolchain.py
+++ b/test/integration/toolchains/gnu/test_gnutoolchain.py
@@ -436,30 +436,23 @@ def test_conf_build_does_not_exist():
     host = textwrap.dedent("""
     [settings]
     arch=x86_64
-    build_type=Release
-    compiler=gcc
-    compiler.cppstd=gnu17
-    compiler.libcxx=libstdc++11
-    compiler.version=13
     os=Linux
     [conf]
-    tools.build:compiler_executables={'c': 'x86_64-linux-gnu-gcc', 'cpp': 'x86_64-linux-gnu-g++'}
+    tools.build:compiler_executables={'c': '/usr/bin/gcc', 'cpp': '/usr/bin/g++'}
     """)
     build = textwrap.dedent("""
     [settings]
     arch=armv8
-    build_type=Release
-    compiler=gcc
-    compiler.cppstd=gnu17
-    compiler.libcxx=libstdc++11
-    compiler.version=13
     os=Linux
+    [conf]
+    tools.build:compiler_executables={'c': 'x86_64-linux-gnu-gcc', 'cpp': 'x86_64-linux-gnu-g++'}
     """)
     c = TestClient()
-    c.save({
-        "conanfile.py": GenConanfile("pkg", "0.1"),
-        "host": host,
-        "build": build
-    })
+    c.save({"conanfile.py": GenConanfile("pkg", "0.1"),
+            "host": host,
+            "build": build})
     c.run("export .")
     c.run("install --requires=pkg/0.1 --build=pkg/0.1 -g GnuToolchain -pr:h host -pr:b build")
+    tc = c.load("conangnutoolchain.sh")
+    assert 'export CC_FOR_BUILD="x86_64-linux-gnu-gcc"' in tc
+    assert 'export CXX_FOR_BUILD="x86_64-linux-gnu-g++"' in tc
diff --git a/test/integration/workspace/test_workspace.py b/test/integration/workspace/test_workspace.py
index 59d309957..92eb9d1f7 100644
--- a/test/integration/workspace/test_workspace.py
+++ b/test/integration/workspace/test_workspace.py
@@ -433,11 +433,94 @@ class TestWorkspaceBuild:
         assert "There are no products defined in the workspace, can't build" in c.out
 
 
-def test_new():
-    # Very basic workspace for testing
-    c = TestClient()
-    c.run("new workspace")
-    c.run("workspace info")
-    assert "liba/0.1" in c.out
-    assert "libb/0.1" in c.out
-    assert "app1/0.1" in c.out
+class TestNew:
+    def test_new(self):
+        # Very basic workspace for testing
+        c = TestClient(light=True)
+        c.run("new workspace")
+        assert 'name = "liba"' in c.load("liba/conanfile.py")
+        c.run("workspace info")
+        assert "liba/0.1" in c.out
+        assert "libb/0.1" in c.out
+        assert "app1/0.1" in c.out
+
+    def test_new_dep(self):
+        c = TestClient(light=True)
+        c.run("new workspace -d requires=dep/0.1")
+        assert 'self.requires("dep/0.1")' in c.load("liba/conanfile.py")
+        assert 'name = "liba"' in c.load("liba/conanfile.py")
+        c.run("workspace info")
+        assert "liba/0.1" in c.out
+        assert "libb/0.1" in c.out
+        assert "app1/0.1" in c.out
+
+
+class TestMeta:
+    def test_install(self):
+        c = TestClient()
+        c.save({"dep/conanfile.py": GenConanfile()})
+        c.run("create dep --name=dep1 --version=0.1")
+        c.run("create dep --name=dep2 --version=0.1")
+        c.save({"conanws.yml": "",
+                "liba/conanfile.py": GenConanfile("liba", "0.1").with_requires("dep1/0.1",
+                                                                               "dep2/0.1"),
+                "libb/conanfile.py": GenConanfile("libb", "0.1").with_requires("liba/0.1",
+                                                                               "dep1/0.1")},
+               clean_first=True)
+        c.run("workspace add liba")
+        c.run("workspace add libb")
+        c.run("workspace install -g CMakeDeps -g CMakeToolchain -of=build --envs-generation=false")
+        assert "Workspace conanfilews.py not found in the workspace folder, using default" in c.out
+        files = os.listdir(os.path.join(c.current_folder, "build"))
+        assert "conan_toolchain.cmake" in files
+        assert "dep1-config.cmake" in files
+        assert "dep2-config.cmake" in files
+        assert "liba-config.cmake" not in files
+        assert "libb-config.cmake" not in files
+        assert "conanbuild.bat" not in files
+        assert "conanbuild.sh" not in files
+
+    def test_conanfilews_custom(self):
+        c = TestClient()
+        conanfilews = textwrap.dedent("""
+            from conan import ConanFile
+            from conan import Workspace
+
+            class MyWs(ConanFile):
+                settings = "arch", "build_type"
+                generators = "MSBuildDeps"
+
+            class Ws(Workspace):
+                def root_conanfile(self):
+                    return MyWs
+            """)
+
+        c.save({"dep/conanfile.py": GenConanfile("dep", "0.1"),
+                "conanws.py": conanfilews})
+        c.run("workspace add dep")
+        c.run("workspace install -of=build")
+        print(c.out)
+        files = os.listdir(os.path.join(c.current_folder, "build"))
+        assert "conandeps.props" in files
+
+    def test_conanfilews_errors(self):
+        c = TestClient()
+        conanfilews = textwrap.dedent("""
+            from conan import ConanFile
+            from conan import Workspace
+            class MyWs(ConanFile):
+                requires = "dep/0.1"
+
+            class Ws(Workspace):
+                def root_conanfile(self):
+                    return MyWs
+            """)
+
+        c.save({"conanws.yml": "conanfilews: myconanfilews.py",
+                "dep/conanfile.py": GenConanfile("dep", "0.1"),
+                "conanws.py": conanfilews})
+        c.run("workspace install", assert_error=True)
+        assert "ERROR: This workspace cannot be installed, it doesn't have any editable" in c.out
+        c.run("workspace add dep")
+        c.run("workspace install", assert_error=True)
+        assert "ERROR: Conanfile in conanws.py shouldn't have 'requires'" in c.out