diff --git a/doc/changelog.d/7561.fixed.md b/doc/changelog.d/7561.fixed.md new file mode 100644 index 000000000000..b0cc2f4f0a44 --- /dev/null +++ b/doc/changelog.d/7561.fixed.md @@ -0,0 +1 @@ +Wait for active project after ImportEDB (#7560) diff --git a/src/ansys/aedt/core/application/design.py b/src/ansys/aedt/core/application/design.py index e5219fdb8b92..355d24682b17 100644 --- a/src/ansys/aedt/core/application/design.py +++ b/src/ansys/aedt/core/application/design.py @@ -77,6 +77,7 @@ from ansys.aedt.core.generic.numbers_utils import _units_assignment from ansys.aedt.core.generic.numbers_utils import decompose_variable_value from ansys.aedt.core.generic.settings import inner_project_settings +from ansys.aedt.core.generic.settings import settings from ansys.aedt.core.internal.aedt_versions import aedt_versions from ansys.aedt.core.internal.errors import AEDTRuntimeError from ansys.aedt.core.internal.errors import GrpcApiError @@ -1307,7 +1308,22 @@ def oproject(self, proj_name: str = None) -> None: oTool.ImportEDB(proj_name) else: oTool.ImportEDB(str(Path(proj_name) / "edb.def")) - self._oproject = self.desktop_class.active_project() + # ImportEDB is asynchronous — AEDT processes the import in its own + # event loop and does not return a project object. Poll + # active_project() until the project is ready. + _timeout = settings.edb_import_timeout + _message_interval = 15 # Issue a progress message every 15 seconds. + _start = time.time() + self._oproject = None + start_counter = _start + while self._oproject is None and (time.time() - _start) < _timeout: + time.sleep(1) + self._oproject = self.desktop_class.active_project() + if time.time() - start_counter > _message_interval: + self.logger.info("Importing EDB. Elapsed time: %.0f s.", time.time() - _start) + start_counter = time.time() + if self._oproject is None: + raise RuntimeError(f"Timed out waiting for AEDT to finish importing EDB: {proj_name}") self._oproject.Save() self._add_handler() self.logger.info( diff --git a/src/ansys/aedt/core/generic/settings.py b/src/ansys/aedt/core/generic/settings.py index 9a2959b9f62a..4eb9e801acf8 100644 --- a/src/ansys/aedt/core/generic/settings.py +++ b/src/ansys/aedt/core/generic/settings.py @@ -88,6 +88,7 @@ "objects_lazy_load", "aedt_version", "desktop_launch_timeout", + "edb_import_timeout", "disable_bounding_box_sat", "edb_dll_path", "enable_error_handler", @@ -218,6 +219,7 @@ def __init__(self) -> None: self.__enable_pandas_output = False self.__edb_dll_path: str | None = None self.__desktop_launch_timeout: int = 120 + self.__edb_import_timeout: int = 300 self.__number_of_grpc_api_retries: int = 6 self.__retry_n_times_time_interval: float = 0.1 self.__wait_for_license: bool = False @@ -752,6 +754,20 @@ def desktop_launch_timeout(self) -> int: def desktop_launch_timeout(self, value: int) -> None: self.__desktop_launch_timeout = int(value) + @property + def edb_import_timeout(self) -> int: + """Timeout in seconds for waiting for AEDT to finish an asynchronous EDB import. + + The default is ``300`` seconds (5 minutes). Increase this value for very large + EDB layouts. Can also be set via the ``general.edb_import_timeout`` key in the + PyAEDT YAML settings file. + """ + return self.__edb_import_timeout + + @edb_import_timeout.setter + def edb_import_timeout(self, value: int) -> None: + self.__edb_import_timeout = int(value) + @property def aedt_version(self) -> str | None: """AEDT version in the form ``"2023.x"``. diff --git a/tests/system/layout/test_3dlayout_edb.py b/tests/system/layout/test_3dlayout_edb.py index ea008a3faff3..07a0fbf29808 100644 --- a/tests/system/layout/test_3dlayout_edb.py +++ b/tests/system/layout/test_3dlayout_edb.py @@ -423,6 +423,35 @@ def test_change_options(aedt_app) -> None: assert not aedt_app.change_options(color_by_net=None) +def test_edb_import_oproject_is_valid(add_app_example) -> None: + """Verify that the ``oproject`` setter correctly waits for an asynchronous EDB import. + + Regression test for issue #7560 where ``active_project()`` was called + immediately after ``ImportEDB``, returning ``None`` and causing + ``AttributeError: 'NoneType' object has no attribute 'Save'``. + + This test opens a real ``.aedb`` layout through the normal + ``Hfss3dLayout(project=)`` constructor path (which exercises the + ``oproject`` setter) and asserts that: + + - ``_oproject`` is not ``None`` after construction. + - ``_oproject.Save()`` does not raise an exception. + - The project name is a non-empty string. + """ + app = add_app_example(project=ORIGINAL_PROJECT, application=Hfss3dLayout, subfolder=TEST_SUBFOLDER, is_edb=True) + try: + # _oproject must be set — not None — after a successful import + assert app._oproject is not None, "_oproject is None after EDB import (issue #7560)" + + # Save() must be callable on the resolved project object + app._oproject.Save() + + # The project name must be a valid non-empty string + assert app.project_name, "project_name is empty after EDB import" + finally: + app.close_project(app.project_name, save=False) + + def test_show_extent(aedt_app) -> None: assert aedt_app.show_extent() assert aedt_app.show_extent(show=False) diff --git a/tests/unit/test_design_oproject.py b/tests/unit/test_design_oproject.py new file mode 100644 index 000000000000..cc953eb03b45 --- /dev/null +++ b/tests/unit/test_design_oproject.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Unit tests for the Design.oproject setter — EDB import branch. + +These tests cover the fix for issue #7560 where ``active_project()`` was +called immediately after ``ImportEDB`` without waiting for AEDT to finish +the asynchronous import, causing ``AttributeError: 'NoneType' object has no +attribute 'Save'``. +""" + +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from ansys.aedt.core.application.design import Design + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_design(tmp_path): + """Return a minimally-configured Design stub for oproject setter testing. + + Parameters + ---------- + tmp_path : pathlib.Path + Pytest-provided temporary directory used to create real filesystem + paths so that ``Path(proj_name).exists()`` passes the guard inside + the setter. + + Returns + ------- + design : Design + Stubbed instance (``__init__`` is not called). + edb_def : pathlib.Path + Path to the ``edb.def`` file created inside a fake ``.aedb`` folder. + aedb_dir : pathlib.Path + Path to the fake ``.aedb`` directory. + """ + # Create a real edb.def file so Path(proj_name).exists() is True. + aedb_dir = tmp_path / "test_design.aedb" + aedb_dir.mkdir() + edb_def = aedb_dir / "edb.def" + edb_def.write_text("") + # Do NOT create test_design.aedt — this forces the ImportEDB branch. + + with patch("ansys.aedt.core.application.design.Design.__init__", lambda _: None): + design = Design() + + design._remove_lock = False + design._oproject = None + design._logger = MagicMock() + design._add_handler = MagicMock() + design.check_if_project_is_loaded = MagicMock(return_value=None) + return design, edb_def, aedb_dir + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_oproject_edb_import_succeeds_after_retry(tmp_path): + """Oproject setter retries active_project() until a valid project is returned. + + Simulates the real-world scenario where ``ImportEDB`` is asynchronous: + ``active_project()`` returns ``None`` on the first two calls (AEDT still + processing the import) and a valid project object on the third call. + The test asserts that: + + - ``ImportEDB`` is called with the ``.def`` file path. + - ``_oproject.Save()`` is eventually called once the project is ready. + - ``_oproject`` is set to the mock project (not ``None``). + """ + design, edb_def, _ = _make_design(tmp_path) + + mock_project = MagicMock() + mock_project.GetName.return_value = "test_project" + + mock_tool = MagicMock() + mock_desktop = MagicMock() + mock_desktop.project_list = [] # empty — forces the Path.exists() branch + mock_desktop.odesktop.GetTool.return_value = mock_tool + # Return None twice (AEDT still importing), then the real project + mock_desktop.active_project.side_effect = [None, None, mock_project] + design._desktop_class = mock_desktop + + with ( + patch("ansys.aedt.core.application.design.time") as mock_time, + patch("ansys.aedt.core.application.design.settings") as mock_settings, + ): + mock_settings.edb_import_timeout = 300 # use explicit value, not global default + # time.time() calls per iteration: + # _start = time.time() → call 1 (init) + # while condition: (time.time() - _start) → call 2, 4, 6 (each loop check) + # progress: time.time() - start_counter → call 3, 5, 7 (each body check) + # Loop exits after 3rd active_project() returns the mock project (oproject no longer None). + mock_time.time.side_effect = [0, 1, 2, 3, 3, 3, 3] + mock_time.sleep = MagicMock() + + design.oproject = str(edb_def) + + # ImportEDB must be called with the .def path + mock_tool.ImportEDB.assert_called_once_with(str(edb_def)) + + # active_project() must have been retried until the project was returned + assert mock_desktop.active_project.call_count == 3 + + # Save() must be called on the resolved project + mock_project.Save.assert_called_once() + + # The setter must persist the resolved project + assert design._oproject is mock_project + + +def test_oproject_edb_import_timeout(tmp_path): + """Oproject setter raises RuntimeError when AEDT does not return a project in time. + + Simulates a hung import where ``active_project()`` never returns a valid + project object within the configured timeout window. + """ + design, edb_def, _ = _make_design(tmp_path) + + mock_desktop = MagicMock() + mock_desktop.project_list = [] + mock_desktop.odesktop.GetTool.return_value = MagicMock() + mock_desktop.active_project.return_value = None # always None + design._desktop_class = mock_desktop + + with ( + patch("ansys.aedt.core.application.design.time") as mock_time, + patch("ansys.aedt.core.application.design.settings") as mock_settings, + ): + mock_settings.edb_import_timeout = 10 # short timeout for the test + # Simulate time jumping past the 10 s timeout after the first poll + mock_time.time.side_effect = [0, 0, 11, 11, 11] + mock_time.sleep = MagicMock() + + with pytest.raises(RuntimeError, match="Timed out waiting for AEDT to finish importing EDB"): + design.oproject = str(edb_def) + + +def test_oproject_aedb_folder_calls_importedb_with_def(tmp_path): + """Oproject setter passes ``edb.def`` to ImportEDB when given an ``.aedb`` folder path. + + When the caller supplies the ``.aedb`` directory path (rather than the + ``edb.def`` file directly), the setter must append ``edb.def`` before + calling ``ImportEDB``. + """ + design, _edb_def, aedb_dir = _make_design(tmp_path) + + mock_project = MagicMock() + mock_project.GetName.return_value = "test_project" + + mock_tool = MagicMock() + mock_desktop = MagicMock() + mock_desktop.project_list = [] + mock_desktop.odesktop.GetTool.return_value = mock_tool + mock_desktop.active_project.return_value = mock_project + design._desktop_class = mock_desktop + + with ( + patch("ansys.aedt.core.application.design.time") as mock_time, + patch("ansys.aedt.core.application.design.settings") as mock_settings, + ): + mock_settings.edb_import_timeout = 300 + mock_time.time.side_effect = [0, 1, 1, 1] + mock_time.sleep = MagicMock() + + design.oproject = str(aedb_dir) + + expected_def = str(aedb_dir / "edb.def") + mock_tool.ImportEDB.assert_called_once_with(expected_def) + mock_project.Save.assert_called_once() + assert design._oproject is mock_project