diff --git a/doc/changelog.d/5375.added.md b/doc/changelog.d/5375.added.md new file mode 100644 index 00000000000..806ca9fd1c3 --- /dev/null +++ b/doc/changelog.d/5375.added.md @@ -0,0 +1 @@ +Runtime typechecking diff --git a/doc/source/contributing/environment_variables.rst b/doc/source/contributing/environment_variables.rst index 678ba6b2e5b..6065ca98fb3 100644 --- a/doc/source/contributing/environment_variables.rst +++ b/doc/source/contributing/environment_variables.rst @@ -59,6 +59,8 @@ control the behavior of PyFluent within the same Python process. Please see the - Enabled PyFluent logging and specifies the logging level. Possible values are ``DEBUG``, ``INFO``, ``WARNING``, ``ERROR``, and ``CRITICAL``. * - PYFLUENT_NO_FIX_PARAMETER_LIST_RETURN - Disables the return value fix for the parameter list command in settings API. + * - PYFLUENT_RUNTIME_TYPE_CHECKING + - Set to ``1`` to check the type annotations of PyFluent APIs at runtime. Requires the ``type-checking`` extra. * - PYFLUENT_SHOW_SERVER_GUI - Shows the Fluent GUI while launching Fluent in :func:`launch_fluent() `. * - PYFLUENT_SKIP_API_UPGRADE_ADVICE diff --git a/doc/source/user_guide/config_variables.rst b/doc/source/user_guide/config_variables.rst index 2156673e562..aa335d86d57 100644 --- a/doc/source/user_guide/config_variables.rst +++ b/doc/source/user_guide/config_variables.rst @@ -16,3 +16,40 @@ The following code demonstrates how to access and modify the path within the Flu >>> config.container_mount_target = '/home/my_user/workdir' # set a new value >>> config.container_mount_target # new value '/home/my_user/workdir' + +Runtime type-checking +--------------------- + +PyFluent can check the type annotations of its own APIs while they are called, so that an +argument of the wrong type is reported at the call itself instead of surfacing later as an +obscure failure. This is intended for development and testing, and is disabled by default. + +It relies on ``beartype`` `_, which is installed with the +``type-checking`` extra: + +.. code-block:: bash + + pip install ansys-fluent-core[type-checking] + +Type-checking is applied by an import hook, so it has to be requested through the +``PYFLUENT_RUNTIME_TYPE_CHECKING`` environment variable **before** PyFluent is imported. +Setting ``config.runtime_type_checking`` afterwards has no effect and issues a warning, because +modules which are already imported cannot be checked retrospectively. + +.. code-block:: bash + + export PYFLUENT_RUNTIME_TYPE_CHECKING=1 + +The ``config.runtime_type_checking`` variable reports whether type-checking is active in the +current process: + +.. code-block:: python + + >>> from ansys.fluent.core import config + >>> config.runtime_type_checking + True + +Passing an argument of the wrong type then raises a ``beartype.roar.BeartypeCallHintParamViolation``. + +If the ``type-checking`` extra is not installed, PyFluent warns and continues with +type-checking disabled. diff --git a/pyproject.toml b/pyproject.toml index 41d31c41f8e..146f46fa00d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ include = ["src/ansys/fluent/core/generated/"] reader = ["h5py>=3.15.1"] ui-jupyter = ["ipywidgets>=8.1.8"] ui = ["panel>=1.8.1"] +type-checking = ["beartype>=0.19,<1"] search = ["nltk>=3.10.3"] tests = [ "pytest==9.1.1", @@ -54,6 +55,7 @@ tests = [ "pyfakefs==6.2.0", "ansys-platform-instancemanagement==1.1.2", "ansys-tools-common==0.5.2", + "beartype==0.22.9", "docker==7.1.0", "grpcio==1.81.1", "grpcio-health-checking==1.81.1", diff --git a/src/ansys/fluent/core/__init__.py b/src/ansys/fluent/core/__init__.py index e029c2b0fa8..e95174d7486 100644 --- a/src/ansys/fluent/core/__init__.py +++ b/src/ansys/fluent/core/__init__.py @@ -25,6 +25,14 @@ # isort: off +# Runtime type-checking works by transforming module source at import time, so +# the hook has to be installed before any other PyFluent module is imported. +# This module only depends on the standard library. +from ansys.fluent.core._type_checking import PyFluentTypeCheckingError +from ansys.fluent.core._type_checking import install_import_hook + +install_import_hook() + # config must be initialized before logging setup. from ansys.fluent.core.module_config import * diff --git a/src/ansys/fluent/core/_type_checking.py b/src/ansys/fluent/core/_type_checking.py new file mode 100644 index 00000000000..6be1f3427f5 --- /dev/null +++ b/src/ansys/fluent/core/_type_checking.py @@ -0,0 +1,291 @@ +# Copyright (C) 2021 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. +# 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. + +"""Runtime type-checking support for PyFluent. + +This module is the single integration point for the third-party runtime +type-checking library, so that the library can be swapped without touching any +call site. ``beartype`` is the default backend. + +Runtime type-checking is **disabled by default**. It is activated by setting the +``PYFLUENT_RUNTIME_TYPE_CHECKING`` environment variable to ``"1"`` before +importing PyFluent, and is reflected by +:attr:`ansys.fluent.core.config.runtime_type_checking`. + +Notes +----- +The hook has to be installed before any PyFluent submodule is imported, because +it works by transforming module source at import time. Modules which are +already imported are never checked. This is why the switch is read from the +environment here instead of from the configuration object, which itself lives in +a PyFluent submodule. + +This module must only depend on the standard library so that it can be imported +as the very first statement of ``ansys.fluent.core``. +""" + +from collections.abc import Callable +import os +import typing +import warnings + +__all__ = ( + "ENV_VAR", + "PACKAGE_NAME", + "PyFluentTypeCheckingError", + "install_import_hook", + "is_type_checking_enabled", + "no_runtime_type_check", + "runtime_type_check", +) + +#: Environment variable which activates runtime type-checking. +ENV_VAR = "PYFLUENT_RUNTIME_TYPE_CHECKING" + + +class PyFluentTypeCheckingError(TypeError): + """Runtime type-checking violation in PyFluent API. + + Raised when a PyFluent function or method is called with arguments that do + not match its declared type annotations, and runtime type-checking is + enabled via the ``PYFLUENT_RUNTIME_TYPE_CHECKING`` environment variable. + + This exception wraps the underlying type-checking backend's exception, + providing a stable PyFluent API that is independent of the backend + implementation (e.g., ``beartype`` vs ``typeguard``). + + Users should not need to know or import the specific type-checking library. + + See Also + -------- + :attr:`ansys.fluent.core.config.runtime_type_checking` : Configuration option + """ + + pass + + +#: Package whose submodules are type-checked at import time. +#: +#: ``ansys`` and ``ansys.fluent`` are :pep:`420` namespace packages, so the +#: package has to be named explicitly. ``beartype_this_package()`` must not be +#: used here: it derives its target from the parent of ``__name__``, which +#: resolves to the ``ansys.fluent`` namespace package and fails. +PACKAGE_NAME = "ansys.fluent.core" + +#: Name of the active backend. +BACKEND = "beartype" + +_HOOK_INSTALLED = False + +T = typing.TypeVar("T") + + +def _beartype_install_hook() -> bool: + """Install the ``beartype`` import hook on the PyFluent package.""" + from beartype import BeartypeConf + from beartype.claw import beartype_package + + beartype_package( + PACKAGE_NAME, + conf=BeartypeConf( + # Do not check :pep:`526` annotated variable assignments. Only + # callable parameters and return values are checked. + claw_is_pep526=False, + ), + ) + return True + + +def _beartype_decorator(obj: T) -> T: + """Apply the ``beartype`` decorator to ``obj``, wrapping exceptions. + + Catches backend-specific exceptions and re-raises as PyFluentTypeCheckingError + to maintain encapsulation and API stability. + """ + import functools + + from beartype import beartype + from beartype.roar import BeartypeException + + # Apply beartype decorator + decorated = beartype(obj) + + # For callables (not classes), wrap to catch backend exceptions + if callable(decorated) and not isinstance(decorated, type): + + @functools.wraps(decorated) + def wrapper(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: + try: + return decorated(*args, **kwargs) + except BeartypeException as exc: + raise PyFluentTypeCheckingError( + f"Type-checking violation: {exc}" + ) from exc + + return wrapper # type: ignore[return-value] + + # For classes and other objects, return as-is (claw handles them) + return decorated + + +def _no_op_install_hook() -> bool: + """Do not install any import hook.""" + return False + + +def _no_op_decorator(obj: T) -> T: + """Return ``obj`` unchanged.""" + return obj + + +#: Supported backends. Each entry maps a backend name to its import-hook +#: installer and its decorator, which is the only pair of operations the rest of +#: PyFluent relies on. +_BACKENDS: dict[str, dict[str, Callable]] = { + "beartype": { + "install_hook": _beartype_install_hook, + "decorator": _beartype_decorator, + }, + "none": { + "install_hook": _no_op_install_hook, + "decorator": _no_op_decorator, + }, +} + + +def is_type_checking_enabled() -> bool: + """Whether runtime type-checking is active in the current process. + + Returns + ------- + bool + ``True`` if the import hook has been installed. + """ + return _HOOK_INSTALLED + + +def install_import_hook() -> bool: + """Install the runtime type-checking import hook. + + This is a no-op unless the ``PYFLUENT_RUNTIME_TYPE_CHECKING`` environment + variable is set to ``"1"``. It never raises: if the backend is not + installed, a warning is emitted and type-checking stays disabled. + + Returns + ------- + bool + ``True`` if the hook was installed by this call. + """ + global _HOOK_INSTALLED + if _HOOK_INSTALLED: + return False + if os.environ.get(ENV_VAR) != "1": + return False + try: + _HOOK_INSTALLED = _BACKENDS[BACKEND]["install_hook"]() + except ImportError: + warnings.warn( + f"{ENV_VAR} is set but the '{BACKEND}' package is not installed, so " + "runtime type-checking is disabled. Install it with " + "'pip install ansys-fluent-core[type-checking]'.", + UserWarning, + ) + _HOOK_INSTALLED = False + return _HOOK_INSTALLED + + +def runtime_type_check(obj: T) -> T: + """Type-check the annotations of ``obj`` at runtime. + + Use this decorator for objects which the import hook cannot reach, such as + classes created dynamically. It is a no-op when runtime type-checking is + disabled. + + Parameters + ---------- + obj : Callable or type + Object to type-check. + + Returns + ------- + Callable or type + The type-checked object, or ``obj`` unchanged when disabled. + """ + if not _HOOK_INSTALLED: + return obj + return _BACKENDS[BACKEND]["decorator"](obj) + + +def no_runtime_type_check(obj): + """Disable runtime type-checking for an object. + + Marks an object so that beartype (or other type-checking backends) skip + validation. This is useful for disabling type-checks on specific callables + or classes that may cause issues with type-checking or have incompatible + type annotations. + + This function directly sets the ``__no_type_check__`` attribute that beartype + and other type-checking libraries recognize, without relying on the standard + library's :func:`typing.no_type_check` which has issues with generic class + definitions. + + Parameters + ---------- + obj : Callable, type, or object + Object to mark for skipping runtime type-checking. Can be a function, + class, method, or other callable. + + Returns + ------- + Callable, type, or object + The input object unchanged, or with the ``__no_type_check__`` attribute + set if the object supports attribute assignment. + + Notes + ----- + Objects that cannot have attributes assigned (e.g., types.GenericAlias, + built-in types) are returned unchanged. This is acceptable because these + objects are typically not directly callable or type-checkable anyway. + + See Also + -------- + :func:`runtime_type_check` : Enable runtime type-checking for an object + """ + import types + + # Skip GenericAlias objects (e.g., SettingsBase[DictStateType]) + # as they don't support attribute assignment + if isinstance(obj, types.GenericAlias): + return obj + + # Try to set __no_type_check__ directly on objects that support it + # Skip objects that don't support attribute assignment + if hasattr(obj, "__dict__") or isinstance(obj, type): + try: + obj.__no_type_check__ = True # type: ignore[attr-defined] + except (AttributeError, TypeError): + # Object doesn't support attribute assignment, return unchanged + # This is acceptable - such objects typically aren't type-checkable anyway + pass + + return obj diff --git a/src/ansys/fluent/core/codegen/builtin_settingsgen.py b/src/ansys/fluent/core/codegen/builtin_settingsgen.py index 6f225870e43..f102a3b6761 100644 --- a/src/ansys/fluent/core/codegen/builtin_settingsgen.py +++ b/src/ansys/fluent/core/codegen/builtin_settingsgen.py @@ -176,7 +176,7 @@ def _write_init_signature(f, kind: str, named_objects: list) -> None: f.write(f", {named_object}: str") f.write(", settings_source: SettingsBase | Solver | None = None") if kind == "NonCreatableNamedObject": - f.write(", name: str = None") + f.write(", name: str | None = None") elif kind == "CreatableNamedObject": f.write(", name: str | None = None, new_instance_name: str | None = None") f.write("):\n") diff --git a/src/ansys/fluent/core/local_parametric_study.py b/src/ansys/fluent/core/local_parametric_study.py index 68f2fbfa6ee..8f0b164c087 100644 --- a/src/ansys/fluent/core/local_parametric_study.py +++ b/src/ansys/fluent/core/local_parametric_study.py @@ -330,7 +330,7 @@ def design_point(self, idx_or_name) -> LocalDesignPoint: def run_in_fluent( self, num_servers: int, - launcher: Any = None, + launcher: Any | None = None, start_transcript: bool = False, capture_report_data: bool = False, ): diff --git a/src/ansys/fluent/core/module_config.py b/src/ansys/fluent/core/module_config.py index d8f6ef7fb74..57d0d99aef2 100644 --- a/src/ansys/fluent/core/module_config.py +++ b/src/ansys/fluent/core/module_config.py @@ -37,12 +37,24 @@ from typing import Any, Generic, TypeVar, cast import warnings +from ansys.fluent.core._type_checking import ( + is_type_checking_enabled as _is_type_checking_enabled, +) +from ansys.fluent.core._type_checking import ( + no_runtime_type_check, +) +from ansys.fluent.core._type_checking import ENV_VAR as _TYPE_CHECKING_ENV_VAR + __all__ = ("config",) TConfig = TypeVar("TConfig", bound="Config") +# ``TConfig`` is bound to a forward reference which cannot be resolved while the +# ``Config`` class body is still executing, which is exactly when +# ``__set_name__`` runs. +@no_runtime_type_check class _ConfigDescriptor(Generic[TConfig]): """Descriptor for managing configuration attributes.""" @@ -88,6 +100,26 @@ def _get_default_examples_path(instance: "Config") -> str: return str(default_path) +class _RuntimeTypeCheckingDescriptor(_ConfigDescriptor[TConfig]): + """Descriptor for the runtime type-checking configuration attribute. + + Runtime type-checking is driven by an import hook which is installed while + PyFluent is imported. Assigning to this attribute afterwards cannot check + modules which are already imported, so a warning is emitted whenever the + assigned value disagrees with the state of the hook. + """ + + def __set__(self, instance: TConfig, value: Any): + if bool(value) != _is_type_checking_enabled(): + warnings.warn( + "Runtime type-checking cannot be changed after PyFluent is imported. " + f"Set the '{_TYPE_CHECKING_ENV_VAR}' environment variable to '1' " + "before importing PyFluent instead.", + UserWarning, + ) + super().__set__(instance, value) + + class Config: """Set the global configuration variables for PyFluent.""" @@ -317,6 +349,13 @@ class Config: lambda instance: instance._env.get("PYFLUENT_USE_RUNTIME_PYTHON_CLASSES") == "1" ) + #: Whether runtime type-checking of PyFluent APIs is active, defaults to whether the + #: ``PYFLUENT_RUNTIME_TYPE_CHECKING`` environment variable was set to ``1`` when PyFluent was imported. + #: The import hook backing this option is installed while PyFluent is imported, so assigning to this option afterwards has no effect. + runtime_type_checking = _RuntimeTypeCheckingDescriptor["Config"]( + lambda instance: _is_type_checking_enabled() + ) + #: Whether to hide sensitive information in logs, defaults to the value of ``PYFLUENT_HIDE_LOG_SECRETS`` environment variable. hide_log_secrets = _ConfigDescriptor["Config"]( lambda instance: instance._env.get("PYFLUENT_HIDE_LOG_SECRETS") == "1" diff --git a/src/ansys/fluent/core/rest/transport.py b/src/ansys/fluent/core/rest/transport.py index 4c9fc8e31ea..48fbda70767 100644 --- a/src/ansys/fluent/core/rest/transport.py +++ b/src/ansys/fluent/core/rest/transport.py @@ -63,7 +63,7 @@ class RequestStrategy(Protocol): this protocol structurally — no inheritance required. """ - def request(self, method: str, endpoint: str, *, body: Any = None) -> Any: + def request(self, method: str, endpoint: str, *, body: Any | None = None) -> Any: """Execute one HTTP request and return the decoded JSON response. Parameters @@ -146,7 +146,7 @@ def _build_request( self, method: str, url: str, - body: Any = None, + body: Any | None = None, ) -> urllib.request.Request: data: bytes | None = None headers: dict[str, str] = dict(self._headers) @@ -210,7 +210,7 @@ def _send_with_retry(self, req: urllib.request.Request, retries: int) -> Any: # RequestStrategy implementation # ------------------------------------------------------------------ - def request(self, method: str, endpoint: str, *, body: Any = None) -> Any: + def request(self, method: str, endpoint: str, *, body: Any | None = None) -> Any: """Implement :class:`RequestStrategy` — build, send, and retry.""" url = f"{self._base_url}/{endpoint}" req = self._build_request(method, url, body) diff --git a/src/ansys/fluent/core/search.py b/src/ansys/fluent/core/search.py index 19c0e8255b9..f0c2b2b51bd 100644 --- a/src/ansys/fluent/core/search.py +++ b/src/ansys/fluent/core/search.py @@ -300,7 +300,7 @@ def _search_whole_word( search_string: str, match_case: bool = False, match_whole_word: bool = True, - api_tree_data: dict = None, + api_tree_data: dict | None = None, api_path: str | None = None, ): """Perform exact search for a word through the Fluent's object hierarchy. diff --git a/src/ansys/fluent/core/services/object_model.py b/src/ansys/fluent/core/services/object_model.py index 9d267b58bdf..62db2841a23 100644 --- a/src/ansys/fluent/core/services/object_model.py +++ b/src/ansys/fluent/core/services/object_model.py @@ -479,7 +479,7 @@ def set_state(self, state: Any | None = None, **kwargs) -> None: setState = set_state def get_completer_info( - self, prefix: str = "", excluded: Iterable = None + self, prefix: str = "", excluded: Iterable | None = None ) -> list[list[str]]: """Get completer information of all children. @@ -1028,7 +1028,7 @@ def get_object_names(self) -> Any: getChildObjectDisplayNames = get_object_names def get_completer_info( - self, prefix: str = "", excluded: Iterable = None + self, prefix: str = "", excluded: Iterable | None = None ) -> list[list[str]]: """Get completer information of all children. @@ -1251,7 +1251,7 @@ def create_instance(self) -> "PyArguments": return PyArguments(*args) def get_completer_info( - self, prefix: str = "", excluded: Iterable = None + self, prefix: str = "", excluded: Iterable | None = None ) -> list[list[str]]: """Get completer information of all children. diff --git a/src/ansys/fluent/core/solver/flobject.py b/src/ansys/fluent/core/solver/flobject.py index 85fac136827..001f31473d0 100644 --- a/src/ansys/fluent/core/solver/flobject.py +++ b/src/ansys/fluent/core/solver/flobject.py @@ -71,6 +71,7 @@ import warnings import weakref +from ansys.fluent.core._type_checking import no_runtime_type_check from ansys.fluent.core._variable_strategies import ( FluentFieldDataNamingStrategy as naming_strategy, ) @@ -478,6 +479,7 @@ def _is_deprecated(obj) -> bool | None: ) +@no_runtime_type_check class Base: """Provides the base class for settings and command objects. @@ -750,7 +752,7 @@ def __eq__(self, other): return self.flproxy == other.flproxy and self.path == other.path def get_completer_info( - self, prefix: str = "", excluded: Iterable = None + self, prefix: str = "", excluded: Iterable | None = None ) -> list[list[str]]: """Get completer information of all children. @@ -1015,6 +1017,7 @@ def _create_child(cls, name, parent: weakref.CallableProxyType, alias_path=None) return cls(name, parent) +@no_runtime_type_check class SettingsBase(Base, Generic[StateT]): """Base class for settings objects. @@ -1268,6 +1271,7 @@ class BooleanList(SettingsBase[BoolListType], Property): } +@no_runtime_type_check class Group(SettingsBase[DictStateType]): """A ``Group`` container object. @@ -1445,6 +1449,7 @@ def __setattr__(self, name: str, value): raise +@no_runtime_type_check class WildcardPath(Group): """Class wrapping a wildcard path to perform get_var and set_var on flproxy.""" @@ -1541,6 +1546,7 @@ def __setitem__(self, name, value): ChildTypeT = TypeVar("ChildTypeT") +@no_runtime_type_check class NamedObject(SettingsBase[DictStateType], Generic[ChildTypeT]): """A ``NamedObject`` container is a container object similar to a Python dictionary object. Generally, many such objects can be created with different names. @@ -1867,6 +1873,7 @@ def _convert_to_target_units(path, state, quantity, target_units): raise UnhandledQuantity(path, state) from ex +@no_runtime_type_check class ListObject(SettingsBase[ListStateType], Generic[ChildTypeT]): """A ``ListObject`` container is a container object, similar to a Python list object. Generally, many such objects can be created. @@ -2086,6 +2093,7 @@ def _get_new_keywords(obj, *args, **kwds): return newkwds +@no_runtime_type_check class Action(Base): """Intermediate Base class for Command and Query class.""" diff --git a/src/ansys/fluent/core/utils/deprecate.py b/src/ansys/fluent/core/utils/deprecate.py index 4625795f294..1737bf01503 100644 --- a/src/ansys/fluent/core/utils/deprecate.py +++ b/src/ansys/fluent/core/utils/deprecate.py @@ -138,6 +138,7 @@ def wrapper(*args, **kwargs): return func(*args, **kwargs) + wrapper.__signature__ = inspect.signature(func) return wrapper return decorator @@ -181,6 +182,7 @@ def wrapper(*args, **kwargs): warnings.warn(reason, warning_cls, stacklevel=2) return decorated(*args, **kwargs) + wrapper.__signature__ = inspect.signature(decorated) return wrapper return decorator diff --git a/src/ansys/fluent/core/utils/get_completer_info.py b/src/ansys/fluent/core/utils/get_completer_info.py index 26dc6cb15e1..e84ac08b829 100644 --- a/src/ansys/fluent/core/utils/get_completer_info.py +++ b/src/ansys/fluent/core/utils/get_completer_info.py @@ -41,9 +41,9 @@ def get_completer_info( obj, base_class: type, prefix: str = "", - excluded: Iterable = None, - filter_function: Callable = None, - type_name_map: dict = None, + excluded: Iterable | None = None, + filter_function: Callable | None = None, + type_name_map: dict | None = None, ) -> list[list[str]]: """Get completer information of all children. diff --git a/tests/test_deprecate.py b/tests/test_deprecate.py index 75e617dae90..4655446b39f 100644 --- a/tests/test_deprecate.py +++ b/tests/test_deprecate.py @@ -21,6 +21,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +import inspect import warnings import pytest @@ -171,3 +172,42 @@ def new_add(a, b): assert "3.0.0" in str(warning.message) assert result == 3 + + +def test_deprecate_arguments_preserves_signature(): + """Test that deprecate_arguments decorator preserves function signature.""" + + @deprecate_arguments(old_args="old_param", new_args="new_param", version="3.0.0") + def example_func(new_param: int, other: str = "default"): + return new_param, other + + # Check that the signature is preserved + sig = inspect.signature(example_func) + params = list(sig.parameters.keys()) + assert params == [ + "new_param", + "other", + ], f"Expected ['new_param', 'other'], got {params}" + + # Check parameter annotations + assert sig.parameters["new_param"].annotation == int + assert sig.parameters["other"].annotation == str + assert sig.parameters["other"].default == "default" + + +def test_deprecate_function_preserves_signature(): + """Test that deprecate_function decorator preserves function signature.""" + + @deprecate_function(version="3.0.0", new_func="new_multiply") + def old_multiply(x: int, y: int) -> int: + return x * y + + # Check that the signature is preserved + sig = inspect.signature(old_multiply) + params = list(sig.parameters.keys()) + assert params == ["x", "y"], f"Expected ['x', 'y'], got {params}" + + # Check parameter annotations + assert sig.parameters["x"].annotation == int + assert sig.parameters["y"].annotation == int + assert sig.return_annotation == int diff --git a/tests/test_runtime_type_checking.py b/tests/test_runtime_type_checking.py new file mode 100644 index 00000000000..cbefa6814d7 --- /dev/null +++ b/tests/test_runtime_type_checking.py @@ -0,0 +1,196 @@ +# Copyright (C) 2021 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. +# 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 runtime type-checking.""" + +import os +import subprocess +import sys + +import pytest + +import ansys.fluent.core as pyfluent +from ansys.fluent.core import _type_checking + +# The import hook is installed while PyFluent is imported, which has already +# happened by the time these tests run. Anything which depends on the state of +# the hook therefore has to be exercised in a fresh interpreter. + + +def _run(source: str, enabled: bool) -> str: + """Run ``source`` in a fresh interpreter and return its stripped output.""" + env = os.environ.copy() + if enabled: + env[_type_checking.ENV_VAR] = "1" + else: + env.pop(_type_checking.ENV_VAR, None) + result = subprocess.run( + [sys.executable, "-W", "ignore", "-c", source], + env=env, + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip().splitlines()[-1] + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_hook_installed_only_when_env_var_is_set(enabled): + pytest.importorskip("beartype") + source = ( + "from ansys.fluent.core import _type_checking\n" + "print(_type_checking.is_type_checking_enabled())" + ) + assert _run(source, enabled=enabled) == str(enabled) + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_config_reflects_hook_state(enabled): + pytest.importorskip("beartype") + source = ( + "import ansys.fluent.core as pyfluent\n" + "print(pyfluent.config.runtime_type_checking)" + ) + assert _run(source, enabled=enabled) == str(enabled) + + +@pytest.mark.parametrize( + "enabled, expected", + [ + # With type-checking on, the bad argument is reported at the call + # boundary via PyFluentTypeCheckingError. With it off, it surfaces + # later as an obscure downstream error. + (True, "BeartypeCallHintParamViolation"), + (False, "AttributeError"), + ], +) +def test_type_violation_is_reported_only_when_enabled(enabled, expected): + pytest.importorskip("beartype") + source = ( + "from ansys.fluent.core.utils.fluent_version import get_version_for_file_name\n" + "try:\n" + " get_version_for_file_name(version=123)\n" + " print('no-raise')\n" + "except Exception as exc:\n" + " print(type(exc).__name__)" + ) + assert _run(source, enabled=enabled) == expected + + +def test_import_succeeds_with_type_checking_enabled(): + pytest.importorskip("beartype") + source = "import ansys.fluent.core\nprint('imported')" + assert _run(source, enabled=True) == "imported" + + +def test_runtime_type_check_is_a_no_op_when_disabled(): + def fn(x: int) -> int: + return x + + assert _type_checking.runtime_type_check(fn) is fn + + +def test_runtime_type_check_uses_the_active_backend(monkeypatch): + pytest.importorskip("beartype") + from ansys.fluent.core._type_checking import PyFluentTypeCheckingError + + monkeypatch.setattr(_type_checking, "_HOOK_INSTALLED", True) + + @_type_checking.runtime_type_check + def fn(x: int) -> int: + return x + + assert fn(1) == 1 + with pytest.raises(PyFluentTypeCheckingError): + fn("1") + + +def test_backend_is_swappable(monkeypatch): + monkeypatch.setattr(_type_checking, "_HOOK_INSTALLED", True) + monkeypatch.setattr(_type_checking, "BACKEND", "none") + + def fn(x: int) -> int: + return x + + assert _type_checking.runtime_type_check(fn) is fn + assert _type_checking._BACKENDS["none"]["install_hook"]() is False + + +def test_no_runtime_type_check_marks_the_object(): + def fn(x: int) -> int: + return x + + assert _type_checking.no_runtime_type_check(fn).__no_type_check__ is True + + +def test_install_import_hook_is_a_no_op_without_the_env_var(monkeypatch): + monkeypatch.delenv(_type_checking.ENV_VAR, raising=False) + monkeypatch.setattr(_type_checking, "_HOOK_INSTALLED", False) + assert _type_checking.install_import_hook() is False + assert _type_checking.is_type_checking_enabled() is False + + +def test_install_import_hook_is_idempotent(monkeypatch): + monkeypatch.setenv(_type_checking.ENV_VAR, "1") + monkeypatch.setattr(_type_checking, "_HOOK_INSTALLED", True) + assert _type_checking.install_import_hook() is False + + +def test_install_import_hook_warns_when_the_backend_is_missing(monkeypatch): + def _raise(): + raise ImportError("No module named 'beartype'") + + monkeypatch.setenv(_type_checking.ENV_VAR, "1") + monkeypatch.setattr(_type_checking, "_HOOK_INSTALLED", False) + monkeypatch.setitem( + _type_checking._BACKENDS[_type_checking.BACKEND], "install_hook", _raise + ) + with pytest.warns(UserWarning, match="runtime type-checking is disabled"): + assert _type_checking.install_import_hook() is False + assert _type_checking.is_type_checking_enabled() is False + + +def test_config_warns_when_set_after_import(monkeypatch): + monkeypatch.delattr(pyfluent.config, "_runtime_type_checking", raising=False) + with pytest.warns( + UserWarning, match="cannot be changed after PyFluent is imported" + ): + pyfluent.config.runtime_type_checking = ( + not _type_checking.is_type_checking_enabled() + ) + monkeypatch.delattr(pyfluent.config, "_runtime_type_checking", raising=False) + + +def test_config_print_includes_runtime_type_checking(capsys): + pyfluent.config.print() + assert "runtime_type_checking" in capsys.readouterr().out + + +def test_pyfluent_type_checking_error_is_exported(): + """Verify PyFluentTypeCheckingError is accessible from the main package.""" + assert hasattr(pyfluent, "PyFluentTypeCheckingError") + assert issubclass(pyfluent.PyFluentTypeCheckingError, TypeError) + # Verify it's the same class from _type_checking module + assert ( + pyfluent.PyFluentTypeCheckingError is _type_checking.PyFluentTypeCheckingError + )