Repository: DavidVujic/python-polylith

Issue:
poly error if config is in pyproject.toml in subfolder
**Describe the bug**
We moved the poly configuration to `pyproject.toml` which broke poly command line. 
The problem is in the function `find_workspace_root`,  which is not  looking for the `pyproject.toml` to find the workspace root

https://github.com/DavidVujic/python-polylith/blob/eb7cf13840d8fa0b0be7d40cfe0361191ccc1207/components/polylith/repo/repo.py#L46C5-L46C24


**To Reproduce**
Steps to reproduce the behavior:
1. Create a poly structure in a child folder
2. Merge `workspace.toml` into `pyproject.toml`
4. run poly

**Error Log**
```
╭──────────────────────────────────────────────────────────────────────────────────────── Traceback (most recent call last) ─────────────────────────────────────────────────────────────────────────────────────────╮
│ /workspaces/hawk/python/.venv/lib/python3.12/site-packages/polylith_cli/polylith/cli/create.py:13 in base_command                                                                                                  │
│                                                                                                                                                                                                                    │
│   10 @app.command('base')                                                                     ╭─────── locals ───────╮                                                                                             │
│   11 def base_command(name: Annotated[str, Option(help='Name of the base.')], description: An │ description = ''     │                                                                                             │
│   12 │   """Creates a Polylith base."""                                                       │        name = 'test' │                                                                                             │
│ ❱ 13 │   create(name, description, base.create_base)                                          ╰──────────────────────╯                                                                                             │
│   14                                                                                                                                                                                                               │
│   15 @app.command('component')                                                                                                                                                                                     │
│   16 def component_command(name: Annotated[str, Option(help='Name of the component.')], descr                                                                                                                      │
│                                                                                                                                                                                                                    │
│ /workspaces/hawk/python/.venv/lib/python3.12/site-packages/polylith_cli/polylith/commands/create.py:8 in create                                                                                                    │
│                                                                                                                                                                                                                    │
│    5 def create(name: Union[str, None], description: Union[str, None], fn):                   ╭────────────────── locals ───────────────────╮                                                                      │
│    6 │   root = repo.get_workspace_root(Path.cwd())                                           │ description = ''                            │                                                                      │
│    7 │   breakpoint()                                                                         │        name = 'test'                        │                                                                      │
│ ❱  8 │   namespace = configuration.get_namespace_from_config(root)                            │        root = PosixPath('/workspaces/hawk') │                                                                      │
│    9 │   if not name:                                                                         ╰─────────────────────────────────────────────╯                                                                      │
│   10 │   │   raise ValueError('Please add a name by using --name')                                                                                                                                                 │
│   11 │   if not namespace:                                                                                                                                                                                         │
│                                                                                                                                                                                                                    │
│ /workspaces/hawk/python/.venv/lib/python3.12/site-packages/polylith_cli/polylith/configuration/core.py:16 in get_namespace_from_config                                                                             │
│                                                                                                                                                                                                                    │
│   13 │   return tomlkit.loads(content)                                                        ╭─────────────── locals ───────────────╮                                                                             │
│   14                                                                                          │ path = PosixPath('/workspaces/hawk') │                                                                             │
│   15 def get_namespace_from_config(path: Path) -> str:                                        ╰──────────────────────────────────────╯                                                                             │
│ ❱ 16 │   toml: dict = _load_workspace_config(path)                                                                                                                                                                 │
│   17 │   return toml['tool']['polylith']['namespace']                                                                                                                                                              │
│   18                                                                                                                                                                                                               │
│   19 def get_git_tag_pattern(toml: dict) -> str:                                                                                                                                                                   │
│                                                                                                                                                                                                                    │
│ /workspaces/hawk/python/.venv/lib/python3.12/site-packages/polylith_cli/polylith/configuration/core.py:12 in _load_workspace_config                                                                                │
│                                                                                                                                                                                                                    │
│    9 │   fullpath = path / repo.workspace_file                                                ╭──────────────────────── locals ─────────────────────────╮                                                          │
│   10 │   if not fullpath.exists():                                                            │ fullpath = PosixPath('/workspaces/hawk/pyproject.toml') │                                                          │
│   11 │   │   fullpath = path / repo.default_toml                                              │     path = PosixPath('/workspaces/hawk')                │                                                          │
│ ❱ 12 │   content = fullpath.read_text()                                                       ╰─────────────────────────────────────────────────────────╯                                                          │
│   13 │   return tomlkit.loads(content)                                                                                                                                                                             │
│   14                                                                                                                                                                                                               │
│   15 def get_namespace_from_config(path: Path) -> str:                                                                                                                                                             │
│                                                                                                                                                                                                                    │
│ /usr/lib/python3.12/pathlib.py:1027 in read_text                                                                                                                                                                   │
│                                                                                                                                                                                                                    │
│   1024 │   │   Open the file in text mode, read it, and close the file.                         ╭──────────────────────── locals ─────────────────────────╮                                                        │
│   1025 │   │   """                                                                              │ encoding = 'locale'                                     │                                                        │
│   1026 │   │   encoding = io.text_encoding(encoding)                                            │   errors = None                                         │                                                        │
│ ❱ 1027 │   │   with self.open(mode='r', encoding=encoding, errors=errors) as f:                 │     self = PosixPath('/workspaces/hawk/pyproject.toml') │                                                        │
│   1028 │   │   │   return f.read()                                                              ╰─────────────────────────────────────────────────────────╯                                                        │
│   1029 │                                                                                                                                                                                                           │
│   1030 │   def write_bytes(self, data):                                                                                                                                                                            │
│                                                                                                                                                                                                                    │
│ /usr/lib/python3.12/pathlib.py:1013 in open                                                                                                                                                                        │
│                                                                                                                                                                                                                    │
│   1010 │   │   """                                                                              ╭───────────────────────── locals ─────────────────────────╮                                                       │
│   1011 │   │   if "b" not in mode:                                                              │ buffering = -1                                           │                                                       │
│   1012 │   │   │   encoding = io.text_encoding(encoding)                                        │  encoding = 'locale'                                     │                                                       │
│ ❱ 1013 │   │   return io.open(self, mode, buffering, encoding, errors, newline)                 │    errors = None                                         │                                                       │
│   1014 │                                                                                        │      mode = 'r'                                          │                                                       │
│   1015 │   def read_bytes(self):                                                                │   newline = None                                         │                                                       │
│   1016 │   │   """                                                                              │      self = PosixPath('/workspaces/hawk/pyproject.toml') │                                                       │
│                                                                                                 ╰──────────────────────────────────────────────────────────╯                                                       │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
FileNotFoundError: [Errno 2] No such file or directory: '/workspaces/hawk/pyproject.toml'
```

**Expected behavior**
Poly should work with or without workspace file from any folder structure

**Desktop (please complete the following information):**
 - OS: Linux
 - Python version: 3.12
 - Poetry version:  NA


Discussion:

Patch:
diff --git a/components/polylith/configuration/core.py b/components/polylith/configuration/core.py
index 0e6d95e..1f79c86 100644
--- a/components/polylith/configuration/core.py
+++ b/components/polylith/configuration/core.py
@@ -1,25 +1,11 @@
-from functools import lru_cache
 from pathlib import Path
 from typing import List, Union
 
-import tomlkit
 from polylith import repo
 
 
-@lru_cache
-def _load_workspace_config(path: Path) -> tomlkit.TOMLDocument:
-    fullpath = path / repo.workspace_file
-
-    if not fullpath.exists():
-        fullpath = path / repo.default_toml
-
-    content = fullpath.read_text()
-
-    return tomlkit.loads(content)
-
-
 def get_namespace_from_config(path: Path) -> str:
-    toml: dict = _load_workspace_config(path)
+    toml: dict = repo.load_workspace_config(path)
 
     return toml["tool"]["polylith"]["namespace"]
 
@@ -30,7 +16,7 @@ def get_git_tag_pattern(toml: dict) -> str:
 
 
 def get_tag_pattern_from_config(path: Path, key: Union[str, None]) -> Union[str, None]:
