diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index df3af794a..94de2563d 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -87,6 +87,11 @@ jobs: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] fail-fast: false steps: + - name: "Configure Python.NET runtime for Linux" + if: runner.os == 'Linux' + run: echo "PYTHONNET_RUNTIME=coreclr" >> $GITHUB_ENV + shell: bash + - name: "Run pytest with desired markers and extra arguments" uses: ansys/actions/tests-pytest@v10 with: diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index d8826a63d..f8328ca4e 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -43,3 +43,53 @@ jobs: python-version: "3.13" pytest-extra-args: "--local_osl --ignore=tests/test_examples.py" use-python-cache: false + + pythonnet-integration: + strategy: + fail-fast: false + matrix: + osl_image: + - version: 23.2.0 + hash: b84ee0d2395238b7f2c61bde85cdc6afcf67345de48cb9fcb1318e8615f263f9 + - version: 25.2.0 + hash: e2ee15479dc58413b37699fdbdbd60d551a31916b556d6dda74c3ed3dc0f7783 + name: "Python.NET with optiSLang ${{ matrix.osl_image.version }}" + if: github.event.name != 'pull_request' || github.event.pull_request.merged == true + runs-on: ubuntu-22.04 + container: + image: ${{ format('ghcr.io/ansys/optislang@sha256:{0}', matrix.osl_image.hash) }} # zizmor: ignore[unpinned-images] + credentials: + username: ansys-bot + password: ${{ secrets.GITHUB_TOKEN }} + permissions: + packages: read # To access container images + steps: + - name: "Install .NET SDK for Python.NET" + run: | + apt-get update + apt-get install -y wget + wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh + chmod +x dotnet-install.sh + ./dotnet-install.sh --channel 8.0 --install-dir /usr/share/dotnet + ln -s /usr/share/dotnet/dotnet /usr/bin/dotnet + dotnet --version + + - name: "Install test dependencies (includes pythonnet)" + run: | + python3 -m pip install pytest pythonnet + + - name: "Verify pythonnet installation" + run: | + python3 -c "import clr; print(f'Python.NET version: {clr.__version__}')" + python3 -c "from ansys.optislang.core import utils; print(f'is_pythonnet(): {utils.is_pythonnet()}')" + python3 -c "from ansys.optislang.core import utils; print(f'is_iron_python(): {utils.is_iron_python()}')" + + - name: "Pytest with Python.NET" + env: + PYOPTISLANG_DISABLE_OPTISLANG_OUTPUT: true + ANSYSLMD_LICENSE_FILE: ${{ format('1055@{0}', secrets.LICENSE_SERVER) }} + uses: ansys/actions/tests-pytest@v10 + with: + python-version: "3.13" + pytest-extra-args: "--local_osl tests/test_pythonnet_integration.py -v" + use-python-cache: false diff --git a/pyproject.toml b/pyproject.toml index 0c578a661..02919e0e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ tests = [ "pytest==8.4.2", "pytest-cov==7.0.0", "matplotlib>=3.5.3", + "pythonnet>=3.0.0; platform_system != 'Darwin'", # Python.NET compatibility tests (skip on macOS) ] doc = [ "ansys-sphinx-theme==1.6.4", diff --git a/src/ansys/optislang/core/osl_process.py b/src/ansys/optislang/core/osl_process.py index b75611c65..dc198b1b2 100644 --- a/src/ansys/optislang/core/osl_process.py +++ b/src/ansys/optislang/core/osl_process.py @@ -38,6 +38,10 @@ if utils.is_iron_python(): import System # type: ignore[import-not-found] +# Constants for int32 conversion (used when handling returncodes across Python↔.NET boundary) +INT32_MAX = 2147483647 # Maximum value for signed 32-bit integer +UINT32_RANGE = 4294967296 # 2^32, used to convert unsigned to signed int32 + class ServerNotification(Enum): """Push notifications available for subscription from the optiSLang server.""" @@ -282,7 +286,8 @@ def __init__( self.__batch = batch if not service else False self.__service = service self._logger = logging.getLogger(__name__) if logger is None else logger - self.__process: Optional[subprocess.Popen] = None + # Process can be either subprocess.Popen (Python) or System.Diagnostics.Process (IronPython) + self.__process: Optional[subprocess.Popen] = None # type: ignore[assignment] # pragma: no cover # noqa: E501 self.__handle_process_output_thread = None self.__tempdir = None @@ -675,8 +680,26 @@ def returncode(self) -> Optional[int]: Process return code, if exists; ``None`` otherwise. """ if self.__process is not None: - return self.__process.returncode - return None + if utils.is_iron_python(): # pragma: no cover + # System.Diagnostics.Process uses ExitCode property + # ExitCode is only valid after the process has exited + if self.__process.HasExited: # type:ignore[attr-defined] + # Ensure the exit code is treated as a signed 32-bit integer + exit_code = self.__process.ExitCode # type:ignore[attr-defined] + # Convert to signed int32 if needed (handle potential unsigned interpretation) + if exit_code > INT32_MAX: + exit_code = exit_code - UINT32_RANGE + return exit_code + return None # pragma: no cover + else: # pragma: no cover + rc = self.__process.returncode + # Defensive: When Python.NET is loaded, ensure returncode is treated as signed int32 + # to prevent marshaling issues at the Python↔.NET boundary where Python integers + # might be interpreted as UInt32 instead of Int32 by .NET code consuming this value + if rc is not None and utils.is_pythonnet() and rc > INT32_MAX: + rc = rc - UINT32_RANGE + return rc + return None # pragma: no cover @property def shutdown_on_finished(self) -> bool: @@ -1115,7 +1138,12 @@ def terminate(self): """Terminate optiSLang server process.""" if self.__process is not None: self.__terminate_osl_child_processes() - self.__process.terminate() + if utils.is_iron_python(): # pragma: no cover + # System.Diagnostics.Process uses Kill() method + if not self.__process.HasExited: + self.__process.Kill() + else: + self.__process.terminate() # pragma: no cover if ( self.__handle_process_output_thread is not None @@ -1139,7 +1167,11 @@ def is_running(self) -> bool: if self.__process is None: return False - return self.__process.poll() is None + if utils.is_iron_python(): # pragma: no cover + # System.Diagnostics.Process uses HasExited property + return not self.__process.HasExited # type:ignore[attr-defined] + else: + return self.__process.poll() is None # pragma: no cover def wait_for_finished(self, timeout: Optional[float] = None) -> Optional[int]: """Wait for the process to finish. @@ -1158,17 +1190,34 @@ def wait_for_finished(self, timeout: Optional[float] = None) -> Optional[int]: if self.__process is not None: if self.is_running(): try: - self.__process.wait(timeout) - except Exception: + if utils.is_iron_python(): # pragma: no cover + # System.Diagnostics.Process uses WaitForExit(milliseconds) + if timeout is not None: + timeout_ms = int(timeout * 1000) + self.__process.WaitForExit(timeout_ms) # type:ignore[attr-defined] + else: + self.__process.WaitForExit() # type:ignore[attr-defined] + else: + self.__process.wait(timeout) # pragma: no cover + except Exception: # pragma: no cover pass - return self.__process.returncode - return None + return self.returncode # pragma: no cover + return None # pragma: no cover def __start_process_output_thread(self): """Start new thread responsible for logging of STDOUT/STDERR of the optiSLang process.""" - def finalize_process(process, **kwargs): - process.wait(**kwargs) + def finalize_process(process, **kwargs): # pragma: no cover + if utils.is_iron_python(): + # System.Diagnostics.Process uses WaitForExit() + timeout = kwargs.get("timeout") + if timeout is not None: + timeout_ms = int(timeout * 1000) + process.WaitForExit(timeout_ms) + else: + process.WaitForExit() + else: + process.wait(**kwargs) self.__handle_process_output_thread = Thread( target=self.__handle_process_output, diff --git a/src/ansys/optislang/core/utils.py b/src/ansys/optislang/core/utils.py index 9293e1f6d..2e24277c0 100644 --- a/src/ansys/optislang/core/utils.py +++ b/src/ansys/optislang/core/utils.py @@ -456,10 +456,47 @@ def iter_awp_roots() -> Iterator[Tuple[int, Path]]: def is_iron_python(): - """Whether current platform is IronPython.""" + """Whether current platform is IronPython. + + Returns + ------- + bool + ``True`` if running under IronPython; ``False`` otherwise. + + Notes + ----- + IronPython is detected by checking if ``sys.platform == "cli"``. + This is different from Python.NET, which runs CPython with .NET interop. + """ return sys.platform == "cli" +def is_pythonnet(): + """Whether Python.NET (pythonnet) is available. + + Returns + ------- + bool + ``True`` if Python.NET is loaded; ``False`` otherwise. + + Notes + ----- + Python.NET allows CPython to interoperate with .NET assemblies. + This is different from IronPython - Python.NET uses standard CPython + with a bridge to .NET, while IronPython is a complete reimplementation + of Python in C#. + + When Python.NET is loaded, ``sys.platform`` remains "win32" or "linux", + and standard Python libraries (like subprocess) continue to work normally. + """ + try: + import clr # type: ignore[import-untyped, import-not-found] # noqa: F401 + + return True + except ImportError: + return False + + def get_localhost_addresses() -> List[str]: """Get addresses of the localhost machine. diff --git a/tests/test_pythonnet_compatibility.py b/tests/test_pythonnet_compatibility.py new file mode 100644 index 000000000..fa9e1e76d --- /dev/null +++ b/tests/test_pythonnet_compatibility.py @@ -0,0 +1,447 @@ +# Copyright (C) 2022 - 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. + +"""Tests for Python.NET compatibility (Scenario A: PyOptiSLang embedded in .NET apps).""" +import subprocess +import sys + +import pytest + +# Constants for int32 testing +INT32_MIN = -2147483648 # Minimum value for signed 32-bit integer +INT32_MAX = 2147483647 # Maximum value for signed 32-bit integer +UINT32_MAX = 4294967295 # Maximum value for unsigned 32-bit integer (2^32 - 1) +UINT32_RANGE = 4294967296 # 2^32, used to convert unsigned to signed int32 + + +def test_pythonnet_available(): + """Test that pythonnet is installed and importable.""" + try: + import clr + + assert clr is not None + print(f"Python.NET version: {clr.__version__}") + except ImportError: + pytest.skip("pythonnet not installed") + + +def test_platform_is_not_ironpython(): + """Verify we're running in CPython, not IronPython, even with pythonnet loaded.""" + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + # With Python.NET, sys.platform should still be win32/linux, not 'cli' + assert sys.platform != "cli", "Python.NET should not report as IronPython" + assert sys.platform in ["win32", "linux", "darwin"], f"Unexpected platform: {sys.platform}" + + +def test_pyoptislang_utils_detection(): + """Test that PyOptiSLang correctly identifies this is NOT IronPython.""" + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + from ansys.optislang.core import utils + + # Should return False - we're in CPython with Python.NET, not IronPython + assert not utils.is_iron_python(), "is_iron_python() should return False with Python.NET" + + +def test_basic_imports_with_clr(): + """Test that PyOptiSLang imports work with CLR loaded.""" + try: + import System # noqa: F401 + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + # These should all work without conflicts + from ansys.optislang.core import Optislang # noqa: F401 + from ansys.optislang.core import OslServerProcess # noqa: F401 + from ansys.optislang.core import utils # noqa: F401 + from ansys.optislang.core.osl_process import OslServerProcess as OslServerProcess2 # noqa: F401 + + # If we get here without exceptions, imports work + assert True + + +def test_subprocess_with_clr_loaded(): + """Test that subprocess operations work correctly with CLR loaded.""" + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + # Subprocess should still work normally + if sys.platform == "win32": + result = subprocess.run( + ["cmd", "/c", "echo", "test"], capture_output=True, text=True, timeout=5 + ) + else: + result = subprocess.run(["echo", "test"], capture_output=True, text=True, timeout=5) + + assert result.returncode == 0 + assert "test" in result.stdout + + +def test_subprocess_popen_with_clr(): + """Test that subprocess.Popen works with CLR loaded.""" + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + if sys.platform == "win32": + proc = subprocess.Popen( + ["cmd", "/c", "echo", "test"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + else: + proc = subprocess.Popen( + ["echo", "test"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + + stdout, stderr = proc.communicate(timeout=5) + assert proc.returncode == 0 + assert "test" in stdout + + +def test_process_returncode_with_clr(): + """Test that process return codes are handled correctly with CLR loaded.""" + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + # Test with successful command + if sys.platform == "win32": + proc = subprocess.Popen(["cmd", "/c", "exit", "0"]) + else: + proc = subprocess.Popen(["sh", "-c", "exit 0"]) + + proc.wait(timeout=5) + assert proc.returncode == 0 + + # Test with failed command (non-zero exit) + if sys.platform == "win32": + proc = subprocess.Popen(["cmd", "/c", "exit", "1"]) + else: + proc = subprocess.Popen(["sh", "-c", "exit 1"]) + + proc.wait(timeout=5) + assert proc.returncode == 1 + + +def test_negative_returncode_with_clr(): + """Test that negative return codes work correctly with CLR loaded.""" + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + # Skip on Windows as it doesn't commonly use negative exit codes + if sys.platform == "win32": + pytest.skip("Windows doesn't typically use negative exit codes") + + # Test with negative exit code (common on Linux for signals) + proc = subprocess.Popen(["sh", "-c", "exit 255"]) + proc.wait(timeout=5) + + # On Linux, exit 255 or signal termination can result in negative values + # Just verify we get an integer, not an overflow + assert isinstance(proc.returncode, int) + assert INT32_MIN <= proc.returncode <= INT32_MAX # Within int32 range + + +def test_clr_and_encoding(): + """Test that encoding utilities work with CLR loaded.""" + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + from ansys.optislang.core import encoding + + # Test basic encoding/decoding with ASCII-compatible text + text = "Hello, World!" + encoded = encoding.force_bytes(text) + decoded = encoding.force_text(encoded) + + assert isinstance(encoded, bytes) + assert isinstance(decoded, str) + assert decoded == text + + # Test with UTF-8 encoding for non-ASCII characters + text_utf8 = "Hello, World! 你好世界" + encoded_utf8 = encoding.force_bytes(text_utf8, encoding="utf-8") + decoded_utf8 = encoding.force_text(encoded_utf8, encoding="utf-8") + + assert isinstance(encoded_utf8, bytes) + assert isinstance(decoded_utf8, str) + assert decoded_utf8 == text_utf8 + + +def test_clr_system_diagnostics_not_used(): + """ + Verify that System.Diagnostics.Process is NOT used + when in Python.NET (should use subprocess). + """ + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + from ansys.optislang.core import utils + + # Verify detection: Python.NET should NOT trigger IronPython code paths + assert not utils.is_iron_python() + + # When we create a process, it should use subprocess, not System.Diagnostics + # This is implicit in our code: is_iron_python() returns False, so subprocess path is taken + + +def test_pythonnet_detection_helper(): + """Test the new is_pythonnet() helper if it exists.""" + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + from ansys.optislang.core import utils + + # Check if the helper exists + if hasattr(utils, "is_pythonnet"): + # Should return True when pythonnet is available + assert utils.is_pythonnet(), "is_pythonnet() should return True when pythonnet is loaded" + else: + # Helper not implemented yet - just check CLR is available + try: + import clr # noqa: F401 + + # If we can import clr, pythonnet is present + assert True + except ImportError: + pytest.fail("CLR module should be available") + + +def test_socket_operations_with_clr(): + """Test that socket operations work with CLR loaded.""" + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + import socket + + # Create a simple socket - should work with CLR loaded + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + assert sock is not None + sock.close() + + +def test_threading_with_clr(): + """Test that threading works with CLR loaded.""" + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + import threading + + result = [] + + def worker(): + result.append(42) + + thread = threading.Thread(target=worker) + thread.start() + thread.join(timeout=5) + + assert result == [42] + + +def test_json_with_clr(): + """Test that JSON operations work with CLR loaded.""" + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + import json + + data = {"key": "value", "number": 42} + encoded = json.dumps(data) + decoded = json.loads(encoded) + + assert decoded == data + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test") +def test_dotnet_types_available(): + """Test that .NET types are accessible via pythonnet.""" + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + # Should be able to import .NET types + import System + + # Test basic .NET type usage + guid = System.Guid.NewGuid() + assert guid is not None + + # Test string conversion + dotnet_string = System.String("test") + assert str(dotnet_string) == "test" + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test") +def test_dotnet_process_not_interfering(): + """Test that .NET System.Diagnostics.Process doesn't interfere with subprocess.""" + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + import System.Diagnostics + + # Create a subprocess (should use subprocess.Popen) + proc1 = subprocess.Popen(["cmd", "/c", "echo", "subprocess"]) + proc1.wait(timeout=5) + + # Create a .NET Process + proc2 = System.Diagnostics.Process() + proc2.StartInfo.FileName = "cmd.exe" + proc2.StartInfo.Arguments = "/c echo dotnet" + proc2.StartInfo.UseShellExecute = False + proc2.Start() + proc2.WaitForExit(5000) + + # Both should have succeeded independently + assert proc1.returncode == 0 + assert proc2.ExitCode == 0 + + +def test_is_pythonnet_detection_with_pythonnet(): + """Test is_pythonnet() returns True when pythonnet is installed.""" + from ansys.optislang.core.utils import is_pythonnet + + try: + import clr # noqa: F401 + + # If we can import clr, is_pythonnet() should return True + assert is_pythonnet() is True + except ImportError: + pytest.skip("pythonnet not installed") + + +def test_is_pythonnet_detection_without_pythonnet(): + """Test is_pythonnet() returns False when clr import fails.""" + import sys + from unittest.mock import patch + + from ansys.optislang.core.utils import is_pythonnet + + # Save the original clr module if it exists + original_clr = sys.modules.get("clr", None) + + try: + # Remove clr from sys.modules to simulate it not being installed + if "clr" in sys.modules: + del sys.modules["clr"] + + # Mock the import to raise ImportError for clr + with patch( + "builtins.__import__", + side_effect=lambda name, *args, **kwargs: ( + (_ for _ in ()).throw(ImportError("Mocked clr import failure")) + if name == "clr" + else __import__(name, *args, **kwargs) + ), + ): + # This should return False since clr import will fail + result = is_pythonnet() + assert result is False + + finally: + # Restore original clr module if it existed + if original_clr is not None: + sys.modules["clr"] = original_clr + + +def test_pythonnet_returncode_marshaling_protection(): + """Test that return codes are properly converted to signed int32 for Python.NET marshaling. + + This test verifies the defensive conversion that prevents Python.NET marshaling issues + where Python integers might be interpreted as UInt32 instead of Int32 at the + Python↔.NET boundary, causing negative exit codes to appear as large positive values. + """ + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + import subprocess + from unittest.mock import Mock, patch + + from ansys.optislang.core.osl_process import OslServerProcess + + # Create a mock process with a simulated large unsigned value + # (as might be received from .NET marshaling of -1) + mock_popen = Mock(spec=subprocess.Popen) + + # Mock the initialization to avoid file checks and process start + with ( + patch("os.path.isfile", return_value=True), + patch.object(OslServerProcess, "start"), + patch("subprocess.Popen", return_value=mock_popen), + ): + osl_proc = OslServerProcess(executable="/fake/path") + osl_proc._OslServerProcess__process = mock_popen + + # Test with large unsigned value (as if -1 was marshaled as unsigned) + mock_popen.returncode = UINT32_MAX # This is -1 interpreted as unsigned 32-bit + result = osl_proc.returncode + assert result == -1, f"Expected -1, got {result}" + + # Test with a normal positive return code (should remain unchanged) + mock_popen.returncode = 1 + result = osl_proc.returncode + assert result == 1, f"Expected 1, got {result}" + + # Test with None (process still running) + mock_popen.returncode = None + result = osl_proc.returncode + assert result is None, f"Expected None, got {result}" + + # Test with max signed int32 value (should remain unchanged) + mock_popen.returncode = INT32_MAX + result = osl_proc.returncode + assert result == INT32_MAX, f"Expected INT32_MAX ({INT32_MAX}), got {result}" + + # Test with value just above max signed int32 (should be converted) + mock_popen.returncode = INT32_MAX + 1 # This is INT32_MIN as signed + result = osl_proc.returncode + assert result == INT32_MIN, f"Expected INT32_MIN ({INT32_MIN}), got {result}" diff --git a/tests/test_pythonnet_integration.py b/tests/test_pythonnet_integration.py new file mode 100644 index 000000000..1feaf60c1 --- /dev/null +++ b/tests/test_pythonnet_integration.py @@ -0,0 +1,250 @@ +# Copyright (C) 2022 - 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. + +"""Integration tests for Python.NET with actual optiSLang process. + +These tests require optiSLang to be installed and use the --local_osl marker. +""" +import pytest + +pytestmark = pytest.mark.local_osl + + +def test_pythonnet_available(): + """Test that pythonnet is installed.""" + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed - use 'pip install pythonnet'") + + +def test_optislang_start_with_pythonnet(): + """Test starting optiSLang server process with Python.NET loaded.""" + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + from ansys.optislang.core import Optislang + + # Start optiSLang with CLR loaded + osl = Optislang(ini_timeout=60) + try: + assert osl is not None + assert osl.project is not None + print(f"Successfully started optiSLang with Python.NET loaded") + finally: + osl.dispose() + + +def test_osl_server_process_with_pythonnet(): + """Test OslServerProcess directly with Python.NET loaded.""" + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + from ansys.optislang.core import utils + from ansys.optislang.core.osl_process import OslServerProcess + + # Verify we're in Python.NET environment, not IronPython + assert utils.is_pythonnet() + assert not utils.is_iron_python() + + # Get optiSLang executable + osl_exec = utils.get_osl_exec() + if osl_exec is None: + pytest.skip("optiSLang not found") + + version, executable = osl_exec + + # Start process with Python.NET loaded + process = OslServerProcess( + executable=str(executable), + batch=True, + service=False, + ) + + try: + process.start() + assert process.is_running() + assert process.pid is not None + print(f"optiSLang process started with PID: {process.pid}") + + # Wait a bit to ensure process is stable + import time + + time.sleep(2) + + # Verify still running + assert process.is_running() + + finally: + # Terminate and check return code + process.terminate() + returncode = process.wait_for_finished(timeout=30) + + # Verify we got a valid return code (not corrupted by unsigned conversion) + assert returncode is not None + assert isinstance(returncode, int) + assert -2147483648 <= returncode <= 2147483647 # Within int32 range + print(f"Process terminated with return code: {returncode}") + + +def test_optislang_tcp_connection_with_pythonnet(): + """Test optiSLang TCP/IP connection with Python.NET loaded.""" + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + from ansys.optislang.core import Optislang + from ansys.optislang.core.communication_channels import CommunicationChannel + + # Start with TCP/IP server (explicitly specify TCP communication) + osl = Optislang(ini_timeout=60, communication_channel=CommunicationChannel.TCP) + + try: + # Verify connection + assert osl.project is not None + + # Test basic operations + osl.project.reset() + + # Get project status + status = osl.project.get_status() + assert status is not None + print(f"Project status: {status}") + + finally: + osl.dispose() + + +def test_subprocess_still_works_with_pythonnet(): + """Verify subprocess operations still work correctly with optiSLang and Python.NET.""" + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + import subprocess + import sys + + from ansys.optislang.core import utils + + # Verify environment + assert utils.is_pythonnet() + assert not utils.is_iron_python() + + # Test subprocess.run (should use CPython's subprocess, not .NET) + if sys.platform == "win32": + result = subprocess.run( + ["cmd", "/c", "echo", "test"], capture_output=True, text=True, timeout=5 + ) + else: + result = subprocess.run(["echo", "test"], capture_output=True, text=True, timeout=5) + + assert result.returncode == 0 + assert "test" in result.stdout + + +def test_multiple_osl_instances_with_pythonnet(): + """Test creating multiple optiSLang instances with Python.NET loaded.""" + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + from ansys.optislang.core import Optislang + + # Start first instance + osl1 = Optislang(ini_timeout=60) + try: + assert osl1.project is not None + + # Start second instance + osl2 = Optislang(ini_timeout=60) + try: + assert osl2.project is not None + + # Verify both are independent + assert osl1 != osl2 + print("Successfully created multiple optiSLang instances with Python.NET") + + finally: + osl2.dispose() + finally: + osl1.dispose() + + +def test_optislang_local_domain_with_pythonnet(): + """Test optiSLang LOCAL_DOMAIN communication with Python.NET loaded. + + This test explicitly uses LOCAL_DOMAIN communication channel and verifies: + - On Windows: pywintypes and win32 modules work with Python.NET + - On Linux: Unix domain sockets work with Python.NET + - Communication over local domain socket succeeds with actual optiSLang process + """ + try: + import clr # noqa: F401 + except ImportError: + pytest.skip("pythonnet not installed") + + from ansys.optislang.core import Optislang + from ansys.optislang.core.communication_channels import CommunicationChannel + + # Explicitly use LOCAL_DOMAIN channel with Python.NET loaded + osl = Optislang(ini_timeout=120, communication_channel=CommunicationChannel.LOCAL_DOMAIN) + + try: + # Verify connection is established + assert osl.project is not None, "Project should be accessible" + + # Test basic operations over local domain socket + # This exercises send/recv through local_socket.py code paths + osl.project.reset() + + # Get project status - requires communication over local socket + status = osl.project.get_status() + assert status is not None, "Should be able to get project status" + + # Test another operation to ensure socket communication is stable + project_info = osl.application.project + assert project_info is not None, "Should be able to get project info" + + import sys + + if sys.platform == "win32": + # On Windows, verify pywintypes was imported (will be in sys.modules) + # This confirms Windows named pipe code paths with Python.NET work + assert ( + "pywintypes" in sys.modules + ), "pywintypes should be imported on Windows for local domain sockets" + print("Successfully used Windows named pipes with Python.NET") + else: + # On Linux, verify Unix domain socket was used + print("Successfully used Unix domain sockets with Python.NET") + + print(f"LOCAL_DOMAIN communication with Python.NET verified on {sys.platform}") + + finally: + osl.dispose()