diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f68b255..92bbd0d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3.x - name: Install dependencies @@ -23,7 +23,7 @@ jobs: - name: Create packages run: python -m build - name: Archive packages - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: dist path: dist @@ -36,7 +36,7 @@ jobs: id-token: write steps: - name: Retrieve packages - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 - name: Upload packages uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 04d2ef6..898794e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,23 +11,23 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - python-version: ["3.7", "3.10", "3.11", "3.12", "pypy-3.9"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy-3.10"] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true cache: pip cache-dependency-path: pyproject.toml - name: Install dependencies - run: pip install -e .[test] coverage + run: pip install -e .[test] - name: Test with pytest - run: | - coverage run -m pytest - coverage xml + run: coverage run -m pytest -v + - name: Generate coverage report + run: coverage xml - name: Upload Coverage uses: coverallsapp/github-action@v2 with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4a794fc..fa5ce9a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ # * Run "pre-commit install". repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v5.0.0 hooks: - id: check-toml - id: check-yaml @@ -16,14 +16,17 @@ repos: - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.9 + rev: v0.8.3 hooks: - id: ruff args: [--fix, --show-fixes] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.8.0 + rev: v1.13.0 hooks: - id: mypy - additional_dependencies: ["pytest"] + additional_dependencies: + - asphalt@git+https://github.com/asphalt-framework/asphalt + - pytest + - sentry-sdk diff --git a/.readthedocs.yml b/.readthedocs.yml index 026b967..ac1099a 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -3,7 +3,7 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3.8" + python: "3.9" sphinx: configuration: docs/conf.py diff --git a/docs/api.rst b/docs/api.rst index 82455e2..3075522 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,28 +1,26 @@ API reference ============= +.. py:currentmodule:: asphalt.exceptions + Component --------- -.. autoclass:: asphalt.exceptions.component.ExceptionReporterComponent - :members: +.. autoclass:: ExceptionReporterComponent Functions --------- -.. autofunction:: asphalt.exceptions.report_exception +.. autofunction:: report_exception -Abstract classes ----------------- +Interfaces +---------- -.. automodule:: asphalt.exceptions.api - :members: +.. autoclass:: ExceptionReporter +.. autoclass:: ExtrasProvider Exception reporters ------------------- .. autoclass:: asphalt.exceptions.reporters.sentry.SentryExceptionReporter - :members: - .. autoclass:: asphalt.exceptions.reporters.raygun.RaygunExceptionReporter - :members: diff --git a/docs/conf.py b/docs/conf.py index 9ebf2f8..dc3799d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -from importlib.metadata import version +import importlib.metadata from packaging.version import parse @@ -8,6 +8,7 @@ "sphinx.ext.intersphinx", "sphinx.ext.extlinks", "sphinx_autodoc_typehints", + "sphinx_rtd_theme", ] templates_path = ["_templates"] @@ -17,7 +18,7 @@ author = "Alex Grönholm" copyright = "2017, " + author -v = parse(version(project)) +v = parse(importlib.metadata.version(project)) version = v.base_version release = v.public @@ -25,11 +26,12 @@ exclude_patterns = ["_build"] pygments_style = "sphinx" +autodoc_default_options = {"members": True, "show-inheritance": True} +autodoc_inherit_docstrings = False highlight_language = "python3" todo_include_todos = False html_theme = "sphinx_rtd_theme" -html_static_path = ["_static"] htmlhelp_basename = project.replace("-", "") + "doc" extlinks = { diff --git a/docs/extending.rst b/docs/extending.rst index dbb9883..def88c2 100644 --- a/docs/extending.rst +++ b/docs/extending.rst @@ -5,8 +5,8 @@ Writing new reporter backends ----------------------------- To support new exception reporting services, you can subclass the -:class:`~asphalt.exceptions.api.ExceptionReporter` class. You just need to implement the -:meth:`~asphalt.exceptions.api.ExceptionReporter.report_exception` method. +:class:`~asphalt.exceptions.ExceptionReporter` class. You just need to implement the +:meth:`~asphalt.exceptions.ExceptionReporter.report_exception` method. If you want your exception reporter to be available as a backend for :class:`~asphalt.exceptions.component.ExceptionReporterComponent`, you need to add the @@ -39,7 +39,7 @@ Writing extras providers ------------------------ If you want to provide backend specific extra data for exception reporting, you can do so by -subclassing :class:`~asphalt.exceptions.api.ExtrasProvider` and adding one or more instances of it +subclassing :class:`~asphalt.exceptions.ExtrasProvider` and adding one or more instances of it as resources to the context. For example, if you wanted to provide extra data for Sentry about your custom context diff --git a/docs/usage.rst b/docs/usage.rst index ce3cbf5..bf75a30 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -10,8 +10,8 @@ When an exception is caught, the typical course of action is to log it:: except Exception: logger.exception('Tried to do something but it failed :(') -To take advantage of the exception reporters configured with this component, all you have to do is -call :func:`~asphalt.exceptions.report_exception` instead:: +To take advantage of the exception reporters configured with this component, all you +have to do is call :func:`~asphalt.exceptions.report_exception` instead:: from asphalt.exceptions import report_exception @@ -26,6 +26,7 @@ call :func:`~asphalt.exceptions.report_exception` instead:: This will not only log the exception as usual, but also send it to any external services represented by the configured exception reporter backends. -The ``ctx`` argument is required in order for the function to find the configured exception -reporter resources. Additionally, it looks up plugins matching the fully qualified class name of -the context object to provide additional information to each exception reporter backend. +The ``ctx`` argument is required in order for the function to find the configured +exception reporter resources. Additionally, it looks up plugins matching the fully +qualified class name of the context object to provide additional information to each +exception reporter backend. diff --git a/pyproject.toml b/pyproject.toml index 9fa0e64..94ff360 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,16 +19,15 @@ classifiers = [ "Typing :: Typed", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] -requires-python = ">=3.7" +requires-python = ">=3.9" dependencies = [ - "asphalt ~= 4.6", + "asphalt @ git+https://github.com/asphalt-framework/asphalt", "typing_extensions >= 4.6.0; python_version < '3.10'" ] dynamic = ["version"] @@ -38,10 +37,10 @@ Homepage = "https://github.com/asphalt-framework/asphalt-exceptions" [project.optional-dependencies] test = [ + "anyio[trio] ~= 4.1", "asphalt-exceptions[sentry,raygun]", + "coverage >= 7", "pytest", - "pytest-asyncio", - "pytest-cov", "pytest-mock", ] doc = [ @@ -64,33 +63,31 @@ raygun = "asphalt.exceptions.reporters.raygun:RaygunExceptionReporter" version_scheme = "post-release" local_scheme = "dirty-tag" -[tool.ruff] -line-length = 99 -target-version = "py37" - [tool.ruff.lint] select = [ "ASYNC", # flake8-async - "E", "F", "W", # default Flake8 "G", # flake8-logging-format "I", # isort "ISC", # flake8-implicit-str-concat "PGH", # pygrep-hooks - "RUF100", # unused noqa (yesqa) + "RUF", # Ruff-specific rules "UP", # pyupgrade + "W", # pycodestyle warnings ] -[tool.ruff.isort] +[tool.ruff.lint.isort] known-first-party = ["asphalt.exceptions"] +known-third-party = ["asphalt.core"] [tool.pytest.ini_options] -addopts = "-rsx --tb=short" -asyncio_mode = "strict" +addopts = ["-rsfE", "--tb=short"] testpaths = ["tests"] [tool.mypy] -python_version = "3.7" -ignore_missing_imports = true +python_version = "3.9" +strict = true +explicit_package_bases = true +mypy_path = ["src", "tests"] [tool.coverage.run] source = ["asphalt.exceptions"] @@ -101,19 +98,18 @@ branch = true show_missing = true [tool.tox] -legacy_tox_ini = """ -[tox] -envlist = py37, py38, py39, py310, py311, py312, pypy3 +env_list = ["py39", "py310", "py311", "py312", "py313", "pypy3"] skip_missing_interpreters = true -minversion = 4.4.3 - -[testenv] -extras = test -commands = python -m pytest {posargs} -package = editable - -[testenv:docs] -extras = doc -commands = sphinx-build docs build/sphinx -package = editable -""" + +[tool.tox.env_run_base] +commands = [["python", "-m", "pytest", { replace = "posargs", extend = true }]] +package = "editable" +extras = ["test"] + +[tool.tox.env.pyright] +deps = ["pyright"] +commands = [["pyright", "--verifytypes", "asphalt.exceptions"]] + +[tool.tox.env.docs] +commands = [["sphinx-build", "-W", "-n", "docs", "build/sphinx", { replace = "posargs", extend = true }]] +extras = ["doc"] diff --git a/src/asphalt/exceptions/__init__.py b/src/asphalt/exceptions/__init__.py index 73fae70..67acabd 100644 --- a/src/asphalt/exceptions/__init__.py +++ b/src/asphalt/exceptions/__init__.py @@ -1,85 +1,11 @@ -from __future__ import annotations +from ._api import ExceptionReporter as ExceptionReporter +from ._api import ExtrasProvider as ExtrasProvider +from ._component import ExceptionReporterComponent as ExceptionReporterComponent +from ._utils import report_exception as report_exception -import inspect -import logging -import sys -from typing import Any +# Re-export imports, so they look like they live directly in this package +for __value in list(locals().values()): + if getattr(__value, "__module__", "").startswith(f"{__name__}."): + __value.__module__ = __name__ -from asphalt.core import Context, merge_config, qualified_name - -from asphalt.exceptions.api import ExceptionReporter, ExtrasProvider - -__all__ = ("report_exception",) - -module_logger = logging.getLogger(__name__) - - -def report_exception( - ctx: Context, - message: str, - exception: BaseException | None = None, - *, - logger: logging.Logger | str | bool = True, -) -> None: - """ - Report an exception to all exception reporters in the given context (and optionally log it too) - - :param ctx: context object to use for adding tags and other contextual information to the - report, as well as for retrieving the sentry client object - :param message: a free-form message to pass to the exception reporters (often used to describe - what was happening when the exception occurred) - :param exception: the exception to report; retrieved from :func:`sys.exc_info` if omitted - :param logger: logger instance or logger name to log the exception in, instead of the name of - the module where the exception was raised (or ``False`` to skip logging the exception) - - """ - if not exception: - exception = sys.exc_info()[1] - if not exception: - raise ValueError( - 'missing "exception" parameter and no current exception present in ' - "sys.exc_info()" - ) - - actual_logger: logging.Logger | None - if isinstance(logger, bool): - tb = exception.__traceback__ - if logger and tb: - frame = tb.tb_frame - module = inspect.getmodule(frame) - if module and module.__spec__: - actual_logger = logging.getLogger(module.__spec__.name) - else: # pragma: no cover - actual_logger = logging.getLogger(frame.f_globals["__name__"]) - else: - actual_logger = None - elif isinstance(logger, str): - actual_logger = logging.getLogger(logger) - else: - actual_logger = logger - - if actual_logger: - actual_logger.error(message, exc_info=exception) - - extras_providers = ctx.get_resources(ExtrasProvider) - for reporter in ctx.get_resources(ExceptionReporter): - extra: dict[str, Any] = {} - for provider in extras_providers: - try: - new_extra = provider.get_extras(ctx, reporter) - except Exception: - module_logger.exception( - "error retrieving exception extras for %s from %s", - qualified_name(reporter), - qualified_name(provider), - ) - else: - if isinstance(new_extra, dict): - extra = merge_config(extra, new_extra) - - try: - reporter.report_exception(ctx, exception, message, extra) - except Exception: - module_logger.exception( - "error calling exception reporter (%s)", qualified_name(reporter) - ) +del __value diff --git a/src/asphalt/exceptions/api.py b/src/asphalt/exceptions/_api.py similarity index 63% rename from src/asphalt/exceptions/api.py rename to src/asphalt/exceptions/_api.py index 33aba4b..6c6a490 100644 --- a/src/asphalt/exceptions/api.py +++ b/src/asphalt/exceptions/_api.py @@ -3,21 +3,18 @@ from abc import ABCMeta, abstractmethod from typing import Any -from asphalt.core import Context - class ExceptionReporter(metaclass=ABCMeta): """ Interface for services that log exceptions on external systems. - Exception reporter instances must be made available as resources in the context for them to - take effect. + Exception reporter instances must be made available as resources in the context for + them to take effect. """ @abstractmethod def report_exception( self, - ctx: Context, exception: BaseException, message: str, extra: dict[str, Any], @@ -25,13 +22,13 @@ def report_exception( """ Report the given exception to an external service. - Implementors should typically queue an event or something instead of using a synchronous - operations to send the exception. + Implementors should typically queue an event or something instead of using a + synchronous operations to send the exception. - :param ctx: the context in which the exception occurred :param exception: an exception :param message: an accompanying message - :param extra: backend specific extra contextual information gathered from extras providers + :param extra: backend specific extra contextual information gathered from extras + providers """ @@ -39,21 +36,20 @@ class ExtrasProvider(metaclass=ABCMeta): """ Interface for a provider of extra data for exception reporters. - Implementors must check the type of the reporter and provide extra data specific to each - backend. See the documentation of each reporter class to find out the acceptable data - structures. + Implementors must check the type of the reporter and provide extra data specific to + each backend. See the documentation of each reporter class to find out the + acceptable data structures. - .. note:: Extras are gathered from providers in an unspecified order. The dicts are then - merged, so any conflicting keys might be lost. + .. note:: Extras are gathered from providers in an unspecified order. The dicts are + then merged, so any conflicting keys might be lost. """ @abstractmethod - def get_extras(self, ctx: Context, reporter: ExceptionReporter) -> dict[str, Any] | None: + def get_extras(self, reporter: ExceptionReporter) -> dict[str, Any] | None: """ Return context specific extras for the given exception reporter backend. - :param ctx: the context in which the exception was raised :param reporter: the exception reporter for which to provide extras - :return: a dict containing backend specific extra data, or ``None`` if no appropriate - extra data can be provided + :return: a dict containing backend specific extra data, or ``None`` if no + appropriate extra data can be provided """ diff --git a/src/asphalt/exceptions/component.py b/src/asphalt/exceptions/_component.py similarity index 56% rename from src/asphalt/exceptions/component.py rename to src/asphalt/exceptions/_component.py index 4accaff..69bcf31 100644 --- a/src/asphalt/exceptions/component.py +++ b/src/asphalt/exceptions/_component.py @@ -2,85 +2,84 @@ import logging from asyncio import AbstractEventLoop, get_running_loop -from functools import partial -from typing import Any, AsyncIterator +from collections.abc import AsyncGenerator, Mapping +from typing import Any from asphalt.core import ( Component, - Context, PluginContainer, + add_resource, context_teardown, merge_config, qualified_name, ) -from asphalt.exceptions import report_exception -from asphalt.exceptions.api import ExceptionReporter +from ._api import ExceptionReporter +from ._utils import report_exception reporter_backends = PluginContainer("asphalt.exceptions.reporters", ExceptionReporter) logger = logging.getLogger(__name__) -def default_exception_handler( - loop: AbstractEventLoop, context: dict[str, Any], *, ctx: Context -) -> None: +def default_exception_handler(loop: AbstractEventLoop, context: dict[str, Any]) -> None: if "exception" in context: - report_exception(ctx, context["message"], context["exception"]) + report_exception(context["message"], context["exception"]) class ExceptionReporterComponent(Component): """ Creates one or more :class:`~asphalt.exceptions.api.ExceptionReporter` resources. - These resources are used by :func:`~asphalt.exceptions.report_exception` which calls each one - of them to report the exception. + These resources are used by :func:`~asphalt.exceptions.report_exception` which calls + each one of them to report the exception. - Optionally (and by default), a default exception handler is also installed for the event loop - which calls :func:`~asphalt.exceptions.report_exception` for all exceptions that occur in the - event loop machinery or in tasks which are garbage collected without ever having been awaited - on. + Optionally (and by default), a default exception handler is also installed for the + event loop which calls :func:`~asphalt.exceptions.report_exception` for all + exceptions that occur in the event loop machinery or in tasks which are garbage + collected without ever having been awaited on. Exception reporters can be configured in two ways: - #. a single reporter, with configuration supplied directly as keyword arguments to this - component's constructor - #. multiple reporters, by providing the ``reporters`` option where each key is the resource - name and each value is a dictionary containing that reporter's configuration + #. a single reporter, with configuration supplied directly as keyword arguments to + this component's constructor + #. multiple reporters, by providing the ``reporters`` option where each key is the + resource name and each value is a dictionary containing that reporter's + configuration - Each exception reporter configuration has one special option that is not passed to the - constructor of the backend class: + Each exception reporter configuration has one special option that is not passed to + the constructor of the backend class: * backend: entry point name of the reporter backend class (required) - :param reporters: a dictionary of resource name ⭢ constructor arguments for the chosen - reporter class - :param install_default_handler: ``True`` to install a new default exception handler for the - event loop when the component starts + :param reporters: a dictionary of resource name ⭢ constructor arguments for the + chosen reporter class + :param install_default_handler: ``True`` to install a new default exception handler + for the event loop when the component starts :param default_args: default values for constructor keyword arguments """ def __init__( self, - reporters: dict[str, dict[str, Any] | None] | None = None, + reporters: Mapping[str, dict[str, Any] | None] | None = None, install_default_handler: bool = True, - **default_args, + **default_args: Any, ) -> None: self.install_default_handler = install_default_handler if not reporters: reporters = {"default": default_args} - self.reporters: list[tuple] = [] + self.reporters: list[tuple[str, ExceptionReporter]] = [] for resource_name, config in reporters.items(): merged_config = merge_config(default_args, config or {}) type_ = merged_config.pop("backend", resource_name) - serializer = reporter_backends.create_object(type_, **merged_config) - self.reporters.append((resource_name, serializer)) + reporter = reporter_backends.create_object(type_, **merged_config) + self.reporters.append((resource_name, reporter)) @context_teardown - async def start(self, ctx: Context) -> AsyncIterator[None]: + async def start(self) -> AsyncGenerator[None, BaseException | None]: for resource_name, reporter in self.reporters: - types = [ExceptionReporter, type(reporter)] - ctx.add_resource(reporter, resource_name, types=types) + types: list[type] = [ExceptionReporter, type(reporter)] + add_resource(reporter, resource_name, types=types) logger.info( "Configured exception reporter (%s; class=%s)", resource_name, @@ -88,8 +87,7 @@ async def start(self, ctx: Context) -> AsyncIterator[None]: ) if self.install_default_handler: - handler = partial(default_exception_handler, ctx=ctx) - get_running_loop().set_exception_handler(handler) + get_running_loop().set_exception_handler(default_exception_handler) logger.info("Installed default event loop exception handler") yield diff --git a/src/asphalt/exceptions/_utils.py b/src/asphalt/exceptions/_utils.py new file mode 100644 index 0000000..a24a7d1 --- /dev/null +++ b/src/asphalt/exceptions/_utils.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import inspect +import logging +import sys +from typing import Any + +from asphalt.core import get_resources, merge_config, qualified_name + +from ._api import ExceptionReporter, ExtrasProvider + +module_logger = logging.getLogger(__name__) + + +def report_exception( + message: str, + exception: BaseException | None = None, + *, + logger: logging.Logger | str | bool = True, +) -> None: + """ + Report an exception to all exception reporters in the given context (and optionally + log it too) + + :param message: a free-form message to pass to the exception reporters (often used + to describe what was happening when the exception occurred) + :param exception: the exception to report; retrieved from :func:`sys.exc_info` if + omitted + :param logger: logger instance or logger name to log the exception in, instead of + the name ofthe module where the exception was raised (or ``False`` to skip + logging the exception) + + """ + if not exception: + exception = sys.exc_info()[1] + if not exception: + raise ValueError( + 'missing "exception" parameter and no current exception present in ' + "sys.exc_info()" + ) + + actual_logger: logging.Logger | None + if isinstance(logger, bool): + tb = exception.__traceback__ + if logger and tb: + frame = tb.tb_frame + module = inspect.getmodule(frame) + if module and module.__spec__: + actual_logger = logging.getLogger(module.__spec__.name) + else: # pragma: no cover + actual_logger = logging.getLogger(frame.f_globals["__name__"]) + else: + actual_logger = None + elif isinstance(logger, str): + actual_logger = logging.getLogger(logger) + else: + actual_logger = logger + + if actual_logger: + actual_logger.error(message, exc_info=exception) + + extras_providers = get_resources(ExtrasProvider).values() # type: ignore[type-abstract] + for reporter in get_resources(ExceptionReporter).values(): # type: ignore[type-abstract] + extra: dict[str, Any] = {} + for provider in extras_providers: + try: + new_extra = provider.get_extras(reporter) + except Exception: + module_logger.exception( + "error retrieving exception extras for %s from %s", + qualified_name(reporter), + qualified_name(provider), + ) + else: + if isinstance(new_extra, dict): + extra = merge_config(extra, new_extra) + + try: + reporter.report_exception(exception, message, extra) + except Exception: + module_logger.exception( + "error calling exception reporter (%s)", qualified_name(reporter) + ) diff --git a/src/asphalt/exceptions/reporters/__init__.py b/src/asphalt/exceptions/reporters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/asphalt/exceptions/reporters/raygun.py b/src/asphalt/exceptions/reporters/raygun.py index f557554..ec4d5a9 100644 --- a/src/asphalt/exceptions/reporters/raygun.py +++ b/src/asphalt/exceptions/reporters/raygun.py @@ -2,10 +2,9 @@ from typing import Any -from asphalt.core import Context from raygun4py.raygunprovider import RaygunSender -from asphalt.exceptions.api import ExceptionReporter +from .._api import ExceptionReporter class RaygunExceptionReporter(ExceptionReporter): @@ -14,23 +13,23 @@ class RaygunExceptionReporter(ExceptionReporter): To use this backend, install asphalt-exceptions with the ``raygun`` extra. - All keyword arguments are directly passed to :class:`raygun4py.raygunprovider.RaygunSender`. + All keyword arguments are directly passed to + :class:`raygun4py.raygunprovider.RaygunSender`. The extras passed to this backend are passed to :meth:`raygun4py.raygunprovider.RaygunSender.send_exception` as keyword arguments. - .. warning:: The current implementation of this backend sends exceptions synchronously, - potentially blocking the event loop. + .. warning:: The current implementation of this backend sends exceptions + synchronously, potentially blocking the event loop. .. _Raygun: https://raygun.com/ """ - def __init__(self, api_key: str, **config) -> None: + def __init__(self, api_key: str, **config: Any) -> None: self.client = RaygunSender(api_key, config) def report_exception( self, - ctx: Context, exception: BaseException, message: str, extra: dict[str, Any], diff --git a/src/asphalt/exceptions/reporters/sentry.py b/src/asphalt/exceptions/reporters/sentry.py index a8ed9dd..e6faab3 100644 --- a/src/asphalt/exceptions/reporters/sentry.py +++ b/src/asphalt/exceptions/reporters/sentry.py @@ -2,14 +2,14 @@ import logging import sys -from collections.abc import Callable -from typing import Any, Sequence +from collections.abc import Callable, Sequence +from typing import Any import sentry_sdk -from asphalt.core import Context, resolve_reference +from asphalt.core import resolve_reference from sentry_sdk.integrations import Integration -from asphalt.exceptions.api import ExceptionReporter +from .._api import ExceptionReporter if sys.version_info >= (3, 10): from typing import TypeAlias @@ -23,7 +23,9 @@ Breadcrumb: TypeAlias = "dict[str, Any]" BreadcrumbHint: TypeAlias = "dict[str, Any]" EventProcessor: TypeAlias = "Callable[[Event, Hint], Event | None]" -BreadcrumbProcessor: TypeAlias = "Callable[[Breadcrumb, BreadcrumbHint], Breadcrumb | None]" +BreadcrumbProcessor: TypeAlias = ( + "Callable[[Breadcrumb, BreadcrumbHint], Breadcrumb | None]" +) class SentryExceptionReporter(ExceptionReporter): @@ -37,16 +39,17 @@ class SentryExceptionReporter(ExceptionReporter): * environment: "development" or "production", depending on the ``__debug__`` flag - Integrations can be added via the ``integrations`` option which is a list where each item is - either an object that implements the :class:`sentry_sdk.integrations.Integration` interface, - or a dictionary where the ``type`` key is a module:varname reference to a class implementing - the aforementioned interface. The ``args`` key, when present, should be a sequence that is - passed to the integration as positional arguments, while the ``kwargs`` key, when present, - should be a mapping of keyword arguments to their values. + Integrations can be added via the ``integrations`` option which is a list where each + item is either an object that implements the + :class:`sentry_sdk.integrations.Integration` interface, or a dictionary where the + ``type`` key is a module:varname reference to a class implementing + the aforementioned interface. The ``args`` key, when present, should be a sequence + that is passed to the integration as positional arguments, while the ``kwargs`` key, + when present, should be a mapping of keyword arguments to their values. - The extras passed to this backend are passed to :func:`sentry_sdk.capture_exception` as keyword - arguments. Two such options have been special cased and can be looked up as a - ``module:varname`` reference: + The extras passed to this backend are passed to :func:`sentry_sdk.capture_exception` + as keyword arguments. Two such options have been special cased and can be looked up + as a ``module:varname`` reference: - ``before_send`` - ``before_breadcrumb`` @@ -62,7 +65,7 @@ def __init__( integrations: Sequence[Integration | dict[str, Any]] = (), before_send: EventProcessor | str | None = None, before_breadcrumb: BreadcrumbProcessor | str | None = None, - **options, + **options: Any, ) -> None: if isinstance(before_send, str): _before_send: EventProcessor | None = resolve_reference(before_send) @@ -70,7 +73,9 @@ def __init__( _before_send = before_send if isinstance(before_breadcrumb, str): - _before_breadcrumb: BreadcrumbProcessor | None = resolve_reference(before_breadcrumb) + _before_breadcrumb: BreadcrumbProcessor | None = resolve_reference( + before_breadcrumb + ) else: _before_breadcrumb = before_breadcrumb @@ -80,22 +85,23 @@ def __init__( for integration in integrations: if isinstance(integration, dict): integration_class = resolve_reference(integration["type"]) - integration = integration_class( - *integration.get("args", ()), **integration.get("kwargs", {}) + integrations_.append( + integration_class( + *integration.get("args", ()), **integration.get("kwargs", {}) + ) ) - - integrations_.append(integration) + else: + integrations_.append(integration) sentry_sdk.init( integrations=integrations_, - before_send=_before_send, + before_send=_before_send, # type: ignore[arg-type] before_breadcrumb=_before_breadcrumb, **options, ) def report_exception( self, - ctx: Context, exception: BaseException, message: str, extra: dict[str, Any], diff --git a/tests/reporters/test_raygun.py b/tests/reporters/test_raygun.py index 2b0ac91..a3a6b13 100644 --- a/tests/reporters/test_raygun.py +++ b/tests/reporters/test_raygun.py @@ -1,14 +1,14 @@ -import asyncio from unittest.mock import patch import pytest -from asphalt.core import Context +from anyio import sleep from asphalt.exceptions.reporters.raygun import RaygunExceptionReporter +pytestmark = pytest.mark.anyio -@pytest.mark.asyncio -async def test_raygun(): + +async def test_raygun() -> None: exception = None try: 1 / 0 @@ -17,7 +17,8 @@ async def test_raygun(): reporter = RaygunExceptionReporter("abvcgrdg234") with patch.object(reporter.client, "_post") as post: - reporter.report_exception(Context(), exception, "test exception", {}) + assert exception is not None + reporter.report_exception(exception, "test exception", {}) - await asyncio.sleep(0.1) + await sleep(0.1) assert post.call_count == 1 diff --git a/tests/reporters/test_sentry.py b/tests/reporters/test_sentry.py index 08aa27e..598fad4 100644 --- a/tests/reporters/test_sentry.py +++ b/tests/reporters/test_sentry.py @@ -1,34 +1,37 @@ +from __future__ import annotations + +from typing import Any from unittest.mock import MagicMock import pytest -from asphalt.core import Context from pytest_mock import MockerFixture from sentry_sdk import Transport from sentry_sdk.integrations import Integration from asphalt.exceptions.reporters.sentry import SentryExceptionReporter +pytestmark = pytest.mark.anyio + class DummyIntegration(Integration): - def __init__(self, arg_a, arg_b): + def __init__(self, arg_a: Any, arg_b: Any): self.arg_a = arg_a self.arg_b = arg_b @staticmethod - def setup_once(): + def setup_once() -> None: pass -def before_send(event, hint): +def before_send(event: dict[str, Any], hint: dict[str, Any]) -> None: pass -def before_breadcrumb(breadcrumb, hint): +def before_breadcrumb(breadcrumb: dict[str, Any], hint: dict[str, Any]) -> None: pass -@pytest.mark.asyncio -async def test_sentry(): +async def test_sentry() -> None: exception = None try: 1 / 0 @@ -39,13 +42,14 @@ async def test_sentry(): reporter = SentryExceptionReporter( dsn="http://username:password@127.0.0.1/000000", transport=transport ) - reporter.report_exception(Context(), exception, "test exception", {}) + assert exception is not None + reporter.report_exception(exception, "test exception", {}) - transport.capture_event.assert_called_once() + transport.capture_envelope.assert_called_once() -def test_integrations(): - integrations = [ +def test_integrations() -> None: + integrations: list[Integration | dict[str, Any]] = [ DummyIntegration(1, 2), {"type": f"{__name__}:DummyIntegration", "args": [3], "kwargs": {"arg_b": 4}}, ] @@ -54,10 +58,11 @@ def test_integrations(): ) -def test_hook_lookup(mocker: MockerFixture): +def test_hook_lookup(mocker: MockerFixture) -> None: init_func = mocker.patch("sentry_sdk.init") SentryExceptionReporter( - before_send=f"{__name__}:before_send", before_breadcrumb=f"{__name__}:before_breadcrumb" + before_send=f"{__name__}:before_send", + before_breadcrumb=f"{__name__}:before_breadcrumb", ) init_func.assert_called_once_with( integrations=[], diff --git a/tests/test_component.py b/tests/test_component.py index 11f5e1c..3fb5838 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -1,98 +1,106 @@ +from __future__ import annotations + import gc import logging -from asyncio import sleep -from collections import OrderedDict +from asyncio import get_running_loop, sleep +from typing import Any import pytest -from asphalt.core.context import Context +import sniffio +from asphalt.core import Context, get_resource_nowait +from pytest import LogCaptureFixture + +from asphalt.exceptions import ExceptionReporter, ExceptionReporterComponent -from asphalt.exceptions.api import ExceptionReporter -from asphalt.exceptions.component import ExceptionReporterComponent +pytestmark = pytest.mark.anyio class DummyExceptionReporter(ExceptionReporter): + reported_exception: BaseException + def report_exception( - self, ctx: Context, exception: BaseException, message: str, extra=None + self, + exception: BaseException, + message: str, + extra: dict[str, Any], ) -> None: self.reported_exception = exception -@pytest.mark.parametrize("install_default_handler", [True, False], ids=["default", "nodefault"]) -@pytest.mark.asyncio -async def test_start(caplog, install_default_handler): +async def test_start(caplog: LogCaptureFixture) -> None: class DummyReporter(ExceptionReporter): def report_exception( - self, ctx: Context, exception: BaseException, message: str, extra=None + self, + exception: BaseException, + message: str, + xtra: dict[str, Any] | None = None, ) -> None: pass class DummyReporter2(ExceptionReporter): def report_exception( - self, ctx: Context, exception: BaseException, message: str, extra=None + self, + exception: BaseException, + message: str, + xtra: dict[str, Any] | None = None, ) -> None: pass - caplog.set_level(logging.INFO) - reporters = OrderedDict( - [ - ("dummy1", {"backend": DummyReporter}), - ("dummy2", {"backend": DummyReporter2}), - ] - ) + caplog.set_level(logging.INFO, "asphalt.exceptions") + reporters: dict[str, dict[str, Any]] = { + "dummy1": {"backend": DummyReporter}, + "dummy2": {"backend": DummyReporter2}, + } + install_default_handler = sniffio.current_async_library() == "asyncio" cmp = ExceptionReporterComponent( reporters=reporters, install_default_handler=install_default_handler ) - async with Context() as ctx: - await cmp.start(ctx) - - messages = [ - record.message - for record in caplog.records - if record.name == "asphalt.exceptions.component" - ] + async with Context(): + await cmp.start() + if install_default_handler: - assert len(messages) == 4 - assert messages[-2] == "Installed default event loop exception handler" - assert messages[-1] == "Uninstalled default event loop exception handler" + assert len(caplog.messages) == 4 + assert caplog.messages[-2] == "Installed default event loop exception handler" + assert caplog.messages[-1] == "Uninstalled default event loop exception handler" else: - assert len(messages) == 2 + assert len(caplog.messages) == 2 - assert messages[0] == ( + assert caplog.messages[0] == ( "Configured exception reporter (dummy1; " "class=tests.test_component.test_start..DummyReporter)" ) - assert messages[1] == ( + assert caplog.messages[1] == ( "Configured exception reporter (dummy2; " "class=tests.test_component.test_start..DummyReporter2)" ) -@pytest.mark.asyncio -async def test_default_exception_handler(event_loop): +@pytest.mark.parametrize("anyio_backend", ["asyncio"], indirect=True) +async def test_default_exception_handler() -> None: """ - Test that an unawaited Task being garbage collected ends up being processed by the default - exception handler. - + Test that an unawaited Task being garbage collected ends up being processed by the + default exception handler. """ - async def fail_task(): + async def fail_task() -> float: return 1 / 0 - async with Context() as ctx: + async with Context(): component = ExceptionReporterComponent(backend=DummyExceptionReporter) - await component.start(ctx) - reporter = ctx.get_resource(ExceptionReporter) - event_loop.create_task(fail_task()) + await component.start() + reporter = get_resource_nowait(ExceptionReporter) # type: ignore[type-abstract] + task = get_running_loop().create_task(fail_task()) await sleep(0.1) + del task gc.collect() + assert isinstance(reporter, DummyExceptionReporter) assert isinstance(reporter.reported_exception, ZeroDivisionError) -@pytest.mark.asyncio -async def test_default_exception_handler_no_exception(event_loop): - async with Context() as ctx: +@pytest.mark.parametrize("anyio_backend", ["asyncio"], indirect=True) +async def test_default_exception_handler_no_exception() -> None: + async with Context(): component = ExceptionReporterComponent(backend=DummyExceptionReporter) - await component.start(ctx) - handler = event_loop.get_exception_handler() - handler(event_loop, {"message": "dummy"}) + await component.start() + get_running_loop().call_exception_handler({"message": "dummy"}) diff --git a/tests/test_report_exception.py b/tests/test_report_exception.py index 472a04a..a30392f 100644 --- a/tests/test_report_exception.py +++ b/tests/test_report_exception.py @@ -1,10 +1,15 @@ +from __future__ import annotations + import logging +from typing import Any import pytest -from asphalt.core import Context +from asphalt.core import Context, add_resource +from pytest import LogCaptureFixture + +from asphalt.exceptions import ExceptionReporter, ExtrasProvider, report_exception -from asphalt.exceptions import ExtrasProvider, report_exception -from asphalt.exceptions.api import ExceptionReporter +pytestmark = pytest.mark.anyio @pytest.mark.parametrize( @@ -12,15 +17,23 @@ [True, False, "asphalt.exceptions", logging.getLogger("asphalt.exceptions")], ids=["autologger", "nologger", "strlogger", "loggerinstance"], ) -@pytest.mark.parametrize("faulty_extras_provider", [False, True], ids=["goodextras", "badextras"]) -@pytest.mark.parametrize("faulty_reporter", [False, True], ids=["goodreporter", "badreporter"]) -@pytest.mark.asyncio -async def test_report_exception(logger, faulty_extras_provider, faulty_reporter, caplog): +@pytest.mark.parametrize( + "faulty_extras_provider", [False, True], ids=["goodextras", "badextras"] +) +@pytest.mark.parametrize( + "faulty_reporter", [False, True], ids=["goodreporter", "badreporter"] +) +async def test_report_exception( + logger: logging.Logger | str, + faulty_extras_provider: bool, + faulty_reporter: bool, + caplog: LogCaptureFixture, +) -> None: reported_exception = reported_message = None class DummyReporter(ExceptionReporter): def report_exception( - self, ctx: Context, exception: BaseException, message: str, extra + self, exception: BaseException, message: str, extra: dict[str, Any] ) -> None: nonlocal reported_exception, reported_message assert extra == ({} if faulty_extras_provider else {"foo": "bar"}) @@ -30,37 +43,37 @@ def report_exception( raise Exception("bar") class DummyProvider(ExtrasProvider): - def get_extras(self, ctx: Context, reporter: ExceptionReporter): + def get_extras(self, reporter: ExceptionReporter) -> dict[str, Any]: if faulty_extras_provider: raise Exception("foo") else: return {"foo": "bar"} class EmptyProvider(ExtrasProvider): - def get_extras(self, ctx: Context, reporter: ExceptionReporter): + def get_extras(self, reporter: ExceptionReporter) -> None: return None - async with Context() as ctx: - ctx.add_resource(DummyReporter(), types=[ExceptionReporter]) - ctx.add_resource(DummyProvider(), "dummy", types=[ExtrasProvider]) - ctx.add_resource(EmptyProvider(), "empty", types=[ExtrasProvider]) + async with Context(): + add_resource(DummyReporter(), types=[ExceptionReporter]) + add_resource(DummyProvider(), "dummy", types=[ExtrasProvider]) + add_resource(EmptyProvider(), "empty", types=[ExtrasProvider]) try: 1 / 0 except ZeroDivisionError as e: - report_exception(ctx, "Got a boo-boo", logger=logger) + report_exception("Got a boo-boo", logger=logger) assert reported_exception is e assert reported_message == "Got a boo-boo" if faulty_reporter and logger: - messages = [ - record.message for record in caplog.records if record.name == "asphalt.exceptions" - ] - assert messages[-1].startswith( + assert caplog.messages[-1].startswith( "error calling exception reporter " "(tests.test_report_exception.test_report_exception..DummyReporter)" ) -def test_no_exception(): - exc = pytest.raises(Exception, report_exception, Context(), "test") - exc.match('missing "exception" parameter and no current exception present in sys.exc_info()') +def test_no_exception() -> None: + exc = pytest.raises(Exception, report_exception, "test") + exc.match( + 'missing "exception" parameter and no current exception present in ' + "sys.exc_info()" + )