From 86dcd23f56cba8dd4175270331a1d0a39c2a0618 Mon Sep 17 00:00:00 2001 From: Edward Jazzhands Date: Tue, 2 Sep 2025 01:23:09 -0700 Subject: [PATCH 1/2] Functional --- pyproject.toml | 5 +- src/term_desktop/aceofbase.py | 8 +- src/term_desktop/app_sdk/appbase.py | 4 +- src/term_desktop/apps/notepad/app.py | 4 +- src/term_desktop/apps/sysinfo.py | 4 +- src/term_desktop/apps/syslogs.py | 22 +- src/term_desktop/main.py | 147 +++++------ src/term_desktop/services/apps.py | 25 +- src/term_desktop/services/databases.py | 15 +- src/term_desktop/services/fileassociations.py | 2 +- src/term_desktop/services/screens.py | 12 +- src/term_desktop/services/servicebase.py | 4 +- src/term_desktop/services/servicesmanager.py | 174 ++++++++---- src/term_desktop/services/shells.py | 28 +- src/term_desktop/services/tde_logging.py | 248 ++++++++++++++++++ src/term_desktop/services/windows.py | 8 +- src/term_desktop/shell/default/explorer.py | 4 +- src/term_desktop/shell/default/taskbar.py | 4 +- uv.lock | 11 + 19 files changed, 574 insertions(+), 155 deletions(-) create mode 100644 src/term_desktop/services/tde_logging.py diff --git a/pyproject.toml b/pyproject.toml index fce50e3..0651a92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ license = { text = "MIT" } keywords = ["python", "textual", "tui", "window", "desktop", "shell", "multiplexer", "terminal", "cli", "command-line"] dependencies = [ "ezpubsub>=0.2.0", + "python-json-logger>=3.3.0", "textual>=5.3.0", "textual-autocomplete>=4.0.4", "textual-coloromatic>=1.0.0", @@ -54,7 +55,7 @@ build-backend = "hatchling.build" term-desktop = "term_desktop.main:run" [tool.black] -line-length = 110 +line-length = 100 [tool.mypy] pretty = true @@ -67,4 +68,4 @@ include = ["src"] typeCheckingMode = "strict" [tool.pytest.ini_options] -asyncio_mode = "auto" \ No newline at end of file +asyncio_mode = "auto" diff --git a/src/term_desktop/aceofbase.py b/src/term_desktop/aceofbase.py index 6ee03b0..5442da9 100644 --- a/src/term_desktop/aceofbase.py +++ b/src/term_desktop/aceofbase.py @@ -18,6 +18,8 @@ class ProcessType(enum.Enum): SCREEN = "screen" SHELL = "shell" WINDOW = "window" + DATABASE = "database" + LOGGER = "logger" # Add more process types as needed. @@ -30,7 +32,7 @@ class ProcessContext(TypedDict, total=True): sessions to 1) have access to all services directly through self properties, and 2) access their own unique process identifiers that is used to track them in the system, if they need to do so. - + Remember that that process "children" here refers to things spawned by the process, but not the process itself (ie an app process needs to spawn its own Textual widget). @@ -119,4 +121,6 @@ def validate_stage2(cls, required_members: dict[str, str]) -> None: raise NotImplementedError(f"{cls.__name__} must implement {attr_name} ({kind}).") else: if attr is None: - raise NotImplementedError(f"{cls.__name__} must implement {attr_name} ({kind}).") + raise NotImplementedError( + f"{cls.__name__} must implement {attr_name} ({kind})." + ) diff --git a/src/term_desktop/app_sdk/appbase.py b/src/term_desktop/app_sdk/appbase.py index 2febe25..14d0302 100644 --- a/src/term_desktop/app_sdk/appbase.py +++ b/src/term_desktop/app_sdk/appbase.py @@ -275,7 +275,7 @@ class TDEMainWidget(Widget): #! NOTE: NOT FOR SCREENS, STILL NEED TO BUILD SUPPORT FOR THEM. - The app_context is passed in by the Process Manager when it initializes the app. + The app_context is passed in by the App Service when it initializes the app. """ class Initialized(Message): @@ -286,7 +286,7 @@ def __init__(self, window: Window): self.window = window def __init__(self, process_context: ProcessContext): - """The process context is passed in by the Process Manager when it initializes the app. + """The process context is passed in by the App Service when it initializes the app. It contains the process type, process ID, process UID, and services manager. If you override this method, you must have an argument named `process_context` diff --git a/src/term_desktop/apps/notepad/app.py b/src/term_desktop/apps/notepad/app.py index 205f6b2..7333db8 100644 --- a/src/term_desktop/apps/notepad/app.py +++ b/src/term_desktop/apps/notepad/app.py @@ -225,7 +225,9 @@ def on_mount(self) -> None: for button in buttons: button.compact = True - menu.offset = Offset(self.menu_offset.x, self.menu_offset.y + 1) # +1 to go below the button + menu.offset = Offset( + self.menu_offset.x, self.menu_offset.y + 1 + ) # +1 to go below the button def on_mouse_up(self) -> None: diff --git a/src/term_desktop/apps/sysinfo.py b/src/term_desktop/apps/sysinfo.py index a42d248..3b7ee3b 100644 --- a/src/term_desktop/apps/sysinfo.py +++ b/src/term_desktop/apps/sysinfo.py @@ -104,7 +104,9 @@ def get_static_system_info(self) -> dict[str, str]: return { "OS": f"{uname.system} {uname.release}", - "Freedesktop_os label": str(platform.freedesktop_os_release().get("PRETTY_NAME", "Unknown")), + "Freedesktop_os label": str( + platform.freedesktop_os_release().get("PRETTY_NAME", "Unknown") + ), "Machine": uname.machine, "Architecture": platform.architecture()[0], "CPU Model": self.get_cpu_model(), diff --git a/src/term_desktop/apps/syslogs.py b/src/term_desktop/apps/syslogs.py index a50139e..9c54316 100644 --- a/src/term_desktop/apps/syslogs.py +++ b/src/term_desktop/apps/syslogs.py @@ -29,6 +29,7 @@ LaunchMode, CustomWindowSettings, ) +from term_desktop.services.tde_logging import LogPayload class SysLogsMeta(TDEAppBase): @@ -85,7 +86,26 @@ class SysLogsWidget(TDEMainWidget): # #title { border: solid $primary; } # #content { width: auto; height: auto; } # """ - + def compose(self) -> ComposeResult: yield RichLog(id="log_viewer") + + def on_mount(self): + log_viewer = self.query_one(RichLog) + log_memory = self.services.logging_service.memory_buffer + + # write out the memory buffer to the log viewer + for record in log_memory: + log_viewer.write(record) + + # subscribe to new log records + self.services.logging_service.log_signal.subscribe(self.handle_new_log) + + def handle_new_log(self, log_payload: LogPayload) -> None: + log_viewer = self.query_one(RichLog) + log_viewer.write(log_payload["msg"]) + + def on_unmount(self) -> None: + # unsubscribe from log records + self.services.logging_service.log_signal.unsubscribe(self.handle_new_log) diff --git a/src/term_desktop/main.py b/src/term_desktop/main.py index 6a6cec9..91acee6 100644 --- a/src/term_desktop/main.py +++ b/src/term_desktop/main.py @@ -5,9 +5,10 @@ from typing import TYPE_CHECKING, Literal, Any import sys import inspect - -# from time import time +# from pathlib import Path +# import platformdirs import logging +# from pythonjsonlogger.json import JsonFormatter if TYPE_CHECKING: from textual.app import ComposeResult @@ -15,6 +16,8 @@ # from textual.await_complete import AwaitComplete +# from ezpubsub import Signal + # Textual imports from textual import LogGroup, LogVerbosity, on # type: ignore from textual.app import App @@ -30,43 +33,13 @@ # Local imports # ################# from term_desktop.services import ServicesManager +from term_desktop.services.tde_logging import DevtoolsLog, LogPayload from term_desktop.screens.screenbase import TDEScreen from term_desktop.common.exceptions import TDEException # from term_desktop.app_sdk.appbase import TDEApp -################# -# Logging Setup # -################# - - -logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) - -file_handler = logging.FileHandler("app.log") -file_handler.setLevel(logging.INFO) - -# Create console handler (optional - for both file and console output) -console_handler = logging.StreamHandler() -console_handler.setLevel(logging.INFO) - -# Create formatter -formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S") - -# Add formatter to handlers -file_handler.setFormatter(formatter) -console_handler.setFormatter(formatter) - -# Add handlers to logger -logger.addHandler(file_handler) -logger.addHandler(console_handler) - -logger.debug("This is a debug message") -logger.info("This is an info message") -logger.warning("This is a warning message") -logger.error("This is an error message") -logger.critical("This is a critical message") class TermDesktop(App[None]): @@ -78,14 +51,21 @@ class TermDesktop(App[None]): Binding("f8", "log_debug_readout", "Log app debug readout to dev console", show=False), ] + def on_load(self) -> None: + try: + self.services = ServicesManager() + except Exception as e: + raise e + def compose(self) -> ComposeResult: - self.services = ServicesManager() yield self.services - def on_mount(self) -> None: + async def on_mount(self) -> None: - self.services.start_all_services() - self.log() + try: + await self.services.start_all_services() + except Exception as e: + raise e @on(ServicesManager.ServicesStarted) def all_services_started(self) -> None: @@ -155,6 +135,13 @@ def install_screen(self, screen: Screen, name: str) -> None: # type: ignore "Use the screen service to push screens instead." ) + #! Override + @property + def _is_devtools_connected(self) -> bool: + # This override will trick the app into thinking the dev tools + # are always connected, thus keeping the logging system active. + return True + #! Override def _log( self, @@ -166,45 +153,59 @@ def _log( ) -> None: devtools = self.devtools - if devtools is None or not devtools.is_connected: - return - if verbosity.value > LogVerbosity.NORMAL.value and not devtools.verbose: + if len(objects) == 1 and not kwargs: + log_msg_obj = DevtoolsLog(objects, caller=_textual_calling_frame) + log_msg_str = str(objects[0]) + else: + output = " ".join(str(arg) for arg in objects) + if kwargs: + key_values = " ".join(f"{key}={value!r}" for key, value in kwargs.items()) + output = f"{output} {key_values}" if output else key_values + log_msg_obj = DevtoolsLog(output, caller=_textual_calling_frame) + log_msg_str = output + + if devtools: + devtools.log( + log_msg_obj, # type: ignore + group, + verbosity, + ) + + group_to_level: dict[int, int] = { + 2: logging.DEBUG, + 3: logging.INFO, + 4: logging.WARNING, + 5: logging.ERROR, + 6: logging.INFO, + } + services = getattr(self, "services", None) + if services is None: # app is not yet fully initialized return - try: - from textual_dev.client import DevtoolsLog - - if len(objects) == 1 and not kwargs: - #! modified next 3 lines - log_msg_obj = DevtoolsLog(objects, caller=_textual_calling_frame) - devtools.log( - log_msg_obj, - group, - verbosity, - ) - else: - output = " ".join(str(arg) for arg in objects) - if kwargs: - key_values = " ".join(f"{key}={value!r}" for key, value in kwargs.items()) - output = f"{output} {key_values}" if output else key_values - #! modified next 3 lines - log_msg_obj = DevtoolsLog(objects, caller=_textual_calling_frame) - devtools.log( - log_msg_obj, - group, - verbosity, - ) - except Exception as error: - self._handle_exception(error) - # else: - # log_payload = { - # "group": group.value, - # "verbosity": verbosity.value, - # "timestamp": int(time()), - # "path": getattr(log_msg_obj.caller, "filename", ""), - # "line_number": getattr(log_msg_obj.caller, "lineno", 0), - # } + if group.value in group_to_level: + self.services.logging_service( + level=group_to_level.get(group.value, logging.INFO), + msg=log_msg_str, + exc_info=getattr(log_msg_obj.caller, "exc_info", None), + extra={ + "session_id": id(self), + "group": group.name, + "path": getattr(log_msg_obj.caller, "filename", ""), + "line_number": getattr(log_msg_obj.caller, "lineno", 0), + }, + ) + log_payload: LogPayload = { + "level": group_to_level.get(group.value, logging.INFO), + "msg": log_msg_str, + "exc_info": getattr(log_msg_obj.caller, "exc_info", None), + "session_id": id(self), + "group": group.name, + "path": getattr(log_msg_obj.caller, "filename", ""), + "line_number": getattr(log_msg_obj.caller, "lineno", 0), + + } + self.services.logging_service.publish_to_signal(log_payload) def action_log_debug_readout(self) -> None: diff --git a/src/term_desktop/services/apps.py b/src/term_desktop/services/apps.py index fb5ab34..04142f1 100644 --- a/src/term_desktop/services/apps.py +++ b/src/term_desktop/services/apps.py @@ -100,7 +100,9 @@ async def start(self) -> bool: raise e else: if len(self.registered_apps) == 0: - self.log.error("Loader 'worked', but no apps were discovered. Must have malfunctioned.") + self.log.error( + "Loader 'worked', but no apps were discovered. Must have malfunctioned." + ) return True else: self.log.info( @@ -267,7 +269,9 @@ async def _launch_app(self, TDE_App: type[TDEAppBase]) -> None: try: app_process = TDE_App(process_id=process_id, instance_num=instance_num) except Exception as e: - raise RuntimeError(f"Error while creating app process '{TDE_App.__class__.__name__}': {e}") from e + raise RuntimeError( + f"Error while creating app process '{TDE_App.__class__.__name__}': {e}" + ) from e # Stage 3: Add the app process to the process dictionary try: @@ -317,7 +321,10 @@ async def _launch_app(self, TDE_App: type[TDEAppBase]) -> None: # Custom settings will override the default settings default_window_settings = app_process.default_window_settings custom_window_settings = app_process.custom_window_settings() - window_settings: DefaultWindowSettings = {**default_window_settings, **custom_window_settings} + window_settings: DefaultWindowSettings = { + **default_window_settings, + **custom_window_settings, + } # The custom window mounts should be a static set of decorative or utility # widgets. The window mounts and the window styles will be loaded from the @@ -389,13 +396,17 @@ async def _discover_apps(self, directories: list[Path]) -> dict[str, type[TDEApp app_tuple = ("dir", path) break if app_tuple is None: - self.log.warning(f"Directory {path} does not contain a valid app file. Skipping.") + self.log.warning( + f"Directory {path} does not contain a valid app file. Skipping." + ) continue else: continue if path.stem in apps_to_load: - self.log.error(f"App with name '{path.stem}' already exists. Skipping: {path}") + self.log.error( + f"App with name '{path.stem}' already exists. Skipping: {path}" + ) self._failed_apps[path.name] = ValueError("Duplicate app name") continue apps_to_load[path.stem] = app_tuple @@ -488,6 +499,8 @@ def _load_app_class(self, path: Path, file_or_dir: str) -> type[TDEAppBase]: "Ensure that your main app class inherits from TDEAppBase." ) except Exception as e: - raise ImportError(f"Failed to retrieve app class from module {module_name}: {str(e)}") from e + raise ImportError( + f"Failed to retrieve app class from module {module_name}: {str(e)}" + ) from e return AppClass diff --git a/src/term_desktop/services/databases.py b/src/term_desktop/services/databases.py index 8598360..ff4656a 100644 --- a/src/term_desktop/services/databases.py +++ b/src/term_desktop/services/databases.py @@ -131,7 +131,12 @@ def delete_one(self, table_name: str, column_name: str, value: Any) -> None: cursor.execute(sql_delete_query, (value,)) def update_column( - self, table_name: str, column_name: str, new_value: Any, condition_column: str, condition_value: Any + self, + table_name: str, + column_name: str, + new_value: Any, + condition_column: str, + condition_value: Any, ) -> None: """Update a column in a database table. @@ -143,7 +148,9 @@ def update_column( `UPDATE {table_name} SET {column_name} = ? WHERE {condition_column} = ?; ` """ - sql_update_query = f"UPDATE {table_name} SET {column_name} = ? WHERE {condition_column} = ?;" + sql_update_query = ( + f"UPDATE {table_name} SET {column_name} = ? WHERE {condition_column} = ?;" + ) with self.transaction() as cursor: cursor.execute(sql_update_query, (new_value, condition_value)) @@ -212,8 +219,7 @@ def __init__(self, services_manager: ServicesManager) -> None: """ super().__init__(services_manager) - self.storage_dir = Path(platformdirs.user_data_dir(appname="term-desktop", ensure_exists=True)) - self.log.debug(f"Database storage directory: {self.storage_dir}") + self.storage_dir = self.services_manager.storage_dir / "databases" self.database_owners: dict[Any, list[str]] = {} """Mapping of database owners to a list of their databases.""" @@ -231,6 +237,7 @@ def __init__(self, services_manager: ServicesManager) -> None: async def start(self) -> bool: """Start the Database service.""" self.log("Starting Database service") + self.log.debug(f"Database storage directory: {self.storage_dir}") if True: return True diff --git a/src/term_desktop/services/fileassociations.py b/src/term_desktop/services/fileassociations.py index 728adbe..a1989da 100644 --- a/src/term_desktop/services/fileassociations.py +++ b/src/term_desktop/services/fileassociations.py @@ -66,7 +66,7 @@ async def stop(self) -> bool: #################### # Methods that might need to be accessed by # anything else in TDE, including other services. - + def get_associated_application(self, file_extension: str) -> str | None: """ Get the application associated with a given file extension. diff --git a/src/term_desktop/services/screens.py b/src/term_desktop/services/screens.py index 0e716cc..d3591f1 100644 --- a/src/term_desktop/services/screens.py +++ b/src/term_desktop/services/screens.py @@ -102,7 +102,9 @@ def register_pushing_callback(self, callback: Callable[[TDEScreen], Awaitable[No raise ValueError(f"Callback {callback} is not callable.") self._pushing_callback = callback - def register_dismissing_callback(self, callback: Callable[[TDEScreen], Awaitable[None]]) -> None: + def register_dismissing_callback( + self, callback: Callable[[TDEScreen], Awaitable[None]] + ) -> None: """This is used by the Uber App Class (TermDesktop) to register a callback that will be called when a screen is dismissed. @@ -135,7 +137,9 @@ def request_screen_push( # Stage 0: Validate if not issubclass(TDE_Screen, TDEScreenBase): # type: ignore[unused-ignore] - self.log.error(f"Invalid app class: {TDE_Screen.__name__} is not a subclass of TDEAppBase") + self.log.error( + f"Invalid app class: {TDE_Screen.__name__} is not a subclass of TDEAppBase" + ) raise TypeError(f"{TDE_Screen.__name__} is not a valid TDEAppBase subclass") if TDE_Screen.SCREEN_ID is None: self.log.error(f"Invalid screen class: {TDE_Screen.__name__} has no SCREEN_ID defined.") @@ -254,7 +258,9 @@ async def _push_screen(self, TDE_Screen: type[TDEScreenBase]) -> None: try: screen_instance = tde_screen(process_context=screen_context) except Exception as e: - raise RuntimeError(f"Failed to create screen instance for {TDE_Screen.SCREEN_ID}: {e}") from e + raise RuntimeError( + f"Failed to create screen instance for {TDE_Screen.SCREEN_ID}: {e}" + ) from e # Stage 7: Store the screen instance in the dictionary self._screen_instance_dict[process_id] = screen_instance diff --git a/src/term_desktop/services/servicebase.py b/src/term_desktop/services/servicebase.py index 7b7f3b5..a95778c 100644 --- a/src/term_desktop/services/servicebase.py +++ b/src/term_desktop/services/servicebase.py @@ -166,10 +166,10 @@ def run_worker( Positional and keyword arguments are passed to the work function, which is the "work" key in the worker_meta dictionary. - + So for example if you pass in something like: `worker = self.run_worker(window_meta, worker_meta=worker_meta)` - + ...then the `window_meta` dictionary will be passed to the work function as its first positional argument. This uses functools.partial under the hood: `partial(worker_meta["work"], *args, **kwargs)` diff --git a/src/term_desktop/services/servicesmanager.py b/src/term_desktop/services/servicesmanager.py index c626c71..c39404b 100644 --- a/src/term_desktop/services/servicesmanager.py +++ b/src/term_desktop/services/servicesmanager.py @@ -6,11 +6,14 @@ from typing import TypedDict, Callable, Any, cast, TYPE_CHECKING from functools import partial from time import time - +from pathlib import Path +import platformdirs if TYPE_CHECKING: import rich.repr -# from uuid import uuid4 +# from logging import Logger +# from ezpubsub import Signal + # Textual imports from textual import on @@ -21,6 +24,7 @@ # Local imports from term_desktop.services.servicebase import TDEServiceBase +from term_desktop.services.tde_logging import LoggingService from term_desktop.services.apps import AppService from term_desktop.services.windows import WindowService from term_desktop.services.screens import ScreenService @@ -71,12 +75,13 @@ class ActiveWorkerInfo(TypedDict): class ServicesStarted(Message): """Message to indicate that all services have been started.""" + def __init__(self) -> None: super().__init__() - - + @dataclass(frozen=True) class Services: + logging_service: LoggingService shell_service: ShellService screen_service: ScreenService window_service: WindowService @@ -84,6 +89,7 @@ class Services: database_service: DatabaseService file_association_service: FileAssociationService + initialized = False def __init__(self) -> None: super().__init__() @@ -91,6 +97,12 @@ def __init__(self) -> None: # display = False to make this a non-visible background widget. self.display = False + self.storage_dir = Path( + platformdirs.user_data_dir(appname="term-desktop", ensure_exists=True) + ) + """ServicesManager storage directory. Use this as the path for any services + that need to store data on disk.""" + # _active_workers is a dict that maps # worker IDs to tuples of (worker name, service ID, start time) # self._active_workers: dict[str, tuple[str, str, float]] = {} @@ -103,30 +115,78 @@ def __init__(self) -> None: # This is a debounce flag to prevent # _check_running_workers from being called too frequently: self._worker_check_pending = False - - # Create instances of the services + try: - self._services = ServicesManager.Services( - shell_service=ShellService(self), - screen_service=ScreenService(self), - window_service=WindowService(self), - app_service=AppService(self), - database_service=DatabaseService(self), - file_association_service=FileAssociationService(self), - ) + logging_service = LoggingService(self) + except Exception as e: + raise RuntimeError(f"Failed to initialize LoggingService: {str(e)}") from e + + try: + shell_service = ShellService(self) + except Exception as e: + raise RuntimeError(f"Failed to initialize ShellService: {str(e)}") from e + + try: + screen_service = ScreenService(self) + except Exception as e: + raise RuntimeError(f"Failed to initialize ScreenService: {str(e)}") from e + + try: + window_service = WindowService(self) + except Exception as e: + raise RuntimeError(f"Failed to initialize WindowService: {str(e)}") from e + + try: + app_service = AppService(self) except Exception as e: - raise RuntimeError(f"Failed to initialize services: {str(e)}") from e + raise RuntimeError(f"Failed to initialize AppService: {str(e)}") from e + + try: + database_service = DatabaseService(self) + except Exception as e: + raise RuntimeError(f"Failed to initialize DatabaseService: {str(e)}") from e + + try: + file_association_service = FileAssociationService(self) + except Exception as e: + raise RuntimeError(f"Failed to initialize FileAssociationService: {str(e)}") from e + + self._services = ServicesManager.Services( + logging_service = logging_service, + shell_service = shell_service, + screen_service = screen_service, + window_service = window_service, + app_service = app_service, + database_service = database_service, + file_association_service = file_association_service, + ) + + self.initialized = True def __rich_repr__(self) -> rich.repr.Result: - yield f"{self.shell_service.SERVICE_ID}: \n", self.shell_service.processes.keys() - yield f"{self.screen_service.SERVICE_ID}: \n", self.screen_service.processes.keys() - yield f"{self.window_service.SERVICE_ID}: \n", self.window_service.processes.keys() - yield f"{self.app_service.SERVICE_ID}: \n", self.app_service.processes.keys() - yield f"{self.database_service.SERVICE_ID}: \n", self.database_service.processes.keys() + if self.initialized: + yield f"{self.logging_service.SERVICE_ID}: \n", self.logging_service.processes.keys() + yield f"{self.shell_service.SERVICE_ID}: \n", self.shell_service.processes.keys() + yield f"{self.screen_service.SERVICE_ID}: \n", self.screen_service.processes.keys() + yield f"{self.window_service.SERVICE_ID}: \n", self.window_service.processes.keys() + yield f"{self.app_service.SERVICE_ID}: \n", self.app_service.processes.keys() + yield f"{self.database_service.SERVICE_ID}: \n", self.database_service.processes.keys() + else: + yield "ServicesManager not initialized" ################## # ~ Properties ~ # ################## + + @property + def services(self) -> Services: + """Access all services.""" + return self._services + + @property + def logging_service(self) -> LoggingService: + """Access the Logging Service.""" + return self._services.logging_service @property def shell_service(self) -> ShellService: @@ -147,12 +207,12 @@ def window_service(self) -> WindowService: def app_service(self) -> AppService: """Access the App Service.""" return self._services.app_service - + @property def database_service(self) -> DatabaseService: """Access the Database Service.""" return self._services.database_service - + @property def fileassociation_service(self) -> FileAssociationService: """Access the File Association Service.""" @@ -160,14 +220,15 @@ def fileassociation_service(self) -> FileAssociationService: @property def active_workers(self) -> dict[str, ServicesManager.ActiveWorkerInfo]: - """A dictionary of active workers with their IDs, names, service IDs, and start times.""" + """A dictionary of active workers with their IDs, names, service IDs, + and start times.""" return self._active_workers #################### # ~ External API ~ # #################### - def start_all_services(self) -> None: + async def start_all_services(self) -> None: """Start all services.""" worker_meta: ServicesManager.WorkerMeta = { @@ -181,7 +242,11 @@ def start_all_services(self) -> None: "exclusive": True, "thread": False, } - self.run_worker(worker_meta=worker_meta) + try: + worker: Worker[bool] = self.run_worker(worker_meta=worker_meta) + await worker.wait() + except Exception as e: + raise e #! Override def run_worker( # type: ignore @@ -255,28 +320,29 @@ def run_worker( # type: ignore # ~ Internal ~ # ################ - async def _start_all_services(self) -> None: + async def _start_all_services(self) -> bool | None: """ # ? This will eventually be built out to have some kind of monitoring # system to watch the state of active services, stop/restart them, etc. """ - + # Using the services dataclass as the source of truth for what services exist. # Prevents code duplication in this method. for service_name, service in self._services.__dict__.items(): - self.log(f"Starting {service_name}...") try: assert isinstance(service, TDEServiceBase) service_success = await service.start() except RuntimeError: raise except Exception as e: - raise RuntimeError(f"{service_name} startup failed with an unexpected error: {str(e)}") from e + raise RuntimeError( + f"{service_name} startup failed with an unexpected error: {str(e)}" + ) from e else: if not service_success: raise RuntimeError(f"{service_name} startup returned False after running.") self.log(f"{service_name} started up successfully.") - + self.post_message(self.ServicesStarted()) @on(Worker.StateChanged) @@ -302,7 +368,9 @@ def _worker_state_changed(self, event: Worker.StateChanged) -> None: elif worker.state == WorkerState.ERROR: self.log.error( - Text.from_markup(f"[bold red]Worker {worker.name} encountered an error: {worker.error!r}") + Text.from_markup( + f"[bold red]Worker {worker.name} encountered an error: {worker.error!r}" + ) ) # In the future this should be replaced by a proper error screen. @@ -315,7 +383,9 @@ def _worker_state_changed(self, event: Worker.StateChanged) -> None: del self._active_workers[worker_id] elif worker.state == WorkerState.SUCCESS: - self.log(Text.from_markup(f"[bold green]Worker {worker.name} has completed successfully.")) + self.log( + Text.from_markup(f"[bold green]Worker {worker.name} has completed successfully.") + ) # Remove the worker from the active workers dict worker_id = getattr(worker, "worker_id") # type: ignore[unused-ignore] @@ -341,7 +411,9 @@ def _check_running_workers(self) -> None: at_least_one_from_service_manager = True start_time = cast(float, getattr(worker, "start_time")) elapsed_time = time() - start_time - log_string += f"{worker.name}:\n{worker.state.name} | Elapsed time: {elapsed_time:.2f}\n" + log_string += ( + f"{worker.name}:\n{worker.state.name} | Elapsed time: {elapsed_time:.2f}\n" + ) # And now we can track any over the time limit if elapsed_time > 10: @@ -349,7 +421,9 @@ def _check_running_workers(self) -> None: # This function restarts itself if one of the workers is ours. if at_least_one_from_service_manager: - self.log(Text.from_markup(f"[bold yellow]Worker Status[/bold yellow]\n{log_string}")) + self.log( + Text.from_markup(f"[bold yellow]Worker Status[/bold yellow]\n{log_string}") + ) self._worker_check_pending = True self.set_timer(3, self._check_running_workers) else: @@ -367,16 +441,24 @@ def _check_running_workers(self) -> None: async def on_unmount(self) -> None: """Unmount the ServicesManager and stop all services.""" - + for service_name, service in self._services.__dict__.items(): - self.log(f"Stopping {service_name}...") - try: - service_success = await service.stop() - except RuntimeError: - raise - except Exception as e: - raise RuntimeError(f"{service_name} shutdown failed with an unexpected error: {str(e)}") from e - else: - if not service_success: - raise RuntimeError(f"{service_name} shutdown returned False after running.") - self.log(f"{service_name} stopped successfully.") \ No newline at end of file + if service_name != "logging_service": + try: + service_success = await service.stop() + except RuntimeError: + raise + except Exception as e: + raise RuntimeError( + f"{service_name} shutdown failed with an unexpected error: {str(e)}" + ) from e + else: + if not service_success: + raise RuntimeError(f"{service_name} shutdown returned False after running.") + self.log(f"{service_name} stopped successfully.") + + # stop logging service last + try: + await self.logging_service.stop() + except Exception as e: + raise e diff --git a/src/term_desktop/services/shells.py b/src/term_desktop/services/shells.py index 390faa3..4fcaa95 100644 --- a/src/term_desktop/services/shells.py +++ b/src/term_desktop/services/shells.py @@ -96,7 +96,9 @@ async def start(self) -> bool: raise e else: if len(self.registered_shells) == 0: - self.log.error("Loader 'worked', but no shells were discovered. Must have malfunctioned.") + self.log.error( + "Loader 'worked', but no shells were discovered. Must have malfunctioned." + ) return True else: self.log.info( @@ -180,12 +182,16 @@ def request_shell_launch( # More validation should go here in the future if not issubclass(TDE_Shell, TDEShellBase): # type: ignore[unused-ignore] - self.log.error(f"Invalid shell class: {TDE_Shell.__name__} is not a subclass of TDEShellBase") + self.log.error( + f"Invalid shell class: {TDE_Shell.__name__} is not a subclass of TDEShellBase" + ) raise TypeError(f"{TDE_Shell.__name__} is not a valid TDEShellBase subclass") asyncio.create_task(self._launch_shell_runner(TDE_Shell)) - def register_mounting_callback(self, callback: Callable[[TDEShellSession], Awaitable[None]]) -> None: + def register_mounting_callback( + self, callback: Callable[[TDEShellSession], Awaitable[None]] + ) -> None: """This is used by the MainScreen class to register a callback that will be called when a new shell is mounted. @@ -201,7 +207,9 @@ def register_mounting_callback(self, callback: Callable[[TDEShellSession], Await raise ValueError(f"Callback {callback} is not callable.") self._mounting_callback = callback - def register_unmounting_callback(self, callback: Callable[[TDEShellSession], Awaitable[None]]) -> None: + def register_unmounting_callback( + self, callback: Callable[[TDEShellSession], Awaitable[None]] + ) -> None: """This is used by the MainScreen Class to register a callback that will be called when a shell is unmounted. @@ -387,7 +395,9 @@ async def _discover_shells(self, directories: list[Path]) -> dict[str, type[TDES continue if path.stem in shells_to_load: - self.log.error(f"Shell with name '{path.stem}' already exists. Skipping: {path}") + self.log.error( + f"Shell with name '{path.stem}' already exists. Skipping: {path}" + ) self._failed_shells[path.name] = ValueError("Duplicate shell name") continue shells_to_load[path.stem] = shell_path @@ -468,7 +478,9 @@ def _load_shell_class(self, path: Path) -> type[TDEShellBase]: ShellClass = next( cls for _name_, cls in module.__dict__.items() - if isinstance(cls, type) and issubclass(cls, TDEShellBase) and cls is not TDEShellBase + if isinstance(cls, type) + and issubclass(cls, TDEShellBase) + and cls is not TDEShellBase ) except StopIteration: raise ImportError( @@ -476,6 +488,8 @@ def _load_shell_class(self, path: Path) -> type[TDEShellBase]: "Ensure that your main shell class inherits from TDEShellBase." ) except Exception as e: - raise ImportError(f"Failed to retrieve shell class from module {module_name}: {str(e)}") from e + raise ImportError( + f"Failed to retrieve shell class from module {module_name}: {str(e)}" + ) from e return ShellClass diff --git a/src/term_desktop/services/tde_logging.py b/src/term_desktop/services/tde_logging.py new file mode 100644 index 0000000..d686cb4 --- /dev/null +++ b/src/term_desktop/services/tde_logging.py @@ -0,0 +1,248 @@ +"tde_logging.py - Logging service." + +from __future__ import annotations +from typing import TYPE_CHECKING, Any, TypedDict, NamedTuple +import logging +from pathlib import Path +import inspect +from collections import deque +# import platformdirs +if TYPE_CHECKING: + from term_desktop.services.servicesmanager import ServicesManager +from ezpubsub import Signal +from pythonjsonlogger.json import JsonFormatter + +# Textual imports +from term_desktop.services.servicebase import TDEServiceBase +from term_desktop.aceofbase import AceOfBase #, ProcessContext, ProcessType + +# Textual library imports +# None + +# Local imports +# None + + + +class LogPayload(TypedDict): + """This is for the log signal. It can be subsribed to using + the `subscribe_to_logs` method on the LoggingService. + + Required: + - level: int + - msg: str + - exc_info: inspect.Traceback | None + - session_id: int + - group: str + - path: str + - line_number: int + """ + + level: int + msg: str + exc_info: inspect.Traceback | None + session_id: int + group: str + path: str + line_number: int + + +#? This class is copied from the textual devtools module. +# It's here so we can use it without needing the devtools package. +class DevtoolsLog(NamedTuple): + objects_or_string: tuple[Any, ...] | str + caller: inspect.Traceback + + +class HybridFIFOHandler(logging.FileHandler): + def __init__( + self, + filename: Path, + memory_buffer: deque[str], + cleanup_interval: int = 100, + **kwargs: Any, + ): + super().__init__(filename, mode="a", encoding="utf-8", **kwargs) + self.filename = filename + self.cleanup_interval = cleanup_interval + self.message_count = 0 + self.memory_buffer = memory_buffer + + # Load existing logs into memory + try: + with open(self.filename, "r") as f: + lines = f.readlines() + for line in lines: + self.memory_buffer.append(line.strip()) + except FileNotFoundError: + pass + + def emit(self, record: logging.LogRecord) -> None: + super().emit(record) + + formatted = self.format(record) + self.memory_buffer.append(formatted) + self.message_count += 1 + + # Periodic cleanup (expensive operation done rarely) + if self.message_count % self.cleanup_interval == 0: + try: + # Use our memory buffer to rewrite file efficiently + with open(self.filename, "w") as f: + for line in self.memory_buffer: + f.write(line + "\n") + except (IOError, OSError): + pass + #! this should do something better. + + +class LoggerProcess(AceOfBase): + + def __init__(self, process_id: str, logs_dir: Path, max_lines:int = 1000) -> None: + super().__init__() + + self.process_id = process_id + self.memory_buffer: deque[str] = deque(maxlen=max_lines) + + self.tde_logger = logging.getLogger(process_id) + self.tde_logger.setLevel(logging.DEBUG) + + # Every logging process will get its own log file. + # There's only one default process at the moment, but I have + # a feeling that this will be useful in the future. + handler = HybridFIFOHandler( + logs_dir / f"{self.process_id}.log", + memory_buffer=self.memory_buffer, + cleanup_interval=100, + ) + formatter = JsonFormatter() + handler.setFormatter(formatter) + self.tde_logger.addHandler(handler) + + def __call__(self, msg: str, level: int = logging.INFO, **kwargs: Any) -> LoggerProcess: + """Log a message with the given level. + + Args: + message (str): The message to log. + level (int, optional): The logging level. Defaults to logging.INFO. + **kwargs: Additional keyword arguments to pass to the logger. + + Returns: + LoggerProcess: Returns self to allow for method chaining. + """ + + if 'exc_info' in kwargs: + exc_info = kwargs.pop('exc_info') + else: + exc_info = None + + self.tde_logger.log(level, msg, exc_info=exc_info, **kwargs) + + return self + +class LoggingService(TDEServiceBase[LoggerProcess]): + + ##################### + # ~ Initialzation ~ # + ##################### + + SERVICE_ID = "logging_service" + logger_name = "tde_logger" + + def __init__(self, services_manager: ServicesManager) -> None: + """ + Initialize the logging service + """ + super().__init__(services_manager) + + self.logs_dir = self.services_manager.storage_dir / "logs" + self.logs_dir.mkdir(parents=True, exist_ok=True) + self.max_lines = 1000 #* make this a config setting + self.start_default_logger() + + def start_default_logger(self) -> None: + + # instance_num = self._get_available_instance_num(self.logger_name) + # if instance_num == 1: + # process_id = self.logger_name + # else: + # process_id = f"{self.logger_name}_{instance_num}" + + tde_logger_process = LoggerProcess( + process_id=self.logger_name, + logs_dir=self.logs_dir, + max_lines=self.max_lines, + ) + self._add_process_to_dict(tde_logger_process, self.logger_name) + self.log_signal: Signal[LogPayload] = Signal("log_signal") + + ################ + # ~ Messages ~ # + ################ + # None yet + + #################### + # ~ External API ~ # + #################### + # Methods that might need to be accessed by + # anything else in TDE, including other services. + + @property + def loggers(self) -> dict[str, LoggerProcess]: + """Alias for self.processes on the Logging service""" + return self.processes + + @property + def logger(self) -> LoggerProcess: + """Return the default logger process.""" + return self.processes[self.logger_name] + + @property + def memory_buffer(self) -> deque[str]: + """Get a copy of the default logger process memory buffer.""" + return self.processes[self.logger_name].memory_buffer.copy() + + @property + def recent_logs(self) -> deque[str]: + "Alias for self.memory_buffer" + return self.memory_buffer + + def __call__(self, msg: str, *args: Any, **kwargs: Any) -> LoggerProcess: + """Call the default logger process.""" + return self.processes[self.logger_name](msg, *args, **kwargs) + + async def start(self) -> bool: + """Start the Logging service.""" + + if len(self.processes) == 0: + self.start_default_logger() + return True + else: + if isinstance(self.processes.get(self.logger_name), LoggerProcess): + return True + else: + return False + + async def stop(self) -> bool: + self.log("Stopping Logging service") + try: + self.processes.clear() + self.log_signal.clear() + except Exception as e: + raise e + else: + return True + + def subscribe_to_signal(self, callback: Any) -> None: + """Subscribe to log messages.""" + self.log_signal.subscribe(callback) + + def publish_to_signal(self, log_payload: LogPayload) -> None: + """Publish a log message to the signal.""" + self.log_signal.publish(log_payload) + + ################ + # ~ Internal ~ # + ################ + # Methods that are only used inside this service. + # These should be marked with a leading underscore. \ No newline at end of file diff --git a/src/term_desktop/services/windows.py b/src/term_desktop/services/windows.py index 8a5ce7a..138064b 100644 --- a/src/term_desktop/services/windows.py +++ b/src/term_desktop/services/windows.py @@ -178,7 +178,9 @@ async def request_new_window(self, window_meta: WindowMeta) -> None: ) raise TypeError(f"{content_instance.__name__} is not a valid TDEMainWidget subclass") if callback_id not in self.window_manager.mounting_callbacks: - raise ValueError(f"Callback ID '{callback_id}' is not registered in the window manager.") + raise ValueError( + f"Callback ID '{callback_id}' is not registered in the window manager." + ) asyncio.create_task(self._mount_window_runner(window_meta)) @@ -343,7 +345,9 @@ def _window_unregistered(self, window: Window) -> None: self.log.error(f"Failed to remove window process '{window_process_id}': {e}") raise e else: - self.log.debug(f"Removed window instance with ID '{window_process_id}' from window processes.") + self.log.debug( + f"Removed window instance with ID '{window_process_id}' from window processes." + ) # Only shutdown the app process if removing the window was successful. self.services_manager.app_service.shutdown_app(app_process_id) diff --git a/src/term_desktop/shell/default/explorer.py b/src/term_desktop/shell/default/explorer.py index 2ccd877..e92e495 100644 --- a/src/term_desktop/shell/default/explorer.py +++ b/src/term_desktop/shell/default/explorer.py @@ -204,7 +204,9 @@ def compose(self) -> ComposeResult: scan_button = Button("Scan Directory (ctrl+s)", id="scan_directory") scan_button.compact = True yield scan_button - scan_spinner = SpinnerWidget(text="Scanning...", id="scan_spinner", mount_running=False) + scan_spinner = SpinnerWidget( + text="Scanning...", id="scan_spinner", mount_running=False + ) scan_spinner.display = False yield scan_spinner yield ExplorerResizeBar(self) diff --git a/src/term_desktop/shell/default/taskbar.py b/src/term_desktop/shell/default/taskbar.py index b60fcdf..d07791a 100644 --- a/src/term_desktop/shell/default/taskbar.py +++ b/src/term_desktop/shell/default/taskbar.py @@ -31,7 +31,9 @@ def __init__(self, content: str, id: str, window_bar: WindowBar) -> None: self.click_started_on: bool = False if not hasattr(self, "on_mouse_up"): - raise NotImplementedError(f"{self.__class__.__name__} must implement the on_mouse_up method.") + raise NotImplementedError( + f"{self.__class__.__name__} must implement the on_mouse_up method." + ) def on_mouse_down(self, event: events.MouseDown) -> None: diff --git a/uv.lock b/uv.lock index 7dde174..6a0c4b8 100644 --- a/uv.lock +++ b/uv.lock @@ -691,6 +691,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157 }, ] +[[package]] +name = "python-json-logger" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/de/d3144a0bceede957f961e975f3752760fbe390d57fbe194baf709d8f1f7b/python_json_logger-3.3.0.tar.gz", hash = "sha256:12b7e74b17775e7d565129296105bbe3910842d9d0eb083fc83a6a617aa8df84", size = 16642 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/20/0f2523b9e50a8052bc6a8b732dfc8568abbdc42010aef03a2d750bdab3b2/python_json_logger-3.3.0-py3-none-any.whl", hash = "sha256:dd980fae8cffb24c13caf6e158d3d61c0d6d22342f932cb6e9deedab3d35eec7", size = 15163 }, +] + [[package]] name = "rich" version = "14.1.0" @@ -735,6 +744,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "ezpubsub" }, + { name = "python-json-logger" }, { name = "textual" }, { name = "textual-autocomplete" }, { name = "textual-coloromatic" }, @@ -759,6 +769,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "ezpubsub", specifier = ">=0.2.0" }, + { name = "python-json-logger", specifier = ">=3.3.0" }, { name = "textual", specifier = ">=5.3.0" }, { name = "textual-autocomplete", specifier = ">=4.0.4" }, { name = "textual-coloromatic", specifier = ">=1.0.0" }, From ea86fa757e945464a0e51e3e619cce2d7e895a13 Mon Sep 17 00:00:00 2001 From: Edward Jazzhands Date: Wed, 3 Sep 2025 01:56:09 -0700 Subject: [PATCH 2/2] working on this commit --- src/term_desktop/apps/syslogs.py | 115 ++++++-- src/term_desktop/main.py | 25 +- src/term_desktop/screens/mainscreen.py | 4 + src/term_desktop/services/databases.py | 2 +- src/term_desktop/services/servicesmanager.py | 47 ++-- src/term_desktop/services/tde_logging.py | 273 +++++++++++++------ src/term_desktop/shell/desktop.py | 5 + src/term_desktop/shell/shellbase.py | 5 + src/term_desktop/shell/shellmanager.py | 5 + 9 files changed, 334 insertions(+), 147 deletions(-) diff --git a/src/term_desktop/apps/syslogs.py b/src/term_desktop/apps/syslogs.py index 9c54316..629d2f4 100644 --- a/src/term_desktop/apps/syslogs.py +++ b/src/term_desktop/apps/syslogs.py @@ -4,6 +4,10 @@ # Python imports from __future__ import annotations +import logging +import time + +# import inspect # from typing import Any, Type # import os @@ -11,8 +15,11 @@ # import platform # Textual imports +# import rich.repr from textual.app import ComposeResult -from textual.widgets import RichLog # , Static +from textual.widgets import DataTable #, Static +# from textual.containers import Container +from rich.text import Text # Unused Textual imports (for reference): # from textual import events, on @@ -29,7 +36,7 @@ LaunchMode, CustomWindowSettings, ) -from term_desktop.services.tde_logging import LogPayload +# from term_desktop.services.tde_logging import LogPayload class SysLogsMeta(TDEAppBase): @@ -65,47 +72,107 @@ def custom_window_settings(self) -> CustomWindowSettings: """ return { # This returns an empty dictionary when not overridden. + "starting_horizontal": "right", # default is "center" + # "start_open": False, # default is True + # "allow_resize": False, # default is True + # "allow_maximize": False, # default is True # see CustomWindowSettings for more options } def window_styles(self) -> WindowStylesDict: return { - "width": 60, # - "height": 30, # - # "max_width": None, # default is 'size of the parent container' - # "max_height": None, # default is 'size of the parent container' - # "min_width": 12, # - # "min_height": 6, # + "width": 87, + "height": 30, } class SysLogsWidget(TDEMainWidget): - # DEFAULT_CSS = """ - # #title { border: solid $primary; } - # #content { width: auto; height: auto; } - # """ - + DEFAULT_CSS = """ + #menubar_placeholder { width: 1fr; height: 1; } + """ + def compose(self) -> ComposeResult: - yield RichLog(id="log_viewer") + self.initialized = False + self.counter = 0 + yield DataTable( + id="log_viewer_table", + zebra_stripes=True, + cursor_type="row", + ) + + def on_mount(self) -> None: + + table: DataTable[str | Text] = self.query_one("#log_viewer_table", DataTable) + self.index_key = table.add_column(" ", key="index") + table.add_column("Message", key="message", width=60) + table.add_column("Group", key="level") + table.add_column("Path", key="path") + table.add_column("Line", key="line_number") + table.add_column("Time", key="created") + table.add_column("Session ID", key="session_id") - def on_mount(self): - log_viewer = self.query_one(RichLog) log_memory = self.services.logging_service.memory_buffer - + # write out the memory buffer to the log viewer for record in log_memory: - log_viewer.write(record) - + self.handle_new_log(record) + # subscribe to new log records - self.services.logging_service.log_signal.subscribe(self.handle_new_log) + self.services.logging_service.subscribe_to_signal(self.handle_new_log) - def handle_new_log(self, log_payload: LogPayload) -> None: - log_viewer = self.query_one(RichLog) - log_viewer.write(log_payload["msg"]) + table.scroll_end() + self.initialized = True + + + def handle_new_log(self, log_record: logging.LogRecord) -> None: + table: DataTable[str | Text] = self.query_one("#log_viewer_table", DataTable) + row_key = table.add_row( + str(self.counter), + Text(str(log_record.msg), overflow="fold"), + str(log_record.__dict__.get("group")), + Text(str(log_record.__dict__.get("path")), overflow="fold"), + str(log_record.__dict__.get("line_number")), + time.strftime("%H:%M:%S", time.localtime(log_record.__dict__.get("timestamp"))), + str(log_record.__dict__.get("session_id")), + ) + # self.counter += 1 + row_index = table.get_row_index(row_key) + table.update_cell(row_key, self.index_key, str(row_index)) + if self.initialized: + table.scroll_end() + + # name=tde_logger ▊ + # msg=RichLog(id='log_viewer') was focused ▊ + # args=() ▊ + # levelname=DEBUG ▊ + # levelno=10 ▊ + # pathname=/home/brent/vscode-projects/term-desktop/src/term_desktop/services/tde_logging.py ▊ + # filename=tde_logging.py ▊ + # module=tde_logging ▊ + # exc_info=None ▊ + # exc_text=None ▊ + # stack_info=None ▊ + # lineno=154 ▊ + # funcName=log ▊ + # created=1756861622.4891434 ▊ + # msecs=489.0 ▊ + # relativeCreated=8955812.801361084 ▊ + # thread=139809355833472 ▊ + # threadName=MainThread ▊ + # processName=MainProcess ▊ + # process=60827 ▊ + # taskName=Task-1 ▊ + # session_id=139809345227616 ▊ + # group=DEBUG ▊ + # path=/home/brent/vscode-projects/term-desktop/.venv/lib/python3.12/site-packages/textual/screen.py ▊ + # line_number=1042 ▊ + # message=RichLog(id='log_viewer') was focused + + def on_unmount(self) -> None: # unsubscribe from log records - self.services.logging_service.log_signal.unsubscribe(self.handle_new_log) + self.services.logging_service.unsubscribe_from_signal(self.handle_new_log) diff --git a/src/term_desktop/main.py b/src/term_desktop/main.py index 91acee6..17a2472 100644 --- a/src/term_desktop/main.py +++ b/src/term_desktop/main.py @@ -5,9 +5,12 @@ from typing import TYPE_CHECKING, Literal, Any import sys import inspect +import time + # from pathlib import Path # import platformdirs import logging + # from pythonjsonlogger.json import JsonFormatter if TYPE_CHECKING: @@ -33,15 +36,13 @@ # Local imports # ################# from term_desktop.services import ServicesManager -from term_desktop.services.tde_logging import DevtoolsLog, LogPayload +from term_desktop.services.tde_logging import DevtoolsLog from term_desktop.screens.screenbase import TDEScreen from term_desktop.common.exceptions import TDEException # from term_desktop.app_sdk.appbase import TDEApp - - class TermDesktop(App[None]): TITLE = "Term-Desktop" @@ -56,7 +57,7 @@ def on_load(self) -> None: self.services = ServicesManager() except Exception as e: raise e - + def compose(self) -> ComposeResult: yield self.services @@ -180,11 +181,11 @@ def _log( 6: logging.INFO, } services = getattr(self, "services", None) - if services is None: # app is not yet fully initialized + if services is None: # app is not yet fully initialized return if group.value in group_to_level: - self.services.logging_service( + self.services.logging_service.log( level=group_to_level.get(group.value, logging.INFO), msg=log_msg_str, exc_info=getattr(log_msg_obj.caller, "exc_info", None), @@ -193,19 +194,9 @@ def _log( "group": group.name, "path": getattr(log_msg_obj.caller, "filename", ""), "line_number": getattr(log_msg_obj.caller, "lineno", 0), + "timestamp": time.time(), }, ) - log_payload: LogPayload = { - "level": group_to_level.get(group.value, logging.INFO), - "msg": log_msg_str, - "exc_info": getattr(log_msg_obj.caller, "exc_info", None), - "session_id": id(self), - "group": group.name, - "path": getattr(log_msg_obj.caller, "filename", ""), - "line_number": getattr(log_msg_obj.caller, "lineno", 0), - - } - self.services.logging_service.publish_to_signal(log_payload) def action_log_debug_readout(self) -> None: diff --git a/src/term_desktop/screens/mainscreen.py b/src/term_desktop/screens/mainscreen.py index 06ef2c8..71cb2b0 100644 --- a/src/term_desktop/screens/mainscreen.py +++ b/src/term_desktop/screens/mainscreen.py @@ -49,6 +49,7 @@ class MainScreen(TDEScreen): Binding("f2", "toggle_explorer", "Toggle File Explorer"), Binding("f1", "toggle_startmenu", "Toggle Start Menu"), Binding("f5", "toggle_windowbar", "Toggle Task Bar"), + Binding("f9", "toggle_bg_animation", "Toggle Background Animation"), Binding("f12", "toggle_transparency", "Toggle Transparency"), ] @@ -82,6 +83,9 @@ def animate_ready() -> None: def action_toggle_transparency(self) -> None: self.app.ansi_color = not self.app.ansi_color self.app.push_screen(DummyScreen()) + + def action_toggle_bg_animation(self) -> None: + self.shell_manager.action_toggle_bg_animation() # @on(ToggleTaskBar) def action_toggle_windowbar(self) -> None: diff --git a/src/term_desktop/services/databases.py b/src/term_desktop/services/databases.py index ff4656a..7fd0da9 100644 --- a/src/term_desktop/services/databases.py +++ b/src/term_desktop/services/databases.py @@ -14,7 +14,7 @@ # from importlib import resources # python 3rd party -import platformdirs +# import platformdirs # Textual imports from textual.worker import WorkerError diff --git a/src/term_desktop/services/servicesmanager.py b/src/term_desktop/services/servicesmanager.py index c39404b..d20e9b0 100644 --- a/src/term_desktop/services/servicesmanager.py +++ b/src/term_desktop/services/servicesmanager.py @@ -8,6 +8,7 @@ from time import time from pathlib import Path import platformdirs + if TYPE_CHECKING: import rich.repr @@ -88,7 +89,7 @@ class Services: app_service: AppService database_service: DatabaseService file_association_service: FileAssociationService - + initialized = False def __init__(self) -> None: @@ -115,52 +116,52 @@ def __init__(self) -> None: # This is a debounce flag to prevent # _check_running_workers from being called too frequently: self._worker_check_pending = False - + try: logging_service = LoggingService(self) except Exception as e: raise RuntimeError(f"Failed to initialize LoggingService: {str(e)}") from e - + try: shell_service = ShellService(self) except Exception as e: raise RuntimeError(f"Failed to initialize ShellService: {str(e)}") from e - + try: screen_service = ScreenService(self) except Exception as e: raise RuntimeError(f"Failed to initialize ScreenService: {str(e)}") from e - + try: window_service = WindowService(self) except Exception as e: raise RuntimeError(f"Failed to initialize WindowService: {str(e)}") from e - + try: app_service = AppService(self) except Exception as e: raise RuntimeError(f"Failed to initialize AppService: {str(e)}") from e - + try: database_service = DatabaseService(self) except Exception as e: raise RuntimeError(f"Failed to initialize DatabaseService: {str(e)}") from e - + try: file_association_service = FileAssociationService(self) except Exception as e: raise RuntimeError(f"Failed to initialize FileAssociationService: {str(e)}") from e - + self._services = ServicesManager.Services( - logging_service = logging_service, - shell_service = shell_service, - screen_service = screen_service, - window_service = window_service, - app_service = app_service, - database_service = database_service, - file_association_service = file_association_service, - ) - + logging_service=logging_service, + shell_service=shell_service, + screen_service=screen_service, + window_service=window_service, + app_service=app_service, + database_service=database_service, + file_association_service=file_association_service, + ) + self.initialized = True def __rich_repr__(self) -> rich.repr.Result: @@ -177,12 +178,12 @@ def __rich_repr__(self) -> rich.repr.Result: ################## # ~ Properties ~ # ################## - + @property def services(self) -> Services: """Access all services.""" return self._services - + @property def logging_service(self) -> LoggingService: """Access the Logging Service.""" @@ -320,7 +321,7 @@ def run_worker( # type: ignore # ~ Internal ~ # ################ - async def _start_all_services(self) -> bool | None: + async def _start_all_services(self) -> None: """ # ? This will eventually be built out to have some kind of monitoring # system to watch the state of active services, stop/restart them, etc. @@ -342,7 +343,7 @@ async def _start_all_services(self) -> bool | None: if not service_success: raise RuntimeError(f"{service_name} startup returned False after running.") self.log(f"{service_name} started up successfully.") - + self.post_message(self.ServicesStarted()) @on(Worker.StateChanged) @@ -456,7 +457,7 @@ async def on_unmount(self) -> None: if not service_success: raise RuntimeError(f"{service_name} shutdown returned False after running.") self.log(f"{service_name} stopped successfully.") - + # stop logging service last try: await self.logging_service.stop() diff --git a/src/term_desktop/services/tde_logging.py b/src/term_desktop/services/tde_logging.py index d686cb4..0601562 100644 --- a/src/term_desktop/services/tde_logging.py +++ b/src/term_desktop/services/tde_logging.py @@ -1,20 +1,23 @@ "tde_logging.py - Logging service." from __future__ import annotations -from typing import TYPE_CHECKING, Any, TypedDict, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple, TypedDict, Mapping import logging from pathlib import Path import inspect +import json from collections import deque -# import platformdirs if TYPE_CHECKING: from term_desktop.services.servicesmanager import ServicesManager + from logging import _ExcInfoType, _SysExcInfoType #type: ignore + from ezpubsub import Signal from pythonjsonlogger.json import JsonFormatter # Textual imports +# import rich.repr from term_desktop.services.servicebase import TDEServiceBase -from term_desktop.aceofbase import AceOfBase #, ProcessContext, ProcessType +from term_desktop.aceofbase import AceOfBase # , ProcessContext, ProcessType # Textual library imports # None @@ -23,87 +26,110 @@ # None - class LogPayload(TypedDict): """This is for the log signal. It can be subsribed to using the `subscribe_to_logs` method on the LoggingService. - + Required: - level: int - - msg: str - - exc_info: inspect.Traceback | None + - message: str - session_id: int - group: str - path: str - line_number: int + - exc_info: inspect.Traceback | None """ - + level: int - msg: str - exc_info: inspect.Traceback | None + message: str session_id: int group: str path: str line_number: int - - -#? This class is copied from the textual devtools module. + exc_info: inspect.Traceback | None + + +# ? This class is copied from the textual devtools module. # It's here so we can use it without needing the devtools package. class DevtoolsLog(NamedTuple): objects_or_string: tuple[Any, ...] | str caller: inspect.Traceback - + class HybridFIFOHandler(logging.FileHandler): def __init__( self, filename: Path, - memory_buffer: deque[str], - cleanup_interval: int = 100, + logger_process: LoggerProcess, **kwargs: Any, ): super().__init__(filename, mode="a", encoding="utf-8", **kwargs) self.filename = filename - self.cleanup_interval = cleanup_interval + self.logger_process = logger_process + self.cleanup_interval = 100 self.message_count = 0 - self.memory_buffer = memory_buffer + self.max_lines = logger_process.max_lines # Load existing logs into memory try: with open(self.filename, "r") as f: lines = f.readlines() - for line in lines: - self.memory_buffer.append(line.strip()) except FileNotFoundError: + # If the file doesn't already exist, it's because + # the logger is new. No problem. pass + else: + for line in lines[-self.max_lines:]: + # convert line into logging.LogRecord object using json: + json_line: dict[str, Any] = json.loads(line) + log_record = self.logger_process.make_log_record( + name=json_line.get("name", ""), + level=json_line.get("level", logging.INFO), + pathname=json_line.get("path", ""), + lineno=json_line.get("line_number", 0), + msg=json_line.get("message", ""), + group=json_line.get("group", ""), + ) + self.logger_process.memory_buffer.append(log_record) + + # Cleanup log file right away if its over limit + existing_lines = len(lines) + if existing_lines > self.max_lines: + self.cleanup() + def emit(self, record: logging.LogRecord) -> None: - super().emit(record) - formatted = self.format(record) - self.memory_buffer.append(formatted) + super().emit(record) + self.logger_process.log_signal.publish(record) + self.logger_process.memory_buffer.append(record) self.message_count += 1 - - # Periodic cleanup (expensive operation done rarely) if self.message_count % self.cleanup_interval == 0: - try: - # Use our memory buffer to rewrite file efficiently - with open(self.filename, "w") as f: - for line in self.memory_buffer: - f.write(line + "\n") - except (IOError, OSError): - pass - #! this should do something better. - + self.cleanup() + + def cleanup(self): + """Periodic cleanup (expensive operation done rarely)""" + + try: + # Use the memory buffer to rewrite file efficiently + with open(self.filename, "w") as f: + for log_record in self.logger_process.memory_buffer: + f.write(self.format(log_record) + "\n") + except (IOError, OSError): + pass + #! this should probably do something better. + class LoggerProcess(AceOfBase): - - def __init__(self, process_id: str, logs_dir: Path, max_lines:int = 1000) -> None: + + def __init__(self, process_id: str, logs_dir: Path, max_lines: int = 1000) -> None: super().__init__() - + self.process_id = process_id - self.memory_buffer: deque[str] = deque(maxlen=max_lines) - + self.max_lines = max_lines + self.memory_buffer: deque[logging.LogRecord] = deque(maxlen=max_lines) + self.log_signal: Signal[logging.LogRecord] = Signal(f"{process_id}_signal") + self.tde_logger = logging.getLogger(process_id) self.tde_logger.setLevel(logging.DEBUG) @@ -112,33 +138,73 @@ def __init__(self, process_id: str, logs_dir: Path, max_lines:int = 1000) -> Non # a feeling that this will be useful in the future. handler = HybridFIFOHandler( logs_dir / f"{self.process_id}.log", - memory_buffer=self.memory_buffer, - cleanup_interval=100, + logger_process=self, ) formatter = JsonFormatter() handler.setFormatter(formatter) - self.tde_logger.addHandler(handler) - - def __call__(self, msg: str, level: int = logging.INFO, **kwargs: Any) -> LoggerProcess: - """Log a message with the given level. + self.tde_logger.addHandler(handler) - Args: - message (str): The message to log. - level (int, optional): The logging level. Defaults to logging.INFO. - **kwargs: Additional keyword arguments to pass to the logger. + def log( # type: ignore + self, + msg: object, + level: int = logging.INFO, + *args: object, + exc_info: _ExcInfoType = None, + stack_info: bool = False, + stacklevel: int = 1, + extra: Mapping[str, object] | None = None , + **kwargs: Any, + ) -> None: + """ + Log a message using this Logger Process. - Returns: - LoggerProcess: Returns self to allow for method chaining. + NOTE this is not the same as the log method everywhere else + in TDE. This one ONLY uses this TDE Logger process, which bypasses + the Textual devtools logging. """ - - if 'exc_info' in kwargs: - exc_info = kwargs.pop('exc_info') - else: - exc_info = None - - self.tde_logger.log(level, msg, exc_info=exc_info, **kwargs) + self.tde_logger.log( + level, + msg, + *args, + exc_info=exc_info, + stack_info=stack_info, + stacklevel=stacklevel, + extra=extra, + **kwargs, + ) - return self + # def __call__(self) -> logging.Logger: + # return self.tde_logger + + def make_log_record( + self, + name: str, + level: int, + pathname: str, + lineno: int, + msg: str, + group: str, + exc_info: _SysExcInfoType | None = None, + ) -> logging.LogRecord: + + return logging.LogRecord( + name=name, + level=level, + pathname=pathname, + lineno=lineno, + msg=msg, + args=(group,), + exc_info=exc_info, + ) + + def subscribe_to_signal(self, callback: Any) -> None: + """Subscribe to log messages.""" + self.log_signal.subscribe(callback) + + def publish_to_signal(self, log_record: logging.LogRecord) -> None: + """Publish a log message to the signal.""" + self.log_signal.publish(log_record) + class LoggingService(TDEServiceBase[LoggerProcess]): @@ -154,14 +220,14 @@ def __init__(self, services_manager: ServicesManager) -> None: Initialize the logging service """ super().__init__(services_manager) - + self.logs_dir = self.services_manager.storage_dir / "logs" self.logs_dir.mkdir(parents=True, exist_ok=True) - self.max_lines = 1000 #* make this a config setting + self.max_lines = 1000 # * make this a config setting self.start_default_logger() - + def start_default_logger(self) -> None: - + # instance_num = self._get_available_instance_num(self.logger_name) # if instance_num == 1: # process_id = self.logger_name @@ -174,8 +240,8 @@ def start_default_logger(self) -> None: max_lines=self.max_lines, ) self._add_process_to_dict(tde_logger_process, self.logger_name) - self.log_signal: Signal[LogPayload] = Signal("log_signal") - + # self.log_signal: Signal[logging.LogRecord] = Signal("log_signal") + ################ # ~ Messages ~ # ################ @@ -187,29 +253,54 @@ def start_default_logger(self) -> None: # Methods that might need to be accessed by # anything else in TDE, including other services. + def log( # type: ignore + self, + msg: object, + level: int = logging.INFO, + *args: object, + exc_info: _ExcInfoType = None, + stack_info: bool = False, + stacklevel: int = 1, + extra: Mapping[str, object] | None = None , + **kwargs: Any, + ) -> None: + """ + Log a message using the Logger Service. + + NOTE this is not the same as the log method everywhere else + in TDE. This one ONLY uses the logger service, which bypasses + the Textual devtools logging. + """ + self.logger.log( + msg=msg, + level=level, + *args, + exc_info=exc_info, + stack_info=stack_info, + stacklevel=stacklevel, + extra=extra, + **kwargs, + ) + @property def loggers(self) -> dict[str, LoggerProcess]: """Alias for self.processes on the Logging service""" return self.processes - + @property def logger(self) -> LoggerProcess: """Return the default logger process.""" return self.processes[self.logger_name] - + @property - def memory_buffer(self) -> deque[str]: + def memory_buffer(self) -> deque[logging.LogRecord]: """Get a copy of the default logger process memory buffer.""" return self.processes[self.logger_name].memory_buffer.copy() - + @property - def recent_logs(self) -> deque[str]: + def recent_logs(self) -> deque[logging.LogRecord]: "Alias for self.memory_buffer" return self.memory_buffer - - def __call__(self, msg: str, *args: Any, **kwargs: Any) -> LoggerProcess: - """Call the default logger process.""" - return self.processes[self.logger_name](msg, *args, **kwargs) async def start(self) -> bool: """Start the Logging service.""" @@ -226,23 +317,41 @@ async def start(self) -> bool: async def stop(self) -> bool: self.log("Stopping Logging service") try: + for process in self.processes.values(): + process.tde_logger.handlers.clear() + process.log_signal.clear() self.processes.clear() - self.log_signal.clear() except Exception as e: raise e else: return True - - def subscribe_to_signal(self, callback: Any) -> None: + + def subscribe_to_signal(self, callback: Any, logger: str = "tde_logger") -> None: """Subscribe to log messages.""" - self.log_signal.subscribe(callback) - - def publish_to_signal(self, log_payload: LogPayload) -> None: + log_process = self.processes.get(logger) + if log_process is not None: + log_process.log_signal.subscribe(callback) + else: + raise ValueError(f"Logger '{logger}' not found.") + + def publish_to_signal(self, log_record: logging.LogRecord, logger: str = "tde_logger") -> None: """Publish a log message to the signal.""" - self.log_signal.publish(log_payload) + log_process = self.processes.get(logger) + if log_process is not None: + log_process.log_signal.publish(log_record) + else: + raise ValueError(f"Logger '{logger}' not found.") + + def unsubscribe_from_signal(self, callback: Any, logger: str = "tde_logger") -> None: + """Unsubscribe from log messages.""" + log_process = self.processes.get(logger) + if log_process is not None: + log_process.log_signal.unsubscribe(callback) + else: + raise ValueError(f"Logger '{logger}' not found.") ################ # ~ Internal ~ # ################ # Methods that are only used inside this service. - # These should be marked with a leading underscore. \ No newline at end of file + # These should be marked with a leading underscore. diff --git a/src/term_desktop/shell/desktop.py b/src/term_desktop/shell/desktop.py index 59fbf49..c68a66a 100644 --- a/src/term_desktop/shell/desktop.py +++ b/src/term_desktop/shell/desktop.py @@ -103,3 +103,8 @@ def compose(self) -> ComposeResult: horizontal=True, gradient_quality=30, ) + + def toggle_bg_animation(self): + for figlet in self.query(FigletWidget): + figlet.animated = not figlet.animated + # figlet.refresh() diff --git a/src/term_desktop/shell/shellbase.py b/src/term_desktop/shell/shellbase.py index fa13381..b08d3ed 100644 --- a/src/term_desktop/shell/shellbase.py +++ b/src/term_desktop/shell/shellbase.py @@ -253,6 +253,11 @@ def action_toggle_explorer(self) -> None: def action_toggle_startmenu(self) -> None: """Open the start menu / quick launcher.""" self.query_one(StartMenu).toggle() + + def action_toggle_bg_animation(self) -> None: + """Toggle the background animation.""" + desktop = self.query_one(Desktop) + desktop.toggle_bg_animation() #################### # ~ Other Events ~ # diff --git a/src/term_desktop/shell/shellmanager.py b/src/term_desktop/shell/shellmanager.py index 1faae50..ffb22a3 100644 --- a/src/term_desktop/shell/shellmanager.py +++ b/src/term_desktop/shell/shellmanager.py @@ -81,3 +81,8 @@ def action_toggle_startmenu(self) -> None: """Open the start menu / quick launcher.""" if self.current_shell is not None: self.current_shell.action_toggle_startmenu() + + def action_toggle_bg_animation(self) -> None: + """Toggle the background animation.""" + if self.current_shell is not None: + self.current_shell.action_toggle_bg_animation() \ No newline at end of file