-    toml: dict = _load_workspace_config(path)
+    toml: dict = repo.load_workspace_config(path)
 
     patterns = toml["tool"]["polylith"].get("tag", {}).get("patterns")
 
@@ -41,7 +27,7 @@ def get_tag_pattern_from_config(path: Path, key: Union[str, None]) -> Union[str,
 
 
 def get_tag_sort_options_from_config(path: Path) -> List[str]:
-    toml: dict = _load_workspace_config(path)
+    toml: dict = repo.load_workspace_config(path)
 
     options = toml["tool"]["polylith"].get("tag", {}).get("sorting")
     # Default sorting option
@@ -51,21 +37,21 @@ def get_tag_sort_options_from_config(path: Path) -> List[str]:
 
 
 def is_test_generation_enabled(path: Path) -> bool:
-    toml: dict = _load_workspace_config(path)
+    toml: dict = repo.load_workspace_config(path)
 
     enabled = toml["tool"]["polylith"]["test"]["enabled"]
     return bool(enabled)
 
 
 def is_readme_generation_enabled(path: Path) -> bool:
-    toml: dict = _load_workspace_config(path)
+    toml: dict = repo.load_workspace_config(path)
 
     enabled = toml["tool"]["polylith"].get("resources", {}).get("brick_docs_enabled")
     return bool(enabled)
 
 
 def get_theme_from_config(path: Path) -> str:
-    toml: dict = _load_workspace_config(path)
+    toml: dict = repo.load_workspace_config(path)
 
     return toml["tool"]["polylith"]["structure"].get("theme") or "tdd"
 
diff --git a/components/polylith/repo/__init__.py b/components/polylith/repo/__init__.py
index c05041e..99195cc 100644
--- a/components/polylith/repo/__init__.py
+++ b/components/polylith/repo/__init__.py
@@ -9,6 +9,7 @@ from polylith.repo.repo import (
     is_pdm,
     is_pep_621_ready,
     is_poetry,
+    load_workspace_config,
     projects_dir,
     readme_file,
     workspace_file,
@@ -26,6 +27,7 @@ __all__ = [
     "is_pdm",
     "is_pep_621_ready",
     "is_poetry",
+    "load_workspace_config",
     "projects_dir",
     "readme_file",
     "workspace_file",
diff --git a/components/polylith/repo/repo.py b/components/polylith/repo/repo.py
index 5752143..e43b44e 100644
--- a/components/polylith/repo/repo.py
+++ b/components/polylith/repo/repo.py
@@ -1,6 +1,9 @@
+from functools import lru_cache
 from pathlib import Path
 from typing import Union
 
+import tomlkit
+
 workspace_file = "workspace.toml"
 root_file = ".git"
 default_toml = "pyproject.toml"
@@ -12,6 +15,38 @@ projects_dir = "projects"
 development_dir = "development"
 
 
+def load_content(fullpath: Path) -> tomlkit.TOMLDocument:
+    content = fullpath.read_text()
+
+    return tomlkit.loads(content)
+
+
+@lru_cache
+def load_root_project_config(path: Path) -> tomlkit.TOMLDocument:
+    fullpath = path / default_toml
+
+    return load_content(fullpath)
+
+
+def has_workspace_config(data: tomlkit.TOMLDocument) -> bool:
+    ns = data.get("tool", {}).get("polylith", {}).get("namespace")
+
+    return True if ns else False
+
+
+@lru_cache
+def load_workspace_config(path: Path) -> tomlkit.TOMLDocument:
+    fullpath = path / workspace_file
+
+    if fullpath.exists():
+        content = load_content(fullpath)
+
+        if has_workspace_config(content):
+            return content
+
+    return load_root_project_config(path)
+
+
 def is_drive_root(cwd: Path) -> bool:
     return cwd == Path(cwd.root) or cwd == cwd.parent
 
@@ -22,6 +57,12 @@ def is_repo_root(cwd: Path) -> bool:
     return fullpath.exists()
 
 
+def is_python_workspace_root(path: Path) -> bool:
+    data = load_root_project_config(path)
+
+    return has_workspace_config(data)
+
+
 def find_upwards(cwd: Path, name: str) -> Union[Path, None]:
     if is_drive_root(cwd):
         return None
@@ -29,7 +70,10 @@ def find_upwards(cwd: Path, name: str) -> Union[Path, None]:
     fullpath = cwd / name
 
     if fullpath.exists():
-        return fullpath
+        if name == workspace_file:
+            return fullpath
+
+        return fullpath if is_python_workspace_root(cwd) else None
 
     if is_repo_root(cwd):
         return None
@@ -45,9 +89,13 @@ def find_upwards_dir(cwd: Path, name: str) -> Union[Path, None]:
 
 def find_workspace_root(cwd: Path) -> Union[Path, None]:
     workspace_root = find_upwards_dir(cwd, workspace_file)
+
     if workspace_root:
         return workspace_root
-    return find_upwards_dir(cwd, root_file)
+
+    repo_root = find_upwards_dir(cwd, root_file)
+
+    return repo_root or find_upwards_dir(cwd, default_toml)
 
 
 def get_workspace_root(cwd: Path) -> Path:
@@ -55,7 +103,7 @@ def get_workspace_root(cwd: Path) -> Path:
 
     if not root:
         raise ValueError(
-            "Didn't find the workspace root. Expected to find a workspace.toml or .git file."
+            "Didn't find the workspace root. Expected to find a workspace.toml or pyproject.toml with Workspace config."
         )
 
     return root
diff --git a/projects/hatch_polylith_bricks/pyproject.toml b/projects/hatch_polylith_bricks/pyproject.toml
index d2e1271..e66166d 100644
--- a/projects/hatch_polylith_bricks/pyproject.toml
+++ b/projects/hatch_polylith_bricks/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "hatch-polylith-bricks"
-version = "1.2.8"
+version = "1.2.9"
 description = "Hatch build hook plugin for Polylith"
 authors = ['David Vujic']
 homepage = "https://davidvujic.github.io/python-polylith-docs/"
diff --git a/projects/pdm_polylith_bricks/pyproject.toml b/projects/pdm_polylith_bricks/pyproject.toml
index 029151e..be910e7 100644
--- a/projects/pdm_polylith_bricks/pyproject.toml
+++ b/projects/pdm_polylith_bricks/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "pdm-polylith-bricks"
-version = "1.0.9"
+version = "1.1.0"
 description = "a PDM build hook for Polylith"
 authors = ["David Vujic"]
 homepage = "https://davidvujic.github.io/python-polylith-docs/"
diff --git a/projects/pdm_polylith_workspace/pyproject.toml b/projects/pdm_polylith_workspace/pyproject.toml
index 1742342..588f76e 100644
--- a/projects/pdm_polylith_workspace/pyproject.toml
+++ b/projects/pdm_polylith_workspace/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "pdm-polylith-workspace"
-version = "1.0.9"
+version = "1.1.0"
 description = "a PDM build hook for a Polylith workspace"
 homepage = "https://davidvujic.github.io/python-polylith-docs/"
 repository = "https://github.com/davidvujic/python-polylith"
diff --git a/projects/poetry_polylith_plugin/pyproject.toml b/projects/poetry_polylith_plugin/pyproject.toml
index c43100e..0cccda4 100644
--- a/projects/poetry_polylith_plugin/pyproject.toml
+++ b/projects/poetry_polylith_plugin/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "poetry-polylith-plugin"
-version = "1.33.0"
+version = "1.34.0"
 description = "A Poetry plugin that adds tooling support for the Polylith Architecture"
 authors = ["David Vujic"]
 homepage = "https://davidvujic.github.io/python-polylith-docs/"
diff --git a/projects/polylith_cli/pyproject.toml b/projects/polylith_cli/pyproject.toml
index bfdf967..d89ba24 100644
--- a/projects/polylith_cli/pyproject.toml
+++ b/projects/polylith_cli/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "polylith-cli"
-version = "1.22.0"
+version = "1.23.0"
 description = "Python tooling support for the Polylith Architecture"
 authors = ['David Vujic']
 homepage = "https://davidvujic.github.io/python-polylith-docs/"


Test patch:
diff --git a/test/components/polylith/configuration/test_core.py b/test/components/polylith/configuration/test_core.py
index 05295ca..842ac84 100644
--- a/test/components/polylith/configuration/test_core.py
+++ b/test/components/polylith/configuration/test_core.py
@@ -41,9 +41,9 @@ def use_fake(monkeypatch):
         config = config_template.format(
             theme=theme, tag_sorting=_tag_sorting(tag_sorting)
         )
-        name = "_load_workspace_config"
+        name = "load_workspace_config"
 
-        monkeypatch.setattr(core, name, lambda *args: tomlkit.loads(config))
+        monkeypatch.setattr(core.repo, name, lambda *args: tomlkit.loads(config))
 
     return patch
 
diff --git a/test/components/polylith/libs/test_report.py b/test/components/polylith/libs/test_report.py
index 737dc70..5c72c7d 100644
--- a/test/components/polylith/libs/test_report.py
+++ b/test/components/polylith/libs/test_report.py
@@ -1,5 +1,15 @@
+import pytest
 from polylith.libs import report
 
+third_party_libs = {
+    "cleo",
+    "mypy-extensions",
+    "poetry",
+    "tomlkit",
+    "requests",
+    "rich",
+}
+
 
 def test_calculate_diff_reports_no_diff():
     brick_imports = {
@@ -11,13 +21,6 @@ def test_calculate_diff_reports_no_diff():
         },
     }
 
-    third_party_libs = {
-        "tomlkit",
-        "cleo",
-        "requests",
-        "rich",
-    }
-
     res = report.calculate_diff(brick_imports, third_party_libs)
 
     assert len(res) == 0
@@ -35,67 +38,33 @@ def test_calculate_diff_should_report_missing_dependency():
         },
     }
 
