diff --git a/fedn/cli/client_cmd.py b/fedn/cli/client_cmd.py index 0d3da5f7e..10511f0d5 100644 --- a/fedn/cli/client_cmd.py +++ b/fedn/cli/client_cmd.py @@ -6,8 +6,8 @@ from fedn.common.exceptions import InvalidClientConfig from fedn.network.clients.client import Client -from .main import main -from .shared import CONTROLLER_DEFAULTS, apply_config, get_api_url, get_token, print_response +from fedn.cli.main import main +from fedn.cli.shared import CONTROLLER_DEFAULTS, apply_config, get_api_url, get_token, print_response def validate_client_config(config): diff --git a/fedn/cli/controller_cmd.py b/fedn/cli/controller_cmd.py index ab8727b27..443628c9a 100644 --- a/fedn/cli/controller_cmd.py +++ b/fedn/cli/controller_cmd.py @@ -1,6 +1,6 @@ import click -from .main import main +from fedn.cli.main import main @main.group("controller") diff --git a/fedn/cli/run_cmd.py b/fedn/cli/run_cmd.py index bf6c5f36c..731f83624 100644 --- a/fedn/cli/run_cmd.py +++ b/fedn/cli/run_cmd.py @@ -10,10 +10,9 @@ from fedn.network.combiner.combiner import Combiner from fedn.utils.dispatcher import Dispatcher, _read_yaml_file -from .client_cmd import validate_client_config -from .main import main -from .shared import apply_config - +from fedn.cli.client_cmd import validate_client_config +from fedn.cli.main import main +from fedn.cli.shared import apply_config def get_statestore_config_from_file(init): """:param init: @@ -36,7 +35,20 @@ def check_helper_config_file(config): exit(-1) return helper - +def check_yaml_exists(path): + """Check if fedn.yaml exists in the given path.""" + yaml_file = os.path.join(path, "fedn.yaml") + if not os.path.exists(yaml_file): + logger.error(f"Could not find fedn.yaml in {path}") + click.echo(f"Could not find fedn.yaml in {path}") + exit(-1) + return yaml_file +def delete_virtual_environment(dispatcher): + if dispatcher.python_env_path: + logger.info(f"Removing virtualenv {dispatcher.python_env_path}") + shutil.rmtree(dispatcher.python_env_path) + else: + logger.warning("No virtualenv found to remove.") @main.group("run") @click.pass_context def run_cmd(ctx): @@ -46,9 +58,10 @@ def run_cmd(ctx): @run_cmd.command("validate") @click.option("-p", "--path", required=True, help="Path to package directory containing fedn.yaml") @click.option("-i", "--input", required=True, help="Path to input model" ) -@click.option("-o", "--output", required=True,help="Path to write the output JSON containing validation metrics") +@click.option("-o", "--output", required=True, help="Path to write the output JSON containing validation metrics") +@click.option("-v", "--keep-venv", is_flag=True, required=False, help="Use flag to keep the python virtual environment (python_env in fedn.yaml)") @click.pass_context -def validate_cmd(ctx, path,input,output): +def validate_cmd(ctx, path, input, output, keep_venv): """Execute 'validate' entrypoint in fedn.yaml. :param ctx: @@ -56,10 +69,7 @@ def validate_cmd(ctx, path,input,output): :type path: str """ path = os.path.abspath(path) - yaml_file = os.path.join(path, "fedn.yaml") - if not os.path.exists(yaml_file): - logger.error(f"Could not find fedn.yaml in {path}") - exit(-1) + yaml_file = check_yaml_exists(path) config = _read_yaml_file(yaml_file) # Check that validate is defined in fedn.yaml under entry_points @@ -70,17 +80,15 @@ def validate_cmd(ctx, path,input,output): dispatcher = Dispatcher(config, path) _ = dispatcher._get_or_create_python_env() dispatcher.run_cmd("validate {} {}".format(input, output)) - - # delete the virtualenv - if dispatcher.python_env_path: - logger.info(f"Removing virtualenv {dispatcher.python_env_path}") - shutil.rmtree(dispatcher.python_env_path) + if not keep_venv: + delete_virtual_environment(dispatcher) @run_cmd.command("train") @click.option("-p", "--path", required=True, help="Path to package directory containing fedn.yaml") @click.option("-i", "--input", required=True, help="Path to input model parameters" ) -@click.option("-o", "--output", required=True,help="Path to write the updated model parameters ") +@click.option("-o", "--output", required=True, help="Path to write the updated model parameters ") +@click.option("-v", "--keep-venv", is_flag=True, required=False, help="Use flag to keep the python virtual environment (python_env in fedn.yaml)") @click.pass_context -def train_cmd(ctx, path,input,output): +def train_cmd(ctx, path, input, output, keep_venv): """Execute 'train' entrypoint in fedn.yaml. :param ctx: @@ -88,10 +96,7 @@ def train_cmd(ctx, path,input,output): :type path: str """ path = os.path.abspath(path) - yaml_file = os.path.join(path, "fedn.yaml") - if not os.path.exists(yaml_file): - logger.error(f"Could not find fedn.yaml in {path}") - exit(-1) + yaml_file = check_yaml_exists(path) config = _read_yaml_file(yaml_file) # Check that train is defined in fedn.yaml under entry_points @@ -102,15 +107,13 @@ def train_cmd(ctx, path,input,output): dispatcher = Dispatcher(config, path) _ = dispatcher._get_or_create_python_env() dispatcher.run_cmd("train {} {}".format(input, output)) - - # delete the virtualenv - if dispatcher.python_env_path: - logger.info(f"Removing virtualenv {dispatcher.python_env_path}") - shutil.rmtree(dispatcher.python_env_path) + if not keep_venv: + delete_virtual_environment(dispatcher) @run_cmd.command("startup") @click.option("-p", "--path", required=True, help="Path to package directory containing fedn.yaml") +@click.option("-v", "--keep-venv", is_flag=True, required=False, help="Use flag to keep the python virtual environment (python_env in fedn.yaml)") @click.pass_context -def startup_cmd(ctx, path): +def startup_cmd(ctx, path, keep_venv): """Execute 'startup' entrypoint in fedn.yaml. :param ctx: @@ -118,30 +121,24 @@ def startup_cmd(ctx, path): :type path: str """ path = os.path.abspath(path) - yaml_file = os.path.join(path, "fedn.yaml") - if not os.path.exists(yaml_file): - logger.error(f"Could not find fedn.yaml in {path}") - exit(-1) + yaml_file = check_yaml_exists(path) config = _read_yaml_file(yaml_file) # Check that startup is defined in fedn.yaml under entry_points if "startup" not in config["entry_points"]: logger.error("No startup command defined in fedn.yaml") exit(-1) - dispatcher = Dispatcher(config, path) _ = dispatcher._get_or_create_python_env() dispatcher.run_cmd("startup") - - # delete the virtualenv - if dispatcher.python_env_path: - logger.info(f"Removing virtualenv {dispatcher.python_env_path}") - shutil.rmtree(dispatcher.python_env_path) + if not keep_venv: + delete_virtual_environment(dispatcher) @run_cmd.command("build") @click.option("-p", "--path", required=True, help="Path to package directory containing fedn.yaml") +@click.option("-v", "--keep-venv", is_flag=True, required=False, help="Use flag to keep the python virtual environment (python_env in fedn.yaml)") @click.pass_context -def build_cmd(ctx, path): +def build_cmd(ctx, path, keep_venv): """Execute 'build' entrypoint in fedn.yaml. :param ctx: @@ -149,10 +146,7 @@ def build_cmd(ctx, path): :type path: str """ path = os.path.abspath(path) - yaml_file = os.path.join(path, "fedn.yaml") - if not os.path.exists(yaml_file): - logger.error(f"Could not find fedn.yaml in {path}") - exit(-1) + yaml_file = check_yaml_exists(path) config = _read_yaml_file(yaml_file) # Check that build is defined in fedn.yaml under entry_points @@ -163,11 +157,8 @@ def build_cmd(ctx, path): dispatcher = Dispatcher(config, path) _ = dispatcher._get_or_create_python_env() dispatcher.run_cmd("build") - - # delete the virtualenv - if dispatcher.python_env_path: - logger.info(f"Removing virtualenv {dispatcher.python_env_path}") - shutil.rmtree(dispatcher.python_env_path) + if not keep_venv: + delete_virtual_environment(dispatcher) @run_cmd.command("client") diff --git a/fedn/cli/tests/tests.py b/fedn/cli/tests/tests.py index dffe90a2c..7cf6878c4 100644 --- a/fedn/cli/tests/tests.py +++ b/fedn/cli/tests/tests.py @@ -1,9 +1,17 @@ import unittest - +import sys +import os +import fedn +from unittest.mock import patch from click.testing import CliRunner from run_cmd import check_helper_config_file +from run_cmd import run_cmd,check_yaml_exists,logger +import click +from main import main +from fedn.network.api.server import start_server_api +from controller_cmd import main, controller_cmd - +MOCK_VERSION = "0.11.1" class TestReducerCLI(unittest.TestCase): def setUp(self): @@ -56,6 +64,221 @@ def test_get_statestore_config_from_file(self): # self.assertEqual(result.output, "--remote was set to False, but no helper was found in --init settings file: settings.yaml\n") # self.assertEqual(result.exit_code, -1) + #testcase for --version in fedn + @patch('main.get_version') + def test_version_output(self, mock_get_version): + # Mock the get_version function to return a predefined version + mock_get_version.return_value = MOCK_VERSION + + runner = CliRunner() + result = runner.invoke(main, ['--version']) + + # Check that the command exits with a status code of 0 + self.assertEqual(result.exit_code, 0) + + # Check that the output contains the mocked version string + expected_output = f"main, version {MOCK_VERSION}\n" + self.assertEqual(result.output, expected_output) + + #train command unit test cases + #To test check yaml function + @patch('run_cmd.os.path.exists') + @patch('run_cmd.click.echo') + def test_yaml_exists(self, mock_click_echo, mock_exists): + path = '/fake/path' + mock_exists.return_value = True + + # Call the function + result = check_yaml_exists(path) + # Assertions + mock_exists.assert_called_once_with(os.path.join(path, 'fedn.yaml')) + self.assertEqual(result, os.path.join(path, 'fedn.yaml')) + mock_click_echo.assert_not_called() + #test missing fedn yaml file + @patch('run_cmd.os.path.exists') + def test_missing_fedn_yaml(self, mock_exists): + mock_exists.return_value = False + result = self.runner.invoke(run_cmd, [ + 'train', + '--path', 'fedn/examples/mnist-pytorch/client', + '--input', 'client.npz', + '--output', 'client' + ]) + self.assertEqual(result.exit_code, -1) + self.assertIn("", result.output) + + #train cmd missing in fedn yaml file + @patch('run_cmd._read_yaml_file') + @patch('run_cmd.logger') + @patch('run_cmd.exit') + @patch('run_cmd.check_yaml_exists') + def test_train_not_defined(self, mock_check_yaml_exists, mock_exit, mock_logger, mock_read_yaml_file): + # Setup the mock to simulate fedn.yaml content without "train" entry point + mock_read_yaml_file.return_value = { + "entry_points": { + "vaidate": "some_train_command" + } + } + mock_check_yaml_exists.return_value = '/fake/path/fedn.yaml' + result = self.runner.invoke(run_cmd, [ + 'train', + '--path', '/fake/path', + '--input', 'input', + '--output', 'output', + '--remove-venv', 'True' + ]) + mock_logger.error.assert_called_once_with("No train command defined in fedn.yaml") + #print("hereeeeee",mock_logger.error.call_count) + log_messages = [call[0][0] for call in mock_logger.error.call_args_list] + #print("Captured log messages:", log_messages) + mock_exit.assert_called_once_with(-1) + + #to test with venv flag as false + @patch('run_cmd.os.path.exists') + @patch('run_cmd.logger') + @patch('run_cmd.Dispatcher') + def test_train_cmd_with_venv_false(self, MockDispatcher,mock_exists,mock_logger): + mock_exists.return_value = True + mock_dispatcher = MockDispatcher.return_value + mock_dispatcher.run_cmd.return_value = None + result = self.runner.invoke(run_cmd, [ + 'train', + '--path', '../../.././fedn/examples/mnist-pytorch/client', + '--input', 'client.npz', + '--output', 'client', + '--remove-venv', 'False' + ]) + + self.assertEqual(result.exit_code, 0) + mock_dispatcher.run_cmd.assert_called_once_with("train client.npz client") + #print(mock_dispatcher.run_cmd.call_count) + +#Validate cmd test cases + @patch('run_cmd._read_yaml_file') + @patch('run_cmd.logger') + @patch('run_cmd.exit') + @patch('run_cmd.check_yaml_exists') + def test_validate_not_defined(self, mock_check_yaml_exists, mock_exit, mock_logger, mock_read_yaml_file): + mock_read_yaml_file.return_value = { + "entry_points": { + "train": "some_train_command" + } + } + mock_check_yaml_exists.return_value = '/fake/path/fedn.yaml' + result = self.runner.invoke(run_cmd, [ + 'validate', + '--path', '/fake/path', + '--input', 'input', + '--output', 'output', + '--remove-venv', 'True' + ]) + + + # Verify that the error was logged + mock_logger.error.assert_called_once_with("No validate command defined in fedn.yaml") + #log_messages = [call[0][0] for call in mock_logger.error.call_args_list] + #print("Captured log messages:", log_messages) + mock_exit.assert_called_once_with(-1) + + #test missing fedn yaml file + @patch('run_cmd.os.path.exists') + def test_missing_fedn_yaml(self, mock_exists): + mock_exists.return_value = False + result = self.runner.invoke(run_cmd, [ + 'vaidate', + '--path', 'fedn/examples/mnist-pytorch/client', + '--input', 'client.npz', + '--output', 'client' + ]) + self.assertEqual(result.exit_code, -1) + self.assertIn("", result.output) + + #Test validate cmd with venv false + @patch('run_cmd.os.path.exists') + @patch('run_cmd.logger') + @patch('run_cmd.Dispatcher') + def test_validate_cmd_with_venv_false(self, MockDispatcher,mock_exists,mock_logger): + mock_exists.return_value = True + mock_dispatcher = MockDispatcher.return_value + mock_dispatcher.run_cmd.return_value = None + result = self.runner.invoke(run_cmd, [ + 'validate', + '--path', '../../.././fedn/examples/mnist-pytorch/client', + '--input', 'client.npz', + '--output', 'client', + '--remove-venv', 'False' + ]) + + self.assertEqual(result.exit_code, 0) + mock_dispatcher.run_cmd.assert_called_once_with("validate client.npz client") + #print(mock_dispatcher.run_cmd.call_count) + +#build cmd test cases + @patch('run_cmd._read_yaml_file') + @patch('run_cmd.logger') + @patch('run_cmd.exit') + @patch('run_cmd.check_yaml_exists') + def test_startup_not_defined(self, mock_check_yaml_exists, mock_exit, mock_logger, mock_read_yaml_file): + mock_read_yaml_file.return_value = { + "entry_points": { + "train": "some_train_command" + } + } + mock_check_yaml_exists.return_value = '/fake/path/fedn.yaml' + runner = CliRunner() + result = runner.invoke(run_cmd, [ + 'startup', + '--path', '/fake/path', + '--remove-venv', 'True' + ]) + + + # Verify that the error was logged + mock_logger.error.assert_called_once_with("No startup command defined in fedn.yaml") + log_messages = [call[0][0] for call in mock_logger.error.call_args_list] + #print("Captured log messages:", log_messages) + mock_exit.assert_called_once_with(-1) + + #test missing fedn yaml file + @patch('run_cmd.os.path.exists') + def test_missing_fedn_yaml(self, mock_exists): + mock_exists.return_value = False + result = self.runner.invoke(run_cmd, [ + 'startup', + '--path', 'fedn/examples/mnist-pytorch/client' + ]) + self.assertEqual(result.exit_code, -1) + self.assertIn("", result.output) + + @patch('run_cmd.os.path.exists') + @patch('run_cmd.logger') + @patch('run_cmd.Dispatcher') + def test_startup_cmd_with_venv_false(self, MockDispatcher,mock_exists,mock_logger): + mock_exists.return_value = True + mock_dispatcher = MockDispatcher.return_value + mock_dispatcher.run_cmd.return_value = None + result = self.runner.invoke(run_cmd, [ + 'startup', + '--path', '../../.././fedn/examples/mnist-pytorch/client', + '--remove-venv', 'False' + ]) + + self.assertEqual(result.exit_code, 0) + mock_dispatcher.run_cmd.assert_called_once_with("startup") + #print(mock_dispatcher.run_cmd.call_count) + + #to test controller start + @patch('fedn.network.api.server.start_server_api') + def test_controller_start(self, mock_start_server_api): + runner = CliRunner() + result = runner.invoke(main, ['controller', 'start']) + + # Check that the command exits with a status code of 0 + self.assertEqual(result.exit_code, 0) + + # Check that the start_server_api function was called + mock_start_server_api.assert_called_once() + #print("hereeee",mock_start_server_api.call_count) def test_check_helper_config_file(self): self.assertEqual(check_helper_config_file( diff --git a/fedn/network/api/gunicorn_app.py b/fedn/network/api/gunicorn_app.py new file mode 100644 index 000000000..8c5cb0c30 --- /dev/null +++ b/fedn/network/api/gunicorn_app.py @@ -0,0 +1,23 @@ +from gunicorn.app.base import BaseApplication +class GunicornApp(BaseApplication): + def __init__(self, app, options=None): + self.options = options or {} + self.application = app + super().__init__() + + def load_config(self): + config = {key: value for key, value in self.options.items() + if key in self.cfg.settings and value is not None} + for key, value in config.items(): + self.cfg.set(key.lower(), value) + + def load(self): + return self.application + +def run_gunicorn(app, host,port,workers=4): + bind_address = f"{host}:{port}" + options = { + "bind": bind_address, # Specify the bind address and port here + "workers": workers, + } + GunicornApp(app, options).run() diff --git a/fedn/network/api/server.py b/fedn/network/api/server.py index d56c3ab0b..ac87ab7b1 100644 --- a/fedn/network/api/server.py +++ b/fedn/network/api/server.py @@ -7,6 +7,7 @@ from fedn.network.api.interface import API from fedn.network.api.shared import control, statestore from fedn.network.api.v1 import _routes +from fedn.network.api import gunicorn_app custom_url_prefix = os.environ.get("FEDN_CUSTOM_URL_PREFIX", False) # statestore_config,modelstorage_config,network_id,control=set_statestore_config() @@ -630,10 +631,12 @@ def list_combiners_data(): def start_server_api(): config = get_controller_config() port = config["port"] - debug = config["debug"] host = "0.0.0.0" - app.run(debug=debug, port=port, host=host) - - + debug = config["debug"] + if debug: + app.run(debug=debug, port=port, host=host) + else: + workers=os.cpu_count() + gunicorn_app.run_gunicorn(app, host, port, workers) if __name__ == "__main__": start_server_api() diff --git a/pyproject.toml b/pyproject.toml index 2bcde6e1d..6b87612c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ requires-python = '>=3.8,<3.13' dependencies = [ "requests", "urllib3>=1.26.4", + "gunicorn>=20.0.4", "minio", "grpcio~=1.60.0", "grpcio-tools~=1.60.0",