Repository: conan-io/conan
Pull request #18343 (2025-05-22 13:54:00)

[workspace] Putting `conanws` folder as top limit search (if exists)
Changelog: Feature: Putting a folder named `conanws` as the top limit search if it exists.
Docs: https://github.com/conan-io/docs/pull/XXXX



Patch:
diff --git a/conan/api/conan_api.py b/conan/api/conan_api.py
index 909148e14..c62ca5a50 100644
--- a/conan/api/conan_api.py
+++ b/conan/api/conan_api.py
@@ -45,7 +45,8 @@ class ConanAPI:
             raise ConanException("cache_folder has to be an absolute path")
 
         init_colorama(sys.stderr)
-        self.cache_folder = cache_folder or get_conan_user_home()
+        self.workspace = WorkspaceAPI(self)
+        self.cache_folder = self.workspace.home_folder() or cache_folder or get_conan_user_home()
         self.home_folder = self.cache_folder  # Lets call it home, deprecate "cache"
         self.migrate()
 
@@ -70,8 +71,6 @@ class ConanAPI:
         self.lockfile = LockfileAPI(self)
         self.local = LocalAPI(self)
         self.audit = AuditAPI(self)
-        # Now, lazy loading of editables
-        self.workspace = WorkspaceAPI(self)
 
         _check_conan_version(self)
 
diff --git a/conan/api/subapi/workspace.py b/conan/api/subapi/workspace.py
index 7e2b3d322..dff610c55 100644
--- a/conan/api/subapi/workspace.py
+++ b/conan/api/subapi/workspace.py
@@ -10,7 +10,7 @@ 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, WORKSPACE_YML, WORKSPACE_PY
+from conan.internal.model.workspace import Workspace, WORKSPACE_YML, WORKSPACE_PY, WORKSPACE_FOLDER
 from conan.tools.scm import Git
 from conan.internal.graph.graph import RECIPE_EDITABLE, DepsGraph, CONTEXT_HOST, RECIPE_VIRTUAL, Node, \
     RECIPE_CONSUMER
@@ -23,7 +23,12 @@ from conan.internal.util.files import merge_directories, save
 
 def _find_ws_folder():
     path = Path(os.getcwd())
-    while path.is_dir() and len(path.parts) > 1:  # finish at '/'
+    while path.is_dir() and len(path.parts) > 1:  # finish at '/' or 'conanws/'
+        if path.name == WORKSPACE_FOLDER:
+            if not (path / WORKSPACE_YML).is_file() and not (path / WORKSPACE_PY).is_file():
+                raise ConanException("Within the 'conanws/' folder, there should be at least one of "
+                                     f"these files: {WORKSPACE_YML} and/or {WORKSPACE_PY}")
+            return str(path)
         if (path / WORKSPACE_YML).is_file() or (path / WORKSPACE_PY).is_file():
             return str(path)
         else:
@@ -87,6 +92,17 @@ class WorkspaceAPI:
         self._check_ws()
         return self._ws.name()
 
+    def home_folder(self):
+        """
+        @return: The custom defined Conan home/cache folder if defined, else None
+        """
+        if not self._folder:
+            return
+        folder = self._ws.home_folder()
+        if folder is None or os.path.isabs(folder):
+            return folder
+        return os.path.normpath(os.path.join(self._folder, folder))
+
     def folder(self):
         """
         @return: the current workspace folder where the conanws.yml or conanws.py is located
diff --git a/conan/internal/model/workspace.py b/conan/internal/model/workspace.py
index b2da994c8..5f5ad9aa2 100644
--- a/conan/internal/model/workspace.py
+++ b/conan/internal/model/workspace.py
@@ -7,6 +7,8 @@ from conan.api.output import ConanOutput
 from conan.errors import ConanException
 from conan.internal.util.files import load, save
 
+# Related folder
+WORKSPACE_FOLDER = "conanws"
 # Related files
 WORKSPACE_YML = "conanws.yml"
 WORKSPACE_PY = "conanws.py"
@@ -36,6 +38,9 @@ class Workspace:
             raise ConanException("Invalid yml format at {}: {}".format(WORKSPACE_YML, e))
         return data or {}
 
+    def home_folder(self):
+        return self.conan_data.get("home_folder")
+
     def add(self, ref, path, output_folder, product=False):
         assert os.path.isfile(path)
         path = self._conan_rel_path(os.path.dirname(path))


Test patch:
diff --git a/test/integration/workspace/test_workspace.py b/test/integration/workspace/test_workspace.py
index 8de6342c1..fe6982f70 100644
--- a/test/integration/workspace/test_workspace.py
+++ b/test/integration/workspace/test_workspace.py
@@ -15,7 +15,30 @@ from conan.internal.util.files import save, save_files
 WorkspaceAPI.TEST_ENABLED = "will_break_next"
 
 
-class TestWorkspaceRoot:
+class TestHomeRoot:
+    def test_workspace_home_yml(self):
+        folder = temp_folder()
+        cwd = os.path.join(folder, "sub1", "sub2")
+        save(os.path.join(folder, f"conanws.yml"), "home_folder: myhome")
+        c = TestClient(current_folder=cwd, light=True)
+        c.run("config home")
+        assert os.path.join(folder, "myhome") in c.stdout
+
+    def test_workspace_home_user_py(self):
+        folder = temp_folder()
+        cwd = os.path.join(folder, "sub1", "sub2")
+        conanwspy = textwrap.dedent("""
+            from conan import Workspace
+
+            class MyWs(Workspace):
+                def home_folder(self):
+                    return "new" + self.conan_data["home_folder"]
+            """)
+        save(os.path.join(folder, f"conanws.py"), conanwspy)
+        save(os.path.join(folder, "conanws.yml"), "home_folder: myhome")
+        c = TestClient(current_folder=cwd, light=True)
+        c.run("config home")
+        assert os.path.join(folder, "newmyhome") in c.stdout
 
     def test_workspace_root(self):
         c = TestClient(light=True)
@@ -23,8 +46,18 @@ class TestWorkspaceRoot:
         c.run("workspace root", assert_error=True)
         assert "ERROR: No workspace defined, conanws.py file not found" in c.out
 
+        # error, conanws/ folder does not contain conanws.[py | yml]
+        c.save({"conanws/test.txt": ""})
+        with c.chdir("conanws"):
+            c.run("workspace root", assert_error=True)
+            assert ("ERROR: Within the 'conanws/' folder, there should be at least one of these "
+                    "files: conanws.yml and/or conanws.py") in c.out
+            c.save({"conanws.yml": ""})
+            c.run("workspace root")
+            assert c.current_folder in c.stdout
+
         # error, empty .py
-        c.save({"conanws.py": ""})
+        c.save({"conanws.py": ""}, clean_first=True)
         c.run("workspace root", assert_error=True)
         assert "Error loading conanws.py" in c.out
         assert "No subclass of Workspace" in c.out
@@ -553,7 +586,7 @@ def test_workspace_with_local_recipes_index():
                                 "zlib/all/conandata.yml": ""})
 
     c = TestClient(light=True)
-    c.save({".conanrc": 'conan_home=deps\n'})
+    c.save({"conanws.yml": 'home_folder: "deps"'})
     c.run(f'remote add local "{c3i_folder}"')
 
     c.run("list zlib/1.2.11#* -r=local")