Repository: useblocks/sphinx-needs
Pull request #1443 (2025-04-22 09:57:09)

🐛 Fix cyclic imports
This PR fixes circular SN imports by moving `exceptions.py` to the root.

A test case was added to prevent this in future.

> [!NOTE]
> Ruff might support this in future: https://github.com/astral-sh/ruff/issues/2914

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/useblocks/sphinx-needs/pull/1443?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks) Report
Attention: Patch coverage is `92.30769%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 88.53%. Comparing base [(`4e10030`)](https://app.codecov.io/gh/useblocks/sphinx-needs/commit/4e10030581c03c59764e517ab750d3ddbb2695d8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks) to head [(`77192cd`)](https://app.codecov.io/gh/useblocks/sphinx-needs/commit/77192cdcda527c3362bc679c549418173dfb8c4c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks).
> Report is 127 commits behind head on master.

| [Files with missing lines](https://app.codecov.io/gh/useblocks/sphinx-needs/pull/1443?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks) | Patch % | Lines |
|---|---|---|
| [sphinx\_needs/config.py](https://app.codecov.io/gh/useblocks/sphinx-needs/pull/1443?src=pr&el=tree&filepath=sphinx_needs%2Fconfig.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks#diff-c3BoaW54X25lZWRzL2NvbmZpZy5weQ==) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/useblocks/sphinx-needs/pull/1443?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #1443      +/-   ##
==========================================
+ Coverage   86.87%   88.53%   +1.65%     
==========================================
  Files          56       60       +4     
  Lines        6532     7239     +707     
==========================================
+ Hits         5675     6409     +734     
+ Misses        857      830      -27     
```

