diff --git a/docs/docs/SUMMARY.md b/docs/docs/SUMMARY.md index da5deb947..d0f625cff 100644 --- a/docs/docs/SUMMARY.md +++ b/docs/docs/SUMMARY.md @@ -80,10 +80,12 @@ search: - [get_import_string](api/fastagency/cli/discover/get_import_string.md) - [get_module_data_from_path](api/fastagency/cli/discover/get_module_data_from_path.md) - [import_from_string](api/fastagency/cli/discover/import_from_string.md) - - exceptions - - [FastAgencyCLIError](api/fastagency/cli/exceptions/FastAgencyCLIError.md) - logging - [setup_logging](api/fastagency/cli/logging/setup_logging.md) + - exceptions + - [FastAgencyCLIError](api/fastagency/exceptions/FastAgencyCLIError.md) + - [FastAgencyCLIPythonVersionError](api/fastagency/exceptions/FastAgencyCLIPythonVersionError.md) + - [FastAgencyError](api/fastagency/exceptions/FastAgencyError.md) - logging - [get_logger](api/fastagency/logging/get_logger.md) - runtime diff --git a/docs/docs/en/api/fastagency/cli/exceptions/FastAgencyCLIError.md b/docs/docs/en/api/fastagency/exceptions/FastAgencyCLIError.md similarity index 69% rename from docs/docs/en/api/fastagency/cli/exceptions/FastAgencyCLIError.md rename to docs/docs/en/api/fastagency/exceptions/FastAgencyCLIError.md index d00c07f9b..8a88d38c3 100644 --- a/docs/docs/en/api/fastagency/cli/exceptions/FastAgencyCLIError.md +++ b/docs/docs/en/api/fastagency/exceptions/FastAgencyCLIError.md @@ -8,4 +8,4 @@ search: boost: 0.5 --- -::: fastagency.cli.exceptions.FastAgencyCLIError +::: fastagency.exceptions.FastAgencyCLIError diff --git a/docs/docs/en/api/fastagency/exceptions/FastAgencyCLIPythonVersionError.md b/docs/docs/en/api/fastagency/exceptions/FastAgencyCLIPythonVersionError.md new file mode 100644 index 000000000..7a8df6dcd --- /dev/null +++ b/docs/docs/en/api/fastagency/exceptions/FastAgencyCLIPythonVersionError.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: fastagency.exceptions.FastAgencyCLIPythonVersionError diff --git a/docs/docs/en/api/fastagency/exceptions/FastAgencyError.md b/docs/docs/en/api/fastagency/exceptions/FastAgencyError.md new file mode 100644 index 000000000..c65fe29b1 --- /dev/null +++ b/docs/docs/en/api/fastagency/exceptions/FastAgencyError.md @@ -0,0 +1,11 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 0.5 +--- + +::: fastagency.exceptions.FastAgencyError diff --git a/fastagency/cli/cli.py b/fastagency/cli/cli.py index 31d75d2a9..d204dd521 100644 --- a/fastagency/cli/cli.py +++ b/fastagency/cli/cli.py @@ -5,8 +5,8 @@ import typer from .. import __version__ +from ..exceptions import FastAgencyCLIError, FastAgencyCLIPythonVersionError from .discover import get_import_string -from .exceptions import FastAgencyCLIError from .logging import setup_logging app = typer.Typer(rich_markup_mode="rich") @@ -57,8 +57,13 @@ def _run_app( initial_message=initial_message, single_run=single_run, ) + except FastAgencyCLIPythonVersionError as e: + msg = e.args[0] + typer.echo(msg, err=True) + raise typer.Exit(code=1) # noqa: B904 except FastAgencyCLIError as e: - logger.error(str(e)) + msg = e.args[0] + typer.echo(msg, err=True) raise typer.Exit(code=1) from None diff --git a/fastagency/cli/discover.py b/fastagency/cli/discover.py index 733766b1a..a6a8473ac 100644 --- a/fastagency/cli/discover.py +++ b/fastagency/cli/discover.py @@ -12,7 +12,7 @@ from rich.tree import Tree from .. import FastAgency -from .exceptions import FastAgencyCLIError +from ..exceptions import FastAgencyCLIError logger = getLogger(__name__) diff --git a/fastagency/cli/exceptions.py b/fastagency/cli/exceptions.py deleted file mode 100644 index e3441bf6b..000000000 --- a/fastagency/cli/exceptions.py +++ /dev/null @@ -1,2 +0,0 @@ -class FastAgencyCLIError(Exception): - pass diff --git a/fastagency/exceptions.py b/fastagency/exceptions.py new file mode 100644 index 000000000..094bb2bc3 --- /dev/null +++ b/fastagency/exceptions.py @@ -0,0 +1,10 @@ +class FastAgencyError(Exception): + pass + + +class FastAgencyCLIError(FastAgencyError): + pass + + +class FastAgencyCLIPythonVersionError(FastAgencyCLIError): + pass diff --git a/fastagency/studio/models/llms/together.py b/fastagency/studio/models/llms/together.py index 8d208f7c8..14d28bc87 100644 --- a/fastagency/studio/models/llms/together.py +++ b/fastagency/studio/models/llms/together.py @@ -36,10 +36,9 @@ "Gemma-2 Instruct (9B)": "google/gemma-2-9b-it", "Meta Llama 3 8B Instruct Reference": "meta-llama/Llama-3-8b-chat-hf", "Meta Llama 3.1 70B Instruct Turbo": "albert/meta-llama-3-1-70b-instruct-turbo", - "Meta Llama 3.1 8B Instruct Turbo": "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo", "WizardLM-2 (8x22B)": "microsoft/WizardLM-2-8x22B", "Mixtral-8x7B Instruct v0.1": "mistralai/Mixtral-8x7B-Instruct-v0.1", - "Meta Llama 3.1 405B Instruct Turbo": "meta-llama/Meta-Llama-3.1-405B-Instruct-Lite-Pro", + "Meta Llama 3.1 405B Instruct Turbo": "meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo", "Meta Llama 3 70B Instruct Reference": "meta-llama/Llama-3-70b-chat-hf", "LLaVa-Next (Mistral-7B)": "llava-hf/llava-v1.6-mistral-7b-hf", "DBRX Instruct": "databricks/dbrx-instruct", @@ -47,6 +46,7 @@ "Meta Llama 3 8B Instruct Turbo": "meta-llama/Meta-Llama-3-8B-Instruct-Turbo", "Meta Llama 3 8B Instruct Lite": "meta-llama/Meta-Llama-3-8B-Instruct-Lite", "Meta Llama 3.1 8B Instruct": "meta-llama/Meta-Llama-3.1-8B-Instruct-Reference", + "Meta Llama 3.1 8B Instruct Turbo": "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo", "Mixtral-8x22B Instruct v0.1": "mistralai/Mixtral-8x22B-Instruct-v0.1", "Gryphe MythoMax L2 Lite (13B)": "Gryphe/MythoMax-L2-13b-Lite", "Hermes 3 - Llama-3.1 405B": "NousResearch/Hermes-3-Llama-3.1-405B-Turbo", diff --git a/fastagency/ui/mesop/__init__.py b/fastagency/ui/mesop/__init__.py index f05aa1303..3fcaf912c 100644 --- a/fastagency/ui/mesop/__init__.py +++ b/fastagency/ui/mesop/__init__.py @@ -1,3 +1,10 @@ +import sys + +from ...exceptions import FastAgencyCLIPythonVersionError + +if sys.version_info < (3, 10): + raise FastAgencyCLIPythonVersionError("Mesop requires Python 3.10 or higher") + from .base import MesopUI __all__ = ["MesopUI"] diff --git a/fastagency/ui/mesop/base.py b/fastagency/ui/mesop/base.py index d7b851dfa..c2d978880 100644 --- a/fastagency/ui/mesop/base.py +++ b/fastagency/ui/mesop/base.py @@ -1,4 +1,3 @@ -import sys import threading from collections.abc import Generator, Iterator from contextlib import contextmanager @@ -9,12 +8,6 @@ from typing import ClassVar, Optional from uuid import uuid4 -import typer - -if sys.version_info < (3, 10): - typer.echo("Error: Mesop requires Python 3.10 or higher", err=True) - raise typer.Exit(code=1) - from mesop.bin.bin import FLAGS as MESOP_FLAGS from mesop.bin.bin import main as mesop_main @@ -86,8 +79,8 @@ def app(self) -> Runnable: @contextmanager def create(self, app: Runnable, import_string: str) -> Iterator[None]: logger.info(f"Creating MesopUI with import string: {import_string}") - self._app = app - self._import_string = import_string + MesopUI._app = app + MesopUI._import_string = import_string start_script = """import fastagency.ui.mesop.main""" @@ -290,26 +283,3 @@ def conversation_worker(ui: MesopUI, subconversation: MesopUI) -> None: thread.start() return subconversation - - -# ui.process_message( -# IOMessage.create( -# sender="tester", -# recipient="workflow", -# type="text_input", -# prompt="What is your fvourite fruit", -# suggestions=["Pomegratten", "I do not eat fruit"], -# ) -# ) - -# ui.process_message( -# IOMessage.create( -# sender="tester", -# recipient="workflow", -# type="multiple_choice", -# prompt="Concentrate and choose correct answer. When are you going to write unit tests?", -# choices=["Today", "Tomorrow", "Never", "I already have unit tests"], -# default="Tomorrow", -# single=False, -# ) -# ) diff --git a/tests/cli/test_discover.py b/tests/cli/test_discover.py index 7fc8541d3..2bd5c743c 100644 --- a/tests/cli/test_discover.py +++ b/tests/cli/test_discover.py @@ -1,5 +1,9 @@ import os -from collections.abc import Generator +import random +import shutil +import sys +from collections.abc import Generator, Iterator +from contextlib import contextmanager from pathlib import Path from tempfile import TemporaryDirectory from typing import Any @@ -9,22 +13,39 @@ from fastagency.app import FastAgency # Import the function we want to test -from fastagency.cli.discover import get_import_string, import_from_string +from fastagency.cli.discover import ( + ModuleData, + get_app_name, + get_default_path, + get_import_string, + get_module_data_from_path, + import_from_string, +) +from fastagency.exceptions import FastAgencyCLIError -@pytest.fixture(scope="module") -def import_fixture() -> Generator[dict[str, Any], None, None]: +@contextmanager +def _import_fixture( + mock_app: bool = False, missing_init: bool = False, syntax_error: bool = False +) -> Generator[dict[str, Any], None, None]: # Create a temporary file for testing - file_content = """ + main_content = f""" +from unittest.mock import MagicMock from fastagency.ui.console import ConsoleUI from fastagency.runtime.autogen.base import AutoGenWorkflows -from fastagency import FastAgency +{'frim fastagency import FastAgency' if syntax_error else 'from fastagency import FastAgency'} + wf = AutoGenWorkflows() -app = FastAgency(wf=wf, ui=ConsoleUI()) +app = {'FastAgency(wf=wf, ui=ConsoleUI())' if not mock_app else 'MagicMock()'} """ + + init_content = """ +from .test_app import app +""" + with TemporaryDirectory() as tmp_dir: try: # save old working directory @@ -34,34 +55,208 @@ def import_fixture() -> Generator[dict[str, Any], None, None]: os.chdir(tmp_dir) # Write the content to a temporary Python file - app_path = Path("test/test_app.py") - init_path = Path("test/__init__.py") + suffix = random.randint(1_000_000_000, 1_000_000_000_000 - 1) + mod_name = f"test-{suffix:d}" + app_path = Path(mod_name) / "test_app.py" + init_path = Path(mod_name) / "__init__.py" app_path.parent.mkdir(parents=True, exist_ok=True) with app_path.open("w") as f: - f.write(file_content) + f.write(main_content) - with init_path.open("w") as f: - f.write("") + if not missing_init: + with init_path.open("w") as f: + f.write(init_content) # Yield control back to the tests - import_string, _ = get_import_string(path=app_path, app_name="app") + # import_string, _ = get_import_string(path=app_path, app_name="app") yield { - "import_string": import_string, - "path": "test/test_app.py", + "import_string": f"{mod_name}.test_app:app", + "path": f"{mod_name}/test_app.py", + "init_path": f"{mod_name}/__init__.py", "app_name": "app", + "mod_name": mod_name, } finally: # Restore the old working directory os.chdir(old_cwd) +@pytest.fixture +def import_fixture() -> Generator[dict[str, Any], None, None]: + with _import_fixture() as fixture: + yield fixture + + +@pytest.fixture +def import_fixture_mocked_app() -> Generator[dict[str, Any], None, None]: + with _import_fixture(mock_app=True) as fixture: + yield fixture + + +@pytest.fixture +def import_fixture_missing_init() -> Generator[dict[str, Any], None, None]: + with _import_fixture(missing_init=True) as fixture: + yield fixture + + +@pytest.fixture +def import_fixture_syntax_error() -> Generator[dict[str, Any], None, None]: + with _import_fixture(syntax_error=True) as fixture: + yield fixture + + +class TestGetAppName: + @contextmanager + def _get_mod_data(self, path: str) -> Iterator[ModuleData]: + mod_data = get_module_data_from_path(Path(path)) + system_path_updated = False + try: + sys.path.insert(0, str(mod_data.extra_sys_path.resolve())) + system_path_updated = True + yield mod_data + finally: + if system_path_updated: + sys.path.remove(str(mod_data.extra_sys_path.resolve())) + + @pytest.mark.parametrize("use_app_name", [True, False]) + def test_get_app_name_success( + self, import_fixture: dict[str, Any], use_app_name: bool + ) -> None: + """Test that the app name is correct.""" + path, app_name = import_fixture["path"], import_fixture["app_name"] + with self._get_mod_data(path) as mod_data: + if use_app_name: + app_name, app = get_app_name(mod_data=mod_data, app_name=app_name) + else: + app_name, app = get_app_name(mod_data=mod_data) + assert app_name == "app" + assert app is not None, "The app should be imported successfully." + assert isinstance( + app, FastAgency + ), "The imported object should be a FastAgency object." + + def test_get_app_name_syntax_error( + self, import_fixture_syntax_error: dict[str, Any] + ) -> None: + path, app_name = ( + import_fixture_syntax_error["path"], + import_fixture_syntax_error["app_name"], + ) + with self._get_mod_data(path) as mod_data: # noqa: SIM117 + with pytest.raises(SyntaxError, match="invalid syntax"): + get_app_name(mod_data=mod_data, app_name=app_name) + + def test_get_app_name_import_error( + self, import_fixture_syntax_error: dict[str, Any] + ) -> None: + with self._get_mod_data("something_random") as mod_data: # noqa: SIM117 + with pytest.raises(ImportError, match="No module named 'something_random'"): + get_app_name(mod_data=mod_data) + + def test_get_app_name_mocked_app( + self, import_fixture_mocked_app: dict[str, Any] + ) -> None: + path = import_fixture_mocked_app["path"] + app_name = import_fixture_mocked_app["app_name"] + mod_name = import_fixture_mocked_app["mod_name"] + with self._get_mod_data(path) as mod_data: # noqa: SIM117 + with pytest.raises( + FastAgencyCLIError, + match=f"The app name app in {mod_name}.test_app doesn't seem to be a FastAgency app", + ): + app_name, app = get_app_name(mod_data=mod_data, app_name=app_name) + + class TestGetImportString: - def test_get_import_string(self, import_fixture: dict[str, Any]) -> None: + def test_get_import_string_success(self, import_fixture: dict[str, Any]) -> None: """Test that the import string is correct.""" path, app_name = import_fixture["path"], import_fixture["app_name"] import_string, app = get_import_string(path=Path(path), app_name=app_name) - assert import_string == "test.test_app:app" + mod_name = import_fixture["mod_name"] + assert import_string == f"{mod_name}.test_app:app" + assert app is not None, "The app should be imported successfully." + assert isinstance( + app, FastAgency + ), "The imported object should be a FastAgency object." + + @pytest.mark.skip("This test is not working as expected.") + def test_get_import_string_default_path_success( + self, import_fixture: dict[str, Any] + ) -> None: + """Test that the import string is correct.""" + path, _ = import_fixture["path"], import_fixture["app_name"] + shutil.copy2(Path(path), Path("main.py")) + import_string, app = get_import_string() + assert import_string == "main:app" + assert app is not None, "The app should be imported successfully." + assert isinstance( + app, FastAgency + ), "The imported object should be a FastAgency object." + + def test_get_import_string_missing_path(self) -> None: + """Test that the import string is correct.""" + with pytest.raises(FastAgencyCLIError, match="Path does not exist"): + get_import_string(path=Path("some_random_name.py")) + + def test_get_import_string_missing_app_name( + self, import_fixture: dict[str, Any] + ) -> None: + path, app_name = import_fixture["path"], import_fixture["app_name"] + mod_name = import_fixture["mod_name"] + assert app_name == "app" + + import_string, app = get_import_string(path=Path(path)) + + assert import_string == f"{mod_name}.test_app:app" + assert app is not None, "The app should be imported successfully." + assert isinstance( + app, FastAgency + ), "The imported object should be a FastAgency object." + + def test_get_import_string_missing_init( + self, import_fixture_missing_init: dict[str, Any] + ) -> None: + path, app_name = ( + import_fixture_missing_init["path"], + import_fixture_missing_init["app_name"], + ) + path = path.split("/")[0] + with pytest.raises( + FastAgencyCLIError, match="Could not find app name app in test" + ): + get_import_string(path=Path(path), app_name=app_name) + + def test_get_import_string_syntax_error( + self, import_fixture_syntax_error: dict[str, Any] + ) -> None: + path, app_name = ( + import_fixture_syntax_error["path"], + import_fixture_syntax_error["app_name"], + ) + path = path.split("/")[0] + with pytest.raises(SyntaxError, match="invalid syntax"): + get_import_string(path=Path(path), app_name=app_name) + + def test_get_import_init(self, import_fixture: dict[str, Any]) -> None: + """Test that the import string is correct.""" + init_path, _ = import_fixture["init_path"], import_fixture["app_name"] + import_string, app = get_import_string(path=Path(init_path)) + mod_name = import_fixture["mod_name"] + assert import_string == f"{mod_name}:app" + assert app is not None, "The app should be imported successfully." + assert isinstance( + app, FastAgency + ), "The imported object should be a FastAgency object." + + def test_get_import_dir(self, import_fixture: dict[str, Any]) -> None: + """Test that the import string is correct.""" + init_path, _ = import_fixture["init_path"], import_fixture["app_name"] + dir_path = Path(init_path).parent + import_string, app = get_import_string(path=dir_path) + + mod_name = import_fixture["mod_name"] + assert import_string == f"{mod_name}:app" assert app is not None, "The app should be imported successfully." assert isinstance( app, FastAgency @@ -72,7 +267,8 @@ class TestImportFromString: def test_import_string(self, import_fixture: dict[str, Any]) -> None: """Test that the import string is correct.""" import_string = import_fixture["import_string"] - assert import_string == "test.test_app:app" + mod_name = import_fixture["mod_name"] + assert import_string == f"{mod_name}.test_app:app" def test_import_valid_app(self, import_fixture: dict[str, Any]) -> None: """Test importing a valid app from a Python file.""" @@ -107,8 +303,66 @@ def test_import_attribute_not_found(self, import_fixture: dict[str, Any]) -> Non """Test when the module exists but the attribute doesn't.""" import_string = import_fixture["import_string"] test_module, _ = import_string.split(":") + mod_name = import_fixture["mod_name"] with pytest.raises( ImportError, - match="Attribute 'nonexistent' not found in module 'test.test_app'.", + match=f"Attribute 'nonexistent' not found in module '{mod_name}.test_app'.", ): import_from_string(f"{test_module}:nonexistent") + + def test_not_fastagency_app( + self, import_fixture_mocked_app: dict[str, Any] + ) -> None: + """Test when the attribute is not a FastAgency app.""" + import_string = import_fixture_mocked_app["import_string"] + test_module, _ = import_string.split(":") + with pytest.raises( + ImportError, + match="Import string must be in 'module_name:attribute_name' format.", + ): + import_from_string(test_module) + + +class TestGetDefaultPath: + @contextmanager + def _create_empty_path(self, path: str) -> Iterator[Path]: + with TemporaryDirectory() as tmp_dir: + # save old working directory + old_cwd = Path.cwd() + + # set new working directory + try: + os.chdir(tmp_dir) + + # Write the content to a temporary Python file + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + p.touch() + + yield p + finally: + # Restore the old working directory + os.chdir(old_cwd) + + @pytest.mark.parametrize( + "potential_path", + [ + "main.py", + "app.py", + "api.py", + "app/main.py", + "app/app.py", + "app/api.py", + ], + ) + def test_get_default_path_success(self, potential_path: str) -> None: + """Test that the correct default path is returned.""" + with self._create_empty_path(potential_path) as p: + assert get_default_path() == p + assert str(get_default_path()) == str(p), f"{get_default_path=}, {p=}" + + def test_get_default_path_missing(self) -> None: + """Test that the correct default path is returned.""" + with self._create_empty_path("random_stuff.py"): # noqa: SIM117 + with pytest.raises(FastAgencyCLIError): + get_default_path() diff --git a/tests/cli/test_mesop.py b/tests/cli/test_mesop.py index b49ca414d..b6f9092ef 100644 --- a/tests/cli/test_mesop.py +++ b/tests/cli/test_mesop.py @@ -71,4 +71,4 @@ def test_app_failure_for_python39() -> None: assert result.exit_code == 1 - assert "Error: Mesop requires Python 3.10 or higher" in result.output + assert "Mesop requires Python 3.10 or higher" in result.output diff --git a/tests/ui/mesop/test_base.py b/tests/ui/mesop/test_base.py new file mode 100644 index 000000000..6ccd665ad --- /dev/null +++ b/tests/ui/mesop/test_base.py @@ -0,0 +1,42 @@ +import sys + +import pytest + +from fastagency.app import FastAgency +from fastagency.base import TextMessage +from fastagency.runtime.autogen.base import AutoGenWorkflows + +if sys.version_info >= (3, 10): + from fastagency.ui.mesop.base import MesopUI + + class TestMesopUI: + def test_mesop_init(self) -> None: + mesop_ui = MesopUI() + assert mesop_ui is not None + assert mesop_ui._in_queue is not None + assert mesop_ui._out_queue is not None + + def test_create(self) -> None: + mesop_ui = MesopUI() + with pytest.raises(RuntimeError, match="MesopUI has not been created yet."): + MesopUI.get_created_instance() + + wf = AutoGenWorkflows() + app = FastAgency(wf=wf, ui=mesop_ui) + + with mesop_ui.create(app, "import_string"): + assert MesopUI.get_created_instance() == mesop_ui + assert mesop_ui.app == app + + def test_mesop_mesage(self) -> None: + mesop_ui = MesopUI() + + io_msg = TextMessage( + sender="sender", + recipient="recipient", + body="message", + ) + + mesop_msg = mesop_ui._mesop_message(io_msg) + assert mesop_msg.conversation == mesop_ui + assert mesop_msg.io_message == io_msg diff --git a/tests/ui/mesop/test_init.py b/tests/ui/mesop/test_init.py new file mode 100644 index 000000000..2773a4d74 --- /dev/null +++ b/tests/ui/mesop/test_init.py @@ -0,0 +1,15 @@ +import sys + +import pytest + +from fastagency.exceptions import FastAgencyCLIPythonVersionError + + +@pytest.mark.skipif(sys.version_info > (3, 9), reason="Python 3.9 or lower is required") +def test_import_below_python_3_10() -> None: + with pytest.raises( # noqa: PT012 + FastAgencyCLIPythonVersionError, match="Mesop requires Python 3.10 or higher" + ): + from fastagency.ui.mesop import MesopUI + + assert MesopUI is not None