diff --git a/aprsd/cli_helper.py b/aprsd/cli_helper.py index 657e882..050cb99 100644 --- a/aprsd/cli_helper.py +++ b/aprsd/cli_helper.py @@ -1,3 +1,4 @@ +import cProfile import logging import typing as t from functools import update_wrapper @@ -46,6 +47,15 @@ common_options = [ default=False, help="Don't log to stdout", ), + click.option( + '--profile', + 'profile_output', + default=None, + required=False, + metavar='FILENAME', + help='Enable profiling and save results to FILENAME. ' + 'If FILENAME is not provided, defaults to aprsd_profile.prof', + ), ] @@ -129,10 +139,30 @@ def process_standard_options(f: F) -> F: LOG = logging.getLogger('APRSD') # noqa: N806 LOG.error("No config file found!! run 'aprsd sample-config'") + profile_output = kwargs.pop('profile_output', None) del kwargs['loglevel'] del kwargs['config_file'] del kwargs['quiet'] - return f(*args, **kwargs) + + # Enable profiling if requested + if profile_output is not None: + # If profile_output is empty string, use default filename + if not profile_output or profile_output == '': + profile_output = 'aprsd_profile.prof' + profiler = cProfile.Profile() + profiler.enable() + try: + result = f(*args, **kwargs) + finally: + profiler.disable() + profiler.dump_stats(profile_output) + LOG = logging.getLogger('APRSD') # noqa: N806 + LOG.info(f'Profile data saved to {profile_output}') + LOG.info(f'Analyze with: python -m pstats {profile_output}') + LOG.info(f'Or visualize with: snakeviz {profile_output}') + return result + else: + return f(*args, **kwargs) return update_wrapper(t.cast(F, new_func), f) @@ -151,9 +181,29 @@ def process_standard_options_no_config(f: F) -> F: ctx.obj['quiet'], ) + profile_output = kwargs.pop('profile_output', None) del kwargs['loglevel'] del kwargs['config_file'] del kwargs['quiet'] - return f(*args, **kwargs) + + # Enable profiling if requested + if profile_output is not None: + # If profile_output is empty string, use default filename + if not profile_output or profile_output == '': + profile_output = 'aprsd_profile.prof' + profiler = cProfile.Profile() + profiler.enable() + try: + result = f(*args, **kwargs) + finally: + profiler.disable() + profiler.dump_stats(profile_output) + LOG = logging.getLogger('APRSD') # noqa: N806 + LOG.info(f'Profile data saved to {profile_output}') + LOG.info(f'Analyze with: python -m pstats {profile_output}') + LOG.info(f'Or visualize with: snakeviz {profile_output}') + return result + else: + return f(*args, **kwargs) return update_wrapper(t.cast(F, new_func), f) diff --git a/aprsd/cmds/listen.py b/aprsd/cmds/listen.py index 7c6654e..d624daf 100644 --- a/aprsd/cmds/listen.py +++ b/aprsd/cmds/listen.py @@ -18,6 +18,7 @@ from rich.console import Console import aprsd from aprsd import cli_helper, packets, plugin, threads, utils from aprsd.client.client import APRSDClient +from aprsd.log import log from aprsd.main import cli from aprsd.packets import collector as packet_collector from aprsd.packets import core, seen_list @@ -116,6 +117,101 @@ class ListenStatsThread(APRSDThread): return True +def process_listen_options(f): + """Custom decorator for listen command that modifies log format before setup_logging.""" + import cProfile + import typing as t + from functools import update_wrapper + + def new_func(*args, **kwargs): + ctx = args[0] + ctx.ensure_object(dict) + config_file_found = True + + # Extract show_thread and show_level + show_thread = kwargs.get('show_thread', False) + show_level = kwargs.get('show_level', False) + + if kwargs['config_file']: + default_config_files = [kwargs['config_file']] + else: + default_config_files = None + + try: + CONF( + [], + project='aprsd', + version=aprsd.__version__, + default_config_files=default_config_files, + ) + except cfg.ConfigFilesNotFoundError: + config_file_found = False + + # NOW modify config AFTER CONF is initialized but BEFORE setup_logging + # Build custom log format for listen command + # By default, disable thread, level, and location for cleaner output + from aprsd.conf import log as conf_log + + parts = [] + # Timestamp is always included + parts.append(conf_log.DEFAULT_LOG_FORMAT_TIMESTAMP) + + if show_thread: + parts.append(conf_log.DEFAULT_LOG_FORMAT_THREAD) + + if show_level: + parts.append(conf_log.DEFAULT_LOG_FORMAT_LEVEL) + + # Message is always included + parts.append(conf_log.DEFAULT_LOG_FORMAT_MESSAGE) + + # Location is never included for listen command + + # Set the custom log format + CONF.logging.logformat = ' | '.join(parts) + + ctx.obj['loglevel'] = kwargs['loglevel'] + ctx.obj['quiet'] = kwargs['quiet'] + + # Now call setup_logging with our modified config + log.setup_logging(ctx.obj['loglevel'], ctx.obj['quiet']) + + if CONF.trace_enabled: + from aprsd.utils import trace + + trace.setup_tracing(['method', 'api']) + + if not config_file_found: + LOG = logging.getLogger('APRSD') # noqa: N806 + LOG.error("No config file found!! run 'aprsd sample-config'") + + profile_output = kwargs.pop('profile_output', None) + del kwargs['loglevel'] + del kwargs['config_file'] + del kwargs['quiet'] + + # Enable profiling if requested + if profile_output is not None: + if not profile_output or profile_output == '': + profile_output = 'aprsd_profile.prof' + profiler = cProfile.Profile() + profiler.enable() + try: + result = f(*args, **kwargs) + finally: + profiler.disable() + profiler.dump_stats(profile_output) + LOG = logging.getLogger('APRSD') # noqa: N806 + LOG.info(f'Profile data saved to {profile_output}') + LOG.info(f'Analyze with: python -m pstats {profile_output}') + LOG.info(f'Or visualize with: snakeviz {profile_output}') + return result + else: + return f(*args, **kwargs) + + return update_wrapper(t.cast(t.Callable, new_func), f) + + @cli.command() @cli_helper.add_options(cli_helper.common_options) @click.option( @@ -180,8 +276,20 @@ class ListenStatsThread(APRSDThread): is_flag=True, help='Enable packet stats periodic logging.', ) +@click.option( + '--show-thread', + default=False, + is_flag=True, + help='Show thread name in log format (disabled by default for listen).', +) +@click.option( + '--show-level', + default=False, + is_flag=True, + help='Show log level in log format (disabled by default for listen).', +) @click.pass_context -@cli_helper.process_standard_options +@process_listen_options def listen( ctx, aprs_login, @@ -192,6 +300,8 @@ def listen( filter, log_packets, enable_packet_stats, + show_thread, + show_level, ): """Listen to packets on the APRS-IS Network based on FILTER. diff --git a/aprsd/conf/log.py b/aprsd/conf/log.py index 6da1cbc..78bafe7 100644 --- a/aprsd/conf/log.py +++ b/aprsd/conf/log.py @@ -15,17 +15,23 @@ LOG_LEVELS = { } DEFAULT_DATE_FORMAT = '%m/%d/%Y %I:%M:%S %p' -DEFAULT_LOG_FORMAT = ( - '[%(asctime)s] [%(threadName)-20.20s] [%(levelname)-5.5s]' - ' %(message)s - [%(pathname)s:%(lineno)d]' + +# Default log format parts +DEFAULT_LOG_FORMAT_TIMESTAMP = '{time:YYYY-MM-DD HH:mm:ss.SSS}' +DEFAULT_LOG_FORMAT_THREAD = '{thread.name: <18}' +DEFAULT_LOG_FORMAT_LEVEL = '{level: <8}' +DEFAULT_LOG_FORMAT_MESSAGE = '{message}' +DEFAULT_LOG_FORMAT_LOCATION = ( + '{name}:{function:}:{line:}' ) +# Build default format from parts DEFAULT_LOG_FORMAT = ( - '{time:YYYY-MM-DD HH:mm:ss.SSS} | ' - '{thread.name: <18} | ' - '{level: <8} | ' - '{message} | ' - '{name}:{function:}:{line:}' + f'{DEFAULT_LOG_FORMAT_TIMESTAMP} | ' + f'{DEFAULT_LOG_FORMAT_THREAD} | ' + f'{DEFAULT_LOG_FORMAT_LEVEL} | ' + f'{DEFAULT_LOG_FORMAT_MESSAGE} | ' + f'{DEFAULT_LOG_FORMAT_LOCATION}' ) logging_group = cfg.OptGroup( diff --git a/aprsd/log/log.py b/aprsd/log/log.py index 7452ea5..14a67de 100644 --- a/aprsd/log/log.py +++ b/aprsd/log/log.py @@ -12,6 +12,16 @@ CONF = cfg.CONF LOG = logger +def build_log_format(): + """Build log format from configurable parts.""" + # If logformat is explicitly set, use it + if CONF.logging.logformat: + return CONF.logging.logformat + + # Otherwise, use the default format + return conf_log.DEFAULT_LOG_FORMAT + + class QueueLatest(queue.Queue): """Custom Queue to keep only the latest N items. @@ -84,13 +94,16 @@ def setup_logging(loglevel=None, quiet=False, custom_handler=None): logging.getLogger(name).handlers = [] logging.getLogger(name).propagate = name not in disable_list + # Build the log format from configurable parts + log_format = build_log_format() + handlers = [] if CONF.logging.enable_console_stdout and not quiet: handlers.append( { 'sink': sys.stdout, 'serialize': False, - 'format': CONF.logging.logformat, + 'format': log_format, 'colorize': CONF.logging.enable_color, 'level': log_level, }, @@ -101,13 +114,16 @@ def setup_logging(loglevel=None, quiet=False, custom_handler=None): { 'sink': CONF.logging.logfile, 'serialize': False, - 'format': CONF.logging.logformat, + 'format': log_format, 'colorize': False, 'level': log_level, }, ) if custom_handler: + # If custom_handler doesn't have a format set, use the built format + if 'format' not in custom_handler: + custom_handler['format'] = log_format handlers.append(custom_handler) # configure loguru