Skip to content

Commit

Permalink
Add base server enhancements (#38)
Browse files Browse the repository at this point in the history
* README.md badges

* Add warning and critical levels to logger

* Add asyncio to setup, and also increment minor version

* Enhance server perfomance and efficiency, add keep-alive support

* Server improvements

* Server bug fixes

* Small changes

* Add httpserver tests

* Sever enhancements

* tmp fix
  • Loading branch information
JoshCap20 authored Sep 24, 2024
1 parent 803fc41 commit 18656a3
Show file tree
Hide file tree
Showing 10 changed files with 379 additions and 58 deletions.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Areion

[![PyPi][pypi-shield]][pypi-url] [![PyPi][pypiversion-shield]][pypi-url] [![License][license-shield]][license-url]

Areion is a lightweight, fast, and extensible Python web server framework. It supports asynchronous operations, multithreading, routing, orchestration, customizable loggers, and template engines. The framework provides an intuitive API for building web services, with components like the `Orchestrator`, `Router`, `Logger`, and `Engine` easily swappable or extendable.

Some say it is the simplest API ever. They might be right. To return a JSON response, you just return a dictionary. To return an HTML response, you just return a string. Return bytes for an octet-stream response. That's it.
Expand Down Expand Up @@ -284,3 +286,15 @@ MIT License
Copyright (c) 2024 Joshua Caponigro

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.


[pypi-shield]: https://img.shields.io/pypi/pyversions/areion?color=281158

[pypi-url]: https://pypi.org/project/areion/

[pypiversion-shield]: https://img.shields.io/pypi/v/areion?color=361776

[license-url]: https://github.com/JoshCap20/areion/blob/main/LICENSE

[license-shield]: https://img.shields.io/github/license/joshcap20/areion

Empty file removed __init__.py
Empty file.
14 changes: 11 additions & 3 deletions areion/base/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@

class BaseLogger(ABC):
@abstractmethod
def info(self, message: str):
def info(self, message: str) -> None:
pass

@abstractmethod
def debug(self, message: str):
def debug(self, message: str) -> None:
pass

@abstractmethod
def error(self, message: str):
def error(self, message: str) -> None:
pass

@abstractmethod
def warning(self, message: str) -> None:
pass

@abstractmethod
def critical(self, message: str) -> None:
pass
143 changes: 98 additions & 45 deletions areion/core/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,79 +9,132 @@ def __init__(
router,
request_factory,
host: str = "localhost",
port: int = 8080
port: int = 8080,
max_conns: int = 1000,
buffer_size: int = 8192,
keep_alive_timeout: int = 5,
):
if not isinstance(port, int):
raise ValueError("Port must be an integer.")
if not isinstance(host, str):
raise ValueError("Host must be a string.")
if not router:
raise ValueError("Router must be provided.")
if not request_factory:
raise ValueError("Request factory must be provided.")
if not isinstance(max_conns, int) or max_conns <= 0:
raise ValueError("Max connections must be a positive integer.")
if not isinstance(buffer_size, int) or buffer_size <= 0:
raise ValueError("Buffer size must be a positive integer.")
if not isinstance(keep_alive_timeout, int) or keep_alive_timeout <= 0:
raise ValueError("Keep alive timeout must be a positive integer.")
self.semaphore = asyncio.Semaphore(max_conns)
self.router = router
self.request_factory = request_factory
self.host = host
self.port = port
self.buffer_size = buffer_size
self.keep_alive_timeout = keep_alive_timeout
self._shutdown_event = asyncio.Event()

async def _handle_client(self, reader, writer, timeout: int = 15) -> None:
# Handles client connections
async with self.semaphore:
try:
await asyncio.wait_for(
self._process_request(reader, writer), timeout=timeout
)
except asyncio.TimeoutError:
pass
finally:
writer.close()
await writer.wait_closed()

async def _handle_client(self, reader, writer):
async def _process_request(self, reader, writer):
# Ensures that the request is processed within the timeout
# TODO: Move request and client timeouts to separate variables
# TODO: Add optional request logging
# TODO: Add custom exception handling
try:
request_line = await reader.readline()
if not request_line:
return

# Parse the request line
request_line = request_line.decode("utf-8").strip()
method, path, _ = request_line.split(" ")
await asyncio.wait_for(
self._handle_request_logic(reader, writer),
timeout=self.keep_alive_timeout,
)
except asyncio.TimeoutError:
response = HttpResponse(status_code=408, body="Request Timeout")
await self._send_response(writer, response)

# Parse headers
headers = {}
while True:
header_line = await reader.readline()
if header_line == b"\r\n":
break
header_name, header_value = (
header_line.decode("utf-8").strip().split(": ", 1)
)
headers[header_name] = header_value
async def _handle_request_logic(self, reader, writer):
request_line = await reader.readline()
if not request_line:
return
method, path, _ = request_line.decode("utf-8").strip().split(" ")
headers = await self._parse_headers(reader)
request = self.request_factory.create(method, path, headers)

# Create request object
request = self.request_factory.create(method, path, headers)
try:
handler, path_params = self.router.get_handler(method, path)

# Change this to raise 404 exception after thats built
if not handler:
response = HttpResponse(status_code=404, body="Not Found")

if not path_params:
path_params = {}

# TODO: Move this to router get handler dict so dont have to do this at runtime
# TODO: Simplify
if handler and asyncio.iscoroutinefunction(handler):
response = await handler(request, **path_params)
elif handler:
response = handler(request, **path_params)
except Exception:
response = HttpResponse(status_code=500, body="Internal Server Error")

if handler:
if path_params:
response = handler(request, **path_params)
else:
response = handler(request)
await self._send_response(writer, response)

# Ensure the response is an instance of HttpResponse
if not isinstance(response, HttpResponse):
response = HttpResponse(body=response)
async def _parse_headers(self, reader):
headers = {}
while True:
line = await reader.readline()
if line == b"\r\n":
break
header_name, header_value = line.decode("utf-8").strip().split(": ", 1)
headers[header_name] = header_value
return headers

# Send the formatted response
writer.write(response.format_response())
else:
# Handle 404 not found
response = HttpResponse(status_code=404, body="404 Not Found")
writer.write(response.format_response())
async def _send_response(self, writer, response):
# TODO: Move Response assertion here
# TODO: Add optional response logging
if not isinstance(response, HttpResponse):
response = HttpResponse(body=response)

buffer = response.format_response()
chunk_size = self.buffer_size

for i in range(0, len(buffer), chunk_size):
writer.write(buffer[i : i + chunk_size])
await writer.drain()
finally:
writer.close()
await writer.wait_closed()

async def start(self):
server = await asyncio.start_server(self._handle_client, self.host, self.port)
await writer.drain()

async with server:
await server.serve_forever()
async def start(self):
# Handles server startup
self._server = await asyncio.start_server(
self._handle_client, self.host, self.port
)
async with self._server:
await self._shutdown_event.wait()

async def stop(self):
# Handles server shutdown
if self._server:
self._server.close()
await self._server.wait_closed()
self._shutdown_event.set()

def run(self):
async def run(self, *args, **kwargs):
try:
asyncio.run(self.start())
await self.start()
except (KeyboardInterrupt, SystemExit):
self.stop()
await self.stop()
12 changes: 9 additions & 3 deletions areion/default/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,17 @@ def __init__(self, log_file=None, log_level=logging.INFO):
console_handler.setFormatter(formatter)
self.logger.addHandler(console_handler)

def info(self, message):
def info(self, message: str) -> None:
self.logger.info(message)

def debug(self, message):
def debug(self, message: str) -> None:
self.logger.debug(message)

def error(self, message):
def error(self, message: str) -> None:
self.logger.error(message)

def warning(self, message: str) -> None:
self.logger.warning(message)

def critical(self, message: str) -> None:
self.logger.critical(message)
7 changes: 4 additions & 3 deletions areion/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,6 @@ async def start(self) -> None:
self._start_orchestrator_in_thread()

# Add the HTTP Server
# TODO: Could pass logger here
self.http_server = HttpServer(
router=self.router,
host=self.host,
Expand All @@ -129,7 +128,7 @@ async def start(self) -> None:
self._serve_static_files()

# Start the HTTP server
server_task = asyncio.create_task(self.http_server.start())
server_task = await self.http_server.run()

self.logger.info(f"Server running on http://{self.host}:{self.port}")
self.logger.debug(f"Available Routes and Handlers: {self.router.routes}")
Expand Down Expand Up @@ -257,7 +256,9 @@ def with_orchestrator(self, orchestrator):
return self

def with_logger(self, logger):
self._validate_component(logger, ["info", "error", "debug"], "Logger")
self._validate_component(
logger, ["info", "error", "debug", "warning", "critical"], "Logger"
)
self.logger = logger
return self

Expand Down
2 changes: 0 additions & 2 deletions areion/tests/components/test_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,6 @@ def task_with_exception():

self.orchestrator.submit_task(task_with_exception)

# TODO: Test for logging on error later

@patch.object(ThreadPoolExecutor, "shutdown", return_value=None)
@patch("apscheduler.schedulers.background.BackgroundScheduler.shutdown")
def test_orchestrator_shutdown(
Expand Down
2 changes: 2 additions & 0 deletions areion/tests/core/test_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ def test_log_no_logger(self):
self.request.logger = None
self.request.log("Test message", "info") # Should not raise an exception

# TODO: Add integration tests with server


class TestHttpRequestFactory(unittest.TestCase):

Expand Down
Loading

0 comments on commit 18656a3

Please sign in to comment.