diff --git a/doc/changelog.d/6914.added.md b/doc/changelog.d/6914.added.md new file mode 100644 index 00000000000..daa6f4e560b --- /dev/null +++ b/doc/changelog.d/6914.added.md @@ -0,0 +1 @@ +Record console setups into a python file diff --git a/src/ansys/aedt/core/__init__.py b/src/ansys/aedt/core/__init__.py index f2538baa602..7077e408b34 100644 --- a/src/ansys/aedt/core/__init__.py +++ b/src/ansys/aedt/core/__init__.py @@ -105,9 +105,9 @@ def custom_show_warning(message, category, filename, lineno, file=None, line=Non from ansys.aedt.core.generic.general_methods import inside_desktop_ironpython_console from ansys.aedt.core.generic.general_methods import is_linux from ansys.aedt.core.generic.general_methods import is_windows -from ansys.aedt.core.generic.general_methods import online_help from ansys.aedt.core.generic.general_methods import pyaedt_function_handler from ansys.aedt.core.generic.numbers_utils import Quantity +from ansys.aedt.core.help import online_help from ansys.aedt.core.hfss import Hfss from ansys.aedt.core.hfss3dlayout import Hfss3dLayout from ansys.aedt.core.icepak import Icepak diff --git a/src/ansys/aedt/core/extensions/installer/console_setup.py b/src/ansys/aedt/core/extensions/installer/console_setup.py index f33b3341bc1..1c481b7ece8 100644 --- a/src/ansys/aedt/core/extensions/installer/console_setup.py +++ b/src/ansys/aedt/core/extensions/installer/console_setup.py @@ -34,39 +34,48 @@ """ import atexit -import os +from pathlib import Path import sys +from IPython import get_ipython +import tempfile aedt_process_id = int(sys.argv[1]) version = sys.argv[2] print("Loading the PyAEDT Console.") -try: +try: # pragma: no cover if version <= "2023.1": from pyaedt import Desktop from pyaedt.generic.general_methods import active_sessions from pyaedt.generic.general_methods import is_windows else: + from ansys.aedt.core import * + import ansys.aedt.core # noqa: F401 from ansys.aedt.core import Desktop from ansys.aedt.core.generic.general_methods import active_sessions from ansys.aedt.core.generic.general_methods import is_windows -except ImportError: + from ansys.aedt.core.generic.file_utils import available_file_name + +except ImportError: # pragma: no cover # Debug only purpose. If the tool is added to the ribbon from a GitHub clone, then a link # to PyAEDT is created in the personal library. - console_setup_dir = os.path.dirname(__file__) - if "PersonalLib" in console_setup_dir: - sys.path.append(os.path.join(console_setup_dir, "../..", "..", "..")) + console_setup_dir = Path(__file__).resolve().parent + if "PersonalLib" in console_setup_dir.parts: + sys.path.append(str(console_setup_dir / ".." / ".." / "..")) if version <= "2023.1": from pyaedt import Desktop from pyaedt.generic.general_methods import active_sessions from pyaedt.generic.general_methods import is_windows else: + from ansys.aedt.core import * # noqa: F401 + import ansys.aedt.core # noqa: F401 from ansys.aedt.core import Desktop from ansys.aedt.core.generic.general_methods import active_sessions from ansys.aedt.core.generic.general_methods import is_windows + from ansys.aedt.core.generic.file_utils import available_file_name -def release(d): +def release(d): # pragma: no cover d.logger.info("Exiting the PyAEDT Console.") d.release_desktop(False, False) @@ -78,11 +87,11 @@ def release(d): sessions = active_sessions(version=version, student_version=False) -if aedt_process_id in sessions: +if aedt_process_id in sessions: # pragma: no cover session_found = True if sessions[aedt_process_id] != -1: port = sessions[aedt_process_id] -if not session_found: +if not session_found: # pragma: no cover sessions = active_sessions(version=version, student_version=True) if aedt_process_id in sessions: session_found = True @@ -91,7 +100,7 @@ def release(d): port = sessions[aedt_process_id] error = False -if port: +if port: # pragma: no cover desktop = Desktop( version=version, port=port, @@ -100,7 +109,7 @@ def release(d): close_on_exit=False, student_version=student_version, ) -elif is_windows: +elif is_windows: # pragma: no cover desktop = Desktop( version=version, aedt_process_id=aedt_process_id, @@ -109,26 +118,25 @@ def release(d): close_on_exit=False, student_version=student_version, ) -else: +else: # pragma: no cover print("Error. AEDT should be started in gRPC mode in Linux to connect to PyAEDT") print("use ansysedt -grpcsrv portnumber command.") error = True -if not error: # pragma: no cover + +if not error: # pragma: no cover print(" ") print("\033[92m****************************************************************") print(f"* ElectronicsDesktop {version} Process ID {aedt_process_id}") print(f"* CPython {sys.version.split(' ')[0]}") print("*---------------------------------------------------------------") - print("* Example: \033[94m hfss = ansys.aedt.core.Hfss() \033[92m") - print("* Example: \033[94m m2d = ansys.aedt.core.Maxwell2d() \033[92m") + print("* Example: \033[94m hfss = Hfss() \033[92m") + print("* Example: \033[94m m2d = Maxwell2d() \033[92m") + print("* Desktop object is initialized: \033[94mdesktop.logger.info('Hello world')\033[92m") print("* \033[31mType exit() to close the console and release the desktop. \033[92m ") - print("* desktop object is initialized and available. Example: ") - print("* \033[94mdesktop.logger.info('Hello world')\033[92m") print("****************************************************************\033[0m") print(" ") - print(" ") - print(" ") + if is_windows: try: import win32api @@ -156,3 +164,44 @@ def signal_handler(sig, frame): except ImportError: pass atexit.register(release, desktop) + +if version > "2023.1": # pragma: no cover + + log_file = Path(tempfile.gettempdir()) / "pyaedt_script.py" + log_file = available_file_name(log_file) + + with open(log_file, 'a', encoding='utf-8') as f: + f.write("# PyAEDT script recorded from PyAEDT Console:\n\n") + f.write("import ansys.aedt.core\n") + f.write("from ansys.aedt.core import *\n") + + def log_successful_command(result): + """ + IPython Hook: Executes after every command (cell). + Logs the input command only if 'result.error_in_exec' is False (no exception). + """ + # Check for execution error + if not result.error_in_exec: + command = result.info.raw_cell.strip() + + # Avoid logging empty lines, comments, or the hook code itself + if command and not command.startswith('#') and "log_successful_command" not in command: + try: + # Append the successful command to the log file + with open(log_file, 'a', encoding='utf-8') as f: + f.write(command + "\n") + except Exception as e: + # Handle potential file writing errors + print(f"ERROR: Failed to write to log file: {e}") + + + # Register the Hook + ip = get_ipython() + if ip: + # Register the function to run after every command execution + ip.events.register('post_run_cell', log_successful_command) + # Inform the user that logging is active + print(f"Successful commands will be saved to: \033[94m'{log_file}'\033[92m") + print(" ") + print(" ") + print(" ") diff --git a/src/ansys/aedt/core/generic/general_methods.py b/src/ansys/aedt/core/generic/general_methods.py index fdd67c81ac6..22a6d4af32a 100644 --- a/src/ansys/aedt/core/generic/general_methods.py +++ b/src/ansys/aedt/core/generic/general_methods.py @@ -1103,103 +1103,3 @@ def install_with_pip(package_name, package_path=None, upgrade=False, uninstall=F subprocess.run(command, check=True) # nosec except subprocess.CalledProcessError as e: # nosec raise AEDTRuntimeError("An error occurred while installing with pip") from e - - -class Help(PyAedtBase): # pragma: no cover - def __init__(self): - self._base_path = "https://aedt.docs.pyansys.com/version/stable" - self.browser = "default" - - def _launch_ur(self, url): - import webbrowser - - if self.browser != "default": - webbrowser.get(self.browser).open_new_tab(url) - else: - webbrowser.open_new_tab(url) - - def search(self, keywords, app_name=None, search_in_examples_only=False): - """Search for one or more keywords. - - Parameters - ---------- - keywords : str or list - app_name : str, optional - Name of a PyAEDT app. For example, ``"Hfss"``, ``"Circuit"``, ``"Icepak"``, or any other available app. - search_in_examples_only : bool, optional - Whether to search for the one or more keywords only in the PyAEDT examples. - The default is ``False``. - """ - if isinstance(keywords, str): - keywords = [keywords] - if search_in_examples_only: - keywords.append("This example") - if app_name: - keywords.append(app_name) - url = self._base_path + f"/search.html?q={'+'.join(keywords)}" - self._launch_ur(url) - - def getting_started(self): - """Open the PyAEDT User guide page.""" - url = self._base_path + "/User_guide/index.html" - self._launch_ur(url) - - def examples(self): - """Open the PyAEDT Examples page.""" - url = self._base_path + "/examples/index.html" - self._launch_ur(url) - - def github(self): - """Open the PyAEDT GitHub page.""" - url = "https://github.com/ansys/pyaedt" - self._launch_ur(url) - - def changelog(self, release=None): - """Open the PyAEDT GitHub Changelog for a given release. - - Parameters - ---------- - release : str, optional - Release to get the changelog for. For example, ``"0.6.70"``. - """ - if release is None: - from ansys.aedt.core import __version__ as release - url = "https://github.com/ansys/pyaedt/releases/tag/v" + release - self._launch_ur(url) - - def issues(self): - """Open the PyAEDT GitHub Issues page.""" - url = "https://github.com/ansys/pyaedt/issues" - self._launch_ur(url) - - def ansys_forum(self): - """Open the PyAEDT GitHub Issues page.""" - url = "https://discuss.ansys.com/discussions/tagged/pyaedt" - self._launch_ur(url) - - def developer_forum(self): - """Open the Discussions page on the Ansys Developer site.""" - url = "https://developer.ansys.com/" - self._launch_ur(url) - - -# class Property(property): -# -# @pyaedt_function_handler() -# def getter(self, fget): -# """Property getter.""" -# return self.__class__.__base__(fget, self.fset, self.fdel, self.__doc__) -# -# @pyaedt_function_handler() -# def setter(self, fset): -# """Property setter.""" -# return self.__class__.__base__(self.fget, fset, self.fdel, self.__doc__) -# -# @pyaedt_function_handler() -# def deleter(self, fdel): -# """Property deleter.""" -# return self.__class__.__base__(self.fget, self.fset, fdel, self.__doc__) - -# property = Property - -online_help = Help() diff --git a/src/ansys/aedt/core/help.py b/src/ansys/aedt/core/help.py new file mode 100644 index 00000000000..093703dbe2e --- /dev/null +++ b/src/ansys/aedt/core/help.py @@ -0,0 +1,387 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 - 2025 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. + +from typing import Iterable +from typing import List +from typing import Optional +from typing import Union +from urllib.parse import quote_plus +import webbrowser + +from ansys.aedt.core.generic.general_methods import PyAedtBase + + +class Help(PyAedtBase): # pragma: no cover + """Utility class to open PyAEDT documentation and related resources. + + This class provides convenience methods to open documentation, examples, + GitHub pages, and community resources for PyAEDT. + + Features + -------- + - Browser control. + - Silent mode: return URLs without launching a browser. + - Advanced search helper. + - Helper methods for frequently accessed documentation pages. + """ + + _DOCS_ROOT = "https://aedt.docs.pyansys.com" + _DEFAULT_VERSION = "stable" + _EXAMPLES_ROOT = "https://examples.aedt.docs.pyansys.com" + _GITHUB_ROOT = "https://github.com/ansys/pyaedt" + + def __init__( + self, + version: Optional[str] = None, + browser: str = "default", + silent: bool = False, + ) -> None: + """Initialize the Help utility. + + Parameters + ---------- + version : str, optional + Documentation version to use. The default is ``"stable"``. + browser : str, optional + Browser name recognized by :mod:`webbrowser`. Use ``"default"`` + to rely on the system default browser. The browser name is + validated using :func:`webbrowser.get`. + silent : bool, optional + If ``True``, no browser windows are opened. All public methods + only return URLs. The default is ``False``. + """ + self._version = version or self._DEFAULT_VERSION + self._browser = "default" # will be validated by setter below + self.browser = browser + + self._silent = bool(silent) + + @property + def version(self) -> str: + """Documentation version currently configured.""" + return self._version + + @version.setter + def version(self, value: str) -> None: + """Set the documentation version.""" + if not value: + raise ValueError("Version cannot be an empty string.") + self._version = value + + @property + def base_path(self) -> str: + """Base URL of the PyAEDT documentation for the selected version.""" + return f"{self._DOCS_ROOT}/version/{self._version}" + + @property + def examples_base(self) -> str: + """Base URL of the PyAEDT examples site.""" + return self._EXAMPLES_ROOT + + @property + def browser(self) -> str: + """Browser currently configured for URL launching.""" + return self._browser + + @browser.setter + def browser(self, value: str) -> None: + """Set the browser used to open URLs. + + Parameters + ---------- + value : str + Browser name recognized by :mod:`webbrowser`, or ``"default"``. + + Raises + ------ + ValueError + If a non-default browser name is provided and + :func:`webbrowser.get` cannot resolve it. + """ + if value != "default": + try: + webbrowser.get(value) + except webbrowser.Error as exc: # type: ignore[attr-defined] + raise ValueError(f"Invalid browser: {value!r}") from exc + self._browser = value + + @property + def silent(self) -> bool: + """Whether URL opening is suppressed. + + If ``True``, URL-opening methods do not open the browser and only + return the constructed URLs. + """ + return self._silent + + @silent.setter + def silent(self, value: bool) -> None: + """Enable or disable silent mode.""" + self._silent = bool(value) + + def _launch_url( + self, + url: str, + new_tab: bool = True, + ) -> None: + """Open a URL in the configured browser unless silent mode is enabled. + + Parameters + ---------- + url : str + URL to open. + new_tab : bool, optional + Whether to open the URL in a new browser tab. The default is + ``True``. + """ + if self.browser != "default": + web_controller = webbrowser.get(self.browser) + else: + web_controller = webbrowser + + if new_tab: + web_controller.open_new_tab(url) + else: + web_controller.open(url) + + @staticmethod + def _build_search_query( + keywords: Union[str, Iterable[str]], + ) -> str: + """Build a Sphinx-style search query string. + + Parameters + ---------- + keywords : str or iterable of str + One or more search terms. + + Returns + ------- + str + Search query. + """ + if isinstance(keywords, str): + keywords_list: List[str] = [keywords] + else: + keywords_list = list(keywords) + + keywords_list = [k.strip() for k in keywords_list if k and k.strip()] + if not keywords_list: + raise ValueError("At least one keyword is required for search.") + + query = " ".join(keywords_list) + + return quote_plus(query) + + def search( + self, + keywords: Union[str, Iterable[str]], + ) -> str: + """Search the PyAEDT documentation. + + Parameters + ---------- + keywords : str or iterable of str + One or more search terms. + + Returns + ------- + str + The constructed search URL. + """ + query = self._build_search_query(keywords=keywords) + url = f"{self.base_path}/search.html?q={query}" + if not self.silent: + self._launch_url(url) + return url + + def home(self) -> str: + """Open the top-level documentation page for the selected version. + + Returns + ------- + str + URL of the page. + """ + url = f"{self.base_path}/index.html" + if not self.silent: + self._launch_url(url) + return url + + def user_guide(self) -> str: + """Open the PyAEDT User Guide. + + Returns + ------- + str + URL of the User Guide. + """ + url = f"{self.base_path}/User_guide/index.html" + if not self.silent: + self._launch_url(url) + return url + + def getting_started(self) -> str: + """Open the PyAEDT Getting Started guide. + + Returns + ------- + str + URL of the Getting Started guide. + """ + url = f"{self.base_path}/Getting_started/index.html" + if not self.silent: + self._launch_url(url) + return url + + def installation_guide(self) -> str: + """Open the PyAEDT installation instructions. + + Returns + ------- + str + URL of the Installation Guide. + """ + url = f"{self.base_path}/Getting_started/Installation.html" + if not self.silent: + self._launch_url(url) + return url + + def api_reference(self) -> str: + """Open the PyAEDT API Reference page. + + Returns + ------- + str + URL of the API Reference. + """ + url = f"{self.base_path}/API/index.html" + if not self.silent: + self._launch_url(url) + return url + + def release_notes(self) -> str: + """Open the PyAEDT release notes page. + + Returns + ------- + str + URL of the release notes page. + """ + url = f"{self.base_path}/changelog.html" + if not self.silent: + self._launch_url(url) + return url + + def examples(self) -> str: + """Open the official PyAEDT examples website. + + Returns + ------- + str + URL of the examples site. + """ + url = self.examples_base + if not self.silent: + self._launch_url(url) + return url + + def github(self) -> str: + """Open the PyAEDT GitHub repository. + + Returns + ------- + str + URL of the GitHub repository. + """ + url = self._GITHUB_ROOT + if not self.silent: + self._launch_url(url) + return url + + def changelog( + self, + release: Optional[str] = None, + ) -> str: + """Open the GitHub changelog page for a specific release. + + Parameters + ---------- + release : str, optional + Version tag. If omitted, the currently installed PyAEDT version is used. + + Returns + ------- + str + URL of the release notes page for the specified version. + """ + if release is None: + from ansys.aedt.core import __version__ as release + + url = f"{self._GITHUB_ROOT}/releases/tag/v{release}" + if not self.silent: + self._launch_url(url) + return url + + def issues(self) -> str: + """Open the PyAEDT GitHub issues page. + + Returns + ------- + str + URL of the issues page. + """ + url = f"{self._GITHUB_ROOT}/issues" + if not self.silent: + self._launch_url(url) + return url + + def ansys_forum(self) -> str: + """Open the Ansys forum filtered to the PyAEDT tag. + + Returns + ------- + str + URL of the forum page. + """ + url = "https://discuss.ansys.com/discussions/tagged/pyaedt" + if not self.silent: + self._launch_url(url) + return url + + def developer_forum(self) -> str: + """Open the Ansys Developer portal. + + Returns + ------- + str + URL of the developer portal. + """ + url = "https://developer.ansys.com/" + if not self.silent: + self._launch_url(url) + return url + + +online_help = Help() diff --git a/tests/unit/test_help.py b/tests/unit/test_help.py new file mode 100644 index 00000000000..8b861aae59b --- /dev/null +++ b/tests/unit/test_help.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 - 2025 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. + +"""Tests for the Help utility.""" + +from urllib.parse import parse_qs +from urllib.parse import urlparse +import webbrowser + +import pytest + +from ansys.aedt.core.help import Help + + +def test_base_paths(): + helper = Help(version="stable", silent=True) + + assert helper.base_path.endswith("/version/stable") + assert helper.examples_base == "https://examples.aedt.docs.pyansys.com" + + +def test_version_setter_validation(): + helper = Help(version="stable", silent=True) + + with pytest.raises(ValueError): + helper.version = "" + + helper.version = "dev" + assert helper.version == "dev" + assert helper.base_path.endswith("/version/dev") + + +def test_search_builds_url_and_respects_silent(monkeypatch): + """Search should return URL and not open browser in silent mode.""" + + def _fail_open(*args, **kwargs): + raise AssertionError("Browser should not be opened in silent mode.") + + monkeypatch.setattr(webbrowser, "open_new_tab", _fail_open) + monkeypatch.setattr(webbrowser, "open", _fail_open) + + helper = Help(version="stable", silent=True) + + url = helper.search("mesh") + + parsed = urlparse(url) + assert parsed.path.endswith("/search.html") + + qs = parse_qs(parsed.query) + assert "q" in qs + assert qs["q"][0] == "mesh" + + +def test_search_raises_on_empty_keywords(): + helper = Help(version="stable", silent=True) + + with pytest.raises(ValueError): + helper.search("") + + with pytest.raises(ValueError): + helper.search([]) + + +def test_search_multiple_keywords_encoding(): + helper = Help(version="stable", silent=True) + + url = helper.search(["mesh", "heat"]) + q_value = parse_qs(urlparse(url).query)["q"][0] + + assert "mesh" in q_value + assert "heat" in q_value + + +@pytest.mark.parametrize( + ("method_name", "expected_suffix"), + [ + ("home", "/index.html"), + ("user_guide", "/User_guide/index.html"), + ("getting_started", "/Getting_started/index.html"), + ("installation_guide", "/Getting_started/Installation.html"), + ("api_reference", "/API/index.html"), + ("release_notes", "/changelog.html"), + ], +) +def test_helper_urls_under_base_path(method_name, expected_suffix, monkeypatch): + """Helper methods should construct URLs under the base path and honor silent mode.""" + + def _fail_open(*args, **kwargs): + raise AssertionError("Browser should not be opened in silent mode.") + + monkeypatch.setattr(webbrowser, "open_new_tab", _fail_open) + monkeypatch.setattr(webbrowser, "open", _fail_open) + + helper = Help(version="stable", silent=True) + method = getattr(helper, method_name) + + url = method() + assert url.startswith(helper.base_path) + assert url.endswith(expected_suffix) + + +def test_examples_github_issues_forums_dev_urls(monkeypatch): + """URLs that are not version-dependent should be fixed.""" + + def _fail_open(*args, **kwargs): + raise AssertionError("Browser should not be opened in silent mode.") + + monkeypatch.setattr(webbrowser, "open_new_tab", _fail_open) + monkeypatch.setattr(webbrowser, "open", _fail_open) + + helper = Help(silent=True) + + assert helper.examples() == "https://examples.aedt.docs.pyansys.com" + assert helper.github() == "https://github.com/ansys/pyaedt" + assert helper.issues() == "https://github.com/ansys/pyaedt/issues" + assert helper.ansys_forum() == "https://discuss.ansys.com/discussions/tagged/pyaedt" + assert helper.developer_forum() == "https://developer.ansys.com/" + + +def test_changelog_with_explicit_release(monkeypatch): + """changelog(release=...) should build the correct GitHub URL.""" + + def _fail_open(*args, **kwargs): + raise AssertionError("Browser should not be opened in silent mode.") + + monkeypatch.setattr(webbrowser, "open_new_tab", _fail_open) + monkeypatch.setattr(webbrowser, "open", _fail_open) + + helper = Help(silent=True) + + url = helper.changelog(release="0.7.0") + assert url == "https://github.com/ansys/pyaedt/releases/tag/v0.7.0" + + +def test_changelog_default_release_uses_installed_version(monkeypatch): + """When release is None, changelog should use ansys.aedt.core.__version__.""" + + def _fail_open(*args, **kwargs): + raise AssertionError("Browser should not be opened in silent mode.") + + monkeypatch.setattr(webbrowser, "open_new_tab", _fail_open) + monkeypatch.setattr(webbrowser, "open", _fail_open) + + monkeypatch.setattr("ansys.aedt.core.__version__", "1.2.3", raising=False) + + helper = Help(silent=True) + url = helper.changelog() + assert url == "https://github.com/ansys/pyaedt/releases/tag/v1.2.3" + + +def test_browser_validation(monkeypatch): + """Browser setter should validate custom browsers via webbrowser.get.""" + + class DummyController: + def open_new_tab(self, *args, **kwargs): + return True + + def open(self, *args, **kwargs): + return True + + def fake_get(name): + if name == "dummy": + return DummyController() + raise webbrowser.Error("No such browser.") + + monkeypatch.setattr(webbrowser, "get", fake_get) + + helper = Help(browser="dummy", silent=True) + assert helper.browser == "dummy" + + with pytest.raises(ValueError): + helper.browser = "this-browser-does-not-exist" + + +def test_non_silent_methods_open_browser(monkeypatch): + """When silent is False, helper methods should call webbrowser.open_new_tab.""" + opened_urls = [] + + class DummyController: + def open_new_tab(self, url, *args, **kwargs): + opened_urls.append(url) + return True + + def open(self, url, *args, **kwargs): + opened_urls.append(url) + return True + + def fake_get(name="default"): + return DummyController() + + monkeypatch.setattr(webbrowser, "get", fake_get) + + helper = Help(version="stable", browser="default", silent=False) + + url = helper.home() + assert url in opened_urls + assert url.endswith("/index.html")