# Copyright (c) 2025-2026 profiler-qgis-plugin contributors.
#
#
# This file is part of profiler-qgis-plugin.
#
# profiler-qgis-plugin is free software: you can redistribute it and/or
# modify it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# profiler-qgis-plugin is distributed in the hope that it will be
# useful, but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with profiler-qgis-plugin. If not, see <https://www.gnu.org/licenses/>.
"""Decorators for profiling functions and classes.
Provides :func:`profile` and :func:`profile_class` for QgsRuntimeProfiler-based
timing, and :func:`cprofile` and :func:`cprofile_plugin` for cProfile-based
profiling with optional file output.
"""
import logging
from collections.abc import Callable
from functools import wraps
from pathlib import Path
from typing import Any
from qgis_profiler.profiler import ProfilerWrapper
from qgis_profiler.settings import (
Settings,
resolve_group_name_with_cache,
)
from qgis_profiler.utils import QgisPluginType, get_rotated_path, parse_arguments
LOGGER = logging.getLogger(__name__)
[docs]
def profile(
function: Callable | None = None,
*,
name: str | None = None,
group: str | None = None,
event_args: list[str] | None = None,
) -> Callable:
"""Create a profiling decorator for the given function.
Measure the time taken by a function and group the profiler data under a
specified name using QgsApplication's profiling infrastructure.
:param function: Provided here to support both ``@profile`` and
``@profile()`` syntax.
:param name: Optional name for the profiler item. If not provided, the function's
name will be used as the name.
:param group: Optional name for the profiler group. If not provided, the group name
is read from settings.
:param event_args: Optional list of argument names to include in the event name.
If specified, the event name will include these argument values.
:return: A decorator that wraps the specified function for profiling.
Example::
from qgis_profiler.decorators import profile
@profile
def process_features():
pass
# With custom name and group
@profile(name="Feature Processing", group="My Plugin")
def another_function():
pass
# With argument values in event name
@profile(event_args=["layer_name"])
def process_layer(layer_name: str):
pass
# Event name will be: "process_layer(layer_name=roads)"
"""
if function is None: # @profile() syntax
def decorator(function: Callable) -> Callable:
return profile(
function=function,
name=name,
group=group,
event_args=event_args,
)
return decorator
# @profile syntax
@wraps(function)
def wrapper(*args: Any, **kwargs: Any) -> Any:
if not Settings.profiler_enabled.get_with_cache():
LOGGER.debug("Profiling is disabled.")
return function(*args, **kwargs)
group_name = resolve_group_name_with_cache(group)
event_name = name if name is not None else function.__name__
if event_args:
event_name += parse_arguments(function, event_args, args, kwargs)
ProfilerWrapper.get().start(event_name, group_name)
try:
return function(*args, **kwargs)
finally:
ProfilerWrapper.get().end(group_name)
# Mark wrapper as profiled
wrapper._profiled = True # type: ignore
return wrapper
[docs]
def profile_class( # noqa: C901
*,
group: str | None = None,
include: list[str] | None = None,
exclude: list[str] | None = None,
) -> Callable[[type], type]:
"""Wrap public methods of a class with the 'profile' decorator.
Skip methods already decorated with 'profile' and all ``__dunder__`` methods.
:param group: Optional name for the profiler group. If not provided, the group name
is read from settings.
:param include: List of method names to include
(only these will be wrapped if provided).
:param exclude: List of method names to exclude
(these will NOT be wrapped, overrides include).
:return: A class with decorated methods based on the include/exclude criteria.
Example::
from qgis_profiler.decorators import profile_class
@profile_class(group="My Plugin", exclude=["helper"])
class LayerProcessor:
def load(self):
pass # profiled
def process(self):
pass # profiled
def helper(self):
pass # NOT profiled (excluded)
"""
def decorator(cls: type) -> type: # noqa: C901
for attr_name, attr_value in cls.__dict__.items():
# Ignore special methods (__ methods)
if attr_name.startswith("__"):
continue
# Apply wrapping rules based on include/exclude
if include and attr_name not in include:
continue # Skip methods not in the "include" list
if exclude and attr_name in exclude:
continue # Skip methods in the "exclude" list
# Handle staticmethod and classmethod separately
is_static = isinstance(attr_value, staticmethod)
is_class_method = isinstance(attr_value, classmethod)
is_method = callable(attr_value)
if is_static or is_class_method:
original_func = attr_value.__func__
elif is_method:
original_func = attr_value
else:
continue
# Omit if method is already decorated with profiler
if hasattr(original_func, "_profiled"):
continue
wrapper = profile(name=attr_name, group=group)
if is_static:
# Unwrap staticmethod before decorating
wrapped_func = wrapper(original_func)
setattr(cls, attr_name, staticmethod(wrapped_func))
elif is_class_method:
# Unwrap classmethod before decorating
wrapped_func = wrapper(original_func)
setattr(cls, attr_name, classmethod(wrapped_func))
# Check if the attribute is a callable (method)
elif is_method:
# Wrap the method with @profile
setattr(cls, attr_name, wrapper(original_func))
return cls
return decorator
[docs]
def cprofile(
function: Callable | None = None,
*,
log_stats: bool = True,
trim_zeros: bool = True,
sort: tuple[str, ...] = ("cumtime",),
output_file_path: Path | None = None,
) -> Callable:
"""Profile the execution of a function using cProfile.
Can be used as a decorator with or without arguments.
:param function: Function to be profiled. Defaults to None.
:param log_stats: Whether to log profiling statistics. Defaults to True.
:param trim_zeros: Trim lines with zero times from the report.
:param sort: Tuple of columns used for sorting profiling output.
:param output_file_path: File path to save profiling output, if provided.
:return: Callable decorator or wrapped function.
Example::
from pathlib import Path
from qgis_profiler.decorators import cprofile
@cprofile(output_file_path=Path("/tmp/report.prof"))
def expensive_operation():
pass
"""
if function is None: # @cprofile() syntax
def decorator(function: Callable) -> Callable:
return cprofile(
function=function,
log_stats=log_stats,
sort=sort,
output_file_path=output_file_path,
)
return decorator
# @cprofile syntax
@wraps(function)
def wrapper(*args: Any, **kwargs: Any) -> Any:
if not Settings.profiler_enabled.get():
LOGGER.debug("Profiling is disabled.")
return function(*args, **kwargs)
ProfilerWrapper.get().cprofiler.enable()
try:
return function(*args, **kwargs)
finally:
ProfilerWrapper.get().cprofiler.disable()
if log_stats:
report = ProfilerWrapper.get().cprofiler.get_stat_report(
sort=sort, trim_zeros=trim_zeros
)
LOGGER.info(report)
if output_file_path:
ProfilerWrapper.get().cprofiler.dump_stats(output_file_path)
return wrapper
[docs]
def cprofile_plugin(
*,
output_file_path: Path,
) -> Callable[[type], type]:
"""Apply a decorator to a QGIS plugin class to enable profiling.
This function decorates a class to integrate profiling functionality
via `cProfile`. Profiling is enabled during the plugin's execution
and additional profiling statistics are logged after the plugin
unloads.
The output file can then be further analysed for example with tools like
https://github.com/jrfonseca/gprof2dot
and
https://jiffyclub.github.io/snakeviz/#snakeviz
:param output_file_path: Path to save profiling results.
If the file exists, a suffix will be added to the filename.
:return: Decorated class.
Example::
from pathlib import Path
from qgis_profiler.decorators import cprofile_plugin
@cprofile_plugin(output_file_path=Path("/tmp/my_plugin.prof"))
class MyPlugin:
def __init__(self, iface):
self.iface = iface
def initGui(self):
pass
def unload(self):
# cProfile results are saved here automatically
pass
The output ``.prof`` file can be analyzed with
`snakeviz <https://jiffyclub.github.io/snakeviz/>`_::
pip install snakeviz
snakeviz /tmp/my_plugin.prof
"""
def decorator(cls: type) -> type:
if not Settings.profiler_enabled.get():
LOGGER.debug("Profiling is disabled.")
return cls
if not issubclass(cls, QgisPluginType):
raise TypeError(f"Class {cls.__name__} is not a QGIS plugin") # noqa: TRY003
original_unload = cls.unload # type: ignore[attr-defined]
@wraps(original_unload)
def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return original_unload(*args, **kwargs)
finally:
output_file_path.parent.mkdir(parents=True, exist_ok=True)
path = get_rotated_path(output_file_path)
LOGGER.info(
"Stopping cprofiler for the plugin %s and saving results to %s",
cls.__name__,
path,
)
ProfilerWrapper.get().cprofiler.dump_stats(path)
setattr(cls, "unload", wrapper) # noqa: B010
LOGGER.info("Starting cprofiler for the plugin %s", cls.__name__)
ProfilerWrapper.get().cprofiler.enable()
return cls
return decorator