diff --git a/CHANGELOG.md b/CHANGELOG.md index c4b1e9185..3a101deb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,10 @@ ## PyAdditive 0.20.0 ### Breaking Changes +* Removed support for multiple server connections. Specifically, the `nservers` parameter was removed from `Additive()` and the `server_connections` parameter was renamed to `channel`. ### New Features -* Added the capability to fetch Additive Server logs. +* Added the capability to fetch Additive Server logs. ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 7a1891969..15f47615a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "flit_core.buildapi" [project] # Check https://flit.readthedocs.io/en/latest/pyproject_toml.html for all available sections name = "ansys-additive-core" -version = "0.20.dev0" +version = "0.20.dev1" description = "A Python client for the Ansys Additive service" readme = "README.rst" requires-python = ">=3.10,<4" diff --git a/src/ansys/additive/core/additive.py b/src/ansys/additive/core/additive.py index b729c68c9..103cf4399 100644 --- a/src/ansys/additive/core/additive.py +++ b/src/ansys/additive/core/additive.py @@ -86,36 +86,21 @@ class Additive: """Provides the client interface to one or more Additive services. - In a typical cloud environment, a single Additive service with load balancing and - auto-scaling is used. The ``Additive`` client connects to the service via a - single connection. However, for atypical environments or when running on localhost, - the ``Additive`` client can perform crude load balancing by connecting to multiple - servers and distributing simulations across them. You can use the ``server_connections``, - ``nservers``, and ``nsims_per_server`` parameters to control the - number of servers to connect to and the number of simulations to run on each - server. - Parameters ---------- - server_connections: list[str, grpc.Channel], None - List of connection definitions for servers. The list may be a combination of strings and - connected :class:`grpc.Channel ` objects. Strings use the format - ``host:port`` to specify the server IPv4 address. + channel: grpc.Channel, default: None + Server connection. If provided, it is assumed that the + :class:`grpc.Channel ` object is connected to the server. + Also, if provided, the ``host`` and ``port`` parameters are ignored. host: str, default: None Host name or IPv4 address of the server. This parameter is ignored if the ``server_channels`` or ``channel`` parameters is other than ``None``. port: int, default: 50052 Port number to use when connecting to the server. nsims_per_server: int, default: 1 - Number of simultaneous simulations to run on each server. Each simulation + Number of simultaneous simulations to run on the server. Each simulation requires a license checkout. If a license is not available, the simulation fails. - nservers: int, default: 1 - Number of Additive servers to start and connect to. This parameter is only - applicable in `PyPIM`_-enabled cloud environments and on localhost. For - this to work on localhost, the Additive portion of the Ansys Structures - package must be installed. This parameter is ignored if the ``server_connections`` - parameter or ``host`` parameter is other than ``None``. product_version: str Version of the Ansys product installation in the form ``"YYR"``, where ``YY`` is the two-digit year and ``R`` is the release number. For example, the release @@ -145,11 +130,11 @@ class Additive: >>> additive = Additive(host="additive.ansys.com", port=12345) - Start and connect to two servers on localhost or in a - `PyPIM`_-enabled cloud environment. Allow each server to run two - simultaneous simulations. + Start and connect to a server on localhost or in a + `PyPIM`_-enabled cloud environment. Allow two simultaneous + simulations on the server. - >>> additive = Additive(nsims_per_server=2, nservers=2) + >>> additive = Additive(nsims_per_server=2) Start a single server on localhost or in a `PyPIM`_-enabled cloud environment. Use version 2024 R1 of the Ansys product installation. @@ -164,11 +149,10 @@ class Additive: def __init__( self, - server_connections: list[str | grpc.Channel] = None, + channel: grpc.Channel | None = None, host: str | None = None, port: int = DEFAULT_ADDITIVE_SERVICE_PORT, nsims_per_server: int = 1, - nservers: int = 1, product_version: str = DEFAULT_PRODUCT_VERSION, log_level: str = "", log_file: str = "", @@ -184,11 +168,10 @@ def __init__( if log_file: LOG.log_to_file(filename=log_file, level=log_level) - self._servers = Additive._connect_to_servers( - server_connections, + self._server = Additive._connect_to_server( + channel, host, port, - nservers, product_version, LOG, linux_install_path, @@ -210,41 +193,64 @@ def __init__( LOG.info("user data path: " + self._user_data_path) @staticmethod - def _connect_to_servers( - server_connections: list[str | grpc.Channel] = None, + def _connect_to_server( + channel: grpc.Channel | None = None, host: str | None = None, port: int = DEFAULT_ADDITIVE_SERVICE_PORT, - nservers: int = 1, product_version: str = DEFAULT_PRODUCT_VERSION, log: logging.Logger = None, linux_install_path: os.PathLike | None = None, - ) -> list[ServerConnection]: - """Connect to Additive servers. + ) -> ServerConnection: + """Connect to an Additive server, starting it if necessary. + + Parameters + ---------- + channel: grpc.Channel, default: None + Server connection. If provided, it is assumed to be connected + and the ``host`` and ``port`` parameters are ignored. + host: str, default: None + Host name or IPv4 address of the server. This parameter is ignored if + the ``channel`` parameter is other than ``None``. + port: int, default: 50052 + Port number to use when connecting to the server. + product_version: str + Version of the Ansys product installation in the form ``"YYR"``, where ``YY`` + is the two-digit year and ``R`` is the release number. For example, "251". + This parameter is only applicable in `PyPIM`_-enabled cloud environments and + on localhost. Using an empty string or ``None`` uses the default product version. + log: logging.Logger, default: None + Logger to use for logging messages. + linux_install_path: os.PathLike, None, default: None + Path to the Ansys installation directory on Linux. This parameter is only + required when Ansys has not been installed in the default location. Example: + ``/usr/shared/ansys_inc``. Note that the path should not include the product + version. + + Returns + ------- + ServerConnection + Connection to the server. + + NOTE: If ``channel`` and ``host`` are not provided and the environment variable + ``ANSYS_ADDITIVE_ADDRESS`` is set, the client will connect to the server at the + address specified by the environment variable. The value of the environment variable + should be in the form ``host:port``. - Start them if necessary. """ - connections = [] - if server_connections: - for target in server_connections: - if isinstance(target, grpc.Channel): - connections.append(ServerConnection(channel=target, log=log)) - else: - connections.append(ServerConnection(addr=target, log=log)) + if channel: + if not isinstance(channel, grpc.Channel): + raise ValueError("channel must be a grpc.Channel object") + return ServerConnection(channel=channel, log=log) elif host: - connections.append(ServerConnection(addr=f"{host}:{port}", log=log)) + return ServerConnection(addr=f"{host}:{port}", log=log) elif os.getenv("ANSYS_ADDITIVE_ADDRESS"): - connections.append(ServerConnection(addr=os.getenv("ANSYS_ADDITIVE_ADDRESS"), log=log)) + return ServerConnection(addr=os.getenv("ANSYS_ADDITIVE_ADDRESS"), log=log) else: - for _ in range(nservers): - connections.append( - ServerConnection( - product_version=product_version, - log=log, - linux_install_path=linux_install_path, - ) - ) - - return connections + return ServerConnection( + product_version=product_version, + log=log, + linux_install_path=linux_install_path, + ) @property def enable_beta_features(self) -> bool: @@ -268,18 +274,28 @@ def about(self) -> str: about = ( f"ansys.additive.core version {__version__}\nClient side API version: {api_version}\n" ) - if self._servers is None: + if self._server is None: about += "Client is not connected to a server.\n" else: - for server in self._servers: - about += str(server.status()) + "\n" + about += str(self._server.status()) + "\n" return about - def apply_server_settings(self, settings: dict[str, str]) -> dict[str, list[str]]: + def apply_server_settings(self, settings: dict[str, str]) -> list[str]: """Apply settings to each server. Current settings include: - ``NumConcurrentSims``: number of concurrent simulations per server. + + Parameters + ---------- + settings: dict[str, str] + Dictionary of settings to apply to the server. + + Returns + ------- + list[str] + List of messages from the server. + """ request = SettingsRequest() for setting_key, setting_value in settings.items(): @@ -287,29 +303,17 @@ def apply_server_settings(self, settings: dict[str, str]) -> dict[str, list[str] setting.key = setting_key setting.value = setting_value - responses = {} - for server in self._servers: - responses[server.channel_str] = server.settings_stub.ApplySettings(request) - - unpacked_responses = {} - for key, value in responses.items(): - unpacked_responses[key] = value.messages - - return unpacked_responses + response = self._server.settings_stub.ApplySettings(request) - def list_server_settings(self) -> dict[str, dict[str, str]]: - """Get a dictionary of settings for each server by channel.""" - responses = {} - for server in self._servers: - responses[server.channel_str] = server.settings_stub.ListSettings(Empty()) + return response.messages - unpacked_responses = {} - for key, list_response in responses.items(): - unpacked_responses[key] = {} - for setting in list_response.settings: - unpacked_responses[key][setting.key] = setting.value - - return unpacked_responses + def list_server_settings(self) -> dict[str, str]: + """Get a dictionary of settings for the server.""" + response = self._server.settings_stub.ListSettings(Empty()) + settings = {} + for setting in response.settings: + settings[setting.key] = setting.value + return settings def simulate( self, @@ -399,8 +403,7 @@ def simulate_async( if not isinstance(inputs, list): if not progress_handler: progress_handler = DefaultSingleSimulationProgressHandler() - server = self._servers[0] - simulation_task = self._simulate(inputs, server, progress_handler) + simulation_task = self._simulate(inputs, self._server, progress_handler) task_manager.add_task(simulation_task) return task_manager @@ -408,10 +411,8 @@ def simulate_async( raise ValueError("No simulation inputs provided") LOG.info(f"Starting {len(inputs)} simulations") - for i, sim_input in enumerate(inputs): - server_id = i % len(self._servers) - server = self._servers[server_id] - task = self._simulate(sim_input, server, progress_handler) + for sim_input in inputs: + task = self._simulate(sim_input, self._server, progress_handler) task_manager.add_task(task) return task_manager @@ -489,7 +490,7 @@ def materials_list(self) -> list[str]: Names of available additive materials. """ - response = self._servers[0].materials_stub.GetMaterialsList(Empty()) + response = self._server.materials_stub.GetMaterialsList(Empty()) return response.names def material(self, name: str) -> AdditiveMaterial: @@ -507,7 +508,7 @@ def material(self, name: str) -> AdditiveMaterial: """ request = GetMaterialRequest(name=name) - result = self._servers[0].materials_stub.GetMaterial(request) + result = self._server.materials_stub.GetMaterial(request) return AdditiveMaterial._from_material_message(result) @staticmethod @@ -593,7 +594,7 @@ def add_material( request = AddMaterialRequest(id=misc.short_uuid(), material=material._to_material_message()) LOG.info(f"Adding material {request.material.name}") - response = self._servers[0].materials_stub.AddMaterial(request) + response = self._server.materials_stub.AddMaterial(request) if response.HasField("error"): raise RuntimeError(response.error) @@ -612,7 +613,7 @@ def remove_material(self, name: str): if name.lower() in (material.lower() for material in RESERVED_MATERIAL_NAMES): raise ValueError(f"Unable to remove Ansys-supplied material '{name}'.") - self._servers[0].materials_stub.RemoveMaterial(RemoveMaterialRequest(name=name)) + self._server.materials_stub.RemoveMaterial(RemoveMaterialRequest(name=name)) def tune_material( self, @@ -692,9 +693,9 @@ def tune_material_async( request = input._to_request() - operation = self._servers[0].materials_stub.TuneMaterial(request) + operation = self._server.materials_stub.TuneMaterial(request) - return SimulationTask(self._servers[0], operation, input, out_dir) + return SimulationTask(self._server, operation, input, out_dir) def simulate_study( self, @@ -819,17 +820,20 @@ def _check_for_duplicate_id(self, inputs): raise ValueError(f'Duplicate simulation ID "{i.id}" in input list') ids.append(i.id) - def download_server_logs(self, out_dir: str | os.PathLike): + def download_server_logs(self, log_dir: str | os.PathLike) -> str: """Download server logs to a specified directory. Parameters ---------- - out_dir : str + log_dir : str Directory to save the logs to. + Returns + ------- + str + Path to the downloaded logs. + """ - for server in self._servers: - local_out_dir = os.path.join( - out_dir, "AdditiveServerLogs", server.channel_str.replace(":", "_") - ) - download_logs(server.simulation_stub, local_out_dir) + if log_dir and not os.path.exists(log_dir): + os.makedirs(log_dir) + return download_logs(self._server.simulation_stub, log_dir) diff --git a/src/ansys/additive/core/download.py b/src/ansys/additive/core/download.py index c493a6c85..f78bd5fb5 100644 --- a/src/ansys/additive/core/download.py +++ b/src/ansys/additive/core/download.py @@ -21,6 +21,7 @@ # SOFTWARE. """Provides a function for downloading files from the server to the client.""" +import datetime import hashlib import os @@ -96,9 +97,11 @@ def download_logs( os.makedirs(local_folder) request = DownloadLogsRequest() - dest = os.path.join(local_folder, "AdditiveServerLogs.zip") + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + dest = os.path.join(local_folder, f"additive-server-logs-{timestamp}.zip") + response = stub.DownloadLogs(request) - handle_download_file_response(dest, stub.DownloadLogs(request), progress_handler) + handle_download_file_response(dest, response, progress_handler) return dest diff --git a/tests/test_additive.py b/tests/test_additive.py index c11b1e2e7..1bd0e8df4 100644 --- a/tests/test_additive.py +++ b/tests/test_additive.py @@ -21,7 +21,6 @@ # SOFTWARE. import logging -import os import pathlib from unittest import mock from unittest.mock import ( @@ -116,42 +115,40 @@ ("", DEFAULT_PRODUCT_VERSION), ], ) -def test_Additive_init_calls_connect_to_servers_correctly( +def test_Additive_init_calls_connect_to_server_correctly( monkeypatch: pytest.MonkeyPatch, in_prod_version, expected_prod_version ): # arrange server_connections = ["connection1", "connection2"] host = "hostname" port = 12345 - nservers = 3 - mock_server_connections = [Mock(ServerConnection)] + mock_server_connection = Mock(ServerConnection) mock_connect = create_autospec( - ansys.additive.core.additive.Additive._connect_to_servers, - return_value=mock_server_connections, + ansys.additive.core.additive.Additive._connect_to_server, + return_value=mock_server_connection, ) - monkeypatch.setattr(ansys.additive.core.additive.Additive, "_connect_to_servers", mock_connect) + monkeypatch.setattr(ansys.additive.core.additive.Additive, "_connect_to_server", mock_connect) # act additive = Additive( server_connections, host, port, - nservers=nservers, product_version=in_prod_version, linux_install_path=None, ) # assert mock_connect.assert_called_with( - server_connections, host, port, nservers, expected_prod_version, ANY, None + server_connections, host, port, expected_prod_version, ANY, None ) - assert additive._servers == mock_server_connections + assert additive._server == mock_server_connection assert additive._user_data_path == USER_DATA_PATH @patch("ansys.additive.core.additive.ServerConnection") -def test_Additive_init_assigns_nsims_per_servers(server): +def test_Additive_init_assigns_nsims_per_server(server): # arrange nsims_per_server = 99 @@ -202,7 +199,7 @@ def test_apply_server_settings_returns_appropriate_responses(server): # assert assert mock_connection_with_stub.settings_stub.ApplySettings.call_count == 2 assert mock_connection_with_stub.settings_stub.ApplySettings.call_args[0][0] == expected_request - assert result[channel_str] == ["applied"] + assert result == ["applied"] @patch("ansys.additive.core.additive.ServerConnection") @@ -212,7 +209,6 @@ def test_list_server_settings_returns_appropriate_responses(server): # a NonCallable mock). So we manually create the mock and test the calls manually. channel_str = "1.1.1.1" mock_connection_with_stub = Mock(ServerConnection) - mock_connection_with_stub.channel_str = channel_str response = ListSettingsResponse() setting = response.settings.add() setting.key = "key" @@ -228,44 +224,34 @@ def test_list_server_settings_returns_appropriate_responses(server): # assert assert mock_connection_with_stub.settings_stub.ListSettings.call_count == 1 - assert result[channel_str] == {"key": "value"} + assert result == {"key": "value"} @patch("ansys.additive.core.additive.ServerConnection") -def test_connect_to_servers_with_server_connections_creates_server_connections( +def test_connect_to_server_with_channel_creates_server_connection( mock_connection, ): # arrange mock_connection.return_value = Mock(ServerConnection) host1 = "localhost:1234" - host2 = "localhost:5678" channel = grpc.insecure_channel("target") - connections = [host1, channel, host2] log = logging.Logger("testlogger") # act - servers = Additive._connect_to_servers( - server_connections=connections, + server = Additive._connect_to_server( + channel=channel, host=host1, port=99999, - nservers=92, log=log, ) # assert - assert len(servers) == len(connections) - assert len(mock_connection.mock_calls) == 3 - mock_connection.assert_has_calls( - [ - call(addr=host1, log=log), - call(channel=channel, log=log), - call(addr=host2, log=log), - ] - ) + assert server is not None + mock_connection.assert_called_once_with(channel=channel, log=log) @patch("ansys.additive.core.additive.ServerConnection") -def test_connect_to_servers_with_host_creates_server_connection(mock_connection): +def test_connect_to_server_with_host_creates_server_connection(mock_connection): # arrange mock_connection.return_value = Mock(ServerConnection) host = "127.0.0.1" @@ -273,17 +259,15 @@ def test_connect_to_servers_with_host_creates_server_connection(mock_connection) log = logging.Logger("testlogger") # act - servers = Additive._connect_to_servers( - server_connections=None, host=host, port=port, nservers=99, log=log - ) + server = Additive._connect_to_server(channel=None, host=host, port=port, log=log) # assert - assert len(servers) == 1 + assert server is not None mock_connection.assert_called_once_with(addr=f"{host}:{port}", log=log) @patch("ansys.additive.core.additive.ServerConnection") -def test_connect_to_servers_with_env_var_creates_server_connection( +def test_connect_to_server_with_env_var_creates_server_connection( mock_connection, monkeypatch: pytest.MonkeyPatch ): # arrange @@ -293,42 +277,18 @@ def test_connect_to_servers_with_env_var_creates_server_connection( log = logging.Logger("testlogger") # act - servers = Additive._connect_to_servers(server_connections=None, host=None, nservers=99, log=log) + server = Additive._connect_to_server(log=log) # assert - assert len(servers) == 1 + assert server is not None mock_connection.assert_called_once_with(addr=addr, log=log) -@patch("ansys.additive.core.additive.ServerConnection") -def test_connect_to_servers_with_nservers_creates_server_connections(mock_connection): - # arrange - nservers = 99 - product_version = "123" - mock_connection.return_value = Mock(ServerConnection) - log = logging.Logger("testlogger") - - # act - servers = Additive._connect_to_servers( - server_connections=None, - host=None, - nservers=nservers, - product_version=product_version, - log=log, - ) - - # assert - assert len(servers) == nservers - mock_connection.assert_called_with( - product_version=product_version, log=log, linux_install_path=None - ) - - def test_about_prints_not_connected_message(): # arrange mock_additive = MagicMock() mock_additive.about = Additive.about - mock_additive._servers = None + mock_additive._server = None # act about = mock_additive.about(mock_additive) @@ -345,11 +305,9 @@ def test_about_prints_server_status_messages(): # arrange mock_additive = MagicMock() mock_additive.about = Additive.about - servers = [] - for i in range(5): - servers.append(Mock(ServerConnection)) - servers[i].status.return_value = f"server {i} running" - mock_additive._servers = servers + mockServer = Mock(ServerConnection) + mockServer.status.return_value = f"server status" + mock_additive._server = mockServer # act about = mock_additive.about(mock_additive) @@ -359,8 +317,7 @@ def test_about_prints_server_status_messages(): f"ansys.additive.core version {__version__}\nClient side API version: {api_version}" in about ) - for i in range(len(servers)): - assert f"server {i} running" in about + assert f"server status" in about @pytest.mark.parametrize( @@ -592,53 +549,6 @@ def test_simulate_study_performs_expected_steps(_, tmp_path: pathlib.Path): assert isinstance(mock_task_mgr.status.call_args[0][0], ParametricStudyProgressHandler) -@pytest.mark.parametrize( - "inputs, nservers, nsims_per_server, expected_n_threads", - [ - ( - [ - SingleBeadInput(), - PorosityInput(), - MicrostructureInput(), - ThermalHistoryInput(), - SingleBeadInput(), - ], - 2, - 2, - 4, - ), - ( - [ - SingleBeadInput(), - PorosityInput(), - MicrostructureInput(), - ThermalHistoryInput(), - ], - 3, - 2, - 4, - ), - ], -) -@patch("ansys.additive.core.additive.ServerConnection") -def test_simulate_with_n_servers_uses_uses_n_mock_connections( - mock_connection, inputs, nservers, nsims_per_server, expected_n_threads -): - # arrange - mock_connection.return_value = Mock(ServerConnection) - - additive = Additive(nservers=nservers, nsims_per_server=nsims_per_server) - - # act - try: - additive.simulate(inputs) - except Exception: - pass - - # assert - assert mock_connection.call_count == nservers - - # patch needed for Additive() call @patch("ansys.additive.core.additive.ServerConnection") def test_simulate_with_duplicate_simulation_ids_raises_exception(_): @@ -748,7 +658,6 @@ def test_internal_simulate_called_with_single_input_updates_SimulationTask( mock_connection.return_value = mock_connection_with_stub additive = Additive( - server_connections=[mock_connection_with_stub], enable_beta_features=True, nsims_per_server=2, ) @@ -877,7 +786,7 @@ def test_internal_simulate_returns_errored_operation_from_server(mock_server): mock_connection_with_stub.simulation_stub.Simulate.return_value = errored_operation mock_connection_with_stub.channel_str = "1.1.1.1" mock_server.return_value = mock_connection_with_stub - additive = Additive(server_connections=["1.1.1.1"], nsims_per_server=1) + additive = Additive(nsims_per_server=1) # act task = additive._simulate(input, mock_connection_with_stub) @@ -1414,41 +1323,20 @@ def test_3d_microstructure_without_beta_enabled_raises_exception(_): @patch("ansys.additive.core.additive.download_logs") @patch("ansys.additive.core.additive.ServerConnection") -def test_download_server_logs_calls_download_logs(mock_connection, mock_download_logs, tmp_path: pathlib.Path): +def test_download_server_logs_calls_download_logs( + mock_connection, mock_download_logs, tmp_path: pathlib.Path +): # arrange mock_server = Mock(ServerConnection) mock_server.channel_str = "1.1.1.1:50052" mock_connection.return_value = mock_server - additive = Additive(server_connections=[mock_server]) - out_dir = tmp_path / "logs" - - # act - additive.download_server_logs(out_dir) - - # assert - expected_local_out_dir = os.path.join(out_dir, "AdditiveServerLogs", "1.1.1.1_50052") - mock_download_logs.assert_called_once_with(mock_server.simulation_stub, expected_local_out_dir) - - -@patch("ansys.additive.core.additive.download_logs") -@patch("ansys.additive.core.additive.ServerConnection") -def test_download_server_logs_handles_multiple_servers(mock_connection, mock_download_logs, tmp_path: pathlib.Path): - # arrange - mock_server1 = Mock(ServerConnection) - mock_server1.channel_str = "1.1.1.1:50052" - mock_server2 = Mock(ServerConnection) - mock_server2.channel_str = "2.2.2.2:50052" - mock_connection.side_effect = [mock_server1, mock_server2] - additive = Additive(server_connections=[mock_server1, mock_server2]) + mock_download_logs.return_value = "additive-server-logs.zip" + additive = Additive() out_dir = tmp_path / "logs" # act - additive.download_server_logs(out_dir) + log_file = additive.download_server_logs(out_dir) # assert - expected_local_out_dir1 = os.path.join(out_dir, "AdditiveServerLogs", "1.1.1.1_50052") - expected_local_out_dir2 = os.path.join(out_dir, "AdditiveServerLogs", "2.2.2.2_50052") - mock_download_logs.assert_any_call(mock_server1.simulation_stub, expected_local_out_dir1) - mock_download_logs.assert_any_call(mock_server2.simulation_stub, expected_local_out_dir2) - assert mock_download_logs.call_count == 2 - + mock_download_logs.assert_called_once_with(mock_server.simulation_stub, out_dir) + assert log_file == "additive-server-logs.zip"