From dc193e6b658043d0b2203aeb61890fff621b2390 Mon Sep 17 00:00:00 2001 From: thetestgame Date: Mon, 11 Nov 2024 13:56:59 -0600 Subject: [PATCH 1/3] Added standards and new workflow --- .github/workflows/main.yml | 15 ++- panda3d_toolbox/application.py | 226 +++++++++++++++++++++++++++++++++ panda3d_toolbox/prc.py | 42 ++++-- panda3d_toolbox/runtime.py | 115 +++++++++++++++++ setup.py | 9 +- 5 files changed, 395 insertions(+), 12 deletions(-) create mode 100644 panda3d_toolbox/application.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index df70198..a0b1508 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -40,13 +40,23 @@ jobs: major_pattern: "(MAJOR)" minor_pattern: "(MINOR)" - # Build the package using the version + # Export the version as an environment variable and also export + # the current release vs prerelease state as an environment variable. + # This is determined if the event_name is release or not. Finally + # build the package. - name: Build package + if: github.event_name != 'pull_request' run: | export MAJOR=${{ steps.package-version.outputs.major }} export MINOR=${{ steps.package-version.outputs.minor }} export PATCH=${{ steps.package-version.outputs.patch }} + if [ "${{ github.event_name }}" == "release" ]; then + export RELEASE="true" + else + export RELEASE="false" + fi + echo "Building version $MAJOR.$MINOR.$PATCH" python -m build @@ -54,6 +64,7 @@ jobs: - name: Load secret id: op-load-secret uses: 1password/load-secrets-action@v2 + if: github.event_name != 'pull_request' with: export-env: false env: @@ -63,7 +74,7 @@ jobs: # Publish the package to PyPi if a release is created - name: Publish package uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 - if: github.event_name == 'release' + if: github.event_name != 'pull_request' with: user: __token__ password: ${{ steps.op-load-secret.outputs.SECRET }} \ No newline at end of file diff --git a/panda3d_toolbox/application.py b/panda3d_toolbox/application.py new file mode 100644 index 0000000..722c5ff --- /dev/null +++ b/panda3d_toolbox/application.py @@ -0,0 +1,226 @@ +""" +Application base class for creating standardized applications using the Panda3D game engine. +This module provides ApplicationBase and HeadlessApplication classes for creating applications. These +are extensions of the ShowBase class provided by the Panda3D engine. +""" + +import sys + +from direct.directnotify.DirectNotifyGlobal import directNotify +from direct.showbase.ShowBase import ShowBase + +from panda3d import core as p3d +from panda3d_toolbox import runtime, prc +import panda3d_vfs as vfs + +class ApplicationBase(ShowBase): + """ + Custom ShowBase instance for creating standardized applications + using the Panda3D game engine + """ + + def __init__(self, *args, **kwargs): + """ + Initialize the ApplicationBase instance + """ + + self.load_runtime_configuration() + ShowBase.__init__(self, *args, **kwargs) + self.notify = directNotify.newCategory('showbase') + + self.set_developer_flag() + self.set_antialias(prc.get_prc_bool('render-antialias', True)) + self.__showing_frame_rate = prc.get_prc_bool('show-frame-rate-meter', False) + + runtime.base = self + runtime.task_mgr = self.task_mgr + runtime.loader = self.loader + runtime.cam = self.cam + runtime.camera = self.camera + + self.configure_virtual_file_system() + self.accept('f1', self.render.ls) + + def load_runtime_configuration(self) -> None: + """ + Loads the runtime configuration for the application. + This is intended to be overridden by subclasses if desired. + + Preferably runtime configuration would be set prior to the instantiation + of the ApplicationBase instance in the main entry point of the application. + """ + + def set_developer_flag(self) -> None: + """ + Sets the developer flag for the application based on PRC configuration + and the current compiled state of the application. + """ + + is_compiled = False # TODO: + want_dev = prc.get_prc_bool('want-dev', is_compiled) + + runtime.dev = want_dev + __dev__ = want_dev + + def configure_virtual_file_system(self) -> None: + """ + Configures the virtual file system for the application + """ + + # TODO: mount a multifile when we are running + # under a compiled build + vfs.vfs_mount_directory('.', 'assets') + + vfs.switch_file_functions_to_vfs() + vfs.switch_io_functions_to_vfs() + + def set_window_title(self, window_title: str) -> None: + """ + Sets the primary window's title + """ + + # Verify we have a window instance + if not self.win: + return + + props = p3d.WindowProperties() + props.set_title(window_title) + self.win.request_properties(props) + + def set_window_dimensions(self, origin: tuple, size: tuple) -> None: + """ + Sets the current window dimensions + """ + + if not self.win: + return + + props = p3d.WindowProperties() + props.set_origin(*origin) + props.set_size(*size) + self.win.request_properties(props) + + def get_window_dimensions(self) -> tuple: + """ + Returns the current windows dimensions + """ + + origin = (-1, -1) + size = (-1, -1) + + if not self.win: + return (origin, size) + + props = self.win.get_properties() + if sys.platform == 'darwin': + origin = (25, 50) + elif props.has_origin(): + origin = (props.get_x_origin(), props.get_y_origin()) + + if props.has_size(): + size = (props.get_x_size(), props.get_y_size()) + + return (origin, size) + + def set_clear_color(self, clear_color: object) -> None: + """ + Sets the primary window's clear color + """ + + # Verify we have a window instance + if not self.win: + return + + self.win.set_clear_color(clear_color) + + def set_antialias(self, antialias: bool) -> None: + """ + Sets the graphics library based antialiasing state + """ + + if not prc.get_prc_bool('framebuffer-mutlisample', False): + prc.set_prc_value('framebuffer-multisample', True) + + if prc.get_prc_int('multisamples', 0) < 2: + self.notify.warning('Multisamples not set. Defaulting to a value of 2') + prc.set_prc_value('multisamples', 2) + + if antialias: + self.render.set_antialias(p3d.AntialiasAttrib.MAuto) + else: + self.render.clear_antialias() + + def post_window_setup(self) -> None: + """ + Performs setup operations after the window has succesfully + opened + """ + + def open_default_window(self) -> object: + """ + Opens a window with the default configuration + options + """ + + props = p3d.WindowProperties.get_default() + return self.openMainWindow(props = props) + + def openMainWindow(self, *args, **kwargs) -> object: + """ + Custom override of the ShowBase openMainWindow function + for handling runtime registering + """ + + result = ShowBase.openMainWindow(self, *args, **kwargs) + + if result: + runtime.window = self.win + runtime.render = self.render + + self.post_window_setup() + + return self.win + + def toggle_frame_rate(self) -> None: + """ + Toggles the application's frame rate meter + """ + + self.__showing_frame_rate = not self.__showing_frame_rate + self.set_frame_rate_meter(self.__showing_frame_rate) + + def is_oobe(self) -> bool: + """ + Returns true if the ShowBase instance is in oobe mode + """ + + if not hasattr(self, 'oobeMode'): + return False + + return self.oobeMode + + def set_exit_callback(self, func: object) -> None: + """ + Sets the showbase's shutdown callback + """ + + assert func != None + assert callable(func) + + self.exitFunc = func + +class HeadlessApplication(ApplicationBase): + """ + Headless varient of the ApplicationBase object. Creates + without a primary window instance + """ + + def __init__(self, *args, **kwargs): + """ + Initialize the HeadlessApplication instance + """ + + prc.load_headless_prc_data() + + kwargs['windowType'] = 'none' + super().__init__(*args, **kwargs) \ No newline at end of file diff --git a/panda3d_toolbox/prc.py b/panda3d_toolbox/prc.py index 65d4022..3c6a098 100644 --- a/panda3d_toolbox/prc.py +++ b/panda3d_toolbox/prc.py @@ -1,4 +1,11 @@ -from six import with_metaclass +""" +This module serves as a wrapper for the Panda3D runtime configuration system. It provides +a set of functions for loading, setting, and retrieving configuration values from the +Panda3D runtime configuration system. + +The module also provides load functions for standardized sets of configuration values +such as for headless applications. +""" from panda3d.core import ConfigVariable, ConfigVariableList, ConfigVariableString from panda3d.core import ConfigVariableFilename, ConfigVariableBool, ConfigVariableInt @@ -7,13 +14,13 @@ from panda3d.core import load_prc_file as _load_prc_file from panda3d.core import load_prc_file_data as _load_prc_file_data -from direct.directnotify.DirectNotifyGlobal import directNotify -from panda3d_toolbox import runtime -from panda3d_vfs import path_exists +from direct.directnotify.DirectNotifyGlobal import directNotify as __directNotify +from panda3d_toolbox import runtime as __runtime +from panda3d_vfs import path_exists as __path_exists #----------------------------------------------------------------------------------------------------------------------------------# -__prc_notify = directNotify.newCategory('prc') +__prc_notify = __directNotify.newCategory('prc') __prc_notify.setInfo(True) def load_prc_file_data(data: str, label: str = '') -> None: @@ -28,7 +35,7 @@ def load_prc_file_data(data: str, label: str = '') -> None: # Check if the base has already been defined # if it has been defined warn the user. - if runtime.has_base(): + if __runtime.has_base(): __prc_notify.warning('Showbase has already been defined. PRC changes may be ignored') if label != '' and label.isspace() == False: @@ -43,15 +50,15 @@ def load_prc_file(path: str, optional: bool = False) -> bool: # Check if the base has already been defined # if it has been defined warn the user. - if runtime.has_base(): + if __runtime.has_base(): __prc_notify.warning('Showbase has already been defined. PRC changes may be ignored') - if not path_exists(path) and not optional: + if not __path_exists(path) and not optional: __prc_notify.error('Failed to load prc file: %s. File does not exist' % path) return False # Return if the path does not exist and we are optional - if not path_exists(path) and optional: + if not __path_exists(path) and optional: __prc_notify.warning('Skipping optional prc: %s' % path) return False @@ -63,6 +70,23 @@ def load_prc_file(path: str, optional: bool = False) -> bool: _load_prc_file(Filename.from_os_specific(path)) return True +def load_headless_prc_data(label: str = 'headless-config') -> None: + """ + Loads the standard prc configuration values used for headless + applications built with the Panda3D engine + """ + + prc_data = """ + # Disable window + window-type none + + # Disable audio + audio-library-name null + audio-active false + """ + + load_prc_file_data(prc_data, label) + def get_prc_list(key: str) -> list: """ Retrieves a int variable from the Panda3D diff --git a/panda3d_toolbox/runtime.py b/panda3d_toolbox/runtime.py index ad2cbc4..82e10b1 100644 --- a/panda3d_toolbox/runtime.py +++ b/panda3d_toolbox/runtime.py @@ -1,5 +1,109 @@ import builtins import sys as __sys +import os as __os + +#----------------------------------------------------------------------------------------------------------------------------------# + +dev = False + +#----------------------------------------------------------------------------------------------------------------------------------# + +def __get_base_executable_name() -> str: + """ + Returns the base executable name + """ + + basename = __os.path.basename(__sys.argv[0]) + if basename == '-m': + basename = __os.environ.get('APP_NAME', 'panda3d') + + basename = __os.path.splitext(basename)[0] + return basename + +executable_name = __get_base_executable_name() + +def is_venv() -> bool: + """ + Returns true if the application is being run inside + a virtual environment + """ + + real_prefix = hasattr(__sys, 'real_prefix') + base_prefix = hasattr(__sys, 'base_prefix') and __sys.base_prefix != __sys.prefix + + return real_prefix or base_prefix + +def is_frozen() -> bool: + """ + Returns true if the application is being run from within + a frozen Python environment + """ + + import importlib + spec = importlib.util.find_spec(__name__) + return spec is not None and spec.origin is not None + +def is_interactive() -> bool: + """ + Returns true if the application is being run from an + interactive command prompt + """ + + import sys + return hasattr(sys, 'ps1') and hasattr(sys, 'ps2') + +def is_developer_build() -> bool: + """ + Returns true if the application is currently + running as a developer build + """ + + return (dev or is_interactive()) and not is_frozen() + +def is_production_build() -> bool: + """ + Returns true if the application is currently + running as a production build + """ + + return not is_developer_build() + +def get_repository() -> object: + """ + Returns the Client repository object or AI repository object + if either exist. Otherwise returning NoneType + """ + + if __has_variable('cr'): + return __get_variable('cr') + elif __has_variable('air'): + return __get_variable('air') + else: + return None + +def is_nuitka_build() -> bool: + """ + Returns true if the application is currently + running as a Nuitka build + """ + + return 'NUITKA' in __os.environ + +def is_panda3d_build() -> bool: + """ + Returns true if the application is currently + running as a Panda3d build + """ + + return is_frozen() + +def is_built_executable() -> bool: + """ + Returns true if the application is currently + running as a built executable + """ + + return is_panda3d_build() or is_nuitka_build() #----------------------------------------------------------------------------------------------------------------------------------# @@ -38,6 +142,14 @@ def __get_variable(variable_name: str) -> object: module = __get_module() return getattr(module, variable_name) +def __set_variable(variable_name: str, value: object) -> None: + """ + Sets the requested variable in the runtime module + """ + + module = __get_module() + setattr(module, variable_name, value) + def __getattr__(key: str) -> object: """ Custom get attribute handler for allowing access to the has_x method names @@ -48,6 +160,7 @@ def __getattr__(key: str) -> object: result = None is_has_method = key.startswith('has_') is_get_method = key.startswith('get_') + is_set_method = key.startswith('set_') if len(key) > 4: variable_name = key[4:] @@ -58,6 +171,8 @@ def __getattr__(key: str) -> object: result = lambda: __has_variable(variable_name) elif is_get_method: result = lambda: __get_variable(variable_name) + elif is_set_method: + result = lambda value: __set_variable(variable_name, value) elif hasattr(builtins, key): result = getattr(builtins, key) diff --git a/setup.py b/setup.py index 12e27d9..be6988d 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,14 @@ def get_version() -> str: minor = os.environ.get('MINOR', '0') patch = os.environ.get('PATCH', '0') - return f'{major}.{minor}.{patch}' + # Determine if this is a pre-release version + release_flag = os.environ.get('RELEASE', 'true').lower() + prerelease = not bool(release_flag) + + if not prerelease: + return f'{major}.{minor}.{patch}' + else: + return f'{major}.{minor}.{patch}.dev' def get_readme(filename: str = 'README.md') -> str: """ From e83bee078fe8cc84b992ab6746795cf70bd70fc5 Mon Sep 17 00:00:00 2001 From: thetestgame Date: Thu, 14 Nov 2024 20:56:35 -0600 Subject: [PATCH 2/3] Misc. changes --- panda3d_toolbox/__version__.py | 1 - panda3d_toolbox/application.py | 55 +++++++++++++++++++++------- panda3d_toolbox/prc.py | 65 +++++++++++++++++++++++++++++++++- panda3d_toolbox/runtime.py | 31 +++++++--------- 4 files changed, 119 insertions(+), 33 deletions(-) delete mode 100644 panda3d_toolbox/__version__.py diff --git a/panda3d_toolbox/__version__.py b/panda3d_toolbox/__version__.py deleted file mode 100644 index e9e169e..0000000 --- a/panda3d_toolbox/__version__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = '1.1.1' \ No newline at end of file diff --git a/panda3d_toolbox/application.py b/panda3d_toolbox/application.py index 722c5ff..fd53220 100644 --- a/panda3d_toolbox/application.py +++ b/panda3d_toolbox/application.py @@ -5,6 +5,8 @@ """ import sys +import builtins +import traceback from direct.directnotify.DirectNotifyGlobal import directNotify from direct.showbase.ShowBase import ShowBase @@ -13,7 +15,7 @@ from panda3d_toolbox import runtime, prc import panda3d_vfs as vfs -class ApplicationBase(ShowBase): +class Application(ShowBase): """ Custom ShowBase instance for creating standardized applications using the Panda3D game engine @@ -27,19 +29,19 @@ def __init__(self, *args, **kwargs): self.load_runtime_configuration() ShowBase.__init__(self, *args, **kwargs) self.notify = directNotify.newCategory('showbase') + self.exit_code = 0 self.set_developer_flag() self.set_antialias(prc.get_prc_bool('render-antialias', True)) self.__showing_frame_rate = prc.get_prc_bool('show-frame-rate-meter', False) runtime.base = self - runtime.task_mgr = self.task_mgr + runtime.task_mgr = self.taskMgr runtime.loader = self.loader runtime.cam = self.cam runtime.camera = self.camera self.configure_virtual_file_system() - self.accept('f1', self.render.ls) def load_runtime_configuration(self) -> None: """ @@ -56,20 +58,21 @@ def set_developer_flag(self) -> None: and the current compiled state of the application. """ - is_compiled = False # TODO: + is_compiled = runtime.is_built_executable() want_dev = prc.get_prc_bool('want-dev', is_compiled) - - runtime.dev = want_dev - __dev__ = want_dev + builtins.__dev__ = want_dev def configure_virtual_file_system(self) -> None: """ Configures the virtual file system for the application """ - # TODO: mount a multifile when we are running - # under a compiled build - vfs.vfs_mount_directory('.', 'assets') + if not runtime.is_built_executable(): + vfs.vfs_mount_directory('.', 'assets') + else: + multifiles = prc.get_prc_list('vfs-multifile') + for multifile in multifiles: + vfs.vfs_mount_multifile('.', multifile) vfs.switch_file_functions_to_vfs() vfs.switch_io_functions_to_vfs() @@ -209,7 +212,35 @@ def set_exit_callback(self, func: object) -> None: self.exitFunc = func -class HeadlessApplication(ApplicationBase): + def set_exit_code(self, code: int) -> None: + """ + Sets the exit code for the application + """ + + # if the exit code provided is an enum get the value + if hasattr(code, 'value'): + code = code.value + + # set the exit code + self.exit_code = code + + def execute(self) -> int: + """ + Calls the Panda3D ShowBase run() method with automatic + error handling and exit code return. + """ + + try: + self.run() + except Exception as e: + self.notify.error('An error occurred during execution: %s' % e) + self.notify.error(traceback.format_exc()) + + self.exit_code = 1 + + return self.exit_code + +class HeadlessApplication(Application): """ Headless varient of the ApplicationBase object. Creates without a primary window instance @@ -221,6 +252,4 @@ def __init__(self, *args, **kwargs): """ prc.load_headless_prc_data() - - kwargs['windowType'] = 'none' super().__init__(*args, **kwargs) \ No newline at end of file diff --git a/panda3d_toolbox/prc.py b/panda3d_toolbox/prc.py index 3c6a098..720c8e6 100644 --- a/panda3d_toolbox/prc.py +++ b/panda3d_toolbox/prc.py @@ -7,6 +7,8 @@ such as for headless applications. """ +import os as __os + from panda3d.core import ConfigVariable, ConfigVariableList, ConfigVariableString from panda3d.core import ConfigVariableFilename, ConfigVariableBool, ConfigVariableInt from panda3d.core import ConfigVariableDouble, ConfigVariableColor, ConfigVariableInt64 @@ -16,6 +18,7 @@ from direct.directnotify.DirectNotifyGlobal import directNotify as __directNotify from panda3d_toolbox import runtime as __runtime +from panda3d_toolbox import utils as __utils from panda3d_vfs import path_exists as __path_exists #----------------------------------------------------------------------------------------------------------------------------------# @@ -87,9 +90,69 @@ def load_headless_prc_data(label: str = 'headless-config') -> None: load_prc_file_data(prc_data, label) +def get_launch_double(key: str, default: int = 0) -> float: + """ + Retrieves a double variable from the environment variables if present. Otherwise + we attempt to retrieve the value from the Panda runtime configuration. If the value + is not found we return the default value. + + The key is converted to snake case and upper case for the environment variable. + Example test-variable becomes TEST_VARIABLE. + """ + + environment_key = __utils.get_snake_case(key).upper() + prc_variable = ConfigVariableDouble(key, default) + launch_value = float(__os.environ.get(environment_key, str(prc_variable.get_value()))) + return launch_value + +def get_launch_int(key: str, default: int = 0) -> int: + """ + Retrieves a int variable from the environment variables if present. Otherwise + we attempt to retrieve the value from the Panda runtime configuration. If the value + is not found we return the default value. + + The key is converted to snake case and upper case for the environment variable. + Example test-variable becomes TEST_VARIABLE. + """ + + environment_key = __utils.get_snake_case(key).upper() + prc_variable = ConfigVariableInt(key, default) + launch_value = int(__os.environ.get(environment_key, str(prc_variable.get_value()))) + return launch_value + +def get_launch_string(key: str, default: str = '') -> str: + """ + Retrieves a string variable from the environment variables if present. Otherwise + we attempt to retrieve the value from the Panda runtime configuration. If the value + is not found we return the default value. + + The key is converted to snake case and upper case for the environment variable. + Example test-variable becomes TEST_VARIABLE. + """ + + environment_key = __utils.get_snake_case(key).upper() + prc_variable = ConfigVariableString(key, default) + launch_value = __os.environ.get(environment_key, prc_variable.get_value()) + return launch_value + +def get_launch_bool(key: str, default: bool = False) -> bool: + """ + Retrieves a boolean variable from the environment variables if present. Otherwise + we attempt to retrieve the value from the Panda runtime configuration. If the value + is not found we return the default value. + + The key is converted to snake case and upper case for the environment variable. + Example test-variable becomes TEST_VARIABLE. + """ + + environment_key = __utils.get_snake_case(key).upper() + prc_variable = ConfigVariableBool(key, default) + launch_value = bool(__os.environ.get(environment_key, str(prc_variable.get_value()))) + return launch_value + def get_prc_list(key: str) -> list: """ - Retrieves a int variable from the Panda3D + Retrieves a list variable from the Panda3D runtime configuration if present. Otherwise the default value """ diff --git a/panda3d_toolbox/runtime.py b/panda3d_toolbox/runtime.py index 82e10b1..9f65e3b 100644 --- a/panda3d_toolbox/runtime.py +++ b/panda3d_toolbox/runtime.py @@ -4,10 +4,6 @@ #----------------------------------------------------------------------------------------------------------------------------------# -dev = False - -#----------------------------------------------------------------------------------------------------------------------------------# - def __get_base_executable_name() -> str: """ Returns the base executable name @@ -58,7 +54,7 @@ def is_developer_build() -> bool: running as a developer build """ - return (dev or is_interactive()) and not is_frozen() + return (builtins.__dev__ or is_interactive()) and not is_frozen() def is_production_build() -> bool: """ @@ -74,20 +70,17 @@ def get_repository() -> object: if either exist. Otherwise returning NoneType """ - if __has_variable('cr'): - return __get_variable('cr') - elif __has_variable('air'): - return __get_variable('air') - else: + module = __get_module() + if not module.has_base(): return None -def is_nuitka_build() -> bool: - """ - Returns true if the application is currently - running as a Nuitka build - """ - - return 'NUITKA' in __os.environ + base = module.get_base() + if hasattr(base, 'air'): + return base.air + elif hasattr(base, 'cr'): + return base.cr + else: + raise AttributeError('base has no repository object') def is_panda3d_build() -> bool: """ @@ -103,7 +96,9 @@ def is_built_executable() -> bool: running as a built executable """ - return is_panda3d_build() or is_nuitka_build() + compiled = is_panda3d_build() + + return compiled #----------------------------------------------------------------------------------------------------------------------------------# From ed8a9e53ceafb3f8ba5ec0022eaafa0440999b96 Mon Sep 17 00:00:00 2001 From: thetestgame Date: Sat, 23 Nov 2024 21:09:52 -0600 Subject: [PATCH 3/3] Added utilities from EoE --- panda3d_toolbox/logging.py | 283 +++++++++++++++++++++++++++++++++++++ panda3d_toolbox/utils.py | 38 ++++- 2 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 panda3d_toolbox/logging.py diff --git a/panda3d_toolbox/logging.py b/panda3d_toolbox/logging.py new file mode 100644 index 0000000..c505f6f --- /dev/null +++ b/panda3d_toolbox/logging.py @@ -0,0 +1,283 @@ +""" +Utility functions for logging messages to the Panda3D logging system. Also provides +utility methods for merging with the native Python logging module and optional +support for Sentry monitoring. +""" + +import os +import sys +import time +import logging +from io import open as io_open +from logging import StreamHandler + +from panda3d.core import Filename, MultiplexStream, Notify +from panda3d_toolbox import runtime, prc + +# ----------------------------------------------------------------------------------------------- + +def get_notify_categories() -> object: + """ + Retrieves all Panda3D notifier categories + """ + + from direct.directnotify.DirectNotifyGlobal import directNotify + return directNotify.getCategories() + +def get_notify_category(name: str, create: bool = True) -> object: + """ + Returns the requested Panda3D notifier category. Creating a new + one if create is set to True + """ + + assert name != None + assert name != '' + + from direct.directnotify.DirectNotifyGlobal import directNotify + + category = None + if create: + category = directNotify.newCategory(name) + else: + category = directNotify.getCategory(name) + return category + +def log(message: str, name: str = 'global', type: str = 'info') -> None: + """ + Writes a message to the requested logger name + """ + + category = get_notify_category(name) + assert hasattr(category, type) + getattr(category, type)(message) + +def log_error(message: str, name: str = 'global') -> None: + """ + Writes an error message to the requested logger name + """ + + log(name, message, 'error') + +def log_warn(message: str, name: str = 'global') -> None: + """ + Writes an warn message to the requested logger name + """ + + log(name, message, 'warn') + +def log_info(message: str, name: str = 'global') -> None: + """ + Writes an info message to the requested logger name + """ + + log(name, message, 'info') + +def log_debug(message: str, name: str = 'global') -> None: + """ + Writes an debug message to the requested logger name + """ + + log(name, message, 'debug') + +def condition_error(logger: object, condition: bool, message: str) -> None: + """ + Writes a error message to the logging object if the provided + condition is true + """ + + condition_log(logger, condition, message, 'error') + +def condition_warn(logger: object, condition: bool, message: str) -> None: + """ + Writes a warning message to the logging object if the provided + condition is true + """ + + condition_log(logger, condition, message, 'warning') + +def condition_info(logger: object, condition: bool, message: str) -> None: + """ + Writes a info message to the logging object if the provided + condition is true + """ + + condition_log(logger, condition, message, 'info') + +def condition_debug(logger: object, condition: bool, message: str) -> None: + """ + Writes a debug message to the logging object if the provided + condition is true + """ + + condition_log(logger, condition, message, 'debug') + +def condition_log(logger: object, condition: bool, message: str, type: str = 'info') -> None: + """ + Writes a message to the logging object if the provided + condition is true using the supplied type attribute function name + """ + + assert hasattr(logger, type) + if condition: + getattr(logger, type)(message) + +def get_log_directory() -> str: + """ + Returns this applications log directory + based on the PRC configuration + """ + + default = '.%slogs' % os.sep + return prc.get_prc_string('app-log-directory', default) + +class PythonLogHandler: + """ + Redirects native Python logs to a log file + """ + + def __init__(self, original: object, log_stream: object): + """ + Initializes the PythonLogHandler instance + """ + + self.original = original + self.log_stream = log_stream + + def write(self, message: str) -> None: + """ + Writes a message to the log file + """ + + self.log_stream.write(message) + self.log_stream.flush() + + self.original.write(message) + self.original.flush() + + def flush(self) -> None: + """ + Flushes the log stream + """ + + self.original.flush() + self.log_stream.flush() + +def configure_log_file() -> None: + """ + Configures the application's log file based on the PRC configuration + """ + + # Create a timestamped log file name based on the executable name. This will allow + # us to have multiple log files for different application sessions. The resulting + # filename should be in the format of '{executable}_YYYY-MM-DD_HH-MM-SS.{ext}' + local_time = time.localtime() + log_prefix = runtime.executable_name.lower() + log_suffix = time.strftime('%Y-%m-%d_%H-%M-%S', local_time) + + log_ext = prc.get_prc_string('app-log-ext', 'txt') + log_filename = f"{log_prefix}_{log_suffix}.{log_ext}" + + # Open a new log file stream for appending. + # Make sure to use the 'a' mode (appending) because both Python and Panda3D + # open this same filename to write to. Append mode has the nice property of seeking to the end of + # the output stream before actually writing to the file. 'w' mode does not do this, so you will see Panda3D's + # output and Python's output not interlace properly. + log_file_path = os.path.join(get_log_directory(), log_filename) + log_stream = io_open(log_file_path, 'a') + + # Create new Python log handlers for stdout and stderr and redirect + # stdout and stderr to these handlers + log_output = PythonLogHandler(sys.stdout, log_stream) + log_error = PythonLogHandler(sys.stderr, log_stream) + + sys.stdout = log_output + sys.stderr = log_error + + # Configture Panda3D to use the same log file + nout = MultiplexStream() + Notify.ptr().set_ostream_ptr(nout, 0) + + nout.add_file(Filename(log_file_path)) + nout.add_standard_output() + nout.add_system_debug() + + # Write our log file header with useful information should we ever end up with + # a log file that needs to be analyzed. + print("\n\nStarting application...") + print(f"Current time: {time.asctime(time.localtime(time.time()))}") + print(f"sys.path = ", sys.path) + print(f"sys.argv = ", sys.argv) + print(f"os.environ = ", os.environ) + +# ----------------------------------------------------------------------------------------------- + + +class NotifyHandler(StreamHandler): + """ + Custom logging module StreamHandler for pipeing lodding module based messages to a Panda3D + notifier category + """ + + def __init__(self, name: str = 'global'): + """ + Initializes the NotifyHandler instance + """ + + StreamHandler.__init__(self) + + self.name = name + self.notify = get_notify_category(name) + + def emite(self, record: object) -> None: + """ + Processes the incoming record from the logging module + """ + + message_parts = self.format(record).split('||') + level = message_parts[0].lower() + message = message_parts[1] + + if hasattr(self.notify, level): + func = getattr(self.notify, level) + else: + func = self.notify.info + + func.info(message) + +def configure_logging_module() -> None: + """ + Initializes the Python logging module to pipe through the Panda3D notifier + """ + + level_map = { + 'spam': logging.DEBUG, + 'debug': logging.DEBUG, + 'info': logging.INFO, + 'warning': logging.WARN, + 'error': logging.ERROR + } + + level = level_map.get(prc.get_prc_string('notify-level-python', ''), logging.INFO) + formatter = '%(levelname)s||%(message)s' + logging.basicConfig(format=formatter, level=level, handlers=[NotifyHandler()]) + + +# ----------------------------------------------------------------------------------------------- + +def configure_sentry_monitoring() -> None: + """ + Configures the Sentry SDK for use by the application + """ + + sentry_dsn = prc.get_prc_string('sentry-dsn', '') + sentry_trace_rate = prc.get_prc_double('sentry-trace-rate', 1.0) + + assert sentry_dsn != '' + + import sentry_sdk + sentry_sdk.init( + dsn=sentry_dsn, + traces_sample_rate=sentry_trace_rate + ) + +# ----------------------------------------------------------------------------------------------- \ No newline at end of file diff --git a/panda3d_toolbox/utils.py b/panda3d_toolbox/utils.py index 41ce80b..daabc7c 100644 --- a/panda3d_toolbox/utils.py +++ b/panda3d_toolbox/utils.py @@ -5,6 +5,7 @@ import itertools import datetime import types +import math import gc import functools @@ -44,7 +45,7 @@ def open_web_url(url: str) -> bool: success = False if sys.platform == 'darwin': os.system('/usr/bin/open %s' % url) - elif system.platform == 'linux': + elif sys.platform == 'linux': import webbrowser webbrowser.open(url) success = True @@ -466,4 +467,37 @@ def do_method_after_n_frames(frames_to_wait: int, method: object, args: list = [ if frames_to_wait > 0: create_task(_DoMethodAfterNFrames(frames_to_wait, method, args).task_func, priority=priority) else: - __utility_notify.error('Invalid request. do_method_after_n_frames received a frames wait of 0') \ No newline at end of file + __utility_notify.error('Invalid request. do_method_after_n_frames received a frames wait of 0') + +def set_setters_from_dict(obj: object, data: dict) -> None: + """ + Sets the attributes of an object from a dictionary. + The dictionary keys should match the object's setter methods. + + IE. name -> set_name(self, name) + """ + + for key, value in data.items(): + setter = f"set_{get_snake_case(key)}" + if not hasattr(obj, setter): + raise AttributeError( + f"Object {obj} does not have a setter for {key}") + + getattr(obj, setter)(value) + +def calculate_circle_edge_point(center: Vec3, diameter: float, angle_degrees: float) -> Vec3: + """ + Calculate the point on the edge of a circle given the center, diameter, and angle. + """ + + # Calculate radius + radius = diameter / 2 + + # Convert angle to radians + angle_radians = math.radians(angle_degrees) + + # Calculate the x and y coordinates of the point on the edge + x = center.get_x() + radius * math.cos(angle_radians) + y = center.get_y() + radius * math.sin(angle_radians) + + return Vec3(x, y, center.get_z())