Repository: conan-io/conan
Pull request #17887 (2025-03-04 00:11:29)

Feature/workspace partial install
Changelog: Feature: Allow partial ``workspace install <path1> ... <pathN>`` installation of workspace.
Docs: https://github.com/conan-io/docs/pull/XXXX

Close https://github.com/conan-io/conan/issues/17844



Patch:
diff --git a/conan/api/subapi/workspace.py b/conan/api/subapi/workspace.py
index 8eb077496..09dd7cbe2 100644
--- a/conan/api/subapi/workspace.py
+++ b/conan/api/subapi/workspace.py
@@ -122,6 +122,16 @@ class WorkspaceAPI:
                                                                    v["output_folder"]))
         return editables
 
+    def select_editables(self, paths):
+        filtered_refs = [self.editable_from_path(p) for p in paths or []]
+        editables = self.editable_packages
+        requires = [ref for ref in editables]
+        if filtered_refs:
+            ConanOutput().info(f"Filtering and installing only selected editable packages")
+            requires = [ref for ref in requires if ref in filtered_refs]
+            ConanOutput().info(f"Filtered references: {requires}")
+        return requires
+
     @property
     def products(self):
         self._check_ws()
diff --git a/conan/cli/commands/workspace.py b/conan/cli/commands/workspace.py
index ed49e7820..0dfbf2a27 100644
--- a/conan/cli/commands/workspace.py
+++ b/conan/cli/commands/workspace.py
@@ -50,8 +50,7 @@ def workspace_add(conan_api: ConanAPI, parser, subparser, *args):
     subparser.add_argument('path',  nargs="?",
                            help='Path to the package folder in the user workspace')
     add_reference_args(subparser)
-    subparser.add_argument("--ref", nargs="?",
-                           help="Open and add this reference")
+    subparser.add_argument("--ref", help="Open and add this reference")
     subparser.add_argument("-of", "--output-folder",
                            help='The root output folder for generated and build files')
     group = subparser.add_mutually_exclusive_group()
@@ -78,9 +77,10 @@ def workspace_add(conan_api: ConanAPI, parser, subparser, *args):
 @conan_subcommand()
 def workspace_remove(conan_api: ConanAPI, parser, subparser, *args):
     """
-    Remove packages to current workspace
+    Remove packages from the current workspace
     """
-    subparser.add_argument('path', help='Path to the package folder in the user workspace')
+    subparser.add_argument('path',
+                           help='Path to the package folder in the user workspace')
     args = parser.parse_args(*args)
     removed = conan_api.workspace.remove(make_abs_path(args.path))
     ConanOutput().info(f"Removed from workspace: {removed}")
@@ -170,6 +170,8 @@ 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("path", nargs="*",
+                           help="Install only these editable packages, not all")
     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')
@@ -191,8 +193,7 @@ def workspace_install(conan_api: ConanAPI, parser, subparser, *args):
 
     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]
+    requires = conan_api.workspace.select_editables(args.path)
     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, [],


Test patch:
diff --git a/test/integration/workspace/test_workspace.py b/test/integration/workspace/test_workspace.py
index 2fabaf9a3..982f99d1a 100644
--- a/test/integration/workspace/test_workspace.py
+++ b/test/integration/workspace/test_workspace.py
@@ -1,5 +1,6 @@
 import json
 import os
+import shutil
 import textwrap
 
 import pytest
@@ -254,6 +255,19 @@ class TestAddRemove:
         c.run("workspace info")
         assert "mydeppkg" not in c.out
 
+    def test_remove_removed_folder(self):
+        c = TestClient(light=True)
+        c.save({"conanws.yml": "",
+                "mydeppkg/conanfile.py": GenConanfile("mydeppkg", "0.1")})
+        c.run("workspace add mydeppkg")
+        # If we now remove the folder
+        shutil.rmtree(os.path.join(c.current_folder, "mydeppkg"))
+        # It can still be removed by path, even if the path doesn't exist
+        c.run("workspace remove mydeppkg")
+        assert "Removed from workspace: mydeppkg/0.1" in c.out
+        c.run("workspace info")
+        assert "mydeppkg" not in c.out
+
     def test_custom_add_remove(self):
         c = TestClient(light=True)
 
@@ -524,6 +538,30 @@ class TestMeta:
         c.run("workspace install", assert_error=True)
         assert "ERROR: Conanfile in conanws.py shouldn't have 'requires'" in c.out
 
+    def test_install_partial(self):
+        # If we want to install only some part of the workspace
+        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"),
+                "libb/conanfile.py": GenConanfile("libb", "0.1").with_requires("liba/0.1"),
+                "libc/conanfile.py": GenConanfile("libc", "0.1").with_requires("libb/0.1",
+                                                                               "dep2/0.1")},
+               clean_first=True)
+        c.run("workspace add liba")
+        c.run("workspace add libb")
+        c.run("workspace add libc")
+        for arg in ("libb", "libb liba"):
+            c.run(f"workspace install {arg} -g CMakeDeps -of=build")
+            assert "dep1/0.1" in c.out
+            assert "dep2/0.1" not in c.out
+            assert "libc/0.1" not in c.out
+            files = os.listdir(os.path.join(c.current_folder, "build"))
+            assert "dep1-config.cmake" in files
+            assert "dep2-config.cmake" not in files
+
 
 def test_workspace_with_local_recipes_index():
     c3i_folder = temp_folder()