diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index de95a03e..d4b8a843 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -67,6 +67,11 @@ jobs: run: | pytest + # - uses: codecov/codecov-action@v5 + # name: 'Upload coverage to CodeCov' + # with: + # token: ${{ secrets.CODECOV_TOKEN }} + doc-build: name: Build documentation runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index e54b923d..60a609ac 100644 --- a/.gitignore +++ b/.gitignore @@ -175,4 +175,6 @@ cython_debug/ .vscode ._build -doc/source/api \ No newline at end of file +doc/source/api +.cov +not_a_dir \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 7c81a443..7110ada8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,10 @@ Releases = "https://github.com/ansys/ansys-tools-common/releases/" [tool.flit.module] name = "ansys.tools.common" +[tool.pytest.ini_options] +addopts = "-ra --cov=ansys.tools.common --cov-report html:.cov/html --cov-report xml:.cov/xml --cov-report term --capture=sys -vv" + + [tool.ruff] line-length = 120 fix = true diff --git a/src/ansys/tools/common/abstractions/__init__.py b/src/ansys/tools/common/abstractions/__init__.py index 14673872..74a8d56a 100644 --- a/src/ansys/tools/common/abstractions/__init__.py +++ b/src/ansys/tools/common/abstractions/__init__.py @@ -22,4 +22,4 @@ """Init module for abstractions.""" from .connection import AbstractGRPCConnection # noqa F401 -from .launcher import AbstractServiceLauncher # noqa F401 +from .launcher import LauncherProtocol # noqa F401 diff --git a/src/ansys/tools/common/abstractions/connection.py b/src/ansys/tools/common/abstractions/connection.py index 4dbbed3a..5f743fb4 100644 --- a/src/ansys/tools/common/abstractions/connection.py +++ b/src/ansys/tools/common/abstractions/connection.py @@ -25,7 +25,7 @@ try: import grpc -except ImportError: +except ImportError: # pragma: no cover import warnings warnings.warn( @@ -47,33 +47,43 @@ class AbstractGRPCConnection(ABC): @abstractmethod def __init__(self, host: str, port: str) -> None: """Initialize the gRPC connection with host and port.""" - pass + pass # pragma: no cover @abstractmethod def connect(self) -> None: """Establish a connection to the gRPC server.""" - pass + pass # pragma: no cover @abstractmethod def close(self) -> None: """Disconnect from the gRPC server.""" - pass + pass # pragma: no cover - @abstractmethod @property + @abstractmethod def service(self): """Return the gRPC stub for making requests.""" - pass + pass # pragma: no cover @property def _host(self) -> str: """Return the host for the gRPC connection.""" - return self._host + return self.__host + + @_host.setter + def _host(self, value: str) -> None: + """Set the host for the gRPC connection.""" + self.__host = value @property def _port(self) -> str: """Return the port for the gRPC connection.""" - return self._port + return self.__port + + @_port.setter + def _port(self, value: str) -> None: + """Set the port for the gRPC connection.""" + self.__port = value @property def _channel(self, options: list = None) -> grpc.Channel: diff --git a/src/ansys/tools/common/abstractions/launcher.py b/src/ansys/tools/common/abstractions/launcher.py index a7464b53..f3bb0cb9 100644 --- a/src/ansys/tools/common/abstractions/launcher.py +++ b/src/ansys/tools/common/abstractions/launcher.py @@ -136,7 +136,7 @@ class LauncherProtocol(Protocol[LAUNCHER_CONFIG_T]): """ def __init__(self, *, config: LAUNCHER_CONFIG_T): - pass + pass # pragma: no cover def start(self) -> None: """Start the product instance.""" diff --git a/src/ansys/tools/common/example_download.py b/src/ansys/tools/common/example_download.py index 3f372c81..14bbdaf1 100644 --- a/src/ansys/tools/common/example_download.py +++ b/src/ansys/tools/common/example_download.py @@ -99,12 +99,12 @@ def download_file( destination_path = Path(destination) if destination is not None else None # If destination is not a dir, create it - if destination_path is not None and not destination_path.is_dir(): + if destination_path is not None and not destination_path.exists(): destination_path.mkdir(parents=True, exist_ok=True) - # Check if it was able to create the dir + # Check if it was able to create the dir, very rare case if destination_path is not None and not destination_path.is_dir(): - raise ValueError("Destination directory provided does not exist") + raise ValueError("Destination directory provided does not exist") # pragma: no cover url = self._get_filepath_on_default_server(filename, directory) local_path = self._retrieve_data(url, filename, dest=destination, force=force) @@ -125,7 +125,8 @@ def _add_file(self, file_path: str): file_path : str Local path of the downloaded example file. """ - self._downloads_list.append(file_path) + if file_path not in self._downloads_list: + self._downloads_list.append(file_path) def _joinurl(self, base: str, directory: str) -> str: """Join multiple paths to a base URL. @@ -185,10 +186,11 @@ def _retrieve_data(self, url: str, filename: str, dest: str = None, force: bool str The local path where the file was saved. """ + local_path = "" if dest is None: dest = tempfile.gettempdir() # Use system temp directory if no destination is provided local_path = Path(dest) / Path(filename).name - if not force and Path.is_file(local_path): + if not force and Path(local_path).is_file(): return local_path try: local_path, _ = urllib.request.urlretrieve(url, filename=local_path) diff --git a/src/ansys/tools/common/launcher/interface.py b/src/ansys/tools/common/launcher/interface.py index a7464b53..f3bb0cb9 100644 --- a/src/ansys/tools/common/launcher/interface.py +++ b/src/ansys/tools/common/launcher/interface.py @@ -136,7 +136,7 @@ class LauncherProtocol(Protocol[LAUNCHER_CONFIG_T]): """ def __init__(self, *, config: LAUNCHER_CONFIG_T): - pass + pass # pragma: no cover def start(self) -> None: """Start the product instance.""" diff --git a/src/ansys/tools/common/versioning.py b/src/ansys/tools/common/versioning.py index cdcd61e2..b44b93d1 100644 --- a/src/ansys/tools/common/versioning.py +++ b/src/ansys/tools/common/versioning.py @@ -112,10 +112,6 @@ def version_string_as_tuple(version_string): # Check version string numbers are numeric by converting to integers version_tuple = tuple(map(VersionNumber, version_string.split("."))) - # Check version numbers are positive integers - if not all(num >= VersionNumber(0) for num in version_tuple): - raise ValueError - except ValueError: raise VersionSyntaxError( "Version string can only contain positive integers following .. versioning." diff --git a/tests/test_connection.py b/tests/test_connection.py new file mode 100644 index 00000000..f7228796 --- /dev/null +++ b/tests/test_connection.py @@ -0,0 +1,89 @@ +# Copyright (C) 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. + +"""Module for testing gRPC connection abstraction.""" + +from unittest.mock import MagicMock + +import pytest + +from ansys.tools.common.abstractions.connection import AbstractGRPCConnection + + +class MockGRPCConnection(AbstractGRPCConnection): + """Mock implementation of AbstractGRPCConnection for testing.""" + + def __init__(self, host: str, port: str) -> None: + """Initialize the mock gRPC connection.""" + self._host = host + self._port = port + self._connected = False + + def connect(self) -> None: + """Connect to the mock gRPC server.""" + self._connected = True + + def close(self) -> None: + """Close the mock gRPC connection.""" + self._connected = False + + @property + def service(self): + """Service property that returns a mock gRPC stub.""" + return MagicMock() + + +@pytest.fixture +def mock_connection(): + """Fixture for creating a mock gRPC connection.""" + return MockGRPCConnection(host="localhost", port="50051") + + +def test_initialization(mock_connection): + """Test initialization of the connection.""" + assert mock_connection._host == "localhost" + assert mock_connection._port == "50051" + assert mock_connection.is_closed + + +def test_connect(mock_connection): + """Test connecting to the gRPC server.""" + mock_connection.connect() + + +def test_close(mock_connection): + """Test disconnecting from the gRPC server.""" + mock_connection.connect() + mock_connection.close() + assert mock_connection.is_closed + + +def test_service_property(mock_connection): + """Test the service property.""" + service = mock_connection.service + assert service is not None + assert isinstance(service, MagicMock) + + +def test_is_closed_property(mock_connection): + """Test the is_closed property.""" + assert mock_connection.is_closed diff --git a/tests/test_example_download.py b/tests/test_example_download.py index 99943e8d..422ef452 100644 --- a/tests/test_example_download.py +++ b/tests/test_example_download.py @@ -38,6 +38,11 @@ def test_download(): assert Path.is_file(local_path) + # Check that file is cached + local_path2 = download_manager.download_file(filename, directory) + + assert local_path2 == local_path + download_manager.clear_download_cache() assert not Path.is_file(local_path) @@ -51,3 +56,33 @@ def test_non_existent_file(): # Attempt to download the non-existent file with pytest.raises(FileNotFoundError): download_manager.download_file(filename, directory) + + +def test_get_filepath(): + """Test getting the file path of a downloaded file.""" + filename = "11_blades_mode_1_ND_0.csv" + directory = "pymapdl/cfx_mapping" + + # Get the file path + filepath = download_manager._get_filepath_on_default_server(filename, directory) + + assert filepath == "https://github.com/ansys/example-data/raw/main/pymapdl/cfx_mapping/11_blades_mode_1_ND_0.csv" + + directory += "/" + filepath = download_manager._get_filepath_on_default_server(filename, directory) + + assert filepath == "https://github.com/ansys/example-data/raw/main/pymapdl/cfx_mapping/11_blades_mode_1_ND_0.csv" + + filepath = download_manager._get_filepath_on_default_server(filename) + + assert filepath == "https://github.com/ansys/example-data/raw/main/11_blades_mode_1_ND_0.csv" + + +def test_destination_directory(): + """Test getting the destination directory for a downloaded file.""" + filename = "11_blades_mode_1_ND_0.csv" + directory = "pymapdl/cfx_mapping" + + # Test directory gets created + result = download_manager.download_file(filename, directory, destination="not_a_dir") + assert result is not None diff --git a/tests/test_logger.py b/tests/test_logger.py new file mode 100644 index 00000000..ae0a7186 --- /dev/null +++ b/tests/test_logger.py @@ -0,0 +1,178 @@ +# Copyright (C) 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. +"""Module for testing the logger.""" + +import logging + +import pytest + +from ansys.tools.common.logger import LOGGER, CustomFormatter, Logger + + +def test_logger_singleton(): + """Test that Logger is a singleton.""" + another_instance = Logger(level=logging.INFO, logger_name="AnotherLogger") + assert LOGGER is another_instance + + +def test_logger_name(): + """Test the name of the logger.""" + assert LOGGER.get_logger().name == "Logger" + + +def test_logger_level(): + """Test setting and getting the logger level.""" + LOGGER.set_level(logging.WARNING) + assert LOGGER.get_logger().level == logging.WARNING + + +def test_logger_enable_output(capsys): + """Test enabling logger output to a stream.""" + LOGGER.enable_output() + LOGGER.set_level(logging.DEBUG) # Set the logger to DEBUG level for testing + LOGGER.info("Test message") + captured = capsys.readouterr() + assert "Test message" in captured.err + + +def test_logger_file_handler(tmp_path): + """Test adding a file handler to the logger.""" + log_dir = tmp_path / "logs" + LOGGER.add_file_handler(log_dir) + LOGGER.set_level(logging.DEBUG) # Set the logger to DEBUG level for testing + LOGGER.info("Test message in file handler") + log_file = next(log_dir.glob("log_*.log")) + assert log_file.exists() + with log_file.open() as f: + content = f.read() + assert "Timestamp" in content + + +def test_custom_formatter_truncation(): + """Test truncation of module and function names in CustomFormatter.""" + formatter = CustomFormatter("%(module)s | %(funcName)s") + assert formatter.max_column_width == 15 # Default width + formatter.set_column_width(10) + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="test_path", + lineno=1, + msg="Test message", + args=None, + exc_info=None, + ) + record.module = "very_long_module_name" + record.funcName = "very_long_function_name" + formatted_message = formatter.format(record) + assert "very_lo..." in formatted_message + assert "very_lo..." in formatted_message + + +def test_custom_formatter_column_width(): + """Test setting and getting column width in CustomFormatter.""" + formatter = CustomFormatter("%(module)s | %(funcName)s") + formatter.set_column_width(12) + assert formatter.max_column_width == 12 + + with pytest.raises(ValueError): + formatter.set_column_width(5) + + +def test_logger_debug(capsys): + """Test the debug method.""" + LOGGER.enable_output() + LOGGER.set_level(logging.DEBUG) # Set the logger to DEBUG level for testing + LOGGER.debug("Debug message") + captured = capsys.readouterr() + assert "DEBUG" in captured.err + assert "Debug message" in captured.err + + +def test_logger_info(capsys): + """Test the info method.""" + LOGGER.enable_output() + LOGGER.set_level(logging.INFO) # Set the logger to DEBUG level for testing + LOGGER.info("Debug message") + captured = capsys.readouterr() + assert "INFO" in captured.err + assert "Debug message" in captured.err + + +def test_logger_warn(capsys): + """Test the warn method.""" + LOGGER.enable_output() + LOGGER.set_level(logging.WARN) # Set the logger to DEBUG level for testing + LOGGER.warn("Debug message") + captured = capsys.readouterr() + assert "WARN" in captured.err + assert "Debug message" in captured.err + + +def test_logger_warning(capsys): + """Test the warning method.""" + LOGGER.enable_output() + LOGGER.set_level(logging.WARNING) # Set the logger to DEBUG level for testing + LOGGER.warning("Debug message") + captured = capsys.readouterr() + assert "WARNING" in captured.err + assert "Debug message" in captured.err + + +def test_logger_error(capsys): + """Test the error method.""" + LOGGER.enable_output() + LOGGER.set_level(logging.ERROR) # Set the logger to DEBUG level for testing + LOGGER.error("Debug message") + captured = capsys.readouterr() + assert "ERROR" in captured.err + assert "Debug message" in captured.err + + +def test_logger_critical(capsys): + """Test the critical method.""" + LOGGER.enable_output() + LOGGER.set_level(logging.CRITICAL) # Set the logger to DEBUG level for testing + LOGGER.critical("Debug message") + captured = capsys.readouterr() + assert "CRITICAL" in captured.err + assert "Debug message" in captured.err + + +def test_logger_fatal(capsys): + """Test the fatal method.""" + LOGGER.enable_output() + LOGGER.set_level(logging.FATAL) # Set the logger to DEBUG level for testing + LOGGER.fatal("Debug message") + captured = capsys.readouterr() + assert "CRITICAL" in captured.err + assert "Debug message" in captured.err + + +def test_logger_log(capsys): + """Test the fatal method.""" + LOGGER.enable_output() + LOGGER.set_level(logging.FATAL) # Set the logger to DEBUG level for testing + LOGGER.log(logging.FATAL, "Debug message") + captured = capsys.readouterr() + assert "CRITICAL" in captured.err + assert "Debug message" in captured.err