Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changelog.d/7561.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Wait for active project after ImportEDB (#7560)
18 changes: 17 additions & 1 deletion src/ansys/aedt/core/application/design.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
16 changes: 16 additions & 0 deletions src/ansys/aedt/core/generic/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"``.
Expand Down
29 changes: 29 additions & 0 deletions tests/system/layout/test_3dlayout_edb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=<aedb_path>)`` 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)
Expand Down
199 changes: 199 additions & 0 deletions tests/unit/test_design_oproject.py
Original file line number Diff line number Diff line change
@@ -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
Loading