Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .github/workflows/ci_cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
50 changes: 50 additions & 0 deletions .github/workflows/integration_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
rfahlberg marked this conversation as resolved.
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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
71 changes: 60 additions & 11 deletions src/ansys/optislang/core/osl_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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,
Expand Down
39 changes: 38 additions & 1 deletion src/ansys/optislang/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading