Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/core/__init__.py

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unit tests?

Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@
from .exceptions import *
from .get_logger import get_logger
from .structs import Addr
from .util import Immutable
from .util import Immutable, uninterruptible, process_redis_url

__all__ = ["Addr", "StageEnum", "Immutable",
"RCError", "AssignmentError", "NetworkError",
"PartialResponseError", "PartialRequestError", "ConnectionCountError",
"IS_CLI", "STAGE", "TLS_ENFORCED", "MAX_CONNECTIONS",
"FILE_HANDLER", "STDOUT_HANDLER", "STDERR_HANDLER",
"get_logger"]
"get_logger", "uninterruptible", "process_redis_url"]
2 changes: 1 addition & 1 deletion src/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import sys

from .constants import StageEnum
from .util import LogCompressor
from .log_compressor import LogCompressor

__all__ = ["IS_CLI", "STAGE", "TLS_ENFORCED", "MAX_CONNECTIONS",
"FILE_HANDLER", "STDOUT_HANDLER", "STDERR_HANDLER"]
Expand Down
60 changes: 60 additions & 0 deletions src/core/log_compressor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from logging import Formatter

class LogCompressor(Formatter):
"""
Custom formatter that truncates messages longer than a specified byte limit.
"""

DEFAULT_MAX_BYTES: int = 256
"""
Default maximum byte limit for message truncation.
"""
TRUNCATION_SUFFIX: bytes = "...".encode()
"""
Suffix added to truncated messages.
"""
MINIMUM_REQUIRED_BYTES: int = 3
"""
Minimum required byte limit for message truncation.
"""

def __init__(self, fmt=None, datefmt=None, max_bytes: int = DEFAULT_MAX_BYTES):
"""
Initializes the formatter with the specified format, date format, and maximum byte limit.

Args:
fmt: The format string for the formatter.
datefmt: The date format string for the formatter.
max_bytes: The maximum byte limit for message truncation.

Raises:
ValueError: If max_bytes is less than the minimum required bytes.
"""
super().__init__(fmt, datefmt)
if max_bytes < LogCompressor.MINIMUM_REQUIRED_BYTES:
raise ValueError("Invalid number of maximum bytes; must be at least 3")
self.max_bytes = max_bytes

def format(self, record) -> str:
"""
Formats the log record by truncating the message if it exceeds the byte limit.

Args:
record: The log record to format.

Returns:
str: The formatted log record.
"""
msg = record.getMessage()
msg_bytes = msg.encode()

if len(msg_bytes) > self.max_bytes:
# Truncate and add indicator.
suffix_bytes = len(LogCompressor.TRUNCATION_SUFFIX)
truncated_bytes = msg_bytes[:self.max_bytes - suffix_bytes] + LogCompressor.TRUNCATION_SUFFIX
truncated_msg = truncated_bytes.decode()
record.msg = truncated_msg
# Clear args so getMessage() doesn't try to re-format.
record.args = ()

return super().format(record)
135 changes: 75 additions & 60 deletions src/core/util.py
Original file line number Diff line number Diff line change
@@ -1,66 +1,13 @@
from logging import Formatter
from typing import Any
from typing import Any, Callable
from functools import wraps
from urllib.parse import urlparse

from .config import TLS_ENFORCED
from .constants import EMPTY_STR, SCHEME_LIST
from .exceptions import AssignmentError
from .get_logger import get_logger

class LogCompressor(Formatter):
"""
Custom formatter that truncates messages longer than a specified byte limit.
"""

DEFAULT_MAX_BYTES: int = 256
"""
Default maximum byte limit for message truncation.
"""
TRUNCATION_SUFFIX: bytes = "...".encode()
"""
Suffix added to truncated messages.
"""
MINIMUM_REQUIRED_BYTES: int = 3
"""
Minimum required byte limit for message truncation.
"""

def __init__(self, fmt=None, datefmt=None, max_bytes: int = DEFAULT_MAX_BYTES):
"""
Initializes the formatter with the specified format, date format, and maximum byte limit.

Args:
fmt: The format string for the formatter.
datefmt: The date format string for the formatter.
max_bytes: The maximum byte limit for message truncation.

Raises:
ValueError: If max_bytes is less than the minimum required bytes.
"""
super().__init__(fmt, datefmt)
if max_bytes < LogCompressor.MINIMUM_REQUIRED_BYTES:
raise ValueError("Invalid number of maximum bytes; must be at least 3")
self.max_bytes = max_bytes

def format(self, record) -> str:
"""
Formats the log record by truncating the message if it exceeds the byte limit.

Args:
record: The log record to format.

Returns:
str: The formatted log record.
"""
msg = record.getMessage()
msg_bytes = msg.encode()

if len(msg_bytes) > self.max_bytes:
# Truncate and add indicator.
suffix_bytes = len(LogCompressor.TRUNCATION_SUFFIX)
truncated_bytes = msg_bytes[:self.max_bytes - suffix_bytes] + LogCompressor.TRUNCATION_SUFFIX
truncated_msg = truncated_bytes.decode()
record.msg = truncated_msg
# Clear args so getMessage() doesn't try to re-format.
record.args = ()

return super().format(record)
logger = get_logger(__name__)

class Immutable:
"""
Expand Down Expand Up @@ -104,3 +51,71 @@ def __setattr__(self, name: str, value: Any) -> None:
# If __getattribute__ raises AttributeError, the attribute is missing.
# Therefore allow the construction of the object.
super().__setattr__(name, value)

def uninterruptible(callback: Callable) -> Callable:
"""
Decorator to make a function uninterruptible.
Catches system signals (like KeyboardInterrupt) and retries the execution,
ensuring the operation completes unless a standard Exception occurs.
"""
@wraps(callback)
def wrapper(*args, **kwargs):
while True:
try:
return callback(*args, **kwargs)
except Exception:
# Allow standard exceptions (ValueError, TypeError, etc.) to propagate.
raise
except BaseException as e:
# Catch signals (KeyboardInterrupt, SystemExit) and retry.
logger.error(f"func: {callback.__name__} interrupted by {e!r}. Retrying...")
return wrapper

def process_redis_url(url: str, tls_enforced: bool = TLS_ENFORCED) -> tuple[str, str, str, str, str]:
"""
Processes and extracts data from a Redis URL string.

Format: redis[s]://[[username][:password]@][host][:port][/db-number]
See more: https://docs.python.org/3/library/urllib.parse.html#url-parsing

Args:
url (str): A string representing the Redis connection URL.

Returns:
arr: A five-string tuple contaning the connection information.

Raises:
ValueError: For invalid URL scheme or malformed URL.
ConnecterError: If the connection to the server fails.
"""
logger.debug(f"Processing Redis URL: {url}.")
parsed = urlparse(url)

if parsed.scheme not in SCHEME_LIST:
raise ValueError(f"Invalid url scheme: '{parsed.scheme}'")
if tls_enforced and parsed.scheme == SCHEME_LIST[0]:
raise ValueError("TLS is enforced, and an invalid scheme was provided.")

host = parsed.hostname if parsed.hostname else EMPTY_STR
port = str(parsed.port) if parsed.port else EMPTY_STR
user = parsed.username if parsed.username else EMPTY_STR
pasw = parsed.password if parsed.password else EMPTY_STR

# Extraction of logical database index (the path segment).
# Redis URLs typically use /0, /1, etc.
db_idx = EMPTY_STR
if parsed.path:
# Strip leading slash, and check if the integer is valid.
db_idx = parsed.path.lstrip('/')

if db_idx != EMPTY_STR:
try:
int(db_idx)
except ValueError:
raise ValueError(
f"Invalid database index: '{parsed.path}'; must be an integer"
)

# Do not log the password, sensitive data.
logger.debug(f"Url connection details: host={host}, port={port}, user={user}, db={db_idx}")
return (host, port, user, pasw, db_idx)
44 changes: 20 additions & 24 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,58 +4,54 @@
import core
from frontend import Layout

from reactor import Reactor, ReactorClient
from multiplexing import loop_multiplexing
from util import uninterruptible
from util import shutdown_app, add_conn

logger = core.get_logger(__name__)

@uninterruptible
async def close_page(multiplexing_event: Event, page: ft.Page) -> None:
logger.info("Closing application...")
try:
multiplexing_event.clear()
except BaseException as e:
logger.error(f"Application failed: {e}.", exc_info=True)
finally:
page.window.prevent_close = False
page.window.on_event = None
await page.window.destroy()
await page.window.close()
logger = core.get_logger(__name__)

@uninterruptible
@core.uninterruptible
def build_page(page: ft.Page) -> None:
"""
Builds the main page.
"""
try:
reactor = Reactor(page)
multiplexing_event = Event()
multiplexing_event.set()

# Run the multiplexing loop in a background thread managed by Flet.
page.run_thread(loop_multiplexing, multiplexing_event)
page.run_thread(loop_multiplexing, multiplexing_event, reactor)
logger.info("Multiplexing thread started.")

async def handle_close(event: ft.WindowEvent | None = None) -> None:
if event and event.data != "close" and event.type != ft.WindowEventType.CLOSE:
return
await close_page(multiplexing_event, page)
async def handle_close(event: ft.WindowEvent | None) -> None:
if event is None or event.data == "close" or event.type == ft.WindowEventType.CLOSE:
await shutdown_app(multiplexing_event, page)

page.window.on_event = handle_close
page.window.prevent_close = True
page.window.width = 800
page.window.height = 600
page.window.resizable = False
page.theme_mode = ft.ThemeMode.DARK
page.title = "RC-application"

# Handles OS intrusions gracefully.
# Similar to a regular container.
safe_area = ft.SafeArea(Layout(), expand=True)
reactor_client = ReactorClient(reactor)
layout = Layout(lambda conn_data: add_conn(conn_data, reactor_client, layout))
safe_area = ft.SafeArea(layout, expand=True)
page.add(safe_area)
logger.info("Flet app window initialized.")

except Exception as e:
logger.error(f"Application failed: {e}.", exc_info=True)
page.window.close()
page.run_task(page.window.close)

if __name__ == "__main__":
if core.IS_CLI:
raise NotImplementedError("CLI mode is not implemented yet")
logger.info("CLI mode enabled.")
else:
logger.info("GUI mode enabled.")
ft.run(build_page, assets_dir="src/frontend/assets")
Loading