diff --git a/docs/api.rst b/docs/api.rst index e8aa1801f..74700063d 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -38,4 +38,4 @@ Data ---- .. automodule:: sphinx_needs.data - :members: NeedsInfoType, NeedsView + :members: NeedsInfoType, NeedsMutable, NeedsView diff --git a/docs/conf.py b/docs/conf.py index 2913a542b..527bcecda 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -756,7 +756,7 @@ def create_tutorial_needs(app: Sphinx, _env, _docnames): We do this dynamically, to avoid having to maintain the JSON file manually. """ - all_data = SphinxNeedsData(app.env).get_needs_view() + all_data = SphinxNeedsData(app.env).get_needs_mutable() writer = NeedsList(app.config, outdir=app.confdir, confdir=app.confdir) for i in range(1, 5): test_id = f"T_00{i}" diff --git a/sphinx_needs/api/__init__.py b/sphinx_needs/api/__init__.py index 6021aedec..1601cf1d6 100644 --- a/sphinx_needs/api/__init__.py +++ b/sphinx_needs/api/__init__.py @@ -4,7 +4,7 @@ add_need_type, get_need_types, ) -from .need import add_external_need, add_need, del_need, make_hashed_id +from .need import add_external_need, add_need, del_need, get_needs_view, make_hashed_id __all__ = ( "add_dynamic_function", @@ -14,5 +14,6 @@ "add_need_type", "del_need", "get_need_types", + "get_needs_view", "make_hashed_id", ) diff --git a/sphinx_needs/api/need.py b/sphinx_needs/api/need.py index b734c4cf5..3eaba290d 100644 --- a/sphinx_needs/api/need.py +++ b/sphinx_needs/api/need.py @@ -24,7 +24,7 @@ NeedsTemplateException, ) from sphinx_needs.config import NEEDS_CONFIG, GlobalOptionsType, NeedsSphinxConfig -from sphinx_needs.data import NeedsInfoType, SphinxNeedsData +from sphinx_needs.data import NeedsInfoType, NeedsView, SphinxNeedsData from sphinx_needs.directives.needuml import Needuml, NeedumlException from sphinx_needs.filter_common import filter_single_need from sphinx_needs.logging import get_logger, log_warning @@ -805,3 +805,15 @@ def _merge_global_options( # has at least the key. if key not in needs_info.keys(): needs_info[key] = "" + + +def get_needs_view(app: Sphinx) -> NeedsView: + """Return a read-only view of all resolved needs. + + .. important:: this should only be called within the write phase, + after the needs have been fully collected. + If not already done, this will ensure all needs are resolved + (e.g. back links have been computed etc), + and then lock the data to prevent further modification. + """ + return SphinxNeedsData(app.env).get_needs_view() diff --git a/sphinx_needs/builder.py b/sphinx_needs/builder.py index ad026c130..9acbd179b 100644 --- a/sphinx_needs/builder.py +++ b/sphinx_needs/builder.py @@ -9,8 +9,6 @@ from sphinx_needs.config import NeedsSphinxConfig from sphinx_needs.data import SphinxNeedsData -from sphinx_needs.directives.need import post_process_needs_data -from sphinx_needs.filter_common import filter_needs_view from sphinx_needs.logging import get_logger, log_warning from sphinx_needs.needsfile import NeedsList @@ -58,9 +56,10 @@ def write( return super().write(build_docnames, updated_docnames, method) def finish(self) -> None: - post_process_needs_data(self.app) + from sphinx_needs.filter_common import filter_needs_view data = SphinxNeedsData(self.env) + needs = data.get_needs_view() needs_config = NeedsSphinxConfig(self.env.config) filters = data.get_or_create_filters() version = getattr(self.env.config, "version", "unset") @@ -84,7 +83,7 @@ def finish(self) -> None: filter_string = needs_config.builder_filter filtered_needs = filter_needs_view( - data.get_needs_view(), + needs, needs_config, filter_string, append_warning="(from need_builder_filter)", @@ -173,7 +172,7 @@ def write( pass def finish(self) -> None: - post_process_needs_data(self.app) + from sphinx_needs.filter_common import filter_needs_view data = SphinxNeedsData(self.env) version = getattr(self.env.config, "version", "unset") diff --git a/sphinx_needs/data.py b/sphinx_needs/data.py index a61eb1430..c52f9cec2 100644 --- a/sphinx_needs/data.py +++ b/sphinx_needs/data.py @@ -730,6 +730,8 @@ def add_need(self, need: NeedsInfoType) -> None: .. important:: this should only be called within the read phase, before the needs have been fully collected and resolved. """ + if self.needs_is_post_processed: + raise RuntimeError("Needs have already been post-processed.") self._env_needs[need["id"]] = need def remove_need(self, need_id: str) -> None: @@ -738,6 +740,8 @@ def remove_need(self, need_id: str) -> None: .. important:: this should only be called within the read phase, before the needs have been fully collected and resolved. """ + if self.needs_is_post_processed: + raise RuntimeError("Needs have already been post-processed.") if need_id in self._env_needs: del self._env_needs[need_id] self.remove_need_node(need_id) @@ -748,6 +752,8 @@ def remove_doc(self, docname: str) -> None: .. important:: this should only be called within the read phase, before the needs have been fully collected and resolved. """ + if self.needs_is_post_processed: + raise RuntimeError("Needs have already been post-processed.") for need_id in list(self._env_needs): if self._env_needs[need_id]["docname"] == docname: del self._env_needs[need_id] @@ -762,15 +768,23 @@ def get_needs_mutable(self) -> NeedsMutable: .. important:: this should only be called within the read phase, before the needs have been fully collected and resolved. """ + if self.needs_is_post_processed: + raise RuntimeError("Needs have already been post-processed.") return self._env_needs # type: ignore[return-value] def get_needs_view(self) -> NeedsView: - """Return a read-only view of all needs, after resolution. + """Return a read-only view of all resolved needs. .. important:: this should only be called within the write phase, - after the needs have been fully collected - and resolved (e.g. back links have been computed etc) + after the needs have been fully collected. + If not already done, this will ensure all needs are resolved + (e.g. back links have been computed etc), + and then lock the data to prevent further modification. """ + if not self.needs_is_post_processed: + from sphinx_needs.directives.need import post_process_needs_data + + post_process_needs_data(self.env.app) return self._env_needs # type: ignore[return-value] @property diff --git a/sphinx_needs/directives/need.py b/sphinx_needs/directives/need.py index 6ad096a57..2b1c587e7 100644 --- a/sphinx_needs/directives/need.py +++ b/sphinx_needs/directives/need.py @@ -373,10 +373,10 @@ def post_process_needs_data(app: Sphinx) -> None: After this function has been run, one should assume that the needs data is finalised, and so in principle should be treated as read-only. """ - needs_config = NeedsSphinxConfig(app.config) needs_data = SphinxNeedsData(app.env) - needs = needs_data.get_needs_mutable() - if needs and not needs_data.needs_is_post_processed: + if not needs_data.needs_is_post_processed: + needs_config = NeedsSphinxConfig(app.config) + needs = needs_data.get_needs_mutable() extend_needs_data(needs, needs_data.get_or_create_extends(), needs_config) resolve_dynamic_values(needs, app) resolve_variants_options(needs, needs_config, app.builder.tags) @@ -404,8 +404,6 @@ def process_need_nodes(app: Sphinx, doctree: nodes.document, fromdocname: str) - if not needs_data.get_needs_view(): return - post_process_needs_data(app) - for extend_node in list(doctree.findall(Needextend)): remove_node_from_tree(extend_node) diff --git a/sphinx_needs/external_needs.py b/sphinx_needs/external_needs.py index 5182adaa1..7a596ac1a 100644 --- a/sphinx_needs/external_needs.py +++ b/sphinx_needs/external_needs.py @@ -167,7 +167,7 @@ def load_external_needs(app: Sphinx, env: BuildEnvironment, docname: str) -> Non # check if external needs already exist ext_need_id = need_params["id"] - need = SphinxNeedsData(env).get_needs_view().get(ext_need_id) + need = SphinxNeedsData(env).get_needs_mutable().get(ext_need_id) if need is not None: # check need_params for more detail diff --git a/sphinx_needs/functions/common.py b/sphinx_needs/functions/common.py index 105d21565..b2d48ba2b 100644 --- a/sphinx_needs/functions/common.py +++ b/sphinx_needs/functions/common.py @@ -14,9 +14,9 @@ from sphinx_needs.api.exceptions import NeedsInvalidFilter from sphinx_needs.config import NeedsSphinxConfig -from sphinx_needs.data import NeedsInfoType, NeedsView +from sphinx_needs.data import NeedsInfoType, NeedsMutable, NeedsView from sphinx_needs.filter_common import ( - filter_needs_view, + filter_needs, filter_single_need, ) from sphinx_needs.logging import log_warning @@ -26,7 +26,7 @@ def test( app: Sphinx, need: NeedsInfoType | None, - needs: NeedsView, + needs: NeedsMutable | NeedsView, *args: Any, **kwargs: Any, ) -> str: @@ -54,7 +54,7 @@ def test( def echo( app: Sphinx, need: NeedsInfoType | None, - needs: NeedsView, + needs: NeedsMutable | NeedsView, text: str, *args: Any, **kwargs: Any, @@ -78,7 +78,7 @@ def echo( def copy( app: Sphinx, need: NeedsInfoType | None, - needs: NeedsView, + needs: NeedsMutable | NeedsView, option: str, need_id: str | None = None, lower: bool = False, @@ -170,14 +170,15 @@ def copy( need = needs[need_id] if filter: - result = filter_needs_view( - needs, + location = ( + (need["docname"], need["lineno"]) if need and need["docname"] else None + ) + result = filter_needs( + needs.values(), NeedsSphinxConfig(app.config), filter, need, - location=(need["docname"], need["lineno"]) - if need and need["docname"] - else None, + location=location, ) if result: need = result[0] @@ -201,7 +202,7 @@ def copy( def check_linked_values( app: Sphinx, need: NeedsInfoType | None, - needs: NeedsView, + needs: NeedsMutable | NeedsView, result: Any, search_option: str, search_value: Any, @@ -373,7 +374,7 @@ def check_linked_values( def calc_sum( app: Sphinx, need: NeedsInfoType | None, - needs: NeedsView, + needs: NeedsMutable | NeedsView, option: str, filter: str | None = None, links_only: bool = False, @@ -488,7 +489,7 @@ def calc_sum( def links_from_content( app: Sphinx, need: NeedsInfoType | None, - needs: NeedsView, + needs: NeedsMutable | NeedsView, need_id: str | None = None, filter: str | None = None, ) -> list[str]: diff --git a/sphinx_needs/functions/functions.py b/sphinx_needs/functions/functions.py index a849e87cb..c15708c37 100644 --- a/sphinx_needs/functions/functions.py +++ b/sphinx_needs/functions/functions.py @@ -40,7 +40,7 @@ def __call__( self, app: Sphinx, need: NeedsInfoType | None, - needs: NeedsView, + needs: NeedsView | NeedsMutable, *args: Any, **kwargs: Any, ) -> str | int | float | list[str] | list[int] | list[float] | None: ... @@ -78,6 +78,7 @@ def register_func(need_function: DynamicFunction, name: str | None = None) -> No def execute_func( app: Sphinx, need: NeedsInfoType | None, + needs: NeedsView | NeedsMutable, func_string: str, location: str | tuple[str | None, int | None] | nodes.Node | None, ) -> str | int | float | list[str] | list[int] | list[float] | None: @@ -118,7 +119,7 @@ def execute_func( func_return = func( app, need, - SphinxNeedsData(app.env).get_needs_view(), + needs, *func_args, **func_kwargs, ) @@ -201,7 +202,9 @@ def find_and_replace_node_content( msg = f"The [[{func_string}]] syntax in need content is deprecated. Replace with :ndf:`{func_string}` instead." log_warning(logger, msg, "deprecation", location=node) - func_return = execute_func(env.app, need, func_string, node) + func_return = execute_func( + env.app, need, SphinxNeedsData(env).get_needs_view(), func_string, node + ) if isinstance(func_return, list): func_return = ", ".join(str(el) for el in func_return) @@ -266,7 +269,7 @@ def resolve_dynamic_values(needs: NeedsMutable, app: Sphinx) -> None: while func_call: try: func_call, func_return = _detect_and_execute_field( - need[need_option], need, app + need[need_option], need, needs, app ) except FunctionParsingException: raise SphinxError( @@ -298,7 +301,7 @@ def resolve_dynamic_values(needs: NeedsMutable, app: Sphinx) -> None: for element in need[need_option]: try: func_call, func_return = _detect_and_execute_field( - element, need, app + element, need, needs, app ) except FunctionParsingException: raise SphinxError( @@ -401,7 +404,7 @@ def check_and_get_content( func_call = func_match.group(1) # Extract function call func_return = execute_func( - env.app, need, func_call, location + env.app, need, SphinxNeedsData(env).get_needs_view(), func_call, location ) # Execute function call and get return value if isinstance(func_return, list): @@ -415,13 +418,10 @@ def check_and_get_content( def _detect_and_execute_field( - content: Any, need: NeedsInfoType, app: Sphinx + content: Any, need: NeedsInfoType, needs: NeedsMutable, app: Sphinx ) -> tuple[str | None, str | int | float | list[str] | list[int] | list[float] | None]: """Detects if given need field value is a function call and executes it.""" - try: - content = str(content) - except UnicodeEncodeError: - content = content.encode("utf-8") + content = str(content) func_match = FUNC_RE.search(content) if func_match is None: @@ -431,6 +431,7 @@ def _detect_and_execute_field( func_return = execute_func( app, need, + needs, func_call, (need["docname"], need["lineno"]) if need["docname"] else None, ) # Execute function call and get return value diff --git a/sphinx_needs/needs.py b/sphinx_needs/needs.py index 44051b063..5ce24aa32 100644 --- a/sphinx_needs/needs.py +++ b/sphinx_needs/needs.py @@ -503,7 +503,6 @@ def prepare_env(app: Sphinx, env: BuildEnvironment, _docname: str) -> None: """ needs_config = NeedsSphinxConfig(app.config) data = SphinxNeedsData(env) - data.get_needs_view() data.get_or_create_filters() data.get_or_create_docs() services = data.get_or_create_services() diff --git a/sphinx_needs/roles/need_func.py b/sphinx_needs/roles/need_func.py index 309f883d3..d5d09ae13 100644 --- a/sphinx_needs/roles/need_func.py +++ b/sphinx_needs/roles/need_func.py @@ -9,7 +9,7 @@ from sphinx.environment import BuildEnvironment from sphinx.util.docutils import SphinxRole -from sphinx_needs.data import NeedsInfoType +from sphinx_needs.data import NeedsInfoType, SphinxNeedsData from sphinx_needs.logging import get_logger, log_warning from sphinx_needs.utils import add_doc @@ -60,7 +60,13 @@ def get_text(self, env: BuildEnvironment, need: NeedsInfoType | None) -> nodes.T from sphinx_needs.functions.functions import check_and_get_content, execute_func if not self.with_brackets: - func_return = execute_func(env.app, need, self.astext(), self) + func_return = execute_func( + env.app, + need, + SphinxNeedsData(env).get_needs_view(), + self.astext(), + self, + ) if isinstance(func_return, list): func_return = ", ".join(str(el) for el in func_return)