Skip to content

Commit

Permalink
Initialize domain before starting engine (#453)
Browse files Browse the repository at this point in the history
Initialize domain before starting engine.

This ensures that all domain elements are registered and all adapters are initialized before the server starts processing messages.
  • Loading branch information
subhashb authored Aug 12, 2024
1 parent 97a2dde commit 01549e7
Show file tree
Hide file tree
Showing 5 changed files with 383 additions and 13 deletions.
12 changes: 10 additions & 2 deletions src/protean/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from protean.cli.new import new
from protean.cli.shell import shell
from protean.exceptions import NoDomainException
from protean.server.engine import Engine
from protean.utils.domain_discovery import derive_domain

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -152,10 +153,17 @@ def server(
try:
domain = derive_domain(domain)
except NoDomainException as exc:
logger.error(f"Error loading Protean domain: {exc.args[0]}")
msg = f"Error loading Protean domain: {exc.args[0]}"
print(msg) # Required for tests to capture output
logger.error(msg)

raise typer.Abort()

from protean.server import Engine
# Traverse and initialize domain
# This will load all aggregates, entities, services, and other domain elements.
#
# By the time the handlers are invoked, the domain is fully initialized and ready to serve requests.
domain.init()

engine = Engine(domain, test_mode=test_mode, debug=debug)
engine.run()
Expand Down
26 changes: 18 additions & 8 deletions src/protean/server/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ def __init__(self, domain, test_mode: bool = False, debug: bool = False) -> None
self.exit_code = 0
self.shutting_down = False # Flag to indicate the engine is shutting down

if self.debug:
logger.setLevel(logging.DEBUG)

self.loop = asyncio.get_event_loop()

# Gather all handlers
Expand Down Expand Up @@ -182,7 +185,7 @@ async def shutdown(self, signal=None, exit_code=0):

try:
msg = (
"Received exit signal {signal.name}. Shutting down..."
f"Received exit signal {signal.name}. Shutting down..."
if signal
else "Shutting down..."
)
Expand Down Expand Up @@ -224,16 +227,23 @@ def run(self):

# Handle Exceptions
def handle_exception(loop, context):
# context["message"] will always be there; but context["exception"] may not
msg = context.get("exception", context["message"])

# Print the stack trace
traceback.print_stack(context.get("exception"))
print(
f"Exception caught: {msg}"
) # Debugging line to ensure this code path runs

logger.error(f"Caught exception: {msg}")
logger.info("Shutting down...")
if loop.is_running():
asyncio.create_task(self.shutdown(exit_code=1))
# Print the stack trace
if "exception" in context and context["exception"]:
traceback.print_stack(context["exception"])
logger.error(f"Caught exception: {msg}")
logger.info("Shutting down...")
if loop.is_running():
asyncio.create_task(self.shutdown(exit_code=1))

raise context["exception"] # Raise the exception to stop the loop
else:
logger.error(f"Caught exception: {msg}")

self.loop.set_exception_handler(handle_exception)

Expand Down
95 changes: 92 additions & 3 deletions tests/cli/test_server.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import asyncio
import logging
import os
import sys
from pathlib import Path
from unittest.mock import ANY, MagicMock, patch

import pytest
from typer.testing import CliRunner

from protean.cli import app
from protean.exceptions import NoDomainException
from protean.server.engine import Engine
from tests.shared import change_working_directory_to

runner = CliRunner()
Expand All @@ -23,6 +28,19 @@ def reset_path(self):
sys.path[:] = original_path
os.chdir(cwd)

@pytest.fixture(autouse=True)
def auto_set_and_close_loop(self):
# Create and set a new loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)

yield

# Close the loop after the test
if not loop.is_closed():
loop.close()
asyncio.set_event_loop(None) # Explicitly unset the loop

def test_server_with_invalid_domain(self):
"""Test that the server command fails when domain is not provided"""
args = ["server", "--domain", "foobar"]
Expand All @@ -31,6 +49,32 @@ def test_server_with_invalid_domain(self):
assert isinstance(result.exception, SystemExit)
assert "Aborted" in result.output

def test_server_with_valid_domain(self):
"""Test that the server command initializes and runs with a valid domain"""
change_working_directory_to("test7")

with patch.object(Engine, "run", return_value=None) as mock_run:
args = ["server", "--domain", "publishing7.py"]
result = runner.invoke(app, args)

assert result.exit_code == 0
mock_run.assert_called_once()

def test_server_initializes_domain(self):
"""Test that the server command correctly initializes the domain"""
change_working_directory_to("test7")

with patch(
"protean.cli.derive_domain", return_value=MagicMock()
) as mock_derive_domain: # Correct the patch path here
with patch("protean.server.engine.Engine.run") as mock_engine_run:
args = ["server", "--domain", "publishing7.py"]
result = runner.invoke(app, args)

assert result.exit_code == 0
mock_derive_domain.assert_called_once_with("publishing7.py")
mock_engine_run.assert_called_once()

def test_server_start_successfully(self):
change_working_directory_to("test7")

Expand All @@ -42,13 +86,58 @@ def test_server_start_successfully(self):
# Assertions
assert result.exit_code == 0

def test_server_handles_no_domain_exception(self):
"""Test that the server command gracefully handles NoDomainException"""
with patch(
"protean.cli.derive_domain",
side_effect=NoDomainException("Domain not found"),
):
args = ["server", "--domain", "invalid_domain.py"]
result = runner.invoke(app, args)

assert result.exit_code != 0
assert "Error loading Protean domain: Domain not found" in result.output
assert isinstance(result.exception, SystemExit)

def test_server_runs_in_test_mode(self):
"""Test that the server runs in test mode when the flag is provided"""
change_working_directory_to("test7")

# Mock the Engine class entirely
with patch("protean.cli.Engine") as MockEngine:
mock_engine_instance = MockEngine.return_value
mock_engine_instance.exit_code = 0 # Set the exit code

args = ["server", "--domain", "publishing7.py", "--test-mode"]
result = runner.invoke(app, args)

# Assertions
assert result.exit_code == 0
mock_engine_instance.run.assert_called_once() # Ensure `run` was called
MockEngine.assert_called_once_with(
ANY, test_mode=True, debug=False
) # Ensure Engine was instantiated with the correct arguments

def test_server_runs_in_debug_mode(self):
"""Test that the server runs in debug mode and sets the correct logger level"""
change_working_directory_to("test7")

# Mock the logger used in the Engine class
with patch("protean.server.engine.logger") as mock_logger:
args = ["server", "--domain", "publishing7.py", "--debug"]
result = runner.invoke(app, args)

assert result.exit_code == 0
mock_logger.setLevel.assert_called_once_with(logging.DEBUG)

@pytest.mark.skip(reason="Not implemented")
def test_that_server_processes_messages_on_start(self):
# Start in non-test mode
# Ensure messages are processed
# Manually shutdown with `asyncio.create_task(engine.shutdown())`
pass

@pytest.mark.skip(reason="Not implemented")
def test_debug_mode(self):
# Test debug mode is saved and correct logger level is set
def test_server_with_max_workers(self):
"""Test that the server command handles the MAX_WORKERS input (future implementation)"""
# This is a placeholder for when MAX_WORKERS is implemented as a command-line input
pass
177 changes: 177 additions & 0 deletions tests/cli/test_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
from unittest.mock import call

import pytest
from typer.testing import CliRunner

from protean.cli import Category, app

runner = CliRunner()


@pytest.fixture
def mock_subprocess_call(mocker):
return mocker.patch("protean.cli.subprocess.call")


@pytest.mark.parametrize(
"category,expected_calls",
[
(
Category.EVENTSTORE,
[
call(
[
"pytest",
"--cache-clear",
"--ignore=tests/support/",
"-m",
"eventstore",
"--store=MEMORY",
]
),
call(
[
"pytest",
"--cache-clear",
"--ignore=tests/support/",
"-m",
"eventstore",
"--store=MESSAGE_DB",
]
),
],
),
(
Category.DATABASE,
[
call(
[
"pytest",
"--cache-clear",
"--ignore=tests/support/",
"-m",
"database",
"--db=POSTGRESQL",
]
),
call(
[
"pytest",
"--cache-clear",
"--ignore=tests/support/",
"-m",
"database",
"--db=SQLITE",
]
),
],
),
(
Category.FULL,
[
call(
[
"pytest",
"--cache-clear",
"--ignore=tests/support/",
"--slow",
"--sqlite",
"--postgresql",
"--elasticsearch",
"--redis",
"--message_db",
"--cov=protean",
"--cov-config",
".coveragerc",
"tests",
]
),
call(
[
"pytest",
"--cache-clear",
"--ignore=tests/support/",
"-m",
"database",
"--db=POSTGRESQL",
]
),
call(
[
"pytest",
"--cache-clear",
"--ignore=tests/support/",
"-m",
"database",
"--db=SQLITE",
]
),
call(
[
"pytest",
"--cache-clear",
"--ignore=tests/support/",
"-m",
"eventstore",
"--store=MESSAGE_DB",
]
),
],
),
(
Category.COVERAGE,
[
call(
[
"pytest",
"--cache-clear",
"--ignore=tests/support/",
"--slow",
"--sqlite",
"--postgresql",
"--elasticsearch",
"--redis",
"--message_db",
"--cov=protean",
"--cov-config",
".coveragerc",
"tests",
]
),
],
),
(
Category.CORE,
[
call(["pytest", "--cache-clear", "--ignore=tests/support/"]),
],
),
],
)
def test_command(mock_subprocess_call, category, expected_calls):
result = runner.invoke(app, ["test", "--category", category.value])

assert result.exit_code == 0
assert mock_subprocess_call.call_count == len(expected_calls)
mock_subprocess_call.assert_has_calls(expected_calls, any_order=True)


def test_default_category(mock_subprocess_call):
# Test the command with the default category (CORE)
result = runner.invoke(app, ["test"])

assert result.exit_code == 0
mock_subprocess_call.assert_called_once_with(
["pytest", "--cache-clear", "--ignore=tests/support/"]
)


def test_invalid_category(mock_subprocess_call):
# Test the command with an invalid category (should raise error)
result = runner.invoke(app, ["test", "--category", "INVALID"])

assert result.exit_code == 2
assert (
"Invalid value for '-c' / '--category': 'INVALID' is not one of "
in result.output
)
Loading

0 comments on commit 01549e7

Please sign in to comment.