| [Flag](https://app.codecov.io/gh/useblocks/sphinx-needs/pull/1443/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks) | Coverage Δ | |
|---|---|---|
| [pytests](https://app.codecov.io/gh/useblocks/sphinx-needs/pull/1443/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks) | `88.53% <92.30%> (+1.65%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/useblocks/sphinx-needs/pull/1443?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=useblocks).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>

Patch:
diff --git a/docs/api.rst b/docs/api.rst
index d331941e..7332cebb 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -31,7 +31,7 @@ Need
 Exceptions
 ----------
 
-.. automodule:: sphinx_needs.api.exceptions
+.. automodule:: sphinx_needs.exceptions
    :members:
 
 Data
diff --git a/sphinx_needs/api/__init__.py b/sphinx_needs/api/__init__.py
index 39f3bf42..627efaf5 100644
--- a/sphinx_needs/api/__init__.py
+++ b/sphinx_needs/api/__init__.py
@@ -1,10 +1,11 @@
+from sphinx_needs.exceptions import InvalidNeedException
+
 from .configuration import (
     add_dynamic_function,
     add_extra_option,
     add_need_type,
     get_need_types,
 )
-from .exceptions import InvalidNeedException
 from .need import add_external_need, add_need, del_need, generate_need, get_needs_view
 
 __all__ = (
diff --git a/sphinx_needs/api/configuration.py b/sphinx_needs/api/configuration.py
index 0592a908..d4a2bb86 100644
--- a/sphinx_needs/api/configuration.py
+++ b/sphinx_needs/api/configuration.py
@@ -11,9 +11,9 @@ from typing import Callable
 from sphinx.application import Sphinx
 from sphinx.util.logging import SphinxLoggerAdapter
 
-from sphinx_needs.api.exceptions import NeedsApiConfigException
 from sphinx_needs.config import _NEEDS_CONFIG, NeedsSphinxConfig
 from sphinx_needs.data import NeedsInfoType
+from sphinx_needs.exceptions import NeedsApiConfigException
 from sphinx_needs.functions.functions import DynamicFunction
 
 
diff --git a/sphinx_needs/api/exceptions.py b/sphinx_needs/api/exceptions.py
index 0174a76a..f956141f 100644
--- a/sphinx_needs/api/exceptions.py
+++ b/sphinx_needs/api/exceptions.py
@@ -1,76 +1,15 @@
-from __future__ import annotations
-
-from typing import Literal
-
-from sphinx.errors import SphinxError, SphinxWarning
-
-
-class NeedsApiConfigException(SphinxError):
-    """
-    A configuration changes collides with the already provided configuration by the user.
-
-    Example: An extension wants to add an already existing needs_type.
-    """
-
-
-class InvalidNeedException(Exception):
-    """Raised when a need could not be created/added, due to a validation issue."""
-
-    def __init__(
-        self,
-        type_: Literal[
-            "invalid_kwargs",
-            "invalid_type",
-            "missing_id",
-            "invalid_id",
-            "duplicate_id",
-            "invalid_status",
-            "invalid_tags",
-            "invalid_constraints",
-            "invalid_jinja_content",
-            "invalid_template",
-            "global_option",
-        ],
-        message: str,
-    ) -> None:
-        self._type = type_
-        self._message = message
-        super().__init__(f"{message} [{type_}]")
-
-    @property
-    def type(
-        self,
-    ) -> Literal[
-        "invalid_kwargs",
-        "invalid_type",
-        "missing_id",
-        "invalid_id",
-        "duplicate_id",
-        "invalid_status",
-        "invalid_tags",
-        "invalid_constraints",
-        "invalid_jinja_content",
-        "invalid_template",
-        "global_option",
-    ]:
-        return self._type
-
-    @property
-    def message(self) -> str:
-        return self._message
-
-
-class NeedsApiConfigWarning(SphinxWarning):
-    pass
-
-
-class NeedsInvalidException(SphinxError):
-    pass
-
-
-class NeedsConstraintFailed(SphinxError):
-    pass
-
-
-class NeedsInvalidFilter(SphinxError):
-    pass
+import warnings
+
+from sphinx_needs.exceptions import (
+    InvalidNeedException,  # noqa: F401
+    NeedsApiConfigException,  # noqa: F401
+    NeedsApiConfigWarning,  # noqa: F401
+    NeedsConstraintFailed,  # noqa: F401
+    NeedsInvalidException,  # noqa: F401
+    NeedsInvalidFilter,  # noqa: F401
+)
+
+warnings.warn(
+    "'sphinx_needs.api.exceptions' is deprecated. Use 'sphinx_needs.exception' instead.",
+    stacklevel=2,
+)
diff --git a/sphinx_needs/api/need.py b/sphinx_needs/api/need.py
index da1cbd30..c08d4ccd 100644
--- a/sphinx_needs/api/need.py
+++ b/sphinx_needs/api/need.py
@@ -16,10 +16,10 @@ from jinja2 import Template
 from sphinx.application import Sphinx
 from sphinx.environment import BuildEnvironment
 
-from sphinx_needs.api.exceptions import InvalidNeedException
 from sphinx_needs.config import NeedsSphinxConfig
 from sphinx_needs.data import NeedsInfoType, NeedsPartType, SphinxNeedsData
 from sphinx_needs.directives.needuml import Needuml, NeedumlException
+from sphinx_needs.exceptions import InvalidNeedException
 from sphinx_needs.filter_common import filter_single_need
 from sphinx_needs.logging import get_logger, log_warning
 from sphinx_needs.nodes import Need
diff --git a/sphinx_needs/config.py b/sphinx_needs/config.py
index 6aff2640..7caa8420 100644
--- a/sphinx_needs/config.py
+++ b/sphinx_needs/config.py
@@ -104,7 +104,7 @@ class _Config:
                     None,
                 )
             else:
-                from sphinx_needs.api.exceptions import (
+                from sphinx_needs.exceptions import (
                     NeedsApiConfigWarning,  # avoid circular import
                 )
 
diff --git a/sphinx_needs/directives/needextend.py b/sphinx_needs/directives/needextend.py
index 4ef01ffe..a7cd8e51 100644
--- a/sphinx_needs/directives/needextend.py
+++ b/sphinx_needs/directives/needextend.py
@@ -7,10 +7,10 @@ from docutils import nodes
 from docutils.parsers.rst import directives
 from sphinx.util.docutils import SphinxDirective
 
-from sphinx_needs.api.exceptions import NeedsInvalidFilter
 from sphinx_needs.api.need import _split_list_with_dyn_funcs
 from sphinx_needs.config import NeedsSphinxConfig
 from sphinx_needs.data import NeedsExtendType, NeedsMutable, SphinxNeedsData
+from sphinx_needs.exceptions import NeedsInvalidFilter
 from sphinx_needs.filter_common import filter_needs_mutable
 from sphinx_needs.logging import get_logger, log_warning
 from sphinx_needs.utils import add_doc
diff --git a/sphinx_needs/directives/needpie.py b/sphinx_needs/directives/needpie.py
index 1c5e8d9c..db761ec2 100644
--- a/sphinx_needs/directives/needpie.py
+++ b/sphinx_needs/directives/needpie.py
@@ -7,11 +7,11 @@ from docutils import nodes
 from docutils.parsers.rst import directives
 from sphinx.application import Sphinx
 
-from sphinx_needs.api.exceptions import NeedsInvalidFilter
 from sphinx_needs.config import NeedsSphinxConfig
 from sphinx_needs.data import NeedsPieType, SphinxNeedsData
 from sphinx_needs.debug import measure_time
 from sphinx_needs.directives.utils import no_needs_found_paragraph
+from sphinx_needs.exceptions import NeedsInvalidFilter
 from sphinx_needs.filter_common import FilterBase, filter_needs_parts
 from sphinx_needs.logging import get_logger, log_warning
 from sphinx_needs.utils import (
diff --git a/sphinx_needs/directives/needtable.py b/sphinx_needs/directives/needtable.py
index 80f76df9..d006499f 100644
--- a/sphinx_needs/directives/needtable.py
+++ b/sphinx_needs/directives/needtable.py
@@ -8,7 +8,6 @@ from docutils import nodes
 from docutils.parsers.rst import directives
 from sphinx.application import Sphinx
 
-from sphinx_needs.api.exceptions import NeedsInvalidException
 from sphinx_needs.config import NeedsSphinxConfig
 from sphinx_needs.data import NeedsInfoType, NeedsTableType, SphinxNeedsData
 from sphinx_needs.debug import measure_time
@@ -18,6 +17,7 @@ from sphinx_needs.directives.utils import (
     no_needs_found_paragraph,
     used_filter_paragraph,
 )
+from sphinx_needs.exceptions import NeedsInvalidException
 from sphinx_needs.filter_common import FilterBase, process_filters
 from sphinx_needs.functions.functions import check_and_get_content
 from sphinx_needs.roles.need_part import iter_need_parts
diff --git a/sphinx_needs/exceptions.py b/sphinx_needs/exceptions.py
new file mode 100644
index 00000000..0174a76a
--- /dev/null
+++ b/sphinx_needs/exceptions.py
@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from sphinx.errors import SphinxError, SphinxWarning
+
+
+class NeedsApiConfigException(SphinxError):
+    """
+    A configuration changes collides with the already provided configuration by the user.
+
+    Example: An extension wants to add an already existing needs_type.
+    """
+
+
+class InvalidNeedException(Exception):
+    """Raised when a need could not be created/added, due to a validation issue."""
+
+    def __init__(
+        self,
+        type_: Literal[
+            "invalid_kwargs",
+            "invalid_type",
+            "missing_id",
+            "invalid_id",
+            "duplicate_id",
+            "invalid_status",
+            "invalid_tags",
+            "invalid_constraints",
+            "invalid_jinja_content",
+            "invalid_template",
+            "global_option",
+        ],
+        message: str,
+    ) -> None:
+        self._type = type_
+        self._message = message
+        super().__init__(f"{message} [{type_}]")
+
+    @property
+    def type(
+        self,
+    ) -> Literal[
+        "invalid_kwargs",
+        "invalid_type",
+        "missing_id",
+        "invalid_id",
+        "duplicate_id",
+        "invalid_status",
+        "invalid_tags",
+        "invalid_constraints",
+        "invalid_jinja_content",
+        "invalid_template",
+        "global_option",
+    ]:
+        return self._type
+
+    @property
+    def message(self) -> str:
+        return self._message
+
+
+class NeedsApiConfigWarning(SphinxWarning):
+    pass
+
+
+class NeedsInvalidException(SphinxError):
+    pass
+
+
+class NeedsConstraintFailed(SphinxError):
+    pass
+
+
+class NeedsInvalidFilter(SphinxError):
+    pass
diff --git a/sphinx_needs/filter_common.py b/sphinx_needs/filter_common.py
index 743525aa..037919fc 100644
--- a/sphinx_needs/filter_common.py
+++ b/sphinx_needs/filter_common.py
@@ -19,7 +19,6 @@ from docutils.parsers.rst import directives
 from sphinx.application import Sphinx
 from sphinx.util.docutils import SphinxDirective
 
-from sphinx_needs.api.exceptions import NeedsInvalidFilter
 from sphinx_needs.config import NeedsSphinxConfig
 from sphinx_needs.data import (
     NeedsFilteredBaseType,
@@ -27,6 +26,7 @@ from sphinx_needs.data import (
     NeedsMutable,
 )
 from sphinx_needs.debug import measure_time, measure_time_func
+from sphinx_needs.exceptions import NeedsInvalidFilter
 from sphinx_needs.logging import log_warning
 from sphinx_needs.utils import check_and_get_external_filter_func
 from sphinx_needs.utils import logger as log
diff --git a/sphinx_needs/functions/common.py b/sphinx_needs/functions/common.py
index c8b009e3..cb66e0b0 100644
--- a/sphinx_needs/functions/common.py
+++ b/sphinx_needs/functions/common.py
@@ -12,9 +12,9 @@ from typing import Any
 
 from sphinx.application import Sphinx
 
-from sphinx_needs.api.exceptions import NeedsInvalidFilter
 from sphinx_needs.config import NeedsSphinxConfig
 from sphinx_needs.data import NeedsInfoType, NeedsMutable
+from sphinx_needs.exceptions import NeedsInvalidFilter
 from sphinx_needs.filter_common import (
     filter_needs,
     filter_single_need,
diff --git a/sphinx_needs/need_constraints.py b/sphinx_needs/need_constraints.py
index c11d207f..4a3d6dcf 100644
--- a/sphinx_needs/need_constraints.py
+++ b/sphinx_needs/need_constraints.py
@@ -2,9 +2,9 @@ from __future__ import annotations
 
 import jinja2
 
-from sphinx_needs.api.exceptions import NeedsConstraintFailed
 from sphinx_needs.config import NeedsSphinxConfig
 from sphinx_needs.data import NeedsMutable
+from sphinx_needs.exceptions import NeedsConstraintFailed
 from sphinx_needs.filter_common import filter_single_need
 from sphinx_needs.logging import get_logger, log_warning
 
diff --git a/sphinx_needs/roles/need_count.py b/sphinx_needs/roles/need_count.py
index 06ee0d47..3f3deae1 100644
--- a/sphinx_needs/roles/need_count.py
+++ b/sphinx_needs/roles/need_count.py
@@ -9,9 +9,9 @@ from __future__ import annotations
 from docutils import nodes
 from sphinx.application import Sphinx
 
-from sphinx_needs.api.exceptions import NeedsInvalidFilter
 from sphinx_needs.config import NeedsSphinxConfig
 from sphinx_needs.data import SphinxNeedsData
+from sphinx_needs.exceptions import NeedsInvalidFilter
 from sphinx_needs.filter_common import filter_needs_parts
 from sphinx_needs.logging import get_logger
 
diff --git a/sphinx_needs/services/github.py b/sphinx_needs/services/github.py
index 5f0f000a..881ed876 100644
--- a/sphinx_needs/services/github.py
+++ b/sphinx_needs/services/github.py
@@ -13,8 +13,8 @@ from sphinx.util.docutils import SphinxDirective
 from sphinx.util.logging import getLogger
 
 from sphinx_needs.api import add_need_type
-from sphinx_needs.api.exceptions import NeedsApiConfigException
 from sphinx_needs.config import NeedsSphinxConfig
+from sphinx_needs.exceptions import NeedsApiConfigException
 from sphinx_needs.logging import log_warning
 from sphinx_needs.services.base import BaseService
 from sphinx_needs.services.config.github import (
diff --git a/sphinx_needs/utils.py b/sphinx_needs/utils.py
index a9398bc4..ce577744 100644
--- a/sphinx_needs/utils.py
+++ b/sphinx_needs/utils.py
@@ -15,10 +15,10 @@ from jinja2 import Environment, Template
 from sphinx.application import Sphinx
 from sphinx.environment import BuildEnvironment
 
-from sphinx_needs.api.exceptions import NeedsInvalidFilter
 from sphinx_needs.config import LinkOptionsType, NeedsSphinxConfig
 from sphinx_needs.data import NeedsInfoType, SphinxNeedsData
 from sphinx_needs.defaults import NEEDS_PROFILING
+from sphinx_needs.exceptions import NeedsInvalidFilter
 from sphinx_needs.logging import get_logger, log_warning
 from sphinx_needs.views import NeedsAndPartsListView, NeedsView
 


Test patch:
diff --git a/tests/test_circular_imports.py b/tests/test_circular_imports.py
new file mode 100644
index 00000000..f8b91547
--- /dev/null
+++ b/tests/test_circular_imports.py
@@ -0,0 +1,24 @@
+import pkgutil
+import subprocess
+import sys
+
+import sphinx_needs
+
+
+def test_no_circular_imports():
+    """
+    Test for circular imports in the sphinx_needs package.
+
+    Each package is tested isolated by importing it into a
+    new Python interpreter.
+    """
+    for _, name, _ in pkgutil.walk_packages(
+        sphinx_needs.__path__, prefix="sphinx_needs."
+    ):
+        subprocess.check_call(
+            [
+                sys.executable,
+                "-c",
+                "import importlib; importlib.import_module(" + repr(name) + ")",
+            ]
+        )