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