-    third_party_libs = {
-        "tomlkit",
-        "poetry",
-        "mypy-extensions",
-        "rich",
-    }
-
     res = report.calculate_diff(brick_imports, third_party_libs)
 
     assert res == {expected_missing}
 
 
-def test_calculate_diff_should_identify_close_match():
+@pytest.mark.parametrize(
+    "imports, is_strict",
+    [
+        ({"aws_lambda_powertools", "PIL", "pyyoutube"}, False),
+        ({"typing_extensions"}, True),
+    ],
+)
+def test_calculate_diff_should_identify_close_match(imports: set, is_strict: bool):
     brick_imports = {
-        "bases": {"my_base": {"poetry"}},
-        "components": {
-            "one": {"tomlkit"},
-            "two": {"tomlkit", "aws_lambda_powertools", "rich"},
-            "three": {"rich", "pyyoutube"},
-        },
+        "bases": {"thebase": {"typer"}},
+        "components": {"one": imports},
     }
 
-    third_party_libs = {
-        "tomlkit",
-        "python-youtube",
-        "poetry",
+    libs = {
         "aws-lambda-powertools",
-        "rich",
-    }
-
-    res = report.calculate_diff(brick_imports, third_party_libs)
-
-    assert len(res) == 0
-
-
-def test_calculate_diff_should_identify_close_match_case_insensitive():
-    brick_imports = {
-        "bases": {"my_base": {}},
-        "components": {
-            "one": {"PIL"},
-        },
-    }
-
-    third_party_libs = {"pillow"}
-
-    res = report.calculate_diff(brick_imports, third_party_libs)
-
-    assert len(res) == 0
-
-
-def test_calculate_diff_strict_should_identify_close_match_for_dash_and_low_dash():
-    brick_imports = {
-        "bases": {"thebase": {"typer"}},
-        "components": {
-            "one": {"typing_extensions"},
-        },
+        "pillow",
+        "python-youtube",
+        "typer",
+        "typing-extensions",
     }
 
-    third_party_libs = {"typer", "typing-extensions"}
-
-    res = report.calculate_diff(brick_imports, third_party_libs, True)
+    res = report.calculate_diff(brick_imports, libs, is_strict)
 
     assert len(res) == 0